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:
@@ -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<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)]
|
||||
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::<PairAttachClaims>(bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user