diff --git a/CHANGELOG.md b/CHANGELOG.md index 703c204be..e88536343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,22 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ## [Unreleased] +### Added + +- **Passive content-addressed capability-registry primitives.** Astrid now has + exact capability IDs, typed content-bound references, immutable registered + definitions, deterministic BLAKE3 semantic digests and canonical registry + manifests. Existing profile persistence, wildcard evaluation, bootstrap, + socket and wire behavior remain unchanged. Closes #1233. Refs #1228. + ### Changed +- **Device key IDs now use BLAKE3.** The short per-device handle is derived from + the first eight bytes of `BLAKE3(pubkey_hex_bytes)`. Profile loading already + treats the stored `key_id` as informational and re-derives it from the public + key, so existing local profiles self-heal; device-scoped bearer sessions must + authenticate again after upgrading. + - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies compatibility directory aliases for the existing runtime harness, replacing diff --git a/Cargo.lock b/Cargo.lock index 893fa139b..febb4da1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -555,13 +555,13 @@ version = "0.9.4" dependencies = [ "async-trait", "base64", + "blake3", "chrono", "getrandom 0.4.3", "hex", "rand 0.10.2", "serde", "serde_json", - "sha2 0.11.0", "subtle", "tempfile", "thiserror 2.0.18", diff --git a/crates/astrid-core/Cargo.toml b/crates/astrid-core/Cargo.toml index efb6bc7d3..cfc270268 100644 --- a/crates/astrid-core/Cargo.toml +++ b/crates/astrid-core/Cargo.toml @@ -16,7 +16,7 @@ hex = { workspace = true } rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -sha2 = { workspace = true } +blake3 = { workspace = true } subtle = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["time", "sync"] } diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs new file mode 100644 index 000000000..ed26e74ed --- /dev/null +++ b/crates/astrid-core/src/capability_registry.rs @@ -0,0 +1,809 @@ +//! Content-addressed capability registry primitives. +//! +//! Exact capability identifiers, semantic entry digests, content-bound +//! references, and canonical registry manifests. + +use std::collections::BTreeSet; +use std::num::NonZeroU32; + +use thiserror::Error; + +use crate::capability_grammar::{ + CapabilityDanger, CapabilityGrammarError, CapabilityScope, validate_capability, +}; +use util::{ + domain_hash, encode_array_len, encode_bool, encode_bytes, encode_text, encode_unsigned, + validate_digest_length, +}; + +mod util; + +const ENTRY_DIGEST_DOMAIN: &[u8] = b"astrid-capability-entry\0"; +const REGISTRY_DIGEST_DOMAIN: &[u8] = b"astrid-capability-registry\0"; + +/// Digest algorithm used by capability entries and registry manifests. +pub const CAPABILITY_REGISTRY_DIGEST_ALGORITHM: &str = "blake3"; + +/// Nonzero schema revision for a capability-registry manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CapabilityRegistryRevision(T); + +impl CapabilityRegistryRevision { + /// Consume the wrapper and return its storage. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } + + /// Borrow the wrapped revision storage. + #[must_use] + pub const fn as_inner(&self) -> &T { + &self.0 + } +} + +impl CapabilityRegistryRevision { + /// Wrap a validated nonzero schema revision. + #[must_use] + pub const fn new(value: NonZeroU32) -> Self { + Self(value) + } + + /// Return the revision as a primitive integer. + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } +} + +/// Exact capability IDs in capability-registry revision 1. +/// +/// 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", + "capsule:install", + "self:capsule:install", + "capsule:reload", + "self:capsule:reload", + "capsule:remove", + "self:capsule:remove", + "self:workspace:promote", + "self:workspace:rollback", + "capsule:list", + "self:capsule:list", + "agent:create", + "agent:create:inherit", + "agent:create:clone", + "agent:delete", + "agent:enable", + "agent:disable", + "agent:modify", + "agent:list", + "self:agent:list", + "quota:set", + "self:quota:set", + "quota:get", + "self:quota:get", + "group:create", + "group:delete", + "group:modify", + "group:list", + "self:group:list", + "caps:grant", + "caps:revoke", + "caps:token:mint", + "caps:token:revoke", + "caps:token:list", + "invite:issue", + "invite:redeem", + "invite:list", + "invite:revoke", + "audit:read_all", + "self:approval:respond", + "self:auth:pair", + "self:auth:pair:admin", + "auth:pair:redeem", + "auth:pair", + "system:resources:unbounded", + "net_bind", + "uplink", + "capsule:access:any", + "authority:profile:manage", + "authority:repair", +]; + +/// A validated capability identifier containing no wildcard segment. +/// +/// The generic storage parameter permits borrowed views at validation and +/// lookup boundaries without changing the owned public representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExactCapabilityId(T); + +impl> ExactCapabilityId { + /// Validate an exact capability identifier. + /// + /// # Errors + /// + /// Returns an error when the capability grammar is invalid or any segment + /// is the wildcard marker `*`. + pub fn new(value: T) -> Result { + let raw = value.as_ref(); + validate_capability(raw).map_err(|source| AuthorityRegistryError::InvalidCapabilityId { + id: raw.to_string(), + source, + })?; + if raw.split(':').any(|segment| segment == "*") { + return Err(AuthorityRegistryError::WildcardCapabilityId { + id: raw.to_string(), + }); + } + Ok(Self(value)) + } + + /// Borrow the validated identifier as a string. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_ref() + } + + /// Borrow the same validated identifier without allocating. + #[must_use] + pub fn as_borrowed(&self) -> ExactCapabilityId<&str> { + ExactCapabilityId(self.as_str()) + } +} + +impl ExactCapabilityId { + /// Consume the wrapper and return its storage. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } +} + +/// BLAKE3 digest of one immutable registered capability definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CapabilityEntryDigest(T); + +impl CapabilityEntryDigest { + /// Borrow the wrapped digest storage. + #[must_use] + pub const fn as_inner(&self) -> &T { + &self.0 + } + + /// Consume the wrapper and return its storage. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } +} + +impl> CapabilityEntryDigest { + /// Validate and wrap digest storage. + /// + /// # Errors + /// + /// Returns an error unless the storage contains exactly 32 bytes. + pub fn new(value: T) -> Result { + validate_digest_length("capability entry", value.as_ref())?; + Ok(Self(value)) + } + + /// Borrow the digest bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Render the digest as lowercase hexadecimal. + #[must_use] + pub fn to_hex(&self) -> String { + hex::encode(self.as_bytes()) + } +} + +impl CapabilityEntryDigest { + /// Wrap a compile-time-sized BLAKE3 digest. + #[must_use] + pub const fn from_array(value: [u8; 32]) -> Self { + Self(value) + } + + /// Borrow the fixed-size digest array. + #[must_use] + pub const fn as_array(&self) -> &[u8; 32] { + &self.0 + } +} + +/// BLAKE3 digest of one complete capability-registry manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CapabilityRegistryDigest(T); + +impl CapabilityRegistryDigest { + /// Borrow the wrapped digest storage. + #[must_use] + pub const fn as_inner(&self) -> &T { + &self.0 + } + + /// Consume the wrapper and return its storage. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } +} + +impl> CapabilityRegistryDigest { + /// Validate and wrap digest storage. + /// + /// # Errors + /// + /// Returns an error unless the storage contains exactly 32 bytes. + pub fn new(value: T) -> Result { + validate_digest_length("capability registry", value.as_ref())?; + Ok(Self(value)) + } + + /// Borrow the digest bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Render the digest as lowercase hexadecimal. + #[must_use] + pub fn to_hex(&self) -> String { + hex::encode(self.as_bytes()) + } +} + +impl CapabilityRegistryDigest { + /// Wrap a compile-time-sized BLAKE3 digest. + #[must_use] + pub const fn from_array(value: [u8; 32]) -> Self { + Self(value) + } + + /// Borrow the fixed-size digest array. + #[must_use] + pub const fn as_array(&self) -> &[u8; 32] { + &self.0 + } +} + +/// BLAKE3 digest of a signed extension package that defines capabilities. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ExtensionPackageDigest(T); + +impl ExtensionPackageDigest { + /// Borrow the wrapped digest storage. + #[must_use] + pub const fn as_inner(&self) -> &T { + &self.0 + } + + /// Consume the wrapper and return its storage. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } +} + +impl> ExtensionPackageDigest { + /// Validate and wrap digest storage. + /// + /// # Errors + /// + /// Returns an error unless the storage contains exactly 32 bytes. + pub fn new(value: T) -> Result { + validate_digest_length("signed extension package", value.as_ref())?; + Ok(Self(value)) + } + + /// Borrow the digest bytes. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_ref() + } + + /// Render the digest as lowercase hexadecimal. + #[must_use] + pub fn to_hex(&self) -> String { + hex::encode(self.as_bytes()) + } +} + +impl ExtensionPackageDigest { + /// Wrap a compile-time-sized BLAKE3 digest. + #[must_use] + pub const fn from_array(value: [u8; 32]) -> Self { + Self(value) + } + + /// Borrow the fixed-size digest array. + #[must_use] + pub const fn as_array(&self) -> &[u8; 32] { + &self.0 + } +} + +/// A content-bound reference used on an authority edge. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CapabilityRef { + id: ExactCapabilityId, + entry_digest: CapabilityEntryDigest, +} + +impl CapabilityRef { + /// Construct a reference from an already validated ID and digest. + #[must_use] + pub const fn new(id: ExactCapabilityId, entry_digest: CapabilityEntryDigest) -> Self { + Self { id, entry_digest } + } + + /// Borrow the exact capability ID. + #[must_use] + pub const fn id(&self) -> &ExactCapabilityId { + &self.id + } + + /// Borrow the bound registry-entry digest. + #[must_use] + pub const fn entry_digest(&self) -> &CapabilityEntryDigest { + &self.entry_digest + } + + /// Consume the reference into its parts. + #[must_use] + pub fn into_parts(self) -> (ExactCapabilityId, CapabilityEntryDigest) { + (self.id, self.entry_digest) + } +} + +/// Canonical authorization target families understood by the host. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum AuthorityTargetKind { + /// The local Astrid system. + System, + /// A principal identity. + Principal, + /// A capability-profile or legacy group identity. + Group, + /// A device or service credential. + Credential, + /// A signed capsule package. + CapsulePackage, + /// One installed or running capsule instance. + CapsuleInstance, + /// An application-level session. + ApplicationSession, + /// A configured model. + Model, + /// An audit query or subscription scope. + AuditScope, +} + +impl AuthorityTargetKind { + const fn code(self) -> u64 { + match self { + Self::System => 0, + Self::Principal => 1, + Self::Group => 2, + Self::Credential => 3, + Self::CapsulePackage => 4, + Self::CapsuleInstance => 5, + Self::ApplicationSession => 6, + Self::Model => 7, + Self::AuditScope => 8, + } + } +} + +/// Provenance of a registered capability definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum CapabilitySource { + /// A capability defined by the Astrid host. + Kernel, + /// A capability supplied by a verified signed extension package. + SignedExtension { + /// BLAKE3 digest of the signed extension package. + package_digest: ExtensionPackageDigest, + }, +} + +/// One capability definition and its semantic digest. +/// +/// `danger` is display metadata and is excluded from entry and manifest digests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegisteredCapability { + id: ExactCapabilityId, + scope: CapabilityScope, + target_kinds: BTreeSet, + danger: CapabilityDanger, + delegable: bool, + privileged: bool, + source: CapabilitySource, + entry_digest: CapabilityEntryDigest, +} + +impl RegisteredCapability { + /// Build and seal a registered capability definition. + /// + /// Target order is canonicalized before hashing. At least one target kind + /// is required so an authority check can never be registered without a + /// host-owned target domain. + /// + /// # Errors + /// + /// Returns an error when `target_kinds` is empty. + pub fn new( + id: ExactCapabilityId, + scope: CapabilityScope, + target_kinds: impl IntoIterator, + danger: CapabilityDanger, + delegable: bool, + privileged: bool, + source: CapabilitySource, + ) -> Result { + let target_kinds = target_kinds.into_iter().collect::>(); + if target_kinds.is_empty() { + return Err(AuthorityRegistryError::MissingTargetKind { + id: id.as_str().to_string(), + }); + } + let entry_digest = digest_entry(&id, scope, &target_kinds, delegable, privileged, source); + Ok(Self { + id, + scope, + target_kinds, + danger, + delegable, + privileged, + source, + entry_digest, + }) + } + + /// Borrow the exact capability ID. + #[must_use] + pub const fn id(&self) -> &ExactCapabilityId { + &self.id + } + + /// Return the authority scope. + #[must_use] + pub const fn scope(&self) -> CapabilityScope { + self.scope + } + + /// Borrow the canonical target-kind set. + #[must_use] + pub const fn target_kinds(&self) -> &BTreeSet { + &self.target_kinds + } + + /// Return the operator-facing danger classification. + #[must_use] + pub const fn danger(&self) -> CapabilityDanger { + self.danger + } + + /// Whether the registry may generate an exact delegation companion. + #[must_use] + pub const fn delegable(&self) -> bool { + self.delegable + } + + /// Whether granting this capability is itself privileged authority. + #[must_use] + pub const fn privileged(&self) -> bool { + self.privileged + } + + /// Return the definition provenance. + #[must_use] + pub const fn source(&self) -> CapabilitySource { + self.source + } + + /// Return the immutable definition digest. + #[must_use] + pub const fn entry_digest(&self) -> CapabilityEntryDigest { + self.entry_digest + } + + /// Create the content-bound reference used by authority edges. + #[must_use] + pub fn capability_ref(&self) -> CapabilityRef<&str, &[u8; 32]> { + CapabilityRef::new( + self.id.as_borrowed(), + CapabilityEntryDigest(self.entry_digest.as_array()), + ) + } + + fn verify_digest(&self) -> Result<(), AuthorityRegistryError> { + let actual = digest_entry( + &self.id, + self.scope, + &self.target_kinds, + self.delegable, + self.privileged, + self.source, + ); + if actual != self.entry_digest { + return Err(AuthorityRegistryError::EntryDigestMismatch { + id: self.id.as_str().to_string(), + expected: self.entry_digest.to_hex(), + actual: actual.to_hex(), + }); + } + Ok(()) + } +} + +/// A sorted, content-addressed registry generation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilityRegistryManifest { + schema_revision: CapabilityRegistryRevision, + entries: Vec, + digest: CapabilityRegistryDigest, +} + +impl CapabilityRegistryManifest { + /// Validate, sort, and seal a complete registry manifest. + /// + /// # Errors + /// + /// Returns an error for an empty registry, a duplicate capability ID, or an + /// entry whose stored digest does not match its authorization semantics. + pub fn new( + schema_revision: CapabilityRegistryRevision, + entries: impl IntoIterator, + ) -> Result { + let mut entries = entries.into_iter().collect::>(); + if entries.is_empty() { + return Err(AuthorityRegistryError::EmptyRegistry); + } + for entry in &entries { + entry.verify_digest()?; + } + entries.sort_by(|left, right| left.id.cmp(&right.id)); + for pair in entries.windows(2) { + if pair[0].id == pair[1].id { + return Err(AuthorityRegistryError::DuplicateCapabilityId { + id: pair[0].id.as_str().to_string(), + }); + } + } + let digest = digest_registry(schema_revision, &entries); + Ok(Self { + schema_revision, + entries, + digest, + }) + } + + /// Return the registry schema revision. + #[must_use] + pub const fn schema_revision(&self) -> CapabilityRegistryRevision { + self.schema_revision + } + + /// Return the fixed digest algorithm identifier. + #[must_use] + pub const fn digest_algorithm(&self) -> &'static str { + CAPABILITY_REGISTRY_DIGEST_ALGORITHM + } + + /// Borrow entries in canonical `(id, digest)` order. + #[must_use] + pub fn entries(&self) -> &[RegisteredCapability] { + &self.entries + } + + /// Return the canonical registry digest. + #[must_use] + pub const fn digest(&self) -> CapabilityRegistryDigest { + self.digest + } + + /// Resolve an exact content-bound capability reference. + #[must_use] + pub fn resolve(&self, reference: &CapabilityRef) -> Option<&RegisteredCapability> + where + I: AsRef, + D: AsRef<[u8]>, + { + let index = self + .entries + .binary_search_by(|entry| entry.id.as_str().cmp(reference.id().as_str())) + .ok()?; + let entry = &self.entries[index]; + (entry.entry_digest.as_bytes() == reference.entry_digest().as_bytes()).then_some(entry) + } + + /// Recompute and verify all entry and manifest digests. + /// + /// # Errors + /// + /// Returns an error if an entry or the aggregate digest has drifted. + pub fn verify(&self) -> Result<(), AuthorityRegistryError> { + for entry in &self.entries { + entry.verify_digest()?; + } + let actual = digest_registry(self.schema_revision, &self.entries); + if actual != self.digest { + return Err(AuthorityRegistryError::RegistryDigestMismatch { + expected: self.digest.to_hex(), + actual: actual.to_hex(), + }); + } + Ok(()) + } +} + +/// Validation failures for content-addressed authority registry data. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum AuthorityRegistryError { + /// A capability ID failed the existing static capability grammar. + #[error("invalid capability id {id:?}: {source}")] + InvalidCapabilityId { + /// Rejected identifier. + id: String, + /// Underlying grammar error. + #[source] + source: CapabilityGrammarError, + }, + /// A grantable capability ID contained a wildcard segment. + #[error("registered capability id {id:?} must be exact")] + WildcardCapabilityId { + /// Rejected identifier. + id: String, + }, + /// A capability definition omitted its authorization target domain. + #[error("registered capability {id:?} must name at least one target kind")] + MissingTargetKind { + /// Capability identifier. + id: String, + }, + /// A supplied digest did not contain exactly 32 bytes. + #[error("{kind} digest must contain exactly 32 bytes, got {actual}")] + InvalidDigestLength { + /// Digest domain. + kind: &'static str, + /// Supplied byte count. + actual: usize, + }, + /// A registry contained no definitions. + #[error("capability registry must contain at least one entry")] + EmptyRegistry, + /// A registry assigned more than one definition to a stable ID. + #[error("capability registry contains more than one definition for {id:?}")] + DuplicateCapabilityId { + /// Duplicated capability identifier. + id: String, + }, + /// An entry's stored digest did not match its authorization semantics. + #[error("capability {id:?} digest mismatch: expected {expected}, computed {actual}")] + EntryDigestMismatch { + /// Capability identifier. + id: String, + /// Stored digest. + expected: String, + /// Recomputed digest. + actual: String, + }, + /// The aggregate registry digest did not match its entries. + #[error("capability registry digest mismatch: expected {expected}, computed {actual}")] + RegistryDigestMismatch { + /// Stored digest. + expected: String, + /// Recomputed digest. + actual: String, + }, +} + +fn digest_entry( + id: &ExactCapabilityId, + scope: CapabilityScope, + target_kinds: &BTreeSet, + delegable: bool, + privileged: bool, + source: CapabilitySource, +) -> CapabilityEntryDigest { + let mut canonical = Vec::new(); + encode_entry( + &mut canonical, + id, + scope, + target_kinds, + delegable, + privileged, + source, + ); + CapabilityEntryDigest::from_array(domain_hash(ENTRY_DIGEST_DOMAIN, &canonical)) +} + +fn digest_registry( + schema_revision: CapabilityRegistryRevision, + entries: &[RegisteredCapability], +) -> CapabilityRegistryDigest { + let mut canonical = Vec::new(); + encode_array_len(&mut canonical, 2); + encode_unsigned(&mut canonical, u64::from(schema_revision.get())); + encode_array_len(&mut canonical, entries.len()); + for entry in entries { + encode_entry( + &mut canonical, + &entry.id, + entry.scope, + &entry.target_kinds, + entry.delegable, + entry.privileged, + entry.source, + ); + } + CapabilityRegistryDigest::from_array(domain_hash(REGISTRY_DIGEST_DOMAIN, &canonical)) +} + +#[allow( + clippy::too_many_arguments, + reason = "the tuple is the normative capability entry" +)] +fn encode_entry( + output: &mut Vec, + id: &ExactCapabilityId, + scope: CapabilityScope, + target_kinds: &BTreeSet, + delegable: bool, + privileged: bool, + source: CapabilitySource, +) { + encode_array_len(output, 6); + encode_text(output, id.as_str()); + encode_unsigned(output, scope_code(scope)); + let mut target_codes = target_kinds + .iter() + .map(|target| target.code()) + .collect::>(); + target_codes.sort_unstable(); + encode_array_len(output, target_codes.len()); + for target_code in target_codes { + encode_unsigned(output, target_code); + } + encode_bool(output, delegable); + encode_bool(output, privileged); + encode_source(output, source); +} + +const fn scope_code(scope: CapabilityScope) -> u64 { + match scope { + CapabilityScope::Self_ => 0, + CapabilityScope::Global => 1, + } +} + +fn encode_source(output: &mut Vec, source: CapabilitySource) { + match source { + CapabilitySource::Kernel => { + encode_array_len(output, 1); + encode_unsigned(output, 0); + }, + CapabilitySource::SignedExtension { package_digest } => { + encode_array_len(output, 2); + encode_unsigned(output, 1); + encode_bytes(output, package_digest.as_bytes()); + }, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs new file mode 100644 index 000000000..e437514d4 --- /dev/null +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -0,0 +1,434 @@ +use super::*; +use crate::capability_grammar::{ + CAP_NET_BIND, CAP_RESOURCES_UNBOUNDED, CAP_UPLINK, CAPABILITY_CATALOG, KNOWN_CAPABILITIES_COUNT, +}; + +fn registered( + id: &str, + targets: impl IntoIterator, + delegable: bool, + privileged: bool, +) -> RegisteredCapability { + RegisteredCapability::new( + ExactCapabilityId::new(id.to_string()).unwrap(), + CapabilityScope::Global, + targets, + CapabilityDanger::Elevated, + delegable, + privileged, + CapabilitySource::Kernel, + ) + .unwrap() +} + +fn revision(value: u32) -> CapabilityRegistryRevision { + CapabilityRegistryRevision::new(NonZeroU32::new(value).unwrap()) +} + +#[test] +fn exact_capability_id_rejects_every_wildcard_position() { + for value in ["*", "self:*", "a:*:b"] { + assert!(matches!( + ExactCapabilityId::new(value), + Err(AuthorityRegistryError::WildcardCapabilityId { .. }) + )); + } + assert!(ExactCapabilityId::new("self:capsule:list").is_ok()); +} + +#[test] +fn capability_registry_revision_1_freezes_current_and_dormant_exact_ids() { + let baseline = CAPABILITY_REGISTRY_REVISION_1_IDS + .iter() + .copied() + .collect::>(); + assert_eq!(baseline.len(), CAPABILITY_REGISTRY_REVISION_1_IDS.len()); + for id in &CAPABILITY_REGISTRY_REVISION_1_IDS { + ExactCapabilityId::new(*id).unwrap(); + } + + let catalog = CAPABILITY_CATALOG + .iter() + .map(|entry| entry.id) + .collect::>(); + assert_eq!(catalog.len(), KNOWN_CAPABILITIES_COUNT); + assert!(catalog.is_subset(&baseline)); + + let additions = baseline + .difference(&catalog) + .copied() + .collect::>(); + assert_eq!( + additions, + BTreeSet::from([ + CAP_RESOURCES_UNBOUNDED, + CAP_NET_BIND, + CAP_UPLINK, + "capsule:access:any", + "authority:profile:manage", + "authority:repair", + ]) + ); +} + +#[test] +fn target_order_does_not_change_entry_digest() { + let left = registered( + "capsule:list", + [ + AuthorityTargetKind::CapsuleInstance, + AuthorityTargetKind::System, + ], + true, + false, + ); + let right = registered( + "capsule:list", + [ + AuthorityTargetKind::System, + AuthorityTargetKind::CapsuleInstance, + ], + true, + false, + ); + assert_eq!(left.entry_digest(), right.entry_digest()); +} + +#[test] +fn every_authorization_field_changes_the_entry_digest() { + let baseline = registered("capsule:list", [AuthorityTargetKind::System], true, false); + let different_id = registered( + "capsule:inspect", + [AuthorityTargetKind::System], + true, + false, + ); + let different_scope = RegisteredCapability::new( + ExactCapabilityId::new("capsule:list".to_string()).unwrap(), + CapabilityScope::Self_, + [AuthorityTargetKind::System], + CapabilityDanger::Elevated, + true, + false, + CapabilitySource::Kernel, + ) + .unwrap(); + let different_target = registered( + "capsule:list", + [AuthorityTargetKind::CapsuleInstance], + true, + false, + ); + let nondelegable = registered("capsule:list", [AuthorityTargetKind::System], false, false); + let privileged = registered("capsule:list", [AuthorityTargetKind::System], true, true); + let extension = RegisteredCapability::new( + ExactCapabilityId::new("capsule:list".to_string()).unwrap(), + CapabilityScope::Global, + [AuthorityTargetKind::System], + CapabilityDanger::Elevated, + true, + false, + CapabilitySource::SignedExtension { + package_digest: ExtensionPackageDigest::from_array([7; 32]), + }, + ) + .unwrap(); + let other_extension = RegisteredCapability::new( + ExactCapabilityId::new("capsule:list".to_string()).unwrap(), + CapabilityScope::Global, + [AuthorityTargetKind::System], + CapabilityDanger::Elevated, + true, + false, + CapabilitySource::SignedExtension { + package_digest: ExtensionPackageDigest::from_array([8; 32]), + }, + ) + .unwrap(); + + for changed in [ + different_id, + different_scope, + different_target, + nondelegable, + privileged, + extension, + other_extension, + ] { + assert_ne!(baseline.entry_digest(), changed.entry_digest()); + } +} + +#[test] +fn danger_presentation_does_not_change_authority_identity() { + let safe = RegisteredCapability::new( + ExactCapabilityId::new("system:status".to_string()).unwrap(), + CapabilityScope::Global, + [AuthorityTargetKind::System], + CapabilityDanger::Safe, + true, + false, + CapabilitySource::Kernel, + ) + .unwrap(); + let extreme = RegisteredCapability::new( + ExactCapabilityId::new("system:status".to_string()).unwrap(), + CapabilityScope::Global, + [AuthorityTargetKind::System], + CapabilityDanger::Extreme, + true, + false, + CapabilitySource::Kernel, + ) + .unwrap(); + + assert_eq!(safe.entry_digest(), extreme.entry_digest()); + let revision = revision(1); + let safe_manifest = CapabilityRegistryManifest::new(revision, [safe]).unwrap(); + let extreme_manifest = CapabilityRegistryManifest::new(revision, [extreme]).unwrap(); + assert_eq!(safe_manifest.digest(), extreme_manifest.digest()); +} + +#[test] +fn manifest_order_is_canonical_and_exact_refs_resolve() { + let capsule = registered("capsule:list", [AuthorityTargetKind::System], true, false); + let system = registered("system:status", [AuthorityTargetKind::System], true, false); + let reference = capsule.capability_ref(); + let revision = revision(1); + let left = + CapabilityRegistryManifest::new(revision, [system.clone(), capsule.clone()]).unwrap(); + let right = CapabilityRegistryManifest::new(revision, [capsule.clone(), system]).unwrap(); + + assert_eq!(left, right); + assert_eq!( + left.resolve(&reference) + .map(RegisteredCapability::id) + .map(ExactCapabilityId::as_str), + Some(reference.id().as_str()) + ); + left.verify().unwrap(); +} + +#[test] +fn duplicate_content_bound_entries_fail_closed() { + let entry = registered("system:status", [AuthorityTargetKind::System], true, false); + assert!(matches!( + CapabilityRegistryManifest::new(revision(1), [entry.clone(), entry]), + Err(AuthorityRegistryError::DuplicateCapabilityId { .. }) + )); +} + +#[test] +fn resolution_requires_the_exact_digest() { + let safe = registered("system:status", [AuthorityTargetKind::System], true, false); + let safe_ref = safe.capability_ref(); + let owned_ref = CapabilityRef::new( + ExactCapabilityId::new("system:status".to_string()).unwrap(), + safe.entry_digest(), + ); + let manifest = CapabilityRegistryManifest::new(revision(1), [safe.clone()]).unwrap(); + let unknown = CapabilityRef::new( + ExactCapabilityId::new("system:status".to_string()).unwrap(), + CapabilityEntryDigest::from_array([0; 32]), + ); + + assert_eq!( + manifest + .resolve(&safe_ref) + .map(RegisteredCapability::danger), + Some(CapabilityDanger::Elevated) + ); + assert_eq!(manifest.resolve(&owned_ref), Some(&safe)); + assert!(manifest.resolve(&unknown).is_none()); +} + +#[test] +fn digest_wrappers_reject_wrong_lengths() { + assert!(matches!( + CapabilityEntryDigest::new(&[0_u8; 31][..]), + Err(AuthorityRegistryError::InvalidDigestLength { actual: 31, .. }) + )); + assert!(matches!( + CapabilityRegistryDigest::new(&[0_u8; 33][..]), + Err(AuthorityRegistryError::InvalidDigestLength { actual: 33, .. }) + )); + assert!(matches!( + ExtensionPackageDigest::new(&[0_u8; 30][..]), + Err(AuthorityRegistryError::InvalidDigestLength { actual: 30, .. }) + )); + assert!(CapabilityEntryDigest::new(&[0_u8; 32]).is_ok()); + assert!(CapabilityRegistryDigest::new(&[0_u8; 32]).is_ok()); + assert!(ExtensionPackageDigest::new(&[0_u8; 32]).is_ok()); +} + +#[test] +fn empty_targets_and_registry_fail_closed() { + assert!(matches!( + RegisteredCapability::new( + ExactCapabilityId::new("system:status".to_string()).unwrap(), + CapabilityScope::Global, + [], + CapabilityDanger::Safe, + true, + false, + CapabilitySource::Kernel, + ), + Err(AuthorityRegistryError::MissingTargetKind { .. }) + )); + assert!(matches!( + CapabilityRegistryManifest::new(revision(1), []), + Err(AuthorityRegistryError::EmptyRegistry) + )); +} + +#[test] +fn tampered_entry_and_manifest_digests_fail_closed() { + let mut entry = registered("system:status", [AuthorityTargetKind::System], true, false); + entry.delegable = false; + assert!(matches!( + CapabilityRegistryManifest::new(revision(1), [entry]), + Err(AuthorityRegistryError::EntryDigestMismatch { .. }) + )); + + let entry = registered("system:status", [AuthorityTargetKind::System], true, false); + let mut manifest = CapabilityRegistryManifest::new(revision(1), [entry]).unwrap(); + manifest.digest = CapabilityRegistryDigest::from_array([0; 32]); + assert!(matches!( + manifest.verify(), + Err(AuthorityRegistryError::RegistryDigestMismatch { .. }) + )); +} + +#[test] +fn same_id_with_different_authorization_semantics_fails_closed() { + let delegable = registered("system:status", [AuthorityTargetKind::System], true, false); + let direct_only = registered("system:status", [AuthorityTargetKind::System], false, false); + + assert_ne!(delegable.entry_digest(), direct_only.entry_digest()); + assert!(matches!( + CapabilityRegistryManifest::new(revision(1), [delegable, direct_only]), + Err(AuthorityRegistryError::DuplicateCapabilityId { .. }) + )); +} + +#[test] +fn registry_digests_have_golden_vectors() { + let capsule = registered( + "capsule:list", + [ + AuthorityTargetKind::System, + AuthorityTargetKind::CapsuleInstance, + ], + true, + false, + ); + assert_eq!( + capsule.entry_digest().to_hex(), + "f521ee33bd074ae2b608d5f415d8cc2567b1b2dba0a61b611518da0967b25253" + ); + let manifest = CapabilityRegistryManifest::new( + revision(1), + [ + capsule, + registered("system:status", [AuthorityTargetKind::System], true, false), + ], + ) + .unwrap(); + assert_eq!( + manifest.digest().to_hex(), + "82ce5c60e68fe848606aed138d81ac8f814623a7f8823e3e48989c2d8f872ddd" + ); + + let next_revision = + CapabilityRegistryManifest::new(revision(2), manifest.entries().iter().cloned()).unwrap(); + assert_ne!(manifest.digest(), next_revision.digest()); +} + +#[test] +fn canonical_entry_bytes_are_stable() { + let entry = registered( + "capsule:list", + [ + AuthorityTargetKind::CapsuleInstance, + AuthorityTargetKind::System, + ], + true, + false, + ); + let mut encoded = Vec::new(); + encode_entry( + &mut encoded, + entry.id(), + entry.scope(), + entry.target_kinds(), + entry.delegable(), + entry.privileged(), + entry.source(), + ); + assert_eq!( + hex::encode(encoded), + "866c63617073756c653a6c69737401820005f5f48100" + ); +} + +#[test] +fn canonical_unsigned_boundaries_use_shortest_forms() { + let cases: &[(u64, &[u8])] = &[ + (23, &[0x17]), + (24, &[0x18, 0x18]), + (255, &[0x18, 0xff]), + (256, &[0x19, 0x01, 0x00]), + (65_535, &[0x19, 0xff, 0xff]), + (65_536, &[0x1a, 0x00, 0x01, 0x00, 0x00]), + (u64::from(u32::MAX), &[0x1a, 0xff, 0xff, 0xff, 0xff]), + ( + u64::from(u32::MAX) + 1, + &[0x1b, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00], + ), + ( + u64::MAX, + &[0x1b, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], + ), + ]; + for (value, expected) in cases { + let mut encoded = Vec::new(); + encode_unsigned(&mut encoded, *value); + assert_eq!(&encoded, expected, "value {value}"); + } +} + +#[test] +fn canonical_enum_and_source_tags_are_stable() { + assert_eq!(scope_code(CapabilityScope::Self_), 0); + assert_eq!(scope_code(CapabilityScope::Global), 1); + assert_eq!( + [ + AuthorityTargetKind::System, + AuthorityTargetKind::Principal, + AuthorityTargetKind::Group, + AuthorityTargetKind::Credential, + AuthorityTargetKind::CapsulePackage, + AuthorityTargetKind::CapsuleInstance, + AuthorityTargetKind::ApplicationSession, + AuthorityTargetKind::Model, + AuthorityTargetKind::AuditScope, + ] + .map(AuthorityTargetKind::code), + [0, 1, 2, 3, 4, 5, 6, 7, 8] + ); + + let mut kernel = Vec::new(); + encode_source(&mut kernel, CapabilitySource::Kernel); + assert_eq!(kernel, [0x81, 0x00]); + + let mut extension = Vec::new(); + encode_source( + &mut extension, + CapabilitySource::SignedExtension { + package_digest: ExtensionPackageDigest::from_array([7; 32]), + }, + ); + assert_eq!(&extension[..3], &[0x82, 0x01, 0x58]); + assert_eq!(extension[3], 0x20); + assert_eq!(&extension[4..], &[7; 32]); +} diff --git a/crates/astrid-core/src/capability_registry/util.rs b/crates/astrid-core/src/capability_registry/util.rs new file mode 100644 index 000000000..3d94f126d --- /dev/null +++ b/crates/astrid-core/src/capability_registry/util.rs @@ -0,0 +1,69 @@ +use super::AuthorityRegistryError; + +pub(super) fn domain_hash(domain: &[u8], canonical: &[u8]) -> [u8; 32] { + let mut hasher = blake3::Hasher::new(); + hasher.update(domain); + hasher.update(canonical); + *hasher.finalize().as_bytes() +} + +pub(super) fn encode_array_len(output: &mut Vec, len: usize) { + encode_major_len(output, 4, usize_to_u64(len)); +} + +pub(super) fn encode_text(output: &mut Vec, value: &str) { + encode_major_len(output, 3, usize_to_u64(value.len())); + output.extend_from_slice(value.as_bytes()); +} + +pub(super) fn encode_bytes(output: &mut Vec, value: &[u8]) { + encode_major_len(output, 2, usize_to_u64(value.len())); + output.extend_from_slice(value); +} + +pub(super) fn encode_unsigned(output: &mut Vec, value: u64) { + encode_major_len(output, 0, value); +} + +pub(super) fn encode_bool(output: &mut Vec, value: bool) { + output.push(if value { 0xf5 } else { 0xf4 }); +} + +fn encode_major_len(output: &mut Vec, major: u8, value: u64) { + let prefix = major << 5; + if let Ok(value) = u8::try_from(value) { + if value <= 23 { + output.push(prefix | value); + } else { + output.push(prefix | 0x18); + output.push(value); + } + } else if let Ok(value) = u16::try_from(value) { + output.push(prefix | 0x19); + output.extend_from_slice(&value.to_be_bytes()); + } else if let Ok(value) = u32::try_from(value) { + output.push(prefix | 0x1a); + output.extend_from_slice(&value.to_be_bytes()); + } else { + output.push(prefix | 0x1b); + output.extend_from_slice(&value.to_be_bytes()); + } +} + +fn usize_to_u64(value: usize) -> u64 { + match u64::try_from(value) { + Ok(value) => value, + Err(_) => unreachable!("usize always fits into u64 on supported targets"), + } +} + +pub(super) fn validate_digest_length( + kind: &'static str, + value: &[u8], +) -> Result<(), AuthorityRegistryError> { + let actual = value.len(); + if actual != 32 { + return Err(AuthorityRegistryError::InvalidDigestLength { kind, actual }); + } + Ok(()) +} diff --git a/crates/astrid-core/src/lib.rs b/crates/astrid-core/src/lib.rs index 986dcc066..ca790de39 100644 --- a/crates/astrid-core/src/lib.rs +++ b/crates/astrid-core/src/lib.rs @@ -17,6 +17,7 @@ pub mod prelude; pub mod capability_grammar; +pub mod capability_registry; pub mod dirs; pub mod elicitation; pub mod env_policy; diff --git a/crates/astrid-core/src/profile/device.rs b/crates/astrid-core/src/profile/device.rs index 515156ec5..f03b844dc 100644 --- a/crates/astrid-core/src/profile/device.rs +++ b/crates/astrid-core/src/profile/device.rs @@ -106,7 +106,7 @@ pub struct DeviceKey { } /// Length, in hex characters, of a [`DeviceKey::key_id`] fingerprint -/// (8 bytes of SHA-256 → 16 hex chars). Collision-resistant enough for the +/// (8 bytes of BLAKE3 -> 16 hex chars). Collision-resistant enough for the /// per-principal device set while staying short enough to paste. pub const DEVICE_KEY_ID_HEX_LEN: usize = 16; @@ -372,7 +372,7 @@ impl<'de> Deserialize<'de> for DevicePubkey { /// Derive a deterministic, non-secret fingerprint for a device key from its /// canonical lowercase-hex pubkey: the first [`DEVICE_KEY_ID_HEX_LEN`] hex -/// chars of `SHA-256(pubkey_hex_bytes)`. +/// chars of `BLAKE3(pubkey_hex_bytes)`. /// /// The fingerprint is **not** a secret — it is derived purely from the /// already-public ed25519 public key, so surfacing it in listings, audit @@ -385,14 +385,8 @@ pub fn device_key_id_fingerprint(pubkey_hex: &str) -> String { } fn device_key_id_for_pubkey_hex(pubkey_hex: &str) -> DeviceKeyId { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(pubkey_hex.as_bytes()); - let mut digest = hex::encode(hasher.finalize()); - // SHA-256 hex is always longer than the short device-key id; truncate - // defensively so a future hash swap still cannot panic. - digest.truncate(DEVICE_KEY_ID_HEX_LEN.min(digest.len())); - DeviceKeyId(digest) + let digest = blake3::hash(pubkey_hex.as_bytes()); + DeviceKeyId(hex::encode(&digest.as_bytes()[..DEVICE_KEY_ID_HEX_LEN / 2])) } /// Normalise a candidate ed25519 public key string to canonical lowercase hex @@ -621,11 +615,12 @@ mod tests { } #[test] - fn device_key_id_is_deterministic_and_short() { + fn device_key_id_is_deterministic_blake3_and_short() { let hex = "a".repeat(64); let a = device_key_id_fingerprint(&hex); let b = device_key_id_fingerprint(&hex); assert_eq!(a, b, "fingerprint must be deterministic"); + assert_eq!(a, "472c51290d607f10"); assert_eq!(a.len(), DEVICE_KEY_ID_HEX_LEN); assert_ne!(a, device_key_id_fingerprint(&"b".repeat(64))); } @@ -752,22 +747,14 @@ mod tests { } #[test] - fn device_key_full_struct_rederives_key_id_ignoring_on_disk_value() { - // A hand-edited / stale `key_id` that does NOT match the pubkey is - // ignored: `key_id` is always re-derived as the deterministic - // fingerprint of the pubkey, so the handshake-returned id can never - // diverge from the gate/revocation fingerprint. + fn device_key_full_struct_self_heals_legacy_sha256_key_id() { let hex = "a".repeat(64); let json = format!( - r#"{{"key_id":"deadbeefdeadbeef","pubkey":"{hex}","scope":{{"type":"full"}}}}"# + r#"{{"key_id":"ffe054fe7ae0cb6d","pubkey":"{hex}","scope":{{"type":"full"}}}}"# ); let key: DeviceKey = serde_json::from_str(&json).unwrap(); - assert_eq!( - key.key_id, - device_key_id_fingerprint(&hex), - "key_id must be re-derived from the pubkey, not taken from disk" - ); - assert_ne!(key.key_id, "deadbeefdeadbeef"); + assert_eq!(key.key_id, "472c51290d607f10"); + assert_eq!(key.key_id, device_key_id_fingerprint(&hex)); } #[test]