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}