Skip to main content

cheers_core/
claims.rs

1//! Identifiers, device bindings, and the `Claims` carried by a verified session token.
2//!
3//! These types are the **mesofact ↔ cheers contract**: any change after the
4//! mesofact integration (P11) ships requires a coordinated migration. Public
5//! structs and enums are `#[non_exhaustive]` so adding fields or variants
6//! later is not a SemVer-breaking change. Construct new values through the
7//! provided constructors (and builder-style setters where present), not
8//! struct literals.
9//!
10//! @yah:ticket(R020-F2, "Principal kinds: user|service|camp enum + Principal record in cheers-core")
11//! @yah:assignee(agent:claude)
12//! @yah:at(2026-06-04T01:35:04Z)
13//! @yah:status(review)
14//! @yah:phase(P1)
15//! @yah:parent(R020)
16//! @yah:next("Add PrincipalKind { User, Service, Camp } and Principal { id, kind, bound_to: Option<PrincipalId>, status, created_at } to cheers-core.")
17//! @yah:next("Extend sub-claim parser to accept 'user:<id>' | 'svc:<id>' | 'camp:<id>' prefixes; reject unprefixed sub at parse time.")
18//! @yah:verify("cargo test -p cheers-core")
19//! @yah:verify("Roundtrip test: Principal { kind: Camp, bound_to: Some(user) } serializes/parses; bound_to=None on a Camp is a parse error.")
20//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
21//! @yah:depends_on(R019-F5)
22//! @yah:handoff("Landed new module crates/cheers-core/src/principal.rs (exported from lib.rs): PrincipalKind { User, Service, Camp } + PrincipalId { kind, id } + PrincipalStatus { Active, Revoked } + Principal { id: PrincipalId, bound_to: Option<PrincipalId>, status, created_at } + PrincipalError + PrincipalIdParseError. All #[non_exhaustive].")
23//! @yah:handoff("PrincipalId is the typed sub-claim — serializes transparent as 'user:<id>' | 'svc:<id>' | 'camp:<id>'. FromStr/Deserialize reject unprefixed input (MissingPrefix), unknown prefixes (UnknownPrefix incl. legacy 'service'/'agent'), and empty ids — so a session-shaped bare sub cannot silently be read as a user principal. PrincipalKind::prefix uses 'svc' (matches the doc), not 'service'.")
24//! @yah:handoff("Principal invariants enforced in BOTH try_new and the Deserialize impl (via RawPrincipal intermediate): Camp ⇒ bound_to=Some(user:_); User/Service ⇒ bound_to=None; Camp bound_to that isn't a user is rejected. JSON omits bound_to when None (skip_serializing_if).")
25//! @yah:handoff("Did NOT touch existing Claims.sub: UserId — that's the session contract; the MCP-claims shape (act/owns/camp_id/auth_strength) lands in R020-F3 alongside the Scope enum and will be where PrincipalId actually replaces a sub field. Foundation laid; R020-F3 builds on PrincipalId for its sub typing.")
26//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (33 unit incl. 17 new principal tests, 1 doctest), cargo test -p cheers-server (35+9+2+4+0 across binaries/integration), cargo test -p cheers-verify (clean). R020 parent verify smoke passes.")
27//!
28//! @yah:ticket(R020-F3, "Scope vocabulary as typed enum + composition rules in cheers-core")
29//! @yah:assignee(agent:claude)
30//! @yah:at(2026-06-04T01:35:12Z)
31//! @yah:status(review)
32//! @yah:phase(P1)
33//! @yah:parent(R020)
34//! @yah:next("Add Scope enum covering arch:* board:* camp:* cloud:* party:* subagent:* ownership:write audit:* per §Scope vocabulary.")
35//! @yah:next("Enforce composition rules at grant/mint: no wildcards on the wire; <category>:admin does NOT imply read/write; ownership:write and audit:write are kind=service only; aud-scoping mandatory.")
36//! @yah:next("Add MCP claim shapes alongside Scope: act { sub }, owns { service: [], arch_doc: [] }, camp_id, auth_strength enum { Bootstrap, UserFresh }.")
37//! @yah:verify("cargo test -p cheers-core")
38//! @yah:verify("Negative test: serializing a Scope list containing 'cloud:*' fails; granting ownership:write to a User principal returns a typed error.")
39//! @yah:gotcha("A user-kind grant with ownership:write or audit:write must be rejected at write time, not just at mint. Rule (4) is a CHECK that lives in the grant API, not the mint path.")
40//! @arch:see(.yah/docs/working/mcp-auth-and-ownership.md)
41//! @yah:handoff("Landed new module crates/cheers-core/src/mcp.rs (exported from lib.rs): closed-vocabulary Scope enum (16 variants — arch/board/camp/cloud/party/subagent + ownership:write + audit:{read,write}), GrantError, validate_grant(), and the MCP claim shapes (Actor, Owns, AuthStrength, McpClaims). All #[non_exhaustive].")
42//! @yah:handoff("Composition rules: (1) wildcards — enforced by Scope::from_str rejecting any '*' BEFORE the literal match, so a wildcard cannot be deserialized into a Vec<Scope> on the wire (tested via Vec<Scope> mid-list rejection). (3) <category>:admin distinct — enforced structurally: CampAdmin and CampRead are independent variants; a grant of one literally isn't a grant of the other; pinned with a test. (4) ownership:write + audit:write service-only — enforced by validate_grant(kind, scope), which rejects BOTH User and Camp (not just User — doc says 'kind=service only'). Service principals pass. (5) aud-scoping is documented as a mint-path concern, not a per-scope predicate.")
43//! @yah:handoff("Scope serializes as the literal wire string ('cloud:deploy'), not the variant name — hand-rolled Serialize/Deserialize via as_wire()/FromStr, NOT serde rename. McpClaims.sub is a PrincipalId (R020-F2), so a token whose sub is bare 'alice' fails deserialize with the 'must be prefixed' message inherited from PrincipalId. Owns has explicit service+arch_doc Vec<String> fields PLUS #[serde(flatten)] extra: BTreeMap<String,Vec<String>> so adding a new resource kind in the ownership table doesn't break the wire contract.")
44//! @yah:handoff("AuthStrength uses #[serde(rename_all=\"kebab-case\")] — Bootstrap→'bootstrap', UserFresh→'user-fresh' (matches the doc verbatim).")
45//! @yah:handoff("Did NOT touch the existing Claims.sub: UserId (session contract). McpClaims is the peer for MCP-call tokens. R020-F4 (ownership table writers) bolts the ownership lookups onto cheers-server and reads them into Owns at mint.")
46//! @yah:handoff("Verified GREEN: cargo test -p cheers-core (51 unit incl. 18 new mcp tests + 1 doctest), cargo test -p cheers-server (35+9+2+4+0), cargo test -p cheers-verify (clean). R020 parent verify smoke passes.")
47//!
48//! @yah:relay(R515, "Bind a session claim to a long-lived public key, so an edge can prove token holder == connecting peer")
49//! @yah:at(2026-09-03T06:37:07Z)
50//! @yah:status(open)
51//! @yah:assignee(agent:bundle-anthropic-ashguard)
52//! @arch:see(.yah/docs/working/edge-verifiable-auth.md)
53
54use base64::Engine as _;
55use base64::engine::general_purpose::URL_SAFE_NO_PAD;
56use serde::{Deserialize, Serialize};
57
58/// Stable user identifier — minted by `UserStore` on first sight of a credential.
59///
60/// Opaque to consumers; cheers does not interpret the inner string. Products
61/// pick the shape (UUID, base32 ULID, …); cheers passes it through.
62#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(transparent)]
64pub struct UserId(String);
65
66impl UserId {
67    pub fn new(s: impl Into<String>) -> Self {
68        Self(s.into())
69    }
70
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74
75    pub fn into_inner(self) -> String {
76        self.0
77    }
78}
79
80impl std::fmt::Display for UserId {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(&self.0)
83    }
84}
85
86impl From<String> for UserId {
87    fn from(s: String) -> Self {
88        Self(s)
89    }
90}
91
92impl From<&str> for UserId {
93    fn from(s: &str) -> Self {
94        Self(s.to_owned())
95    }
96}
97
98/// Per-device identifier — minted on the first sign-in from a given device.
99///
100/// One user has many devices; one device has one `DeviceId` per user.
101#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
102#[serde(transparent)]
103pub struct DeviceId(String);
104
105impl DeviceId {
106    pub fn new(s: impl Into<String>) -> Self {
107        Self(s.into())
108    }
109
110    pub fn as_str(&self) -> &str {
111        &self.0
112    }
113
114    pub fn into_inner(self) -> String {
115        self.0
116    }
117}
118
119impl std::fmt::Display for DeviceId {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        f.write_str(&self.0)
122    }
123}
124
125impl From<String> for DeviceId {
126    fn from(s: String) -> Self {
127        Self(s)
128    }
129}
130
131impl From<&str> for DeviceId {
132    fn from(s: &str) -> Self {
133        Self(s.to_owned())
134    }
135}
136
137/// How a device proved its identity to mint this session.
138///
139/// One variant per first-class provider in the build plan. `OidcGeneric`
140/// is the escape hatch for ad-hoc OIDC issuers (e.g. enterprise SSO) that
141/// aren't named providers.
142#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
143#[non_exhaustive]
144#[serde(tag = "kind", rename_all = "snake_case")]
145pub enum DeviceBinding {
146    Passkey,
147    OidcGoogle,
148    OidcApple,
149    OidcGeneric { issuer: String },
150    EmailPassword,
151    EmailMagicLink,
152    LanPair,
153}
154
155/// Resolved user record returned by `UserStore`.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157#[non_exhaustive]
158pub struct User {
159    pub id: UserId,
160    pub email: Option<String>,
161    pub name: Option<String>,
162}
163
164impl User {
165    pub fn new(id: UserId) -> Self {
166        Self {
167            id,
168            email: None,
169            name: None,
170        }
171    }
172
173    pub fn with_email(mut self, email: impl Into<String>) -> Self {
174        self.email = Some(email.into());
175        self
176    }
177
178    pub fn with_name(mut self, name: impl Into<String>) -> Self {
179        self.name = Some(name.into());
180        self
181    }
182}
183
184/// One stored proof-of-identity bound to a `(UserId, DeviceId)` pair.
185///
186/// The shape that `CredentialStore` reads and writes. The `binding` field
187/// records *how* the credential was established; provider-specific secrets
188/// live in `material` as an opaque byte blob (e.g. a passkey credential ID,
189/// an Argon2id hash, a refresh-token chain root, …) whose interpretation is
190/// owned by the provider that produced it.
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192#[non_exhaustive]
193pub struct Credential {
194    pub user_id: UserId,
195    pub device_id: DeviceId,
196    pub binding: DeviceBinding,
197    pub material: Vec<u8>,
198}
199
200impl Credential {
201    pub fn new(
202        user_id: UserId,
203        device_id: DeviceId,
204        binding: DeviceBinding,
205        material: Vec<u8>,
206    ) -> Self {
207        Self {
208            user_id,
209            device_id,
210            binding,
211            material,
212        }
213    }
214}
215
216/// Why a [`PeerKey`] failed to construct or deserialize.
217#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
218#[non_exhaustive]
219pub enum PeerKeyError {
220    /// Zero-length key bytes — a binding to nothing is never meaningful.
221    #[error("peer key must be non-empty")]
222    EmptyKey,
223    /// An [`Other`](PeerKeyAlgorithm::Other) label with no characters in it.
224    #[error("peer key algorithm must be non-empty")]
225    EmptyAlgorithm,
226    /// The algorithm names a fixed key length and the bytes don't match it.
227    #[error("{algorithm} peer key must be {expected} bytes, got {actual}")]
228    WrongKeyLength {
229        algorithm: String,
230        expected: usize,
231        actual: usize,
232    },
233}
234
235/// Which algorithm a bound [`PeerKey`]'s bytes belong to.
236///
237/// cheers is a general auth library, so the *field* on [`Claims`] must not name
238/// one curve. The discriminant rides next to the bytes instead, and a key only
239/// ever matches another key of the same algorithm — so a byte string that is a
240/// valid public key under two schemes can't be confused across them.
241///
242/// [`Other`](Self::Other) is the escape hatch for a scheme cheers hasn't named:
243/// unknown labels still round-trip and still compare, so a consumer isn't
244/// version-locked to a cheers release to bind a key type of its own. Labels are
245/// canonicalized on the way in ([`from_wire`](Self::from_wire) returns
246/// [`Ed25519`](Self::Ed25519) for `"ed25519"`, never `Other("ed25519")`), so
247/// each algorithm has exactly one representation and equality is total.
248#[derive(Debug, Clone, PartialEq, Eq, Hash)]
249#[non_exhaustive]
250pub enum PeerKeyAlgorithm {
251    /// Ed25519 — 32-byte public key. What iroh/QUIC node identities use.
252    Ed25519,
253    /// A scheme cheers doesn't name. Carries the wire label verbatim.
254    Other(String),
255}
256
257impl PeerKeyAlgorithm {
258    /// The wire label. Stable — it *is* the serialized form.
259    pub fn as_wire(&self) -> &str {
260        match self {
261            Self::Ed25519 => "ed25519",
262            Self::Other(s) => s,
263        }
264    }
265
266    /// Parse a wire label, canonicalizing known ones onto their named variant.
267    pub fn from_wire(s: &str) -> Result<Self, PeerKeyError> {
268        match s {
269            "" => Err(PeerKeyError::EmptyAlgorithm),
270            "ed25519" => Ok(Self::Ed25519),
271            other => Ok(Self::Other(other.to_owned())),
272        }
273    }
274
275    /// The exact key length this algorithm requires, when it has one.
276    /// `None` for [`Other`](Self::Other) — cheers can't police a scheme it
277    /// doesn't know.
278    pub fn expected_key_len(&self) -> Option<usize> {
279        match self {
280            Self::Ed25519 => Some(32),
281            Self::Other(_) => None,
282        }
283    }
284}
285
286impl std::fmt::Display for PeerKeyAlgorithm {
287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288        f.write_str(self.as_wire())
289    }
290}
291
292impl Serialize for PeerKeyAlgorithm {
293    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
294        ser.serialize_str(self.as_wire())
295    }
296}
297
298impl<'de> Deserialize<'de> for PeerKeyAlgorithm {
299    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
300        use serde::de::Error as _;
301        let s = String::deserialize(de)?;
302        Self::from_wire(&s).map_err(D::Error::custom)
303    }
304}
305
306/// A long-lived, client-held **public** key a session token can be bound to.
307///
308/// Raw bytes plus an algorithm discriminant — deliberately *not* an
309/// `ed25519-dalek` (or any other crypto crate's) type. Consumers straddle a
310/// real version split (iroh resolves ed25519-dalek 3.0.0-pre, other trust
311/// crates resolve 2.2), so a crypto type here would hand every consumer a
312/// version pin it cannot satisfy. cheers only ever *compares* these bytes; it
313/// never verifies a signature under them, so it needs no crypto to hold one.
314///
315/// Wire form is `{"alg": "<label>", "key": "<base64url-no-pad>"}` — the same
316/// base64url-no-pad encoding [`UserDelegation`](crate::UserDelegation) uses,
317/// which every cross-platform Ed25519 toolchain agrees on.
318///
319/// Nothing secret lives in this type (a public key is public), so equality is
320/// plain `==`, not constant-time.
321#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
322#[non_exhaustive]
323pub struct PeerKey {
324    /// Which scheme [`key`](Self::key) belongs to.
325    #[serde(rename = "alg")]
326    pub algorithm: PeerKeyAlgorithm,
327    /// The raw public key bytes. Wire form: base64url-no-pad.
328    #[serde(rename = "key", with = "peer_key_bytes_serde")]
329    pub key: Vec<u8>,
330}
331
332impl PeerKey {
333    /// Construct + validate: rejects empty bytes, and a length that disagrees
334    /// with an algorithm that names one ([`expected_key_len`]).
335    ///
336    /// [`expected_key_len`]: PeerKeyAlgorithm::expected_key_len
337    pub fn new(algorithm: PeerKeyAlgorithm, key: impl Into<Vec<u8>>) -> Result<Self, PeerKeyError> {
338        let key = key.into();
339        if key.is_empty() {
340            return Err(PeerKeyError::EmptyKey);
341        }
342        // Canonicalize here too, not just on the wire: an `Other("ed25519")`
343        // built in Rust would otherwise serialize to the same JSON as
344        // `Ed25519`, deserialize back as `Ed25519`, and compare unequal to
345        // itself — one algorithm, two representations, and a binding check
346        // that fails on a key that matches.
347        let algorithm = match algorithm {
348            PeerKeyAlgorithm::Other(label) => PeerKeyAlgorithm::from_wire(&label)?,
349            named => named,
350        };
351        if let Some(expected) = algorithm.expected_key_len() {
352            if key.len() != expected {
353                return Err(PeerKeyError::WrongKeyLength {
354                    algorithm: algorithm.as_wire().to_owned(),
355                    expected,
356                    actual: key.len(),
357                });
358            }
359        }
360        Ok(Self { algorithm, key })
361    }
362
363    /// The common case — an Ed25519 node public key. Infallible: the length
364    /// invariant is carried by the array type.
365    pub fn ed25519(key: [u8; 32]) -> Self {
366        Self {
367            algorithm: PeerKeyAlgorithm::Ed25519,
368            key: key.to_vec(),
369        }
370    }
371
372    pub fn algorithm(&self) -> &PeerKeyAlgorithm {
373        &self.algorithm
374    }
375
376    pub fn as_bytes(&self) -> &[u8] {
377        &self.key
378    }
379
380    /// The key's wire encoding (base64url-no-pad), for logs and roster files.
381    pub fn to_base64url(&self) -> String {
382        URL_SAFE_NO_PAD.encode(&self.key)
383    }
384}
385
386/// The wire shape [`PeerKey`] deserializes *through* — a structural mirror
387/// with no invariant checks, reconstructed via [`PeerKey::new`] so a
388/// hand-crafted payload can't bypass the constructor (mirrors
389/// [`Principal`](crate::Principal) / `RawPrincipal`).
390#[derive(Deserialize)]
391struct RawPeerKey {
392    alg: PeerKeyAlgorithm,
393    #[serde(with = "peer_key_bytes_serde")]
394    key: Vec<u8>,
395}
396
397impl<'de> Deserialize<'de> for PeerKey {
398    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
399        use serde::de::Error as _;
400        let raw = RawPeerKey::deserialize(de)?;
401        PeerKey::new(raw.alg, raw.key).map_err(D::Error::custom)
402    }
403}
404
405mod peer_key_bytes_serde {
406    use super::*;
407    use serde::de::Error as DeError;
408
409    pub fn serialize<S: serde::Serializer>(bytes: &[u8], ser: S) -> Result<S::Ok, S::Error> {
410        ser.serialize_str(&URL_SAFE_NO_PAD.encode(bytes))
411    }
412
413    pub fn deserialize<'de, D: serde::Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
414        let s = String::deserialize(de)?;
415        URL_SAFE_NO_PAD
416            .decode(s.as_bytes())
417            .map_err(|e| D::Error::custom(format!("invalid base64url peer key: {e}")))
418    }
419}
420
421/// Verified session claims — what a `TokenVerifier::verify` returns on success.
422///
423/// Stable shape; new fields land behind `#[non_exhaustive]`. Timestamps are
424/// unix seconds (signed to leave room for pre-epoch sentinels in tests).
425///
426/// @yah:ticket(R515-F1, "Add an optional long-lived public-key binding to cheers_core::Claims")
427/// @yah:status(review)
428/// @yah:at(2026-09-03T07:47:27Z)
429/// @yah:assignee(agent:bundle-anthropic-ashguard)
430/// @yah:parent(R515)
431/// @yah:next("WHY A NEW FIELD RATHER THAN REUSING `device`. Claims.device is a DeviceId(String) that cheers-axum mints as a freshly generated random base64url value per sign-in (magic_link.rs, google.rs, apple.rs — their unit tests assert uniqueness per call). Its job is keying the refresh chain and per-device revocation: disposable, per-sign-in, server-chosen. A society/mesh node key is the opposite — long-lived, public, client-held, and the thing a QUIC handshake already authenticates. Overloading one column with both was considered and rejected by the noisetable operator 2026-09-02: (UserId, DeviceId) is the CredentialStore key, so the overload would make a long-lived public key a credential-store key, which reads fine now and is very hard to unpick later.")
432/// @yah:next("SHAPE. Claims is #[non_exhaustive], so this is additive. Add an optional binding to a caller-supplied long-lived public key, serde-skipped when unset so the wire format stays byte-identical to a pre-field token (the same discipline `jti` already uses for the mesofact cookie contract). TAKE RAW BYTES, NOT AN ed25519-dalek TYPE: consumers straddle a real version split — iroh (under mshr) resolves ed25519-dalek 3.0.0-pre while noisetable's trust crates resolve 2.2, so a dalek type in this struct hands every consumer a version pin it cannot satisfy. Keep the field key-agnostic (bytes + an algorithm/kind discriminant) rather than naming Ed25519, since cheers is a general auth library.")
433/// @yah:next("THE CONSUMER, and the security property it buys. noisetable's society rooms (that camp's R117-T5) admit peers by an Ed25519 roster today — layer 1 of its A120 identity model. A user token is layer 2 and may only ADD a claim about WHICH HUMAN a machine key belongs to; it must never become a second admission door. For that to mean anything at the door, the signed claim has to name the peer's node public key, because the peer already proved possession of exactly that key in the QUIC/TLS handshake. Without the binding in the token, a STOLEN token replays under the attacker's own node key and they become the victim user — which is why binding-at-presentation was rejected as an alternative. Verification is offline via cheers-verify's PasetoV4PublicVerifier against a pinned issuer pubkey, so a LAN peer with no internet can still check a token minted a week ago.")
434/// @yah:gotcha("SCOPE: this ticket is the CLAIM FIELD plus mint/verify support, NOT an enrollment ceremony. Nothing in cheers-axum's existing ceremonies can populate it — a browser passkey or magic-link sign-in has no node key, because the key lives in a different process (a desktop app or a headless appliance). The flow that fills this field is a node-enrollment mint (app proves possession of key N, presents the user's existing session, receives a token binding U to N), and that lives in the CONSUMING service, not here. noisetable is filing its own ticket for that side. Ship the field so both sides can be built against it; do not try to infer the key from an HTTP ceremony.")
435/// @yah:handoff("LANDED the field + both wire halves. cheers-core/src/claims.rs: PeerKey { algorithm: PeerKeyAlgorithm, key: Vec<u8> } + PeerKeyAlgorithm { Ed25519, Other(String) } + PeerKeyError, and Claims.peer_key: Option<PeerKey> with #[serde(default, skip_serializing_if = \"Option::is_none\")] — an unset token is byte-identical to a pre-R515 one (pinned by claims_peer_key_defaults_none_and_is_omitted_from_wire). Builder Claims::with_peer_key + predicate Claims::is_bound_to(&PeerKey). All exported from cheers_core.")
436/// @yah:handoff("KEY-AGNOSTIC, AS THE TICKET REQUIRED: raw bytes + algorithm discriminant, no ed25519-dalek (or any crypto crate) type — cheers only ever COMPARES these bytes, never verifies a signature under them, so cheers-core stays crypto-free and no consumer inherits a dalek version pin. Wire form {\"alg\":\"ed25519\",\"key\":\"<base64url-no-pad>\"} reusing UserDelegation's encoding. PeerKeyAlgorithm::Other(String) is the escape hatch so a consumer isn't version-locked to a cheers release for a scheme cheers hasn't named.")
437/// @yah:handoff("MINT (cheers-server/src/session.rs): SessionAuthority::establish_bound(sub, device, binding, peer_key, now) and rotate_bound(refresh, binding, peer_key, now). Existing establish/rotate signatures unchanged — both now delegate to a private establish_inner/rotate_inner taking Option<PeerKey>, and mint_access gained the Option param. Peer key is CALLER-SUPPLIED on rotation for the same reason `binding` is: the refresh record is about which session, not how this access token is presented. So a plain rotate() yields an UNBOUND token rather than a silently stale binding, and a rekeyed node rebinds on the next rotate_bound.")
438/// @yah:handoff("DOOR (cheers-verify/src/edge.rs): EdgeVerifier::verify_bound_at(token, presented: &PeerKey, now) — signature, then binding (byte compare, still offline), then the revocation read. New Error::PeerKeyMismatch in cheers-core/src/error.rs (Error is #[non_exhaustive], so additive). An UNBOUND token fails verify_bound_at: it asserts nothing about any node key, so it can't satisfy a binding check. A deployment that also admits unbound tokens must call verify_at and branch on claims.peer_key itself — that choice is deliberately forced to the call site rather than silently passing.")
439/// @yah:handoff("BUG MY OWN TEST CAUGHT: PeerKey::new originally accepted PeerKeyAlgorithm::Other(\"ed25519\") verbatim, which serializes to the same JSON as Ed25519 and deserializes back AS Ed25519 — so a token bound that way would fail its own is_bound_to check after one wire round-trip. new() now canonicalizes through PeerKeyAlgorithm::from_wire, giving each algorithm exactly one representation. Pinned by unknown_algorithm_round_trips_but_canonicalizes_known_labels.")
440/// @yah:handoff("DOC (in scope, not scope creep): .yah/docs/working/edge-verifiable-auth.md gained §6 \"Peer-key binding — the token names the key, not the connection (R515)\". Needed because that doc's §5 says \"guide by omission — do NOT add a field to Claims\", and without §6 the next reader parses this ticket as violating its own arch:see doc. §6 states the distinction (routing metadata is about where data lives; a peer key is about who holds the token — that is identity), why binding-at-presentation was rejected (needs a live round-trip + challenge cache, exactly the coordination the locality contract buys freedom from), the mint/door surface, and the layering rule that a bound token is layer 2 and never a second admission door.")
441/// @yah:handoff("SCOPE HELD as the ticket's gotcha demanded: no enrollment ceremony, and cheers-axum is untouched. Nothing in cheers establishes that a caller POSSESSES the key it asks to bind — that proof is the consuming service's node-enrollment mint. cheers ships the field and both sides of the wire so noisetable's side can be built against it.")
442/// @yah:verify("cargo test -p cheers-core -p cheers-server -p cheers-verify — GREEN: cheers-core 70 passed (5 new peer-key tests), cheers-server 139 passed (3 new end-to-end tests), cheers-verify green, 0 failed anywhere. The 3 server tests run the real rig (PasetoV4SecretMinter origin + PasetoV4PublicVerifier edge), so the binding is proven to survive the SIGNED token, not just the in-memory struct.")
443/// @yah:verify("The security property is pinned, not just the plumbing: establish_bound_survives_the_wire_and_proves_holder_is_the_peer replays a valid bound token under a DIFFERENT node key and asserts Error::PeerKeyMismatch (the stolen-token case), then revokes the jti and asserts Error::Revoked for a correctly-bound token (binding is layer 2, revocation still kills). unbound_token_is_wire_compatible_but_fails_a_binding_check asserts an unbound token still passes plain verify_at (no regression for existing consumers) but fails verify_bound_at.")
444/// @yah:verify("Wire compatibility with pre-R515 tokens: claims_peer_key_defaults_none_and_is_omitted_from_wire asserts the string \"peer_key\" is absent from an unset token's JSON and that such JSON round-trips back equal. No golden fixture needed rebasing — cheers-test-support/fixtures/*.claims.json are McpClaims (a different shape), which this ticket does not touch.")
445/// @yah:gotcha("PeerKey's fields are `pub` (matching UserDelegation / Principal house style: pub fields + validating constructor + validating Deserialize via a Raw mirror). #[non_exhaustive] blocks struct-literal construction, so every value enters through PeerKey::new or ::ed25519 — but a holder CAN still mutate `key` in place and break the ed25519-is-32-bytes invariant after construction. Consequence is a failed comparison (fail-closed), never an accepted forgery, so it was left consistent with the crate rather than hidden behind accessors.")
446/// @yah:gotcha("NOT DONE, deliberately, and the consumer needs to know: McpClaims (cheers-core/src/mcp.rs — the MCP-token shape) did NOT get a peer_key. This ticket named cheers_core::Claims, the session shape, and that is what noisetable's society door verifies. If an MCP-call token ever needs the same holder==peer proof, it is a separate additive field on McpClaims, not a change here.")
447/// @yah:gotcha("This monorepo builds cheers via PATH deps at version 0.8.31 (crates/yah/cloud-admin, app/yah/cli), so the new field ships to those consumers on the next build, not on a crates.io release. Nothing breaks — Claims and Error are both #[non_exhaustive], so no external crate can struct-literal Claims or match Error exhaustively, and the only external construction site is a 5-arg Claims::new in oss/mesofact/crates/mesofact-core/tests/proxy.rs, whose signature is unchanged.")
448/// @yah:verify("Whole-workspace regression sweep, not just the touched crates: cargo test --workspace from oss/cheers — 560 passed, 0 failed across 33 test binaries (cheers, cheers-axum, cheers-sqlx, cheers-turso, cheers-redis, cheers-store, cheers-test-support and the three touched crates), doctests included. Nothing in the untouched crates regressed on the new Claims field.")
449/// @yah:verify("NOT VERIFIED BY BUILD, stated rather than hedged: the cross-workspace consumer check (cargo check -p yah-cloud-admin, which pulls cheers-core/-verify/-server by path from the root monorepo workspace) sat \"Blocking waiting for file lock on build directory\" for ~15min behind peer sessions and never got the root target dir. The argument it would have confirmed is structural, not empirical: this change removes nothing and alters no signature, Claims and Error are both #[non_exhaustive] (so no external crate can struct-literal Claims or match Error exhaustively), cloud-admin imports cheers_core by explicit name (`use cheers_core::{McpClaims, Scope}`) not a glob, and the only external Claims construction site in the monorepo is a 5-arg Claims::new in oss/mesofact/crates/mesofact-core/tests/proxy.rs. Re-run that check when the camp's root target is free.")
450/// @yah:handoff("DONE: the claim field plus both wire halves. cheers-core gains PeerKey/PeerKeyAlgorithm/PeerKeyError and Claims.peer_key: Option<PeerKey> (serde-skipped when unset, so a pre-R515 token is byte-identical) with with_peer_key + is_bound_to; cheers-server gains SessionAuthority::establish_bound / rotate_bound; cheers-verify gains EdgeVerifier::verify_bound_at + Error::PeerKeyMismatch. Key-agnostic bytes + algorithm discriminant, no dalek type, so no consumer inherits a version pin. Enrollment ceremony deliberately NOT built — that is the consuming service's, per the ticket's scope gotcha.")
451#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
452#[non_exhaustive]
453pub struct Claims {
454    pub sub: UserId,
455    pub device: DeviceId,
456    pub binding: DeviceBinding,
457    pub issued_at: i64,
458    pub expires_at: i64,
459    /// Unique token id — the key the revocation set is keyed on (R019-F4).
460    ///
461    /// Empty means *unset / not individually revocable*; sessions minted through
462    /// `cheers-server`'s `SessionAuthority` get a fresh value via
463    /// [`with_jti`](Self::with_jti). `#[serde(default, skip_serializing_if)]`
464    /// keeps the wire format byte-identical to a pre-`jti` token when unset —
465    /// important for the mesofact cookie contract.
466    #[serde(default, skip_serializing_if = "String::is_empty")]
467    pub jti: String,
468    /// Optional binding to a long-lived, client-held public key (R515).
469    ///
470    /// When set, the token asserts *"the holder of this key is
471    /// [`sub`](Self::sub)"* — so a verifier that has already authenticated the
472    /// connecting peer under that same key (a QUIC/TLS handshake does exactly
473    /// this) can prove **token holder == connecting peer**, and a stolen token
474    /// replayed under the thief's own key fails
475    /// [`is_bound_to`](Self::is_bound_to).
476    ///
477    /// Distinct from [`device`](Self::device) on purpose: a `DeviceId` is a
478    /// freshly generated, server-chosen, per-sign-in value that keys the
479    /// refresh chain and per-device revocation. A peer key is the opposite —
480    /// long-lived, client-held, public, and already proven in the transport.
481    /// `(UserId, DeviceId)` is the `CredentialStore` key, so overloading it
482    /// with a node key would make a long-lived public key a credential-store
483    /// key.
484    ///
485    /// `#[serde(default, skip_serializing_if)]` keeps the wire format
486    /// byte-identical to a pre-`peer_key` token when unset — the same
487    /// discipline [`jti`](Self::jti) uses for the mesofact cookie contract.
488    ///
489    /// Nothing in cheers's own HTTP ceremonies populates this: a browser
490    /// passkey or magic-link sign-in has no node key, because the key lives in
491    /// a different process. The flow that fills it is a node-enrollment mint —
492    /// the app proves possession of key `N`, presents the user's existing
493    /// session, and receives a token binding `U` to `N` — and that ceremony
494    /// belongs to the consuming service. See
495    /// [`SessionAuthority::establish_bound`] in `cheers-server` for the mint
496    /// side and `EdgeVerifier::verify_bound_at` in `cheers-verify` for the
497    /// door side.
498    ///
499    /// [`SessionAuthority::establish_bound`]: https://docs.rs/cheers-server
500    #[serde(default, skip_serializing_if = "Option::is_none")]
501    pub peer_key: Option<PeerKey>,
502}
503
504impl Claims {
505    pub fn new(
506        sub: UserId,
507        device: DeviceId,
508        binding: DeviceBinding,
509        issued_at: i64,
510        expires_at: i64,
511    ) -> Self {
512        Self {
513            sub,
514            device,
515            binding,
516            issued_at,
517            expires_at,
518            jti: String::new(),
519            peer_key: None,
520        }
521    }
522
523    /// Attach a `jti` (the revocation key). Builder-style so existing
524    /// five-arg [`new`](Self::new) call sites are unaffected.
525    pub fn with_jti(mut self, jti: impl Into<String>) -> Self {
526        self.jti = jti.into();
527        self
528    }
529
530    /// Bind these claims to a long-lived peer public key. Builder-style, for
531    /// the same reason [`with_jti`](Self::with_jti) is.
532    pub fn with_peer_key(mut self, peer_key: PeerKey) -> Self {
533        self.peer_key = Some(peer_key);
534        self
535    }
536
537    /// `true` iff these claims name **exactly** `presented` as their peer key.
538    ///
539    /// An *unbound* token returns `false`: it makes no claim about any node
540    /// key, so it is not bound to this one. That is a deliberate fail-closed —
541    /// a caller that accepts unbound tokens (say, a browser session that never
542    /// had a node key) must say so explicitly by checking
543    /// [`peer_key`](Self::peer_key) itself, rather than getting a silent pass
544    /// from a "binding" check.
545    pub fn is_bound_to(&self, presented: &PeerKey) -> bool {
546        self.peer_key.as_ref() == Some(presented)
547    }
548
549    /// `true` if `expires_at` is at or before `now` (unix seconds).
550    pub fn is_expired_at(&self, now: i64) -> bool {
551        self.expires_at <= now
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn user_id_roundtrips_through_string() {
561        let u = UserId::new("alice");
562        assert_eq!(u.as_str(), "alice");
563        assert_eq!(u.to_string(), "alice");
564        assert_eq!(UserId::from("alice"), u);
565    }
566
567    #[test]
568    fn user_id_serde_is_transparent() {
569        let u = UserId::new("u-123");
570        let json = serde_json::to_string(&u).unwrap();
571        assert_eq!(json, "\"u-123\"");
572        let back: UserId = serde_json::from_str(&json).unwrap();
573        assert_eq!(back, u);
574    }
575
576    #[test]
577    fn device_binding_serializes_with_kind_tag() {
578        let b = DeviceBinding::OidcGeneric {
579            issuer: "https://idp.example".into(),
580        };
581        let json = serde_json::to_string(&b).unwrap();
582        assert!(json.contains("\"kind\":\"oidc_generic\""));
583        assert!(json.contains("\"issuer\":\"https://idp.example\""));
584        let back: DeviceBinding = serde_json::from_str(&json).unwrap();
585        assert_eq!(back, b);
586
587        let unit = DeviceBinding::Passkey;
588        let json = serde_json::to_string(&unit).unwrap();
589        assert_eq!(json, "{\"kind\":\"passkey\"}");
590    }
591
592    #[test]
593    fn user_builder_sets_optional_fields() {
594        let u = User::new(UserId::new("u1"))
595            .with_email("a@b")
596            .with_name("Alice");
597        assert_eq!(u.email.as_deref(), Some("a@b"));
598        assert_eq!(u.name.as_deref(), Some("Alice"));
599    }
600
601    #[test]
602    fn claims_expiry_check() {
603        let c = Claims::new(
604            UserId::new("u1"),
605            DeviceId::new("d1"),
606            DeviceBinding::Passkey,
607            100,
608            200,
609        );
610        assert!(!c.is_expired_at(199));
611        assert!(c.is_expired_at(200));
612        assert!(c.is_expired_at(201));
613    }
614
615    #[test]
616    fn claims_jti_defaults_empty_and_omitted_from_wire() {
617        let c = Claims::new(
618            UserId::new("u1"),
619            DeviceId::new("d1"),
620            DeviceBinding::Passkey,
621            100,
622            200,
623        );
624        assert_eq!(c.jti, "");
625        // Unset jti must not appear on the wire — keeps the cookie format
626        // identical to a pre-jti token.
627        let json = serde_json::to_string(&c).unwrap();
628        assert!(!json.contains("jti"), "empty jti must be skipped: {json}");
629
630        let c = c.with_jti("tok-123");
631        assert_eq!(c.jti, "tok-123");
632        let json = serde_json::to_string(&c).unwrap();
633        assert!(json.contains("\"jti\":\"tok-123\""));
634        let back: Claims = serde_json::from_str(&json).unwrap();
635        assert_eq!(back, c);
636    }
637
638    #[test]
639    fn claims_roundtrip_json() {
640        let c = Claims::new(
641            UserId::new("u1"),
642            DeviceId::new("d1"),
643            DeviceBinding::OidcGeneric {
644                issuer: "https://idp".into(),
645            },
646            100,
647            200,
648        );
649        let json = serde_json::to_string(&c).unwrap();
650        let back: Claims = serde_json::from_str(&json).unwrap();
651        assert_eq!(back, c);
652    }
653
654    // ---- R515: peer-key binding ------------------------------------------
655
656    fn node_key(byte: u8) -> PeerKey {
657        PeerKey::ed25519([byte; 32])
658    }
659
660    #[test]
661    fn peer_key_wire_shape_is_alg_plus_base64url() {
662        let k = node_key(0xAB);
663        let json = serde_json::to_string(&k).unwrap();
664        assert_eq!(
665            json,
666            format!(
667                "{{\"alg\":\"ed25519\",\"key\":\"{}\"}}",
668                URL_SAFE_NO_PAD.encode([0xABu8; 32])
669            )
670        );
671        let back: PeerKey = serde_json::from_str(&json).unwrap();
672        assert_eq!(back, k);
673        assert_eq!(back.as_bytes(), &[0xABu8; 32]);
674        assert_eq!(back.to_base64url(), URL_SAFE_NO_PAD.encode([0xABu8; 32]));
675    }
676
677    #[test]
678    fn peer_key_rejects_empty_and_wrong_length() {
679        assert_eq!(
680            PeerKey::new(PeerKeyAlgorithm::Ed25519, Vec::new()),
681            Err(PeerKeyError::EmptyKey)
682        );
683        assert_eq!(
684            PeerKey::new(PeerKeyAlgorithm::Ed25519, vec![1u8; 31]),
685            Err(PeerKeyError::WrongKeyLength {
686                algorithm: "ed25519".into(),
687                expected: 32,
688                actual: 31,
689            })
690        );
691        // …and the same check runs on the wire, so a hand-crafted payload
692        // can't smuggle a short "ed25519" key past the constructor.
693        let json = format!(
694            "{{\"alg\":\"ed25519\",\"key\":\"{}\"}}",
695            URL_SAFE_NO_PAD.encode([1u8; 31])
696        );
697        let err = serde_json::from_str::<PeerKey>(&json).unwrap_err();
698        assert!(
699            err.to_string().contains("must be 32 bytes"),
700            "unexpected error: {err}"
701        );
702    }
703
704    #[test]
705    fn unknown_algorithm_round_trips_but_canonicalizes_known_labels() {
706        // cheers is key-agnostic: a scheme it doesn't name still rides.
707        let k = PeerKey::new(PeerKeyAlgorithm::Other("p256".into()), vec![7u8; 65]).unwrap();
708        let json = serde_json::to_string(&k).unwrap();
709        assert!(json.contains("\"alg\":\"p256\""));
710        assert_eq!(serde_json::from_str::<PeerKey>(&json).unwrap(), k);
711
712        // But one algorithm gets exactly one representation — otherwise a
713        // token minted with Other("ed25519") would fail its own binding check
714        // after a wire round-trip.
715        let spelled_out =
716            PeerKey::new(PeerKeyAlgorithm::Other("ed25519".into()), vec![9u8; 32]).unwrap();
717        assert_eq!(spelled_out.algorithm(), &PeerKeyAlgorithm::Ed25519);
718        assert_eq!(spelled_out, PeerKey::ed25519([9u8; 32]));
719
720        assert_eq!(
721            PeerKey::new(PeerKeyAlgorithm::Other(String::new()), vec![1u8; 4]),
722            Err(PeerKeyError::EmptyAlgorithm)
723        );
724        let err = serde_json::from_str::<PeerKey>("{\"alg\":\"\",\"key\":\"AQID\"}").unwrap_err();
725        assert!(
726            err.to_string().contains("non-empty"),
727            "unexpected error: {err}"
728        );
729    }
730
731    #[test]
732    fn claims_peer_key_defaults_none_and_is_omitted_from_wire() {
733        let c = Claims::new(
734            UserId::new("u1"),
735            DeviceId::new("d1"),
736            DeviceBinding::Passkey,
737            100,
738            200,
739        );
740        assert_eq!(c.peer_key, None);
741        // Unset must not appear on the wire — a pre-R515 token is byte-identical.
742        let json = serde_json::to_string(&c).unwrap();
743        assert!(
744            !json.contains("peer_key"),
745            "unset peer_key must be skipped: {json}"
746        );
747        // …and a pre-R515 token still deserializes into the new shape.
748        let back: Claims = serde_json::from_str(&json).unwrap();
749        assert_eq!(back, c);
750    }
751
752    #[test]
753    fn claims_roundtrip_with_peer_key() {
754        let c = Claims::new(
755            UserId::new("u1"),
756            DeviceId::new("d1"),
757            DeviceBinding::Passkey,
758            100,
759            200,
760        )
761        .with_jti("tok-1")
762        .with_peer_key(node_key(0x11));
763        let json = serde_json::to_string(&c).unwrap();
764        assert!(json.contains("\"peer_key\":{\"alg\":\"ed25519\""), "{json}");
765        let back: Claims = serde_json::from_str(&json).unwrap();
766        assert_eq!(back, c);
767        assert!(back.is_bound_to(&node_key(0x11)));
768    }
769
770    #[test]
771    fn is_bound_to_matches_only_the_exact_key() {
772        let bound = Claims::new(
773            UserId::new("u1"),
774            DeviceId::new("d1"),
775            DeviceBinding::Passkey,
776            100,
777            200,
778        )
779        .with_peer_key(node_key(0x11));
780
781        assert!(bound.is_bound_to(&node_key(0x11)));
782        // A stolen token replayed under the thief's own node key.
783        assert!(!bound.is_bound_to(&node_key(0x22)));
784        // Same bytes, different scheme — no cross-algorithm confusion.
785        let same_bytes_other_alg =
786            PeerKey::new(PeerKeyAlgorithm::Other("p256".into()), vec![0x11u8; 32]).unwrap();
787        assert!(!bound.is_bound_to(&same_bytes_other_alg));
788
789        // An unbound token is bound to nothing — fail closed.
790        let unbound = Claims::new(
791            UserId::new("u1"),
792            DeviceId::new("d1"),
793            DeviceBinding::Passkey,
794            100,
795            200,
796        );
797        assert!(!unbound.is_bound_to(&node_key(0x11)));
798    }
799
800    #[test]
801    fn credential_holds_opaque_material() {
802        let cred = Credential::new(
803            UserId::new("u1"),
804            DeviceId::new("d1"),
805            DeviceBinding::EmailPassword,
806            b"argon2id$...".to_vec(),
807        );
808        assert_eq!(cred.material, b"argon2id$...");
809    }
810}