Separate bearer ids from private tokens

This commit is contained in:
Eric Wendland 2026-05-20 13:10:34 +02:00
commit 460acab67b
9 changed files with 198 additions and 62 deletions

View file

@ -182,9 +182,9 @@ Roadmap items should be actionable and checkable:
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.
Remote module authorization paths can accept optional bearer proofs without Remote module authorization paths can accept optional bearer proofs without
enrolling the caller as a trusted node. Payload encryption, key envelopes, and enrolling the caller as a trusted node. Bearer creation separates the public
separating public bearer ids from private bearer tokens are still roadmap bearer id stored in auth logs from the private bearer token returned to the
work. caller once. Payload encryption and key envelopes 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

@ -98,9 +98,9 @@ The bootstrap implementation provides:
- `geth secret bearer create <resource> --capability <capability>` - `geth secret bearer create <resource> --capability <capability>`
- `geth secret bearer list` - `geth secret bearer list`
- `geth secret bearer challenge <resource> --capability <capability>` - `geth secret bearer challenge <resource> --capability <capability>`
- `geth secret bearer prove <secret> <resource> --nonce <nonce> --capability <capability>` - `geth secret bearer prove <token> <resource> --nonce <nonce> --capability <capability>`
- `geth secret bearer verify <secret> <resource> --nonce <nonce> --response <response> --capability <capability>` - `geth secret bearer verify <token> <resource> --nonce <nonce> --response <response> --capability <capability>`
- `geth secret bearer revoke <resource> <secret>` - `geth secret bearer revoke <resource> <bearer-id>`
- `geth auth explain <subject> <resource> <capability>` - `geth auth explain <subject> <resource> <capability>`
- `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>`
@ -158,8 +158,10 @@ 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 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; resource-scoped bearer proof generated from the private bearer token returned at
it only unlocks the requested capability on that one resource. creation time. The persisted auth log stores a public bearer id and token
verifier, not the private token. 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

@ -48,6 +48,7 @@ pub enum AuthOpKind {
}, },
BearerAccessCreate { BearerAccessCreate {
secret: SecretId, secret: SecretId,
token_hash: Option<String>,
capabilities: Vec<Capability>, capabilities: Vec<Capability>,
expires_at: Option<UnixMillis>, expires_at: Option<UnixMillis>,
}, },
@ -99,6 +100,7 @@ pub struct GroupRecord {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccessRecord { pub struct BearerAccessRecord {
pub secret: SecretId, pub secret: SecretId,
pub token_hash: Option<String>,
pub resource: ResourceId, pub resource: ResourceId,
pub capabilities: Vec<Capability>, pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>, pub expires_at: Option<UnixMillis>,
@ -150,6 +152,7 @@ pub fn reduce_auth_ops(ops: &[AuthOp]) -> AuthView {
} }
AuthOpKind::BearerAccessCreate { AuthOpKind::BearerAccessCreate {
secret, secret,
token_hash,
capabilities, capabilities,
expires_at, expires_at,
} => { } => {
@ -160,6 +163,7 @@ pub fn reduce_auth_ops(ops: &[AuthOp]) -> AuthView {
secret.clone(), secret.clone(),
BearerAccessRecord { BearerAccessRecord {
secret: secret.clone(), secret: secret.clone(),
token_hash: token_hash.clone(),
resource: op.resource.clone(), resource: op.resource.clone(),
capabilities, capabilities,
expires_at: *expires_at, expires_at: *expires_at,
@ -542,6 +546,7 @@ mod tests {
4, 4,
AuthOpKind::BearerAccessCreate { AuthOpKind::BearerAccessCreate {
secret: "secret:invite".into(), secret: "secret:invite".into(),
token_hash: Some("token-hash".to_owned()),
capabilities: vec!["pipe.connect".into()], capabilities: vec!["pipe.connect".into()],
expires_at: Some(UnixMillis(100)), expires_at: Some(UnixMillis(100)),
}, },

View file

@ -215,7 +215,7 @@ pub enum SecretBearerCommand {
capabilities: Vec<String>, capabilities: Vec<String>,
}, },
Prove { Prove {
secret: String, token: String,
resource: String, resource: String,
#[arg(long)] #[arg(long)]
nonce: String, nonce: String,
@ -223,7 +223,7 @@ pub enum SecretBearerCommand {
capabilities: Vec<String>, capabilities: Vec<String>,
}, },
Verify { Verify {
secret: String, token: String,
resource: String, resource: String,
#[arg(long)] #[arg(long)]
nonce: String, nonce: String,
@ -234,7 +234,7 @@ pub enum SecretBearerCommand {
}, },
Revoke { Revoke {
resource: String, resource: String,
secret: String, bearer_id: String,
}, },
} }
@ -673,32 +673,36 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
capabilities, capabilities,
}, },
SecretBearerCommand::Prove { SecretBearerCommand::Prove {
secret, token,
resource, resource,
capabilities, capabilities,
nonce, nonce,
} => ControlRequest::SecretBearerProve { } => ControlRequest::SecretBearerProve {
secret, secret: token,
resource, resource,
capabilities, capabilities,
nonce, nonce,
}, },
SecretBearerCommand::Verify { SecretBearerCommand::Verify {
secret, token,
resource, resource,
capabilities, capabilities,
nonce, nonce,
response, response,
} => ControlRequest::SecretBearerVerify { } => ControlRequest::SecretBearerVerify {
secret, secret: token,
resource, resource,
capabilities, capabilities,
nonce, nonce,
response, response,
}, },
SecretBearerCommand::Revoke { resource, secret } => { SecretBearerCommand::Revoke {
ControlRequest::SecretBearerRevoke { resource, secret } resource,
} bearer_id,
} => ControlRequest::SecretBearerRevoke {
resource,
secret: bearer_id,
},
}, },
}, },
Command::Cas { command } => match command { Command::Cas { command } => match command {
@ -1301,7 +1305,10 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("epoch: {}", secret.epoch); println!("epoch: {}", secret.epoch);
} }
ControlResponse::SecretBearerCreated { access } => { ControlResponse::SecretBearerCreated { access } => {
println!("bearer secret: {}", access.secret); println!("bearer id: {}", access.secret);
if let Some(token) = access.token {
println!("bearer token: {token}");
}
println!("resource: {}", access.resource); println!("resource: {}", access.resource);
println!( println!(
"capabilities: {}", "capabilities: {}",

View file

@ -1724,11 +1724,12 @@ fn bearer_proof(
capability: &str, capability: &str,
nonce: &str, nonce: &str,
) -> Option<BearerProof> { ) -> Option<BearerProof> {
bearer_secret.map(|secret| { bearer_secret.map(|token| {
let secret = geth_types::SecretId::new(secret); let token = geth_types::SecretId::new(token);
let secret = geth_secrets::bearer_id_for_token(&token);
let resource = ResourceId::new(resource.to_owned()); let resource = ResourceId::new(resource.to_owned());
let capabilities = vec![Capability::new(capability.to_owned())]; let capabilities = vec![Capability::new(capability.to_owned())];
let response = geth_secrets::bearer_response(&secret, &resource, &capabilities, nonce); let response = geth_secrets::bearer_response(&token, &resource, &capabilities, nonce);
BearerProof { BearerProof {
secret, secret,
resource, resource,
@ -1793,16 +1794,6 @@ fn explain_peer_or_bearer(
"bearer proof does not include the requested capability".to_owned(), "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 now = UnixMillis(geth_store::now_ms());
let Some(access) = load_bearer_access(store)?.into_iter().find(|access| { let Some(access) = load_bearer_access(store)?.into_iter().find(|access| {
access.secret == proof.secret access.secret == proof.secret
@ -1813,6 +1804,20 @@ fn explain_peer_or_bearer(
"bearer access is not active for requested resource".to_owned(), "bearer access is not active for requested resource".to_owned(),
)); ));
}; };
let Some(token_hash) = access.token_hash.as_deref() else {
return Ok(denied(
"bearer access does not have a token verifier".to_owned(),
));
};
if !geth_secrets::verify_bearer_response_with_token_hash(
token_hash,
&proof.resource,
&proof.capabilities,
nonce,
&proof.response,
) {
return Ok(denied("bearer proof response is invalid".to_owned()));
}
if !access if !access
.capabilities .capabilities
.iter() .iter()
@ -3307,11 +3312,12 @@ pub fn handle_request(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&capabilities)?; geth_secrets::validate_bearer_capabilities(&capabilities)?;
let created_at = UnixMillis(geth_store::now_ms()); let created_at = UnixMillis(geth_store::now_ms());
let secret = geth_types::SecretId::new(format!( let token = geth_types::SecretId::new(format!(
"bearer:{}", "gbt_{}",
geth_crypto::blake3_hex( geth_crypto::blake3_hex(
format!( format!(
"{resource}\0{}\0{}", "{}\0{resource}\0{}\0{}",
node.agent_id,
capabilities capabilities
.iter() .iter()
.map(ToString::to_string) .map(ToString::to_string)
@ -3322,6 +3328,8 @@ pub fn handle_request(
.as_bytes() .as_bytes()
) )
)); ));
let secret = geth_secrets::bearer_id_for_token(&token);
let token_hash = geth_secrets::bearer_token_hash(&token);
let access = BearerAccess::resource_scoped( let access = BearerAccess::resource_scoped(
secret.clone(), secret.clone(),
ResourceId::new(resource.clone()), ResourceId::new(resource.clone()),
@ -3333,6 +3341,7 @@ pub fn handle_request(
created_at, created_at,
kind: AuthOpKind::BearerAccessCreate { kind: AuthOpKind::BearerAccessCreate {
secret, secret,
token_hash: Some(token_hash),
capabilities, capabilities,
expires_at: expires_at_ms.map(UnixMillis), expires_at: expires_at_ms.map(UnixMillis),
}, },
@ -3340,6 +3349,7 @@ pub fn handle_request(
store_auth_op(&store, &op)?; store_auth_op(&store, &op)?;
Ok(ControlResponse::SecretBearerCreated { Ok(ControlResponse::SecretBearerCreated {
access: BearerAccess { access: BearerAccess {
token: Some(token),
expires_at: expires_at_ms.map(UnixMillis), expires_at: expires_at_ms.map(UnixMillis),
..access ..access
}, },
@ -3383,9 +3393,10 @@ pub fn handle_request(
.map(Capability::new) .map(Capability::new)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&capabilities)?; geth_secrets::validate_bearer_capabilities(&capabilities)?;
let secret = geth_types::SecretId::new(secret); let token = geth_types::SecretId::new(secret);
let secret = geth_secrets::bearer_id_for_token(&token);
let resource = ResourceId::new(resource); let resource = ResourceId::new(resource);
let response = geth_secrets::bearer_response(&secret, &resource, &capabilities, &nonce); let response = geth_secrets::bearer_response(&token, &resource, &capabilities, &nonce);
Ok(ControlResponse::SecretBearerProof { Ok(ControlResponse::SecretBearerProof {
proof: BearerProof { proof: BearerProof {
secret, secret,
@ -3410,7 +3421,8 @@ pub fn handle_request(
.map(Capability::new) .map(Capability::new)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
geth_secrets::validate_bearer_capabilities(&requested_capabilities)?; geth_secrets::validate_bearer_capabilities(&requested_capabilities)?;
let secret_id = geth_types::SecretId::new(secret.clone()); let token = geth_types::SecretId::new(secret.clone());
let secret_id = geth_secrets::bearer_id_for_token(&token);
let resource_id = ResourceId::new(resource.clone()); let resource_id = ResourceId::new(resource.clone());
let access = load_bearer_access(&store)?.into_iter().find(|access| { let access = load_bearer_access(&store)?.into_iter().find(|access| {
access.secret == secret_id access.secret == secret_id
@ -3428,13 +3440,15 @@ pub fn handle_request(
false, false,
"bearer secret lacks requested capabilities".to_owned(), "bearer secret lacks requested capabilities".to_owned(),
) )
} else if geth_secrets::verify_bearer_response( } else if access.token_hash.as_deref().is_some_and(|token_hash| {
&secret_id, geth_secrets::verify_bearer_response_with_token_hash(
token_hash,
&resource_id, &resource_id,
&requested_capabilities, &requested_capabilities,
&nonce, &nonce,
&response, &response,
) { )
}) {
( (
true, true,
"bearer proof verified for resource-scoped capabilities".to_owned(), "bearer proof verified for resource-scoped capabilities".to_owned(),
@ -4458,6 +4472,8 @@ fn load_bearer_access(store: &Store) -> Result<Vec<BearerAccess>, NodeError> {
.into_values() .into_values()
.map(|record| BearerAccess { .map(|record| BearerAccess {
secret: record.secret, secret: record.secret,
token: None,
token_hash: record.token_hash,
resource: record.resource, resource: record.resource,
capabilities: record.capabilities, capabilities: record.capabilities,
expires_at: record.expires_at, expires_at: record.expires_at,
@ -5482,7 +5498,10 @@ mod tests {
) )
.expect("create remote CAS bearer access") .expect("create remote CAS bearer access")
{ {
ControlResponse::SecretBearerCreated { access } => access.secret.to_string(), ControlResponse::SecretBearerCreated { access } => access
.token
.expect("private bearer token on create")
.to_string(),
other => panic!("unexpected bearer create response: {other:?}"), other => panic!("unexpected bearer create response: {other:?}"),
}; };
let bearer_fetch = handle_request_async( let bearer_fetch = handle_request_async(

View file

@ -23,6 +23,8 @@ pub struct ResourceKeyEnvelope {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BearerAccess { pub struct BearerAccess {
pub secret: SecretId, pub secret: SecretId,
pub token: Option<SecretId>,
pub token_hash: Option<String>,
pub resource: ResourceId, pub resource: ResourceId,
pub capabilities: Vec<Capability>, pub capabilities: Vec<Capability>,
pub expires_at: Option<UnixMillis>, pub expires_at: Option<UnixMillis>,
@ -55,6 +57,8 @@ impl BearerAccess {
) -> Self { ) -> Self {
Self { Self {
secret, secret,
token: None,
token_hash: None,
resource, resource,
capabilities, capabilities,
expires_at: None, expires_at: None,
@ -93,6 +97,17 @@ pub fn validate_bearer_capabilities(capabilities: &[Capability]) -> Result<(), S
Ok(()) Ok(())
} }
#[must_use]
pub fn bearer_token_hash(token: &SecretId) -> String {
blake3::hash(token.as_str().as_bytes()).to_hex().to_string()
}
#[must_use]
pub fn bearer_id_for_token(token: &SecretId) -> SecretId {
let hash = bearer_token_hash(token);
SecretId::new(format!("bearer:{}", &hash[..32]))
}
#[must_use] #[must_use]
pub fn bearer_response( pub fn bearer_response(
secret: &SecretId, secret: &SecretId,
@ -119,6 +134,34 @@ pub fn bearer_response(
.to_string() .to_string()
} }
#[must_use]
pub fn bearer_response_with_token_hash(
token_hash: &str,
resource: &ResourceId,
capabilities: &[Capability],
nonce: &str,
) -> Option<String> {
let hash_bytes = hex_to_32_bytes(token_hash)?;
let mut capability_strings = capabilities
.iter()
.map(|capability| capability.as_str())
.collect::<Vec<_>>();
capability_strings.sort_unstable();
let mut message = String::new();
message.push_str(resource.as_str());
message.push('\0');
message.push_str(nonce);
for capability in capability_strings {
message.push('\0');
message.push_str(capability);
}
Some(
blake3::keyed_hash(&hash_bytes, message.as_bytes())
.to_hex()
.to_string(),
)
}
#[must_use] #[must_use]
pub fn verify_bearer_response( pub fn verify_bearer_response(
secret: &SecretId, secret: &SecretId,
@ -130,6 +173,29 @@ pub fn verify_bearer_response(
bearer_response(secret, resource, capabilities, nonce) == response bearer_response(secret, resource, capabilities, nonce) == response
} }
#[must_use]
pub fn verify_bearer_response_with_token_hash(
token_hash: &str,
resource: &ResourceId,
capabilities: &[Capability],
nonce: &str,
response: &str,
) -> bool {
bearer_response_with_token_hash(token_hash, resource, capabilities, nonce)
.is_some_and(|expected| expected == response)
}
fn hex_to_32_bytes(hex: &str) -> Option<[u8; 32]> {
if hex.len() != 64 {
return None;
}
let mut bytes = [0_u8; 32];
for index in 0..32 {
bytes[index] = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16).ok()?;
}
Some(bytes)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -153,7 +219,7 @@ mod tests {
#[test] #[test]
fn bearer_response_is_resource_scoped_and_capability_scoped() { fn bearer_response_is_resource_scoped_and_capability_scoped() {
let secret = SecretId::new("bearer:test"); let secret = SecretId::new("gbt_test");
let resource = ResourceId::new("resource:kv:prefs"); let resource = ResourceId::new("resource:kv:prefs");
let capabilities = vec![Capability::new("kv.read"), Capability::new("kv.write")]; let capabilities = vec![Capability::new("kv.read"), Capability::new("kv.write")];
@ -181,4 +247,31 @@ mod tests {
&response &response
)); ));
} }
#[test]
fn bearer_id_is_separate_from_private_token() {
let token = SecretId::new("gbt_private_token");
let bearer_id = bearer_id_for_token(&token);
let token_hash = bearer_token_hash(&token);
let resource = ResourceId::new("resource:cas:local");
let capabilities = vec![Capability::new("cas.fetch")];
let response = bearer_response(&token, &resource, &capabilities, "nonce");
assert_ne!(bearer_id, token);
assert!(bearer_id.as_str().starts_with("bearer:"));
assert!(verify_bearer_response_with_token_hash(
&token_hash,
&resource,
&capabilities,
"nonce",
&response
));
assert!(!verify_bearer_response_with_token_hash(
&token_hash,
&resource,
&[Capability::new("cas.pin")],
"nonce",
&response
));
}
} }

View file

@ -1228,7 +1228,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
}, },
) )
.expect("create bearer"); .expect("create bearer");
let secret = match response { let (secret, token) = match response {
geth_control::ControlResponse::SecretBearerCreated { access } => { geth_control::ControlResponse::SecretBearerCreated { access } => {
assert_eq!(access.resource.to_string(), "resource:cas:local"); assert_eq!(access.resource.to_string(), "resource:cas:local");
assert_eq!(access.capabilities.len(), 2); assert_eq!(access.capabilities.len(), 2);
@ -1237,7 +1237,10 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
Some(4_102_444_800_000) Some(4_102_444_800_000)
); );
assert!(!access.may_delegate); assert!(!access.may_delegate);
access.secret.to_string() let token = access.token.expect("private bearer token on create");
assert_ne!(access.secret, token);
assert!(access.token_hash.is_none());
(access.secret.to_string(), token.to_string())
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
}; };
@ -1248,6 +1251,8 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
geth_control::ControlResponse::SecretBearerList { access } => { geth_control::ControlResponse::SecretBearerList { access } => {
assert_eq!(access.len(), 1); assert_eq!(access.len(), 1);
assert_eq!(access[0].secret.to_string(), secret); assert_eq!(access[0].secret.to_string(), secret);
assert!(access[0].token.is_none());
assert!(access[0].token_hash.is_some());
assert!(!access[0].may_delegate); assert!(!access[0].may_delegate);
} }
other => panic!("unexpected response: {other:?}"), other => panic!("unexpected response: {other:?}"),
@ -1272,7 +1277,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
let response = geth_node::handle_request( let response = geth_node::handle_request(
&node, &node,
geth_control::ControlRequest::SecretBearerProve { geth_control::ControlRequest::SecretBearerProve {
secret: secret.clone(), secret: token.clone(),
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.fetch".to_owned()], capabilities: vec!["cas.fetch".to_owned()],
nonce: nonce.clone(), nonce: nonce.clone(),
@ -1291,7 +1296,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
let response = geth_node::handle_request( let response = geth_node::handle_request(
&node, &node,
geth_control::ControlRequest::SecretBearerVerify { geth_control::ControlRequest::SecretBearerVerify {
secret: secret.clone(), secret: token.clone(),
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.fetch".to_owned()], capabilities: vec!["cas.fetch".to_owned()],
nonce: nonce.clone(), nonce: nonce.clone(),
@ -1308,7 +1313,7 @@ fn bearer_access_create_list_revoke_uses_resource_scoped_auth_ops() {
let response = geth_node::handle_request( let response = geth_node::handle_request(
&node, &node,
geth_control::ControlRequest::SecretBearerVerify { geth_control::ControlRequest::SecretBearerVerify {
secret: secret.clone(), secret: token,
resource: "resource:cas:local".to_owned(), resource: "resource:cas:local".to_owned(),
capabilities: vec!["cas.pin".to_owned()], capabilities: vec!["cas.pin".to_owned()],
nonce, nonce,

View file

@ -270,14 +270,15 @@ key envelopes, bearer secrets, and rotation. Revocation for private data is
modeled initially as secret epoch rotation. The daemon persists resource secret modeled initially as secret epoch rotation. The daemon persists resource secret
epoch metadata through `secret create/rotate/status`. Bearer access is recorded epoch metadata through `secret create/rotate/status`. Bearer access is recorded
as resource-scoped auth operations and rejects trust-mutation capabilities such 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 creation returns a
commands derive deterministic BLAKE3 keyed responses from the bearer secret, private bearer token once and stores a separate public bearer id plus token
resource, nonce, and requested capabilities, then verify them against active verifier in the auth log. Bearer challenge/proof commands derive deterministic
resource-scoped bearer grants. Remote resource operations can carry optional BLAKE3 keyed responses from the private token, resource, nonce, and requested
bearer proofs over the protected Iroh control path; a valid proof authorizes capabilities, then verify them against active resource-scoped bearer grants.
only the requested resource capability and does not create node trust. The daemon Remote resource operations can carry optional bearer proofs over the protected
does not yet store payload key material, encrypt resource data, distribute key Iroh control path; a valid proof authorizes only the requested resource
envelopes, or separate public bearer ids from private bearer tokens. capability and does not create node trust. The daemon does not yet store payload
key material, encrypt resource data, or distribute key envelopes.
## Multi-User Direction ## Multi-User Direction

View file

@ -227,8 +227,12 @@ resource-scoped capability decisions.
the requested resource capability without granting node identity. the requested resource capability without granting node identity.
- `[x]` Tests verify remote CAS fetch succeeds through a bearer proof before - `[x]` Tests verify remote CAS fetch succeeds through a bearer proof before
the caller has a node grant. the caller has a node grant.
- `[ ]` Future completion avoids sending bearer secret identifiers as proof - `[x]` Bearer creation separates the persisted public bearer id from the
material by separating public bearer ids from private bearer tokens. private bearer token returned to the caller.
- `[x]` Bearer list/revoke operate on public bearer ids while proof and remote
authorization use the private token.
- `[x]` Tests verify the public bearer id differs from the private token and
remote bearer auth uses the token.
- `[~]` SSH certificate and revocation lifecycle. - `[~]` SSH certificate and revocation lifecycle.
Acceptance criteria: Acceptance criteria: