Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked.
Signed `.shuttle` grant composition remains deferred and fails explicitly.
Closes #1195.

- **Capability-registry revision 1 now has fixed semantics and BLAKE3 digest
vectors.** All 51 kernel entries bind scope, target kinds, delegability,
privileged status and provenance. Kernel/admin request mappings and the
current capsule-side secondary enforcement constants resolve through the
registry in tests, while the complete role partition is frozen independently.
Authorization and persisted state remain unchanged. Closes #1235. Refs #1228
and #1233.

### Changed

- **Device key IDs now use BLAKE3.** The short per-device handle is derived from
Expand Down
13 changes: 13 additions & 0 deletions crates/astrid-capsule/src/access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,19 @@ mod tests {
DeviceKey::new(seed.to_string().repeat(64), scope, None, 0)
}

#[test]
fn unrestricted_capsule_access_enforcement_id_is_registered() {
assert_eq!(CAPSULE_ACCESS_ANY, "capsule:access:any");
let registry = astrid_core::capability_registry::capability_registry_revision_1().unwrap();
assert!(
registry
.entries()
.iter()
.any(|entry| entry.id().as_str() == CAPSULE_ACCESS_ANY),
"capsule access enforcement uses {CAPSULE_ACCESS_ANY:?} without a registry revision 1 entry"
);
}

#[test]
fn none_principal_denied() {
let (_d, _h, r) = fixture();
Expand Down
3 changes: 2 additions & 1 deletion crates/astrid-capsule/src/engine/wasm/host/ipc_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ fn audit_topic_literal_pinned() {
// `pattern_covers_audit` would otherwise silently stop recognising
// audit subscriptions and leave them on the unscoped firehose default
// for the renamed topic — exactly the drift the doc comment promises
// is guarded. Mirrors `tests::audit_firehose_cap_literal_pinned`.
// is guarded. The separate firehose capability identifier is checked
// against registry revision 1 by `tests::secondary_enforcement_ids_are_registered`.
assert_eq!(AUDIT_TOPIC, "astrid.v1.audit.entry");
}

Expand Down
21 changes: 16 additions & 5 deletions crates/astrid-capsule/src/engine/wasm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,7 @@ pub(crate) fn resolve_exemption(
/// Elevated). A capsule-local literal keeps the kernel/capsule dependency
/// boundary clean (the capsule must not reach into the gateway or grow the
/// core grammar surface for one internal reference); the value is pinned by
/// [`tests::audit_firehose_cap_literal_pinned`].
/// [`tests::secondary_enforcement_ids_are_registered`].
const AUDIT_FIREHOSE_CAP: &str = "audit:read_all";

/// Pure decision: does this load principal's profile hold the audit
Expand Down Expand Up @@ -3562,11 +3562,22 @@ mod tests {
// pinned positively/negatively below.

#[test]
fn audit_firehose_cap_literal_pinned() {
// The capsule-local literal must stay byte-equal to the gateway's
// `events::AUDIT_FIREHOSE_CAP` and the core grammar's `audit:read_all`
// so the three references can never drift.
fn secondary_enforcement_ids_are_registered() {
assert_eq!(AUDIT_FIREHOSE_CAP, "audit:read_all");
assert_eq!(
astrid_core::EXEMPT_CAPABILITIES,
["system:resources:unbounded", "net_bind", "uplink"]
);
let registry = astrid_core::capability_registry::capability_registry_revision_1().unwrap();
for id in std::iter::once(AUDIT_FIREHOSE_CAP).chain(astrid_core::EXEMPT_CAPABILITIES) {
assert!(
registry
.entries()
.iter()
.any(|entry| entry.id().as_str() == id),
"capsule enforcement uses {id:?} without a registry revision 1 entry"
);
}
}

#[test]
Expand Down
149 changes: 148 additions & 1 deletion crates/astrid-core/src/capability_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use util::{
validate_digest_length,
};

mod revision_1;
mod util;

const ENTRY_DIGEST_DOMAIN: &[u8] = b"astrid-capability-entry\0";
Expand Down Expand Up @@ -60,7 +61,6 @@ impl CapabilityRegistryRevision {
///
/// This set is authority-bearing and frozen for its registry schema revision.
/// Expanding it requires an intentional schema revision and reviewed digest vectors.
#[cfg(test)]
const CAPABILITY_REGISTRY_REVISION_1_IDS: [&str; 51] = [
"system:shutdown",
"system:status",
Expand Down Expand Up @@ -115,6 +115,141 @@ const CAPABILITY_REGISTRY_REVISION_1_IDS: [&str; 51] = [
"authority:repair",
];

/// Schema revision for the 51-ID authority registry.
pub const CAPABILITY_REGISTRY_REVISION_1: CapabilityRegistryRevision =
CapabilityRegistryRevision::new(NonZeroU32::MIN);

#[derive(Clone, Copy)]
struct RevisionSemantics {
scope: CapabilityScope,
target_kinds: &'static [AuthorityTargetKind],
delegable: bool,
privileged: bool,
}

/// Build the content-addressed registry for the 51 fixed capability IDs.
///
/// # Errors
///
/// Returns an error if an ID lacks fixed semantics or display metadata, or if
/// any definition fails registry validation.
pub fn capability_registry_revision_1() -> Result<CapabilityRegistryManifest, AuthorityRegistryError>
{
let entries = CAPABILITY_REGISTRY_REVISION_1_IDS
.into_iter()
.map(|id| {
let semantics = revision_1_semantics(id).ok_or_else(|| {
AuthorityRegistryError::MissingRevisionDefinition { id: id.to_string() }
})?;
let danger = revision_1::danger(id).ok_or_else(|| {
AuthorityRegistryError::MissingRevisionDisplayMetadata { id: id.to_string() }
})?;
RegisteredCapability::new(
ExactCapabilityId::new(id.to_string())?,
semantics.scope,
semantics.target_kinds.iter().copied(),
danger,
semantics.delegable,
semantics.privileged,
CapabilitySource::Kernel,
)
})
.collect::<Result<Vec<_>, AuthorityRegistryError>>()?;

CapabilityRegistryManifest::new(CAPABILITY_REGISTRY_REVISION_1, entries)
}

fn revision_1_semantics(id: &str) -> Option<RevisionSemantics> {
use AuthorityTargetKind::{
AuditScope, CapsuleInstance, CapsulePackage, Credential, Group, Principal, System,
};
use CapabilityScope::{Global, Self_};

let semantics = match id {
"system:shutdown" => RevisionSemantics::new(Global, &[System], false, true),
"system:status" => RevisionSemantics::new(Global, &[System], false, false),
"capsule:install" => RevisionSemantics::new(Global, &[System, CapsulePackage], true, true),
"self:capsule:install" => {
RevisionSemantics::new(Self_, &[Principal, CapsulePackage], true, false)
},
"capsule:reload" | "capsule:remove" => {
RevisionSemantics::new(Global, &[System, CapsuleInstance], true, true)
},
"self:capsule:reload"
| "self:capsule:remove"
| "self:workspace:promote"
| "self:workspace:rollback" => {
RevisionSemantics::new(Self_, &[Principal, CapsuleInstance], true, false)
},
"capsule:list" | "agent:list" | "group:list" | "invite:list" => {
RevisionSemantics::new(Global, &[System], true, true)
},
"self:capsule:list"
| "self:agent:list"
| "self:group:list"
| "self:quota:get"
| "self:approval:respond" => RevisionSemantics::new(Self_, &[Principal], true, false),
"agent:create" | "agent:create:clone" | "agent:modify" => {
RevisionSemantics::new(Global, &[Principal, Group, CapsulePackage], true, true)
},
"agent:create:inherit"
| "agent:delete"
| "agent:enable"
| "agent:disable"
| "quota:set"
| "quota:get"
| "caps:grant"
| "caps:revoke"
| "caps:token:list" => RevisionSemantics::new(Global, &[Principal], true, true),
"self:quota:set" => RevisionSemantics::new(Self_, &[Principal], true, true),
"group:create" | "group:delete" | "group:modify" => {
RevisionSemantics::new(Global, &[Group], true, true)
},
"caps:token:mint" | "caps:token:revoke" => {
RevisionSemantics::new(Global, &[Principal, Credential], true, true)
},
"invite:issue" => RevisionSemantics::new(Global, &[Group, Credential], true, true),
"invite:redeem" => {
RevisionSemantics::new(Global, &[Principal, Group, Credential], false, true)
},
"invite:revoke" => RevisionSemantics::new(Global, &[Credential], true, true),
"audit:read_all" => RevisionSemantics::new(Global, &[AuditScope], true, true),
"self:auth:pair" => RevisionSemantics::new(Self_, &[Principal, Credential], true, true),
"self:auth:pair:admin" => {
RevisionSemantics::new(Self_, &[Principal, Credential], false, true)
},
"auth:pair:redeem" => RevisionSemantics::new(Global, &[Principal, Credential], false, true),
"auth:pair" => RevisionSemantics::new(Global, &[Principal, Credential], true, true),
"system:resources:unbounded" | "net_bind" | "uplink" => {
RevisionSemantics::new(Self_, &[Principal, CapsuleInstance], false, true)
},
"capsule:access:any" => {
RevisionSemantics::new(Self_, &[CapsulePackage, CapsuleInstance], false, true)
},
"authority:profile:manage" | "authority:repair" => {
RevisionSemantics::new(Global, &[System, Principal, Group, Credential], false, true)
},
_ => return None,
};
Some(semantics)
}

impl RevisionSemantics {
const fn new(
scope: CapabilityScope,
target_kinds: &'static [AuthorityTargetKind],
delegable: bool,
privileged: bool,
) -> Self {
Self {
scope,
target_kinds,
delegable,
privileged,
}
}
}

/// A validated capability identifier containing no wildcard segment.
///
/// The generic storage parameter permits borrowed views at validation and
Expand Down Expand Up @@ -709,6 +844,18 @@ pub enum AuthorityRegistryError {
/// Recomputed digest.
actual: String,
},
/// A fixed capability ID has no authorization definition.
#[error("capability-registry revision 1 entry {id:?} has no authorization definition")]
MissingRevisionDefinition {
/// Capability identifier.
id: String,
},
/// A fixed capability ID has no danger classification.
#[error("capability-registry revision 1 entry {id:?} has no display metadata")]
MissingRevisionDisplayMetadata {
/// Capability identifier.
id: String,
},
}

fn digest_entry(
Expand Down
60 changes: 60 additions & 0 deletions crates/astrid-core/src/capability_registry/revision_1.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
use super::CapabilityDanger;

pub(super) fn danger(id: &str) -> Option<CapabilityDanger> {
use CapabilityDanger::{Elevated, Extreme, Normal, Safe};

Some(match id {
"system:status"
| "capsule:list"
| "self:capsule:list"
| "agent:list"
| "self:agent:list"
| "quota:get"
| "self:quota:get"
| "group:list"
| "self:group:list"
| "caps:token:list"
| "invite:list"
| "self:approval:respond" => Safe,
"capsule:reload"
| "self:capsule:reload"
| "self:capsule:remove"
| "self:workspace:rollback"
| "agent:create"
| "agent:enable"
| "quota:set"
| "self:quota:set"
| "invite:redeem"
| "invite:revoke"
| "self:auth:pair"
| "auth:pair:redeem" => Normal,
"self:capsule:install"
| "capsule:remove"
| "self:workspace:promote"
| "agent:delete"
| "agent:disable"
| "agent:modify"
| "group:create"
| "group:delete"
| "group:modify"
| "caps:revoke"
| "caps:token:revoke"
| "invite:issue"
| "audit:read_all"
| "self:auth:pair:admin"
| "auth:pair" => Elevated,
"system:shutdown"
| "capsule:install"
| "agent:create:inherit"
| "agent:create:clone"
| "caps:grant"
| "caps:token:mint"
| "system:resources:unbounded"
| "net_bind"
| "uplink"
| "capsule:access:any"
| "authority:profile:manage"
| "authority:repair" => Extreme,
_ => return None,
})
}
Loading
Loading