Skip to main content

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}