From d57b9fbcb7aefe25655f57b4e6b0867af30954c1 Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sun, 27 Sep 2026 10:36:55 +0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(rds-core):=20grant=20payload=20v3=20?= =?UTF-8?q?=E2=80=94=20tenant/policy=20claims=20and=20sync=20path=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrantPayload advances to version 3. The signature domain tracks the payload version (rds/capability-grant/v3), the decoder dispatches on the leading version varint and still accepts v2 bytes — estate-minted v2 grants verify with all new claims absent, so the format cutover does not strand outstanding leases. New claims: tenant binds a grant to an estate tenant identifier (<=64 bytes, no control/whitespace), policy_revision records the estate policy revision the issuer minted under, and constraints.sync_paths scopes Sync reads and writes to <=64 normalized relative subtree entries (relative, nonempty, no traversal or NUL — checked at decode). Renewal cannot change either claim or the path scope: a change is an InvalidRenewal and requires fresh authorization. Agents pin the deployment binding with authority.tenant / policy_min_revision (flags --tenant/--policy-min-revision). Post-verify authorization refuses unscoped, mismatched or below-floor grants with typed TenantMismatch/PolicyRevisionStale errors; pinning a binding with no trusted issuers is refused at preflight since it could never evaluate. Access carries the signed path scope into the sync engine: both Offer and Request check the normalized rel_path as a subtree match after check_rel_path and before any filesystem handle, manifest or journal work, refusing by name without filesystem detail. Tests pin the version contract (v3 verify, genuine v2 decode, a v3 payload relabeled v2 rejected, unsupported versions refused, claim bounds, renewal preservation), the settings merge/validation rules, and a real-agent e2e lane covering tenant refusal, revision floor, read and write scope denial (including traversal and component-boundary lookalikes), no filesystem side effects, and an in-scope pull. --- crates/rds-agent/src/authz.rs | 17 + crates/rds-agent/src/lib.rs | 47 +++ crates/rds-agent/src/main.rs | 12 + crates/rds-agent/src/settings.rs | 71 ++++ crates/rds-agent/tests/e2e.rs | 2 + crates/rds-agent/tests/grant_scopes.rs | 148 +++++++ crates/rds-core/src/grant.rs | 524 ++++++++++++++++++++++++- crates/rds-sync/src/engine.rs | 26 +- 8 files changed, 831 insertions(+), 16 deletions(-) diff --git a/crates/rds-agent/src/authz.rs b/crates/rds-agent/src/authz.rs index b635b39..6d2c23b 100644 --- a/crates/rds-agent/src/authz.rs +++ b/crates/rds-agent/src/authz.rs @@ -476,6 +476,21 @@ pub(crate) async fn authorize( return Err(error.into()); } }; + // Deployment binding: when policy pins a tenant or a policy-revision + // floor, the grant must carry matching v3 claims. Version-2 grants carry + // no claims and therefore fail any pinned binding. + if let Some(tenant) = &policy.tenant + && verified.tenant() != Some(tenant.as_str()) + { + rds_observe::request_refused(rds_observe::Reason::Denied); + return Err(grant::GrantError::TenantMismatch.into()); + } + if let Some(floor) = policy.min_policy_revision + && verified.policy_revision().is_none_or(|r| r < floor) + { + rds_observe::request_refused(rds_observe::Reason::Denied); + return Err(grant::GrantError::PolicyRevisionStale.into()); + } let id = verified.id; let next = if renewal { Some( @@ -913,6 +928,8 @@ mod tests { not_before: 100, expires_at: 160, constraints: Default::default(), + tenant: None, + policy_revision: None, }, ) .verify( diff --git a/crates/rds-agent/src/lib.rs b/crates/rds-agent/src/lib.rs index a76c2c2..3c869ef 100644 --- a/crates/rds-agent/src/lib.rs +++ b/crates/rds-agent/src/lib.rs @@ -127,6 +127,14 @@ pub struct AgentPolicy { pub services: Option>, /// Admission and greeting deadlines applied per connection/stream. pub timeouts: TimeoutPolicy, + /// Tenant this agent belongs to (v3 grant claim). `Some` pins the + /// deployment: every grant must carry the same `tenant` claim — + /// unscoped and mismatched grants are refused at authorization. + pub tenant: Option, + /// Minimum estate policy revision a grant must claim (v3). `Some` + /// retires grants minted before a policy change without waiting for + /// their expiry or a revocation snapshot. + pub min_policy_revision: Option, } impl std::fmt::Debug for AgentPolicy { @@ -140,6 +148,8 @@ impl std::fmt::Debug for AgentPolicy { .field("sync_dir", &self.sync_dir) .field("services", &self.services) .field("timeouts", &self.timeouts) + .field("tenant", &self.tenant) + .field("min_policy_revision", &self.min_policy_revision) .finish_non_exhaustive() } } @@ -157,6 +167,8 @@ impl AgentPolicy { sync_dir: None, services: None, timeouts: TimeoutPolicy::default(), + tenant: None, + min_policy_revision: None, } } @@ -246,6 +258,22 @@ impl AgentPolicy { { return Err("timeouts must be between 1 and 3600 seconds"); } + if let Some(tenant) = &self.tenant + && (tenant.is_empty() + || tenant.len() > rds_core::grant::MAX_TENANT_LEN + || tenant.bytes().any(|b| b < 0x21 || b == 0x7f)) + { + return Err( + "tenant must be nonempty, at most 64 bytes and free of control or whitespace bytes", + ); + } + // A pinned binding with no trusted issuers never evaluates: grants + // are not required, so every connection would pass unscoped. Fail + // loudly instead of silently dropping the deployment's binding. + if self.issuers.is_empty() && (self.tenant.is_some() || self.min_policy_revision.is_some()) + { + return Err("tenant/policy-revision binding requires grant issuers"); + } Ok(()) } @@ -827,9 +855,28 @@ async fn serve_stream( let access = grant .as_ref() .map_or(rds_sync::engine::Access::READ_WRITE, |g| { + // Grant v3 `sync_paths` entries passed the decoder's + // lexical checks; normalize `.`/empty components the + // same way `check_rel_path` normalizes requests so + // prefix matching compares like with like. + let paths = g.payload.constraints.sync_paths.as_ref().map(|list| { + list.iter() + .map(|scope| { + std::path::Path::new(scope) + .components() + .filter_map(|c| match c { + std::path::Component::Normal(p) => Some(p), + _ => None, + }) + .collect::() + }) + .collect::>() + .into() + }); rds_sync::engine::Access { read: g.permits_sync_read(), write: g.permits_sync_write(), + paths, } }); if let Some(transfer) = transfer { diff --git a/crates/rds-agent/src/main.rs b/crates/rds-agent/src/main.rs index a27e4b1..4bca130 100644 --- a/crates/rds-agent/src/main.rs +++ b/crates/rds-agent/src/main.rs @@ -116,6 +116,14 @@ struct Cli { /// Maximum grant lifetime accepted, in seconds (default 300). #[arg(long)] grant_ttl: Option, + /// Tenant this device belongs to (grant v3). When set, every grant + /// must carry the same `tenant` claim; unscoped grants are refused. + #[arg(long)] + tenant: Option, + /// Minimum policy revision a grant must claim (grant v3). Grants + /// minted under older estate policy are refused. + #[arg(long)] + policy_min_revision: Option, /// Verifying key that signs the estate revocation snapshot /// (`GET /v1/revocations`). Required for denylist polling when /// `--directory` is set. @@ -180,6 +188,8 @@ async fn run(cli: Cli) -> anyhow::Result<()> { allow: cli.allow, issuers: cli.issuers, grant_ttl: cli.grant_ttl, + tenant: cli.tenant, + policy_min_revision: cli.policy_min_revision, directory: cli.directory, directory_ca: cli.directory_ca, record_ttl: cli.record_ttl, @@ -234,6 +244,8 @@ async fn run(cli: Cli) -> anyhow::Result<()> { if let Some(timeouts) = resolved.timeouts { policy.timeouts = timeouts; } + policy.tenant = resolved.tenant; + policy.min_policy_revision = resolved.policy_min_revision; policy.issuers.extend(resolved.issuers.iter().copied()); policy .validate() diff --git a/crates/rds-agent/src/settings.rs b/crates/rds-agent/src/settings.rs index a634cbe..b45b692 100644 --- a/crates/rds-agent/src/settings.rs +++ b/crates/rds-agent/src/settings.rs @@ -168,6 +168,13 @@ pub struct AuthoritySettings { /// Trusted grant issuers (base32 Ed25519 verifying keys). pub issuers: Vec, pub grant_ttl_secs: Option, + /// Tenant this agent answers (grant v3 claim binding). When set, every + /// grant must carry the same `tenant`; unscoped grants are refused. + pub tenant: Option, + /// Minimum `policy_revision` a grant must claim (grant v3). Grants + /// minted under older estate policy are refused without waiting for + /// expiry or revocation. + pub policy_min_revision: Option, /// Directory HTTP(S) origin or legacy IP:port. pub directory: Option, pub directory_ca: Option, @@ -235,6 +242,8 @@ pub struct AgentOverrides { pub allow: Vec, pub issuers: Vec, pub grant_ttl: Option, + pub tenant: Option, + pub policy_min_revision: Option, pub directory: Option, pub directory_ca: Option, pub record_ttl: Option, @@ -267,6 +276,8 @@ pub struct ResolvedAgent { pub allow: Vec, pub issuers: Vec<[u8; 32]>, pub grant_ttl: Option, + pub tenant: Option, + pub policy_min_revision: Option, pub directory: Option, pub directory_ca: Option, pub record_ttl: Option, @@ -381,6 +392,12 @@ impl AgentSettings { if flags.grant_ttl.is_some() { self.authority.grant_ttl_secs = flags.grant_ttl; } + if flags.tenant.is_some() { + self.authority.tenant = flags.tenant; + } + if flags.policy_min_revision.is_some() { + self.authority.policy_min_revision = flags.policy_min_revision; + } if flags.directory.is_some() { self.authority.directory = flags.directory; } @@ -498,6 +515,22 @@ impl AgentSettings { "grant_ttl_secs must be 1..=86400", )); } + if let Some(tenant) = &authority.tenant + && (tenant.is_empty() + || tenant.len() > rds_core::grant::MAX_TENANT_LEN + || tenant.bytes().any(|b| b < 0x21 || b == 0x7f)) + { + return Err(AgentConfigError::Invalid( + "tenant must be nonempty, at most 64 bytes and free of control or whitespace bytes", + )); + } + if authority.issuers.is_empty() + && (authority.tenant.is_some() || authority.policy_min_revision.is_some()) + { + return Err(AgentConfigError::Invalid( + "tenant/policy_min_revision require at least one grant issuer", + )); + } if authority.directory.is_none() && (authority.directory_ca.is_some() || authority.record_ttl_secs.is_some() @@ -638,6 +671,8 @@ impl AgentSettings { allow, issuers, grant_ttl: authority.grant_ttl_secs, + tenant: authority.tenant.clone(), + policy_min_revision: authority.policy_min_revision, directory: authority.directory.clone(), directory_ca: authority.directory_ca.clone(), record_ttl: authority.record_ttl_secs, @@ -914,4 +949,40 @@ mod tests { ); assert_eq!(resolve_ok(r#"{"schema_version":1}"#).timeouts, None); } + + #[test] + fn tenant_and_revision_binding() { + // Binding claims without issuers would never evaluate — refused + // loudly rather than silently inert. + for json in [ + r#"{"schema_version":1,"authority":{"tenant":"t1"}}"#, + r#"{"schema_version":1,"authority":{"policy_min_revision":4}}"#, + r#"{"schema_version":1,"authority":{"tenant":"","issuers":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],"directory":"http://localhost:9","revocations":{"key":"k"}}}"#, + r#"{"schema_version":1,"authority":{"tenant":"has space","issuers":["a"],"revocations":{"key":"k"}}}"#, + ] { + let settings = parse(json); + assert!(settings.validate().is_err(), "{json}"); + } + // A valid binding resolves, and flag values override file values. + let resolved = resolve_ok( + r#"{"schema_version":1,"authority":{"issuers":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "tenant":"tenant-a","policy_min_revision":3,"directory":"http://localhost:9", + "revocations":{"key":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}"#, + ); + assert_eq!(resolved.tenant.as_deref(), Some("tenant-a")); + assert_eq!(resolved.policy_min_revision, Some(3)); + let settings = parse( + r#"{"schema_version":1,"authority":{"issuers":["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "tenant":"tenant-a","policy_min_revision":3,"directory":"http://localhost:9", + "revocations":{"key":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}}"#, + ) + .apply(AgentOverrides { + tenant: Some("tenant-b".into()), + ..Default::default() + }); + assert_eq!( + settings.resolve().unwrap().tenant.as_deref(), + Some("tenant-b") + ); + } } diff --git a/crates/rds-agent/tests/e2e.rs b/crates/rds-agent/tests/e2e.rs index 159dfb6..f59895b 100644 --- a/crates/rds-agent/tests/e2e.rs +++ b/crates/rds-agent/tests/e2e.rs @@ -460,6 +460,8 @@ async fn expired_and_wrong_service_grants_rejected() { not_before: 1, expires_at: 2, constraints: GrantConstraints::default(), + tenant: None, + policy_revision: None, }, ); assert!( diff --git a/crates/rds-agent/tests/grant_scopes.rs b/crates/rds-agent/tests/grant_scopes.rs index 8c2e316..342ac57 100644 --- a/crates/rds-agent/tests/grant_scopes.rs +++ b/crates/rds-agent/tests/grant_scopes.rs @@ -282,3 +282,151 @@ async fn fine_scopes_on_iroh() { async fn fine_scopes_on_owned_transport() { directional_sync(Backend::Noq).await; } + +/// Grant v3 tenant/policy-revision binding and `sync_paths` scope enforced +/// by a real agent over loopback QUIC. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn v3_claims_and_path_scope_on_iroh() { + let root = Scratch::new(); + std::fs::create_dir(root.0.join("docs")).unwrap(); + std::fs::write(root.0.join("docs/ok.txt"), b"in scope").unwrap(); + std::fs::write(root.0.join("private.txt"), b"out of scope").unwrap(); + let client = endpoint(Backend::Iroh).await; + let server = endpoint(Backend::Iroh).await; + let issuer = SigningKey::from_bytes(&rand::random()); + let mut policy = AgentPolicy::ssh_only(("127.0.0.1".into(), 9)); + policy.allow.insert(client.id()); + policy.issuers.insert(issuer.verifying_key().to_bytes()); + policy.tenant = Some("tenant-a".into()); + policy.min_policy_revision = Some(3); + policy.sync_dir = Some(root.0.clone()); + policy.use_local_revocations(); + let agent = Arc::new(Agent::new(server.clone(), policy)); + let runner = agent.clone(); + let mut task = Runner(tokio::spawn(async move { + runner.run().await.unwrap(); + })); + + let now = rds_core::grant::now_unix(); + let payload = |tenant: Option<&str>, revision: Option| rds_core::grant::GrantPayload { + version: rds_core::grant::GRANT_VERSION, + revision: 1, + issuer: issuer.verifying_key().to_bytes(), + subject: *client.id().as_bytes(), + audience: *server.id().as_bytes(), + nonce: rand::random(), + services: vec![ServiceKind::Ping, ServiceKind::Sync], + not_before: now, + expires_at: now + 120, + constraints: rds_core::grant::GrantConstraints { + sync_paths: Some(vec!["docs".into()]), + ..Default::default() + }, + tenant: tenant.map(str::to_string), + policy_revision: revision, + }; + // A well-signed grant without the pinned tenant is refused. + let grant = Grant::issue_at(&issuer, payload(None, Some(3))); + assert!( + rds_client::connect_authorized(&client, server.addr(), &grant) + .await + .is_err() + ); + // A mismatched tenant is refused; so is a claim below the policy floor. + let grant = Grant::issue_at(&issuer, payload(Some("tenant-b"), Some(3))); + assert!( + rds_client::connect_authorized(&client, server.addr(), &grant) + .await + .is_err() + ); + let grant = Grant::issue_at(&issuer, payload(Some("tenant-a"), Some(2))); + assert!( + rds_client::connect_authorized(&client, server.addr(), &grant) + .await + .is_err() + ); + // The fully bound grant connects. + let grant = Grant::issue_at(&issuer, payload(Some("tenant-a"), Some(3))); + let conn = rds_client::connect_authorized(&client, server.addr(), &grant) + .await + .unwrap(); + rds_client::ping(&conn, rand::random()).await.unwrap(); + + // Paths outside the signed scope are refused by name, before any + // filesystem work — including a component-boundary lookalike. + for bad in ["private.txt", "docs/../private.txt", "docsx/f"] { + let (mut send, mut recv) = next_sync(&conn).await; + write_frame( + &mut send, + &SyncMsg::Request { + rel_path: bad.into(), + }, + ) + .await + .unwrap(); + let answer = + tokio::time::timeout(Duration::from_secs(3), read_frame::<_, SyncMsg>(&mut recv)) + .await + .unwrap() + .unwrap(); + let SyncMsg::Refuse { reason } = answer else { + panic!("accepted out-of-scope path {bad:?}: {answer:?}") + }; + let expected = if bad.contains("..") { + "traversal in rel_path" + } else { + "sync path outside granted scope" + }; + assert!(reason.contains(expected), "path {bad:?}: {reason}"); + assert_eq!( + std::fs::read(root.0.join("private.txt")).unwrap(), + b"out of scope" + ); + } + // A write outside the scope is refused; nothing is created. + let (mut send, mut recv) = next_sync(&conn).await; + write_frame( + &mut send, + &SyncMsg::Offer { + rel_path: "other/new.bin".into(), + size: 0, + root: [0; 32], + chunk_count: 0, + }, + ) + .await + .unwrap(); + let answer = tokio::time::timeout(Duration::from_secs(3), read_frame::<_, SyncMsg>(&mut recv)) + .await + .unwrap() + .unwrap(); + assert!( + matches!(answer, SyncMsg::Refuse { ref reason } if reason == "sync path outside granted scope") + ); + assert!(!root.0.join("other").exists()); + + // In-scope pull succeeds end to end. + let (send, recv) = next_sync(&conn).await; + let destination = Scratch::new(); + engine::recv_file_with_timeout( + &conn, + "docs/ok.txt", + &destination.0, + send, + recv, + Duration::from_secs(5), + ) + .await + .unwrap(); + assert_eq!( + std::fs::read(destination.0.join("docs/ok.txt")).unwrap(), + b"in scope" + ); + conn.close(0u32.into(), b"done"); + client.close().await; + server.close().await; + tokio::time::timeout(Duration::from_secs(5), &mut task.0) + .await + .unwrap() + .unwrap(); +} diff --git a/crates/rds-core/src/grant.rs b/crates/rds-core/src/grant.rs index 1ab9e40..39e63d4 100644 --- a/crates/rds-core/src/grant.rs +++ b/crates/rds-core/src/grant.rs @@ -36,9 +36,17 @@ use crate::ServiceKind; pub const SKEW_SECS: u64 = 30; /// Grant payload format, independently versioned inside the Authz envelope. -pub const GRANT_VERSION: u16 = 2; +/// Version 3 adds the tenant/policy-revision claims and the `sync_paths` +/// constraint; version-2 payloads still verify with all claims absent so +/// estate-minted grants stay valid across the cutover. A deployment that +/// requires the binding enforces it through policy — version-2 grants simply +/// cannot satisfy a pinned tenant or revision floor. +pub const GRANT_VERSION: u16 = 3; +/// Oldest payload version still accepted at verify time. +pub const GRANT_VERSION_MIN: u16 = 2; pub const MAX_PAYLOAD_LEN: usize = 4096; -const DOMAIN: &[u8] = b"rds/capability-grant/v2\0"; +const DOMAIN_V2: &[u8] = b"rds/capability-grant/v2\0"; +const DOMAIN_V3: &[u8] = b"rds/capability-grant/v3\0"; const ID_DOMAIN: &[u8] = b"rds/grant-session/v2\0"; /// Hard bounds on grant collections, checked after decode so a hostile @@ -46,6 +54,12 @@ const ID_DOMAIN: &[u8] = b"rds/grant-session/v2\0"; pub const MAX_SERVICES: usize = 32; pub const MAX_TCP_PORTS: usize = 128; pub const MAX_DISPLAYS: usize = 32; +/// Bound on a tenant identifier carried by a version-3 grant. +pub const MAX_TENANT_LEN: usize = 64; +/// Bound on the number of `sync_paths` scope entries. +pub const MAX_SYNC_PATHS: usize = 64; +/// Bound on one `sync_paths` entry — same cap as a protocol rel_path. +pub const MAX_SCOPE_PATH_LEN: usize = 512; /// Stable session identifier: domain-separated BLAKE3 of issuer, subject, /// destination and nonce. Revocation and concurrent-replay checks cover every @@ -88,6 +102,60 @@ pub struct GrantPayload { pub expires_at: u64, /// Optional per-service constraints. pub constraints: GrantConstraints, + /// Tenant the issuer binds this grant to (v3+). `None` = unscoped; + /// agents that pin a tenant refuse unscoped and mismatched grants. + pub tenant: Option, + /// Estate policy revision the issuer minted under (v3+). Agents may + /// enforce a floor so policy changes invalidate older grants. + pub policy_revision: Option, +} + +/// Frozen version-2 wire layout, kept only for decode. New grants are +/// always issued at [`GRANT_VERSION`]. +#[derive(Debug, Serialize, Deserialize)] +struct GrantPayloadV2 { + version: u16, + revision: u64, + issuer: [u8; 32], + subject: [u8; 32], + audience: [u8; 32], + nonce: [u8; 16], + services: Vec, + not_before: u64, + expires_at: u64, + constraints: GrantConstraintsV2, +} + +/// Version-2 [`GrantConstraints`] — no `sync_paths` field. +#[derive(Debug, Serialize, Deserialize)] +struct GrantConstraintsV2 { + max_bps: Option, + tcp_ports: Option>, + displays: Option>, +} + +impl From for GrantPayload { + fn from(old: GrantPayloadV2) -> Self { + Self { + version: old.version, + revision: old.revision, + issuer: old.issuer, + subject: old.subject, + audience: old.audience, + nonce: old.nonce, + services: old.services, + not_before: old.not_before, + expires_at: old.expires_at, + constraints: GrantConstraints { + max_bps: old.constraints.max_bps, + tcp_ports: old.constraints.tcp_ports, + displays: old.constraints.displays, + sync_paths: None, + }, + tenant: None, + policy_revision: None, + } + } } /// Narrowing constraints inside a grant. `None` = unconstrained by the @@ -101,6 +169,10 @@ pub struct GrantConstraints { pub tcp_ports: Option>, /// Allowed display indices for `Desktop`. `Some` restricts. pub displays: Option>, + /// Allowed relative sync paths (v3+). `Some` restricts `Sync` reads and + /// writes to the listed subtrees; each entry is a normalized relative + /// path under the agent's sync root. + pub sync_paths: Option>, } impl GrantPayload { @@ -135,6 +207,8 @@ impl VerifiedGrant { if self.id != next.id || self.payload.services != next.payload.services || self.payload.constraints != next.payload.constraints + || self.payload.tenant != next.payload.tenant + || self.payload.policy_revision != next.payload.policy_revision || next.payload.revision <= self.payload.revision || next.payload.not_before < self.payload.not_before || next.payload.expires_at <= self.payload.expires_at @@ -198,6 +272,29 @@ impl VerifiedGrant { self.payload.constraints.max_bps } + /// Tenant the issuer bound this grant to, if any (v3+). + pub fn tenant(&self) -> Option<&str> { + self.payload.tenant.as_deref() + } + + /// Estate policy revision the grant was minted under, if any (v3+). + pub fn policy_revision(&self) -> Option { + self.payload.policy_revision + } + + /// Whether a normalized relative sync path is inside + /// `constraints.sync_paths` — the path itself or a descendant of a listed + /// subtree. `None` means the grant does not narrow the sync root. + pub fn permits_sync_path(&self, rel: &std::path::Path) -> bool { + match &self.payload.constraints.sync_paths { + Some(paths) => paths + .iter() + .map(std::path::Path::new) + .any(|scope| rel == scope || rel.starts_with(scope)), + None => true, + } + } + /// Whether the grant is still valid at `now` (unix seconds). pub fn live_at(&self, now: u64) -> bool { now.saturating_add(SKEW_SECS) >= self.payload.not_before && now < self.payload.expires_at @@ -216,6 +313,10 @@ pub enum GrantError { WrongSubject, #[error("grant destination does not match this device")] WrongAudience, + #[error("grant tenant does not match the agent's pinned tenant")] + TenantMismatch, + #[error("grant policy revision is below the agent's required floor")] + PolicyRevisionStale, #[error("unsupported grant version")] Version, #[error("invalid grant validity interval")] @@ -261,14 +362,17 @@ impl Grant { not_before: now, expires_at: now.saturating_add(ttl.as_secs()), constraints, + tenant: None, + policy_revision: None, }, ) } /// Sign an already-built payload — the deterministic path for tests. + /// The signature domain follows `payload.version`. pub fn issue_at(issuer: &SigningKey, payload: GrantPayload) -> Self { let bytes = postcard::to_stdvec(&payload).expect("grant payload encodes"); - let signature = issuer.sign(&signature_message(&bytes)); + let signature = issuer.sign(&signature_message(payload.version, &bytes)); Self { payload: bytes, signature: signature.to_bytes().to_vec(), @@ -307,7 +411,7 @@ impl Grant { .map_err(|_| GrantError::Malformed("signature is not 64 bytes".into()))?; issuer_key .verify_strict( - &signature_message(&self.payload), + &signature_message(payload.version, &self.payload), &Signature::from_bytes(&sig_bytes), ) .map_err(|_| GrantError::BadSignature)?; @@ -336,14 +440,30 @@ impl Grant { if self.payload.len() > MAX_PAYLOAD_LEN { return Err(GrantError::Oversized); } - let (payload, trailing): (GrantPayload, _) = postcard::take_from_bytes(&self.payload) + // The version is the payload's first field; postcard encodes `u16` + // as a varint, so peeking decodes exactly the version bytes. + let (version, _) = postcard::take_from_bytes::(&self.payload) .map_err(|e| GrantError::Malformed(e.to_string()))?; - if !trailing.is_empty() { - return Err(GrantError::Malformed("trailing grant bytes".into())); - } - if payload.version != GRANT_VERSION { - return Err(GrantError::Version); - } + let payload = match version { + v if v == GRANT_VERSION_MIN => { + let (old, trailing): (GrantPayloadV2, _) = postcard::take_from_bytes(&self.payload) + .map_err(|e| GrantError::Malformed(e.to_string()))?; + if !trailing.is_empty() { + return Err(GrantError::Malformed("trailing grant bytes".into())); + } + old.into() + } + v if v == GRANT_VERSION => { + let (new, trailing): (GrantPayload, _) = + postcard::take_from_bytes(&self.payload) + .map_err(|e| GrantError::Malformed(e.to_string()))?; + if !trailing.is_empty() { + return Err(GrantError::Malformed("trailing grant bytes".into())); + } + new + } + _ => return Err(GrantError::Version), + }; if payload.revision == 0 { return Err(GrantError::InvalidRevision); } @@ -361,16 +481,82 @@ impl Grant { .displays .as_ref() .is_some_and(|d| d.len() > MAX_DISPLAYS) + || payload + .constraints + .sync_paths + .as_ref() + .is_some_and(|s| s.len() > MAX_SYNC_PATHS) + || payload + .tenant + .as_ref() + .is_some_and(|t| t.len() > MAX_TENANT_LEN) { return Err(GrantError::Oversized); } + if let Some(tenant) = &payload.tenant + && (tenant.is_empty() || tenant.bytes().any(|b| b < 0x21 || b == 0x7f)) + { + return Err(GrantError::Malformed( + "tenant contains control or whitespace bytes".into(), + )); + } + if let Some(paths) = &payload.constraints.sync_paths { + for path in paths { + check_scope_path(path)?; + } + } Ok(payload) } } -fn signature_message(payload: &[u8]) -> Vec { - let mut message = Vec::with_capacity(DOMAIN.len() + payload.len()); - message.extend_from_slice(DOMAIN); +/// Lexical validation of a `sync_paths` scope entry — the same rules a +/// transfer-level `rel_path` must satisfy (relative, inside the root, no +/// NUL or `..`, nonempty after normalization). I/O confinement is still +/// proven by the journal's no-follow handles; this only proves the signed +/// scope list is well-formed. +fn check_scope_path(path: &str) -> Result<(), GrantError> { + if path.is_empty() || path.len() > MAX_SCOPE_PATH_LEN { + return Err(GrantError::Malformed(format!( + "bad sync_paths entry {path:?}" + ))); + } + if path.contains('\0') { + return Err(GrantError::Malformed( + "sync_paths entry contains NUL".into(), + )); + } + if path.starts_with(['/', '\\']) || path.as_bytes().get(1) == Some(&b':') { + return Err(GrantError::Malformed(format!( + "absolute sync_paths entry {path:?}" + ))); + } + let mut real_components = 0; + for part in path.split(['/', '\\']) { + match part { + "" | "." => {} + ".." => { + return Err(GrantError::Malformed(format!( + "traversal in sync_paths entry {path:?}" + ))); + } + _ => real_components += 1, + } + } + if real_components == 0 { + return Err(GrantError::Malformed(format!( + "empty sync_paths entry {path:?}" + ))); + } + Ok(()) +} + +fn signature_message(version: u16, payload: &[u8]) -> Vec { + let domain = match version { + v if v == GRANT_VERSION_MIN => DOMAIN_V2, + _ => DOMAIN_V3, + }; + let mut message = Vec::with_capacity(domain.len() + payload.len()); + message.extend_from_slice(domain); message.extend_from_slice(payload); message } @@ -589,6 +775,8 @@ mod tests { not_before: now + 3600, expires_at: now + 7200, constraints: GrantConstraints::default(), + tenant: None, + policy_revision: None, }, ); assert!(matches!( @@ -618,6 +806,7 @@ mod tests { max_bps: Some(2_000_000), tcp_ports: Some(vec![22]), displays: Some(vec![0]), + sync_paths: None, }, ); let v = grant @@ -677,6 +866,8 @@ mod tests { not_before: 105, expires_at: 103, constraints: GrantConstraints::default(), + tenant: None, + policy_revision: None, }, ); assert!( @@ -706,6 +897,8 @@ mod tests { not_before: 100, expires_at: 160, constraints: GrantConstraints::default(), + tenant: None, + policy_revision: None, }; let grant = Grant::issue_at(&key, payload.clone()); let check = |grant: &Grant, audience: &[u8; 32], now| { @@ -754,7 +947,7 @@ mod tests { let mut trailing = grant.clone(); trailing.payload.push(0); trailing.signature = key - .sign(&signature_message(&trailing.payload)) + .sign(&signature_message(GRANT_VERSION, &trailing.payload)) .to_bytes() .to_vec(); assert!(matches!( @@ -794,6 +987,8 @@ mod tests { tcp_ports: Some(vec![22]), ..Default::default() }, + tenant: None, + policy_revision: None, }; let verify = |p: GrantPayload| { Grant::issue_at(&key, p) @@ -881,4 +1076,303 @@ mod tests { } } } + + /// A v3 payload with every claim populated. + fn claimed_payload(key: &SigningKey) -> GrantPayload { + GrantPayload { + version: GRANT_VERSION, + revision: 1, + issuer: key.verifying_key().to_bytes(), + subject: [7; 32], + audience: [8; 32], + nonce: rand::random(), + services: vec![ServiceKind::Sync], + not_before: 100, + expires_at: 160, + constraints: GrantConstraints { + sync_paths: Some(vec!["docs".into(), "media/2026".into()]), + ..Default::default() + }, + tenant: Some("tenant-a".into()), + policy_revision: Some(7), + } + } + + #[test] + fn v3_claims_verify_and_carry_scope() { + let key = issuer(); + let grant = Grant::issue_at(&key, claimed_payload(&key)); + let verified = grant + .verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100, + ) + .expect("v3 grant verifies"); + assert_eq!(verified.tenant(), Some("tenant-a")); + assert_eq!(verified.policy_revision(), Some(7)); + assert!(verified.permits_sync_path(std::path::Path::new("docs/a.txt"))); + assert!(verified.permits_sync_path(std::path::Path::new("media/2026/x"))); + assert!(verified.permits_sync_path(std::path::Path::new("docs"))); + // Prefix matching is component-wise: a shared name prefix does not + // widen the scope. + assert!(!verified.permits_sync_path(std::path::Path::new("docs-else/x"))); + assert!(!verified.permits_sync_path(std::path::Path::new("other/f"))); + + // The same payload signed under the v2 domain must not verify. + let mut cross = grant.clone(); + cross.signature = key + .sign(&signature_message(GRANT_VERSION_MIN, &grant.payload)) + .to_bytes() + .to_vec(); + assert!(matches!( + cross.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::BadSignature) + )); + } + + #[test] + fn v2_grants_still_verify_with_absent_claims() { + let key = issuer(); + // Hand-encode a genuine version-2 wire payload: pinned layout, + // v2 signature domain — exactly what an estate issuer minted + // before v3 produces. + let old = GrantPayloadV2 { + version: GRANT_VERSION_MIN, + revision: 1, + issuer: key.verifying_key().to_bytes(), + subject: [7; 32], + audience: [8; 32], + nonce: rand::random(), + services: vec![ServiceKind::Ping], + not_before: 100, + expires_at: 160, + constraints: GrantConstraintsV2 { + max_bps: None, + tcp_ports: None, + displays: None, + }, + }; + let bytes = postcard::to_stdvec(&old).unwrap(); + let grant = Grant { + signature: key + .sign(&signature_message(GRANT_VERSION_MIN, &bytes)) + .to_bytes() + .to_vec(), + payload: bytes, + }; + let verified = grant + .verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100, + ) + .expect("v2 grant verifies"); + assert_eq!(verified.payload.version, GRANT_VERSION_MIN); + assert_eq!(verified.tenant(), None); + assert_eq!(verified.policy_revision(), None); + // No path constraint narrows a v2 grant. + assert!(verified.permits_sync_path(std::path::Path::new("anything/at/all"))); + } + + #[test] + fn v2_bytes_cannot_smuggle_v3_claims() { + let key = issuer(); + // A v3-encoded payload with its version field rewritten to 2 must + // fail — decode is layout-exact per version. + let mut payload = postcard::to_stdvec(&claimed_payload(&key)).unwrap(); + assert_eq!(payload[0], GRANT_VERSION as u8); + payload[0] = GRANT_VERSION_MIN as u8; + let grant = Grant { + signature: key + .sign(&signature_message(GRANT_VERSION_MIN, &payload)) + .to_bytes() + .to_vec(), + payload, + }; + assert!(matches!( + grant.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::Malformed(_)) + )); + // Unknown versions are refused outright. + for version in [0u16, 1, 4, u16::MAX] { + let mut payload = postcard::to_stdvec(&claimed_payload(&key)).unwrap(); + payload[0] = version as u8; // versions under 0x80 are one varint byte + let grant = Grant { + signature: key + .sign(&signature_message(version, &payload)) + .to_bytes() + .to_vec(), + payload, + }; + assert!(matches!( + grant.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::Version) | Err(GrantError::Malformed(_)) + )); + } + } + + #[test] + fn v3_claim_bounds_are_enforced() { + let key = issuer(); + let mut payload = claimed_payload(&key); + payload.tenant = Some("x".repeat(MAX_TENANT_LEN + 1)); + let grant = Grant::issue_at(&key, payload); + assert!(matches!( + grant.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::Oversized) + )); + for bad in ["", "has space", "nul\0byte", "line\nbreak"] { + let mut payload = claimed_payload(&key); + payload.tenant = Some(bad.into()); + let grant = Grant::issue_at(&key, payload); + assert!( + matches!( + grant.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::Malformed(_)) + ), + "tenant {bad:?}" + ); + } + let mut payload = claimed_payload(&key); + payload.constraints.sync_paths = + Some((0..MAX_SYNC_PATHS + 1).map(|i| format!("p{i}")).collect()); + let grant = Grant::issue_at(&key, payload); + assert!(matches!( + grant.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::Oversized) + )); + for bad in [ + "/abs", + "c:/win", + "../escape", + "a/../b", + ".", + ".\\..\\x", + "nul\0", + ] { + let mut payload = claimed_payload(&key); + payload.constraints.sync_paths = Some(vec![bad.into()]); + let grant = Grant::issue_at(&key, payload); + assert!( + matches!( + grant.verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ), + Err(GrantError::Malformed(_)) + ), + "sync_paths entry {bad:?}" + ); + } + // Normalized-but-legal entries verify. + let mut payload = claimed_payload(&key); + payload.constraints.sync_paths = Some(vec!["a/./b".into(), "deep/sub".into()]); + let grant = Grant::issue_at(&key, payload); + assert!( + grant + .verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100 + ) + .is_ok() + ); + } + + #[test] + fn renewal_cannot_change_v3_claims() { + let key = issuer(); + let grant = Grant::issue_at(&key, claimed_payload(&key)) + .verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 100, + ) + .unwrap(); + let mut next = grant.payload.clone(); + next.revision = 2; + next.expires_at += 60; + // Claim changes are scope changes and require fresh authorization. + for claim in [ + |p: &mut GrantPayload| p.tenant = Some("tenant-b".into()), + |p: &mut GrantPayload| p.tenant = None, + |p: &mut GrantPayload| p.policy_revision = Some(8), + |p: &mut GrantPayload| p.constraints.sync_paths = Some(vec!["other".into()]), + ] { + let mut tampered = next.clone(); + claim(&mut tampered); + let renewed = Grant::issue_at(&key, tampered) + .verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 110, + ) + .unwrap(); + assert!(matches!( + grant.permits_renewal(&renewed), + Err(GrantError::InvalidRenewal) + )); + } + // Identical claims with an advanced lease renew cleanly. + let renewed = Grant::issue_at(&key, next) + .verify( + &issuers(&key), + &[7; 32], + &[8; 32], + Duration::from_secs(600), + 110, + ) + .unwrap(); + assert!(matches!(grant.permits_renewal(&renewed), Ok(true))); + } } diff --git a/crates/rds-sync/src/engine.rs b/crates/rds-sync/src/engine.rs index 69e01df..6fcf541 100644 --- a/crates/rds-sync/src/engine.rs +++ b/crates/rds-sync/src/engine.rs @@ -41,10 +41,14 @@ pub const TRANSFER_TIMEOUT: Duration = Duration::from_secs(3600); /// Agent-side permissions, checked before any path or filesystem operation. /// Read means download from the agent; write means upload to the agent. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Default)] pub struct Access { pub read: bool, pub write: bool, + /// Signed grant path scope (grant v3 `sync_paths`): `Some` restricts + /// transfers to the listed subtrees under the sync root, `None` leaves + /// the whole root open. Entries are stored already-normalized. + pub paths: Option>, } impl Access { @@ -52,7 +56,19 @@ impl Access { pub const READ_WRITE: Self = Self { read: true, write: true, + paths: None, }; + + /// Whether a normalized `check_rel_path` result is inside the granted + /// path scope — the path itself or a descendant of a listed subtree. + pub fn permits_path(&self, rel: &Path) -> bool { + match &self.paths { + Some(paths) => paths + .iter() + .any(|scope| rel == scope.as_path() || rel.starts_with(scope)), + None => true, + } + } } /// Progress/counters a completed (or interrupted) transfer reports. @@ -607,6 +623,10 @@ async fn serve_inner( bail!("offer refused: {e}"); } }; + if !access.permits_path(&rel) { + refuse(wire, send, "sync path outside granted scope").await?; + bail!("offer refused: path outside granted scope"); + } // Preserve early refusal before requesting a manifest. This is // only a preflight: Journal::open independently pins and checks // every handle again before any state or destination I/O. @@ -666,6 +686,10 @@ async fn serve_inner( bail!("request refused: {e}"); } }; + if !access.permits_path(&rel) { + refuse(wire, send, "sync path outside granted scope").await?; + bail!("request refused: path outside granted scope"); + } // Pin the source once. Both manifest and chunk reads use this // same inode, even if the path is replaced after the offer. let source = { From c0720ed02c619d50a1ee95de7ec3123eb51336ac Mon Sep 17 00:00:00 2001 From: rldyourmnd Date: Sun, 27 Sep 2026 10:37:03 +0500 Subject: [PATCH 2/2] docs: grant v3 contract and W2.3 ledger grant-leases documents the v3 claims, the version-dispatched signature domain, v2 compatibility and the sync_paths subtree semantics; the agent-configuration reference gains the authority.tenant / policy_min_revision fields and CLI flags. Architecture, conventions, releases and local-sessions references are updated to the v3 contract, the capability matrix gains a grants-v3 row, and the ledger records the wave with the explicitly deferred cross-repository remainder (account scopes, automatic GDS issuance/renewal, policy reconciliation). --- docs/agent-configuration.md | 10 ++++++- docs/architecture.md | 2 +- docs/capability-matrix.md | 5 ++-- docs/conventions.md | 6 ++-- docs/grant-leases.md | 58 +++++++++++++++++++++++++++--------- docs/local-sessions.md | 4 +-- docs/releases.md | 2 +- docs/remediation-plan.md | 10 +++++++ docs/remediation-progress.md | 41 ++++++++++++++++++++++++- 9 files changed, 114 insertions(+), 24 deletions(-) diff --git a/docs/agent-configuration.md b/docs/agent-configuration.md index d3193b2..da75b63 100644 --- a/docs/agent-configuration.md +++ b/docs/agent-configuration.md @@ -33,7 +33,7 @@ identity creation or socket binding, alongside the endpoint preflight. | `disabled_services` | Services subtracted from the resolved set | | `service` | `ssh_target`, `tcp_targets`, `allow_any_tcp`, `sync_dir` | | `peers` | `allow`: endpoint-id strings, at most 256 | -| `authority` | `issuers`, `grant_ttl_secs` (1–86400), `directory`, `directory_ca`, `record_ttl_secs`, `record_state`, `registry`, `revocations` | +| `authority` | `issuers`, `grant_ttl_secs` (1–86400), `tenant`, `policy_min_revision`, `directory`, `directory_ca`, `record_ttl_secs`, `record_state`, `registry`, `revocations` | | `limits` | `max_connections`, `max_streams`; positive 16-bit | | `timeouts` | `handshake_secs`, `hello_secs`; each 1–3600 | @@ -44,6 +44,14 @@ fields all require `authority.directory`; revocations additionally require at least one issuer — the same requirements the flags carried, now enforced on the merged document so file and flag sources mix freely. +`authority.tenant` pins the grant v3 tenant claim: every grant must carry +the same tenant, and unscoped grants (including all version-2 grants) are +refused at authorization. `authority.policy_min_revision` sets a floor on +the grant's claimed estate policy revision, retiring older-policy grants +without waiting for expiry. Either binding requires at least one issuer — +grants are not evaluated without them, so the pin would silently do +nothing. Flag equivalents: `--tenant`, `--policy-min-revision`. + ## Service enablement `Ping` and `Info` are the always-on control plane and are never gated. diff --git a/docs/architecture.md b/docs/architecture.md index 3f12e71..556e8d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -394,7 +394,7 @@ Every stream opens with a length-prefixed postcard `StreamHello`: | Service | Direction | Payload | | --- | --- | --- | -| `Authz` | bi | capability grant v2 (first stream in grant mode) | +| `Authz` | bi | capability grant v2/v3 (first stream in grant mode) | | `RenewAuthz` | bi | same-session, same-scope signed lease extension | | `Ping` | bi | nonce echo for RTT | | `Info` | bi | agent version, services, displays | diff --git a/docs/capability-matrix.md b/docs/capability-matrix.md index 8efe23c..a8998dc 100644 --- a/docs/capability-matrix.md +++ b/docs/capability-matrix.md @@ -34,8 +34,8 @@ only what is actually served. `ping`/`info` are the always-on control plane. | service:desktop | experimental | enabled + `desktop` build flag; usable capture backend; grant scope | capture→encode→decode→stats contract tests; no viewer | | service:sync | implemented | enabled + `--sync-dir` configured | `rds-sync` tests, resumable transfer tests | | service:audio | stub | no codec; wire shape reserved in v2 | `ServiceKind::Audio` variant only | -| service:sync-read | implemented | grant scope on sync root | grant/scope tests | -| service:sync-write | implemented | grant scope on sync root | grant/scope tests | +| service:sync-read | implemented | grant scope on sync root; optional grant `sync_paths` subtree | grant/scope tests | +| service:sync-write | implemented | grant scope on sync root; optional grant `sync_paths` subtree | grant/scope tests | | service:desktop-view | implemented | grant scope; usable capture backend | scope tests; headless path only | | service:desktop-control | experimental | desktop-view + control scope; X11 input sink | input contract tests; real-session injection unqualified | @@ -81,6 +81,7 @@ only what is actually served. `ping`/`info` are the always-on control plane. |---|---|---|---| | discovery:directory | experimental | `--directory`, `--directory-allow` publishers; HTTPS or explicit http | discovery tests; WAN unqualified | | policy:grants-v2 | implemented | `--issuer` + grant files; `--revocations-key` for managed mode | grant/lease tests | +| policy:grants-v3 | implemented | same surface; adds `tenant`/`policy_revision` claims and `sync_paths` scope; agents pin via `--tenant`/`--policy-min-revision` | grant unit tests + e2e binding/scope suite; v2 payloads still verify | | policy:gds-issuance | unavailable | estate GDS service wiring; not in this module | roadmap; `session renew` is manual-file only | | observability:export | experimental | `RDS_LOG_FORMAT=json`; Vector/OpenObserve pipeline | fixture pipeline, `docs/observability.md` | diff --git a/docs/conventions.md b/docs/conventions.md index f8604da..e44da1c 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -54,9 +54,11 @@ Rules every change follows. CI enforces what it can; the rest is review. - Standard SSH framing, key exchange and key formats belong to `russh`, behind `rds-ssh`; do not duplicate them as RDS control messages. - Transport protocol selection uses ALPN (`rds/0`, `rds-relay/0`). Signed - objects and local IPC also carry independent explicit versions: [grant v2 + objects and local IPC also carry independent explicit versions: [grant v3 and IPC v4](grant-leases.md) require a coordinated upgrade without weakening - authorization. General remote capability negotiation remains W2.2. + authorization — v2 grants still verify, but a deployment that pins the v3 + tenant/policy claims refuses them. General remote capability negotiation + remains W2.2. ## Platform code diff --git a/docs/grant-leases.md b/docs/grant-leases.md index 9858897..98f56db 100644 --- a/docs/grant-leases.md +++ b/docs/grant-leases.md @@ -6,18 +6,39 @@ token/local IPC update. It does not implement an authority service or close W2. ## Signed contract and issuer responsibilities -`rds-core::grant::GrantPayload` version **2** contains `version`, positive +`rds-core::grant::GrantPayload` version **3** contains `version`, positive `revision`, issuer, subject, audience, a 128-bit nonce, services, validity times -and constraints. Subject must match the QUIC-authenticated peer; audience must -match the serving agent's actual endpoint key, never a request-supplied value. -Membership in the agent allowlist and trusted issuer set remains required. - -Ed25519 signs `rds/capability-grant/v2\0` followed by the postcard payload. -Strict verification rejects raw-payload signatures, wrong versions, trailing -bytes, signatures of the wrong size, payloads exceeding 4 KiB and excessive -collections. Expiry must be strictly greater than `not_before`. The existing -30-second future `not_before` tolerance does not relax expiry or the configured -TTL cap. Clock arithmetic saturates instead of overflowing. +and constraints, plus the `tenant` and `policy_revision` claims. Subject must +match the QUIC-authenticated peer; audience must match the serving agent's +actual endpoint key, never a request-supplied value. Membership in the agent +allowlist and trusted issuer set remains required. + +Ed25519 signs `rds/capability-grant/v\0` followed by the postcard payload — +the domain string tracks the payload version, so a v3 payload signed under the +v2 domain fails verification. The decoder dispatches on the leading version +varint and accepts versions **2 and 3**: a v2 payload verifies with all v3 +claims absent (`tenant`/`policy_revision`/`sync_paths` unset), so +estate-minted v2 grants stay valid across the cutover. Strict verification +rejects raw-payload signatures, unsupported versions, trailing bytes, +signatures of the wrong size, payloads exceeding 4 KiB, oversized collections +and malformed claims (empty/whitespace tenants, absolute or traversing +`sync_paths` entries). Expiry must be strictly greater than `not_before`. The +existing 30-second future `not_before` tolerance does not relax expiry or the +configured TTL cap. Clock arithmetic saturates instead of overflowing. + +### Tenant and policy-revision binding (v3) + +`tenant` binds the grant to an estate tenant identifier (≤64 bytes, no +whitespace/control bytes); `policy_revision` records the estate policy revision +the issuer minted under. An agent pins its deployment with `tenant` and +`policy_min_revision` (agent configuration `authority.*` fields or the +`--tenant`/`--policy-min-revision` flags): every grant must then carry the +matching claim and a revision at or above the floor — v2 grants carry no claims +and are refused by a pinned deployment. Pinning either binding requires at +least one trusted issuer; a tenant pin with grants disabled would silently do +nothing, so the agent refuses that configuration. Renewal cannot change +either claim — a changed `tenant`/`policy_revision`/`sync_paths` is an +`InvalidRenewal`, never a quiet scope edit. The stable revocation/replay ID is BLAKE3 over `rds/grant-session/v2\0`, issuer, subject, audience and nonce, in that order with fixed field widths. The issuer @@ -46,8 +67,17 @@ The 2026-09-26 increment adds fine capabilities to the signed `services` list: Prefer narrow capabilities when issuing new grants. Permissions are additive: adding `SyncRead` to a legacy `Sync` grant does **not** remove write permission. `DesktopControl` alone grants neither viewing nor input. Existing display/port -constraints still apply. These are whole-root sync permissions, not per-path -ACLs; account, tenant and policy-revision binding remain open. +constraints still apply. + +Version 3 adds the `constraints.sync_paths` scope: a list of relative paths +(up to 64 entries) under the agent's sync root. When present, both `Request` +(pull) and `Offer` (push) must name a path inside a listed subtree — component +prefix matching, so `docs` covers `docs/a.txt` but not `docsx`. The check runs +immediately after `rel_path` normalization and before any filesystem handle, +manifest or journal work, and an out-of-scope path is refused with +`sync path outside granted scope`. Unconstrained grants (`sync_paths` absent, +including all v2 grants) keep whole-root access. Per-account scopes remain +open. The sync handler checks direction immediately after decoding the first bounded message, before path validation, metadata reads, manifests, journals or writes. @@ -163,7 +193,7 @@ existing private-data rules. Upgrade the Vector projection with the binaries; older operation allowlists drop these new records. No live collector/sink or alert route is changed. -Required next: tenant/policy binding, per-path and account scopes; +Required next: account-level scopes (OS identity, not just grant claims); automatic GDS issuance/renewal and policy reconciliation; viewer/sync manager integration; native macOS and suspend tests; mixed SSH/video/sync, physical WAN/NAT/relay and long-running resource/latency acceptance. Loopback tests and diff --git a/docs/local-sessions.md b/docs/local-sessions.md index 5c2de81..eb41f6f 100644 --- a/docs/local-sessions.md +++ b/docs/local-sessions.md @@ -72,8 +72,8 @@ no accidental ephemeral-identity fallback. `desktop` still requires this explici mode because its manager API remains unimplemented. Send/receive default to the manager. The local wire version is now **4** (adds file transfers); upgrade CLI and agent together. Old/new local versions fail without mutating session state. -Remote ALPN is unchanged, with an appended isolated sync greeting; signed grant v2 requires a coordinated -issuer/agent/client migration. See [renewal contract](grant-leases.md). +Remote ALPN is unchanged, with an appended isolated sync greeting; signed grant v3 accepts v2 payloads but a +pinned tenant/policy binding refuses them. See [renewal contract](grant-leases.md). `list --json` returns instance ID, generation, endpoint, selected handle and entries. Handles print as 32 hexadecimal characters. `ping`, `info`, `ssh`, diff --git a/docs/releases.md b/docs/releases.md index 43f0617..132fde1 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -75,7 +75,7 @@ these instructions. ## Compatibility and remaining acceptance gates This is the first published workspace release, but it breaks earlier -unreleased development contracts: grant v2, local IPC v3, signed policy/name +unreleased development contracts: grant v3 (v2 payloads still verify), local IPC v3, signed policy/name formats and durable directory format 3. Old peers may refuse new scopes and wire messages. A supported automatic old-state migration does not yet exist. Do not point preview executables at an existing deployment's state directory; diff --git a/docs/remediation-plan.md b/docs/remediation-plan.md index f353f3e..25f3ad5 100644 --- a/docs/remediation-plan.md +++ b/docs/remediation-plan.md @@ -254,6 +254,16 @@ slot across ACK failures/cancellation; input ACKs require backend success. See the [contract](grant-leases.md) and [Linux receipt](reports/rds-service-scopes-20260926.md). This does not close enrollment, account/tenant isolation or native platform gates. +W2.3 implementation update (2026-09-27): grant payload v3 adds `tenant` and +`policy_revision` claims plus a `sync_paths` subtree allowlist under +[grant-leases](grant-leases.md). Version-dispatched decode still verifies v2 +grants; agents pin the binding via `authority.tenant`/`policy_min_revision` +(`--tenant`/`--policy-min-revision`) and refuse unscoped or mismatched grants. +Sync `Offer`/`Request` enforce the signed path scope after relative-path +normalization and before any filesystem access. Account-level scopes, +automatic GDS issuance/renewal and policy reconciliation remain +cross-repository work. + ## W3 — complete owned connectivity and recovery Proposed gate: `r3-connectivity-parity`. Scope: net/noq, relay, server, bench. diff --git a/docs/remediation-progress.md b/docs/remediation-progress.md index 7895d4e..7f9af2c 100644 --- a/docs/remediation-progress.md +++ b/docs/remediation-progress.md @@ -47,7 +47,7 @@ Neither increment closes these product gaps or any wave. | W1.10 | Partial; Linux transaction checks passed | Root and destination-parent locks cover overlapping roots and filesystem aliases. Reserved private staging, both-parent sync and bounded known-name recovery are implemented. 23 transaction/cleanup boundaries cover process exit and two returned-error classes. Physical power loss, native macOS, large-file campaign and inactive/legacy journal collection remain open. | | W2.1 | Implemented; endpoint + agent settings checked on Linux | Shared version-1 endpoint JSON, explicit file/flag precedence, typed backend/relay validation and preflight before identity creation are implemented. The version-1 agent JSON now carries role/service/peers/authority/limits/timeouts with the same precedence and preflight; `--role`/`--service`/`--no-service` select the gateable service set, disabled services are refused by name ahead of grant machinery, `Info` and directory announcements advertise exactly the served set, and handshake/hello deadlines come from `TimeoutPolicy`. See [agent configuration](agent-configuration.md). | | W2.2 | Partial; exact ALPN selection + sync/desktop session routing | Immutable per-protocol TLS offers prevent silent fallback and concurrent request interference. Managed single-file transfers use fresh control/uni routing IDs and negotiate version/limits in the `SyncTransferV2` session envelope before any filesystem operation. Desktop sessions mint random per-session IDs in `StreamHello::DesktopV2` and route frames through `UniHello::DesktopFrames { id }`, isolating stale streams and allowing concurrent sessions; the same display grant scope check covers both greetings. Service-wide capability negotiation remains open. | -| W2.3 | Partial; destination-bound renewable grants and directional scopes | Grant v2 adds a strict signature domain, audience and stable session ID across positive lease revisions. Same-scope renewal preserves streams/revocation, retains one replay slot/watchdog and enforces wall/continuous expiry. Explicit managed renewal uses IPC v3 and a control-completion barrier. `SyncRead`/`SyncWrite` and `DesktopView`/`DesktopControl` are enforced before filesystem/input operations. Tenant/policy binding, per-path/account scopes and automatic GDS issuer integration remain open. See [contract](grant-leases.md). | +| W2.3 | Partial; destination-bound renewable grants, directional scopes, tenant/policy binding and per-path sync scopes | Grant v2 adds a strict signature domain, audience and stable session ID across positive lease revisions. Same-scope renewal preserves streams/revocation, retains one replay slot/watchdog and enforces wall/continuous expiry. Explicit managed renewal uses IPC v3 and a control-completion barrier. `SyncRead`/`SyncWrite` and `DesktopView`/`DesktopControl` are enforced before filesystem/input operations. Grant v3 adds the `tenant`/`policy_revision` claims and the `constraints.sync_paths` subtree scope, checked after `rel_path` normalization before any filesystem work; v2 payloads still verify with all claims absent, and agents pin the binding via `authority.tenant`/`policy_min_revision` (`--tenant`/`--policy-min-revision`), refusing unscoped or stale grants. Account-level scopes and automatic GDS issuer integration remain open. See [contract](grant-leases.md). | | W2.4 | Partial; default connectivity manager | Agent local control is enabled by default; ordinary ticket/ping/info/SSH/forward/send/recv commands and keyless `rds session` reuse its endpoint. Same-UID IPC, pinned streams, cancellation and aggregate metrics are implemented. Agent/direct CLI/owned relay acquire exclusive ownership of a validated seed inode. Viewer manager APIs, coordinated installed-binary migration, native macOS and real multi-user/relay qualification remain open. See [contract](local-sessions.md) and [migration receipt](reports/rds-identity-migration-20260925.md). | | W2.5 | Partial; transport and agent task ownership | Owned policy tasks terminate, including explicit shutdown after stopped protocol I/O; uni routing is bounded and acyclic. Agent and client forwarding groups own cancellation, normal joins and positive admission budgets. Client relay queues/peer leases and server admission/owned shutdown are bounded. Metric samplers use weak backend observations, release their gauges on drop and wake on closure independently of the sampling interval. Global RSS/FD bounds, per-service fairness and broader disk/media cancellation remain open. | | W2.6 | Partial; client preludes bounded | One request deadline covers stream credit, writes, replies and Ping echo; canceled Authz closes its connection. Agent and owned relay handshake/shutdown budgets exist; canceling relay drain does not cancel cleanup. Agent local startup no longer waits indefinitely for an iroh relay, including disabled/unavailable relay mode. Global timeout classes, retry jitter, broader startup recovery and desktop/media deadlines remain open. | @@ -1965,3 +1965,42 @@ e2e proves the gate on a real agent: an explicit `{tcp}` set refuses sync/audio by name despite a configured sync root, `Info` lists exactly the enabled set, disabled-service refusal precedes grant requirements, and the greeting deadline follows the configured timeout. + +## Grant v3 — tenant/policy binding and per-path sync scopes (2026-09-27) + +W2.3 executable scope. `GrantPayload` advances to version 3: `tenant` +binds a grant to an estate tenant identifier, `policy_revision` records +the estate policy revision the issuer minted under, and +`constraints.sync_paths` scopes Sync reads and writes to signed subtree +allowlists (≤64 normalized relative entries). The signature domain tracks +the payload version (`rds/capability-grant/v3`), so a v3 payload signed +under the old domain fails verification. + +The decoder dispatches on the leading version varint and accepts versions +2 and 3. Version-2 payloads verify with all v3 claims absent — grants +minted before the estate issuer learns v3 stay valid across the cutover — +and the enforcement point is the pinned binding, not the version number: +an agent configured with `authority.tenant`/`policy_min_revision` +(`--tenant`/`--policy-min-revision`) refuses unscoped, mismatched or +stale-revision grants at authorization. Pinning a binding without any +trusted issuer would never evaluate a grant, so validation refuses that +combination outright. Renewal cannot alter either claim or the path +scope — a change is an `InvalidRenewal` requiring fresh authorization. + +`Access` carries the grant's normalized `sync_paths` into the sync +engine; both the `Request` (pull) and `Offer` (push) arms check the +normalized `rel_path` against the scope after `check_rel_path` and before +any filesystem handle, manifest or journal work. Refusal is by name +(`sync path outside granted scope`) with no filesystem detail. + +Coverage: grant unit tests pin the version contract — v3 claims verify +and carry scope, genuine v2 bytes still verify, a v3 payload relabeled v2 +fails decode, unsupported versions refuse, claim bounds are enforced, and +renewal cannot change claims. Settings tests cover tenant/revision +parsing, the issuer requirement and flag-over-file merge. The +`grant_scopes` e2e drives a real pinned agent: unscoped, mismatched and +stale-revision grants fail authorization; a bound grant syncs inside its +scope while sibling, traversal and write escapes refuse by name. + +Remaining W2.3: account-level scopes (OS identity), automatic GDS +issuance/renewal and policy reconciliation — cross-repository, deferred.