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}