From 45795a144a705098215125634540ddcce18c76a4 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Tue, 26 May 2026 17:46:43 +0000 Subject: [PATCH] feat(maestro): add PairAttachClaims JWT shape (NAP-242) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iss/sub/aud/sid/exp claims for the broker-mediated attach flow. "Pair-scoped" means the credential identifies which home daemon (sub) may attach to which target daemon (aud) for which session (sid) — a stolen token only works between the two daemons it was issued for. Existing JwtClaims stays as a compat path; NAP-243 wires the new claims into the daemon's verifier and the registry's issuer. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/maestro-protocol/src/lib.rs | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 66cd438..98215af 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -300,6 +300,9 @@ pub enum ClientControl { /// Issued by the registry, validated locally by the daemon. Short-lived /// (~5 min). The daemon checks `daemon_id` matches itself and `session_id` /// is one of its live sessions. +/// +/// Superseded by [`PairAttachClaims`] for the broker-mediated attach flow +/// (NAP-242). Retained as a compat path while clients migrate. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct JwtClaims { pub session_id: Uuid, @@ -311,6 +314,27 @@ pub struct JwtClaims { pub iat: Option, } +/// Pair-scoped attach credential. Issued by the registry inside +/// [`AttachCandidates::jwt`]; verified by the target daemon for direct +/// transports (T1/T2) and gated by `allow_inbound` after JWT verification. +/// +/// "Pair-scoped" means: `sub` (home daemon) may initiate one attach to +/// `aud` (target daemon) for session `sid`, valid until `exp`. A stolen +/// token still only works between the two daemons it was issued for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PairAttachClaims { + /// Issuer — registry id. + pub iss: String, + /// Subject — home daemon-id (the daemon initiating attach). + pub sub: String, + /// Audience — target daemon-id (the daemon owning the session). + pub aud: String, + /// Session id this credential is scoped to. + pub sid: Uuid, + /// Expiry (unix seconds). Short-lived; v1 default 60s. + pub exp: i64, +} + #[cfg(test)] mod tests { use super::*; @@ -762,4 +786,37 @@ mod tests { let parsed: JwtClaims = serde_json::from_value(no_iat).unwrap(); assert!(parsed.iat.is_none()); } + + #[test] + fn pair_attach_claims_round_trip() { + let c = PairAttachClaims { + iss: "registry.naps62".into(), + sub: "laptop".into(), + aud: "konishi".into(), + sid: Uuid::from_u128(7), + exp: 1_700_000_060, + }; + assert_eq!(c, round_trip(&c)); + + // Standard JWT short claim names — keep them on the wire as-is so the + // existing `jsonwebtoken` validators don't need a custom name map. + let v = serde_json::to_value(&c).unwrap(); + assert_eq!(v["iss"], "registry.naps62"); + assert_eq!(v["sub"], "laptop"); + assert_eq!(v["aud"], "konishi"); + assert_eq!(v["exp"], 1_700_000_060_i64); + } + + #[test] + fn pair_attach_claims_reject_missing_audience() { + // aud is mandatory — without it a token isn't pair-scoped at all and + // the target daemon couldn't verify itself as the intended recipient. + let bad = serde_json::json!({ + "iss": "registry.naps62", + "sub": "laptop", + "sid": Uuid::from_u128(7), + "exp": 1_700_000_060_i64, + }); + assert!(serde_json::from_value::(bad).is_err()); + } }