From 3d788caeb2f8495a8cb6aa0531190a4e83b6f64a Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 04:19:05 +0400 Subject: [PATCH 1/8] feat(security): replace owned identifiers with blake3 --- CHANGELOG.md | 11 + Cargo.lock | 2 - crates/astrid-cli/src/commands/invite.rs | 8 +- crates/astrid-cli/src/commands/keypair.rs | 179 +++++++++++++-- crates/astrid-config/src/defaults.toml | 2 +- crates/astrid-core/src/kernel_api/mod.rs | 17 +- crates/astrid-crypto/src/identifier.rs | 207 ++++++++++++++++++ crates/astrid-crypto/src/lib.rs | 2 + crates/astrid-crypto/src/prelude.rs | 4 +- crates/astrid-gateway/Cargo.toml | 3 +- crates/astrid-gateway/src/routes/auth.rs | 14 +- crates/astrid-gateway/src/routes/env.rs | 26 +-- crates/astrid-gateway/src/routes/invites.rs | 8 +- .../tests/compatibility_fixtures.rs | 17 ++ crates/astrid-kernel/Cargo.toml | 1 - crates/astrid-kernel/src/invite.rs | 199 ++++++++++++++--- .../kernel_router/admin/invite_handlers.rs | 110 +++++++++- .../src/kernel_router/admin/mod.rs | 15 +- .../admin/pair_device_handlers.rs | 2 +- crates/astrid-kernel/src/pair_token.rs | 161 ++++++++++++-- crates/astrid-mcp/src/config.rs | 59 ++++- docs/config.md | 4 +- docs/distro-signing.md | 6 +- docs/gateway-client.md | 2 +- .../legacy-pair-token-without-scope.toml | 4 +- .../compat/legacy-sha-pair-token.toml | 6 + 26 files changed, 903 insertions(+), 166 deletions(-) create mode 100644 crates/astrid-crypto/src/identifier.rs create mode 100644 e2e/fixtures/compat/legacy-sha-pair-token.toml diff --git a/CHANGELOG.md b/CHANGELOG.md index f5f6f407f..c2a9b31ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,17 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. 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. +- **Astrid-owned identifiers now use domain-separated BLAKE3.** Invite and + pair-device token stores carry an explicit schema and invalidate + legacy SHA-256 records that cannot be rehashed without their raw secrets; + newly issued bearer tokens use type-specific `astrid_inv_` and + `astrid_pair_` prefixes, while fingerprints use an explicit `blake3:` label. + CLI key metadata self-heals from the retained public key. Public-key + fingerprints share a typed derivation primitive, MCP binary pins now carry + an honest `blake3:` label, and gateway env-write logs no longer expose + dictionary-testable fingerprints of low-entropy values. External SHA-based + protocols such as SRI, Git, registry checksums, and release tooling remain + unchanged. Closes #1247. - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies diff --git a/Cargo.lock b/Cargo.lock index 516e9f987..13c77616f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -675,7 +675,6 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2 0.11.0", "subtle", "tempfile", "thiserror 2.0.18", @@ -786,7 +785,6 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2 0.11.0", "subtle", "tempfile", "tokio", diff --git a/crates/astrid-cli/src/commands/invite.rs b/crates/astrid-cli/src/commands/invite.rs index c75181bcf..8c27a9fc7 100644 --- a/crates/astrid-cli/src/commands/invite.rs +++ b/crates/astrid-cli/src/commands/invite.rs @@ -54,7 +54,7 @@ pub(crate) struct IssueArgs { #[derive(Args, Debug, Clone)] pub(crate) struct RedeemArgs { - /// The opaque token returned by a prior `astrid invite issue`. + /// The typed `astrid_inv_` token returned by a prior `astrid invite issue`. #[arg(allow_hyphen_values = true)] pub token: String, /// Hex-encoded ed25519 public key. Accepts bare 64 hex chars or @@ -89,7 +89,7 @@ pub(crate) struct ListArgs { #[derive(Args, Debug, Clone)] pub(crate) struct RevokeArgs { - /// Either the raw token or its hex fingerprint (from `invite list`). + /// Either the raw token or its `blake3:` fingerprint (from `invite list`). #[arg(allow_hyphen_values = true)] pub token_or_fingerprint: String, } @@ -225,12 +225,12 @@ async fn run_list(args: ListArgs) -> Result { println!("{}", Theme::dimmed("no outstanding invites")); } else { println!( - "{:<64} {:<15} {:>5} {:<10} LABEL", + "{:<71} {:<15} {:>5} {:<10} LABEL", "FINGERPRINT", "GROUP", "USES", "EXPIRES", ); for inv in invites { println!( - "{:<64} {:<15} {:>5} {:<10} {}", + "{:<71} {:<15} {:>5} {:<10} {}", inv.token_fingerprint, inv.group, inv.remaining_uses, diff --git a/crates/astrid-cli/src/commands/keypair.rs b/crates/astrid-cli/src/commands/keypair.rs index 1fad2e0d5..8f3a3fc2f 100644 --- a/crates/astrid-cli/src/commands/keypair.rs +++ b/crates/astrid-cli/src/commands/keypair.rs @@ -37,12 +37,12 @@ use std::time::SystemTime; use anyhow::{Context, Result, bail}; use astrid_core::PrincipalId; use astrid_core::dirs::AstridHome; +use astrid_crypto::PublicKeyFingerprint; use clap::{Args, Subcommand}; use colored::Colorize; use ed25519_dalek::SigningKey; use rand::{TryRng, rngs::SysRng}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use crate::theme::Theme; @@ -140,9 +140,9 @@ struct KeyMeta { /// changes to this struct so older `astrid` binaries can refuse /// keys they don't understand. schema_version: u32, - /// SHA-256 of the public key. Same shape the kernel + audit log - /// uses, so an operator can copy-paste from `astrid keypair - /// show` into `astrid invite list` and bind by eye. + /// Domain-separated BLAKE3 fingerprint of the public key. The kernel and + /// audit log use the same derivation, so operators can correlate local + /// key metadata with redeem and pairing events. fingerprint: String, /// Unix-epoch seconds the keypair was generated. created_at_epoch: u64, @@ -160,7 +160,7 @@ struct KeyMeta { bound_principal: Option, } -const META_SCHEMA_VERSION: u32 = 1; +const META_SCHEMA_VERSION: u32 = 2; /// Returns the directory `~/.astrid/keys/local/`, creating it if /// missing with 0700 perms. @@ -237,7 +237,7 @@ fn run_generate(args: GenerateArgs) -> Result { let verifying = signing.verifying_key(); let pub_hex = hex::encode(verifying.to_bytes()); - let fingerprint = fingerprint_pubkey(&pub_hex); + let fingerprint = fingerprint_pubkey(&pub_hex)?; let now = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) @@ -283,13 +283,13 @@ fn run_list(args: &ListArgs) -> Result { return Ok(ExitCode::SUCCESS); } println!( - "{:<24} {:<64} {:<10} BOUND PRINCIPAL", + "{:<24} {:<71} {:<10} BOUND PRINCIPAL", "NAME", "FINGERPRINT", "CREATED", ); for entry in entries { let bound = entry.meta.bound_principal.as_deref().unwrap_or("-"); println!( - "{:<24} {:<64} {:<10} {}", + "{:<24} {:<71} {:<10} {}", entry.name, entry.meta.fingerprint, entry.meta.created_at_epoch, bound, ); } @@ -302,7 +302,7 @@ fn run_show(args: &ShowArgs) -> Result { if !paths.meta.exists() { bail!("keypair {:?} not found", args.name); } - let meta = read_meta(&paths.meta)?; + let meta = read_meta(&paths)?; let pub_hex = read_public(&paths.public_hex).unwrap_or_else(|_| "".to_string()); println!("name: {}", args.name); println!("fingerprint: {}", meta.fingerprint); @@ -384,7 +384,7 @@ pub(crate) fn record_binding(name: &str, principal: &PrincipalId) -> Result<()> if !paths.meta.exists() { return Ok(()); } - let mut meta = read_meta(&paths.meta)?; + let mut meta = read_meta(&paths)?; meta.bound_principal = Some(principal.to_string()); write_meta(&paths.meta, &meta)?; Ok(()) @@ -466,18 +466,51 @@ fn read_public(path: &Path) -> Result { Ok(trimmed) } -fn read_meta(path: &Path) -> Result { - let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; +fn read_meta(paths: &KeyPaths) -> Result { + let text = fs::read_to_string(&paths.meta) + .with_context(|| format!("read {}", paths.meta.display()))?; let meta: KeyMeta = - toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("parse {}", paths.meta.display()))?; if meta.schema_version > META_SCHEMA_VERSION { bail!( "keypair {} was written by a newer astrid (schema {} > {})", - path.display(), + paths.meta.display(), meta.schema_version, META_SCHEMA_VERSION ); } + if meta.schema_version < META_SCHEMA_VERSION { + let public_hex = match read_public(&paths.public_hex) { + Ok(public_hex) => public_hex, + Err(error) => { + tracing::warn!( + path = %paths.meta.display(), + %error, + "keypair fingerprint migration deferred until a valid public key is available" + ); + return Ok(meta); + }, + }; + let expected = fingerprint_pubkey(&public_hex)?; + let migrated = KeyMeta { + schema_version: META_SCHEMA_VERSION, + fingerprint: expected, + ..meta + }; + write_meta(&paths.meta, &migrated)?; + return Ok(migrated); + } + if let Ok(public_hex) = read_public(&paths.public_hex) { + let expected = fingerprint_pubkey(&public_hex)?; + if meta.fingerprint != expected { + let repaired = KeyMeta { + fingerprint: expected, + ..meta + }; + write_meta(&paths.meta, &repaired)?; + return Ok(repaired); + } + } Ok(meta) } @@ -520,7 +553,12 @@ fn scan_keys() -> Result> { else { continue; }; - match read_meta(&path) { + let paths = KeyPaths { + private: dir.join(format!("{name}.ed25519")), + public_hex: dir.join(format!("{name}.pub.hex")), + meta: path, + }; + match read_meta(&paths) { Ok(meta) => out.push(KeyEntry { name: name.to_string(), meta, @@ -565,10 +603,10 @@ fn default_name() -> String { format!("key-{}", hex::encode(bytes)) } -fn fingerprint_pubkey(hex_pub: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(format!("ed25519:{hex_pub}").as_bytes()); - hex::encode(hasher.finalize()) +fn fingerprint_pubkey(hex_pub: &str) -> Result { + PublicKeyFingerprint::from_ed25519_hex(hex_pub) + .map(PublicKeyFingerprint::into_inner) + .map_err(|e| anyhow::anyhow!("fingerprint Ed25519 public key: {e}")) } /// Convert a 64-char hex ed25519 public key into the `ed25519:` @@ -637,12 +675,107 @@ mod tests { #[test] fn fingerprint_is_stable_and_distinct() { - let a = fingerprint_pubkey(&"a".repeat(64)); - let b = fingerprint_pubkey(&"a".repeat(64)); - let c = fingerprint_pubkey(&"b".repeat(64)); + let a = fingerprint_pubkey(&"a".repeat(64)).unwrap(); + let b = fingerprint_pubkey(&"a".repeat(64)).unwrap(); + let c = fingerprint_pubkey(&"b".repeat(64)).unwrap(); assert_eq!(a, b); assert_ne!(a, c); - assert_eq!(a.len(), 64); + assert_eq!(a.len(), 71); + } + + #[test] + fn legacy_key_metadata_self_heals_from_the_public_key() { + let dir = tempfile::tempdir().unwrap(); + let paths = KeyPaths { + private: dir.path().join("laptop.ed25519"), + public_hex: dir.path().join("laptop.pub.hex"), + meta: dir.path().join("laptop.meta.toml"), + }; + let public_hex = "ab".repeat(32); + write_public(&paths.public_hex, &public_hex).unwrap(); + write_meta( + &paths.meta, + &KeyMeta { + schema_version: 1, + fingerprint: "a4182c80cf8467d91a58382943715d4062d3c6f4464c8b346a3f7b1b11164c7a" + .into(), + created_at_epoch: 1, + backend: "file".into(), + note: Some("offline release key".into()), + bound_principal: Some("operator".into()), + }, + ) + .unwrap(); + + let migrated = read_meta(&paths).unwrap(); + assert_eq!(migrated.schema_version, META_SCHEMA_VERSION); + assert_eq!(migrated.note.as_deref(), Some("offline release key")); + assert_eq!(migrated.bound_principal.as_deref(), Some("operator")); + assert_eq!( + migrated.fingerprint, + fingerprint_pubkey(&public_hex).unwrap() + ); + let persisted = fs::read_to_string(&paths.meta).unwrap(); + assert!(persisted.contains("schema_version = 2")); + assert!(!persisted.contains("a4182c80cf8467d")); + } + + #[test] + fn legacy_metadata_without_public_key_remains_readable_and_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let paths = KeyPaths { + private: dir.path().join("laptop.ed25519"), + public_hex: dir.path().join("laptop.pub.hex"), + meta: dir.path().join("laptop.meta.toml"), + }; + write_meta( + &paths.meta, + &KeyMeta { + schema_version: 1, + fingerprint: "a4182c80cf8467d91a58382943715d4062d3c6f4464c8b346a3f7b1b11164c7a" + .into(), + created_at_epoch: 1, + backend: "file".into(), + note: Some("preserve me".into()), + bound_principal: Some("operator".into()), + }, + ) + .unwrap(); + let before = fs::read(&paths.meta).unwrap(); + + let deferred = read_meta(&paths).unwrap(); + assert_eq!(deferred.schema_version, 1); + assert_eq!(deferred.note.as_deref(), Some("preserve me")); + assert_eq!(fs::read(&paths.meta).unwrap(), before); + } + + #[test] + fn legacy_metadata_with_malformed_public_key_remains_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let paths = KeyPaths { + private: dir.path().join("laptop.ed25519"), + public_hex: dir.path().join("laptop.pub.hex"), + meta: dir.path().join("laptop.meta.toml"), + }; + write_public(&paths.public_hex, "not-a-public-key").unwrap(); + write_meta( + &paths.meta, + &KeyMeta { + schema_version: 1, + fingerprint: "a4182c80cf8467d91a58382943715d4062d3c6f4464c8b346a3f7b1b11164c7a" + .into(), + created_at_epoch: 1, + backend: "file".into(), + note: None, + bound_principal: None, + }, + ) + .unwrap(); + let before = fs::read(&paths.meta).unwrap(); + + let deferred = read_meta(&paths).unwrap(); + assert_eq!(deferred.schema_version, 1); + assert_eq!(fs::read(&paths.meta).unwrap(), before); } #[test] diff --git a/crates/astrid-config/src/defaults.toml b/crates/astrid-config/src/defaults.toml index 70236abf0..55346e87a 100644 --- a/crates/astrid-config/src/defaults.toml +++ b/crates/astrid-config/src/defaults.toml @@ -197,7 +197,7 @@ max_pending_requests = 50 # args = ["--config", "react.toml"] # auto_start = true # trusted = true -# binary_hash = "sha256:abc123..." # Optional: verify binary integrity +# binary_hash = "blake3:abc123..." # Optional: verify binary integrity # # # Restart on failure with max 5 retries (exponential backoff applied): # [servers.react.restart_policy] diff --git a/crates/astrid-core/src/kernel_api/mod.rs b/crates/astrid-core/src/kernel_api/mod.rs index a763c8bf4..0523599a9 100644 --- a/crates/astrid-core/src/kernel_api/mod.rs +++ b/crates/astrid-core/src/kernel_api/mod.rs @@ -648,8 +648,7 @@ pub enum AdminRequestKind { /// ed25519 public key on the new principal's profile, and decrements /// the token's use counter (deleting the record on the last use). InviteRedeem { - /// Opaque token bytes (URL-safe base64) returned from a prior - /// `InviteIssue`. + /// Typed `astrid_inv_` bearer token returned from a prior `InviteIssue`. token: String, /// Hex-encoded ed25519 public key (32 bytes / 64 hex chars). /// Registered on the new principal's `AuthConfig.public_keys`. @@ -666,7 +665,7 @@ pub enum AdminRequestKind { /// Revoke an outstanding invite token without consuming it. /// Gated by `invite:revoke`. InviteRevoke { - /// The opaque token to invalidate. + /// The typed `astrid_inv_` token or its `blake3:` fingerprint. token: String, }, /// Issue a pair-device token. Scoped issuance is gated by @@ -703,7 +702,7 @@ pub enum AdminRequestKind { /// principal's `AuthConfig.public_keys`, and decrements / deletes /// the token record. PairDeviceRedeem { - /// The opaque token from a prior `PairDeviceIssue`. + /// The typed `astrid_pair_` token from a prior `PairDeviceIssue`. token: String, /// Hex-encoded ed25519 public key (32 bytes / 64 hex chars). public_key: String, @@ -873,7 +872,7 @@ pub struct AgentSummary { /// Response payload for [`AdminRequestKind::InviteIssue`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InviteIssued { - /// Opaque token (URL-safe base64). The caller delivers this to the + /// Typed `astrid_inv_` bearer token. The caller delivers this to the /// redeemer out-of-band — e.g. printed by the CLI, surfaced by the /// gateway as a redeem URL fragment, or pasted into a chat. pub token: String, @@ -899,7 +898,7 @@ pub struct InviteRedeemed { pub principal: PrincipalId, /// Group the new principal is now a member of. pub group: String, - /// SHA-256 fingerprint (hex) of the registered ed25519 public key. + /// Domain-separated `blake3:` fingerprint of the registered Ed25519 public key. /// Lets the redeemer verify that the kernel registered the key it /// sent rather than substituting one of its own. pub public_key_fingerprint: String, @@ -908,7 +907,7 @@ pub struct InviteRedeemed { /// Response payload for [`AdminRequestKind::PairDeviceIssue`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PairTokenIssued { - /// Opaque token. The issuing device hands this to the new + /// Typed `astrid_pair_` bearer token. The issuing device hands this to the new /// device out-of-band (QR code, NFC, manual copy). pub token: String, /// Principal the new device's key will attach to (always the @@ -926,7 +925,7 @@ pub struct PairTokenIssued { pub struct PairTokenRedeemed { /// The principal the new device is now bound to. pub principal: PrincipalId, - /// SHA-256 fingerprint (hex) of the registered ed25519 key. + /// Domain-separated `blake3:` fingerprint of the registered Ed25519 key. /// Lets the redeemer verify the kernel registered the key it /// sent rather than substituting one of its own. pub public_key_fingerprint: String, @@ -941,7 +940,7 @@ pub struct PairTokenRedeemed { /// [`AdminRequestKind::InviteList`]. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct InviteSummary { - /// SHA-256 fingerprint (hex) of the token — the kernel does not + /// Domain-separated `blake3:` fingerprint of the token — the kernel does not /// leak the raw token through list responses. Issuers retain the /// raw value from the original [`InviteIssued`] response. pub token_fingerprint: String, diff --git a/crates/astrid-crypto/src/identifier.rs b/crates/astrid-crypto/src/identifier.rs new file mode 100644 index 000000000..367cbc35c --- /dev/null +++ b/crates/astrid-crypto/src/identifier.rs @@ -0,0 +1,207 @@ +//! Domain-separated BLAKE3 identifiers. +//! +//! [`ContentHash`](crate::ContentHash) identifies bytes as content. This +//! module covers identifiers derived from values for a particular purpose, +//! such as a token verifier or public-key fingerprint. Keeping the two types +//! distinct prevents equal-width digests from being used across protocols. + +use std::fmt; + +use crate::{CryptoResult, PublicKey}; + +/// A domain-separated BLAKE3 identifier. +/// +/// The default representation is the full 32-byte identifier. The generic +/// parameter supports typed wrappers without changing the derivation API. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct IdentifierHash(T); + +impl IdentifierHash { + /// Borrow the wrapped representation. + #[must_use] + pub const fn as_inner(&self) -> &T { + &self.0 + } + + /// Consume the identifier and return its wrapped representation. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } + + /// Transform the representation while preserving the identifier's domain. + /// + /// This is the safe construction path for non-default generic + /// specializations: callers first derive or validate an identifier, then + /// map its representation without reinterpreting unrelated bytes. + #[must_use] + pub fn map(self, map: impl FnOnce(T) -> U) -> IdentifierHash { + IdentifierHash(map(self.0)) + } +} + +impl IdentifierHash<[u8; 32]> { + /// Derive an identifier within a fixed application context. + /// + /// Callers must use a stable, purpose-specific context constant. BLAKE3's + /// derive-key mode provides the domain separation; callers do not need to + /// construct ad-hoc byte prefixes. + #[must_use] + pub fn derive(context: &'static str, value: &[u8]) -> Self { + Self(blake3::derive_key(context, value)) + } + + /// Create an identifier from its full byte representation. + #[must_use] + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Borrow the full byte representation. + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + /// Encode the identifier as lowercase hexadecimal. + #[must_use] + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + + /// Encode as `blake3:`. + #[must_use] + pub fn to_prefixed_hex(&self) -> String { + format!("blake3:{}", self.to_hex()) + } +} + +impl fmt::Debug for IdentifierHash<[u8; 32]> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "IdentifierHash({})", self.to_prefixed_hex()) + } +} + +impl fmt::Display for IdentifierHash<[u8; 32]> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.to_prefixed_hex()) + } +} + +impl AsRef<[u8]> for IdentifierHash<[u8; 32]> { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// A full BLAKE3 fingerprint for an Ed25519 public key. +/// +/// This is deliberately distinct from [`IdentifierHash`] at API boundaries: +/// a public-key fingerprint must not be accepted where a token verifier or +/// another identifier happens to have the same representation. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct PublicKeyFingerprint(T); + +impl PublicKeyFingerprint { + /// Borrow the wrapped representation. + #[must_use] + pub const fn as_inner(&self) -> &T { + &self.0 + } + + /// Consume the fingerprint and return its wrapped representation. + #[must_use] + pub fn into_inner(self) -> T { + self.0 + } + + /// Transform the representation of a validated fingerprint. + /// + /// This constructs non-default generic specializations without exposing an + /// unchecked constructor for the semantic fingerprint type. + #[must_use] + pub fn map(self, map: impl FnOnce(T) -> U) -> PublicKeyFingerprint { + PublicKeyFingerprint(map(self.0)) + } +} + +impl PublicKeyFingerprint { + /// Derive a fingerprint from a validated Ed25519 public key. + #[must_use] + pub fn from_public_key(public_key: &PublicKey) -> Self { + const CONTEXT: &str = "astrid.runtime.ed25519-public-key.fingerprint.v1"; + Self(IdentifierHash::derive(CONTEXT, public_key.as_bytes()).to_prefixed_hex()) + } + + /// Parse a bare or `ed25519:`-prefixed hexadecimal key and fingerprint it. + /// + /// # Errors + /// + /// Returns an error when the input is not a 32-byte Ed25519 public key. + pub fn from_ed25519_hex(value: &str) -> CryptoResult { + let hex = value.strip_prefix("ed25519:").unwrap_or(value); + let public_key = PublicKey::from_hex(hex)?; + Ok(Self::from_public_key(&public_key)) + } + + /// Borrow the `blake3:` representation. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for PublicKeyFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "PublicKeyFingerprint({})", self.0) + } +} + +impl fmt::Display for PublicKeyFingerprint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl AsRef for PublicKeyFingerprint { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn domains_produce_distinct_identifiers() { + let value = b"same input"; + let invite = IdentifierHash::derive("Astrid invite token 2026-07-15 v1", value); + let pair = IdentifierHash::derive("Astrid pair token 2026-07-15 v1", value); + assert_ne!(invite, pair); + assert_eq!(invite.to_hex().len(), 64); + let encoded = invite.map(hex::encode); + assert_eq!(encoded.as_inner().len(), 64); + } + + #[test] + fn public_key_fingerprint_normalizes_supported_hex_forms() { + let key = "ab".repeat(32); + let bare = PublicKeyFingerprint::from_ed25519_hex(&key).unwrap(); + let prefixed = PublicKeyFingerprint::from_ed25519_hex(&format!("ed25519:{key}")).unwrap(); + assert_eq!(bare, prefixed); + assert_eq!( + bare.as_str(), + "blake3:bfae897f0bf656d68f50c4fde6fc273a7d6c8a28feb8c127f7d2cf9bb54d18b8" + ); + assert_eq!(bare.as_str().len(), 71); + let bytes = bare.map(String::into_bytes); + assert_eq!(bytes.as_inner().len(), 71); + } + + #[test] + fn public_key_fingerprint_rejects_invalid_keys() { + assert!(PublicKeyFingerprint::from_ed25519_hex("abcd").is_err()); + assert!(PublicKeyFingerprint::from_ed25519_hex(&"zz".repeat(32)).is_err()); + } +} diff --git a/crates/astrid-crypto/src/lib.rs b/crates/astrid-crypto/src/lib.rs index c446056d7..4436e8bf5 100644 --- a/crates/astrid-crypto/src/lib.rs +++ b/crates/astrid-crypto/src/lib.rs @@ -41,10 +41,12 @@ pub mod prelude; mod error; mod hash; +mod identifier; mod keypair; mod signature; pub use error::{CryptoError, CryptoResult}; pub use hash::ContentHash; +pub use identifier::{IdentifierHash, PublicKeyFingerprint}; pub use keypair::{KeyPair, PublicKey}; pub use signature::Signature; diff --git a/crates/astrid-crypto/src/prelude.rs b/crates/astrid-crypto/src/prelude.rs index 3b7339d9c..e1d24284b 100644 --- a/crates/astrid-crypto/src/prelude.rs +++ b/crates/astrid-crypto/src/prelude.rs @@ -28,5 +28,5 @@ pub use crate::{KeyPair, PublicKey}; // Signature pub use crate::Signature; -// Hashing -pub use crate::ContentHash; +// Hashing and identifiers +pub use crate::{ContentHash, IdentifierHash, PublicKeyFingerprint}; diff --git a/crates/astrid-gateway/Cargo.toml b/crates/astrid-gateway/Cargo.toml index 8215f277c..8db500850 100644 --- a/crates/astrid-gateway/Cargo.toml +++ b/crates/astrid-gateway/Cargo.toml @@ -37,7 +37,7 @@ description = "HTTP gateway for the Astrid admin API — translates HTTP request # the gateway is a kernel-side consumer so opting in # is correct. # -# Everything else (`anyhow`, `serde*`, `tokio`, `rand`, `sha2`, …) is already +# Everything else (`anyhow`, `serde*`, `tokio`, `rand`, …) is already # load-bearing across the workspace; pulling them here introduces no new # vertices. @@ -71,7 +71,6 @@ reqwest = { workspace = true, features = ["json"] } rustls = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -sha2 = { workspace = true } subtle = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } diff --git a/crates/astrid-gateway/src/routes/auth.rs b/crates/astrid-gateway/src/routes/auth.rs index bef85019b..55a2292ea 100644 --- a/crates/astrid-gateway/src/routes/auth.rs +++ b/crates/astrid-gateway/src/routes/auth.rs @@ -26,9 +26,8 @@ use crate::state::GatewayState; /// Inbound body for `POST /api/auth/redeem`. #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct RedeemRequest { - /// The opaque token from an `astrid invite issue` (or - /// dashboard-issued) invite. - #[schema(example = "AAAA...")] + /// The typed token from an `astrid invite issue` (or dashboard-issued) invite. + #[schema(example = "astrid_inv_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")] pub token: String, /// Hex-encoded ed25519 public key. Bare 64 hex chars or the /// `ed25519:` form. @@ -47,7 +46,7 @@ pub struct RedeemResponse { pub principal: PrincipalId, /// Group the new principal joined. pub group: String, - /// SHA-256 fingerprint of the registered ed25519 key — lets the + /// Domain-separated `blake3:` fingerprint of the registered Ed25519 key — lets the /// redeemer verify the gateway didn't swap their key. pub public_key_fingerprint: String, /// Signed bearer token for subsequent requests. @@ -262,7 +261,8 @@ impl PairDeviceIssueRequest { /// route — the pair-token is the auth. #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct PairDeviceRedeemRequest { - /// Opaque pair-token from a prior issue. + /// Typed `astrid_pair_` token from a prior issue. + #[schema(example = "astrid_pair_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")] pub token: String, /// Hex-encoded ed25519 public key. Bare 64 hex or /// `ed25519:`. @@ -277,7 +277,7 @@ pub struct PairDeviceRedeemResponse { /// The principal the new device is now bound to. #[schema(value_type = String, example = "agent-alice")] pub principal: PrincipalId, - /// SHA-256 fingerprint of the registered key. + /// Domain-separated `blake3:` fingerprint of the registered key. pub public_key_fingerprint: String, /// Deterministic `key_id` of the registered device key. The session /// bearer is scoped to this `key_id`, so the device authenticates with — @@ -291,7 +291,7 @@ pub struct PairDeviceRedeemResponse { } /// `POST /api/auth/pair-device` — issue a pair-token tied to the -/// authenticated caller's principal. Returns the opaque token, +/// authenticated caller's principal. Returns the typed `astrid_pair_` token, /// which the caller hands to the new device out-of-band (QR code, /// NFC, etc.). #[utoipa::path( diff --git a/crates/astrid-gateway/src/routes/env.rs b/crates/astrid-gateway/src/routes/env.rs index a9fdb1bb5..3f687f86f 100644 --- a/crates/astrid-gateway/src/routes/env.rs +++ b/crates/astrid-gateway/src/routes/env.rs @@ -30,9 +30,9 @@ //! //! ## Audit //! -//! Each successful write is logged at `info` with the caller, the -//! capsule, the field name, and the SHA-256 fingerprint of the -//! value (never the value itself). The kernel-side audit log +//! Each successful write is logged at `info` with the caller, capsule, field +//! name, and declared env type. Values and reversible fingerprints of +//! low-entropy values are never logged. The kernel-side audit log //! covers admin-API mutations; env writes are gateway-side only //! today. A proper IPC audit topic for env writes is a follow-up //! (would need a new `AdminRequestKind` or a dedicated topic for @@ -48,7 +48,6 @@ use axum::Json; use axum::extract::{Path, State}; use axum::http::{Request, StatusCode}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use utoipa::ToSchema; use crate::error::{ErrorBody, GatewayError, GatewayResult}; @@ -154,8 +153,6 @@ pub async fn write_env( let home = AstridHome::resolve() .map_err(|e| GatewayError::Internal(anyhow::anyhow!("resolve ASTRID_HOME: {e}")))?; - let value_fp = fingerprint(&body.value); - // The disk writes below (`FileSecretStore::set`, `write_env_string`, // `append_env_array`) are synchronous `std::fs` calls that fsync // through a temp-and-rename. Running them on the tokio worker @@ -214,7 +211,6 @@ pub async fn write_env( capsule = %capsule_id, field = %field, env_type = %def.env_type, - value_fingerprint = %value_fp, "gateway env-write" ); @@ -385,12 +381,6 @@ fn is_safe_field_name(name: &str) -> bool { && !name.contains("..") } -fn fingerprint(value: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(value.as_bytes()); - hex::encode(hasher.finalize()) -} - /// Write or replace a single string field in /// `$ASTRID_HOME/home//.config/env/.env.json`. /// Atomic write-then-rename; existing fields are preserved. @@ -507,16 +497,6 @@ mod tests { assert!(!is_safe_field_name(&"a".repeat(129))); } - #[test] - fn fingerprint_is_deterministic_sha256() { - let a = fingerprint("hello"); - let b = fingerprint("hello"); - let c = fingerprint("world"); - assert_eq!(a, b); - assert_ne!(a, c); - assert_eq!(a.len(), 64); - } - #[test] fn env_type_reads_manifest_type_and_preserves_secret() { let parsed: toml::Value = toml::from_str( diff --git a/crates/astrid-gateway/src/routes/invites.rs b/crates/astrid-gateway/src/routes/invites.rs index 449453a6c..e887c5a15 100644 --- a/crates/astrid-gateway/src/routes/invites.rs +++ b/crates/astrid-gateway/src/routes/invites.rs @@ -34,7 +34,7 @@ pub struct IssueRequest { /// field-for-field with the serialized shape of `InviteIssued`. #[derive(ToSchema)] pub struct InviteIssuedView { - /// Opaque token (URL-safe base64). Returned once — store securely. + /// Typed `astrid_inv_` bearer token. Returned once — store securely. pub token: String, /// Group the redeemer will join on success. pub group: String, @@ -58,7 +58,7 @@ pub struct IssueResponse { tag = "invites", request_body = IssueRequest, responses( - (status = 200, body = IssueResponse, description = "Invite minted; opaque token returned once — store it securely."), + (status = 200, body = IssueResponse, description = "Invite minted; typed `astrid_inv_` token returned once — store it securely."), (status = 401, body = ErrorBody), (status = 403, body = ErrorBody, description = "Caller lacks `invite:issue`."), ) @@ -108,7 +108,7 @@ pub async fn issue_invite( /// `issued_at_epoch` is always present. #[derive(ToSchema)] pub struct InviteSummaryView { - /// SHA-256 fingerprint (hex) of the token. Raw tokens are never + /// Domain-separated `blake3:` fingerprint of the token. Raw tokens are never /// leaked through list responses. pub token_fingerprint: String, /// Group the redeemer will join. @@ -166,7 +166,7 @@ pub async fn list_invites( delete, path = "/api/sys/invites/{fingerprint}", tag = "invites", - params(("fingerprint" = String, Path, description = "SHA-256 fingerprint from a prior `IssueResponse` / list entry")), + params(("fingerprint" = String, Path, description = "`blake3:` fingerprint from a prior `IssueResponse` / list entry")), responses( (status = 204, description = "Invite revoked."), (status = 401, body = ErrorBody), diff --git a/crates/astrid-integration-tests/tests/compatibility_fixtures.rs b/crates/astrid-integration-tests/tests/compatibility_fixtures.rs index 5f41eb520..4e73079dc 100644 --- a/crates/astrid-integration-tests/tests/compatibility_fixtures.rs +++ b/crates/astrid-integration-tests/tests/compatibility_fixtures.rs @@ -65,3 +65,20 @@ fn legacy_pair_token_without_scope_loads_as_full_scope() { assert_eq!(loaded[0].principal.as_str(), "compat-user"); assert_eq!(loaded[0].scope, DeviceScope::Full); } + +#[test] +fn legacy_sha_pair_token_is_invalidated_in_a_working_copy() { + let dir = tempfile::tempdir().expect("compat tempdir"); + let path = dir.path().join("pair-tokens.toml"); + std::fs::copy(fixture("legacy-sha-pair-token.toml"), &path) + .expect("copy legacy pair-token fixture"); + + let loaded = PairTokenStore::new(path.clone()) + .load() + .expect("legacy SHA pair-token store migrates"); + assert!(loaded.is_empty()); + + let rewritten = std::fs::read_to_string(path).expect("read migrated store"); + assert!(rewritten.contains("schema_version = 1")); + assert!(!rewritten.contains("[[pair_token]]")); +} diff --git a/crates/astrid-kernel/Cargo.toml b/crates/astrid-kernel/Cargo.toml index b6bb0ef49..5ae918a3d 100644 --- a/crates/astrid-kernel/Cargo.toml +++ b/crates/astrid-kernel/Cargo.toml @@ -45,7 +45,6 @@ rand = { workspace = true } semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -sha2 = { workspace = true } subtle = { workspace = true } tempfile = { workspace = true } # Base tokio: the wasm-clean subset (mirrors the astrid-capsule base). The diff --git a/crates/astrid-kernel/src/invite.rs b/crates/astrid-kernel/src/invite.rs index c08a37940..606113b51 100644 --- a/crates/astrid-kernel/src/invite.rs +++ b/crates/astrid-kernel/src/invite.rs @@ -2,16 +2,19 @@ //! //! Invite tokens are short opaque secrets that grant a one-time-ish //! right to mint a principal. The kernel never stores the raw token — -//! it stores SHA-256 of the URL-safe base64 form. Redemption hashes -//! the incoming token and compares against the persisted set. +//! it stores a domain-separated BLAKE3 identifier of the URL-safe base64 +//! form. Redemption hashes the incoming token and compares against the +//! persisted set. //! //! ## On-disk layout //! //! `$ASTRID_HOME/etc/invites.toml`: //! //! ```toml +//! schema_version = 1 +//! //! [[invite]] -//! token_hash = "..." # hex(sha256(token)) — 64 hex chars +//! token_hash = "blake3:..." # domain-separated BLAKE3 identifier //! group = "agent" //! remaining_uses = 1 //! expires_at_epoch = 1234567890 @@ -19,7 +22,7 @@ //! metadata = "alice's tablet" //! ``` //! -//! Atomic writes via write-then-rename. The file is owned by the +//! Unix writes use write-then-rename. The file is owned by the //! daemon UID and chmod 0600 — same posture as //! `~/.astrid/run/system.token`. //! @@ -42,10 +45,18 @@ use std::path::PathBuf; use astrid_core::dirs::AstridHome; +use astrid_crypto::IdentifierHash; use base64::Engine; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use tracing::warn; + +const STORE_SCHEMA_VERSION: u32 = 1; +const TOKEN_HASH_CONTEXT: &str = "astrid.runtime.invite-token.identifier.v1"; + +/// Type prefix carried by every raw invite bearer token. +pub const TOKEN_PREFIX: &str = "astrid_inv_"; /// Length of the random token portion in bytes (192 bits → 32 chars /// URL-safe base64, comfortably exceeding the 128-bit work factor we @@ -59,10 +70,10 @@ pub const TOKEN_RAW_LEN: usize = 24; pub const MAX_EXPIRY_SECS: u64 = 60 * 60 * 24 * 30; /// On-disk persisted invite record. The raw token is NEVER stored — -/// only its SHA-256. +/// only its domain-separated BLAKE3 identifier. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Invite { - /// Hex-encoded SHA-256 of the URL-safe base64 token (64 hex chars). + /// `blake3:` identifier of the URL-safe base64 token. pub token_hash: String, /// Group new redeemers join. pub group: String, @@ -79,9 +90,9 @@ pub struct Invite { pub metadata: Option, } -/// File-backed invite store. Read-modify-write with atomic rename; -/// concurrent mutators must serialise externally (the kernel uses -/// `admin_write_lock`). +/// File-backed invite store. Read-modify-write uses atomic rename on Unix; all +/// loads and mutators must serialise externally because a load can migrate +/// legacy state (the kernel uses `admin_write_lock`). #[derive(Debug)] pub struct InviteStore { path: PathBuf, @@ -102,7 +113,9 @@ impl InviteStore { } /// Read the persisted list. Missing file → empty Vec (single-tenant - /// deployments never call invite-issue). + /// deployments never call invite-issue). A schema-0 store is invalidated + /// because its SHA-256 token identifiers cannot be converted + /// without the raw tokens. /// /// # Errors /// Returns an error if the file exists but is unreadable or malformed. @@ -131,16 +144,37 @@ impl InviteStore { InviteStoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) })?; if text.trim().is_empty() { + self.save_to_disk(&[])?; return Ok(Vec::new()); } + let probe: SchemaProbe = toml::from_str(text).map_err(InviteStoreError::Toml)?; + if probe.schema_version > STORE_SCHEMA_VERSION { + return Err(InviteStoreError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "invite store schema {} is newer than supported schema {STORE_SCHEMA_VERSION}", + probe.schema_version + ), + ))); + } let parsed: PersistedFile = toml::from_str(text).map_err(InviteStoreError::Toml)?; + if probe.schema_version == 0 { + let invalidated = parsed.invite.len(); + self.save_to_disk(&[])?; + warn!( + path = %self.path.display(), + invalidated, + "invalidated legacy SHA-256 invite-token store" + ); + return Ok(Vec::new()); + } Ok(parsed.invite) } - /// Write the supplied list atomically (write-then-rename, 0600 - /// permissions). An empty list is persisted as an empty TOML file - /// rather than deleting — keeps the file-permission invariant - /// observable to ops tooling. + /// Write the supplied list with write-then-rename and 0600 permissions on + /// Unix. An empty list retains the versioned TOML envelope rather than + /// deleting the file, keeping the file-permission invariant observable to + /// ops tooling. /// /// # Errors /// Returns an error if the file cannot be written. @@ -164,6 +198,7 @@ impl InviteStore { std::fs::create_dir_all(parent).map_err(InviteStoreError::Io)?; } let body = PersistedFile { + schema_version: STORE_SCHEMA_VERSION, invite: invites.to_vec(), }; let text = toml::to_string_pretty(&body).map_err(InviteStoreError::TomlSer)?; @@ -221,13 +256,21 @@ impl std::fmt::Display for InviteStoreError { impl std::error::Error for InviteStoreError {} +#[derive(Debug, Default, Deserialize)] +struct SchemaProbe { + #[serde(default)] + schema_version: u32, +} + #[derive(Debug, Default, Serialize, Deserialize)] struct PersistedFile { + #[serde(default)] + schema_version: u32, #[serde(default)] invite: Vec, } -/// Generate a random URL-safe-base64 token. Uses the OS CSPRNG. +/// Generate a typed token with a random URL-safe-base64 secret. Uses the OS CSPRNG. /// /// # Panics /// @@ -239,19 +282,20 @@ pub fn generate_token() -> String { SysRng .try_fill_bytes(&mut bytes) .expect("OS CSPRNG unavailable while generating invite token"); - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + format!( + "{TOKEN_PREFIX}{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) } -/// Hash a token for storage / lookup. Hex-encoded SHA-256. +/// Derive a token identifier for storage and lookup. #[must_use] pub fn hash_token(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hex::encode(hasher.finalize()) + IdentifierHash::derive(TOKEN_HASH_CONTEXT, token.as_bytes()).to_prefixed_hex() } -/// Constant-time hash comparison. Both inputs must be hex-encoded -/// SHA-256 (64 hex chars). Returns `false` on any length mismatch +/// Constant-time hash comparison. Both inputs must be `blake3:` +/// identifiers. Returns `false` on any length mismatch /// without leaking the position via short-circuit. #[must_use] pub fn ct_hash_eq(a: &str, b: &str) -> bool { @@ -261,6 +305,20 @@ pub fn ct_hash_eq(a: &str, b: &str) -> bool { a.as_bytes().ct_eq(b.as_bytes()).into() } +/// Canonicalize a copied token identifier. +/// +/// Returns `None` unless `value` is exactly one `blake3:<64 hex>` identifier. +/// Generated raw invite tokens carry [`TOKEN_PREFIX`], so the two accepted +/// revoke-input forms are unambiguous. +#[must_use] +pub fn canonical_token_fingerprint(value: &str) -> Option { + let (algorithm, digest) = value.split_once(':')?; + (algorithm.eq_ignore_ascii_case("blake3") + && digest.len() == 64 + && digest.chars().all(|c| c.is_ascii_hexdigit())) + .then(|| format!("blake3:{}", digest.to_ascii_lowercase())) +} + /// Current wall-clock as seconds since Unix epoch. Saturating on the /// (impossible) pre-1970 case so the returned `u64` never wraps. #[must_use] @@ -306,28 +364,53 @@ mod tests { let a = generate_token(); let b = generate_token(); assert_ne!(a, b, "two tokens must differ"); + assert!(a.starts_with(TOKEN_PREFIX)); + let secret = a.strip_prefix(TOKEN_PREFIX).unwrap(); assert!( - a.chars() + secret + .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') ); // base64-url-no-pad of 24 bytes is 32 chars. - assert_eq!(a.len(), 32); + assert_eq!(secret.len(), 32); } #[test] - fn hash_token_is_deterministic_hex_sha256() { + fn hash_token_is_domain_separated_blake3() { let h = hash_token("hello"); - assert_eq!(h.len(), 64); - assert!(h.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!( + h, + "blake3:d39676568f815c5ec571111d4563251442758032b79ed8d01c97518b4e2630d2" + ); + assert_eq!(h.len(), 71); assert_eq!(h, hash_token("hello")); assert_ne!(h, hash_token("world")); } #[test] fn ct_hash_eq_rejects_length_mismatch() { - assert!(!ct_hash_eq("abc", "abcd")); - assert!(ct_hash_eq("abc", "abc")); - assert!(!ct_hash_eq("abc", "abd")); + let expected = hash_token("hello"); + assert!(ct_hash_eq(&expected, &expected)); + for index in [7, expected.len() / 2, expected.len() - 1] { + let mut different = expected.clone().into_bytes(); + different[index] = if different[index] == b'0' { b'1' } else { b'0' }; + assert!(!ct_hash_eq( + &expected, + std::str::from_utf8(&different).unwrap() + )); + } + assert!(!ct_hash_eq(&expected, &expected[..70])); + assert!(!ct_hash_eq(&expected, &format!("{expected}0"))); + } + + #[test] + fn copied_fingerprint_is_canonicalized() { + let expected = hash_token("hello"); + assert_eq!( + canonical_token_fingerprint(&expected.to_ascii_uppercase()), + Some(expected) + ); + assert_eq!(canonical_token_fingerprint("raw-token"), None); } #[test] @@ -370,7 +453,7 @@ mod tests { let store = InviteStore::new(dir.path().join("invites.toml")); let now = now_epoch(); let invite = Invite { - token_hash: "deadbeef".into(), + token_hash: hash_token("alice invite"), group: "agent".into(), remaining_uses: 2, expires_at_epoch: Some(now.saturating_add(3600)), @@ -378,10 +461,57 @@ mod tests { metadata: Some("alice".into()), }; store.save(std::slice::from_ref(&invite)).unwrap(); + assert!( + std::fs::read_to_string(&store.path) + .unwrap() + .contains("schema_version = 1") + ); let loaded = store.load().unwrap(); assert_eq!(loaded, vec![invite]); } + #[test] + fn legacy_sha256_store_is_invalidated_and_rewritten() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("invites.toml"); + let legacy = "[[invite]]\n\ + token_hash = \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n\ + group = \"agent\"\n\ + remaining_uses = 1\n\ + issued_at_epoch = 1\n"; + std::fs::write(&path, legacy).unwrap(); + + let store = InviteStore::new(path.clone()); + assert!(store.load().unwrap().is_empty()); + let rewritten = std::fs::read_to_string(&path).unwrap(); + assert!(rewritten.contains("schema_version = 1")); + assert!(!rewritten.contains("[[invite]]")); + assert!(store.load().unwrap().is_empty()); + } + + #[test] + fn future_store_is_rejected_without_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("invites.toml"); + let future = "schema_version = 2\nfuture_field = \"preserve me\"\n"; + std::fs::write(&path, future).unwrap(); + + let err = InviteStore::new(path.clone()).load().unwrap_err(); + assert!(err.to_string().contains("schema 2 is newer")); + assert_eq!(std::fs::read_to_string(path).unwrap(), future); + } + + #[test] + fn malformed_store_is_rejected_without_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("invites.toml"); + let malformed = "schema_version = [not valid\n"; + std::fs::write(&path, malformed).unwrap(); + + assert!(InviteStore::new(path.clone()).load().is_err()); + assert_eq!(std::fs::read_to_string(path).unwrap(), malformed); + } + #[test] fn empty_file_loads_as_empty_vec() { let dir = tempfile::tempdir().unwrap(); @@ -391,6 +521,11 @@ mod tests { // Touch empty file → empty std::fs::write(&store.path, "").unwrap(); assert_eq!(store.load().unwrap(), Vec::::new()); + assert!( + std::fs::read_to_string(&store.path) + .unwrap() + .contains("schema_version = 1") + ); } #[cfg(unix)] diff --git a/crates/astrid-kernel/src/kernel_router/admin/invite_handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/invite_handlers.rs index dfa84a882..da56257f1 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/invite_handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/invite_handlers.rs @@ -14,20 +14,25 @@ use astrid_core::PrincipalId; use astrid_core::groups::GroupConfig; use astrid_core::kernel_api::{AdminResponseBody, InviteIssued, InviteRedeemed, InviteSummary}; use astrid_core::profile::{AuthConfig, AuthMethod, DeviceKey, DeviceScope, PrincipalProfile}; -use sha2::{Digest, Sha256}; +use astrid_crypto::{IdentifierHash, PublicKeyFingerprint}; use tracing::{info, warn}; use crate::invite::{self, Invite, InviteStore, MAX_EXPIRY_SECS}; -/// Hex-encoded SHA-256 of a hex-encoded ed25519 public key. Surfaced -/// as the `public_key_fingerprint` field on +/// Domain-separated BLAKE3 fingerprint of an Ed25519 public key. Surfaced as +/// the `public_key_fingerprint` field on /// [`AdminResponseBody::InviteRedeemed`] and used by the audit /// sanitiser to redact the raw key from persisted audit rows. #[must_use] pub(crate) fn fingerprint_public_key(hex_key: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(hex_key.as_bytes()); - hex::encode(hasher.finalize()) + PublicKeyFingerprint::from_ed25519_hex(hex_key).map_or_else( + |_| { + const REJECTED_INPUT_CONTEXT: &str = + "astrid.runtime.rejected-public-key-input.fingerprint.v1"; + IdentifierHash::derive(REJECTED_INPUT_CONTEXT, hex_key.as_bytes()).to_prefixed_hex() + }, + PublicKeyFingerprint::into_inner, + ) } // ── invite.issue ────────────────────────────────────────────────────── @@ -307,15 +312,20 @@ pub(crate) async fn invite_revoke(kernel: &Arc, token: String) -> Err(e) => return err_internal(format!("invites.toml load failed: {e}")), }; // `token` here may be either the raw token (operator paste) or the - // hex fingerprint (operator copy from `invite.list`). Hash the + // `blake3:` fingerprint (operator copy from `invite.list`). Hash the // input as raw token first; if no match, also try the input verbatim // (treating it as the already-hashed fingerprint). This dual lookup // never leaks which form matched — both produce the same // success/failure shape. let from_raw = invite::hash_token(&token); + let from_fingerprint = invite::canonical_token_fingerprint(&token); let pre_len = invites.len(); invites.retain(|i| { - !invite::ct_hash_eq(&i.token_hash, &from_raw) && !invite::ct_hash_eq(&i.token_hash, &token) + let raw_match = invite::ct_hash_eq(&i.token_hash, &from_raw); + let fingerprint_match = from_fingerprint + .as_deref() + .is_some_and(|fingerprint| invite::ct_hash_eq(&i.token_hash, fingerprint)); + !(raw_match | fingerprint_match) }); if invites.len() == pre_len { return err_bad_input("no invite matches the supplied token or fingerprint".into()); @@ -450,6 +460,30 @@ fn err_unauthorized(msg: String) -> AdminResponseBody { #[cfg(test)] mod tests { use super::*; + use astrid_core::dirs::AstridHome; + use tempfile::TempDir; + + async fn fixture() -> (TempDir, Arc) { + let dir = tempfile::tempdir().expect("tempdir"); + let home = AstridHome::from_path(dir.path()); + let kernel = crate::test_kernel_with_home(home).await; + (dir, kernel) + } + + async fn issue_token(kernel: &Arc) -> String { + match invite_issue( + kernel, + "agent".into(), + Some(300), + 1, + Some("test invite".into()), + ) + .await + { + AdminResponseBody::Invite(issued) => issued.token, + other => panic!("invite issue failed: {other:?}"), + } + } #[test] fn normalise_public_key_accepts_bare_hex() { @@ -499,10 +533,62 @@ mod tests { #[test] fn fingerprint_is_deterministic() { - let a = fingerprint_public_key("ed25519:abcd"); - let b = fingerprint_public_key("ed25519:abcd"); + let key = "ab".repeat(32); + let other = "ac".repeat(32); + let a = fingerprint_public_key(&format!("ed25519:{key}")); + let b = fingerprint_public_key(&key); + assert_eq!(a, b); + assert_ne!(a, fingerprint_public_key(&other)); + assert_eq!(a.len(), 71); + } + + #[test] + fn malformed_public_key_is_still_redacted_deterministically() { + let a = fingerprint_public_key("not-a-key"); + let b = fingerprint_public_key("not-a-key"); assert_eq!(a, b); - assert_ne!(a, fingerprint_public_key("ed25519:abce")); - assert_eq!(a.len(), 64); + assert_ne!(a, "not-a-key"); + assert_eq!(a.len(), 71); + } + + #[tokio::test(flavor = "multi_thread")] + async fn issue_persists_schema_one_and_redeems_after_reload() { + let (_dir, kernel) = fixture().await; + let token = issue_token(&kernel).await; + assert!(token.starts_with(invite::TOKEN_PREFIX)); + + let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home)); + let persisted = store.load().expect("reload issued invite"); + assert_eq!(persisted.len(), 1); + assert_eq!(persisted[0].token_hash, invite::hash_token(&token)); + let body = std::fs::read_to_string(InviteStore::path_for(&kernel.astrid_home)) + .expect("read invite store"); + assert!(body.contains("schema_version = 1")); + + let response = + invite_redeem(&kernel, token, "ab".repeat(32), Some("BLAKE3 Test".into())).await; + assert!(matches!(response, AdminResponseBody::InviteRedeemed(_))); + assert!(store.load().expect("reload consumed store").is_empty()); + } + + #[tokio::test(flavor = "multi_thread")] + async fn revoke_accepts_raw_and_copied_uppercase_fingerprints() { + let (_dir, kernel) = fixture().await; + + let raw = issue_token(&kernel).await; + assert!(matches!( + invite_revoke(&kernel, raw).await, + AdminResponseBody::Success(_) + )); + + let copied = issue_token(&kernel).await; + let uppercase = invite::hash_token(&copied).to_ascii_uppercase(); + assert!(matches!( + invite_revoke(&kernel, uppercase).await, + AdminResponseBody::Success(_) + )); + + let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home)); + assert!(store.load().expect("reload revoked store").is_empty()); } } diff --git a/crates/astrid-kernel/src/kernel_router/admin/mod.rs b/crates/astrid-kernel/src/kernel_router/admin/mod.rs index e1cd09c56..ff4aa38f4 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/mod.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/mod.rs @@ -406,11 +406,11 @@ pub fn admin_request_method(req: &AdminRequestKind) -> &'static str { /// /// Redactions: /// -/// * `InviteRedeem.public_key` → `public_key_fingerprint` (SHA-256 of +/// * `InviteRedeem.public_key` → `public_key_fingerprint` (domain-separated BLAKE3 of /// the supplied key). Storing the raw ed25519 key in the audit log /// would double the system of record for authorization, which Layer /// 5/6 treat as `AuthConfig.public_keys` alone. -/// * `InviteRedeem.token` → `token_fingerprint` (`hex(sha256(token))`). +/// * `InviteRedeem.token` → `token_fingerprint` (domain-separated BLAKE3). /// The raw invite token is a secret that grants the right to mint a /// principal; persisting it in the audit log would let anyone with /// read access replay it on a multi-use invite. The fingerprint @@ -419,7 +419,7 @@ pub fn admin_request_method(req: &AdminRequestKind) -> &'static str { /// * `InviteRevoke.token` → `token_fingerprint`. Same hazard as /// `InviteRedeem.token`: the caller can pass either the raw token or /// the already-fingerprinted form. Hash unconditionally when the -/// input doesn't already look like a fingerprint (64 hex chars). +/// input isn't already a `blake3:` fingerprint. /// * `PairDeviceRedeem` `token` / `public_key` → fingerprints, as above. /// /// `PairDeviceIssue` (carries `expires_secs` / `label` / `scope`), @@ -475,16 +475,13 @@ fn sanitize_admin_audit_params(req: &AdminRequestKind) -> Option` /// identifier (from `astrid invite list`). The audit row stores the /// fingerprint form unconditionally so an auditor can correlate /// against `invites.toml` without seeing the secret. fn fingerprint_revoke_input(token: &str) -> String { - if token.len() == 64 && token.chars().all(|c| c.is_ascii_hexdigit()) { - token.to_ascii_lowercase() - } else { - crate::invite::hash_token(token) - } + crate::invite::canonical_token_fingerprint(token) + .unwrap_or_else(|| crate::invite::hash_token(token)) } /// Borrow the target principal for audit purposes — `Some` only when the diff --git a/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs b/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs index 37278ed8a..e4524e9f6 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/pair_device_handlers.rs @@ -12,7 +12,7 @@ //! bound principal's `AuthConfig.public_keys`, and removes the //! token (single-use). //! -//! The store at `etc/pair-tokens.toml` persists only SHA-256 +//! The store at `etc/pair-tokens.toml` persists only domain-separated BLAKE3 //! hashes — same posture as `etc/invites.toml`. Audit redaction //! lives in `admin::mod::sanitize_admin_audit_params` so neither //! the raw token nor the ed25519 key ever reaches the audit log. diff --git a/crates/astrid-kernel/src/pair_token.rs b/crates/astrid-kernel/src/pair_token.rs index 3eb0f68fa..cde4cb2ff 100644 --- a/crates/astrid-kernel/src/pair_token.rs +++ b/crates/astrid-kernel/src/pair_token.rs @@ -9,8 +9,10 @@ //! `$ASTRID_HOME/etc/pair-tokens.toml`: //! //! ```toml +//! schema_version = 1 +//! //! [[pair_token]] -//! token_hash = "..." # hex(sha256(token)) — 64 hex chars +//! token_hash = "blake3:..." # domain-separated BLAKE3 identifier //! principal = "alice" # the principal the new key will bind to //! expires_at_epoch = 1234567890 //! issued_at_epoch = 1234560000 @@ -19,8 +21,8 @@ //! //! ## Threat model //! -//! Same posture as the invite store: hashes on disk, atomic -//! write-then-rename, 0600 perms, constant-time hash comparison on +//! Same posture as the invite store: hashes on disk, Unix write-then-rename, +//! 0600 perms, constant-time hash comparison on //! redeem. Pair-tokens are single-use only (no `remaining_uses` //! field) — a redeemed token is removed immediately. //! @@ -35,11 +37,19 @@ use std::path::PathBuf; use astrid_core::DeviceScope; use astrid_core::PrincipalId; use astrid_core::dirs::AstridHome; +use astrid_crypto::IdentifierHash; use base64::Engine; use rand::{TryRng, rngs::SysRng}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; use subtle::ConstantTimeEq; +#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))] +use tracing::warn; + +const STORE_SCHEMA_VERSION: u32 = 1; +const TOKEN_HASH_CONTEXT: &str = "astrid.runtime.pair-device-token.identifier.v1"; + +/// Type prefix carried by every raw device-pairing bearer token. +pub const TOKEN_PREFIX: &str = "astrid_pair_"; /// Length of the random token portion in bytes (192 bits → 32 chars /// URL-safe base64). Same sizing as invite tokens. @@ -51,10 +61,10 @@ pub const TOKEN_RAW_LEN: usize = 24; pub const MAX_EXPIRY_SECS: u64 = 60 * 60; /// On-disk persisted pair-token record. Raw token is never stored — -/// only its SHA-256. +/// only its domain-separated BLAKE3 identifier. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PairToken { - /// Hex-encoded SHA-256 of the URL-safe base64 token. + /// `blake3:` identifier of the URL-safe base64 token. pub token_hash: String, /// Principal the new device's key will attach to. pub principal: PrincipalId, @@ -83,9 +93,9 @@ fn default_full_scope() -> DeviceScope { DeviceScope::Full } -/// File-backed pair-token store. Read-modify-write with atomic -/// rename; concurrent mutators serialise on the kernel's -/// `admin_write_lock`. +/// File-backed pair-token store. Read-modify-write uses atomic rename on Unix; +/// all loads and mutators serialise on the kernel's `admin_write_lock` because +/// a load can migrate legacy state. #[derive(Debug)] pub struct PairTokenStore { path: PathBuf, @@ -104,7 +114,9 @@ impl PairTokenStore { home.etc_dir().join("pair-tokens.toml") } - /// Read the persisted list. Missing file → empty Vec. + /// Read the persisted list. Missing file → empty Vec. A schema-0 store is + /// invalidated because its SHA-256 token identifiers cannot be + /// converted without the raw tokens. /// /// # Errors /// Returns an error if the file exists but is unreadable or @@ -134,14 +146,35 @@ impl PairTokenStore { PairTokenStoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) })?; if text.trim().is_empty() { + self.save_to_disk(&[])?; return Ok(Vec::new()); } + let probe: SchemaProbe = toml::from_str(text).map_err(PairTokenStoreError::Toml)?; + if probe.schema_version > STORE_SCHEMA_VERSION { + return Err(PairTokenStoreError::Io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "pair-token store schema {} is newer than supported schema {STORE_SCHEMA_VERSION}", + probe.schema_version + ), + ))); + } let parsed: PersistedFile = toml::from_str(text).map_err(PairTokenStoreError::Toml)?; + if probe.schema_version == 0 { + let invalidated = parsed.pair_token.len(); + self.save_to_disk(&[])?; + warn!( + path = %self.path.display(), + invalidated, + "invalidated legacy SHA-256 pair-token store" + ); + return Ok(Vec::new()); + } Ok(parsed.pair_token) } - /// Write the supplied list atomically (write-then-rename, 0600 - /// perms). Empty list persists as an empty TOML file. + /// Write the supplied list with write-then-rename and 0600 permissions on + /// Unix. An empty list retains the versioned TOML envelope. /// /// # Errors /// Returns an error if the file cannot be written. @@ -165,6 +198,7 @@ impl PairTokenStore { std::fs::create_dir_all(parent).map_err(PairTokenStoreError::Io)?; } let body = PersistedFile { + schema_version: STORE_SCHEMA_VERSION, pair_token: tokens.to_vec(), }; let text = toml::to_string_pretty(&body).map_err(PairTokenStoreError::TomlSer)?; @@ -223,13 +257,21 @@ impl std::fmt::Display for PairTokenStoreError { impl std::error::Error for PairTokenStoreError {} +#[derive(Debug, Default, Deserialize)] +struct SchemaProbe { + #[serde(default)] + schema_version: u32, +} + #[derive(Debug, Default, Serialize, Deserialize)] struct PersistedFile { + #[serde(default)] + schema_version: u32, #[serde(default)] pair_token: Vec, } -/// Generate a random URL-safe-base64 token from the OS CSPRNG. +/// Generate a typed token with a random URL-safe-base64 secret from the OS CSPRNG. /// /// # Panics /// @@ -240,15 +282,16 @@ pub fn generate_token() -> String { SysRng .try_fill_bytes(&mut bytes) .expect("OS CSPRNG unavailable while generating pair token"); - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + format!( + "{TOKEN_PREFIX}{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + ) } -/// Hash a token for storage / lookup. Hex-encoded SHA-256. +/// Derive a token identifier for storage and lookup. #[must_use] pub fn hash_token(token: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hex::encode(hasher.finalize()) + IdentifierHash::derive(TOKEN_HASH_CONTEXT, token.as_bytes()).to_prefixed_hex() } /// Constant-time hash comparison. @@ -283,15 +326,37 @@ mod tests { let a = generate_token(); let b = generate_token(); assert_ne!(a, b); - assert_eq!(a.len(), 32); + assert!(a.starts_with(TOKEN_PREFIX)); + assert_eq!(a.strip_prefix(TOKEN_PREFIX).unwrap().len(), 32); } #[test] - fn hash_is_deterministic_hex() { + fn hash_is_domain_separated_blake3() { let h = hash_token("hello"); - assert_eq!(h.len(), 64); + assert_eq!( + h, + "blake3:4e8275107b87254c5236647be8785404cdf3388d1ec2e149df1054de5a01e7a4" + ); + assert_eq!(h.len(), 71); assert_eq!(h, hash_token("hello")); assert_ne!(h, hash_token("world")); + assert_ne!(h, crate::invite::hash_token("hello")); + } + + #[test] + fn ct_hash_eq_checks_the_full_identifier_shape() { + let expected = hash_token("hello"); + assert!(ct_hash_eq(&expected, &expected)); + for index in [7, expected.len() / 2, expected.len() - 1] { + let mut different = expected.clone().into_bytes(); + different[index] = if different[index] == b'0' { b'1' } else { b'0' }; + assert!(!ct_hash_eq( + &expected, + std::str::from_utf8(&different).unwrap() + )); + } + assert!(!ct_hash_eq(&expected, &expected[..70])); + assert!(!ct_hash_eq(&expected, &format!("{expected}0"))); } #[test] @@ -299,7 +364,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let store = PairTokenStore::new(dir.path().join("pair-tokens.toml")); let token = PairToken { - token_hash: "abc".into(), + token_hash: hash_token("pair alice phone"), principal: PrincipalId::new("alice").unwrap(), expires_at_epoch: 9_999_999_999, issued_at_epoch: 1, @@ -310,6 +375,11 @@ mod tests { }, }; store.save(std::slice::from_ref(&token)).unwrap(); + assert!( + std::fs::read_to_string(&store.path) + .unwrap() + .contains("schema_version = 1") + ); let loaded = store.load().unwrap(); assert_eq!(loaded, vec![token]); } @@ -321,8 +391,9 @@ mod tests { // device so the round-trip preserves the prior behaviour. let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("pair-tokens.toml"); - let legacy = "[[pair_token]]\n\ - token_hash = \"abc\"\n\ + let legacy = "schema_version = 1\n\ + [[pair_token]]\n\ + token_hash = \"blake3:4e8275107b87254c5236647be8785404cdf3388d1ec2e149df1054de5a01e7a4\"\n\ principal = \"alice\"\n\ expires_at_epoch = 9999999999\n\ issued_at_epoch = 1\n"; @@ -332,6 +403,48 @@ mod tests { assert_eq!(loaded[0].scope, DeviceScope::Full); } + #[test] + fn legacy_sha256_store_is_invalidated_and_rewritten() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pair-tokens.toml"); + let legacy = "[[pair_token]]\n\ + token_hash = \"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\"\n\ + principal = \"alice\"\n\ + expires_at_epoch = 9999999999\n\ + issued_at_epoch = 1\n"; + std::fs::write(&path, legacy).unwrap(); + + let store = PairTokenStore::new(path.clone()); + assert!(store.load().unwrap().is_empty()); + let rewritten = std::fs::read_to_string(&path).unwrap(); + assert!(rewritten.contains("schema_version = 1")); + assert!(!rewritten.contains("[[pair_token]]")); + assert!(store.load().unwrap().is_empty()); + } + + #[test] + fn future_store_is_rejected_without_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pair-tokens.toml"); + let future = "schema_version = 2\nfuture_field = \"preserve me\"\n"; + std::fs::write(&path, future).unwrap(); + + let err = PairTokenStore::new(path.clone()).load().unwrap_err(); + assert!(err.to_string().contains("schema 2 is newer")); + assert_eq!(std::fs::read_to_string(path).unwrap(), future); + } + + #[test] + fn malformed_store_is_rejected_without_rewrite() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("pair-tokens.toml"); + let malformed = "schema_version = [not valid\n"; + std::fs::write(&path, malformed).unwrap(); + + assert!(PairTokenStore::new(path.clone()).load().is_err()); + assert_eq!(std::fs::read_to_string(path).unwrap(), malformed); + } + #[test] fn prune_drops_expired() { let now = now_epoch(); diff --git a/crates/astrid-mcp/src/config.rs b/crates/astrid-mcp/src/config.rs index d345834cb..81d0a0bdc 100644 --- a/crates/astrid-mcp/src/config.rs +++ b/crates/astrid-mcp/src/config.rs @@ -70,7 +70,7 @@ pub struct ServerConfig { pub args: Vec, /// URL for SSE transport. pub url: Option, - /// Expected binary hash (sha256:...) for verification. + /// Expected binary content hash (`blake3:<64 lowercase hex>`). pub binary_hash: Option, /// Environment variables. #[serde(default)] @@ -237,6 +237,7 @@ impl ServerConfig { let Some(expected) = &self.binary_hash else { return Ok(()); // No hash configured, skip verification }; + let expected_hash = parse_binary_content_hash(expected)?; let Some(command) = &self.command else { return Ok(()); // No command to verify @@ -249,9 +250,9 @@ impl ServerConfig { // Read and hash the binary let binary_data = std::fs::read(&binary_path)?; let actual_hash = ContentHash::hash(&binary_data); - let actual_str = format!("sha256:{}", actual_hash.to_hex()); + let actual_str = format_binary_content_hash(&actual_hash); - if expected != &actual_str { + if expected_hash != actual_hash { return Err(McpError::BinaryHashMismatch { name: self.name.clone(), expected: expected.clone(), @@ -263,6 +264,33 @@ impl ServerConfig { } } +fn format_binary_content_hash(hash: &ContentHash) -> String { + format!("blake3:{}", hash.to_hex()) +} + +fn parse_binary_content_hash(value: &str) -> McpResult { + if value.starts_with("sha256:") { + return Err(McpError::ConfigError( + "binary_hash uses the retired sha256: label for BLAKE3 bytes; replace it with blake3:" + .into(), + )); + } + let digest = value.strip_prefix("blake3:").ok_or_else(|| { + McpError::ConfigError("binary_hash must use blake3:<64 lowercase hex>".into()) + })?; + if digest.len() != 64 + || !digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(McpError::ConfigError( + "binary_hash must use blake3:<64 lowercase hex>".into(), + )); + } + ContentHash::from_hex(digest) + .map_err(|_| McpError::ConfigError("binary_hash must use blake3:<64 lowercase hex>".into())) +} + /// Maximum length for a server name. const MAX_SERVER_NAME_LEN: usize = 128; @@ -441,6 +469,31 @@ impl ServersConfig { mod tests { use super::*; + #[test] + fn binary_hash_uses_an_honest_blake3_label() { + assert_eq!( + format_binary_content_hash(&ContentHash::hash(b"hello")), + "blake3:ea8f163db38682925e4491c5e58d4bb3506ef8c14eb78a86e908c5624a67200f" + ); + assert!(!format_binary_content_hash(&ContentHash::hash(b"hello")).starts_with("sha256:")); + assert_eq!( + parse_binary_content_hash(&format_binary_content_hash(&ContentHash::hash(b"hello"))) + .unwrap(), + ContentHash::hash(b"hello") + ); + let retired = format!("sha256:{}", ContentHash::hash(b"hello").to_hex()); + let error = parse_binary_content_hash(&retired).unwrap_err(); + assert!(error.to_string().contains("retired sha256: label")); + assert!(parse_binary_content_hash("blake3:not-hex").is_err()); + assert!( + parse_binary_content_hash(&format!( + "blake3:{}", + ContentHash::hash(b"hello").to_hex().to_ascii_uppercase() + )) + .is_err() + ); + } + #[test] fn test_server_config_stdio() { let config = ServerConfig::stdio("filesystem", "npx") diff --git a/docs/config.md b/docs/config.md index beb790bbe..4316e6afa 100644 --- a/docs/config.md +++ b/docs/config.md @@ -300,7 +300,7 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] auto_start = true trusted = false restart_policy = "never" -# binary_hash = "sha256:abc123..." # Optional: verify binary integrity +# binary_hash = "blake3:abc123..." # Optional: verify binary integrity # cwd = "/path/to/working/dir" # Optional: working directory for the process # description = "Filesystem access" # Optional: human-readable description @@ -316,7 +316,7 @@ NODE_ENV = "production" | `url` | string | URL for network transports (`sse`, `streamable-http`). | | `env` | table | Environment variables to pass to the process. | | `cwd` | string | (Optional) Working directory for the server process. | -| `binary_hash` | string | (Optional) Expected hash for binary integrity verification (e.g., `"sha256:..."`). | +| `binary_hash` | string | (Optional) Expected hash for binary integrity verification (e.g., `"blake3:..."`). | | `description` | string | (Optional) Human-readable description of this server. | | `trusted` | bool | Whether this server is trusted (affects capability defaults). | | `auto_start` | bool | Start automatically with the daemon. | diff --git a/docs/distro-signing.md b/docs/distro-signing.md index f14cea503..3dc3a560d 100644 --- a/docs/distro-signing.md +++ b/docs/distro-signing.md @@ -26,7 +26,7 @@ transitively covers the whole archive. Two distinct guarantees stack: manifest against `manifest_hash`, the lock against the signature. A signature does **not** survive key theft on its own (a thief re-signs). -The durable guarantee is to **vendor the `.shuttle` / pin its sha256** — +The durable guarantee is to **vendor the `.shuttle` / pin its BLAKE3** — see §6. ## 2. Trust: TOFU, pinning, official keys @@ -156,13 +156,13 @@ lock and re-signs. To get "break rather than install something dangerous," put the pin where the key-holder can't reach it: - **Vendor the `.shuttle`** in your repo / Docker build context, or pin - its `sha256`. Your CI then installs *your* bytes every time; a + its BLAKE3 digest. Your CI then installs *your* bytes every time; a malicious re-publish is a different file with a different hash you'd notice. This is unaffected by upstream key theft. ```dockerfile COPY example-distro-0.1.0.shuttle /tmp/ -RUN echo " /tmp/example-distro-0.1.0.shuttle" | sha256sum -c - \ +RUN echo " /tmp/example-distro-0.1.0.shuttle" | b3sum -c - \ && astrid init --distro /tmp/example-distro-0.1.0.shuttle --yes ``` diff --git a/docs/gateway-client.md b/docs/gateway-client.md index 4b1951f53..9071b6047 100644 --- a/docs/gateway-client.md +++ b/docs/gateway-client.md @@ -39,7 +39,7 @@ curl http://127.0.0.1:2787/api/openapi.json > openapi.json | Browsable | Swagger UI / Redoc / Scalar | Drop the spec URL straight in. | **Pin the generator version and record the spec hash** (e.g. the -`sha256` of `openapi.json`) alongside the generated output. The +`b3sum` of `openapi.json`) alongside the generated output. The gateway is workspace-locked to `core` and ships breaking changes on `core` version bumps — the bearer wire format already went `v1 → v2` (3 → 4 segments) this way — so a client must be able to say diff --git a/e2e/fixtures/compat/legacy-pair-token-without-scope.toml b/e2e/fixtures/compat/legacy-pair-token-without-scope.toml index 0b359e2b5..38cd74963 100644 --- a/e2e/fixtures/compat/legacy-pair-token-without-scope.toml +++ b/e2e/fixtures/compat/legacy-pair-token-without-scope.toml @@ -1,5 +1,7 @@ +schema_version = 1 + [[pair_token]] -token_hash = "abc123" +token_hash = "blake3:4e8275107b87254c5236647be8785404cdf3388d1ec2e149df1054de5a01e7a4" principal = "compat-user" expires_at_epoch = 9999999999 issued_at_epoch = 1 diff --git a/e2e/fixtures/compat/legacy-sha-pair-token.toml b/e2e/fixtures/compat/legacy-sha-pair-token.toml new file mode 100644 index 000000000..302fbda39 --- /dev/null +++ b/e2e/fixtures/compat/legacy-sha-pair-token.toml @@ -0,0 +1,6 @@ +[[pair_token]] +token_hash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" +principal = "compat-user" +expires_at_epoch = 9999999999 +issued_at_epoch = 1 +label = "legacy SHA token" From 11fb2b861425fbda8d23d48e9156f214fdb3a1b9 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 04:36:35 +0400 Subject: [PATCH 2/8] fix(cli): own scanned key names before path moves --- crates/astrid-cli/src/commands/keypair.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/astrid-cli/src/commands/keypair.rs b/crates/astrid-cli/src/commands/keypair.rs index 8f3a3fc2f..d1a49f82f 100644 --- a/crates/astrid-cli/src/commands/keypair.rs +++ b/crates/astrid-cli/src/commands/keypair.rs @@ -553,16 +553,14 @@ fn scan_keys() -> Result> { else { continue; }; + let name = name.to_owned(); let paths = KeyPaths { private: dir.join(format!("{name}.ed25519")), public_hex: dir.join(format!("{name}.pub.hex")), meta: path, }; match read_meta(&paths) { - Ok(meta) => out.push(KeyEntry { - name: name.to_string(), - meta, - }), + Ok(meta) => out.push(KeyEntry { name, meta }), Err(e) => { tracing::warn!(name = %name, error = %e, "skipping unreadable keypair meta"); }, From ed9b8a5d6085f868b41c4b2b315c3b97b3c63c80 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 04:45:05 +0400 Subject: [PATCH 3/8] docs(security): describe typed bearer identifiers --- crates/astrid-kernel/src/invite.rs | 8 ++++---- crates/astrid-kernel/src/pair_token.rs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/astrid-kernel/src/invite.rs b/crates/astrid-kernel/src/invite.rs index 606113b51..e488f03c5 100644 --- a/crates/astrid-kernel/src/invite.rs +++ b/crates/astrid-kernel/src/invite.rs @@ -2,9 +2,9 @@ //! //! Invite tokens are short opaque secrets that grant a one-time-ish //! right to mint a principal. The kernel never stores the raw token — -//! it stores a domain-separated BLAKE3 identifier of the URL-safe base64 -//! form. Redemption hashes the incoming token and compares against the -//! persisted set. +//! it stores a domain-separated BLAKE3 identifier of the complete typed +//! bearer string (`astrid_inv_` plus its URL-safe base64 secret). Redemption +//! hashes the incoming token and compares against the persisted set. //! //! ## On-disk layout //! @@ -73,7 +73,7 @@ pub const MAX_EXPIRY_SECS: u64 = 60 * 60 * 24 * 30; /// only its domain-separated BLAKE3 identifier. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Invite { - /// `blake3:` identifier of the URL-safe base64 token. + /// `blake3:` identifier of the complete `astrid_inv_` bearer token. pub token_hash: String, /// Group new redeemers join. pub group: String, diff --git a/crates/astrid-kernel/src/pair_token.rs b/crates/astrid-kernel/src/pair_token.rs index cde4cb2ff..a8f1d7f3c 100644 --- a/crates/astrid-kernel/src/pair_token.rs +++ b/crates/astrid-kernel/src/pair_token.rs @@ -64,7 +64,7 @@ pub const MAX_EXPIRY_SECS: u64 = 60 * 60; /// only its domain-separated BLAKE3 identifier. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PairToken { - /// `blake3:` identifier of the URL-safe base64 token. + /// `blake3:` identifier of the complete `astrid_pair_` bearer token. pub token_hash: String, /// Principal the new device's key will attach to. pub principal: PrincipalId, From 08022341a3b232797dc40fb01c2751ff750646ff Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 10:13:22 +0400 Subject: [PATCH 4/8] fix(mcp): preserve remote hash no-op --- crates/astrid-mcp/src/config.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/crates/astrid-mcp/src/config.rs b/crates/astrid-mcp/src/config.rs index 81d0a0bdc..e20c2a2da 100644 --- a/crates/astrid-mcp/src/config.rs +++ b/crates/astrid-mcp/src/config.rs @@ -237,11 +237,10 @@ impl ServerConfig { let Some(expected) = &self.binary_hash else { return Ok(()); // No hash configured, skip verification }; - let expected_hash = parse_binary_content_hash(expected)?; - let Some(command) = &self.command else { return Ok(()); // No command to verify }; + let expected_hash = parse_binary_content_hash(expected)?; // Find the binary path let binary_path = which::which(command) @@ -494,6 +493,16 @@ mod tests { ); } + #[test] + fn binary_hash_is_ignored_when_there_is_no_local_command() { + let config = ServerConfig::sse("remote", "https://example.com/mcp") + .with_hash("sha256:legacy-irrelevant-value"); + + config + .verify_binary() + .expect("an SSE server has no local binary to verify"); + } + #[test] fn test_server_config_stdio() { let config = ServerConfig::stdio("filesystem", "npx") From a84d773ab38b016f7d5ec9d88ac92435ddc15ae7 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 11:43:26 +0400 Subject: [PATCH 5/8] test(kernel): compose BLAKE3 device authority flow --- .../kernel_router/admin/pair_device_tests.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs b/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs index 4a5782004..f2ed39228 100644 --- a/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs +++ b/crates/astrid-kernel/src/kernel_router/admin/pair_device_tests.rs @@ -445,6 +445,10 @@ async fn valid_explicit_allow_and_deny_survive_issue_and_redeem() { AdminResponseBody::PairToken(token) => token.token, other => panic!("valid explicit scope must issue, got: {other:?}"), }; + assert!( + token.starts_with("astrid_pair_"), + "pair authority must be carried by the typed BLAKE3-era token" + ); let public_key = "d".repeat(64); let response = handlers::dispatch( @@ -470,6 +474,31 @@ async fn valid_explicit_allow_and_deny_survive_issue_and_redeem() { deny: vec!["self:capsule:install".into()], } ); + let redeemed_key_id = child.key_id.clone(); + drop(profile); + + let response = handlers::dispatch( + &kernel, + &caller, + AdminRequestKind::PairDeviceRevoke { + principal: caller.clone(), + key_id: redeemed_key_id.clone(), + }, + ) + .await; + match response { + AdminResponseBody::PairDeviceRevoked { key_id } => { + assert_eq!(key_id, redeemed_key_id); + }, + other => panic!("redeemed BLAKE3 device identity must revoke, got: {other:?}"), + } + assert!( + load(&kernel, &caller) + .auth + .device_by_key_id(&redeemed_key_id) + .is_none(), + "revoked redeemed device must no longer authorize" + ); } // ── Coordinator correction: deny-inheritance (monotonic narrowing) ──── From b011243755cd5152fd28928f04d9772e675f8952 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 12:19:14 +0400 Subject: [PATCH 6/8] docs(mcp): specify exact BLAKE3 pin format --- docs/config.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 4316e6afa..10e9d9c4c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -300,7 +300,8 @@ args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] auto_start = true trusted = false restart_policy = "never" -# binary_hash = "blake3:abc123..." # Optional: verify binary integrity +# Replace with the local command's complete lowercase BLAKE3 digest. +# binary_hash = "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # cwd = "/path/to/working/dir" # Optional: working directory for the process # description = "Filesystem access" # Optional: human-readable description @@ -316,7 +317,7 @@ NODE_ENV = "production" | `url` | string | URL for network transports (`sse`, `streamable-http`). | | `env` | table | Environment variables to pass to the process. | | `cwd` | string | (Optional) Working directory for the server process. | -| `binary_hash` | string | (Optional) Expected hash for binary integrity verification (e.g., `"blake3:..."`). | +| `binary_hash` | string | (Optional) Exact `blake3:<64 lowercase hex>` content hash for a local command. Other forms are rejected; network transports with no local `command` have no binary to verify. | | `description` | string | (Optional) Human-readable description of this server. | | `trusted` | bool | Whether this server is trusted (affects capability defaults). | | `auto_start` | bool | Start automatically with the daemon. | From 6fa606cf1b9a95407c6f106eebd46350774b38bc Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 12:42:09 +0400 Subject: [PATCH 7/8] docs(config): use a valid BLAKE3 pin example --- crates/astrid-config/src/defaults.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/astrid-config/src/defaults.toml b/crates/astrid-config/src/defaults.toml index 55346e87a..d192297ad 100644 --- a/crates/astrid-config/src/defaults.toml +++ b/crates/astrid-config/src/defaults.toml @@ -197,7 +197,7 @@ max_pending_requests = 50 # args = ["--config", "react.toml"] # auto_start = true # trusted = true -# binary_hash = "blake3:abc123..." # Optional: verify binary integrity +# binary_hash = "blake3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # Optional: verify binary integrity # # # Restart on failure with max 5 retries (exponential backoff applied): # [servers.react.restart_policy] From f948083f5b7a5461a0ff001f1f2f34cd1d26f387 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 21:25:22 +0400 Subject: [PATCH 8/8] docs(api): acknowledge typed credential formats API-CONTRACT-CHANGE: pair and invite bearer tokens now expose typed prefixes, and public key and token fingerprints now use the blake3: format.