fix(maestro): bind registry JWTs to issuer/audience to stop token confusion (NAP-266)
ci / rust (push) Failing after 6m0s
ci / rust (push) Failing after 6m0s
Cookie and pair-attach JWTs are signed with the same shared secret. Neither verifier bound the token's purpose, so a token minted for one could in principle validate as the other. - Add a canonical `REGISTRY_ISSUER` to maestro-protocol; registry re-exports it. - Auth cookies now carry `iss = maestro-registry` + a dedicated `aud = maestro-cookie`; `verify_cookie` binds both, so a pair-attach JWT (daemon-id audience) is rejected. - `PairAttachVerifier::verify` now binds `iss = REGISTRY_ISSUER`, rejecting any token not minted by the registry. Tests: pair-verifier rejects a foreign issuer; verify_cookie rejects a pair-attach JWT. Existing cookie + broker e2e flows unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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::<PairAttachClaims>(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";
|
||||
|
||||
@@ -372,6 +372,12 @@ pub struct JwtClaims {
|
||||
pub iat: Option<i64>,
|
||||
}
|
||||
|
||||
/// `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.
|
||||
|
||||
@@ -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::<CookieClaims>(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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user