Separate bearer ids from private tokens
This commit is contained in:
parent
3382899628
commit
460acab67b
9 changed files with 198 additions and 62 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
12
README.md
12
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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: {}",
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue