Skip to main content

khive_runtime/
outbound_email_policy.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
5use sha2::{Digest, Sha256};
6
7/// The unsubscribe token format version carried as the first token byte.
8const UNSUBSCRIBE_TOKEN_VERSION: u8 = 0x01;
9/// A raw recipient key is one SHA-256 digest.
10const RECIPIENT_KEY_BYTES: usize = 32;
11/// The truncated HMAC tag length carried by a token.
12const UNSUBSCRIBE_TAG_BYTES: usize = 16;
13/// A token is `0x01 || key_id || recipient_key || tag`, base64url (no padding).
14const UNSUBSCRIBE_TOKEN_BYTES: usize = 1 + 1 + RECIPIENT_KEY_BYTES + UNSUBSCRIBE_TAG_BYTES;
15/// The placeholder the operator's footer must carry for the mailto instruction.
16const UNSUBSCRIBE_PLACEHOLDER: &str = "{unsubscribe}";
17/// A recipient-key secret and every token key must decode to at least this many bytes.
18const MIN_KEY_BYTES: usize = 32;
19
20/// Boot-resolved recipient policy shared by comm admission and email delivery.
21/// An absent policy permits queuing without claiming that delivery is configured.
22#[derive(Clone)]
23pub struct OutboundEmailPolicy {
24    recipients: Option<Arc<[String]>>,
25    external_senders: Option<Arc<[String]>>,
26    external_daily_cap: Option<u64>,
27    /// The recipient-key secret. Present only when external senders are configured; the derived
28    /// key for an address is `HMAC-SHA256(K_rk, normalized address)` in lowercase hex.
29    recipient_key_secret: Option<Arc<[u8]>>,
30    /// Token keys by key id, ascending. Every retired id stays present so a link in an old
31    /// message keeps verifying; the highest id mints new tokens.
32    token_keys: Arc<BTreeMap<u8, Arc<[u8]>>>,
33    /// The operator's external footer text, or the reason it is unavailable. `None` while
34    /// external senders are unset, in which case no footer is ever consulted.
35    external_footer: Option<Result<Arc<str>, Arc<str>>>,
36    external_admission: Arc<tokio::sync::Mutex<()>>,
37}
38
39impl Default for OutboundEmailPolicy {
40    fn default() -> Self {
41        Self {
42            recipients: None,
43            external_senders: None,
44            external_daily_cap: None,
45            recipient_key_secret: None,
46            token_keys: Arc::default(),
47            external_footer: None,
48            external_admission: Arc::new(tokio::sync::Mutex::new(())),
49        }
50    }
51}
52
53/// HMAC-SHA256 (RFC 2104) over `sha2::Sha256`.
54///
55/// The 64-byte block is fixed for SHA-256. A key longer than the block is hashed first; a
56/// shorter one is zero-padded to the block. Returns the full 32-byte MAC.
57fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
58    const BLOCK: usize = 64;
59    let mut block = [0u8; BLOCK];
60    if key.len() > BLOCK {
61        block[..32].copy_from_slice(&Sha256::digest(key));
62    } else {
63        block[..key.len()].copy_from_slice(key);
64    }
65    let mut inner_pad = [0x36u8; BLOCK];
66    let mut outer_pad = [0x5cu8; BLOCK];
67    for index in 0..BLOCK {
68        inner_pad[index] ^= block[index];
69        outer_pad[index] ^= block[index];
70    }
71    let mut inner = Sha256::new();
72    inner.update(inner_pad);
73    inner.update(data);
74    let inner_digest = inner.finalize();
75    let mut outer = Sha256::new();
76    outer.update(outer_pad);
77    outer.update(inner_digest);
78    let mut mac = [0u8; 32];
79    mac.copy_from_slice(&outer.finalize());
80    mac
81}
82
83/// The direction stored on one email suppression entry.
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
85pub enum EmailSuppressionScope {
86    Outbound,
87    Inbound,
88}
89
90/// The identity form stored on one email suppression entry.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum EmailSuppressionKind {
93    Address,
94    Domain,
95    RecipientKey,
96}
97
98/// The matching fields needed by outbound admission and delivery.
99#[derive(Clone, Debug, PartialEq, Eq)]
100pub struct EmailSuppression {
101    pub scope: EmailSuppressionScope,
102    pub kind: EmailSuppressionKind,
103    pub value: String,
104}
105
106/// Check one already-normalized recipient against the suppression rows supplied by the caller.
107///
108/// Address normalization is shared with [`OutboundEmailPolicy`]; callers must use
109/// `khive_types::email_address::normalize_email_recipient` before calling this function.
110/// Domain comparison is exact, and recipient keys compare byte-for-byte.
111pub fn outbound_email_is_suppressed(
112    normalized_recipient: &str,
113    recipient_key: Option<&str>,
114    entries: &[EmailSuppression],
115) -> bool {
116    let recipient_domain = normalized_recipient
117        .rsplit_once('@')
118        .map(|(_, domain)| domain);
119    entries.iter().any(|entry| {
120        if entry.scope != EmailSuppressionScope::Outbound {
121            return false;
122        }
123        match entry.kind {
124            EmailSuppressionKind::Address => entry.value == normalized_recipient,
125            EmailSuppressionKind::Domain => recipient_domain == Some(entry.value.as_str()),
126            EmailSuppressionKind::RecipientKey => recipient_key == Some(entry.value.as_str()),
127        }
128    })
129}
130
131/// Check one already-normalized inbound sender address against the suppression rows supplied by
132/// the caller. Only `inbound` entries can match, and only `address` and `domain` kinds can: an
133/// inbound sender has no recipient key. Address comparison is exact after normalization, and
134/// domain comparison is exact with no subdomain wildcard, so a `domain` entry for `example.com`
135/// never blocks `user@sub.example.com`.
136///
137/// Callers must normalize the sender address with
138/// `khive_types::email_address::normalize_email_recipient` before calling this function.
139pub fn inbound_email_is_suppressed(normalized_sender: &str, entries: &[EmailSuppression]) -> bool {
140    let sender_domain = normalized_sender.rsplit_once('@').map(|(_, domain)| domain);
141    entries.iter().any(|entry| {
142        if entry.scope != EmailSuppressionScope::Inbound {
143            return false;
144        }
145        match entry.kind {
146            EmailSuppressionKind::Address => entry.value == normalized_sender,
147            EmailSuppressionKind::Domain => sender_domain == Some(entry.value.as_str()),
148            EmailSuppressionKind::RecipientKey => false,
149        }
150    })
151}
152
153impl OutboundEmailPolicy {
154    /// Install a normalized recipient set; an explicitly empty set denies all recipients.
155    pub fn configured(recipients: Vec<String>) -> Result<Self, String> {
156        let recipients = recipients
157            .iter()
158            .map(|value| {
159                khive_types::email_address::normalize_email_recipient(value)
160                    .ok_or_else(|| "outbound email policy contains an invalid address".to_string())
161            })
162            .collect::<Result<Vec<_>, _>>()?;
163        Ok(Self {
164            recipients: Some(recipients.into()),
165            ..Self::default()
166        })
167    }
168
169    pub fn is_configured(&self) -> bool {
170        self.recipients.is_some()
171    }
172
173    pub fn allows(&self, recipient: &str) -> bool {
174        self.recipients.as_ref().is_none_or(|recipients| {
175            khive_types::email_address::normalize_email_recipient(recipient)
176                .is_some_and(|recipient| recipients.iter().any(|allowed| allowed == &recipient))
177        })
178    }
179
180    pub fn allows_external_sender(&self, actor_id: &str) -> bool {
181        self.external_senders
182            .as_ref()
183            .is_some_and(|senders| senders.iter().any(|sender| sender == actor_id))
184    }
185
186    pub fn has_external_senders(&self) -> bool {
187        self.external_senders.is_some()
188    }
189
190    pub fn external_daily_cap(&self) -> Option<u64> {
191        self.external_daily_cap
192    }
193
194    pub async fn lock_external_admission(&self) -> tokio::sync::OwnedMutexGuard<()> {
195        Arc::clone(&self.external_admission).lock_owned().await
196    }
197
198    /// The recipient key a `recipient_key` suppression entry matches, derived on demand as
199    /// `HMAC-SHA256(K_rk, normalized address)` in lowercase hex; the raw bytes are never stored.
200    /// `None` when no recipient-key secret is configured (external senders unset).
201    pub fn recipient_key(&self, normalized_recipient: &str) -> Option<String> {
202        self.recipient_key_secret
203            .as_deref()
204            .map(|secret| hex_lower(&hmac_sha256(secret, normalized_recipient.as_bytes())))
205    }
206
207    /// The recipient key's raw 32 bytes, used as the token body.
208    fn recipient_key_bytes(&self, normalized_recipient: &str) -> Option<[u8; RECIPIENT_KEY_BYTES]> {
209        self.recipient_key_secret
210            .as_deref()
211            .map(|secret| hmac_sha256(secret, normalized_recipient.as_bytes()))
212    }
213
214    /// The footer text for external messages, or the reason it is unavailable. `None` while
215    /// external senders are unset.
216    pub fn external_footer(&self) -> Option<Result<&str, &str>> {
217        self.external_footer.as_ref().map(|value| {
218            value
219                .as_ref()
220                .map(|text| &**text)
221                .map_err(|reason| &**reason)
222        })
223    }
224
225    /// Mint the unsubscribe token for a normalized external recipient, or `None` when the
226    /// recipient-key secret or any token key is unavailable. The highest configured key id mints.
227    pub fn mint_unsubscribe_token(&self, normalized_recipient: &str) -> Option<String> {
228        let recipient_key = self.recipient_key_bytes(normalized_recipient)?;
229        let (&key_id, key) = self.token_keys.iter().next_back()?;
230        Some(encode_unsubscribe_token(key_id, key, &recipient_key))
231    }
232
233    /// Verify an unsubscribe token and return the recipient key text it identifies, or `None`
234    /// on any malformed input or tag mismatch. Retired key ids keep verifying.
235    pub fn verify_unsubscribe_token(&self, token: &str) -> Option<String> {
236        let decoded = URL_SAFE_NO_PAD.decode(token).ok()?;
237        if decoded.len() != UNSUBSCRIBE_TOKEN_BYTES {
238            return None;
239        }
240        if decoded[0] != UNSUBSCRIBE_TOKEN_VERSION {
241            return None;
242        }
243        let key_id = decoded[1];
244        let key = self.token_keys.get(&key_id)?;
245        let (body, tag) = decoded.split_at(decoded.len() - UNSUBSCRIBE_TAG_BYTES);
246        let expected = hmac_sha256(key, body);
247        if !constant_time_eq(&expected[..UNSUBSCRIBE_TAG_BYTES], tag) {
248            return None;
249        }
250        let recipient_key = &body[2..];
251        Some(hex_lower(recipient_key))
252    }
253
254    /// Read only public policy inputs, without loading a connector or credentials.
255    ///
256    /// The unsubscribe key variables and the footer file are read only when external senders
257    /// are configured; while the senders variable is unset none of them is consulted.
258    pub fn from_env() -> Result<Self, String> {
259        fn read(name: &str) -> Result<Option<String>, String> {
260            match std::env::var(name) {
261                Ok(value) => Ok(Some(value)),
262                Err(std::env::VarError::NotPresent) => Ok(None),
263                Err(std::env::VarError::NotUnicode(_)) => {
264                    Err(format!("{name} must contain valid Unicode text"))
265                }
266            }
267        }
268        let external_senders = read("KHIVE_EMAIL_EXTERNAL_SENDERS")?;
269        let external_daily_cap = read("KHIVE_EMAIL_EXTERNAL_DAILY_CAP")?;
270        let explicit = read("KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS")?;
271        let maintainer = read("KHIVE_EMAIL_MAINTAINER_ADDRESS")?;
272        let (recipient_key_secret, token_keys) = if external_senders.is_some() {
273            (
274                read("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY")?,
275                read("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS")?,
276            )
277        } else {
278            (None, None)
279        };
280        let footer_file = if external_senders.is_some() {
281            read("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE")?
282        } else {
283            None
284        };
285        Self::resolve_with_external(
286            explicit.as_deref(),
287            maintainer.as_deref(),
288            external_senders.as_deref(),
289            external_daily_cap.as_deref(),
290            recipient_key_secret.as_deref(),
291            token_keys.as_deref(),
292            footer_file.as_deref(),
293        )
294    }
295
296    #[cfg(test)]
297    fn resolve(explicit: Option<&str>, maintainer: Option<&str>) -> Result<Self, String> {
298        Self::resolve_with_external(explicit, maintainer, None, None, None, None, None)
299    }
300
301    /// Build a fully enabled policy for debug-build tests: recipients, the external sender
302    /// set, the daily cap, fixed test key material and a footer carrying `{unsubscribe}`.
303    #[cfg(any(test, debug_assertions))]
304    #[doc(hidden)]
305    pub fn configured_for_tests(
306        recipients: Vec<String>,
307        external_senders: Vec<String>,
308        daily_cap: u64,
309    ) -> Result<Self, String> {
310        let token_keys = format!("0:{},1:{}", "11".repeat(32), "22".repeat(32));
311        Self::configured_for_tests_with(
312            recipients,
313            external_senders,
314            daily_cap,
315            &"cc".repeat(32),
316            &token_keys,
317            Ok("Reply to unsubscribe: {unsubscribe}"),
318        )
319    }
320
321    /// Build an enabled policy with explicit key material and footer state for tests that need
322    /// to exercise configuration or footer unavailability.
323    #[cfg(any(test, debug_assertions))]
324    #[doc(hidden)]
325    #[allow(clippy::too_many_arguments)]
326    pub fn configured_for_tests_with(
327        recipients: Vec<String>,
328        external_senders: Vec<String>,
329        daily_cap: u64,
330        recipient_key_secret_hex: &str,
331        token_keys: &str,
332        footer: Result<&str, &str>,
333    ) -> Result<Self, String> {
334        if daily_cap == 0 {
335            return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
336        }
337        let external_senders = parse_external_senders(&external_senders.join(","))?;
338        if external_senders.is_empty() {
339            return Err("KHIVE_EMAIL_EXTERNAL_SENDERS must contain at least one actor id".into());
340        }
341        let mut policy = Self::configured(recipients)?;
342        policy.external_senders = Some(external_senders.into());
343        policy.external_daily_cap = Some(daily_cap);
344        let secret = parse_hex_key("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY", recipient_key_secret_hex)?;
345        policy.recipient_key_secret = Some(Arc::from(secret.as_slice()));
346        policy.token_keys = Arc::new(parse_token_keys(token_keys)?);
347        policy.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
348        Ok(policy)
349    }
350
351    /// Replace the footer state on a test policy (available/unavailable) without touching keys.
352    #[cfg(any(test, debug_assertions))]
353    #[doc(hidden)]
354    pub fn with_external_footer_for_tests(mut self, footer: Result<&str, &str>) -> Self {
355        self.external_footer = Some(footer.map(Arc::from).map_err(Arc::from));
356        self
357    }
358
359    #[allow(clippy::too_many_arguments)]
360    fn resolve_with_external(
361        explicit: Option<&str>,
362        maintainer: Option<&str>,
363        external_senders: Option<&str>,
364        external_daily_cap: Option<&str>,
365        recipient_key_secret: Option<&str>,
366        token_keys: Option<&str>,
367        footer_file: Option<&str>,
368    ) -> Result<Self, String> {
369        let senders = external_senders.map(parse_external_senders).transpose()?;
370        let cap = match external_daily_cap {
371            Some(value) => {
372                let parsed = value.parse::<u64>().map_err(|_| {
373                    "KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".to_string()
374                })?;
375                if parsed == 0 {
376                    return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP must be a positive integer".into());
377                }
378                Some(parsed)
379            }
380            None if senders.is_some() => {
381                return Err("KHIVE_EMAIL_EXTERNAL_DAILY_CAP is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".into());
382            }
383            None => None,
384        };
385        let mut policy = Self::resolve_recipients(explicit, maintainer)?;
386        policy.external_daily_cap = cap;
387        if let Some(senders) = senders {
388            policy.external_senders = Some(senders.into());
389            let recipient_key_secret = parse_hex_key(
390                "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY",
391                recipient_key_secret.ok_or_else(|| {
392                    "KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
393                })?,
394            )?;
395            let token_keys = parse_token_keys(token_keys.ok_or_else(|| {
396                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS is required when KHIVE_EMAIL_EXTERNAL_SENDERS is set".to_string()
397            })?)?;
398            policy.recipient_key_secret = Some(Arc::from(recipient_key_secret.as_slice()));
399            policy.token_keys = Arc::new(token_keys);
400            policy.external_footer = Some(read_external_footer(footer_file));
401        }
402        Ok(policy)
403    }
404
405    fn resolve_recipients(
406        explicit: Option<&str>,
407        maintainer: Option<&str>,
408    ) -> Result<Self, String> {
409        if let Some(policy) = Self::explicit(explicit)? {
410            // A present maintainer value is read the way the email connector reads it at
411            // start, so a value the connector would refuse is refused here too, even
412            // when the explicit list supplies the recipients. An unset one is not an error.
413            if let Some(raw) = maintainer {
414                Self::maintainer_primary(raw)?;
415            }
416            return Ok(policy);
417        }
418        Self::maintainer(maintainer, explicit.is_some())
419    }
420
421    fn explicit(raw: Option<&str>) -> Result<Option<Self>, String> {
422        let recipients: Vec<String> = raw
423            .unwrap_or_default()
424            .split(',')
425            .map(str::trim)
426            .filter(|value| !value.is_empty())
427            .map(str::to_owned)
428            .collect();
429        if recipients.is_empty() {
430            return Ok(None);
431        }
432        Self::configured(recipients)
433            .map(Some)
434            .map_err(|_| "KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS contains an invalid address".into())
435    }
436
437    fn maintainer(raw: Option<&str>, explicit_was_set: bool) -> Result<Self, String> {
438        let Some(raw) = raw else {
439            if explicit_was_set {
440                return Err(
441                    "configured outbound email policy must contain at least one address".into(),
442                );
443            }
444            return Ok(Self::default());
445        };
446        Self::configured(vec![Self::maintainer_primary(raw)?])
447    }
448
449    /// Validate a present maintainer value and return its first (primary) address:
450    /// split on commas, trim, drop empty values, every remaining value must parse
451    /// and at least one must remain.
452    fn maintainer_primary(raw: &str) -> Result<String, String> {
453        let addresses = raw
454            .split(',')
455            .map(str::trim)
456            .filter(|value| !value.is_empty())
457            .map(|value| {
458                khive_types::email_address::normalize_email_recipient(value).ok_or_else(|| {
459                    "KHIVE_EMAIL_MAINTAINER_ADDRESS contains an invalid address".to_string()
460                })
461            })
462            .collect::<Result<Vec<_>, _>>()?;
463        let Some(primary) = addresses.into_iter().next() else {
464            return Err("KHIVE_EMAIL_MAINTAINER_ADDRESS must contain at least one address".into());
465        };
466        Ok(primary)
467    }
468}
469
470fn parse_external_senders(raw: &str) -> Result<Vec<String>, String> {
471    raw.split(',')
472        .map(str::trim)
473        .map(|actor_id| {
474            if actor_id.is_empty() {
475                return Err("KHIVE_EMAIL_EXTERNAL_SENDERS contains an empty actor id".to_string());
476            }
477            khive_types::Namespace::parse(actor_id).map_err(|_| {
478                format!("KHIVE_EMAIL_EXTERNAL_SENDERS contains an invalid actor id {actor_id:?}")
479            })?;
480            Ok(actor_id.to_string())
481        })
482        .collect()
483}
484
485/// Decode a hex key for `name`, requiring at least [`MIN_KEY_BYTES`] bytes. The value is never
486/// echoed in the error.
487fn parse_hex_key(name: &str, raw: &str) -> Result<Vec<u8>, String> {
488    let value = raw.trim().to_ascii_lowercase();
489    if !value.len().is_multiple_of(2) || !khive_types::is_lowercase_hex(&value) {
490        return Err(format!(
491            "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
492        ));
493    }
494    let mut bytes = Vec::with_capacity(value.len() / 2);
495    for pair in value.as_bytes().chunks_exact(2) {
496        let high = (pair[0] as char)
497            .to_digit(16)
498            .expect("validated lowercase hex");
499        let low = (pair[1] as char)
500            .to_digit(16)
501            .expect("validated lowercase hex");
502        bytes.push((high * 16 + low) as u8);
503    }
504    if bytes.len() < MIN_KEY_BYTES {
505        return Err(format!(
506            "{name} must be hex-encoded key material of at least {MIN_KEY_BYTES} bytes"
507        ));
508    }
509    Ok(bytes)
510}
511
512/// Parse `KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS`: comma-separated `id:key` pairs, `id` a decimal
513/// integer 0-255, no duplicate ids, key material hex of at least [`MIN_KEY_BYTES`] bytes.
514fn parse_token_keys(raw: &str) -> Result<BTreeMap<u8, Arc<[u8]>>, String> {
515    const NAME: &str = "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS";
516    let mut keys = BTreeMap::new();
517    for pair in raw.split(',') {
518        let pair = pair.trim();
519        if pair.is_empty() {
520            return Err(format!("{NAME} contains an empty `id:key` pair"));
521        }
522        let Some((id_text, key_text)) = pair.split_once(':') else {
523            return Err(format!("{NAME} entries must be `id:key` pairs"));
524        };
525        let id = id_text
526            .trim()
527            .parse::<u16>()
528            .ok()
529            .filter(|id| *id <= u8::MAX as u16)
530            .ok_or_else(|| format!("{NAME} key ids must be decimal integers 0-255"))?
531            as u8;
532        if keys.contains_key(&id) {
533            return Err(format!("{NAME} contains a duplicate key id"));
534        }
535        let key = parse_hex_key(NAME, key_text)?;
536        keys.insert(id, Arc::from(key.as_slice()));
537    }
538    if keys.is_empty() {
539        return Err(format!("{NAME} must contain at least one `id:key` pair"));
540    }
541    Ok(keys)
542}
543
544/// Read the operator's external footer once at start-up. A missing, unreadable or
545/// placeholder-less footer is not a start-up error; it is recorded as an unavailability reason
546/// that refuses external admission.
547fn read_external_footer(path: Option<&str>) -> Result<Arc<str>, Arc<str>> {
548    let Some(path) = path else {
549        return Err(Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"));
550    };
551    let text = std::fs::read_to_string(path)
552        .map_err(|_| Arc::from("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE could not be read"))?;
553    if !text.contains(UNSUBSCRIBE_PLACEHOLDER) {
554        return Err(Arc::from(
555            "KHIVE_EMAIL_EXTERNAL_FOOTER_FILE does not contain {unsubscribe}",
556        ));
557    }
558    Ok(Arc::from(text))
559}
560
561/// Lowercase hex of a byte slice (no `hex` crate dependency).
562/// Encode an unsubscribe token: `0x01 || key_id || recipient_key || tag`, where the tag is the
563/// first [`UNSUBSCRIBE_TAG_BYTES`] bytes of `HMAC-SHA256(key, 0x01 || key_id || recipient_key)`,
564/// as base64url without padding.
565fn encode_unsubscribe_token(
566    key_id: u8,
567    key: &[u8],
568    recipient_key: &[u8; RECIPIENT_KEY_BYTES],
569) -> String {
570    let mut token = Vec::with_capacity(UNSUBSCRIBE_TOKEN_BYTES);
571    token.push(UNSUBSCRIBE_TOKEN_VERSION);
572    token.push(key_id);
573    token.extend_from_slice(recipient_key);
574    let tag = hmac_sha256(key, &token);
575    token.extend_from_slice(&tag[..UNSUBSCRIBE_TAG_BYTES]);
576    URL_SAFE_NO_PAD.encode(token)
577}
578
579fn hex_lower(bytes: &[u8]) -> String {
580    const DIGITS: &[u8; 16] = b"0123456789abcdef";
581    let mut out = String::with_capacity(bytes.len() * 2);
582    for byte in bytes {
583        out.push(DIGITS[(byte >> 4) as usize] as char);
584        out.push(DIGITS[(byte & 0x0f) as usize] as char);
585    }
586    out
587}
588
589/// Constant-time comparison over two equal-length byte slices (XOR-fold, no early return).
590fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
591    if left.len() != right.len() {
592        return false;
593    }
594    let mut diff = 0u8;
595    for (a, b) in left.iter().zip(right.iter()) {
596        diff |= a ^ b;
597    }
598    diff == 0
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    #[test]
606    fn explicit_policy_and_requests_share_maintainer_normalization() {
607        let policy =
608            OutboundEmailPolicy::explicit(Some(" , First@Example.com , second@example.com,,"))
609                .unwrap()
610                .unwrap();
611        assert!(policy.allows("First@Example.com"));
612        assert!(policy.allows("second@example.com"));
613        assert!(policy.allows("first@example.com"));
614        assert!(policy.allows(" First@Example.com "));
615        assert!(policy.allows("First <FIRST@EXAMPLE.COM>"));
616        assert!(!policy.allows("other@example.com"));
617        assert!(!policy.allows("not-an-address"));
618        assert!(policy.is_configured());
619        assert_eq!(
620            policy.recipients.as_deref().unwrap(),
621            ["first@example.com", "second@example.com"]
622        );
623        assert!(OutboundEmailPolicy::explicit(Some(" , , "))
624            .unwrap()
625            .is_none());
626        assert!(OutboundEmailPolicy::explicit(Some("not-an-address")).is_err());
627    }
628
629    #[test]
630    fn fallback_validates_all_maintainers_and_allows_only_normalized_primary() {
631        let policy = OutboundEmailPolicy::maintainer(
632            Some("Owner <Primary@Example.com>, second@example.com"),
633            false,
634        )
635        .unwrap();
636        assert!(policy.allows("primary@example.com"));
637        assert!(policy.allows("Primary@Example.com"));
638        assert!(policy.allows("Owner <PRIMARY@EXAMPLE.COM>"));
639        assert!(!policy.allows("second@example.com"));
640        assert!(
641            OutboundEmailPolicy::maintainer(Some("primary@example.com,invalid"), false).is_err()
642        );
643        assert!(OutboundEmailPolicy::maintainer(Some(" , "), false).is_err());
644        assert!(OutboundEmailPolicy::maintainer(None, true).is_err());
645        assert!(
646            OutboundEmailPolicy::maintainer(Some("primary@example.com"), true)
647                .unwrap()
648                .allows("PRIMARY@example.com")
649        );
650        assert!(OutboundEmailPolicy::maintainer(None, false)
651            .unwrap()
652            .allows("backlog@example.com"));
653        assert!(!OutboundEmailPolicy::default().is_configured());
654        let deny_all = OutboundEmailPolicy::configured(vec![]).unwrap();
655        assert!(deny_all.is_configured());
656        assert!(!deny_all.allows("backlog@example.com"));
657        assert!(OutboundEmailPolicy::configured(vec!["invalid".into()]).is_err());
658    }
659
660    #[test]
661    fn present_maintainer_is_validated_beside_an_explicit_list_and_an_unset_one_is_not() {
662        let resolve = OutboundEmailPolicy::resolve;
663        let explicit = Some("allowed@example.com");
664        for maintainer in [
665            "",
666            " , ",
667            "primary@example.com,invalid",
668            "invalid,primary@example.com",
669        ] {
670            assert!(
671                resolve(explicit, Some(maintainer)).is_err(),
672                "maintainer {maintainer:?} beside a valid explicit list must be refused"
673            );
674        }
675        let unset = resolve(explicit, None).unwrap();
676        assert!(unset.is_configured());
677        assert!(unset.allows("ALLOWED@example.com"));
678        assert!(!unset.allows("primary@example.com"));
679        let list = Some("Owner <Primary@Example.com>, second@example.com");
680        let valid = resolve(explicit, list).unwrap();
681        assert!(valid.allows("allowed@example.com"));
682        assert!(!valid.allows("primary@example.com"));
683        let blank = Some(" , ");
684        let fallback = resolve(blank, list).unwrap();
685        assert!(fallback.allows("primary@example.com"));
686        assert!(!fallback.allows("second@example.com"));
687        assert!(resolve(blank, None).is_err());
688        assert!(resolve(Some("not-an-address"), None).is_err());
689        assert!(resolve(None, Some("primary@example.com,invalid")).is_err());
690        assert!(!resolve(None, None).unwrap().is_configured());
691    }
692
693    #[test]
694    fn comparison_folds_ascii_case_only() {
695        let policy = OutboundEmailPolicy::configured(vec!["kevin@example.com".into()]).unwrap();
696        // U+212A KELVIN SIGN lowercases to ASCII `k` under Unicode rules; it is a different
697        // address.
698        assert!(!policy.allows("\u{212A}evin@example.com"));
699        assert!(policy.allows("KEVIN@Example.COM"));
700        assert!(policy.allows("kevin@example.com"));
701    }
702
703    #[test]
704    fn configured_entries_and_requests_share_one_normalization() {
705        // The entry and the request pass through the same function, so a non-ASCII letter in
706        // an entry matches only its exact spelling while ASCII case still folds on both sides.
707        let policy =
708            OutboundEmailPolicy::configured(vec!["\u{212A}evin@Example.COM".into()]).unwrap();
709        assert_eq!(
710            policy.recipients.as_deref().unwrap(),
711            ["\u{212A}evin@example.com"]
712        );
713        assert!(policy.allows("\u{212A}evin@EXAMPLE.com"));
714        assert!(!policy.allows("kevin@example.com"));
715        let accented =
716            OutboundEmailPolicy::maintainer(Some("\u{C9}mile@Example.com"), false).unwrap();
717        assert!(accented.allows("\u{C9}mile@example.COM"));
718        assert!(!accented.allows("\u{E9}mile@example.com"));
719    }
720
721    #[test]
722    fn suppression_matching_checks_exact_address_domain_and_recipient_key() {
723        let address = [EmailSuppression {
724            scope: EmailSuppressionScope::Outbound,
725            kind: EmailSuppressionKind::Address,
726            value: "a@example.com".into(),
727        }];
728        assert!(outbound_email_is_suppressed(
729            "a@example.com",
730            None,
731            &address
732        ));
733        assert!(!outbound_email_is_suppressed(
734            "b@example.com",
735            None,
736            &address
737        ));
738
739        let entries = [
740            EmailSuppression {
741                scope: EmailSuppressionScope::Outbound,
742                kind: EmailSuppressionKind::Address,
743                value: "a@example.com".into(),
744            },
745            EmailSuppression {
746                scope: EmailSuppressionScope::Outbound,
747                kind: EmailSuppressionKind::Domain,
748                value: "x.com".into(),
749            },
750            EmailSuppression {
751                scope: EmailSuppressionScope::Outbound,
752                kind: EmailSuppressionKind::RecipientKey,
753                value: "key-1".into(),
754            },
755            EmailSuppression {
756                scope: EmailSuppressionScope::Inbound,
757                kind: EmailSuppressionKind::Address,
758                value: "b@example.com".into(),
759            },
760        ];
761
762        assert!(outbound_email_is_suppressed(
763            "a@example.com",
764            None,
765            &entries
766        ));
767        assert!(outbound_email_is_suppressed("b@x.com", None, &entries));
768        assert!(!outbound_email_is_suppressed("b@sub.x.com", None, &entries));
769        assert!(outbound_email_is_suppressed(
770            "c@example.com",
771            Some("key-1"),
772            &entries
773        ));
774        assert!(!outbound_email_is_suppressed(
775            "c@example.com",
776            Some("key-2"),
777            &entries
778        ));
779        assert!(!outbound_email_is_suppressed(
780            "b@example.com",
781            None,
782            &entries
783        ));
784    }
785
786    #[test]
787    fn inbound_matching_checks_exact_address_and_domain_and_ignores_recipient_keys() {
788        let entries = [
789            EmailSuppression {
790                scope: EmailSuppressionScope::Inbound,
791                kind: EmailSuppressionKind::Address,
792                value: "a@example.com".into(),
793            },
794            EmailSuppression {
795                scope: EmailSuppressionScope::Inbound,
796                kind: EmailSuppressionKind::Domain,
797                value: "blocked.com".into(),
798            },
799            EmailSuppression {
800                scope: EmailSuppressionScope::Inbound,
801                kind: EmailSuppressionKind::RecipientKey,
802                value: "key-1".into(),
803            },
804            // An outbound-only entry never blocks an inbound sender.
805            EmailSuppression {
806                scope: EmailSuppressionScope::Outbound,
807                kind: EmailSuppressionKind::Address,
808                value: "c@example.com".into(),
809            },
810        ];
811
812        assert!(inbound_email_is_suppressed("a@example.com", &entries));
813        assert!(!inbound_email_is_suppressed("b@example.com", &entries));
814        assert!(inbound_email_is_suppressed("z@blocked.com", &entries));
815        assert!(!inbound_email_is_suppressed("z@sub.blocked.com", &entries));
816        assert!(!inbound_email_is_suppressed("c@example.com", &entries));
817        assert!(!inbound_email_is_suppressed("key-1", &entries));
818    }
819
820    #[test]
821    fn cloned_policy_keeps_the_same_immutable_recipient_set() {
822        let policy = OutboundEmailPolicy::configured(vec!["allowed@example.com".into()]).unwrap();
823        let clone = policy.clone();
824        assert!(Arc::ptr_eq(
825            policy.recipients.as_ref().unwrap(),
826            clone.recipients.as_ref().unwrap()
827        ));
828    }
829
830    fn rk() -> String {
831        "cc".repeat(32) // 32 bytes of 0xcc
832    }
833
834    fn token_keys_hex() -> String {
835        format!("0:{},1:{}", "11".repeat(32), "22".repeat(32))
836    }
837
838    fn enabled() -> OutboundEmailPolicy {
839        OutboundEmailPolicy::configured_for_tests_with(
840            vec!["allowed@example.com".into()],
841            vec!["actor:sender".into()],
842            5,
843            &rk(),
844            &token_keys_hex(),
845            Ok("Reply to unsubscribe: {unsubscribe}"),
846        )
847        .unwrap()
848    }
849
850    fn unhex(hex: &str) -> Vec<u8> {
851        hex.as_bytes()
852            .chunks_exact(2)
853            .map(|pair| {
854                let high = (pair[0] as char).to_digit(16).unwrap();
855                let low = (pair[1] as char).to_digit(16).unwrap();
856                (high * 16 + low) as u8
857            })
858            .collect()
859    }
860
861    #[test]
862    fn hmac_sha256_matches_rfc_4231_vectors() {
863        let case1 = hmac_sha256(&[0x0b; 20], b"Hi There");
864        assert_eq!(
865            hex_lower(&case1),
866            "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7"
867        );
868        let case2 = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
869        assert_eq!(
870            hex_lower(&case2),
871            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
872        );
873        let case6 = hmac_sha256(
874            &[0xaa; 131],
875            b"Test Using Larger Than Block-Size Key - Hash Key First",
876        );
877        assert_eq!(
878            hex_lower(&case6),
879            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
880        );
881    }
882
883    #[test]
884    fn key_configuration_refuses_malformed_values_when_senders_are_set() {
885        // Missing recipient-key secret.
886        let missing_rk = OutboundEmailPolicy::resolve_with_external(
887            None,
888            Some("maintainer@example.com"),
889            Some("actor:sender"),
890            Some("5"),
891            None,
892            Some(&token_keys_hex()),
893            None,
894        )
895        .err()
896        .expect("start-up policy is refused");
897        assert!(
898            missing_rk.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"),
899            "{missing_rk}"
900        );
901
902        // Missing token keys.
903        let missing_tokens = OutboundEmailPolicy::resolve_with_external(
904            None,
905            Some("maintainer@example.com"),
906            Some("actor:sender"),
907            Some("5"),
908            Some(&rk()),
909            None,
910            None,
911        )
912        .err()
913        .expect("start-up policy is refused");
914        assert!(
915            missing_tokens.contains("KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
916            "{missing_tokens}"
917        );
918
919        for (rk, tokens, needle) in [
920            (
921                rk(),
922                "0:nothex".to_string(),
923                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
924            ),
925            (rk(), "0".to_string(), "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS"),
926            (
927                rk(),
928                "0:abcd".to_string(),
929                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
930            ),
931            (
932                rk(),
933                format!("0:{},0:{}", "11".repeat(32), "22".repeat(32)),
934                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
935            ),
936            (
937                rk(),
938                format!("256:{}", "11".repeat(32)),
939                "KHIVE_EMAIL_UNSUBSCRIBE_TOKEN_KEYS",
940            ),
941        ] {
942            let error = OutboundEmailPolicy::resolve_with_external(
943                None,
944                Some("maintainer@example.com"),
945                Some("actor:sender"),
946                Some("5"),
947                Some(rk.as_str()),
948                Some(tokens.as_str()),
949                None,
950            )
951            .err()
952            .expect("start-up policy is refused");
953            assert!(error.contains(needle), "{error}");
954            assert!(
955                !error.contains(&"11".repeat(32)),
956                "key material leaked: {error}"
957            );
958        }
959
960        // Short recipient-key secret (31 bytes) and non-hex secret.
961        for rk in ["cc".repeat(31), "cc".repeat(16), "zz".repeat(32)] {
962            let error = OutboundEmailPolicy::resolve_with_external(
963                None,
964                Some("maintainer@example.com"),
965                Some("actor:sender"),
966                Some("5"),
967                Some(&rk),
968                Some(&token_keys_hex()),
969                None,
970            )
971            .err()
972            .expect("start-up policy is refused");
973            assert!(error.contains("KHIVE_EMAIL_UNSUBSCRIBE_RK_KEY"), "{error}");
974            assert!(!error.contains(&rk), "key material leaked: {error}");
975        }
976    }
977
978    #[test]
979    fn key_variables_are_ignored_when_senders_are_unset() {
980        // A malformed key value is never read while the senders variable is unset.
981        let policy = OutboundEmailPolicy::resolve_with_external(
982            None,
983            Some("maintainer@example.com"),
984            None,
985            None,
986            Some("not hex"),
987            Some("garbage"),
988            Some("/nonexistent/footer"),
989        )
990        .unwrap();
991        assert!(!policy.has_external_senders());
992        assert!(policy.recipient_key("a@example.com").is_none());
993        assert!(policy.mint_unsubscribe_token("a@example.com").is_none());
994        assert!(policy.external_footer().is_none());
995    }
996
997    #[test]
998    fn recipient_key_is_normalized_hex_and_stable_across_case() {
999        let policy = enabled();
1000        let key = policy.recipient_key("person@example.com").unwrap();
1001        assert_eq!(key.len(), 64);
1002        assert!(khive_types::is_lowercase_hex(&key));
1003        // Callers pass a normalized address (ADR-200 ยง5.1); normalization folds ASCII case.
1004        let other_case =
1005            khive_types::email_address::normalize_email_recipient("Person@Example.COM").unwrap();
1006        assert_eq!(key, policy.recipient_key(&other_case).unwrap());
1007        let secret = unhex(&rk());
1008        assert_eq!(key, hex_lower(&hmac_sha256(&secret, b"person@example.com")));
1009    }
1010
1011    #[test]
1012    fn token_round_trips_and_rejects_tampering() {
1013        let policy = enabled();
1014        let token = policy.mint_unsubscribe_token("person@example.com").unwrap();
1015        let recipient_key = policy.recipient_key("person@example.com").unwrap();
1016        assert_eq!(
1017            policy.verify_unsubscribe_token(&token).as_deref(),
1018            Some(recipient_key.as_str())
1019        );
1020
1021        // A flipped tag byte fails.
1022        let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1023        let last = bytes.len() - 1;
1024        bytes[last] ^= 0x01;
1025        assert!(policy
1026            .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1027            .is_none());
1028
1029        // A wrong version byte fails.
1030        let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1031        bytes[0] = 0x02;
1032        assert!(policy
1033            .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1034            .is_none());
1035
1036        // An unknown key id fails.
1037        let mut bytes = URL_SAFE_NO_PAD.decode(&token).unwrap();
1038        bytes[1] = 9;
1039        assert!(policy
1040            .verify_unsubscribe_token(&URL_SAFE_NO_PAD.encode(&bytes))
1041            .is_none());
1042
1043        // A truncated token fails.
1044        assert!(policy.verify_unsubscribe_token("AAAA").is_none());
1045    }
1046
1047    // A published test vector for the token format, shared with an independent verifier so that
1048    // a change to how either side mints or verifies fails a suite. The token key is test
1049    // material, never deployment key material.
1050    const VECTOR_KEY_ID: u8 = 3;
1051    const VECTOR_TOKEN_KEY_HEX: &str =
1052        "efaaa78a85da8a505a5278b5ad7d202a8b5028bb0b279c7a01f410c0ff84c09d";
1053    const VECTOR_RECIPIENT_KEY_HEX: &str =
1054        "cd5ac2457bd4442f53a23cfa9972cc3cdf3d0f1bcf1722bd5503ab20acfa3930";
1055    const VECTOR_TOKEN: &str =
1056        "AQPNWsJFe9REL1OiPPqZcsw83z0PG88XIr1VA6sgrPo5MFRThmznJxzUr1otDEzmcaE";
1057    const VECTOR_TOKEN_FLIPPED_TAG: &str =
1058        "AQPNWsJFe9REL1OiPPqZcsw83z0PG88XIr1VA6sgrPo5MFRThmznJxzUr1otDEzmcaA";
1059
1060    #[test]
1061    fn shared_vector_mints_and_verifies_byte_for_byte() {
1062        let recipient_key: [u8; RECIPIENT_KEY_BYTES] = unhex(VECTOR_RECIPIENT_KEY_HEX)
1063            .try_into()
1064            .expect("the vector recipient key is 32 bytes");
1065        assert_eq!(
1066            encode_unsubscribe_token(VECTOR_KEY_ID, &unhex(VECTOR_TOKEN_KEY_HEX), &recipient_key),
1067            VECTOR_TOKEN,
1068            "minting the vector inputs must reproduce the vector token"
1069        );
1070
1071        let policy = OutboundEmailPolicy::configured_for_tests_with(
1072            vec!["allowed@example.com".into()],
1073            vec!["actor:sender".into()],
1074            5,
1075            &rk(),
1076            &format!("{VECTOR_KEY_ID}:{VECTOR_TOKEN_KEY_HEX}"),
1077            Ok("Reply to unsubscribe: {unsubscribe}"),
1078        )
1079        .unwrap();
1080        assert_eq!(
1081            policy.verify_unsubscribe_token(VECTOR_TOKEN).as_deref(),
1082            Some(VECTOR_RECIPIENT_KEY_HEX),
1083            "the vector token verifies to the vector recipient key"
1084        );
1085        assert!(
1086            policy
1087                .verify_unsubscribe_token(VECTOR_TOKEN_FLIPPED_TAG)
1088                .is_none(),
1089            "the vector token with one tag bit flipped does not verify"
1090        );
1091    }
1092
1093    #[test]
1094    fn retired_key_ids_keep_verifying_and_the_highest_id_mints() {
1095        let policy = enabled();
1096        // A token minted under the higher id (1) verifies.
1097        let token_high = policy.mint_unsubscribe_token("person@example.com").unwrap();
1098        let decoded = URL_SAFE_NO_PAD.decode(&token_high).unwrap();
1099        assert_eq!(decoded[1], 1, "minting must use the highest configured id");
1100        assert!(policy.verify_unsubscribe_token(&token_high).is_some());
1101
1102        // Rebuild with a retired id 0 kept alongside the higher id 1.
1103        let retired = OutboundEmailPolicy::configured_for_tests_with(
1104            vec!["allowed@example.com".into()],
1105            vec!["actor:sender".into()],
1106            5,
1107            &rk(),
1108            &token_keys_hex(),
1109            Ok("footer {unsubscribe}"),
1110        )
1111        .unwrap();
1112        // A token that was minted under id 0 before rotation still verifies after id 1 exists.
1113        let mut body = Vec::new();
1114        body.push(0x01u8);
1115        body.push(0u8);
1116        body.extend_from_slice(&unhex(
1117            &retired.recipient_key("person@example.com").unwrap(),
1118        ));
1119        let tag = hmac_sha256(&unhex(&"11".repeat(32)), &body);
1120        body.extend_from_slice(&tag[..16]);
1121        let old_token = URL_SAFE_NO_PAD.encode(&body);
1122        assert_eq!(
1123            retired.verify_unsubscribe_token(&old_token).as_deref(),
1124            Some(
1125                retired
1126                    .recipient_key("person@example.com")
1127                    .unwrap()
1128                    .as_str()
1129            )
1130        );
1131    }
1132
1133    #[test]
1134    fn footer_state_is_recorded_without_a_start_up_error() {
1135        let policy = OutboundEmailPolicy::configured_for_tests_with(
1136            vec!["allowed@example.com".into()],
1137            vec!["actor:sender".into()],
1138            5,
1139            &rk(),
1140            &token_keys_hex(),
1141            Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"),
1142        )
1143        .unwrap();
1144        assert_eq!(
1145            policy.external_footer(),
1146            Some(Err("KHIVE_EMAIL_EXTERNAL_FOOTER_FILE is not set"))
1147        );
1148    }
1149
1150    #[test]
1151    fn footer_file_must_exist_be_readable_and_carry_the_placeholder() {
1152        let missing = read_external_footer(Some("/nonexistent/khive-footer"));
1153        assert!(missing.is_err());
1154        let dir = std::env::temp_dir();
1155        let without = dir.join("khive-footer-without-placeholder.txt");
1156        std::fs::write(&without, "no placeholder here").unwrap();
1157        assert!(read_external_footer(without.to_str()).is_err());
1158        let with = dir.join("khive-footer-with-placeholder.txt");
1159        std::fs::write(&with, "Contact us.\n{unsubscribe}\n").unwrap();
1160        assert_eq!(
1161            read_external_footer(with.to_str()).unwrap().as_ref(),
1162            "Contact us.\n{unsubscribe}\n"
1163        );
1164        assert!(read_external_footer(None).is_err());
1165    }
1166}