cratefield_core/ports/signer.rs
1//! The `Signer` port and the signed-token payload (ADR 0006, amended by
2//! ADR 0014). The HMAC reference implementation — a bounded key ring with
3//! explicit signing / verification-only / revoked states — lives in
4//! `cratefield-core::signer` (issue #3, issue #137).
5
6/// Which key in the [`crate::KeyRing`] a token was signed with. Tokens
7/// name their key so rotation never breaks links in flight.
8///
9/// The two positional names are what the environment-derived runtimes
10/// wire (`HARNESS_SECRET` is `Cur`, `HARNESS_SECRET_PREVIOUS` is `Prev`);
11/// legacy tokens from the flat current/previous scheme carry exactly these
12/// names (issue #137). A ring built programmatically can give its keys
13/// stable [`Kid::Named`] labels instead, so an entry keeps its identity as
14/// it moves from signing to verification-only.
15#[derive(Debug, Clone, PartialEq, Eq)]
16#[non_exhaustive]
17pub enum Kid {
18 /// The current signing key.
19 Cur,
20 /// The previous key (verification-only unless explicitly revoked).
21 Prev,
22 /// A stable, operator-chosen key label (kebab-case or short slug; the
23 /// reference signer caps the wire form at [`MAX_KID_NAME`] characters).
24 Named(String),
25}
26
27impl Kid {
28 /// A stable operator-chosen key id for a programmatically built ring.
29 #[must_use]
30 pub fn named(name: impl Into<String>) -> Self {
31 Self::Named(name.into())
32 }
33}
34
35/// The longest accepted `Kid::Named` wire name. Key ids ride inside every
36/// token; the bound keeps a hostile payload from claiming arbitrary
37/// storage.
38pub const MAX_KID_NAME: usize = 32;
39
40/// The signed payload: `{ purpose, subject, exp?, kid }`.
41///
42/// `purpose` scopes a token to one use (`email-signup.confirm`,
43/// `waitlist.status`, ...) and must be module-qualified: the module part
44/// *is* the module binding (ADR 0014, issue #137) — a token minted by one
45/// module can never verify for another because the full purpose is
46/// compared. `exp` is a Unix timestamp in seconds. Every purpose carries
47/// an explicit lifetime: the reference signer clamps over-long or missing
48/// expiries to its [`crate::TokenPolicy`] (issue #137), so a non-expiring
49/// token is a deliberate policy decision, never an omission. Venture and
50/// environment binding is stamped and enforced by the signer itself, not
51/// carried here: modules hand the same payload shape to every runtime.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Payload {
54 pub purpose: String,
55 pub subject: String,
56 pub exp: Option<u64>,
57 pub kid: Kid,
58}
59
60/// Failures surfaced by `verify` beyond "the token is simply invalid",
61/// which is reported as `None`.
62#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
63pub enum SignatureError {
64 #[error("token payload is not valid UTF-8/JSON")]
65 Malformed,
66 #[error("token is expired")]
67 Expired,
68 #[error("token purpose {actual:?} does not match expected {expected:?}")]
69 WrongPurpose { actual: String, expected: String },
70 #[error("token was minted for another venture or environment (issue #137)")]
71 WrongScope,
72 #[error("token names a revoked key (issue #137)")]
73 RevokedKey,
74}
75
76/// Produces and verifies `base64url(json).base64url(mac)` tokens where the
77/// MAC is computed over the **encoded** payload string, so a token has
78/// exactly one valid encoding (ADR 0006).
79///
80/// The reference implementation keeps a bounded ring of keys (ADR 0014,
81/// issue #137): one in the *signing* state, any number in
82/// *verification-only*, and revoked keys whose tokens are refused.
83/// Verification tries every live key's MAC in constant time; a token
84/// naming a revoked key, minted for another venture/environment binding,
85/// expired past its policy lifetime, or carrying the wrong purpose
86/// verifies as `None`.
87pub trait Signer: Send + Sync {
88 fn sign(&self, payload: &Payload) -> String;
89 /// `None` for malformed input, tampering, expiry, wrong purpose, a
90 /// revoked key id, or a venture/environment binding mismatch; never
91 /// panics on malformed input.
92 fn verify(&self, token: &str, expected_purpose: &str) -> Option<Payload>;
93}