feat(maestro): add PairAttachClaims JWT shape (NAP-242)
ci / rust (push) Failing after 10m43s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 17:46:43 +00:00
co-authored by Claude Opus 4.7
parent 9a8109559b
commit 45795a144a
+57
View File
@@ -300,6 +300,9 @@ pub enum ClientControl {
/// Issued by the registry, validated locally by the daemon. Short-lived /// Issued by the registry, validated locally by the daemon. Short-lived
/// (~5 min). The daemon checks `daemon_id` matches itself and `session_id` /// (~5 min). The daemon checks `daemon_id` matches itself and `session_id`
/// is one of its live sessions. /// 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JwtClaims { pub struct JwtClaims {
pub session_id: Uuid, pub session_id: Uuid,
@@ -311,6 +314,27 @@ pub struct JwtClaims {
pub iat: Option<i64>, pub iat: Option<i64>,
} }
/// 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -762,4 +786,37 @@ mod tests {
let parsed: JwtClaims = serde_json::from_value(no_iat).unwrap(); let parsed: JwtClaims = serde_json::from_value(no_iat).unwrap();
assert!(parsed.iat.is_none()); 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::<PairAttachClaims>(bad).is_err());
}
} }