Skip to main content

cratefield_core/
signer.rs

1//! The HMAC-SHA256 reference `Signer` over a bounded key ring (ADR 0006,
2//! amended by ADR 0014; issue #3, issue #137).
3//!
4//! Token format: `base64url(json).base64url(mac)` where the MAC is
5//! computed over the **encoded** payload string — the exact bytes between
6//! the dots — so a token has exactly one valid encoding.
7//!
8//! ## The ring (issue #137)
9//!
10//! The flat current/previous pair is replaced by a [`KeyRing`] of at most
11//! [`KeyRing::CAPACITY`] keys, each in one of three states:
12//!
13//! - [`KeyState::Signing`] — exactly one; every new token names it.
14//! - [`KeyState::VerifyingOnly`] — still verifies its old tokens, can
15//!   never sign again. Normal rotation demotes the outgoing key here.
16//! - [`KeyState::Revoked`] — refused for verification *and* signing; its
17//!   id cannot be re-added, and its secret can never re-enter the ring.
18//!   This is what makes a compromised key retire immediately rather than
19//!   merely demote (issue #137).
20//!
21//! Because the ring is bounded, adding a key past the bound retires the
22//! oldest non-signing entry: **a link survives at most `CAPACITY − 1`
23//! rotations after it was minted.** That is the honest half of the
24//! longevity-vs-revocation tradeoff; the other half is that the
25//! unsubscribe path does not depend on the signing key at all once a
26//! subscriber has been re-mailed — see `module-email-signup`'s opaque
27//! per-subscription tokens and `docs/KEY-ROTATION.md`.
28//!
29//! ## Time
30//!
31//! Expiry checks and lifetime clamping read the clock exclusively through
32//! the [`Clock`] port (architecture section 5) — never a direct system
33//! read — so a test can drive rotation and expiry without waiting, and the
34//! wasm target never gains a hidden time dependency.
35
36use std::sync::Arc;
37
38use base64::Engine;
39use base64::engine::general_purpose::URL_SAFE_NO_PAD;
40use hmac::{Hmac, KeyInit, Mac};
41use serde::{Deserialize, Serialize};
42use sha2::Sha256;
43use subtle::ConstantTimeEq;
44
45use crate::Clock;
46use crate::ports::signer::{Kid, MAX_KID_NAME, Payload, Signer};
47
48type HmacSha256 = Hmac<Sha256>;
49
50/// Minimum secret length. `HARNESS_SECRET` must be at least 32 bytes.
51pub const MIN_SECRET_BYTES: usize = 32;
52
53/// Errors from constructing an [`HmacSigner`] or a [`KeyRing`].
54#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
55#[non_exhaustive]
56pub enum SignerError {
57    #[error("harness secret must be at least {MIN_SECRET_BYTES} bytes")]
58    SecretTooShort,
59    #[error("key id {0:?} is already present in the ring")]
60    KeyIdTaken(String),
61    #[error("key id {0:?} was revoked and can never re-enter the ring (issue #137)")]
62    KeyRevoked(String),
63    #[error("a revoked secret can never be re-added under another key id (issue #137)")]
64    RevokedSecretReuse,
65    #[error("key id {0:?} exceeds the {MAX_KID_NAME}-character limit")]
66    KeyIdTooLong(String),
67    #[error(
68        "the ring is full and every live key is the signing key; nothing can be retired to make room"
69    )]
70    RingFull,
71}
72
73/// A key's role in the ring (ADR 0014, issue #137).
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum KeyState {
76    /// Signs every new token. At most one key holds this state.
77    Signing,
78    /// Verifies tokens it signed before demotion; can never sign.
79    VerifyingOnly,
80    /// Compromised or deliberately retired: its tokens are refused, its
81    /// id is burned, and its secret can never re-enter the ring.
82    Revoked,
83}
84
85/// One ring entry. Deliberately not `Debug`: the ring's debug view
86/// ([`KeyRing::states`]) shows ids and states but never key material
87/// (issue #135's scrubbing rule applies to key bytes too).
88#[derive(Clone)]
89pub struct RingKey {
90    kid: Kid,
91    secret: Vec<u8>,
92    state: KeyState,
93}
94
95/// A bounded set of token-signing keys with explicit states (issue #137).
96///
97/// A ring is built once — from the environment in a Worker, or
98/// programmatically by rotation tooling — and then frozen inside an
99/// [`HmacSigner`]. It is not ambient request state (ADR 0007): each
100/// signer owns its ring, and rotating keys in a running Worker means
101/// updating the secrets so the next isolate builds a new ring.
102#[derive(Clone)]
103pub struct KeyRing {
104    keys: Vec<RingKey>,
105}
106
107impl KeyRing {
108    /// Maximum number of entries: one signing key plus `CAPACITY − 1`
109    /// verification-only or revoked. A token survives at most
110    /// `CAPACITY − 1` rotations after it was minted; past that, its key
111    /// is retired and the link is dead by design — the documented cost of
112    /// being able to revoke a compromised key without a token table
113    /// (ADR 0014).
114    pub const CAPACITY: usize = 4;
115
116    #[must_use]
117    pub fn new() -> Self {
118        Self { keys: Vec::new() }
119    }
120
121    /// Number of entries, including revoked ones still remembered.
122    #[must_use]
123    pub fn len(&self) -> usize {
124        self.keys.len()
125    }
126
127    #[must_use]
128    pub fn is_empty(&self) -> bool {
129        self.keys.is_empty()
130    }
131
132    /// Safe view of the ring: each key's id and state, never its bytes.
133    #[must_use]
134    pub fn states(&self) -> Vec<(Kid, KeyState)> {
135        self.keys
136            .iter()
137            .map(|key| (key.kid.clone(), key.state))
138            .collect()
139    }
140
141    /// The key currently signing new tokens, if any.
142    #[must_use]
143    pub fn signing_key(&self) -> Option<&RingKey> {
144        self.keys.iter().find(|key| key.state == KeyState::Signing)
145    }
146
147    #[must_use]
148    pub fn state_of(&self, kid: &Kid) -> Option<KeyState> {
149        self.keys
150            .iter()
151            .find(|key| &key.kid == kid)
152            .map(|key| key.state)
153    }
154
155    /// Installs `secret` as the single signing key, demoting the previous
156    /// signer to [`KeyState::VerifyingOnly`]. If the ring is at capacity,
157    /// the oldest non-signing entry is retired to make room (its id is
158    /// kept as a burned tombstone); the retired id is returned.
159    ///
160    /// # Errors
161    ///
162    /// [`SignerError::SecretTooShort`], [`SignerError::KeyIdTooLong`],
163    /// [`SignerError::KeyIdTaken`], [`SignerError::KeyRevoked`] or
164    /// [`SignerError::RevokedSecretReuse`] when the new key is invalid;
165    /// [`SignerError::RingFull`] only in the degenerate ring whose entries
166    /// are all signing or revoked-with-nothing-to-drop (impossible under
167    /// [`Self::CAPACITY`] ≥ 1 except with hand-built state).
168    pub fn rotate_signing(
169        &mut self,
170        kid: Kid,
171        secret: impl Into<Vec<u8>>,
172    ) -> Result<Option<Kid>, SignerError> {
173        self.install(kid, secret.into(), KeyState::Signing)
174    }
175
176    /// Adds a verification-only key — the shape of `HARNESS_SECRET_PREVIOUS`
177    /// in the environment wiring. Same capacity and revocation rules as
178    /// [`Self::rotate_signing`].
179    ///
180    /// # Errors
181    ///
182    /// Identical to [`Self::rotate_signing`]: [`SignerError::SecretTooShort`],
183    /// [`SignerError::KeyIdTooLong`], [`SignerError::KeyIdTaken`],
184    /// [`SignerError::KeyRevoked`] or [`SignerError::RevokedSecretReuse`]
185    /// when the key is invalid, and [`SignerError::RingFull`] only in a
186    /// degenerate ring where nothing can be retired to make room.
187    pub fn add_verifying_only(
188        &mut self,
189        kid: Kid,
190        secret: impl Into<Vec<u8>>,
191    ) -> Result<Option<Kid>, SignerError> {
192        self.install(kid, secret.into(), KeyState::VerifyingOnly)
193    }
194
195    /// Marks `kid` [`KeyState::Revoked`]. Its tokens stop verifying
196    /// immediately, even while its secret stays configured somewhere in
197    /// the ring — revocation is a state, not a deletion race. Returns
198    /// whether an entry existed to be revoked; a missing id is still
199    /// burned as a tombstone, so `revoke(prev)` survives the moment the
200    /// operator also drops `HARNESS_SECRET_PREVIOUS`.
201    pub fn revoke(&mut self, kid: &Kid) -> bool {
202        if let Some(key) = self.keys.iter_mut().find(|key| &key.kid == kid) {
203            key.state = KeyState::Revoked;
204            return true;
205        }
206        // Burn the id without ever having held the secret. Bytes stay
207        // empty: a tombstone is metadata, never key material. The ring is
208        // bounded, so an id is only burned while a slot is genuinely
209        // free: the oldest empty tombstone is recycled first, and a ring
210        // full of live keys keeps its memory (its bytes are refused by
211        // removal anyway; the id simply is not remembered past that).
212        if self.keys.len() >= Self::CAPACITY && !self.make_room_for_tombstone() {
213            return false;
214        }
215        self.keys.push(RingKey {
216            kid: kid.clone(),
217            secret: Vec::new(),
218            state: KeyState::Revoked,
219        });
220        false
221    }
222
223    /// Drops the oldest bytes-empty revoked entry to free a slot for a
224    /// new tombstone. Returns whether a slot was freed.
225    fn make_room_for_tombstone(&mut self) -> bool {
226        match self
227            .keys
228            .iter()
229            .position(|key| key.state == KeyState::Revoked && key.secret.is_empty())
230        {
231            Some(index) => {
232                self.keys.remove(index);
233                true
234            }
235            None => false,
236        }
237    }
238
239    /// The shared insert path: validate, demote the old signer on a
240    /// rotation, evict oldest-if-full, then append.
241    fn install(
242        &mut self,
243        kid: Kid,
244        secret: Vec<u8>,
245        state: KeyState,
246    ) -> Result<Option<Kid>, SignerError> {
247        if secret.len() < MIN_SECRET_BYTES {
248            return Err(SignerError::SecretTooShort);
249        }
250        if kid_name_len(&kid) > MAX_KID_NAME {
251            return Err(SignerError::KeyIdTooLong(kid_display(&kid)));
252        }
253        if let Some(existing) = self.state_of(&kid) {
254            return Err(match existing {
255                KeyState::Revoked => SignerError::KeyRevoked(kid_display(&kid)),
256                _ => SignerError::KeyIdTaken(kid_display(&kid)),
257            });
258        }
259        if self.keys.iter().any(|key| {
260            key.state == KeyState::Revoked && !key.secret.is_empty() && key.secret == secret
261        }) {
262            return Err(SignerError::RevokedSecretReuse);
263        }
264        if state == KeyState::Signing
265            && let Some(previous) = self
266                .keys
267                .iter_mut()
268                .find(|key| key.state == KeyState::Signing)
269        {
270            previous.state = KeyState::VerifyingOnly;
271        }
272        let mut retired = None;
273        while self.keys.len() >= Self::CAPACITY {
274            match self.evict_oldest_non_signing() {
275                Some(evicted) => retired = Some(evicted),
276                None => return Err(SignerError::RingFull),
277            }
278        }
279        self.keys.push(RingKey { kid, secret, state });
280        Ok(retired)
281    }
282
283    /// Drops the oldest entry that is not the signing key, preferring a
284    /// revoked tombstone, then the oldest verification-only key. Returns
285    /// the retired id.
286    ///
287    /// A retired verification-only key is remembered as a *bytes-empty*
288    /// tombstone — its id stays burned so an operator can never re-mint
289    /// it under a live secret and confuse links that still name it — but
290    /// only while that costs nothing: burning an id may never consume the
291    /// very room the eviction was for. Under capacity pressure the oldest
292    /// id decays, the same bounded-memory-over-burned-name rule as
293    /// [`Self::revoke`]. Each call therefore strictly shrinks the ring
294    /// unless the tombstone leaves it below the bound already.
295    fn evict_oldest_non_signing(&mut self) -> Option<Kid> {
296        let index = self
297            .keys
298            .iter()
299            .position(|key| key.state == KeyState::Revoked)
300            .or_else(|| {
301                self.keys
302                    .iter()
303                    .position(|key| key.state == KeyState::VerifyingOnly)
304            })?;
305        let evicted = self.keys.remove(index);
306        if self.keys.len() < Self::CAPACITY - 1 {
307            self.keys.insert(
308                index,
309                RingKey {
310                    kid: evicted.kid.clone(),
311                    secret: Vec::new(),
312                    state: KeyState::Revoked,
313                },
314            );
315        }
316        Some(evicted.kid)
317    }
318}
319
320impl Default for KeyRing {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326impl std::fmt::Debug for KeyRing {
327    /// Never prints key material (issue #135).
328    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329        f.debug_struct("KeyRing")
330            .field("keys", &self.states())
331            .finish_non_exhaustive()
332    }
333}
334
335/// A per-purpose lifetime ceiling (ADR 0014, issue #137).
336///
337/// The signer applies the policy at *sign* time: a requested `exp` beyond
338/// the ceiling is clamped down, and a missing `exp` gets the ceiling as a
339/// definite expiry. Only a purpose whose ceiling is `None` may mint a
340/// genuinely non-expiring token, and that must be a justified line in the
341/// policy table, never an omission by the caller.
342#[derive(Debug, Clone)]
343pub struct TokenPolicy {
344    rules: Vec<(String, i64)>,
345    default_secs: Option<i64>,
346}
347
348/// Seven days: the ADR 0006 confirmation default, expressed as a policy
349/// ceiling rather than a caller habit.
350pub const CONFIRM_TOKEN_MAX_TTL_SECS: i64 = 7 * 86_400;
351
352/// The status-token policy (issue #137): a waitlist position is not
353/// secret, but a URL that never expires is a URL forever in logs,
354/// forwards and bookmarks. Ninety days, renewed on every confirm hit.
355pub const STATUS_TOKEN_MAX_TTL_SECS: i64 = 90 * 86_400;
356
357/// The default ceiling for any purpose not listed explicitly. An unknown
358/// purpose can never mint a non-expiring token by omission.
359pub const DEFAULT_TOKEN_MAX_TTL_SECS: i64 = 30 * 86_400;
360
361/// The one action with a deliberately non-expiring ceiling, justified in
362/// ADR 0014: unsubscribe must always be reachable, and the longevity the
363/// signed link used to buy from the global key ring is now *also* carried
364/// by the per-subscription opaque token in `module-email-signup`, which
365/// outlives any key rotation because it lives in the subscriber's row.
366pub const UNSUBSCRIBE_ACTION: &str = "unsubscribe";
367
368impl Default for TokenPolicy {
369    fn default() -> Self {
370        Self {
371            rules: vec![
372                ("confirm".to_owned(), CONFIRM_TOKEN_MAX_TTL_SECS),
373                ("status".to_owned(), STATUS_TOKEN_MAX_TTL_SECS),
374            ],
375            default_secs: Some(DEFAULT_TOKEN_MAX_TTL_SECS),
376        }
377    }
378}
379
380impl TokenPolicy {
381    /// Overrides one action's ceiling. `Some(secs)` bounds the action;
382    /// `None` makes it explicitly non-expiring — a decision a venture
383    /// must name on purpose, which is exactly the point of issue #137.
384    /// A zero ceiling means the same as `None` (`max_ttl_secs` maps it),
385    /// so the table always carries the decision, never an omission.
386    #[must_use]
387    pub fn with_max(mut self, action: &str, secs: Option<i64>) -> Self {
388        let ceiling = secs.unwrap_or(0);
389        self.rules.retain(|(name, _)| name != action);
390        self.rules.push((action.to_owned(), ceiling));
391        self
392    }
393
394    /// The ceiling for a purpose's action part (the text after the first
395    /// `.`, the whole string when there is none). `None` means an
396    /// explicitly sanctioned non-expiring purpose.
397    #[must_use]
398    pub fn max_ttl_secs(&self, purpose: &str) -> Option<i64> {
399        let action = purpose.split_once('.').map_or(purpose, |(_, rest)| rest);
400        if action == UNSUBSCRIBE_ACTION {
401            return None;
402        }
403        match self.rules.iter().find(|(name, _)| name == action) {
404            Some((_, 0)) => None,
405            Some((_, secs)) => Some(*secs),
406            None => self.default_secs,
407        }
408    }
409
410    /// The effective expiry: `min(requested, now + ceiling)`, and
411    /// `now + ceiling` when the caller supplied no expiry at all.
412    #[must_use]
413    pub fn effective_exp(&self, purpose: &str, requested: Option<u64>, now: u64) -> Option<u64> {
414        self.max_ttl_secs(purpose).map(|ceiling| {
415            let limit = now.saturating_add(u64::try_from(ceiling.max(0)).unwrap_or(0));
416            requested.map_or(limit, |exp| exp.min(limit))
417        })
418    }
419}
420
421#[derive(Serialize, Deserialize)]
422struct PayloadJson {
423    purpose: String,
424    subject: String,
425    #[serde(skip_serializing_if = "Option::is_none", default)]
426    exp: Option<u64>,
427    #[serde(skip_serializing_if = "Option::is_none", default)]
428    iss: Option<String>,
429    kid: String,
430}
431
432/// HMAC-SHA256 signer over a [`KeyRing`] (ADR 0006, ADR 0014).
433///
434/// Cheap to clone: the ring, clock, binding and policy live behind one
435/// `Arc`. `Debug` shows key ids and states only — never key material
436/// (issue #135).
437#[derive(Clone)]
438pub struct HmacSigner {
439    inner: Arc<SignerInner>,
440}
441
442#[derive(Clone)]
443struct SignerInner {
444    ring: KeyRing,
445    clock: Arc<dyn Clock>,
446    /// The venture/environment binding (issue #137): stamped into new
447    /// tokens as `iss` and enforced against tokens that carry it. `None`
448    /// only in tests and hand-built signers; every runtime sets it
449    /// through [`crate::HarnessConfig::signer`].
450    binding: Option<String>,
451    policy: TokenPolicy,
452}
453
454impl std::fmt::Debug for HmacSigner {
455    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
456        f.debug_struct("HmacSigner")
457            .field("ring", &self.inner.ring)
458            .field("bound", &self.inner.binding.is_some())
459            .finish()
460    }
461}
462
463impl HmacSigner {
464    /// The environment shape of ADR 0006: `HARNESS_SECRET` signs, the
465    /// optional `HARNESS_SECRET_PREVIOUS` verifies only. No venture/env
466    /// binding and the default [`TokenPolicy`]; runtimes build the bound
467    /// signer through [`crate::HarnessConfig::signer`], where those
468    /// fields come from config (issue #137).
469    ///
470    /// # Errors
471    ///
472    /// [`SignerError::SecretTooShort`] when the current secret is shorter
473    /// than [`MIN_SECRET_BYTES`].
474    pub fn new(
475        current_secret: impl Into<String>,
476        previous_secret: Option<String>,
477    ) -> Result<Self, SignerError> {
478        let mut ring = KeyRing::new();
479        ring.rotate_signing(Kid::Cur, current_secret.into().into_bytes())?;
480        if let Some(previous) = previous_secret {
481            ring.add_verifying_only(Kid::Prev, previous.into_bytes())?;
482        }
483        Ok(Self::from_ring(ring))
484    }
485
486    /// A signer over an already-built ring: [`crate::SystemClock`], no
487    /// binding, default [`TokenPolicy`]. Apply [`with_clock`],
488    /// [`with_binding`] and [`with_policy`] before handing it out.
489    ///
490    /// [`with_clock`]: Self::with_clock
491    /// [`with_binding`]: Self::with_binding
492    /// [`with_policy`]: Self::with_policy
493    #[must_use]
494    pub fn from_ring(ring: KeyRing) -> Self {
495        Self {
496            inner: Arc::new(SignerInner {
497                ring,
498                clock: Arc::new(crate::SystemClock),
499                binding: None,
500                policy: TokenPolicy::default(),
501            }),
502        }
503    }
504
505    /// Overrides the clock used for expiry checks and lifetime clamping
506    /// (the `Clock` port is the only time source in core, architecture
507    /// section 5).
508    #[must_use]
509    pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
510        Arc::make_mut(&mut self.inner).clock = clock;
511        self
512    }
513
514    /// Stamps and enforces the venture/environment binding (issue #137).
515    /// The labels come from config (`HARNESS_VENTURE`, `ENV`); a token
516    /// minted under one binding never verifies under another. With both
517    /// `None` the signer stays unbound — the pre-#137 shape, kept for
518    /// tests and hand-built rings.
519    #[must_use]
520    pub fn with_binding(mut self, venture: Option<String>, env: Option<String>) -> Self {
521        let binding = match (
522            venture.filter(|value| !value.is_empty()),
523            env.filter(|value| !value.is_empty()),
524        ) {
525            (None, None) => None,
526            (venture, env) => Some(format!(
527                "{}|{}",
528                venture.as_deref().unwrap_or("-"),
529                env.as_deref().unwrap_or("-")
530            )),
531        };
532        Arc::make_mut(&mut self.inner).binding = binding;
533        self
534    }
535
536    /// Replaces the per-purpose lifetime policy.
537    #[must_use]
538    pub fn with_policy(mut self, policy: TokenPolicy) -> Self {
539        Arc::make_mut(&mut self.inner).policy = policy;
540        self
541    }
542
543    /// The ring this signer verifies against (ids and states only).
544    #[must_use]
545    pub fn ring_states(&self) -> Vec<(Kid, KeyState)> {
546        self.inner.ring.states()
547    }
548
549    fn now_secs(&self) -> u64 {
550        u64::try_from(self.inner.clock.now().unix_timestamp().max(0)).unwrap_or(0)
551    }
552
553    fn mac(key: &[u8], encoded_payload: &str) -> [u8; 32] {
554        let mut mac =
555            <HmacSha256 as KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
556        mac.update(encoded_payload.as_bytes());
557        let mut out = [0u8; 32];
558        out.copy_from_slice(&mac.finalize().into_bytes());
559        out
560    }
561
562    /// Constant-time MAC against every live (non-revoked) key, with no
563    /// early exit: the work is bounded by the ring size and independent of
564    /// which key matches (issue #137). The old scheme tried the named key
565    /// then one fallback; trying all live keys is its superset, which is
566    /// what lets a token signed before the ring existed survive a
567    /// rotation whichever positional name it carries.
568    fn mac_matches_any_live_key(&self, mac: [u8; 32], encoded_payload: &str) -> bool {
569        let mut matched = false;
570        for key in &self.inner.ring.keys {
571            if key.state == KeyState::Revoked || key.secret.is_empty() {
572                continue;
573            }
574            matched |= bool::from(mac.ct_eq(&Self::mac(&key.secret, encoded_payload)));
575        }
576        matched
577    }
578}
579
580impl Signer for HmacSigner {
581    fn sign(&self, payload: &Payload) -> String {
582        // Signing always uses the ring's signing key; a caller cannot
583        // steer which key signs. The old `Kid::Prev` signing path is gone
584        // — a verification-only key verifies only (ADR 0014). A ring with
585        // no signing key yields an empty token rather than a panic:
586        // `verify` treats it as malformed, and a correctly wired runtime
587        // always has a signing key.
588        let Some(signing) = self.inner.ring.signing_key() else {
589            tracing::error!(purpose = %payload.purpose, "signer ring has no signing key; emitting an invalid token");
590            return String::new();
591        };
592        let exp = self
593            .inner
594            .policy
595            .effective_exp(&payload.purpose, payload.exp, self.now_secs());
596        let json = PayloadJson {
597            purpose: payload.purpose.clone(),
598            subject: payload.subject.clone(),
599            exp,
600            iss: self.inner.binding.clone(),
601            kid: kid_wire_name(&signing.kid),
602        };
603        let Ok(serialized) = serde_json::to_string(&json) else {
604            tracing::error!(purpose = %payload.purpose, "signer payload failed to serialize; emitting an invalid token");
605            return String::new();
606        };
607        let encoded = URL_SAFE_NO_PAD.encode(serialized);
608        let mac = Self::mac(&signing.secret, &encoded);
609        format!("{encoded}.{}", URL_SAFE_NO_PAD.encode(mac))
610    }
611
612    fn verify(&self, token: &str, expected_purpose: &str) -> Option<Payload> {
613        let (encoded_payload, encoded_mac) = token.split_once('.')?;
614        if encoded_payload.is_empty() || encoded_mac.is_empty() {
615            return None;
616        }
617        if token.matches('.').count() != 1 {
618            return None;
619        }
620
621        let mac: [u8; 32] = URL_SAFE_NO_PAD.decode(encoded_mac).ok()?.try_into().ok()?;
622        let json = URL_SAFE_NO_PAD
623            .decode(encoded_payload)
624            .ok()
625            .and_then(|bytes| String::from_utf8(bytes).ok())?;
626        let payload: PayloadJson = serde_json::from_str(&json).ok()?;
627        let kid = parse_kid(&payload.kid)?;
628
629        // MAC over the encoded payload string (ADR 0006): the exact bytes
630        // of `encoded_payload`, so a re-encoded (e.g. padded) payload
631        // cannot reuse a MAC. Revoked keys are skipped by construction
632        // (issue #137).
633        if !self.mac_matches_any_live_key(mac, encoded_payload) {
634            return None;
635        }
636
637        if payload.purpose != expected_purpose {
638            return None;
639        }
640        // Binding (issue #137): a token that carries a venture/environment
641        // scope must match this signer's scope, and a scope-carrying
642        // token is refused by an unbound signer (fail closed on
643        // misconfiguration). A legacy token with no `iss` verifies on —
644        // already-sent links are not retroactively broken — but a bound
645        // signer never mints one, so the unbound population only shrinks
646        // (ADR 0014).
647        if payload.iss.is_some() && payload.iss.as_deref() != self.inner.binding.as_deref() {
648            return None;
649        }
650        if let Some(exp) = payload.exp
651            && self.now_secs() >= exp
652        {
653            return None;
654        }
655        Some(Payload {
656            purpose: payload.purpose,
657            subject: payload.subject,
658            exp: payload.exp,
659            kid,
660        })
661    }
662}
663
664fn kid_wire_name(kid: &Kid) -> String {
665    match kid {
666        Kid::Cur => "cur".to_owned(),
667        Kid::Prev => "prev".to_owned(),
668        Kid::Named(name) => name.clone(),
669    }
670}
671
672fn kid_display(kid: &Kid) -> String {
673    kid_wire_name(kid)
674}
675
676fn kid_name_len(kid: &Kid) -> usize {
677    match kid {
678        Kid::Cur | Kid::Prev => 3,
679        Kid::Named(name) => name.len(),
680    }
681}
682
683fn parse_kid(name: &str) -> Option<Kid> {
684    match name {
685        "cur" => Some(Kid::Cur),
686        "prev" => Some(Kid::Prev),
687        other if !other.is_empty() && other.len() <= MAX_KID_NAME => {
688            Some(Kid::Named(other.to_owned()))
689        }
690        _ => None,
691    }
692}