fix(maestro): bind registry JWTs to issuer/audience to stop token confusion (NAP-266)
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:
2026-05-31 22:44:25 +00:00
co-authored by Claude Opus 4.8
parent 2f4dde85ac
commit 939d67615a
4 changed files with 62 additions and 7 deletions
+20 -2
View File
@@ -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";
+6
View File
@@ -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.
+33 -2
View File
@@ -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";
+3 -3
View File
@@ -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(