From 639b85d11d6e59f0a01d130fc8ce20537b8625c7 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 19:12:46 +0400 Subject: [PATCH 1/9] feat(core): add content-addressed capability registry primitives --- CHANGELOG.md | 8 + crates/astrid-core/src/capability_registry.rs | 783 ++++++++++++++++++ .../src/capability_registry/tests.rs | 421 ++++++++++ crates/astrid-core/src/lib.rs | 1 + 4 files changed, 1213 insertions(+) create mode 100644 crates/astrid-core/src/capability_registry.rs create mode 100644 crates/astrid-core/src/capability_registry/tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 703c204be..bd1ce8e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ 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 semantic digests and canonical registry manifests. + Existing profile persistence, wildcard evaluation, bootstrap, socket and wire + behavior remain unchanged. Closes #1233. Refs #1228. + ### Changed - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs new file mode 100644 index 000000000..71e8139b5 --- /dev/null +++ b/crates/astrid-core/src/capability_registry.rs @@ -0,0 +1,783 @@ +//! 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 sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::capability_grammar::{ + CapabilityDanger, CapabilityGrammarError, CapabilityScope, validate_capability, +}; + +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 = "sha256"; + +/// Exact capability IDs in the authority migration baseline. +pub const MIGRATION_BASELINE_CAPABILITY_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 + } +} + +/// SHA-256 digest of one immutable registered capability definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CapabilityEntryDigest(T); + +impl CapabilityEntryDigest { + /// 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 { + let actual = value.as_ref().len(); + if actual != 32 { + return Err(AuthorityRegistryError::InvalidDigestLength { + kind: "capability entry", + actual, + }); + } + 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 SHA-256 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 + } +} + +/// SHA-256 digest of one complete capability-registry manifest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct CapabilityRegistryDigest(T); + +impl CapabilityRegistryDigest { + /// 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 { + let actual = value.as_ref().len(); + if actual != 32 { + return Err(AuthorityRegistryError::InvalidDigestLength { + kind: "capability registry", + actual, + }); + } + 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 SHA-256 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)] +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)] +pub enum CapabilitySource { + /// A capability defined by the Astrid host. + Kernel, + /// A capability supplied by a verified signed extension package. + SignedExtension { + /// SHA-256 digest of the signed extension package. + package_digest: [u8; 32], + }, +} + +/// 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: NonZeroU32, + 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: NonZeroU32, + 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) -> NonZeroU32 { + 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]>, + { + self.entries.iter().find(|entry| { + entry.id.as_str() == reference.id().as_str() + && entry.entry_digest.as_bytes() == reference.entry_digest().as_bytes() + }) + } + + /// 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)] +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: NonZeroU32, + 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); + }, + } +} + +fn domain_hash(domain: &[u8], canonical: &[u8]) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(canonical); + hasher.finalize().into() +} + +fn encode_array_len(output: &mut Vec, len: usize) { + encode_major_len(output, 4, usize_to_u64(len)); +} + +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()); +} + +fn encode_bytes(output: &mut Vec, value: &[u8]) { + encode_major_len(output, 2, usize_to_u64(value.len())); + output.extend_from_slice(value); +} + +fn encode_unsigned(output: &mut Vec, value: u64) { + encode_major_len(output, 0, value); +} + +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 value <= 23 { + output.push(prefix | narrow_u8(value)); + } else if u8::try_from(value).is_ok() { + output.push(prefix | 0x18); + output.push(narrow_u8(value)); + } else if u16::try_from(value).is_ok() { + output.push(prefix | 0x19); + output.extend_from_slice(&narrow_u16(value).to_be_bytes()); + } else if u32::try_from(value).is_ok() { + output.push(prefix | 0x1a); + output.extend_from_slice(&narrow_u32(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"), + } +} + +fn narrow_u8(value: u64) -> u8 { + match u8::try_from(value) { + Ok(value) => value, + Err(_) => unreachable!("caller checked u8 range"), + } +} + +fn narrow_u16(value: u64) -> u16 { + match u16::try_from(value) { + Ok(value) => value, + Err(_) => unreachable!("caller checked u16 range"), + } +} + +fn narrow_u32(value: u64) -> u32 { + match u32::try_from(value) { + Ok(value) => value, + Err(_) => unreachable!("caller checked u32 range"), + } +} + +#[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..0d73905b9 --- /dev/null +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -0,0 +1,421 @@ +use super::*; +use crate::capability_grammar::{ + CAP_NET_BIND, CAP_RESOURCES_UNBOUNDED, CAP_UPLINK, CAPABILITY_CATALOG, +}; + +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() +} + +#[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 migration_baseline_freezes_current_and_dormant_exact_ids() { + let baseline = MIGRATION_BASELINE_CAPABILITY_IDS + .iter() + .copied() + .collect::>(); + assert_eq!(baseline.len(), MIGRATION_BASELINE_CAPABILITY_IDS.len()); + for id in &MIGRATION_BASELINE_CAPABILITY_IDS { + ExactCapabilityId::new(*id).unwrap(); + } + + let catalog = CAPABILITY_CATALOG + .iter() + .map(|entry| entry.id) + .collect::>(); + assert_eq!(catalog.len(), 45); + 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: [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: [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 = NonZeroU32::new(1).unwrap(); + 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 = NonZeroU32::new(1).unwrap(); + 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(NonZeroU32::new(1).unwrap(), [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(NonZeroU32::new(1).unwrap(), [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!(CapabilityEntryDigest::new(&[0_u8; 32]).is_ok()); + assert!(CapabilityRegistryDigest::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(NonZeroU32::new(1).unwrap(), []), + 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(NonZeroU32::new(1).unwrap(), [entry]), + Err(AuthorityRegistryError::EntryDigestMismatch { .. }) + )); + + let entry = registered("system:status", [AuthorityTargetKind::System], true, false); + let mut manifest = + CapabilityRegistryManifest::new(NonZeroU32::new(1).unwrap(), [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(NonZeroU32::new(1).unwrap(), [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(), + "908fee5f09e75ca149b93fd78de24340cef97d7ddeecd3d5d56fed3401f44195" + ); + let manifest = CapabilityRegistryManifest::new( + NonZeroU32::new(1).unwrap(), + [ + capsule, + registered("system:status", [AuthorityTargetKind::System], true, false), + ], + ) + .unwrap(); + assert_eq!( + manifest.digest().to_hex(), + "e61693befc8711222db7c91ecc44a1599f95ff3a16a4632f237c2d07545f7a88" + ); + + let next_revision = CapabilityRegistryManifest::new( + NonZeroU32::new(2).unwrap(), + 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]), + ]; + 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: [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/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; From 25ce81e60da0565f28737c93857f6b19a170225a Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Tue, 14 Jul 2026 22:46:56 +0400 Subject: [PATCH 2/9] fix(core): harden capability registry lookups --- crates/astrid-core/src/capability_registry.rs | 13 +++++++++---- crates/astrid-core/src/capability_registry/tests.rs | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index 71e8139b5..f9da9e8ac 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -20,6 +20,9 @@ const REGISTRY_DIGEST_DOMAIN: &[u8] = b"astrid-capability-registry\0"; pub const CAPABILITY_REGISTRY_DIGEST_ALGORITHM: &str = "sha256"; /// Exact capability IDs in the authority migration baseline. +/// +/// This set is authority-bearing and frozen for its registry schema revision. +/// Expanding it requires an intentional schema revision and reviewed digest vectors. pub const MIGRATION_BASELINE_CAPABILITY_IDS: [&str; 51] = [ "system:shutdown", "system:status", @@ -522,10 +525,12 @@ impl CapabilityRegistryManifest { I: AsRef, D: AsRef<[u8]>, { - self.entries.iter().find(|entry| { - entry.id.as_str() == reference.id().as_str() - && entry.entry_digest.as_bytes() == reference.entry_digest().as_bytes() - }) + 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. diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs index 0d73905b9..c7bc1bb15 100644 --- a/crates/astrid-core/src/capability_registry/tests.rs +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::capability_grammar::{ - CAP_NET_BIND, CAP_RESOURCES_UNBOUNDED, CAP_UPLINK, CAPABILITY_CATALOG, + CAP_NET_BIND, CAP_RESOURCES_UNBOUNDED, CAP_UPLINK, CAPABILITY_CATALOG, KNOWN_CAPABILITIES_COUNT, }; fn registered( @@ -47,7 +47,7 @@ fn migration_baseline_freezes_current_and_dormant_exact_ids() { .iter() .map(|entry| entry.id) .collect::>(); - assert_eq!(catalog.len(), 45); + assert_eq!(catalog.len(), KNOWN_CAPABILITIES_COUNT); assert!(catalog.is_subset(&baseline)); let additions = baseline From dbc1857ecaef6a92b0c6fb959e212e85dfeb0f40 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 02:29:54 +0400 Subject: [PATCH 3/9] refactor(core): freeze registry identity with BLAKE3 --- CHANGELOG.md | 6 +- Cargo.lock | 2 +- crates/astrid-core/Cargo.toml | 2 +- crates/astrid-core/src/capability_registry.rs | 62 +++++++------------ .../src/capability_registry/tests.rs | 12 ++-- 5 files changed, 32 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1ce8e56..abe7ce7bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,9 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. - **Passive content-addressed capability-registry primitives.** Astrid now has exact capability IDs, typed content-bound references, immutable registered - definitions, deterministic semantic digests and canonical registry manifests. - Existing profile persistence, wildcard evaluation, bootstrap, socket and wire - behavior remain unchanged. Closes #1233. Refs #1228. + 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 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 index f9da9e8ac..750b4f41a 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -6,7 +6,6 @@ use std::collections::BTreeSet; use std::num::NonZeroU32; -use sha2::{Digest, Sha256}; use thiserror::Error; use crate::capability_grammar::{ @@ -17,13 +16,13 @@ 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 = "sha256"; +pub const CAPABILITY_REGISTRY_DIGEST_ALGORITHM: &str = "blake3"; -/// Exact capability IDs in the authority migration baseline. +/// 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. -pub const MIGRATION_BASELINE_CAPABILITY_IDS: [&str; 51] = [ +const CAPABILITY_REGISTRY_REVISION_1_IDS: [&str; 51] = [ "system:shutdown", "system:status", "capsule:install", @@ -126,7 +125,7 @@ impl ExactCapabilityId { } } -/// SHA-256 digest of one immutable registered capability definition. +/// BLAKE3 digest of one immutable registered capability definition. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilityEntryDigest(T); @@ -169,7 +168,7 @@ impl> CapabilityEntryDigest { } impl CapabilityEntryDigest { - /// Wrap a compile-time-sized SHA-256 digest. + /// Wrap a compile-time-sized BLAKE3 digest. #[must_use] pub const fn from_array(value: [u8; 32]) -> Self { Self(value) @@ -182,7 +181,7 @@ impl CapabilityEntryDigest { } } -/// SHA-256 digest of one complete capability-registry manifest. +/// BLAKE3 digest of one complete capability-registry manifest. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct CapabilityRegistryDigest(T); @@ -225,7 +224,7 @@ impl> CapabilityRegistryDigest { } impl CapabilityRegistryDigest { - /// Wrap a compile-time-sized SHA-256 digest. + /// Wrap a compile-time-sized BLAKE3 digest. #[must_use] pub const fn from_array(value: [u8; 32]) -> Self { Self(value) @@ -317,7 +316,7 @@ pub enum CapabilitySource { Kernel, /// A capability supplied by a verified signed extension package. SignedExtension { - /// SHA-256 digest of the signed extension package. + /// BLAKE3 digest of the signed extension package. package_digest: [u8; 32], }, } @@ -709,10 +708,10 @@ fn encode_source(output: &mut Vec, source: CapabilitySource) { } fn domain_hash(domain: &[u8], canonical: &[u8]) -> [u8; 32] { - let mut hasher = Sha256::new(); + let mut hasher = blake3::Hasher::new(); hasher.update(domain); hasher.update(canonical); - hasher.finalize().into() + *hasher.finalize().as_bytes() } fn encode_array_len(output: &mut Vec, len: usize) { @@ -739,17 +738,19 @@ fn encode_bool(output: &mut Vec, value: bool) { fn encode_major_len(output: &mut Vec, major: u8, value: u64) { let prefix = major << 5; - if value <= 23 { - output.push(prefix | narrow_u8(value)); - } else if u8::try_from(value).is_ok() { - output.push(prefix | 0x18); - output.push(narrow_u8(value)); - } else if u16::try_from(value).is_ok() { + 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(&narrow_u16(value).to_be_bytes()); - } else if u32::try_from(value).is_ok() { + 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(&narrow_u32(value).to_be_bytes()); + output.extend_from_slice(&value.to_be_bytes()); } else { output.push(prefix | 0x1b); output.extend_from_slice(&value.to_be_bytes()); @@ -763,26 +764,5 @@ fn usize_to_u64(value: usize) -> u64 { } } -fn narrow_u8(value: u64) -> u8 { - match u8::try_from(value) { - Ok(value) => value, - Err(_) => unreachable!("caller checked u8 range"), - } -} - -fn narrow_u16(value: u64) -> u16 { - match u16::try_from(value) { - Ok(value) => value, - Err(_) => unreachable!("caller checked u16 range"), - } -} - -fn narrow_u32(value: u64) -> u32 { - match u32::try_from(value) { - Ok(value) => value, - Err(_) => unreachable!("caller checked u32 range"), - } -} - #[cfg(test)] mod tests; diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs index c7bc1bb15..e238706d0 100644 --- a/crates/astrid-core/src/capability_registry/tests.rs +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -33,13 +33,13 @@ fn exact_capability_id_rejects_every_wildcard_position() { } #[test] -fn migration_baseline_freezes_current_and_dormant_exact_ids() { - let baseline = MIGRATION_BASELINE_CAPABILITY_IDS +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(), MIGRATION_BASELINE_CAPABILITY_IDS.len()); - for id in &MIGRATION_BASELINE_CAPABILITY_IDS { + assert_eq!(baseline.len(), CAPABILITY_REGISTRY_REVISION_1_IDS.len()); + for id in &CAPABILITY_REGISTRY_REVISION_1_IDS { ExactCapabilityId::new(*id).unwrap(); } @@ -317,7 +317,7 @@ fn registry_digests_have_golden_vectors() { ); assert_eq!( capsule.entry_digest().to_hex(), - "908fee5f09e75ca149b93fd78de24340cef97d7ddeecd3d5d56fed3401f44195" + "f521ee33bd074ae2b608d5f415d8cc2567b1b2dba0a61b611518da0967b25253" ); let manifest = CapabilityRegistryManifest::new( NonZeroU32::new(1).unwrap(), @@ -329,7 +329,7 @@ fn registry_digests_have_golden_vectors() { .unwrap(); assert_eq!( manifest.digest().to_hex(), - "e61693befc8711222db7c91ecc44a1599f95ff3a16a4632f237c2d07545f7a88" + "82ce5c60e68fe848606aed138d81ac8f814623a7f8823e3e48989c2d8f872ddd" ); let next_revision = CapabilityRegistryManifest::new( From ac1122a7128ee703e9694c4f15738b7558cd1d98 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 02:44:33 +0400 Subject: [PATCH 4/9] refactor(core): type registry identity boundaries --- crates/astrid-core/src/capability_registry.rs | 118 +++++++++++++++--- .../src/capability_registry/tests.rs | 42 ++++--- 2 files changed, 121 insertions(+), 39 deletions(-) diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index 750b4f41a..35ab3dad0 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -18,6 +18,38 @@ 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. @@ -144,13 +176,7 @@ impl> CapabilityEntryDigest { /// /// Returns an error unless the storage contains exactly 32 bytes. pub fn new(value: T) -> Result { - let actual = value.as_ref().len(); - if actual != 32 { - return Err(AuthorityRegistryError::InvalidDigestLength { - kind: "capability entry", - actual, - }); - } + validate_digest_length("capability entry", value.as_ref())?; Ok(Self(value)) } @@ -200,13 +226,7 @@ impl> CapabilityRegistryDigest { /// /// Returns an error unless the storage contains exactly 32 bytes. pub fn new(value: T) -> Result { - let actual = value.as_ref().len(); - if actual != 32 { - return Err(AuthorityRegistryError::InvalidDigestLength { - kind: "capability registry", - actual, - }); - } + validate_digest_length("capability registry", value.as_ref())?; Ok(Self(value)) } @@ -237,6 +257,56 @@ impl CapabilityRegistryDigest { } } +/// 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 { + /// 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 { @@ -317,7 +387,7 @@ pub enum CapabilitySource { /// A capability supplied by a verified signed extension package. SignedExtension { /// BLAKE3 digest of the signed extension package. - package_digest: [u8; 32], + package_digest: ExtensionPackageDigest, }, } @@ -454,7 +524,7 @@ impl RegisteredCapability { /// A sorted, content-addressed registry generation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CapabilityRegistryManifest { - schema_revision: NonZeroU32, + schema_revision: CapabilityRegistryRevision, entries: Vec, digest: CapabilityRegistryDigest, } @@ -467,7 +537,7 @@ impl CapabilityRegistryManifest { /// 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: NonZeroU32, + schema_revision: CapabilityRegistryRevision, entries: impl IntoIterator, ) -> Result { let mut entries = entries.into_iter().collect::>(); @@ -495,7 +565,7 @@ impl CapabilityRegistryManifest { /// Return the registry schema revision. #[must_use] - pub const fn schema_revision(&self) -> NonZeroU32 { + pub const fn schema_revision(&self) -> CapabilityRegistryRevision { self.schema_revision } @@ -635,7 +705,7 @@ fn digest_entry( } fn digest_registry( - schema_revision: NonZeroU32, + schema_revision: CapabilityRegistryRevision, entries: &[RegisteredCapability], ) -> CapabilityRegistryDigest { let mut canonical = Vec::new(); @@ -702,7 +772,7 @@ fn encode_source(output: &mut Vec, source: CapabilitySource) { CapabilitySource::SignedExtension { package_digest } => { encode_array_len(output, 2); encode_unsigned(output, 1); - encode_bytes(output, &package_digest); + encode_bytes(output, package_digest.as_bytes()); }, } } @@ -764,5 +834,13 @@ fn usize_to_u64(value: usize) -> u64 { } } +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(()) +} + #[cfg(test)] mod tests; diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs index e238706d0..bc1bcf46d 100644 --- a/crates/astrid-core/src/capability_registry/tests.rs +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -21,6 +21,10 @@ fn registered( .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"] { @@ -125,7 +129,7 @@ fn every_authorization_field_changes_the_entry_digest() { true, false, CapabilitySource::SignedExtension { - package_digest: [7; 32], + package_digest: ExtensionPackageDigest::from_array([7; 32]), }, ) .unwrap(); @@ -137,7 +141,7 @@ fn every_authorization_field_changes_the_entry_digest() { true, false, CapabilitySource::SignedExtension { - package_digest: [8; 32], + package_digest: ExtensionPackageDigest::from_array([8; 32]), }, ) .unwrap(); @@ -179,7 +183,7 @@ fn danger_presentation_does_not_change_authority_identity() { .unwrap(); assert_eq!(safe.entry_digest(), extreme.entry_digest()); - let revision = NonZeroU32::new(1).unwrap(); + 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()); @@ -190,7 +194,7 @@ 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 = NonZeroU32::new(1).unwrap(); + let revision = revision(1); let left = CapabilityRegistryManifest::new(revision, [system.clone(), capsule.clone()]).unwrap(); let right = CapabilityRegistryManifest::new(revision, [capsule.clone(), system]).unwrap(); @@ -209,7 +213,7 @@ fn manifest_order_is_canonical_and_exact_refs_resolve() { fn duplicate_content_bound_entries_fail_closed() { let entry = registered("system:status", [AuthorityTargetKind::System], true, false); assert!(matches!( - CapabilityRegistryManifest::new(NonZeroU32::new(1).unwrap(), [entry.clone(), entry]), + CapabilityRegistryManifest::new(revision(1), [entry.clone(), entry]), Err(AuthorityRegistryError::DuplicateCapabilityId { .. }) )); } @@ -222,8 +226,7 @@ fn resolution_requires_the_exact_digest() { ExactCapabilityId::new("system:status".to_string()).unwrap(), safe.entry_digest(), ); - let manifest = - CapabilityRegistryManifest::new(NonZeroU32::new(1).unwrap(), [safe.clone()]).unwrap(); + 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]), @@ -249,8 +252,13 @@ fn digest_wrappers_reject_wrong_lengths() { 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] @@ -268,7 +276,7 @@ fn empty_targets_and_registry_fail_closed() { Err(AuthorityRegistryError::MissingTargetKind { .. }) )); assert!(matches!( - CapabilityRegistryManifest::new(NonZeroU32::new(1).unwrap(), []), + CapabilityRegistryManifest::new(revision(1), []), Err(AuthorityRegistryError::EmptyRegistry) )); } @@ -278,13 +286,12 @@ 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(NonZeroU32::new(1).unwrap(), [entry]), + CapabilityRegistryManifest::new(revision(1), [entry]), Err(AuthorityRegistryError::EntryDigestMismatch { .. }) )); let entry = registered("system:status", [AuthorityTargetKind::System], true, false); - let mut manifest = - CapabilityRegistryManifest::new(NonZeroU32::new(1).unwrap(), [entry]).unwrap(); + let mut manifest = CapabilityRegistryManifest::new(revision(1), [entry]).unwrap(); manifest.digest = CapabilityRegistryDigest::from_array([0; 32]); assert!(matches!( manifest.verify(), @@ -299,7 +306,7 @@ fn same_id_with_different_authorization_semantics_fails_closed() { assert_ne!(delegable.entry_digest(), direct_only.entry_digest()); assert!(matches!( - CapabilityRegistryManifest::new(NonZeroU32::new(1).unwrap(), [delegable, direct_only]), + CapabilityRegistryManifest::new(revision(1), [delegable, direct_only]), Err(AuthorityRegistryError::DuplicateCapabilityId { .. }) )); } @@ -320,7 +327,7 @@ fn registry_digests_have_golden_vectors() { "f521ee33bd074ae2b608d5f415d8cc2567b1b2dba0a61b611518da0967b25253" ); let manifest = CapabilityRegistryManifest::new( - NonZeroU32::new(1).unwrap(), + revision(1), [ capsule, registered("system:status", [AuthorityTargetKind::System], true, false), @@ -332,11 +339,8 @@ fn registry_digests_have_golden_vectors() { "82ce5c60e68fe848606aed138d81ac8f814623a7f8823e3e48989c2d8f872ddd" ); - let next_revision = CapabilityRegistryManifest::new( - NonZeroU32::new(2).unwrap(), - manifest.entries().iter().cloned(), - ) - .unwrap(); + let next_revision = + CapabilityRegistryManifest::new(revision(2), manifest.entries().iter().cloned()).unwrap(); assert_ne!(manifest.digest(), next_revision.digest()); } @@ -412,7 +416,7 @@ fn canonical_enum_and_source_tags_are_stable() { encode_source( &mut extension, CapabilitySource::SignedExtension { - package_digest: [7; 32], + package_digest: ExtensionPackageDigest::from_array([7; 32]), }, ); assert_eq!(&extension[..3], &[0x82, 0x01, 0x58]); From 5139d96c6f4d7c8f232ab64765fca363e0000329 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 02:50:55 +0400 Subject: [PATCH 5/9] fix(core): retain released device fingerprint dependency --- Cargo.lock | 1 + crates/astrid-core/Cargo.toml | 1 + 2 files changed, 2 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index febb4da1f..7fe1ccd72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,6 +562,7 @@ dependencies = [ "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 cfc270268..d52dc7d66 100644 --- a/crates/astrid-core/Cargo.toml +++ b/crates/astrid-core/Cargo.toml @@ -17,6 +17,7 @@ rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } blake3 = { workspace = true } +sha2 = { workspace = true } subtle = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["time", "sync"] } From 086e23c451b5ff5b6cd736162810414147920c41 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 02:54:22 +0400 Subject: [PATCH 6/9] fix(core): scope revision fixture to tests --- crates/astrid-core/src/capability_registry.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index 35ab3dad0..84b73a838 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -54,6 +54,7 @@ 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", From 354a89b411c2aa3d93428818af4bf13a608b4ed8 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 03:17:48 +0400 Subject: [PATCH 7/9] refactor(core): isolate registry encoding utilities --- crates/astrid-core/src/capability_registry.rs | 71 ++----------------- .../src/capability_registry/util.rs | 69 ++++++++++++++++++ 2 files changed, 75 insertions(+), 65 deletions(-) create mode 100644 crates/astrid-core/src/capability_registry/util.rs diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index 84b73a838..2ef832217 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -11,6 +11,12 @@ 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"; @@ -778,70 +784,5 @@ fn encode_source(output: &mut Vec, source: CapabilitySource) { } } -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() -} - -fn encode_array_len(output: &mut Vec, len: usize) { - encode_major_len(output, 4, usize_to_u64(len)); -} - -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()); -} - -fn encode_bytes(output: &mut Vec, value: &[u8]) { - encode_major_len(output, 2, usize_to_u64(value.len())); - output.extend_from_slice(value); -} - -fn encode_unsigned(output: &mut Vec, value: u64) { - encode_major_len(output, 0, value); -} - -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"), - } -} - -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(()) -} - #[cfg(test)] mod tests; 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(()) +} From b86d459731d5641bbeddc2fd80dec5b775f4824e Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 03:24:45 +0400 Subject: [PATCH 8/9] refactor(core): derive device key ids with BLAKE3 --- CHANGELOG.md | 6 +++++ Cargo.lock | 1 - crates/astrid-core/Cargo.toml | 1 - crates/astrid-core/src/profile/device.rs | 33 +++++++----------------- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abe7ce7bd..e88536343 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. ### 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 7fe1ccd72..febb4da1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -562,7 +562,6 @@ dependencies = [ "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 d52dc7d66..cfc270268 100644 --- a/crates/astrid-core/Cargo.toml +++ b/crates/astrid-core/Cargo.toml @@ -17,7 +17,6 @@ rand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } blake3 = { workspace = true } -sha2 = { workspace = true } subtle = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["time", "sync"] } 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] From 19830c2006af90874f3c0577fe6fd2bf859becca Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 03:24:53 +0400 Subject: [PATCH 9/9] fix(core): keep registry API extensible --- crates/astrid-core/src/capability_registry.rs | 21 +++++++++++++++++++ .../src/capability_registry/tests.rs | 9 ++++++++ 2 files changed, 30 insertions(+) diff --git a/crates/astrid-core/src/capability_registry.rs b/crates/astrid-core/src/capability_registry.rs index 2ef832217..ed26e74ed 100644 --- a/crates/astrid-core/src/capability_registry.rs +++ b/crates/astrid-core/src/capability_registry.rs @@ -169,6 +169,12 @@ impl ExactCapabilityId { 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 { @@ -219,6 +225,12 @@ impl CapabilityEntryDigest { 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 { @@ -269,6 +281,12 @@ impl CapabilityRegistryDigest { 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 { @@ -349,6 +367,7 @@ impl CapabilityRef { /// 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, @@ -388,6 +407,7 @@ impl AuthorityTargetKind { /// 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, @@ -631,6 +651,7 @@ impl CapabilityRegistryManifest { /// 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}")] diff --git a/crates/astrid-core/src/capability_registry/tests.rs b/crates/astrid-core/src/capability_registry/tests.rs index bc1bcf46d..e437514d4 100644 --- a/crates/astrid-core/src/capability_registry/tests.rs +++ b/crates/astrid-core/src/capability_registry/tests.rs @@ -380,6 +380,15 @@ fn canonical_unsigned_boundaries_use_shortest_forms() { (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();