Authorize remote modules with bearer proofs

This commit is contained in:
Eric Wendland 2026-05-19 19:16:30 +02:00
commit 7e39a19c18
8 changed files with 572 additions and 120 deletions

View file

@ -179,8 +179,10 @@ Roadmap items should be actionable and checkable:
Bearer access metadata can be created/listed/revoked as resource-scoped auth Bearer access metadata can be created/listed/revoked as resource-scoped auth
ops and must not allow trust graph mutation capabilities. Bearer ops and must not allow trust graph mutation capabilities. Bearer
challenge/proof/verify commands exist for resource-scoped possession checks. challenge/proof/verify commands exist for resource-scoped possession checks.
Payload encryption, key envelopes, and wiring bearer proofs into remote module Remote module authorization paths can accept optional bearer proofs without
authorization are still roadmap work. enrolling the caller as a trusted node. Payload encryption, key envelopes, and
separating public bearer ids from private bearer tokens are still roadmap
work.
- SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or - SSH revocations can be exported as JSONL, OpenSSH KRL specification text, or
binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL binary OpenSSH KRL files generated through `ssh-keygen`. JSONL and OpenSSH KRL
specification imports are supported; binary KRL import is unsupported because specification imports are supported; binary KRL import is unsupported because

View file

@ -105,7 +105,8 @@ The bootstrap implementation provides:
- `geth auth grant <subject> <resource> <capability> [--grant-id <id>]` - `geth auth grant <subject> <resource> <capability> [--grant-id <id>]`
- `geth auth revoke <resource> <grant-id>` - `geth auth revoke <resource> <grant-id>`
- local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`, - local filesystem CAS commands: `add`, `get`, `fetch`, `hash`, `has`, `pin`,
`unpin`, `cleanup`, `providers`, `list` `unpin`, `cleanup`, `providers`, `list`; remote fetch accepts
`--bearer-secret <secret>`
- local CAS tree objects describe file trees and are stored as CAS blobs - local CAS tree objects describe file trees and are stored as CAS blobs
- local file-root commands: `geth cas root add/list/scan` - local file-root commands: `geth cas root add/list/scan`
- local file conflict metadata commands: - local file conflict metadata commands:
@ -117,9 +118,11 @@ The bootstrap implementation provides:
`geth db sync <node-id> <name>` `geth db sync <node-id> <name>`
- local SQLite-backed KV commands: `geth kv create/set/get`; `kv set` accepts - local SQLite-backed KV commands: `geth kv create/set/get`; `kv set` accepts
`--subject <principal>` to exercise local capability checks for non-local `--subject <principal>` to exercise local capability checks for non-local
callers; `geth kv sync <node-id> <name>` pulls authorized remote updates callers; `geth kv sync <node-id> <name> [--bearer-secret <secret>]` pulls
authorized remote updates
- local JSON document commands: `geth document create/status/set/get`; `geth - local JSON document commands: `geth document create/status/set/get`; `geth
document sync <node-id> <name>` pulls authorized remote JSON state document sync <node-id> <name> [--bearer-secret <secret>]` pulls authorized
remote JSON state
- local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`; `geth pubsub - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub`; `geth pubsub
pub <topic> <message> --node <node-id>` publishes to an authorized peer; pub <topic> <message> --node <node-id>` publishes to an authorized peer;
`geth pubsub sub <topic> --node <node-id>` reads an authorized peer snapshot `geth pubsub sub <topic> --node <node-id>` reads an authorized peer snapshot
@ -129,15 +132,15 @@ The bootstrap implementation provides:
- `geth ssh cert approve <request-id> --ca-key <path> [--sign] [--subject <principal>]` - `geth ssh cert approve <request-id> --ca-key <path> [--sign] [--subject <principal>]`
- `geth ssh cert import <request-id> --cert <path> [--subject <principal>]` - `geth ssh cert import <request-id> --cert <path> [--subject <principal>]`
- `geth ssh cert list [--subject <principal>]` - `geth ssh cert list [--subject <principal>]`
- `geth ssh cert sync <node-id>` - `geth ssh cert sync <node-id> [--bearer-secret <secret>]`
- `geth ssh revocation add <kind> <target> [--subject <principal>]` - `geth ssh revocation add <kind> <target> [--subject <principal>]`
- `geth ssh revocation list [--subject <principal>]` - `geth ssh revocation list [--subject <principal>]`
- `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl] [--subject <principal>]` - `geth ssh revocation export --out <path> [--format jsonl|openssh-krl-spec|openssh-krl] [--subject <principal>]`
- `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec] [--subject <principal>]` - `geth ssh revocation import <path> [--format jsonl|openssh-krl-spec] [--subject <principal>]`
- `geth ssh revocation sync <node-id>` - `geth ssh revocation sync <node-id> [--bearer-secret <secret>]`
- SSH proxy authorization probe: `geth ssh proxy <node-id>` - SSH proxy authorization probe: `geth ssh proxy <node-id> [--bearer-secret <secret>]`
- pipe registry/connect commands: `geth pipe listen <name>` and - pipe registry/connect commands: `geth pipe listen <name>` and
`geth pipe connect <name> [--node <node-id>]` `geth pipe connect <name> [--node <node-id>] [--bearer-secret <secret>]`
`geth peer export/import/list` is for untrusted peer-card exchange. Peer cards `geth peer export/import/list` is for untrusted peer-card exchange. Peer cards
include the Iroh EndpointID plus currently known relay/direct addresses. include the Iroh EndpointID plus currently known relay/direct addresses.
@ -153,6 +156,9 @@ hash to the requested BLAKE3 CAS hash before storing them locally. Successful
fetches record the serving peer as a local provider, visible with fetches record the serving peer as a local provider, visible with
`geth cas providers <hash>`. This is the bootstrap transfer path; future work `geth cas providers <hash>`. This is the bootstrap transfer path; future work
will move provider/fetch behavior to `iroh-blobs`. will move provider/fetch behavior to `iroh-blobs`.
Remote resource commands that accept `--bearer-secret` can also authorize with a
resource-scoped bearer proof. This does not enroll the caller as a trusted node;
it only unlocks the requested capability on that one resource.
`geth ssh cert sync <node-id>` requires `ssh_cert.sync` on `resource:ssh:certs` `geth ssh cert sync <node-id>` requires `ssh_cert.sync` on `resource:ssh:certs`
at the peer. `geth ssh revocation sync <node-id>` requires at the peer. `geth ssh revocation sync <node-id>` requires
`ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge `ssh_revocation.sync` on `resource:ssh:revocations`. Both commands merge

View file

@ -251,6 +251,8 @@ pub enum CasCommand {
Fetch { Fetch {
node: String, node: String,
hash: String, hash: String,
#[arg(long)]
bearer_secret: Option<String>,
}, },
Hash { Hash {
path: PathBuf, path: PathBuf,
@ -335,6 +337,8 @@ pub enum KvCommand {
Sync { Sync {
node: String, node: String,
name: String, name: String,
#[arg(long)]
bearer_secret: Option<String>,
}, },
} }
@ -345,11 +349,15 @@ pub enum PubsubCommand {
message: String, message: String,
#[arg(long)] #[arg(long)]
node: Option<String>, node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
}, },
Sub { Sub {
topic: String, topic: String,
#[arg(long)] #[arg(long)]
node: Option<String>, node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
}, },
} }
@ -362,6 +370,8 @@ pub enum PipeCommand {
target: String, target: String,
#[arg(long)] #[arg(long)]
node: Option<String>, node: Option<String>,
#[arg(long)]
bearer_secret: Option<String>,
}, },
} }
@ -386,22 +396,40 @@ pub enum DbCommand {
name: String, name: String,
#[arg(long, default_value_t = 100)] #[arg(long, default_value_t = 100)]
limit: u32, limit: u32,
#[arg(long)]
bearer_secret: Option<String>,
}, },
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum DocumentCommand { pub enum DocumentCommand {
Create { name: String }, Create {
Status { name: String }, name: String,
Set { name: String, state_json: String }, },
Get { name: String }, Status {
Sync { node: String, name: String }, name: String,
},
Set {
name: String,
state_json: String,
},
Get {
name: String,
},
Sync {
node: String,
name: String,
#[arg(long)]
bearer_secret: Option<String>,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum SshCommand { pub enum SshCommand {
Proxy { Proxy {
node: String, node: String,
#[arg(long)]
bearer_secret: Option<String>,
}, },
Cert { Cert {
#[command(subcommand)] #[command(subcommand)]
@ -463,6 +491,8 @@ pub enum SshCertCommand {
}, },
Sync { Sync {
node: String, node: String,
#[arg(long)]
bearer_secret: Option<String>,
}, },
} }
@ -499,6 +529,8 @@ pub enum SshRevocationCommand {
}, },
Sync { Sync {
node: String, node: String,
#[arg(long)]
bearer_secret: Option<String>,
}, },
} }
@ -671,9 +703,14 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
hash: hash.into(), hash: hash.into(),
out, out,
}, },
CasCommand::Fetch { node, hash } => ControlRequest::CasFetch { CasCommand::Fetch {
node,
hash,
bearer_secret,
} => ControlRequest::CasFetch {
node, node,
hash: hash.into(), hash: hash.into(),
bearer_secret,
}, },
CasCommand::Hash { path } => ControlRequest::CasHash { path }, CasCommand::Hash { path } => ControlRequest::CasHash { path },
CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() }, CasCommand::Has { hash } => ControlRequest::CasHas { hash: hash.into() },
@ -731,23 +768,49 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
subject, subject,
}, },
KvCommand::Get { name, key } => ControlRequest::KvGet { name, key }, KvCommand::Get { name, key } => ControlRequest::KvGet { name, key },
KvCommand::Sync { node, name } => ControlRequest::KvSync { node, name }, KvCommand::Sync {
node,
name,
bearer_secret,
} => ControlRequest::KvSync {
node,
name,
bearer_secret,
},
}, },
Command::Pubsub { command } => match command { Command::Pubsub { command } => match command {
PubsubCommand::Pub { PubsubCommand::Pub {
topic, topic,
message, message,
node, node,
bearer_secret,
} => ControlRequest::PubsubPub { } => ControlRequest::PubsubPub {
topic, topic,
message, message,
node, node,
bearer_secret,
},
PubsubCommand::Sub {
topic,
node,
bearer_secret,
} => ControlRequest::PubsubSub {
topic,
node,
bearer_secret,
}, },
PubsubCommand::Sub { topic, node } => ControlRequest::PubsubSub { topic, node },
}, },
Command::Pipe { command } => match command { Command::Pipe { command } => match command {
PipeCommand::Listen { name } => ControlRequest::PipeListen { name }, PipeCommand::Listen { name } => ControlRequest::PipeListen { name },
PipeCommand::Connect { target, node } => ControlRequest::PipeConnect { target, node }, PipeCommand::Connect {
target,
node,
bearer_secret,
} => ControlRequest::PipeConnect {
target,
node,
bearer_secret,
},
}, },
Command::Db { command } => match command { Command::Db { command } => match command {
DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path }, DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path },
@ -761,7 +824,17 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
after_db_version, after_db_version,
limit, limit,
}, },
DbCommand::Sync { node, name, limit } => ControlRequest::DbSync { node, name, limit }, DbCommand::Sync {
node,
name,
limit,
bearer_secret,
} => ControlRequest::DbSync {
node,
name,
limit,
bearer_secret,
},
}, },
Command::Document { command } => match command { Command::Document { command } => match command {
DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name }, DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name },
@ -770,10 +843,24 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
ControlRequest::DocumentSet { name, state_json } ControlRequest::DocumentSet { name, state_json }
} }
DocumentCommand::Get { name } => ControlRequest::DocumentGet { name }, DocumentCommand::Get { name } => ControlRequest::DocumentGet { name },
DocumentCommand::Sync { node, name } => ControlRequest::DocumentSync { node, name }, DocumentCommand::Sync {
node,
name,
bearer_secret,
} => ControlRequest::DocumentSync {
node,
name,
bearer_secret,
},
}, },
Command::Ssh { command } => match command { Command::Ssh { command } => match command {
SshCommand::Proxy { node } => ControlRequest::SshProxyConnect { node }, SshCommand::Proxy {
node,
bearer_secret,
} => ControlRequest::SshProxyConnect {
node,
bearer_secret,
},
SshCommand::Cert { command } => match command { SshCommand::Cert { command } => match command {
SshCertCommand::Request { SshCertCommand::Request {
public_key, public_key,
@ -820,7 +907,13 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
subject, subject,
}, },
SshCertCommand::List { subject } => ControlRequest::SshCertList { subject }, SshCertCommand::List { subject } => ControlRequest::SshCertList { subject },
SshCertCommand::Sync { node } => ControlRequest::SshCertSync { node }, SshCertCommand::Sync {
node,
bearer_secret,
} => ControlRequest::SshCertSync {
node,
bearer_secret,
},
}, },
SshCommand::Revocation { command } => match command { SshCommand::Revocation { command } => match command {
SshRevocationCommand::Add { SshRevocationCommand::Add {
@ -857,7 +950,13 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
format, format,
subject, subject,
}, },
SshRevocationCommand::Sync { node } => ControlRequest::SshRevocationSync { node }, SshRevocationCommand::Sync {
node,
bearer_secret,
} => ControlRequest::SshRevocationSync {
node,
bearer_secret,
},
}, },
}, },
Command::Init | Command::Daemon { .. } => bail!("command is handled directly"), Command::Init | Command::Daemon { .. } => bail!("command is handled directly"),

View file

@ -52,6 +52,7 @@ pub enum ControlRequest {
CasFetch { CasFetch {
node: String, node: String,
hash: BlobHash, hash: BlobHash,
bearer_secret: Option<String>,
}, },
CasHash { CasHash {
path: PathBuf, path: PathBuf,
@ -182,6 +183,7 @@ pub enum ControlRequest {
}, },
SshCertSync { SshCertSync {
node: String, node: String,
bearer_secret: Option<String>,
}, },
SshRevocationAdd { SshRevocationAdd {
kind: String, kind: String,
@ -205,9 +207,11 @@ pub enum ControlRequest {
}, },
SshRevocationSync { SshRevocationSync {
node: String, node: String,
bearer_secret: Option<String>,
}, },
SshProxyConnect { SshProxyConnect {
node: String, node: String,
bearer_secret: Option<String>,
}, },
DbAdd { DbAdd {
name: String, name: String,
@ -225,6 +229,7 @@ pub enum ControlRequest {
node: String, node: String,
name: String, name: String,
limit: u32, limit: u32,
bearer_secret: Option<String>,
}, },
KvCreate { KvCreate {
name: String, name: String,
@ -242,6 +247,7 @@ pub enum ControlRequest {
KvSync { KvSync {
node: String, node: String,
name: String, name: String,
bearer_secret: Option<String>,
}, },
DocumentCreate { DocumentCreate {
name: String, name: String,
@ -259,15 +265,18 @@ pub enum ControlRequest {
DocumentSync { DocumentSync {
node: String, node: String,
name: String, name: String,
bearer_secret: Option<String>,
}, },
PubsubPub { PubsubPub {
topic: String, topic: String,
message: String, message: String,
node: Option<String>, node: Option<String>,
bearer_secret: Option<String>,
}, },
PubsubSub { PubsubSub {
topic: String, topic: String,
node: Option<String>, node: Option<String>,
bearer_secret: Option<String>,
}, },
PipeListen { PipeListen {
name: String, name: String,
@ -275,6 +284,7 @@ pub enum ControlRequest {
PipeConnect { PipeConnect {
target: String, target: String,
node: Option<String>, node: Option<String>,
bearer_secret: Option<String>,
}, },
ModuleStub { ModuleStub {
module: String, module: String,
@ -666,48 +676,57 @@ pub enum PeerControlRequest {
peer_card: PeerCard, peer_card: PeerCard,
hash: BlobHash, hash: BlobHash,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
SshCertSync { SshCertSync {
peer_card: PeerCard, peer_card: PeerCard,
since_ms: i64, since_ms: i64,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
SshRevocationSync { SshRevocationSync {
peer_card: PeerCard, peer_card: PeerCard,
since_ms: i64, since_ms: i64,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
KvSync { KvSync {
peer_card: PeerCard, peer_card: PeerCard,
name: String, name: String,
since_ms: i64, since_ms: i64,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
PubsubPublish { PubsubPublish {
peer_card: PeerCard, peer_card: PeerCard,
topic: String, topic: String,
message: String, message: String,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
PubsubSubscribe { PubsubSubscribe {
peer_card: PeerCard, peer_card: PeerCard,
topic: String, topic: String,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
PipeConnect { PipeConnect {
peer_card: PeerCard, peer_card: PeerCard,
target: String, target: String,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
SshProxyConnect { SshProxyConnect {
peer_card: PeerCard, peer_card: PeerCard,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
DocumentSync { DocumentSync {
peer_card: PeerCard, peer_card: PeerCard,
name: String, name: String,
since_ms: i64, since_ms: i64,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
DbSync { DbSync {
peer_card: PeerCard, peer_card: PeerCard,
@ -715,6 +734,7 @@ pub enum PeerControlRequest {
after_db_version: Option<i64>, after_db_version: Option<i64>,
limit: u32, limit: u32,
nonce: String, nonce: String,
bearer_proof: Option<BearerProof>,
}, },
} }
@ -1000,6 +1020,7 @@ mod tests {
let request = ControlRequest::CasFetch { let request = ControlRequest::CasFetch {
node: "node:peer".to_owned(), node: "node:peer".to_owned(),
hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), hash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
bearer_secret: Some("bearer:test".to_owned()),
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1050,6 +1071,7 @@ mod tests {
let request = ControlRequest::SshCertSync { let request = ControlRequest::SshCertSync {
node: "node:ca".to_owned(), node: "node:ca".to_owned(),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1058,6 +1080,7 @@ mod tests {
let request = ControlRequest::SshRevocationSync { let request = ControlRequest::SshRevocationSync {
node: "node:ca".to_owned(), node: "node:ca".to_owned(),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1107,6 +1130,7 @@ mod tests {
let request = ControlRequest::KvSync { let request = ControlRequest::KvSync {
node: "node:peer".to_owned(), node: "node:peer".to_owned(),
name: "prefs".to_owned(), name: "prefs".to_owned(),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1148,6 +1172,7 @@ mod tests {
let request = ControlRequest::PipeConnect { let request = ControlRequest::PipeConnect {
target: "inbox".to_owned(), target: "inbox".to_owned(),
node: Some("node:peer".to_owned()), node: Some("node:peer".to_owned()),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1158,6 +1183,7 @@ mod tests {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
message: "online".to_owned(), message: "online".to_owned(),
node: Some("node:peer".to_owned()), node: Some("node:peer".to_owned()),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1167,6 +1193,7 @@ mod tests {
let request = ControlRequest::PubsubSub { let request = ControlRequest::PubsubSub {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
node: Some("node:peer".to_owned()), node: Some("node:peer".to_owned()),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1231,6 +1258,7 @@ mod tests {
let request = ControlRequest::SshProxyConnect { let request = ControlRequest::SshProxyConnect {
node: "node:peer".to_owned(), node: "node:peer".to_owned(),
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1271,6 +1299,7 @@ mod tests {
node: "node:peer".to_owned(), node: "node:peer".to_owned(),
name: "notes".to_owned(), name: "notes".to_owned(),
limit: 50, limit: 50,
bearer_secret: None,
}; };
assert_eq!( assert_eq!(
decode_request(&encode_request(&request).expect("encode")).expect("decode"), decode_request(&encode_request(&request).expect("encode")).expect("decode"),
@ -1426,6 +1455,7 @@ mod tests {
after_db_version: Some(7), after_db_version: Some(7),
limit: 10, limit: 10,
nonce: "nonce".to_owned(), nonce: "nonce".to_owned(),
bearer_proof: None,
}; };
assert_eq!( assert_eq!(
decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"), decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"),
@ -1552,6 +1582,7 @@ mod tests {
}, },
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
nonce: "nonce".to_owned(), nonce: "nonce".to_owned(),
bearer_proof: None,
}; };
assert_eq!( assert_eq!(
decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"), decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"),
@ -1618,6 +1649,13 @@ mod tests {
}, },
}, },
nonce: "nonce".to_owned(), nonce: "nonce".to_owned(),
bearer_proof: Some(BearerProof {
secret: "bearer:test".into(),
resource: "resource:ssh-proxy:local".into(),
capabilities: vec!["ssh_proxy.connect".into()],
nonce: "nonce".to_owned(),
response: "response".to_owned(),
}),
}; };
assert_eq!( assert_eq!(
decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"), decode_peer_request(&encode_peer_request(&request).expect("encode")).expect("decode"),

View file

@ -255,42 +255,52 @@ pub async fn handle_request_async(
ControlRequest::CasFetch { ControlRequest::CasFetch {
node: peer_node, node: peer_node,
hash, hash,
} => cas_fetch_from_peer(node, &peer_node, hash).await, bearer_secret,
ControlRequest::SshCertSync { node: peer_node } => { } => cas_fetch_from_peer(node, &peer_node, hash, bearer_secret).await,
ssh_cert_sync_from_peer(node, &peer_node).await ControlRequest::SshCertSync {
} node: peer_node,
ControlRequest::SshRevocationSync { node: peer_node } => { bearer_secret,
ssh_revocation_sync_from_peer(node, &peer_node).await } => ssh_cert_sync_from_peer(node, &peer_node, bearer_secret).await,
} ControlRequest::SshRevocationSync {
node: peer_node,
bearer_secret,
} => ssh_revocation_sync_from_peer(node, &peer_node, bearer_secret).await,
ControlRequest::KvSync { ControlRequest::KvSync {
node: peer_node, node: peer_node,
name, name,
} => kv_sync_from_peer(node, &peer_node, &name).await, bearer_secret,
} => kv_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
ControlRequest::DbSync { ControlRequest::DbSync {
node: peer_node, node: peer_node,
name, name,
limit, limit,
} => db_sync_from_peer(node, &peer_node, &name, limit).await, bearer_secret,
} => db_sync_from_peer(node, &peer_node, &name, limit, bearer_secret).await,
ControlRequest::PubsubPub { ControlRequest::PubsubPub {
topic, topic,
message, message,
node: Some(peer_node), node: Some(peer_node),
} => pubsub_publish_to_peer(node, &peer_node, topic, message).await, bearer_secret,
} => pubsub_publish_to_peer(node, &peer_node, topic, message, bearer_secret).await,
ControlRequest::PubsubSub { ControlRequest::PubsubSub {
topic, topic,
node: Some(peer_node), node: Some(peer_node),
} => pubsub_subscribe_from_peer(node, &peer_node, topic).await, bearer_secret,
} => pubsub_subscribe_from_peer(node, &peer_node, topic, bearer_secret).await,
ControlRequest::PipeConnect { ControlRequest::PipeConnect {
target, target,
node: Some(peer_node), node: Some(peer_node),
} => pipe_connect_to_peer(node, &peer_node, target).await, bearer_secret,
ControlRequest::SshProxyConnect { node: peer_node } => { } => pipe_connect_to_peer(node, &peer_node, target, bearer_secret).await,
ssh_proxy_connect_to_peer(node, &peer_node).await ControlRequest::SshProxyConnect {
} node: peer_node,
bearer_secret,
} => ssh_proxy_connect_to_peer(node, &peer_node, bearer_secret).await,
ControlRequest::DocumentSync { ControlRequest::DocumentSync {
node: peer_node, node: peer_node,
name, name,
} => document_sync_from_peer(node, &peer_node, &name).await, bearer_secret,
} => document_sync_from_peer(node, &peer_node, &name, bearer_secret).await,
other => handle_request(node, other), other => handle_request(node, other),
} }
} }
@ -699,6 +709,7 @@ async fn cas_fetch_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
hash: BlobHash, hash: BlobHash,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
let store = Store::open(&node.paths.metadata_db())?; let store = Store::open(&node.paths.metadata_db())?;
let stored = store let stored = store
@ -733,6 +744,7 @@ async fn cas_fetch_from_peer(
peer_card: self_card, peer_card: self_card,
hash: hash.clone(), hash: hash.clone(),
nonce: nonce.clone(), nonce: nonce.clone(),
bearer_proof: bearer_proof(bearer_secret, "resource:cas:local", "cas.fetch", &nonce),
}; };
let conn = endpoint let conn = endpoint
@ -843,6 +855,7 @@ async fn cas_fetch_from_peer(
async fn ssh_cert_sync_from_peer( async fn ssh_cert_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
let since_ms = load_live_sync_cursor( let since_ms = load_live_sync_cursor(
&Store::open(&node.paths.metadata_db())?, &Store::open(&node.paths.metadata_db())?,
@ -853,7 +866,13 @@ async fn ssh_cert_sync_from_peer(
PeerControlRequest::SshCertSync { PeerControlRequest::SshCertSync {
peer_card, peer_card,
since_ms, since_ms,
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh:certs",
"ssh_cert.sync",
&nonce,
),
} }
}) })
.await?; .await?;
@ -913,6 +932,7 @@ async fn ssh_cert_sync_from_peer(
async fn ssh_revocation_sync_from_peer( async fn ssh_revocation_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
let since_ms = load_live_sync_cursor( let since_ms = load_live_sync_cursor(
&Store::open(&node.paths.metadata_db())?, &Store::open(&node.paths.metadata_db())?,
@ -926,7 +946,13 @@ async fn ssh_revocation_sync_from_peer(
|peer_card, nonce| PeerControlRequest::SshRevocationSync { |peer_card, nonce| PeerControlRequest::SshRevocationSync {
peer_card, peer_card,
since_ms, since_ms,
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh:revocations",
"ssh_revocation.sync",
&nonce,
),
}, },
) )
.await?; .await?;
@ -980,6 +1006,7 @@ async fn kv_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
name: &str, name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
geth_kv::validate_kv_name(name).map_err(|_| NodeError::InvalidKvName(name.to_owned()))?; geth_kv::validate_kv_name(name).map_err(|_| NodeError::InvalidKvName(name.to_owned()))?;
let stream = format!("kv:{name}"); let stream = format!("kv:{name}");
@ -990,7 +1017,13 @@ async fn kv_sync_from_peer(
peer_card, peer_card,
name: name.to_owned(), name: name.to_owned(),
since_ms, since_ms,
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:kv:{name}"),
"kv.read",
&nonce,
),
} }
}) })
.await?; .await?;
@ -1065,6 +1098,7 @@ async fn pubsub_publish_to_peer(
peer_node: &str, peer_node: &str,
topic: String, topic: String,
message: String, message: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?; geth_pubsub::validate_message(&message)?;
@ -1073,7 +1107,13 @@ async fn pubsub_publish_to_peer(
peer_card, peer_card,
topic: topic.clone(), topic: topic.clone(),
message: message.clone(), message: message.clone(),
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pubsub:{topic}"),
"pubsub.publish",
&nonce,
),
} }
}) })
.await?; .await?;
@ -1114,13 +1154,20 @@ async fn pubsub_subscribe_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
topic: String, topic: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
let response = request_peer_control(node, peer_node, "pubsub-subscribe", |peer_card, nonce| { let response = request_peer_control(node, peer_node, "pubsub-subscribe", |peer_card, nonce| {
PeerControlRequest::PubsubSubscribe { PeerControlRequest::PubsubSubscribe {
peer_card, peer_card,
topic: topic.clone(), topic: topic.clone(),
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pubsub:{topic}"),
"pubsub.subscribe",
&nonce,
),
} }
}) })
.await?; .await?;
@ -1159,13 +1206,20 @@ async fn pipe_connect_to_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
target: String, target: String,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
geth_pipe::validate_pipe_name(&target)?; geth_pipe::validate_pipe_name(&target)?;
let response = request_peer_control(node, peer_node, "pipe-connect", |peer_card, nonce| { let response = request_peer_control(node, peer_node, "pipe-connect", |peer_card, nonce| {
PeerControlRequest::PipeConnect { PeerControlRequest::PipeConnect {
peer_card, peer_card,
target: target.clone(), target: target.clone(),
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:pipe:{target}"),
"pipe.connect",
&nonce,
),
} }
}) })
.await?; .await?;
@ -1206,10 +1260,20 @@ async fn pipe_connect_to_peer(
async fn ssh_proxy_connect_to_peer( async fn ssh_proxy_connect_to_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
let response = let response =
request_peer_control(node, peer_node, "ssh-proxy-connect", |peer_card, nonce| { request_peer_control(node, peer_node, "ssh-proxy-connect", |peer_card, nonce| {
PeerControlRequest::SshProxyConnect { peer_card, nonce } PeerControlRequest::SshProxyConnect {
peer_card,
nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
"resource:ssh-proxy:local",
"ssh_proxy.connect",
&nonce,
),
}
}) })
.await?; .await?;
match response { match response {
@ -1242,6 +1306,7 @@ async fn document_sync_from_peer(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
name: &str, name: &str,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
geth_document::validate_document_name(name) geth_document::validate_document_name(name)
.map_err(|_| NodeError::InvalidDocumentName(name.to_owned()))?; .map_err(|_| NodeError::InvalidDocumentName(name.to_owned()))?;
@ -1253,7 +1318,13 @@ async fn document_sync_from_peer(
peer_card, peer_card,
name: name.to_owned(), name: name.to_owned(),
since_ms, since_ms,
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:document:{name}"),
"document.read",
&nonce,
),
} }
}) })
.await?; .await?;
@ -1347,6 +1418,7 @@ async fn db_sync_from_peer(
peer_node: &str, peer_node: &str,
name: &str, name: &str,
limit: u32, limit: u32,
bearer_secret: Option<String>,
) -> Result<ControlResponse, NodeError> { ) -> Result<ControlResponse, NodeError> {
geth_db::validate_db_name(name).map_err(|_| NodeError::InvalidDbName(name.to_owned()))?; geth_db::validate_db_name(name).map_err(|_| NodeError::InvalidDbName(name.to_owned()))?;
let store = Store::open(&node.paths.metadata_db())?; let store = Store::open(&node.paths.metadata_db())?;
@ -1362,7 +1434,13 @@ async fn db_sync_from_peer(
name: name.to_owned(), name: name.to_owned(),
after_db_version, after_db_version,
limit, limit,
nonce, nonce: nonce.clone(),
bearer_proof: bearer_proof(
bearer_secret,
&format!("resource:db:{name}"),
"db.sync",
&nonce,
),
} }
}) })
.await?; .await?;
@ -1585,6 +1663,123 @@ fn can_sync_resource(
.allowed) .allowed)
} }
fn bearer_proof(
bearer_secret: Option<String>,
resource: &str,
capability: &str,
nonce: &str,
) -> Option<BearerProof> {
bearer_secret.map(|secret| {
let secret = geth_types::SecretId::new(secret);
let resource = ResourceId::new(resource.to_owned());
let capabilities = vec![Capability::new(capability.to_owned())];
let response = geth_secrets::bearer_response(&secret, &resource, &capabilities, nonce);
BearerProof {
secret,
resource,
capabilities,
nonce: nonce.to_owned(),
response,
}
})
}
fn explain_peer_or_bearer(
store: &Store,
peer_node: &str,
resource: &str,
capability: &str,
nonce: &str,
bearer_proof: Option<&BearerProof>,
) -> Result<AuthExplanation, NodeError> {
let ops = load_auth_ops_for_resource(store, resource)?;
let resource_id = ResourceId::new(resource.to_owned());
let capability_id = Capability::new(capability.to_owned());
let peer_explanation = geth_auth::explain_auth_ops(
&ops,
PrincipalId::new(peer_node.to_owned()),
resource_id.clone(),
capability_id.clone(),
);
if peer_explanation.allowed {
return Ok(peer_explanation);
}
let Some(proof) = bearer_proof else {
return Ok(peer_explanation);
};
let bearer_subject = format!("bearer:{}", proof.secret);
let denied = |reason: String| AuthExplanation {
subject: bearer_subject.clone(),
resource: resource.to_owned(),
capability: capability.to_owned(),
allowed: false,
reason,
evaluated_ops: peer_explanation.evaluated_ops,
};
if proof.resource != resource_id {
return Ok(denied(format!(
"bearer proof resource {} does not match requested resource {resource}",
proof.resource
)));
}
if proof.nonce != nonce {
return Ok(denied(
"bearer proof nonce does not match request".to_owned(),
));
}
if !proof
.capabilities
.iter()
.any(|granted| geth_auth::capability_allows(granted, &capability_id))
{
return Ok(denied(
"bearer proof does not include the requested capability".to_owned(),
));
}
if !geth_secrets::verify_bearer_response(
&proof.secret,
&proof.resource,
&proof.capabilities,
nonce,
&proof.response,
) {
return Ok(denied("bearer proof response is invalid".to_owned()));
}
let now = UnixMillis(geth_store::now_ms());
let Some(access) = load_bearer_access(store)?.into_iter().find(|access| {
access.secret == proof.secret
&& access.resource == proof.resource
&& access.expires_at.is_none_or(|expires| expires.0 > now.0)
}) else {
return Ok(denied(
"bearer access is not active for requested resource".to_owned(),
));
};
if !access
.capabilities
.iter()
.any(|granted| geth_auth::capability_allows(granted, &capability_id))
{
return Ok(denied(
"bearer access lacks requested capability".to_owned(),
));
}
Ok(AuthExplanation {
subject: bearer_subject,
resource: resource.to_owned(),
capability: capability.to_owned(),
allowed: true,
reason:
"bearer proof allows this resource-scoped capability without granting node identity"
.to_owned(),
evaluated_ops: peer_explanation.evaluated_ops,
})
}
async fn request_peer_control( async fn request_peer_control(
node: &LocalNode, node: &LocalNode,
peer_node: &str, peer_node: &str,
@ -1754,7 +1949,7 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
"ssh-certs", "ssh-certs",
remote_watermarks.as_ref(), remote_watermarks.as_ref(),
)? { )? {
if let Err(error) = ssh_cert_sync_from_peer(node, &peer.peer_id).await { if let Err(error) = ssh_cert_sync_from_peer(node, &peer.peer_id, None).await {
tracing::debug!(peer = %peer.peer_id, %error, "SSH cert live sync failed"); tracing::debug!(peer = %peer.peer_id, %error, "SSH cert live sync failed");
} }
} }
@ -1764,7 +1959,7 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
"ssh-revocations", "ssh-revocations",
remote_watermarks.as_ref(), remote_watermarks.as_ref(),
)? { )? {
if let Err(error) = ssh_revocation_sync_from_peer(node, &peer.peer_id).await { if let Err(error) = ssh_revocation_sync_from_peer(node, &peer.peer_id, None).await {
tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed"); tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed");
} }
} }
@ -1772,7 +1967,7 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
let stream = format!("kv:{}", kv.name); let stream = format!("kv:{}", kv.name);
if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())? if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())?
{ {
if let Err(error) = kv_sync_from_peer(node, &peer.peer_id, &kv.name).await { if let Err(error) = kv_sync_from_peer(node, &peer.peer_id, &kv.name, None).await {
tracing::debug!(peer = %peer.peer_id, kv = %kv.name, %error, "KV live sync failed"); tracing::debug!(peer = %peer.peer_id, kv = %kv.name, %error, "KV live sync failed");
} }
} }
@ -1782,7 +1977,7 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())? if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())?
{ {
if let Err(error) = if let Err(error) =
document_sync_from_peer(node, &peer.peer_id, &document.name).await document_sync_from_peer(node, &peer.peer_id, &document.name, None).await
{ {
tracing::debug!(peer = %peer.peer_id, document = %document.name, %error, "document live sync failed"); tracing::debug!(peer = %peer.peer_id, document = %document.name, %error, "document live sync failed");
} }
@ -1792,7 +1987,9 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> {
let stream = format!("db:{}", db.name); let stream = format!("db:{}", db.name);
if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())? if should_live_sync_stream(&store, &peer.peer_id, &stream, remote_watermarks.as_ref())?
{ {
if let Err(error) = db_sync_from_peer(node, &peer.peer_id, &db.name, 100).await { if let Err(error) =
db_sync_from_peer(node, &peer.peer_id, &db.name, 100, None).await
{
tracing::debug!(peer = %peer.peer_id, db = %db.name, %error, "DB live sync failed"); tracing::debug!(peer = %peer.peer_id, db = %db.name, %error, "DB live sync failed");
} }
} }
@ -1919,6 +2116,7 @@ async fn handle_iroh_control_connection(
peer_card, peer_card,
hash, hash,
nonce, nonce,
bearer_proof,
} => { } => {
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
@ -1935,12 +2133,14 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = "resource:cas:local".to_owned(); let resource = "resource:cas:local".to_owned();
let capability = "cas.fetch".to_owned(); let capability = "cas.fetch".to_owned();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
if explanation.allowed { if explanation.allowed {
match LocalCas::new(node.paths.cas_dir()).read_bytes(&hash) { match LocalCas::new(node.paths.cas_dir()).read_bytes(&hash) {
Ok(content) => { Ok(content) => {
@ -1987,6 +2187,7 @@ async fn handle_iroh_control_connection(
peer_card, peer_card,
since_ms, since_ms,
nonce, nonce,
bearer_proof,
} => { } => {
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
@ -2004,12 +2205,14 @@ async fn handle_iroh_control_connection(
let resource = "resource:ssh:certs".to_owned(); let resource = "resource:ssh:certs".to_owned();
let capability = "ssh_cert.sync".to_owned(); let capability = "ssh_cert.sync".to_owned();
let high_water_ms = geth_store::now_ms(); let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let (requests, certificates) = if explanation.allowed { let (requests, certificates) = if explanation.allowed {
( (
store store
@ -2045,6 +2248,7 @@ async fn handle_iroh_control_connection(
peer_card, peer_card,
since_ms, since_ms,
nonce, nonce,
bearer_proof,
} => { } => {
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
@ -2062,12 +2266,14 @@ async fn handle_iroh_control_connection(
let resource = "resource:ssh:revocations".to_owned(); let resource = "resource:ssh:revocations".to_owned();
let capability = "ssh_revocation.sync".to_owned(); let capability = "ssh_revocation.sync".to_owned();
let high_water_ms = geth_store::now_ms(); let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let revocations = if explanation.allowed { let revocations = if explanation.allowed {
store store
.list_ssh_revocations_since(since_ms)? .list_ssh_revocations_since(since_ms)?
@ -2096,6 +2302,7 @@ async fn handle_iroh_control_connection(
name, name,
since_ms, since_ms,
nonce, nonce,
bearer_proof,
} => { } => {
geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?; geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?;
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
@ -2114,12 +2321,14 @@ async fn handle_iroh_control_connection(
if let Some(kv) = store.get_kv_store_by_name(&name)? { if let Some(kv) = store.get_kv_store_by_name(&name)? {
let capability = "kv.read".to_owned(); let capability = "kv.read".to_owned();
let high_water_ms = geth_store::now_ms(); let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &kv.resource_id)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(kv.resource_id.clone()), &kv.resource_id,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let entries = if explanation.allowed { let entries = if explanation.allowed {
store store
.list_kv_entries_since(&kv.kv_id, since_ms)? .list_kv_entries_since(&kv.kv_id, since_ms)?
@ -2158,6 +2367,7 @@ async fn handle_iroh_control_connection(
topic, topic,
message, message,
nonce, nonce,
bearer_proof,
} => { } => {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?; geth_pubsub::validate_message(&message)?;
@ -2176,12 +2386,14 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = format!("resource:pubsub:{topic}"); let resource = format!("resource:pubsub:{topic}");
let capability = "pubsub.publish".to_owned(); let capability = "pubsub.publish".to_owned();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let published = if explanation.allowed { let published = if explanation.allowed {
Some(record_pubsub_message(&node, topic, message)?) Some(record_pubsub_message(&node, topic, message)?)
} else { } else {
@ -2204,6 +2416,7 @@ async fn handle_iroh_control_connection(
peer_card, peer_card,
topic, topic,
nonce, nonce,
bearer_proof,
} => { } => {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
@ -2221,12 +2434,14 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = format!("resource:pubsub:{topic}"); let resource = format!("resource:pubsub:{topic}");
let capability = "pubsub.subscribe".to_owned(); let capability = "pubsub.subscribe".to_owned();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let messages = if explanation.allowed { let messages = if explanation.allowed {
pubsub_messages_for_topic(&node, &topic)? pubsub_messages_for_topic(&node, &topic)?
} else { } else {
@ -2250,6 +2465,7 @@ async fn handle_iroh_control_connection(
peer_card, peer_card,
target, target,
nonce, nonce,
bearer_proof,
} => { } => {
geth_pipe::validate_pipe_name(&target)?; geth_pipe::validate_pipe_name(&target)?;
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
@ -2267,12 +2483,14 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = format!("resource:pipe:{target}"); let resource = format!("resource:pipe:{target}");
let capability = "pipe.connect".to_owned(); let capability = "pipe.connect".to_owned();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let connection = if explanation.allowed { let connection = if explanation.allowed {
Some(record_pipe_connection( Some(record_pipe_connection(
&node, &node,
@ -2295,7 +2513,11 @@ async fn handle_iroh_control_connection(
note: "pipe connect authenticated endpoint/card binding and required pipe.connect on the remote pipe resource; byte streams are not implemented yet".to_owned(), note: "pipe connect authenticated endpoint/card binding and required pipe.connect on the remote pipe resource; byte streams are not implemented yet".to_owned(),
} }
} }
PeerControlRequest::SshProxyConnect { peer_card, nonce } => { PeerControlRequest::SshProxyConnect {
peer_card,
nonce,
bearer_proof,
} => {
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?;
let discovered = DiscoveredPeer::candidate( let discovered = DiscoveredPeer::candidate(
@ -2311,12 +2533,14 @@ async fn handle_iroh_control_connection(
})?; })?;
let resource = "resource:ssh-proxy:local".to_owned(); let resource = "resource:ssh-proxy:local".to_owned();
let capability = "ssh_proxy.connect".to_owned(); let capability = "ssh_proxy.connect".to_owned();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &resource)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(resource), &resource,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let connection = if explanation.allowed { let connection = if explanation.allowed {
Some(SshProxyConnection { Some(SshProxyConnection {
target_node: NodeId::new(node.node_id.clone()), target_node: NodeId::new(node.node_id.clone()),
@ -2346,6 +2570,7 @@ async fn handle_iroh_control_connection(
name, name,
since_ms, since_ms,
nonce, nonce,
bearer_proof,
} => { } => {
geth_document::validate_document_name(&name) geth_document::validate_document_name(&name)
.map_err(|_| NodeError::InvalidDocumentName(name.clone()))?; .map_err(|_| NodeError::InvalidDocumentName(name.clone()))?;
@ -2365,12 +2590,14 @@ async fn handle_iroh_control_connection(
if let Some(document) = store.get_document_resource_by_name(&name)? { if let Some(document) = store.get_document_resource_by_name(&name)? {
let capability = "document.read".to_owned(); let capability = "document.read".to_owned();
let high_water_ms = geth_store::now_ms(); let high_water_ms = geth_store::now_ms();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &document.resource_id)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(document.resource_id.clone()), &document.resource_id,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let state = if explanation.allowed && document.updated_at_ms >= since_ms { let state = if explanation.allowed && document.updated_at_ms >= since_ms {
Some(document_state_from_stored(&document)) Some(document_state_from_stored(&document))
} else { } else {
@ -2402,6 +2629,7 @@ async fn handle_iroh_control_connection(
after_db_version, after_db_version,
limit, limit,
nonce, nonce,
bearer_proof,
} => { } => {
geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?; geth_db::validate_db_name(&name).map_err(|_| NodeError::InvalidDbName(name.clone()))?;
peer_card.validate_candidate()?; peer_card.validate_candidate()?;
@ -2419,12 +2647,14 @@ async fn handle_iroh_control_connection(
})?; })?;
if let Some(db) = store.get_db_resource_by_name(&name)? { if let Some(db) = store.get_db_resource_by_name(&name)? {
let capability = "db.sync".to_owned(); let capability = "db.sync".to_owned();
let explanation = geth_auth::explain_auth_ops( let explanation = explain_peer_or_bearer(
&load_auth_ops_for_resource(&store, &db.resource_id)?, &store,
PrincipalId::new(peer_card.node_id.to_string()), peer_card.node_id.as_str(),
ResourceId::new(db.resource_id.clone()), &db.resource_id,
Capability::new(capability), &capability,
); &nonce,
bearer_proof.as_ref(),
)?;
let sync_result: Result<_, String> = if explanation.allowed { let sync_result: Result<_, String> = if explanation.allowed {
let path = Path::new(&db.path); let path = Path::new(&db.path);
match geth_db::extract_crsqlite_changes(path, after_db_version, limit) { match geth_db::extract_crsqlite_changes(path, after_db_version, limit) {
@ -3731,6 +3961,7 @@ pub fn handle_request(
topic, topic,
message, message,
node: None, node: None,
..
} => { } => {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
geth_pubsub::validate_message(&message)?; geth_pubsub::validate_message(&message)?;
@ -3738,7 +3969,9 @@ pub fn handle_request(
Ok(ControlResponse::PubsubPublished { message }) Ok(ControlResponse::PubsubPublished { message })
} }
ControlRequest::PubsubPub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::PubsubPub { node: Some(_), .. } => Err(NodeError::IrohEndpointUnavailable),
ControlRequest::PubsubSub { topic, node: None } => { ControlRequest::PubsubSub {
topic, node: None, ..
} => {
geth_pubsub::validate_topic(&topic)?; geth_pubsub::validate_topic(&topic)?;
let messages = pubsub_messages_for_topic(node, &topic)?; let messages = pubsub_messages_for_topic(node, &topic)?;
Ok(ControlResponse::PubsubMessages { Ok(ControlResponse::PubsubMessages {
@ -3764,7 +3997,9 @@ pub fn handle_request(
runtime.listeners.insert(name, listener.clone()); runtime.listeners.insert(name, listener.clone());
Ok(ControlResponse::PipeListening { listener }) Ok(ControlResponse::PipeListening { listener })
} }
ControlRequest::PipeConnect { target, node: None } => { ControlRequest::PipeConnect {
target, node: None, ..
} => {
geth_pipe::validate_pipe_name(&target)?; geth_pipe::validate_pipe_name(&target)?;
let connection = record_pipe_connection( let connection = record_pipe_connection(
node, node,
@ -4899,6 +5134,7 @@ mod tests {
ControlRequest::CasFetch { ControlRequest::CasFetch {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
hash: right_blob.hash.clone(), hash: right_blob.hash.clone(),
bearer_secret: None,
}, },
) )
.await .await
@ -4927,6 +5163,7 @@ mod tests {
ControlRequest::KvSync { ControlRequest::KvSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
name: "prefs".to_owned(), name: "prefs".to_owned(),
bearer_secret: None,
}, },
) )
.await .await
@ -4951,6 +5188,7 @@ mod tests {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
message: "hello".to_owned(), message: "hello".to_owned(),
node: Some(right_card.node_id.to_string()), node: Some(right_card.node_id.to_string()),
bearer_secret: None,
}, },
) )
.await .await
@ -4974,6 +5212,7 @@ mod tests {
ControlRequest::PubsubSub { ControlRequest::PubsubSub {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
node: Some(right_card.node_id.to_string()), node: Some(right_card.node_id.to_string()),
bearer_secret: None,
}, },
) )
.await .await
@ -4997,6 +5236,7 @@ mod tests {
ControlRequest::PipeConnect { ControlRequest::PipeConnect {
target: "inbox".to_owned(), target: "inbox".to_owned(),
node: Some(right_card.node_id.to_string()), node: Some(right_card.node_id.to_string()),
bearer_secret: None,
}, },
) )
.await .await
@ -5019,6 +5259,7 @@ mod tests {
&left, &left,
ControlRequest::SshProxyConnect { ControlRequest::SshProxyConnect {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
bearer_secret: None,
}, },
) )
.await .await
@ -5042,6 +5283,7 @@ mod tests {
ControlRequest::DocumentSync { ControlRequest::DocumentSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
name: "notes".to_owned(), name: "notes".to_owned(),
bearer_secret: None,
}, },
) )
.await .await
@ -5066,6 +5308,7 @@ mod tests {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
name: "notes".to_owned(), name: "notes".to_owned(),
limit: 10, limit: 10,
bearer_secret: None,
}, },
) )
.await .await
@ -5088,6 +5331,45 @@ mod tests {
other => panic!("unexpected denied DB sync response: {other:?}"), other => panic!("unexpected denied DB sync response: {other:?}"),
} }
let bearer_secret = match handle_request(
&right,
ControlRequest::SecretBearerCreate {
resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.fetch".to_owned()],
expires_at_ms: None,
},
)
.expect("create remote CAS bearer access")
{
ControlResponse::SecretBearerCreated { access } => access.secret.to_string(),
other => panic!("unexpected bearer create response: {other:?}"),
};
let bearer_fetch = handle_request_async(
&left,
ControlRequest::CasFetch {
node: right_card.node_id.to_string(),
hash: right_blob.hash.clone(),
bearer_secret: Some(bearer_secret),
},
)
.await
.expect("bearer cas fetch");
match bearer_fetch {
ControlResponse::CasFetched {
allowed,
hash,
size_bytes,
reason,
..
} => {
assert!(allowed);
assert_eq!(hash, right_blob.hash);
assert_eq!(size_bytes, right_blob.size_bytes);
assert!(reason.contains("bearer proof"));
}
other => panic!("unexpected bearer CAS fetch response: {other:?}"),
}
handle_request( handle_request(
&right, &right,
ControlRequest::AuthGrant { ControlRequest::AuthGrant {
@ -5198,6 +5480,7 @@ mod tests {
ControlRequest::CasFetch { ControlRequest::CasFetch {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
hash: right_blob.hash.clone(), hash: right_blob.hash.clone(),
bearer_secret: None,
}, },
) )
.await .await
@ -5237,6 +5520,7 @@ mod tests {
ControlRequest::KvSync { ControlRequest::KvSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
name: "prefs".to_owned(), name: "prefs".to_owned(),
bearer_secret: None,
}, },
) )
.await .await
@ -5277,6 +5561,7 @@ mod tests {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
message: "hello".to_owned(), message: "hello".to_owned(),
node: Some(right_card.node_id.to_string()), node: Some(right_card.node_id.to_string()),
bearer_secret: None,
}, },
) )
.await .await
@ -5302,6 +5587,7 @@ mod tests {
ControlRequest::PubsubSub { ControlRequest::PubsubSub {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.expect("right pubsub sub after remote publish"); .expect("right pubsub sub after remote publish");
@ -5318,6 +5604,7 @@ mod tests {
ControlRequest::PubsubSub { ControlRequest::PubsubSub {
topic: "presence/test".to_owned(), topic: "presence/test".to_owned(),
node: Some(right_card.node_id.to_string()), node: Some(right_card.node_id.to_string()),
bearer_secret: None,
}, },
) )
.await .await
@ -5344,6 +5631,7 @@ mod tests {
ControlRequest::PipeConnect { ControlRequest::PipeConnect {
target: "inbox".to_owned(), target: "inbox".to_owned(),
node: Some(right_card.node_id.to_string()), node: Some(right_card.node_id.to_string()),
bearer_secret: None,
}, },
) )
.await .await
@ -5368,6 +5656,7 @@ mod tests {
&left, &left,
ControlRequest::SshProxyConnect { ControlRequest::SshProxyConnect {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
bearer_secret: None,
}, },
) )
.await .await
@ -5398,6 +5687,7 @@ mod tests {
ControlRequest::DocumentSync { ControlRequest::DocumentSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
name: "notes".to_owned(), name: "notes".to_owned(),
bearer_secret: None,
}, },
) )
.await .await
@ -5437,6 +5727,7 @@ mod tests {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
name: "notes".to_owned(), name: "notes".to_owned(),
limit: 10, limit: 10,
bearer_secret: None,
}, },
) )
.await .await
@ -5472,6 +5763,7 @@ mod tests {
&left, &left,
ControlRequest::SshCertSync { ControlRequest::SshCertSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
bearer_secret: None,
}, },
) )
.await .await
@ -5517,6 +5809,7 @@ mod tests {
&left, &left,
ControlRequest::SshCertSync { ControlRequest::SshCertSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
bearer_secret: None,
}, },
) )
.await .await
@ -5552,6 +5845,7 @@ mod tests {
&left, &left,
ControlRequest::SshRevocationSync { ControlRequest::SshRevocationSync {
node: right_card.node_id.to_string(), node: right_card.node_id.to_string(),
bearer_secret: None,
}, },
) )
.await .await

View file

@ -1368,6 +1368,7 @@ fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
topic: "presence/laptop".to_owned(), topic: "presence/laptop".to_owned(),
message: "online".to_owned(), message: "online".to_owned(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.expect("publish"); .expect("publish");
@ -1384,6 +1385,7 @@ fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
geth_control::ControlRequest::PubsubSub { geth_control::ControlRequest::PubsubSub {
topic: "presence/laptop".to_owned(), topic: "presence/laptop".to_owned(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.expect("subscribe snapshot"); .expect("subscribe snapshot");
@ -1407,6 +1409,7 @@ fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
geth_control::ControlRequest::PubsubSub { geth_control::ControlRequest::PubsubSub {
topic: "presence/laptop".to_owned(), topic: "presence/laptop".to_owned(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.expect("subscribe reopened snapshot"); .expect("subscribe reopened snapshot");
@ -1424,6 +1427,7 @@ fn pubsub_pub_sub_uses_lossy_in_memory_runtime() {
topic: "presence/laptop".to_owned(), topic: "presence/laptop".to_owned(),
message: String::new(), message: String::new(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.is_err() .is_err()
@ -1457,6 +1461,7 @@ fn pipe_listen_connect_uses_local_runtime_registry() {
geth_control::ControlRequest::PipeConnect { geth_control::ControlRequest::PipeConnect {
target: "inbox".to_owned(), target: "inbox".to_owned(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.expect("connect pipe"); .expect("connect pipe");
@ -1479,6 +1484,7 @@ fn pipe_listen_connect_uses_local_runtime_registry() {
geth_control::ControlRequest::PipeConnect { geth_control::ControlRequest::PipeConnect {
target: "inbox".to_owned(), target: "inbox".to_owned(),
node: None, node: None,
bearer_secret: None,
}, },
) )
.expect("connect after reopen"); .expect("connect after reopen");

View file

@ -271,8 +271,11 @@ as resource-scoped auth operations and rejects trust-mutation capabilities such
as `auth.delegate`, `auth.revoke`, and `node.enroll`. Bearer challenge/proof as `auth.delegate`, `auth.revoke`, and `node.enroll`. Bearer challenge/proof
commands derive deterministic BLAKE3 keyed responses from the bearer secret, commands derive deterministic BLAKE3 keyed responses from the bearer secret,
resource, nonce, and requested capabilities, then verify them against active resource, nonce, and requested capabilities, then verify them against active
resource-scoped bearer grants. The daemon does not yet store payload key resource-scoped bearer grants. Remote resource operations can carry optional
material, encrypt resource data, or distribute key envelopes. bearer proofs over the protected Iroh control path; a valid proof authorizes
only the requested resource capability and does not create node trust. The daemon
does not yet store payload key material, encrypt resource data, distribute key
envelopes, or separate public bearer ids from private bearer tokens.
## Multi-User Direction ## Multi-User Direction

View file

@ -223,8 +223,12 @@ resource-scoped capability decisions.
- `[x]` `geth secret bearer challenge/prove/verify` exercises - `[x]` `geth secret bearer challenge/prove/verify` exercises
resource-scoped bearer challenge-response proofs. resource-scoped bearer challenge-response proofs.
- `[x]` Tests verify valid bearer proofs and capability-scoped proof denial. - `[x]` Tests verify valid bearer proofs and capability-scoped proof denial.
- `[ ]` Future completion wires bearer proof verification into remote module - `[x]` Remote module authorization paths accept optional bearer proofs for
authorization paths. the requested resource capability without granting node identity.
- `[x]` Tests verify remote CAS fetch succeeds through a bearer proof before
the caller has a node grant.
- `[ ]` Future completion avoids sending bearer secret identifiers as proof
material by separating public bearer ids from private bearer tokens.
- `[~]` SSH certificate and revocation lifecycle. - `[~]` SSH certificate and revocation lifecycle.
Acceptance criteria: Acceptance criteria: