diff --git a/crates/maestro-daemon/src/jwt.rs b/crates/maestro-daemon/src/jwt.rs index 5a812db..2e5c5bd 100644 --- a/crates/maestro-daemon/src/jwt.rs +++ b/crates/maestro-daemon/src/jwt.rs @@ -6,7 +6,7 @@ use std::collections::HashSet; use std::sync::{Arc, RwLock}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use maestro_protocol::{JwtClaims, PairAttachClaims}; +use maestro_protocol::{JwtClaims, PairAttachClaims, REGISTRY_ISSUER}; use uuid::Uuid; #[derive(Debug, thiserror::Error)] @@ -130,6 +130,10 @@ impl PairAttachVerifier { // Require explicit audience match — the jsonwebtoken crate validates // `aud` against this set on decode. validation.set_audience(&[self.daemon_id.as_str()]); + // Bind to the registry issuer so a differently-purposed token signed + // with the same shared secret (e.g. an auth cookie) can't validate as a + // pair-attach credential (NAP-266). + validation.set_issuer(&[REGISTRY_ISSUER]); let data = decode::(token, &self.decoding, &validation)?; let claims = data.claims; if claims.aud != self.daemon_id { @@ -211,7 +215,7 @@ mod tests { fn pair_claims(home: &str, target: &str, sid: Uuid, valid_seconds: i64) -> PairAttachClaims { PairAttachClaims { - iss: "registry".into(), + iss: REGISTRY_ISSUER.into(), sub: home.into(), aud: target.into(), sid, @@ -268,6 +272,20 @@ mod tests { assert!(v.verify(&token, sid).is_err()); } + #[test] + fn pair_verifier_rejects_foreign_issuer() { + // NAP-266: a token signed with the right secret but a non-registry + // `iss` (e.g. minted for some other purpose) must not validate as a + // pair-attach credential. + let secret = "s"; + let v = PairAttachVerifier::new(secret, "konishi", ["laptop".into()]); + let sid = Uuid::new_v4(); + let mut claims = pair_claims("laptop", "konishi", sid, 60); + claims.iss = "somebody-else".into(); + let token = issue_pair(secret, &claims); + assert!(v.verify(&token, sid).is_err()); + } + #[test] fn pair_verifier_rejects_session_id_mismatch() { let secret = "s"; diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index 870fb54..8191ea8 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -372,6 +372,12 @@ pub struct JwtClaims { pub iat: Option, } +/// `iss` claim baked into every registry-issued JWT (pair-attach credentials +/// and auth cookies alike). Verifiers bind to it so a token minted for one +/// purpose can't be replayed as another even though both share the signing +/// secret (NAP-266). +pub const REGISTRY_ISSUER: &str = "maestro-registry"; + /// 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. diff --git a/crates/maestro-registry/src/auth.rs b/crates/maestro-registry/src/auth.rs index 31d5aa4..72a2534 100644 --- a/crates/maestro-registry/src/auth.rs +++ b/crates/maestro-registry/src/auth.rs @@ -25,6 +25,7 @@ use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use chrono::{Duration, Utc}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use maestro_protocol::REGISTRY_ISSUER; use serde::{Deserialize, Serialize}; /// Name of the auth cookie surfaced to web clients. @@ -34,8 +35,18 @@ pub const COOKIE_NAME: &str = "maestro_session"; /// short enough to bound the blast radius if the secret rotates. const COOKIE_TTL_DAYS: i64 = 30; +/// `aud` claim that distinguishes a registry auth cookie from every other +/// registry-signed JWT (pair-attach credentials carry a daemon-id audience). +/// Verifiers bind to it so a token minted for another purpose can't be replayed +/// as a cookie even though both share the signing secret (NAP-266). +const COOKIE_AUDIENCE: &str = "maestro-cookie"; + #[derive(Debug, Clone, Serialize, Deserialize)] struct CookieClaims { + /// Issuer — always [`REGISTRY_ISSUER`]. + iss: String, + /// Audience — always [`COOKIE_AUDIENCE`]. + aud: String, /// Subject — currently always `"client"` because this is single-user. sub: String, exp: i64, @@ -46,6 +57,8 @@ struct CookieClaims { pub fn issue_cookie(secret: &str) -> String { let now = Utc::now(); let claims = CookieClaims { + iss: REGISTRY_ISSUER.into(), + aud: COOKIE_AUDIENCE.into(), sub: "client".into(), iat: now.timestamp(), exp: (now + Duration::days(COOKIE_TTL_DAYS)).timestamp(), @@ -55,10 +68,14 @@ pub fn issue_cookie(secret: &str) -> String { encode(&header, &claims, &key).expect("encode auth cookie") } -/// Returns true if `value` is a JWT validly signed by `secret`. +/// Returns true if `value` is a JWT validly signed by `secret` *and* bound to +/// this cookie's issuer + audience — so a pair-attach JWT (same secret, +/// different `aud`) is rejected here (NAP-266). pub fn verify_cookie(secret: &str, value: &str) -> bool { let key = DecodingKey::from_secret(secret.as_bytes()); - let validation = Validation::new(Algorithm::HS256); + let mut validation = Validation::new(Algorithm::HS256); + validation.set_issuer(&[REGISTRY_ISSUER]); + validation.set_audience(&[COOKIE_AUDIENCE]); decode::(value, &key, &validation).is_ok() } @@ -144,6 +161,20 @@ mod tests { assert!(!verify_cookie("different-secret", &value)); } + /// NAP-266: a pair-attach JWT is signed with the same shared secret but is + /// bound to a daemon-id audience, so it must NOT validate as an auth cookie. + #[test] + fn verify_cookie_rejects_pair_attach_jwt() { + let secret = "shared-secret-bbbb"; + let pair = + crate::jwt::issue_pair_attach_jwt(secret, "laptop", "konishi", uuid::Uuid::new_v4()) + .expect("issue pair jwt"); + assert!( + !verify_cookie(secret, &pair), + "a pair-attach JWT must not be accepted as an auth cookie" + ); + } + #[test] fn extract_cookie_finds_named_value() { let h = "foo=1; maestro_session=abc.def.ghi; bar=2"; diff --git a/crates/maestro-registry/src/jwt.rs b/crates/maestro-registry/src/jwt.rs index d96c8af..73b26c0 100644 --- a/crates/maestro-registry/src/jwt.rs +++ b/crates/maestro-registry/src/jwt.rs @@ -14,9 +14,9 @@ pub const ATTACH_JWT_TTL: Duration = Duration::minutes(5); /// Pair-scoped attach JWT TTL — short-lived per the connection-orchestration /// spec (60 s default; ample window for the home daemon to dial T2). pub const PAIR_ATTACH_JWT_TTL: Duration = Duration::seconds(60); -/// `iss` claim baked into every registry-issued pair JWT. Plain string for -/// now — once the registry gets a per-instance identity, plumb that in here. -pub const REGISTRY_ISSUER: &str = "maestro-registry"; +/// `iss` claim baked into every registry-issued pair JWT. Canonical value +/// lives in `maestro-protocol` so issuers and verifiers can't drift (NAP-266). +pub use maestro_protocol::REGISTRY_ISSUER; /// Mint a short-lived attach JWT for `(daemon_id, session_id)`. pub fn issue_attach_jwt(