From 3d788caeb2f8495a8cb6aa0531190a4e83b6f64a Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 04:19:05 +0400 Subject: [PATCH 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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. From ff0f098b9ea43cf7cce0eb98e9912bae7d67822e Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 04:47:50 +0400 Subject: [PATCH 09/20] feat(release): publish BLAKE3 integrity manifests --- .github/workflows/release.yml | 18 +++- CHANGELOG.md | 11 ++- Cargo.lock | 1 - .../src/github_source.rs | 2 +- crates/astrid-cli/Cargo.toml | 1 - crates/astrid-cli/src/commands/self_update.rs | 99 ++++++++++++------- .../src/commands/self_update_tests.rs | 52 ++++++++-- 7 files changed, 129 insertions(+), 55 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26fd03107..09628459c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,6 +114,14 @@ jobs: with: fetch-depth: 0 + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable + with: + toolchain: "1.95.0" + + - name: Install pinned b3sum + run: cargo install b3sum --version 1.8.5 --locked + - name: Download all binary artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -192,15 +200,15 @@ jobs: - name: Generate checksums run: | cd artifacts - sha256sum *.tar.gz > SHA256SUMS.txt - cat SHA256SUMS.txt + b3sum *.tar.gz > BLAKE3SUMS.txt + cat BLAKE3SUMS.txt - name: Attest release provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: | artifacts/*.tar.gz - artifacts/SHA256SUMS.txt + artifacts/BLAKE3SUMS.txt - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -210,7 +218,7 @@ jobs: COSIGN_YES: "true" run: | cd artifacts - for asset in *.tar.gz SHA256SUMS.txt; do + for asset in *.tar.gz BLAKE3SUMS.txt; do cosign sign-blob --yes --bundle "${asset}.sigstore.json" "$asset" done @@ -219,7 +227,7 @@ jobs: with: files: | artifacts/*.tar.gz - artifacts/SHA256SUMS.txt + artifacts/BLAKE3SUMS.txt artifacts/*.sigstore.json body_path: release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a9b31ec..d77564c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,8 +60,15 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. 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. + protocols such as SRI, Git, and registry checksums remain unchanged. Closes + #1247. + +- **Astrid release archives now use BLAKE3 integrity manifests.** Release + automation publishes, signs, and attests `BLAKE3SUMS.txt`; self-managed + updates require a strict lowercase BLAKE3 entry and reject absent, malformed, + duplicate, or legacy SHA-only manifests. Existing v0.9.x self-managed + installations need one manual, Cargo, or Homebrew upgrade across this format + boundary. External protocol requirements remain unchanged. Closes #1249. - **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 13c77616f..05992c563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -356,7 +356,6 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "sha2 0.11.0", "syntect", "tar", "tempfile", diff --git a/crates/astrid-capsule-install/src/github_source.rs b/crates/astrid-capsule-install/src/github_source.rs index 314ad7b90..e32aa43b3 100644 --- a/crates/astrid-capsule-install/src/github_source.rs +++ b/crates/astrid-capsule-install/src/github_source.rs @@ -347,7 +347,7 @@ mod tests { "name": "cli.capsule", "browser_download_url": "https://example.com/cli.capsule" }), - serde_json::json!({ "name": "checksums.sha256" }), + serde_json::json!({ "name": "BLAKE3SUMS.txt" }), // `.capsule` asset with no download URL is skipped, not panicked on. serde_json::json!({ "name": "broken.capsule" }), ]; diff --git a/crates/astrid-cli/Cargo.toml b/crates/astrid-cli/Cargo.toml index 0897a00da..0863d0479 100644 --- a/crates/astrid-cli/Cargo.toml +++ b/crates/astrid-cli/Cargo.toml @@ -45,7 +45,6 @@ base64 = { workspace = true } ed25519-dalek = { workspace = true } hex = { workspace = true } rand = { workspace = true } -sha2 = { workspace = true } zeroize = { workspace = true } chrono = { workspace = true, features = ["clock"] } clap = { workspace = true } diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index 8e47dd59d..b0c005431 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -381,41 +381,70 @@ async fn download(client: &reqwest::Client, url: &str) -> anyhow::Result Ok(bytes) } -/// Hex-encode bytes (no extra dep). -fn to_hex(bytes: &[u8]) -> String { - use std::fmt::Write as _; - let mut s = String::with_capacity(bytes.len().saturating_mul(2)); - for b in bytes { - let _ = write!(s, "{b:02x}"); - } - s -} - -/// Verify `archive` against the sha256 recorded for `asset_name` in a -/// `SHA256SUMS.txt` body (` ` per line). This is INTEGRITY only — +/// Verify `archive` against the BLAKE3 digest recorded for `asset_name` in a +/// `BLAKE3SUMS.txt` body (` ` per line). This is INTEGRITY only — /// it catches a corrupt/truncated/MITM-altered download whose checksum no longer /// matches the release's recorded sum. It is NOT authenticity (an attacker who /// controls the release controls both the artifact and the sum); a publisher /// signature is tracked separately. -fn verify_sha256(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::Result<()> { - use sha2::{Digest, Sha256}; - let expected = sums_body - .lines() - .find_map(|line| { - let mut it = line.split_whitespace(); - let hex = it.next()?; - let name = it.next()?; - // `sha256sum` marks binary entries with a leading '*'. - (name.trim_start_matches('*') == asset_name).then_some(hex) - }) - .ok_or_else(|| anyhow::anyhow!("no checksum for '{asset_name}' in SHA256SUMS"))?; - let actual = to_hex(&Sha256::digest(archive)); - if !actual.eq_ignore_ascii_case(expected) { - bail!("checksum mismatch for '{asset_name}': expected {expected}, got {actual}"); +fn verify_blake3(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::Result<()> { + let mut expected = None; + let mut seen_assets = std::collections::HashSet::new(); + + for (index, line) in sums_body.lines().enumerate() { + let line_number = index + 1; + let (hex, name) = line.split_once(" ").ok_or_else(|| { + anyhow::anyhow!( + "malformed BLAKE3SUMS.txt line {line_number}: expected ' '" + ) + })?; + anyhow::ensure!( + hex.len() == 64 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "malformed BLAKE3 digest on line {line_number}: expected 64 lowercase hex characters" + ); + anyhow::ensure!( + !name.is_empty() && !name.bytes().any(|byte| byte.is_ascii_whitespace()), + "malformed BLAKE3SUMS.txt asset name on line {line_number}" + ); + anyhow::ensure!( + seen_assets.insert(name), + "duplicate checksum for '{name}' in BLAKE3SUMS.txt" + ); + + let digest = blake3::Hash::from_hex(hex) + .with_context(|| format!("invalid BLAKE3 digest on line {line_number}"))?; + if name == asset_name { + expected = Some(digest); + } + } + + let expected = expected + .ok_or_else(|| anyhow::anyhow!("no checksum for '{asset_name}' in BLAKE3SUMS.txt"))?; + let actual = blake3::hash(archive); + if actual != expected { + bail!( + "checksum mismatch for '{asset_name}': expected {}, got {}", + expected.to_hex(), + actual.to_hex() + ); } Ok(()) } +/// Require the canonical BLAKE3 manifest from a release. Legacy-only releases +/// fail closed; an existing v0.9.x installation needs one manual package-manager +/// upgrade across this format boundary. +fn blake3_sums_url(release: &serde_json::Value) -> anyhow::Result<&str> { + asset_url(release, "BLAKE3SUMS.txt").ok_or_else(|| { + anyhow::anyhow!( + "release has no BLAKE3SUMS.txt — refusing to install an unverifiable binary" + ) + }) +} + /// Back up and atomically swap the named binaries from `extract_dir` into /// `install_dir`. /// @@ -651,20 +680,14 @@ async fn download_verify_extract( .to_string(); let archive = download(client, &url).await?; - // Fail closed: a release with no SHA256SUMS.txt is unverifiable, so we refuse - // to install it rather than swap in an unchecked binary. (SHA256SUMS is + // Fail closed: a release with no BLAKE3SUMS.txt is unverifiable, so we refuse + // to install it rather than swap in an unchecked binary. (BLAKE3SUMS is // integrity, not authenticity — but skipping it entirely would defeat even // the on-the-wire / corrupted-download check.) - let sums_url = asset_url(release, "SHA256SUMS.txt") - .map(str::to_owned) - .ok_or_else(|| { - anyhow::anyhow!( - "release has no SHA256SUMS.txt — refusing to install an unverifiable binary" - ) - })?; + let sums_url = blake3_sums_url(release)?.to_owned(); let sums = download(client, &sums_url).await?; - let sums_body = String::from_utf8(sums).context("SHA256SUMS.txt is not UTF-8")?; - verify_sha256(&archive, &sums_body, &asset_name)?; + let sums_body = String::from_utf8(sums).context("BLAKE3SUMS.txt is not UTF-8")?; + verify_blake3(&archive, &sums_body, &asset_name)?; println!("{}", Theme::dimmed("Checksum verified.")); let tmp_dir = tempfile::tempdir()?; diff --git a/crates/astrid-cli/src/commands/self_update_tests.rs b/crates/astrid-cli/src/commands/self_update_tests.rs index f02686d27..aad1a9c2e 100644 --- a/crates/astrid-cli/src/commands/self_update_tests.rs +++ b/crates/astrid-cli/src/commands/self_update_tests.rs @@ -229,18 +229,56 @@ fn resolve_repo_precedence_and_validation() { } #[test] -fn sha256_verification_matches_and_rejects() { - use sha2::Digest; +fn blake3_verification_matches_independent_vector() { let archive = b"hello astrid"; - let good = to_hex(&sha2::Sha256::digest(archive)); + // Independently generated with `printf %s 'hello astrid' | b3sum --no-names`. + let good = "41251d32ddff968c23c1c83c4e7b3af8d6fef8912b4806aadec90e106a629fef"; + assert_eq!(blake3::hash(archive).to_hex().as_str(), good); let body = format!("{good} astrid-1.0.0-x.tar.gz\n"); - verify_sha256(archive, &body, "astrid-1.0.0-x.tar.gz").expect("matching sum verifies"); + verify_blake3(archive, &body, "astrid-1.0.0-x.tar.gz").expect("matching sum verifies"); + + let asset = "astrid-1.0.0-x.tar.gz"; // Wrong sum -> error. - let bad_body = format!("{} astrid-1.0.0-x.tar.gz\n", "0".repeat(64)); - assert!(verify_sha256(archive, &bad_body, "astrid-1.0.0-x.tar.gz").is_err()); + let bad_body = format!("{} {asset}\n", "0".repeat(64)); + assert!(verify_blake3(archive, &bad_body, asset).is_err()); // Missing entry -> error. - assert!(verify_sha256(archive, "deadbeef other.tar.gz\n", "astrid-1.0.0-x.tar.gz").is_err()); + let other = format!("{} other.tar.gz\n", "0".repeat(64)); + assert!(verify_blake3(archive, &other, asset).is_err()); + // Malformed length, non-hex and uppercase encodings are rejected. + assert!(verify_blake3(archive, &format!("{} {asset}\n", "a".repeat(63)), asset).is_err()); + assert!(verify_blake3(archive, &format!("{}g {asset}\n", "a".repeat(63)), asset).is_err()); + assert!( + verify_blake3( + archive, + &format!("{} {asset}\n", good.to_uppercase()), + asset + ) + .is_err() + ); + // A matching checksum cannot be shadowed by a duplicate entry. + let duplicate = format!("{good} {asset}\n{good} {asset}\n"); + assert!(verify_blake3(archive, &duplicate, asset).is_err()); + // Manifest validity is platform-independent: duplicates for a different + // release asset are rejected as well. + let duplicate_other = format!( + "{good} {asset}\n{} other.tar.gz\n{} other.tar.gz\n", + "0".repeat(64), + "1".repeat(64) + ); + assert!(verify_blake3(archive, &duplicate_other, asset).is_err()); +} + +#[test] +fn legacy_sha_only_release_is_not_accepted() { + let release = serde_json::json!({ + "assets": [{ + "name": "SHA256SUMS.txt", + "browser_download_url": "https://example.com/SHA256SUMS.txt" + }] + }); + let error = blake3_sums_url(&release).unwrap_err().to_string(); + assert!(error.contains("release has no BLAKE3SUMS.txt")); } #[test] From b93315a49f6664a321e744e941d4400d58edf2f7 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 05:04:10 +0400 Subject: [PATCH 10/20] fix(release): retain SHA-256 compatibility manifests --- .github/workflows/release.yml | 8 ++++++-- CHANGELOG.md | 14 ++++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09628459c..614aaa411 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -200,8 +200,10 @@ jobs: - name: Generate checksums run: | cd artifacts - b3sum *.tar.gz > BLAKE3SUMS.txt + b3sum -- ./*.tar.gz | sed 's# \./# #' > BLAKE3SUMS.txt + sha256sum -- ./*.tar.gz | sed 's# \./# #' > SHA256SUMS.txt cat BLAKE3SUMS.txt + cat SHA256SUMS.txt - name: Attest release provenance uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 @@ -209,6 +211,7 @@ jobs: subject-path: | artifacts/*.tar.gz artifacts/BLAKE3SUMS.txt + artifacts/SHA256SUMS.txt - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -218,7 +221,7 @@ jobs: COSIGN_YES: "true" run: | cd artifacts - for asset in *.tar.gz BLAKE3SUMS.txt; do + for asset in *.tar.gz BLAKE3SUMS.txt SHA256SUMS.txt; do cosign sign-blob --yes --bundle "${asset}.sigstore.json" "$asset" done @@ -228,6 +231,7 @@ jobs: files: | artifacts/*.tar.gz artifacts/BLAKE3SUMS.txt + artifacts/SHA256SUMS.txt artifacts/*.sigstore.json body_path: release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d77564c8f..7be76d366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,12 +63,14 @@ Changelog tracking starts with 0.2.0. Prior versions were not tracked. protocols such as SRI, Git, and registry checksums remain unchanged. Closes #1247. -- **Astrid release archives now use BLAKE3 integrity manifests.** Release - automation publishes, signs, and attests `BLAKE3SUMS.txt`; self-managed - updates require a strict lowercase BLAKE3 entry and reject absent, malformed, - duplicate, or legacy SHA-only manifests. Existing v0.9.x self-managed - installations need one manual, Cargo, or Homebrew upgrade across this format - boundary. External protocol requirements remain unchanged. Closes #1249. +- **Astrid release archives now use BLAKE3 as their primary integrity + manifest.** Release automation publishes, signs, and attests + `BLAKE3SUMS.txt`, while retaining a signed `SHA256SUMS.txt` compatibility + manifest for Homebrew and existing downstream tooling. Self-managed updates + require a strict lowercase BLAKE3 entry and reject absent, malformed, + duplicate, or SHA-only manifests. Existing v0.9.x installations can still + cross the boundary through the compatibility manifest. External protocol + requirements remain unchanged. Closes #1249. - **Runtime E2E now stages the pinned Unicity AOS monorepo.** The workflow preserves the AOS Cargo workspace outside the core checkout and supplies From 9fc0ac55721dbbe7b7df98f97dd8517054c039b0 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 05:25:44 +0400 Subject: [PATCH 11/20] docs(update): describe checksum compatibility bridge --- crates/astrid-cli/src/commands/self_update.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index b0c005431..8b0699f73 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -434,9 +434,9 @@ fn verify_blake3(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::R Ok(()) } -/// Require the canonical BLAKE3 manifest from a release. Legacy-only releases -/// fail closed; an existing v0.9.x installation needs one manual package-manager -/// upgrade across this format boundary. +/// Require the canonical BLAKE3 manifest from a release. A release containing +/// only the legacy SHA-256 manifest fails closed; release automation retains a +/// compatibility manifest so existing v0.9.x clients can cross this boundary. fn blake3_sums_url(release: &serde_json::Value) -> anyhow::Result<&str> { asset_url(release, "BLAKE3SUMS.txt").ok_or_else(|| { anyhow::anyhow!( From 7dd8c07637d2814548533f2c8f9ddee5dff7fe3d Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 06:27:14 +0400 Subject: [PATCH 12/20] fix(update): check release manifest line overflow --- crates/astrid-cli/src/commands/self_update.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index 8b0699f73..c62ef7eea 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -392,7 +392,9 @@ fn verify_blake3(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::R let mut seen_assets = std::collections::HashSet::new(); for (index, line) in sums_body.lines().enumerate() { - let line_number = index + 1; + let line_number = index + .checked_add(1) + .context("BLAKE3SUMS.txt line count exceeds usize")?; let (hex, name) = line.split_once(" ").ok_or_else(|| { anyhow::anyhow!( "malformed BLAKE3SUMS.txt line {line_number}: expected ' '" From 1f584a0fa9da33f05168c402a3a12cae929a653d Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 21:38:05 +0400 Subject: [PATCH 13/20] fix(update): bound release manifest entries --- crates/astrid-cli/src/commands/self_update.rs | 9 +++++++++ crates/astrid-cli/src/commands/self_update_tests.rs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index c62ef7eea..1b2c7cc16 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -35,6 +35,11 @@ const CHECK_TTL_SECS: u64 = 86_400; /// Max size of a downloaded release archive. const MAX_ARCHIVE_BYTES: usize = 100 * 1024 * 1024; +/// Release manifest entries are a digest plus one archive filename. Bound each +/// entry independently so malformed manifests fail before parsing untrusted +/// line contents. +const MAX_MANIFEST_LINE_BYTES: usize = 1_024; + /// Binaries managed by an in-place update. const MANAGED_BINARIES: &[&str] = &["astrid", "astrid-daemon"]; @@ -395,6 +400,10 @@ fn verify_blake3(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::R let line_number = index .checked_add(1) .context("BLAKE3SUMS.txt line count exceeds usize")?; + anyhow::ensure!( + line.len() <= MAX_MANIFEST_LINE_BYTES, + "BLAKE3SUMS.txt line {line_number} exceeds {MAX_MANIFEST_LINE_BYTES} byte limit" + ); let (hex, name) = line.split_once(" ").ok_or_else(|| { anyhow::anyhow!( "malformed BLAKE3SUMS.txt line {line_number}: expected ' '" diff --git a/crates/astrid-cli/src/commands/self_update_tests.rs b/crates/astrid-cli/src/commands/self_update_tests.rs index aad1a9c2e..5abca239e 100644 --- a/crates/astrid-cli/src/commands/self_update_tests.rs +++ b/crates/astrid-cli/src/commands/self_update_tests.rs @@ -267,6 +267,15 @@ fn blake3_verification_matches_independent_vector() { "1".repeat(64) ); assert!(verify_blake3(archive, &duplicate_other, asset).is_err()); + + let overlong = "a".repeat(MAX_MANIFEST_LINE_BYTES + 1); + let error = verify_blake3(archive, &overlong, asset) + .unwrap_err() + .to_string(); + assert_eq!( + error, + format!("BLAKE3SUMS.txt line 1 exceeds {MAX_MANIFEST_LINE_BYTES} byte limit") + ); } #[test] From 3d331a494062349aa1fbcb3eb6d0db7259d62ea5 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 07:23:50 +0400 Subject: [PATCH 14/20] feat(update): authenticate release publishers --- .github/workflows/release.yml | 27 + CHANGELOG.md | 15 + Cargo.lock | 510 +++++++++++++++++- Cargo.toml | 4 + README.md | 9 +- crates/astrid-cli/Cargo.toml | 1 + crates/astrid-cli/src/cli.rs | 6 +- crates/astrid-cli/src/commands/mod.rs | 1 + crates/astrid-cli/src/commands/self_update.rs | 311 +++++------ .../src/commands/self_update_tests.rs | 114 ++-- crates/astrid-cli/src/commands/update_auth.rs | 233 ++++++++ .../src/commands/update_auth_tests.rs | 262 +++++++++ .../self_update/v0.9.4/SHA256SUMS.txt | 4 + .../v0.9.4/SHA256SUMS.txt.sigstore.json | 1 + docs/self-update-security.md | 45 ++ 15 files changed, 1320 insertions(+), 223 deletions(-) create mode 100644 crates/astrid-cli/src/commands/update_auth.rs create mode 100644 crates/astrid-cli/src/commands/update_auth_tests.rs create mode 100644 crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt create mode 100644 crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt.sigstore.json create mode 100644 docs/self-update-security.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 614aaa411..37a7bc272 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -113,6 +113,7 @@ jobs: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 0 + submodules: recursive - name: Install Rust toolchain uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable @@ -225,6 +226,32 @@ jobs: cosign sign-blob --yes --bundle "${asset}.sigstore.json" "$asset" done + - name: Verify release signatures + run: | + cd artifacts + IDENTITY="https://github.com/astrid-runtime/astrid/.github/workflows/release.yml@refs/tags/${GITHUB_REF_NAME}" + ISSUER="https://token.actions.githubusercontent.com" + for asset in *.tar.gz BLAKE3SUMS.txt SHA256SUMS.txt; do + cosign verify-blob \ + --bundle "${asset}.sigstore.json" \ + --certificate-identity "$IDENTITY" \ + --certificate-oidc-issuer "$ISSUER" \ + --use-signed-timestamps \ + "$asset" + done + + - name: Verify release archives with the native updater policy + env: + ASTRID_RELEASE_GATE_ARTIFACTS: ${{ github.workspace }}/artifacts + ASTRID_RELEASE_GATE_VERSION: ${{ github.ref_name }} + CARGO_BUILD_JOBS: "1" + run: | + ASTRID_RELEASE_GATE_VERSION="${ASTRID_RELEASE_GATE_VERSION#v}" + export ASTRID_RELEASE_GATE_VERSION + cargo test -p astrid --bin astrid --locked \ + commands::update_auth::tests::release_gate_authenticates_all_archives_with_production_policy \ + -- --ignored --exact + - name: Create release uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7be76d366..ee1dd1893 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,21 @@ 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. +- **Self-managed updates now authenticate the release publisher before any + archive is extracted or installed.** The updater requires a Sigstore bundle + for the exact archive bytes, verifies it with fresh public-good trust + material, and pins the certificate to Astrid's release workflow, repository, + tag, and GitHub Actions issuer. Only an authenticated archive can enter the + independent BLAKE3 integrity stage; missing, duplicated, malformed, or + mismatched evidence fails closed with a distinct publisher-authentication or + integrity error. Homebrew and Cargo installs remain delegated to their + package managers, and the signed SHA-256 compatibility manifest remains + available to downstream tooling. Before publishing, release automation now + requires both Cosign and the updater's native production verifier to accept + every generated archive and bundle pair. Existing v0.9.x self-updaters cannot + enforce the new publisher policy retroactively; it applies from the first + release containing this updater onward. Closes #1250. + - **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; diff --git a/Cargo.lock b/Cargo.lock index 05992c563..f002359ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -171,7 +171,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -182,7 +182,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -349,13 +349,14 @@ dependencies = [ "rand 0.10.2", "ratatui", "regex", - "reqwest", + "reqwest 0.12.28", "rmcp", "rustyline", "semver", "serde", "serde_json", "serde_yaml", + "sigstore-verify", "syntect", "tar", "tempfile", @@ -475,7 +476,7 @@ dependencies = [ "notify", "parking_lot", "rand 0.10.2", - "reqwest", + "reqwest 0.12.28", "semver", "serde", "serde_json", @@ -670,7 +671,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-process", "rand 0.10.2", - "reqwest", + "reqwest 0.12.28", "rustls", "serde", "serde_json", @@ -745,7 +746,7 @@ dependencies = [ "futures", "nix 0.31.3", "rcgen", - "reqwest", + "reqwest 0.12.28", "serde_json", "tempfile", "tokio", @@ -1685,7 +1686,7 @@ dependencies = [ "maybe-owned", "rustix", "rustix-linux-procfs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "winx", ] @@ -1942,6 +1943,30 @@ dependencies = [ "cc", ] +[[package]] +name = "cmpv2" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" +dependencies = [ + "crmf", + "der", + "spki", + "x509-cert", +] + +[[package]] +name = "cms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "x509-cert", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1963,7 +1988,17 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", ] [[package]] @@ -2262,6 +2297,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crmf" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" +dependencies = [ + "cms", + "der", + "spki", + "x509-cert", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -2581,6 +2628,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", + "der_derive", + "flagset", "pem-rfc7468", "zeroize", ] @@ -2599,6 +2648,17 @@ dependencies = [ "rusticata-macros", ] +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "deranged" version = "0.5.8" @@ -2684,7 +2744,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3003,7 +3063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3196,6 +3256,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flatbuffers" version = "25.12.19" @@ -4633,6 +4699,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -5273,7 +5388,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5544,6 +5659,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -6262,6 +6383,7 @@ version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -6743,6 +6865,44 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "revision" version = "0.30.0" @@ -7054,7 +7214,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7083,6 +7243,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -7093,6 +7265,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.7.0", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -7150,6 +7349,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" + [[package]] name = "salsa20" version = "0.10.2" @@ -7168,6 +7373,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "1.2.1" @@ -7300,7 +7514,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -7367,6 +7581,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_json_canonicalizer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -7578,12 +7803,206 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "sigstore-bundle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5948e95f63e900fa92936ecf72c7f2251f83d27560a35a4aae560cc745f8b687" +dependencies = [ + "base64", + "hex", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-rekor", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-crypto" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f27364b37bec104a904f10a675c8cdf05d5e48a980569368f0cd0c119b2652" +dependencies = [ + "aws-lc-rs", + "base64", + "const-oid 0.9.6", + "der", + "digest 0.10.7", + "pem", + "rand_core 0.9.5", + "sha2 0.10.9", + "signature 2.2.0", + "sigstore-types", + "spki", + "thiserror 2.0.18", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-merkle" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee85fd9fb550efd7c8a59447cb0ef4eb02f1258f3ffa80c88d38427c3930af3" +dependencies = [ + "base64", + "hex", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-rekor" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b97c5a866f849a9445ae657bef0caa2db053a82827e6dae3a2b3de9c15a6a1a" +dependencies = [ + "base64", + "hex", + "reqwest 0.13.4", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-types", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "sigstore-trust-root" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389b54d1b8ace20ba86fdf90e5e655495f65f1abbc7d5d0eb7494defa9ad32f0" +dependencies = [ + "base64", + "directories", + "hex", + "jiff", + "rustls-pki-types", + "serde", + "serde_json", + "sigstore-crypto", + "sigstore-tuf", + "sigstore-types", + "thiserror 2.0.18", + "tokio", + "tracing", + "x509-cert", +] + +[[package]] +name = "sigstore-tsa" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58fe92d4d8cf4b7215b927b70bc1245b6e0d70d801febdfe0cd265ad97ebf8b" +dependencies = [ + "aws-lc-rs", + "base64", + "cmpv2", + "cms", + "const-oid 0.9.6", + "der", + "hex", + "jiff", + "rand 0.9.4", + "reqwest 0.13.4", + "rustls-pki-types", + "rustls-webpki", + "sigstore-crypto", + "sigstore-types", + "thiserror 2.0.18", + "tracing", + "x509-cert", + "x509-tsp", +] + +[[package]] +name = "sigstore-tuf" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eedac50883a917b7b434db22e2e6e853ace8c00f4a9c27f53e1e9c87e6d89fe4" +dependencies = [ + "globset", + "hex", + "jiff", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.10.9", + "sigstore-crypto", + "sigstore-types", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "sigstore-types" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "236474c535a3157839926a0ae18f9c0c22b151e49634da6e5b18ad7cac8a3b69" +dependencies = [ + "base64", + "hex", + "pem", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "sigstore-verify" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f71aad0e1c5925d29ae2024f55f0f8898a7ad450c93668f99086624c421e0" +dependencies = [ + "base64", + "cms", + "const-oid 0.9.6", + "hex", + "jiff", + "pem", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sigstore-bundle", + "sigstore-crypto", + "sigstore-merkle", + "sigstore-rekor", + "sigstore-trust-root", + "sigstore-tsa", + "sigstore-types", + "thiserror 2.0.18", + "tls_codec", + "tracing", + "x509-cert", +] + [[package]] name = "simd-adler32" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -7648,7 +8067,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8156,7 +8575,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -8188,7 +8607,7 @@ dependencies = [ "parking_lot", "rustix", "signal-hook 0.3.18", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8374,6 +8793,27 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "tokio" version = "1.52.3" @@ -8830,7 +9270,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9610,6 +10050,15 @@ dependencies = [ "string_cache_codegen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.8" @@ -9768,7 +10217,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -10276,6 +10725,20 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "sha1", + "signature 2.2.0", + "spki", + "tls_codec", +] + [[package]] name = "x509-parser" version = "0.18.1" @@ -10294,6 +10757,17 @@ dependencies = [ "time", ] +[[package]] +name = "x509-tsp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" +dependencies = [ + "cmpv2", + "cms", + "der", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index eabb3dcc9..20f55931c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -187,6 +187,10 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" sha2 = "0.11" +# Security-sensitive native verifier for keyless Sigstore bundles. Keep this +# exact-pinned: it sits on the self-update trust boundary, and its TUF support +# refreshes the public-good trust root without invoking an external verifier. +sigstore-verify = { version = "=0.11.0", default-features = false, features = ["rustls", "tuf"] } subtle = "2" surrealkv = "0.21" surrealdb = { version = "3.0.0-beta.3", default-features = false, features = ["kv-surrealkv", "kv-mem"] } diff --git a/README.md b/README.md index 846b224c7..40ee7d436 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ astrid start # persistent daemon (survives terminal close) astrid status # PID, uptime, connected clients, loaded capsules astrid ps # loaded capsules and their lifecycle state astrid stop # graceful shutdown -astrid update # download, verify, and stage the latest release (self-update alias) +astrid update # authenticate, verify, and install the latest release ``` ## Per-principal isolation @@ -307,7 +307,12 @@ cargo fmt --all -- --check All crates enforce `#![deny(unsafe_code)]` except `astrid-sys` and `astrid-sdk`, where WASM FFI requires it. Clippy runs at pedantic level and integer-overflow arithmetic is a lint error. Release binaries for macOS and Linux (x86_64 and aarch64) are built on tag push and signed with keyless -Sigstore attestations. +Sigstore. Self-managed updates authenticate the exact archive and its pinned Astrid release-workflow +identity before independently checking the BLAKE3 manifest and extracting any bytes. Homebrew and +Cargo remain responsible for updates they install; signed SHA-256 manifests remain available for +their compatibility requirements. GitHub build-provenance attestations are published as additional +evidence and are not substituted for the updater's release-archive signature. See the +[self-update security model](docs/self-update-security.md). ## Contributing diff --git a/crates/astrid-cli/Cargo.toml b/crates/astrid-cli/Cargo.toml index 0863d0479..4f87fc717 100644 --- a/crates/astrid-cli/Cargo.toml +++ b/crates/astrid-cli/Cargo.toml @@ -67,6 +67,7 @@ semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } +sigstore-verify = { workspace = true } syntect = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } diff --git a/crates/astrid-cli/src/cli.rs b/crates/astrid-cli/src/cli.rs index 74a954a35..25d4994a1 100644 --- a/crates/astrid-cli/src/cli.rs +++ b/crates/astrid-cli/src/cli.rs @@ -336,9 +336,9 @@ pub(crate) struct UpdateArgs { #[arg(long)] pub(crate) check: bool, - /// Override the release source as `owner/repo` — rehearse the update flow - /// against a fork or pre-release. (Env: `ASTRID_UPDATE_REPO`; API base: - /// `ASTRID_UPDATE_API`.) + /// Override release discovery as `owner/repo` for an official-asset mirror + /// or test server. This never overrides the required Astrid publisher. + /// (Env: `ASTRID_UPDATE_REPO`; API base: `ASTRID_UPDATE_API`.) #[arg(long, value_name = "OWNER/REPO")] pub(crate) source: Option, } diff --git a/crates/astrid-cli/src/commands/mod.rs b/crates/astrid-cli/src/commands/mod.rs index fa8e92eac..05f61e034 100644 --- a/crates/astrid-cli/src/commands/mod.rs +++ b/crates/astrid-cli/src/commands/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod setup; pub(crate) mod stub; pub(crate) mod top; pub(crate) mod trust; +mod update_auth; pub(crate) mod verb_suggest; pub(crate) mod version; pub(crate) mod voucher; diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index 1b2c7cc16..eabde6567 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -9,7 +9,8 @@ //! for every install method on both macOS and Linux, so the session-start nudge //! fires regardless of how Astrid was installed. The release source can be //! overridden (`--source owner/repo` / `ASTRID_UPDATE_REPO` / `ASTRID_UPDATE_API`) -//! so the whole flow can be rehearsed against a fork, pre-release, or local mock. +//! so metadata and downloads can be rehearsed against a mirror or local mock. +//! Publisher authentication remains pinned to Astrid's release workflow. //! //! Also provides PATH setup helpers for `astrid init`. @@ -21,8 +22,12 @@ use anyhow::{Context, bail}; use crate::cli::UpdateArgs; use crate::theme::Theme; -/// Default GitHub org/repo for the core Astrid release. Overridable for -/// staging/testing — see [`resolve_repo`]. +use super::update_auth::{ + UpdateStageError, authenticate_archive, extract_verified_archive, verify_integrity, +}; + +/// Default Astrid release repository. Discovery overrides never widen the +/// authenticated publisher identity. const DEFAULT_ORG: &str = "astrid-runtime"; const DEFAULT_REPO: &str = "astrid"; @@ -35,10 +40,11 @@ const CHECK_TTL_SECS: u64 = 86_400; /// Max size of a downloaded release archive. const MAX_ARCHIVE_BYTES: usize = 100 * 1024 * 1024; -/// Release manifest entries are a digest plus one archive filename. Bound each -/// entry independently so malformed manifests fail before parsing untrusted -/// line contents. -const MAX_MANIFEST_LINE_BYTES: usize = 1_024; +/// Separate caps keep metadata downloads far below the archive allowance. +const MAX_RELEASE_METADATA_BYTES: usize = 2 * 1024 * 1024; +const MAX_RELEASE_ASSETS: usize = 1_024; +const MAX_BUNDLE_BYTES: usize = 256 * 1024; +const MAX_MANIFEST_BYTES: usize = 256 * 1024; /// Binaries managed by an in-place update. const MANAGED_BINARIES: &[&str] = &["astrid", "astrid-daemon"]; @@ -49,9 +55,8 @@ fn api_base() -> String { std::env::var("ASTRID_UPDATE_API").unwrap_or_else(|_| "https://api.github.com".to_string()) } -/// Resolve the release source repo as `(owner, repo)`. Precedence: the explicit -/// `--source owner/repo`, then `ASTRID_UPDATE_REPO`, then the built-in default. -/// Lets the update flow be pointed at a fork or pre-release for staging/testing. +/// Resolve release discovery: explicit `--source`, environment, then default. +/// Mirrors and mocks must still serve archives signed by Astrid's exact identity. fn resolve_repo(source: Option<&str>) -> anyhow::Result<(String, String)> { let spec = source .map(str::to_owned) @@ -298,18 +303,22 @@ pub(crate) async fn check_for_update_cached() -> Option { .build() .ok()?; let url = format!("{}/repos/{owner}/{repo}/releases/latest", api_base()); - let response = client.get(&url).send().await.ok()?; - if !response.status().is_success() { - return None; - } - let json: serde_json::Value = response.json().await.ok()?; + let body = download_bounded( + &client, + &url, + MAX_RELEASE_METADATA_BYTES, + "release metadata", + ) + .await + .ok()?; + let json: serde_json::Value = serde_json::from_slice(&body).ok()?; let tag = json.get("tag_name")?.as_str()?; - let version_str = tag.strip_prefix('v').unwrap_or(tag); - write_cache(version_str); + let version_str = canonical_release_version(tag).ok()?; + write_cache(&version_str); let current = semver::Version::parse(CURRENT_VERSION).ok()?; - let latest = semver::Version::parse(version_str).ok()?; - (latest > current).then(|| version_str.to_string()) + let latest = semver::Version::parse(&version_str).ok()?; + (latest > current).then_some(version_str) } /// Print an install-aware update banner if a newer version is available. The @@ -339,123 +348,108 @@ async fn fetch_latest_release( repo: &str, ) -> anyhow::Result<(String, serde_json::Value)> { let url = format!("{}/repos/{owner}/{repo}/releases/latest", api_base()); - let response = client - .get(&url) - .send() - .await - .context("failed to reach GitHub API")?; - if !response.status().is_success() { - bail!("GitHub API returned {}", response.status()); - } - let json: serde_json::Value = response - .json() - .await - .context("failed to parse API response")?; + let body = + download_bounded(client, &url, MAX_RELEASE_METADATA_BYTES, "release metadata").await?; + let json: serde_json::Value = + serde_json::from_slice(&body).context("failed to parse release metadata")?; let tag = json .get("tag_name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("release has no tag_name"))?; - let version = tag.strip_prefix('v').unwrap_or(tag).to_string(); + let version = canonical_release_version(tag)?; Ok((version, json)) } -/// Find a release asset's browser download URL by exact name. -fn asset_url<'a>(release: &'a serde_json::Value, name: &str) -> Option<&'a str> { - release - .get("assets")? - .as_array()? +/// Parse the only tag form release authentication can bind to. +fn canonical_release_version(tag: &str) -> anyhow::Result { + let version = tag + .strip_prefix('v') + .ok_or_else(|| anyhow::anyhow!("release tag '{tag}' is not canonical v"))?; + let parsed = semver::Version::parse(version) + .with_context(|| format!("release tag '{tag}' is not valid semver"))?; + anyhow::ensure!( + tag == format!("v{parsed}"), + "release tag '{tag}' is not canonical v" + ); + Ok(parsed.to_string()) +} + +/// Find exactly one release asset and return its browser download URL. +fn exact_asset_url<'a>(release: &'a serde_json::Value, name: &str) -> anyhow::Result<&'a str> { + let assets = release + .get("assets") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| anyhow::anyhow!("release has no asset list"))?; + anyhow::ensure!( + assets.len() <= MAX_RELEASE_ASSETS, + "release contains too many assets" + ); + let mut matches = assets .iter() - .find(|a| a.get("name").and_then(|n| n.as_str()) == Some(name)) - .and_then(|a| a.get("browser_download_url").and_then(|u| u.as_str())) + .filter(|asset| asset.get("name").and_then(|value| value.as_str()) == Some(name)); + let asset = matches + .next() + .ok_or_else(|| anyhow::anyhow!("release has no asset '{name}'"))?; + anyhow::ensure!( + matches.next().is_none(), + "release contains duplicate asset '{name}'" + ); + asset + .get("browser_download_url") + .and_then(|value| value.as_str()) + .filter(|url| !url.is_empty()) + .ok_or_else(|| anyhow::anyhow!("release asset '{name}' has no download URL")) +} + +fn publisher_bundle_url<'a>( + release: &'a serde_json::Value, + archive_name: &str, +) -> Result<&'a str, UpdateStageError> { + let bundle_name = format!("{archive_name}.sigstore.json"); + exact_asset_url(release, &bundle_name) + .map_err(|_| UpdateStageError::publisher(format!("release has no unique '{bundle_name}'"))) +} + +fn integrity_manifest_url(release: &serde_json::Value) -> Result<&str, UpdateStageError> { + exact_asset_url(release, "BLAKE3SUMS.txt") + .map_err(|_| UpdateStageError::integrity("release has no unique BLAKE3SUMS.txt")) } /// Stream a URL into memory under the size cap. -async fn download(client: &reqwest::Client, url: &str) -> anyhow::Result> { - let mut response = client.get(url).send().await?; +async fn download_bounded( + client: &reqwest::Client, + url: &str, + limit: usize, + label: &str, +) -> anyhow::Result> { + let mut response = client + .get(url) + .send() + .await + .map_err(|_| anyhow::anyhow!("{label} download failed"))?; if !response.status().is_success() { - bail!("download failed: HTTP {}", response.status()); + bail!("{label} download failed: HTTP {}", response.status()); + } + if let Some(length) = response.content_length() { + let length = usize::try_from(length) + .map_err(|_| anyhow::anyhow!("{label} exceeds {limit} byte limit"))?; + anyhow::ensure!(length <= limit, "{label} exceeds {limit} byte limit"); } let mut bytes = Vec::new(); - while let Some(chunk) = response.chunk().await? { - bytes.extend_from_slice(&chunk); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| anyhow::anyhow!("{label} download failed"))? + { anyhow::ensure!( - bytes.len() <= MAX_ARCHIVE_BYTES, - "release archive exceeds {MAX_ARCHIVE_BYTES} byte limit" + chunk.len() <= limit.saturating_sub(bytes.len()), + "{label} exceeds {limit} byte limit" ); + bytes.extend_from_slice(&chunk); } Ok(bytes) } -/// Verify `archive` against the BLAKE3 digest recorded for `asset_name` in a -/// `BLAKE3SUMS.txt` body (` ` per line). This is INTEGRITY only — -/// it catches a corrupt/truncated/MITM-altered download whose checksum no longer -/// matches the release's recorded sum. It is NOT authenticity (an attacker who -/// controls the release controls both the artifact and the sum); a publisher -/// signature is tracked separately. -fn verify_blake3(archive: &[u8], sums_body: &str, asset_name: &str) -> anyhow::Result<()> { - let mut expected = None; - let mut seen_assets = std::collections::HashSet::new(); - - for (index, line) in sums_body.lines().enumerate() { - let line_number = index - .checked_add(1) - .context("BLAKE3SUMS.txt line count exceeds usize")?; - anyhow::ensure!( - line.len() <= MAX_MANIFEST_LINE_BYTES, - "BLAKE3SUMS.txt line {line_number} exceeds {MAX_MANIFEST_LINE_BYTES} byte limit" - ); - let (hex, name) = line.split_once(" ").ok_or_else(|| { - anyhow::anyhow!( - "malformed BLAKE3SUMS.txt line {line_number}: expected ' '" - ) - })?; - anyhow::ensure!( - hex.len() == 64 - && hex - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), - "malformed BLAKE3 digest on line {line_number}: expected 64 lowercase hex characters" - ); - anyhow::ensure!( - !name.is_empty() && !name.bytes().any(|byte| byte.is_ascii_whitespace()), - "malformed BLAKE3SUMS.txt asset name on line {line_number}" - ); - anyhow::ensure!( - seen_assets.insert(name), - "duplicate checksum for '{name}' in BLAKE3SUMS.txt" - ); - - let digest = blake3::Hash::from_hex(hex) - .with_context(|| format!("invalid BLAKE3 digest on line {line_number}"))?; - if name == asset_name { - expected = Some(digest); - } - } - - let expected = expected - .ok_or_else(|| anyhow::anyhow!("no checksum for '{asset_name}' in BLAKE3SUMS.txt"))?; - let actual = blake3::hash(archive); - if actual != expected { - bail!( - "checksum mismatch for '{asset_name}': expected {}, got {}", - expected.to_hex(), - actual.to_hex() - ); - } - Ok(()) -} - -/// Require the canonical BLAKE3 manifest from a release. A release containing -/// only the legacy SHA-256 manifest fails closed; release automation retains a -/// compatibility manifest so existing v0.9.x clients can cross this boundary. -fn blake3_sums_url(release: &serde_json::Value) -> anyhow::Result<&str> { - asset_url(release, "BLAKE3SUMS.txt").ok_or_else(|| { - anyhow::anyhow!( - "release has no BLAKE3SUMS.txt — refusing to install an unverifiable binary" - ) - }) -} - /// Back up and atomically swap the named binaries from `extract_dir` into /// `install_dir`. /// @@ -651,9 +645,10 @@ pub(crate) async fn run_self_update(args: UpdateArgs) -> anyhow::Result<()> { return Ok(()); } - // Stage: download → verify checksum → extract. - let (_tmp_dir, extract_dir) = - download_verify_extract(&client, &release, &version_str, target).await?; + // Stage: download → authenticate publisher → verify integrity → extract. + let (_tmp_dir, extract_dir) = download_verify_extract(&client, &release, &version_str, target) + .await + .map_err(anyhow::Error::new)?; // Finish: back up + atomically swap (rolls back on any failure). backup_and_swap(&install_dir, &extract_dir, MANAGED_BINARIES)?; @@ -668,50 +663,60 @@ pub(crate) async fn run_self_update(args: UpdateArgs) -> anyhow::Result<()> { finish_update(&install_dir).await } -/// Download the release archive for `version`/`target`, verify its checksum, and -/// extract it. Returns the temp dir (kept alive by the caller for the lifetime -/// of `extract_dir`) and the extracted `astrid--/` directory. +/// Download the release archive for `version`/`target`, authenticate its +/// publisher, independently verify its BLAKE3 integrity, and only then extract +/// it. Returns the temp dir (kept alive by the caller for the lifetime of +/// `extract_dir`) and the extracted `astrid--/` directory. async fn download_verify_extract( client: &reqwest::Client, release: &serde_json::Value, version: &str, target: &str, -) -> anyhow::Result<(tempfile::TempDir, PathBuf)> { +) -> Result<(tempfile::TempDir, PathBuf), UpdateStageError> { println!( "{}", Theme::info(&format!("Downloading v{version} for {target}...")) ); let asset_name = format!("astrid-{version}-{target}.tar.gz"); - let url = asset_url(release, &asset_name) - .ok_or_else(|| { - anyhow::anyhow!( - "no release asset '{asset_name}' — no pre-built binary for this platform" - ) - })? - .to_string(); - let archive = download(client, &url).await?; - - // Fail closed: a release with no BLAKE3SUMS.txt is unverifiable, so we refuse - // to install it rather than swap in an unchecked binary. (BLAKE3SUMS is - // integrity, not authenticity — but skipping it entirely would defeat even - // the on-the-wire / corrupted-download check.) - let sums_url = blake3_sums_url(release)?.to_owned(); - let sums = download(client, &sums_url).await?; - let sums_body = String::from_utf8(sums).context("BLAKE3SUMS.txt is not UTF-8")?; - verify_blake3(&archive, &sums_body, &asset_name)?; - println!("{}", Theme::dimmed("Checksum verified.")); - - let tmp_dir = tempfile::tempdir()?; - let archive_path = tmp_dir.path().join(&asset_name); - std::fs::write(&archive_path, &archive)?; - { - let tar_gz = std::fs::File::open(&archive_path)?; - let decoder = flate2::read::GzDecoder::new(tar_gz); - let mut tar = tar::Archive::new(decoder); - tar.unpack(tmp_dir.path())?; - } - let extract_dir = tmp_dir.path().join(format!("astrid-{version}-{target}")); - Ok((tmp_dir, extract_dir)) + let archive_url = exact_asset_url(release, &asset_name) + .with_context(|| format!("no pre-built binary for platform {target}")) + .map_err(UpdateStageError::Preparation)? + .to_owned(); + let archive = download_bounded(client, &archive_url, MAX_ARCHIVE_BYTES, "release archive") + .await + .map_err(UpdateStageError::Preparation)?; + + let bundle_url = publisher_bundle_url(release, &asset_name)?.to_owned(); + let bundle = download_bounded( + client, + &bundle_url, + MAX_BUNDLE_BYTES, + "publisher-authentication bundle", + ) + .await + .map_err(|_| UpdateStageError::publisher("could not download Sigstore bundle"))?; + let authenticated = authenticate_archive(archive, &bundle, version).await?; + println!("{}", Theme::dimmed("Publisher authenticated.")); + + // This remains a separate integrity signal. It neither replaces nor is + // presented as publisher authentication. + let sums_url = integrity_manifest_url(release)?.to_owned(); + let sums = download_bounded( + client, + &sums_url, + MAX_MANIFEST_BYTES, + "BLAKE3 integrity manifest", + ) + .await + .map_err(|_| UpdateStageError::integrity("could not download BLAKE3SUMS.txt"))?; + let sums_body = String::from_utf8(sums) + .map_err(|_| UpdateStageError::integrity("BLAKE3SUMS.txt is not UTF-8"))?; + let archive = verify_integrity(authenticated, &sums_body, &asset_name)?; + println!("{}", Theme::dimmed("Integrity verified.")); + + // The mutating boundary accepts only the fully verified archive type. + extract_verified_archive(archive, &asset_name, &format!("astrid-{version}-{target}")) + .map_err(UpdateStageError::Preparation) } /// After the binary swap: restart a running daemon so the new code takes effect, diff --git a/crates/astrid-cli/src/commands/self_update_tests.rs b/crates/astrid-cli/src/commands/self_update_tests.rs index 5abca239e..589a8d89c 100644 --- a/crates/astrid-cli/src/commands/self_update_tests.rs +++ b/crates/astrid-cli/src/commands/self_update_tests.rs @@ -229,65 +229,85 @@ fn resolve_repo_precedence_and_validation() { } #[test] -fn blake3_verification_matches_independent_vector() { - let archive = b"hello astrid"; - // Independently generated with `printf %s 'hello astrid' | b3sum --no-names`. - let good = "41251d32ddff968c23c1c83c4e7b3af8d6fef8912b4806aadec90e106a629fef"; - assert_eq!(blake3::hash(archive).to_hex().as_str(), good); - let body = format!("{good} astrid-1.0.0-x.tar.gz\n"); - verify_blake3(archive, &body, "astrid-1.0.0-x.tar.gz").expect("matching sum verifies"); - - let asset = "astrid-1.0.0-x.tar.gz"; - - // Wrong sum -> error. - let bad_body = format!("{} {asset}\n", "0".repeat(64)); - assert!(verify_blake3(archive, &bad_body, asset).is_err()); - // Missing entry -> error. - let other = format!("{} other.tar.gz\n", "0".repeat(64)); - assert!(verify_blake3(archive, &other, asset).is_err()); - // Malformed length, non-hex and uppercase encodings are rejected. - assert!(verify_blake3(archive, &format!("{} {asset}\n", "a".repeat(63)), asset).is_err()); - assert!(verify_blake3(archive, &format!("{}g {asset}\n", "a".repeat(63)), asset).is_err()); - assert!( - verify_blake3( - archive, - &format!("{} {asset}\n", good.to_uppercase()), - asset - ) - .is_err() - ); - // A matching checksum cannot be shadowed by a duplicate entry. - let duplicate = format!("{good} {asset}\n{good} {asset}\n"); - assert!(verify_blake3(archive, &duplicate, asset).is_err()); - // Manifest validity is platform-independent: duplicates for a different - // release asset are rejected as well. - let duplicate_other = format!( - "{good} {asset}\n{} other.tar.gz\n{} other.tar.gz\n", - "0".repeat(64), - "1".repeat(64) +fn release_tags_are_canonical_and_identity_safe() { + assert_eq!(canonical_release_version("v1.2.3").unwrap(), "1.2.3"); + assert_eq!( + canonical_release_version("v1.2.3-rc.1").unwrap(), + "1.2.3-rc.1" ); - assert!(verify_blake3(archive, &duplicate_other, asset).is_err()); + for invalid in ["1.2.3", "vv1.2.3", "v01.2.3", "v1.2", "v1.2.3-01"] { + assert!( + canonical_release_version(invalid).is_err(), + "unexpectedly accepted {invalid}" + ); + } +} - let overlong = "a".repeat(MAX_MANIFEST_LINE_BYTES + 1); - let error = verify_blake3(archive, &overlong, asset) - .unwrap_err() - .to_string(); +#[test] +fn release_asset_lookup_requires_one_exact_asset() { + let release = serde_json::json!({ + "assets": [ + { + "name": "astrid-1.0.0-x.tar.gz", + "browser_download_url": "https://example.com/archive" + }, + { + "name": "astrid-1.0.0-x.tar.gz.sigstore.json", + "browser_download_url": "https://example.com/bundle" + }, + { + "name": "BLAKE3SUMS.txt", + "browser_download_url": "https://example.com/sums" + } + ] + }); assert_eq!( - error, - format!("BLAKE3SUMS.txt line 1 exceeds {MAX_MANIFEST_LINE_BYTES} byte limit") + exact_asset_url(&release, "astrid-1.0.0-x.tar.gz").unwrap(), + "https://example.com/archive" + ); + assert!(exact_asset_url(&release, "astrid-1.0.0-y.tar.gz").is_err()); + + let duplicate = serde_json::json!({ + "assets": [ + { + "name": "BLAKE3SUMS.txt", + "browser_download_url": "https://example.com/one" + }, + { + "name": "BLAKE3SUMS.txt", + "browser_download_url": "https://example.com/two" + } + ] + }); + assert!(exact_asset_url(&duplicate, "BLAKE3SUMS.txt").is_err()); + + let oversized = serde_json::json!({ + "assets": vec![serde_json::json!({"name": "irrelevant"}); MAX_RELEASE_ASSETS + 1] + }); + assert!( + exact_asset_url(&oversized, "irrelevant") + .unwrap_err() + .to_string() + .contains("too many assets") ); } #[test] -fn legacy_sha_only_release_is_not_accepted() { - let release = serde_json::json!({ +fn publisher_bundle_and_blake3_manifest_are_both_mandatory() { + let sha_only = serde_json::json!({ "assets": [{ "name": "SHA256SUMS.txt", "browser_download_url": "https://example.com/SHA256SUMS.txt" }] }); - let error = blake3_sums_url(&release).unwrap_err().to_string(); - assert!(error.contains("release has no BLAKE3SUMS.txt")); + assert!(matches!( + integrity_manifest_url(&sha_only).unwrap_err(), + UpdateStageError::Integrity(_) + )); + assert!(matches!( + publisher_bundle_url(&sha_only, "astrid-1.0.0-x.tar.gz").unwrap_err(), + UpdateStageError::PublisherAuthentication(_) + )); } #[test] diff --git a/crates/astrid-cli/src/commands/update_auth.rs b/crates/astrid-cli/src/commands/update_auth.rs new file mode 100644 index 000000000..b3f3a6e46 --- /dev/null +++ b/crates/astrid-cli/src/commands/update_auth.rs @@ -0,0 +1,233 @@ +//! Publisher authentication and integrity stages for self-managed updates. +//! +//! A release archive is not extractable until it has crossed both typed +//! boundaries in order: Sigstore authenticates the exact bytes and publisher, +//! then the release's BLAKE3 manifest independently checks transport integrity. + +use std::collections::HashSet; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use sigstore_verify::trust_root::{TrustedRoot, TufConfig}; +use sigstore_verify::types::Bundle; +use sigstore_verify::{VerificationPolicy, verify}; + +const GITHUB_ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com"; +const TRUST_ROOT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub(super) struct PublisherAuthenticationFailure(String); + +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub(super) struct IntegrityFailure(String); + +/// Matchable self-update failure classes. Stage details remain internal while +/// the CLI receives a normal error only after the typed staging pipeline ends. +#[derive(Debug, thiserror::Error)] +pub(super) enum UpdateStageError { + #[error("publisher authentication failed: {0}")] + PublisherAuthentication(#[source] PublisherAuthenticationFailure), + #[error("integrity check failed: {0}")] + Integrity(#[source] IntegrityFailure), + #[error(transparent)] + Preparation(#[from] anyhow::Error), +} + +impl UpdateStageError { + pub(super) fn publisher(message: impl Into) -> Self { + Self::PublisherAuthentication(PublisherAuthenticationFailure(message.into())) + } + + pub(super) fn integrity(message: impl Into) -> Self { + Self::Integrity(IntegrityFailure(message.into())) + } +} + +/// Archive bytes whose Sigstore bundle authenticated the exact Astrid release +/// workflow and tag. Construction is private to this module. +#[derive(Debug)] +pub(super) struct PublisherAuthenticatedArchive(Vec); + +/// Archive bytes that also match their strict BLAKE3 release-manifest entry. +/// Extraction accepts this type rather than unverified bytes. +#[derive(Debug)] +pub(super) struct IntegrityVerifiedArchive(Vec); + +#[cfg(test)] +impl IntegrityVerifiedArchive { + fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +/// The one accepted keyless certificate identity for an Astrid release. +fn release_identity(version: &str) -> String { + format!( + "https://github.com/astrid-runtime/astrid/.github/workflows/release.yml@refs/tags/v{version}" + ) +} + +fn parse_bundle(bundle_json: &[u8]) -> anyhow::Result { + let text = std::str::from_utf8(bundle_json).context("bundle is not UTF-8")?; + Bundle::from_json(text).context("bundle JSON is invalid") +} + +fn parse_publisher_bundle(bundle_json: &[u8]) -> Result { + parse_bundle(bundle_json).map_err(|_| UpdateStageError::publisher("malformed Sigstore bundle")) +} + +fn verify_with_root( + archive: &[u8], + bundle: &Bundle, + version: &str, + root: &TrustedRoot, +) -> anyhow::Result<()> { + let policy = VerificationPolicy::default() + .require_identity(release_identity(version)) + .require_issuer(GITHUB_ACTIONS_ISSUER); + verify(archive, bundle, &policy, root) + .map(|_| ()) + .context("Sigstore evidence did not satisfy the release policy") +} + +#[cfg(test)] +fn authenticate_for_test( + archive: Vec, + bundle_json: &[u8], + identity: &str, + issuer: &str, + root: &TrustedRoot, +) -> Result { + let bundle = parse_publisher_bundle(bundle_json)?; + let policy = VerificationPolicy::default() + .require_identity(identity) + .require_issuer(issuer); + verify(&archive, &bundle, &policy, root) + .map_err(|_| UpdateStageError::publisher("archive signature or identity did not verify"))?; + Ok(PublisherAuthenticatedArchive(archive)) +} + +/// Authenticate exact archive bytes against fresh, TUF-verified Sigstore +/// public-good trust material. There is deliberately no stale/offline fallback +/// and no caller-supplied identity, issuer, or trust-root override. +pub(super) async fn authenticate_archive( + archive: Vec, + bundle_json: &[u8], + version: &str, +) -> Result { + let bundle = parse_publisher_bundle(bundle_json)?; + + let config = TufConfig::production().without_cache(); + let root = tokio::time::timeout(TRUST_ROOT_TIMEOUT, TrustedRoot::from_tuf(config)) + .await + .map_err(|_| UpdateStageError::publisher("Sigstore trust refresh timed out"))? + .map_err(|_| UpdateStageError::publisher("Sigstore trust refresh failed"))?; + + verify_with_root(&archive, &bundle, version, &root).map_err(|_| { + UpdateStageError::publisher("archive signature or exact release identity did not verify") + })?; + + Ok(PublisherAuthenticatedArchive(archive)) +} + +/// Verify the authenticated archive against the one canonical BLAKE3 entry for +/// `asset_name`. Manifest parsing is global: malformed or duplicate entries for +/// another platform invalidate the release too. +pub(super) fn verify_integrity( + archive: PublisherAuthenticatedArchive, + sums_body: &str, + asset_name: &str, +) -> Result { + let mut expected = None; + let mut seen_assets = HashSet::new(); + + for (index, line) in sums_body.lines().enumerate() { + let line_number = index + .checked_add(1) + .ok_or_else(|| UpdateStageError::integrity("BLAKE3SUMS.txt contains too many lines"))?; + let (hex, name) = line.split_once(" ").ok_or_else(|| { + UpdateStageError::integrity(format!( + "malformed BLAKE3SUMS.txt line {line_number}: expected ' '" + )) + })?; + if hex.len() != 64 + || !hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(UpdateStageError::integrity(format!( + "malformed BLAKE3 digest on line {line_number}: expected 64 lowercase hex characters" + ))); + } + if name.is_empty() || name.bytes().any(|byte| byte.is_ascii_whitespace()) { + return Err(UpdateStageError::integrity(format!( + "malformed BLAKE3SUMS.txt asset name on line {line_number}" + ))); + } + if !seen_assets.insert(name) { + return Err(UpdateStageError::integrity(format!( + "duplicate checksum for '{name}' in BLAKE3SUMS.txt" + ))); + } + + let digest = blake3::Hash::from_hex(hex).map_err(|_| { + UpdateStageError::integrity(format!("invalid BLAKE3 digest on line {line_number}")) + })?; + if name == asset_name { + expected = Some(digest); + } + } + + let expected = expected.ok_or_else(|| { + UpdateStageError::integrity(format!("no checksum for '{asset_name}' in BLAKE3SUMS.txt")) + })?; + let actual = blake3::hash(&archive.0); + if actual != expected { + return Err(UpdateStageError::integrity(format!( + "checksum mismatch for '{asset_name}': expected {}, got {}", + expected.to_hex(), + actual.to_hex() + ))); + } + + Ok(IntegrityVerifiedArchive(archive.0)) +} + +/// Extract bytes only after publisher authentication and integrity verification +/// have both succeeded. +pub(super) fn extract_verified_archive( + archive: IntegrityVerifiedArchive, + asset_name: &str, + extracted_dir_name: &str, +) -> anyhow::Result<(tempfile::TempDir, PathBuf)> { + extract_verified_archive_with(archive, asset_name, extracted_dir_name, tempfile::tempdir) +} + +fn extract_verified_archive_with( + archive: IntegrityVerifiedArchive, + asset_name: &str, + extracted_dir_name: &str, + make_temp_dir: F, +) -> anyhow::Result<(tempfile::TempDir, PathBuf)> +where + F: FnOnce() -> std::io::Result, +{ + let IntegrityVerifiedArchive(archive_bytes) = archive; + let tmp_dir = make_temp_dir()?; + let archive_path = tmp_dir.path().join(asset_name); + std::fs::write(&archive_path, archive_bytes)?; + let tar_gz = std::fs::File::open(&archive_path)?; + let decoder = flate2::read::GzDecoder::new(tar_gz); + let mut tar = tar::Archive::new(decoder); + tar.unpack(tmp_dir.path())?; + + let extract_dir = tmp_dir.path().join(extracted_dir_name); + Ok((tmp_dir, extract_dir)) +} + +#[cfg(test)] +#[path = "update_auth_tests.rs"] +mod tests; diff --git a/crates/astrid-cli/src/commands/update_auth_tests.rs b/crates/astrid-cli/src/commands/update_auth_tests.rs new file mode 100644 index 000000000..c550f703d --- /dev/null +++ b/crates/astrid-cli/src/commands/update_auth_tests.rs @@ -0,0 +1,262 @@ +use sigstore_verify::trust_root::{SigstoreInstance, TrustedRoot}; + +use super::*; + +const LEGACY_IDENTITY: &str = + "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/tags/v0.9.4"; +const FIXTURE_BYTES: &[u8] = + include_bytes!("../../tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt"); +const FIXTURE_BUNDLE: &[u8] = + include_bytes!("../../tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt.sigstore.json"); + +fn fixture_root() -> TrustedRoot { + TrustedRoot::from_embedded(SigstoreInstance::PublicGood) + .expect("embedded public-good root parses") +} + +fn verify_fixture(identity: &str, issuer: &str, bytes: &[u8]) -> anyhow::Result<()> { + let bundle = parse_bundle(FIXTURE_BUNDLE)?; + let policy = VerificationPolicy::default() + .require_identity(identity) + .require_issuer(issuer); + verify(bytes, &bundle, &policy, &fixture_root()) + .map(|_| ()) + .context("fixture verification failed") +} + +#[test] +fn release_identity_is_exact_and_tag_bound() { + assert_eq!( + release_identity("1.2.3"), + "https://github.com/astrid-runtime/astrid/.github/workflows/release.yml@refs/tags/v1.2.3" + ); +} + +#[test] +fn real_bundle_verifies_with_its_historical_identity_and_signed_time() { + // The leaf certificate expired ten minutes after v0.9.4 was signed. A + // successful verification now proves the bundle's Rekor/TSA evidence is + // used as signing time rather than incorrectly requiring a live leaf cert. + verify_fixture(LEGACY_IDENTITY, GITHUB_ACTIONS_ISSUER, FIXTURE_BYTES) + .expect("authentic historical bundle verifies"); +} + +#[test] +fn production_identity_rejects_the_pre_rename_release() { + let error = authenticate_for_test( + FIXTURE_BYTES.to_vec(), + FIXTURE_BUNDLE, + &release_identity("0.9.4"), + GITHUB_ACTIONS_ISSUER, + &fixture_root(), + ) + .unwrap_err(); + assert!(matches!( + error, + UpdateStageError::PublisherAuthentication(_) + )); +} + +#[test] +fn modified_bytes_fail_publisher_authentication() { + let mut modified = FIXTURE_BYTES.to_vec(); + modified.extend_from_slice(b"modified"); + let error = authenticate_for_test( + modified, + FIXTURE_BUNDLE, + LEGACY_IDENTITY, + GITHUB_ACTIONS_ISSUER, + &fixture_root(), + ) + .unwrap_err(); + assert!(matches!( + error, + UpdateStageError::PublisherAuthentication(_) + )); +} + +#[test] +fn rejected_evidence_cannot_reach_filesystem_mutation() { + let production_identity = release_identity("0.9.4"); + let cases: [(Vec, &str); 2] = [ + ( + { + let mut modified = FIXTURE_BYTES.to_vec(); + modified.extend_from_slice(b"modified"); + modified + }, + LEGACY_IDENTITY, + ), + (FIXTURE_BYTES.to_vec(), production_identity.as_str()), + ]; + + for (bytes, identity) in cases { + let mutation_root = tempfile::tempdir().expect("create mutation sentinel"); + let digest = blake3::hash(&bytes); + let result = (|| -> Result<_, UpdateStageError> { + let authenticated = authenticate_for_test( + bytes, + FIXTURE_BUNDLE, + identity, + GITHUB_ACTIONS_ISSUER, + &fixture_root(), + )?; + let sums = format!("{digest} archive.tar.gz\n"); + let verified = verify_integrity(authenticated, &sums, "archive.tar.gz")?; + extract_verified_archive_with(verified, "archive.tar.gz", "archive", || { + tempfile::Builder::new() + .prefix("extraction-") + .tempdir_in(mutation_root.path()) + }) + .map_err(UpdateStageError::Preparation) + })(); + + assert!(matches!( + result.unwrap_err(), + UpdateStageError::PublisherAuthentication(_) + )); + assert_eq!( + std::fs::read_dir(mutation_root.path()).unwrap().count(), + 0, + "publisher rejection must happen before any temp directory or archive is written" + ); + } +} + +#[test] +fn wrong_repository_workflow_tag_and_issuer_fail() { + let wrong_values = [ + ( + "https://github.com/other/astrid/.github/workflows/release.yml@refs/tags/v0.9.4", + GITHUB_ACTIONS_ISSUER, + ), + ( + "https://github.com/unicity-astrid/astrid/.github/workflows/other.yml@refs/tags/v0.9.4", + GITHUB_ACTIONS_ISSUER, + ), + ( + "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/tags/v0.9.5", + GITHUB_ACTIONS_ISSUER, + ), + ( + "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/heads/main", + GITHUB_ACTIONS_ISSUER, + ), + ( + "https://github.com/unicity-astrid/astrid/.github/workflows/release.yml@refs/pull/1250/merge", + GITHUB_ACTIONS_ISSUER, + ), + (LEGACY_IDENTITY, "https://issuer.example.invalid"), + ]; + + for (identity, issuer) in wrong_values { + assert!( + verify_fixture(identity, issuer, FIXTURE_BYTES).is_err(), + "unexpectedly accepted identity={identity} issuer={issuer}" + ); + } +} + +#[test] +fn malformed_bundle_is_rejected() { + assert!(matches!( + parse_publisher_bundle(b"{not-json").unwrap_err(), + UpdateStageError::PublisherAuthentication(_) + )); +} + +#[test] +fn integrity_stage_is_strict_and_consumes_authenticated_bytes() { + let asset = "astrid-1.0.0-x86_64-unknown-linux-gnu.tar.gz"; + let digest = blake3::hash(b"archive"); + let body = format!("{} {asset}\n", digest.to_hex()); + let authenticated = PublisherAuthenticatedArchive(b"archive".to_vec()); + let verified = verify_integrity(authenticated, &body, asset).expect("matching digest"); + assert_eq!(verified.as_bytes(), b"archive"); + + let bad = PublisherAuthenticatedArchive(b"modified".to_vec()); + assert!(matches!( + verify_integrity(bad, &body, asset).unwrap_err(), + UpdateStageError::Integrity(_) + )); +} + +#[test] +fn integrity_manifest_rejects_noncanonical_and_duplicate_entries() { + let asset = "astrid-1.0.0-x86_64-unknown-linux-gnu.tar.gz"; + let digest = blake3::hash(b"archive").to_hex(); + let uppercase = format!("{} {asset}\n", digest.to_string().to_uppercase()); + assert!(matches!( + verify_integrity( + PublisherAuthenticatedArchive(b"archive".to_vec()), + &uppercase, + asset + ) + .unwrap_err(), + UpdateStageError::Integrity(_) + )); + + let duplicate = format!("{digest} {asset}\n{digest} {asset}\n"); + assert!(matches!( + verify_integrity( + PublisherAuthenticatedArchive(b"archive".to_vec()), + &duplicate, + asset + ) + .unwrap_err(), + UpdateStageError::Integrity(_) + )); +} + +#[tokio::test] +#[ignore = "release workflow only: requires freshly signed archives and live TUF metadata"] +async fn release_gate_authenticates_all_archives_with_production_policy() { + let artifacts = std::path::PathBuf::from( + std::env::var_os("ASTRID_RELEASE_GATE_ARTIFACTS") + .expect("ASTRID_RELEASE_GATE_ARTIFACTS must name the release artifact directory"), + ); + let version = std::env::var("ASTRID_RELEASE_GATE_VERSION") + .expect("ASTRID_RELEASE_GATE_VERSION must contain canonical semver without v"); + let parsed = semver::Version::parse(&version).expect("release gate version must be semver"); + assert_eq!( + version, + parsed.to_string(), + "release gate version is not canonical" + ); + + let mut archives = std::fs::read_dir(&artifacts) + .expect("read release artifact directory") + .map(|entry| entry.expect("read release artifact entry").path()) + .filter(|path| { + path.file_name() + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|name| name.ends_with(".tar.gz")) + }) + .collect::>(); + archives.sort(); + assert!(!archives.is_empty(), "release gate found no archives"); + + for archive_path in archives { + let archive_name = archive_path + .file_name() + .and_then(std::ffi::OsStr::to_str) + .expect("release archive name must be UTF-8"); + let bundle_path = archive_path.with_file_name(format!("{archive_name}.sigstore.json")); + let archive = std::fs::read(&archive_path).expect("read release archive"); + let bundle = std::fs::read(&bundle_path).unwrap_or_else(|error| { + panic!( + "read native-verifier bundle {}: {error}", + bundle_path.display() + ) + }); + + authenticate_archive(archive, &bundle, &version) + .await + .unwrap_or_else(|error| { + panic!( + "native verifier rejected {} after Cosign accepted it: {error}", + archive_path.display() + ) + }); + } +} diff --git a/crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt b/crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt new file mode 100644 index 000000000..052449252 --- /dev/null +++ b/crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt @@ -0,0 +1,4 @@ +72f336cb43c40598d43550422883ebdbc23de3604c180128ea365d6c11e95bf5 astrid-0.9.4-aarch64-apple-darwin.tar.gz +976014a7ade7b03d7286143440128ba8bae9f73976fba27b6ea77e0b55ae8bfc astrid-0.9.4-aarch64-unknown-linux-gnu.tar.gz +2110edd2d5b59d456e2f1ead9fe35e68040d52049d417179ff2a7f6654552b06 astrid-0.9.4-x86_64-apple-darwin.tar.gz +2a56f6a0e194a25f4eb72cefc556a4317cfb5f30204a80f2c18a4b7cc2a78f04 astrid-0.9.4-x86_64-unknown-linux-gnu.tar.gz diff --git a/crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt.sigstore.json b/crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt.sigstore.json new file mode 100644 index 000000000..892a98b30 --- /dev/null +++ b/crates/astrid-cli/tests/fixtures/self_update/v0.9.4/SHA256SUMS.txt.sigstore.json @@ -0,0 +1 @@ +{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json", "verificationMaterial":{"certificate":{"rawBytes":"MIIHITCCBqegAwIBAgIUHqfp3lwCeTWy7slxY6wadD9lePswCgYIKoZIzj0EAwMwNzEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MR4wHAYDVQQDExVzaWdzdG9yZS1pbnRlcm1lZGlhdGUwHhcNMjYwNzA5MjAyNDM3WhcNMjYwNzA5MjAzNDM3WjAAMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEk/xwSDfmtqDENrGyslQXrcnURCJ8K6Zw6TsAiZImbkh3see1wyWYNBf/PgBQBr2NQelAtSENin+kpR3r+Q8B0qOCBcYwggXCMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAdBgNVHQ4EFgQUTza4Yr3b7NOjfGJgnETWQxojzaQwHwYDVR0jBBgwFoAU39Ppz1YkEZb5qNjpKFWixi4YZD8wZQYDVR0RAQH/BFswWYZXaHR0cHM6Ly9naXRodWIuY29tL3VuaWNpdHktYXN0cmlkL2FzdHJpZC8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjAuOS40MDkGCisGAQQBg78wAQEEK2h0dHBzOi8vdG9rZW4uYWN0aW9ucy5naXRodWJ1c2VyY29udGVudC5jb20wEgYKKwYBBAGDvzABAgQEcHVzaDA2BgorBgEEAYO/MAEDBChkY2RlNmY2OTUxZTQwMDI5YjcyZDk1NmI1ODdmNjliZWUxYjVkNDNkMBUGCisGAQQBg78wAQQEB1JlbGVhc2UwIwYKKwYBBAGDvzABBQQVdW5pY2l0eS1hc3RyaWQvYXN0cmlkMB4GCisGAQQBg78wAQYEEHJlZnMvdGFncy92MC45LjQwOwYKKwYBBAGDvzABCAQtDCtodHRwczovL3Rva2VuLmFjdGlvbnMuZ2l0aHVidXNlcmNvbnRlbnQuY29tMGcGCisGAQQBg78wAQkEWQxXaHR0cHM6Ly9naXRodWIuY29tL3VuaWNpdHktYXN0cmlkL2FzdHJpZC8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjAuOS40MDgGCisGAQQBg78wAQoEKgwoZGNkZTZmNjk1MWU0MDAyOWI3MmQ5NTZiNTg3ZjY5YmVlMWI1ZDQzZDAdBgorBgEEAYO/MAELBA8MDWdpdGh1Yi1ob3N0ZWQwOAYKKwYBBAGDvzABDAQqDChodHRwczovL2dpdGh1Yi5jb20vdW5pY2l0eS1hc3RyaWQvYXN0cmlkMDgGCisGAQQBg78wAQ0EKgwoZGNkZTZmNjk1MWU0MDAyOWI3MmQ5NTZiNTg3ZjY5YmVlMWI1ZDQzZDAgBgorBgEEAYO/MAEOBBIMEHJlZnMvdGFncy92MC45LjQwGgYKKwYBBAGDvzABDwQMDAoxMTU4Nzk5MzkyMDEGCisGAQQBg78wARAEIwwhaHR0cHM6Ly9naXRodWIuY29tL3VuaWNpdHktYXN0cmlkMBkGCisGAQQBg78wAREECwwJMjYxNzQwMzQxMGcGCisGAQQBg78wARIEWQxXaHR0cHM6Ly9naXRodWIuY29tL3VuaWNpdHktYXN0cmlkL2FzdHJpZC8uZ2l0aHViL3dvcmtmbG93cy9yZWxlYXNlLnltbEByZWZzL3RhZ3MvdjAuOS40MDgGCisGAQQBg78wARMEKgwoZGNkZTZmNjk1MWU0MDAyOWI3MmQ5NTZiNTg3ZjY5YmVlMWI1ZDQzZDAUBgorBgEEAYO/MAEUBAYMBHB1c2gwXAYKKwYBBAGDvzABFQRODExodHRwczovL2dpdGh1Yi5jb20vdW5pY2l0eS1hc3RyaWQvYXN0cmlkL2FjdGlvbnMvcnVucy8yOTA0NjgyMjQzMi9hdHRlbXB0cy8xMBYGCisGAQQBg78wARYECAwGcHVibGljMBcGCisGAQQBg78wARcECQwHcmVsZWFzZTA+BgorBgEEAYO/MAEYBDAMLnJlcG86dW5pY2l0eS1hc3RyaWQvYXN0cmlkOmVudmlyb25tZW50OnJlbGVhc2UwgYkGCisGAQQB1nkCBAIEewR5AHcAdQDdPTBqxscRMmMZHhyZZzcCokpeuN48rf+HinKALynujgAAAZ9IjcMFAAAEAwBGMEQCIGViiEzBb7aYf+wtHlvspmpCvetsL9ME4/a1z3b3HT75AiA6VHkFPj1vgLr4gwtiwhYKu/BFqEn4r3YeAXBroFfHfzAKBggqhkjOPQQDAwNoADBlAjB+hNhKpoq88OPoGgVkwK6DdqCMC7ZcliAfRrrEz3Av11IvnOBdaeuX0Mm9sSCY7r0CMQDtWvn2xYk9eFkJHMk4MyAwWYCTn9cgVxoUW/0lH5d/sSHKyLLpFHAtpxSlcdYuE/M="}, "tlogEntries":[{"logIndex":"2131537762", "logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}, "kindVersion":{"kind":"hashedrekord", "version":"0.0.1"}, "integratedTime":"1783628678", "inclusionPromise":{"signedEntryTimestamp":"MEYCIQCPyh1D9DUPqNLsMrDqlUEcoA2rT1NQBkU16pFrsV4y9AIhAIpA2509R+3BulsgI1BH1T4cZ1l2smRPzuYZ54/Ohdqv"}, "inclusionProof":{"logIndex":"2009633500", "rootHash":"MQ6eza+p8Ko7ToObbLrdoK/Lgta7/6zVPPa6gEvFpFk=", "treeSize":"2009633564", "hashes":["3wmkDK/HCPLI6cCGd4RMvIUF4n2vRlQDYuEEpyZwbkw=", "m7JN9To4ugEqFVnQZ+B9QTLRis042KHnLMsHfobef2A=", "4P0xSUnF/euOxZlqFhdvxDTQboa3Qd4SseSoMErYl/Q=", "8zHC1jtIaEIXFATpgcgrL/BzAd5eTXVEx+np+/jqKYQ=", "g19g6EmVUM4pXdTKjJslPKPFg/0tqjbYpiXKyxJNHec=", "jy9Iggf4w8Z4SW7ENdS3R4QiS8WMWXUo8mlxYgCuf8I=", "Pti8rgcHyhrAukFnbGu3kalY7RfAK4HrB0tUhO0G35E=", "38aXY48TLPDFdnjZ6pmvDqXrjEXdONr1bbG/dBcRLeQ=", "lFYRfBE30ByarhHwNZMJW1nzJKiWuIxLVjBJcBM5aWI=", "h0rXkDzNU6ptvC3jmZclcbp3tZrJr7cgGwrz6Doq2Zw=", "jexujGZ0D7SfcIoZVBSN0JYWJtqzye5ZBixSY1vzyF0=", "mRrUc5vcFxFlSSXLA4m7VBccF5uOdRIbwQCZNhl6VZQ=", "N15SYkLk0BUMy0oDqDdOrzKWfqa8kP631ZVyiI0opBQ=", "ZeAyAdx3oRFJE3+JU7nJkAtqkQzOub84VIDsm8niYsk=", "LD5Ws0n7JWm8O7qGvHNSZug32ByFM1WblCpsDJh5M6o=", "f8knrc4LOu/X8scT5SBvEgFJSd7vZZ0LzSaC0Z3lm/U=", "WajL3ZhY6ZX9meV3VTH9TkrbM31OF4o6+7foL0Uf6PI=", "cyKMKBvHkQtwb2xR5svh+YppANOVdn7X8t4DuKMxICA=", "+/VZ56MsIPxMiyLAodzKXo5TEWdQp36z89qLhpzloAo=", "daxmZaajRpZV+JxHiOYZhJBiSKN5ucqjh2WnGbHhirw=", "DOCeoSMovIvLExkhIvisow9AuNXgeWs4ECkyR6EcqYU="], "checkpoint":{"envelope":"rekor.sigstore.dev - 1193050959916656506\n2009633564\nMQ6eza+p8Ko7ToObbLrdoK/Lgta7/6zVPPa6gEvFpFk=\n\n— rekor.sigstore.dev wNI9ajBFAiEArE5WZyDectW+zG1FcptiPY7rw2I2RmyJc2PkwbXIQNwCICsCAGV8M80SvUhJkw8vuoJmI2chSQKXj/lznJDY9O47\n"}}, "canonicalizedBody":"eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoiaGFzaGVkcmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiJkN2JiYzA2MjczNTMyYWFkZGNkMGY5YTczNDFhZTFkNmU2ZGQyMjJjNDg2OGQ3ZGRkYThjZmZjMWFkM2NhZGRkIn19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FWUNJUURRcUxIRmd6blZLbktZVlk3WFl6WE5CeTJLcXRnRlBCSGZrd1FaRDc0ZW13SWhBTVFKVXNXemljekphS29GY296aWVycmFrOVk0ZG45K3NqT0xTczdWdHZtaiIsInB1YmxpY0tleSI6eyJjb250ZW50IjoiTFMwdExTMUNSVWRKVGlCRFJWSlVTVVpKUTBGVVJTMHRMUzB0Q2sxSlNVaEpWRU5EUW5GbFowRjNTVUpCWjBsVlNIRm1jRE5zZDBObFZGZDVOM05zZUZrMmQyRmtSRGxzWlZCemQwTm5XVWxMYjFwSmVtb3dSVUYzVFhjS1RucEZWazFDVFVkQk1WVkZRMmhOVFdNeWJHNWpNMUoyWTIxVmRWcEhWakpOVWpSM1NFRlpSRlpSVVVSRmVGWjZZVmRrZW1SSE9YbGFVekZ3WW01U2JBcGpiVEZzV2tkc2FHUkhWWGRJYUdOT1RXcFpkMDU2UVRWTmFrRjVUa1JOTTFkb1kwNU5hbGwzVG5wQk5VMXFRWHBPUkUwelYycEJRVTFHYTNkRmQxbElDa3R2V2tsNmFqQkRRVkZaU1V0dldrbDZhakJFUVZGalJGRm5RVVZyTDNoM1UwUm1iWFJ4UkVWT2NrZDVjMnhSV0hKamJsVlNRMG80U3paYWR6WlVjMEVLYVZwSmJXSnJhRE56WldVeGQzbFhXVTVDWmk5UVowSlJRbkl5VGxGbGJFRjBVMFZPYVc0cmEzQlNNM0lyVVRoQ01IRlBRMEpqV1hkbloxaERUVUUwUndwQk1WVmtSSGRGUWk5M1VVVkJkMGxJWjBSQlZFSm5UbFpJVTFWRlJFUkJTMEpuWjNKQ1owVkdRbEZqUkVGNlFXUkNaMDVXU0ZFMFJVWm5VVlZVZW1FMENsbHlNMkkzVGs5cVprZEtaMjVGVkZkUmVHOXFlbUZSZDBoM1dVUldVakJxUWtKbmQwWnZRVlV6T1ZCd2VqRlphMFZhWWpWeFRtcHdTMFpYYVhocE5Ga0tXa1E0ZDFwUldVUldVakJTUVZGSUwwSkdjM2RYV1ZwWVlVaFNNR05JVFRaTWVUbHVZVmhTYjJSWFNYVlpNamwwVEROV2RXRlhUbkJrU0d0MFdWaE9NQXBqYld4clRESkdlbVJJU25CYVF6aDFXakpzTUdGSVZtbE1NMlIyWTIxMGJXSkhPVE5qZVRsNVdsZDRiRmxZVG14TWJteDBZa1ZDZVZwWFducE1NMUpvQ2xvelRYWmtha0YxVDFNME1FMUVhMGREYVhOSFFWRlJRbWMzT0hkQlVVVkZTekpvTUdSSVFucFBhVGgyWkVjNWNscFhOSFZaVjA0d1lWYzVkV041Tlc0S1lWaFNiMlJYU2pGak1sWjVXVEk1ZFdSSFZuVmtRelZxWWpJd2QwVm5XVXRMZDFsQ1FrRkhSSFo2UVVKQloxRkZZMGhXZW1GRVFUSkNaMjl5UW1kRlJRcEJXVTh2VFVGRlJFSkRhR3RaTWxKc1RtMVpNazlVVlhoYVZGRjNUVVJKTlZscVkzbGFSR3N4VG0xSk1VOUVaRzFPYW14cFdsZFZlRmxxVm10T1JFNXJDazFDVlVkRGFYTkhRVkZSUW1jM09IZEJVVkZGUWpGS2JHSkhWbWhqTWxWM1NYZFpTMHQzV1VKQ1FVZEVkbnBCUWtKUlVWWmtWelZ3V1RKc01HVlRNV2dLWXpOU2VXRlhVWFpaV0U0d1kyMXNhMDFDTkVkRGFYTkhRVkZSUW1jM09IZEJVVmxGUlVoS2JGcHVUWFprUjBadVkzazVNazFETkRWTWFsRjNUM2RaU3dwTGQxbENRa0ZIUkhaNlFVSkRRVkYwUkVOMGIyUklVbmRqZW05MlRETlNkbUV5Vm5WTWJVWnFaRWRzZG1KdVRYVmFNbXd3WVVoV2FXUllUbXhqYlU1MkNtSnVVbXhpYmxGMVdUSTVkRTFIWTBkRGFYTkhRVkZSUW1jM09IZEJVV3RGVjFGNFdHRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3elZuVUtZVmRPY0dSSWEzUlpXRTR3WTIxc2Ewd3lSbnBrU0Vwd1drTTRkVm95YkRCaFNGWnBURE5rZG1OdGRHMWlSemt6WTNrNWVWcFhlR3haV0U1c1RHNXNkQXBpUlVKNVdsZGFla3d6VW1oYU0wMTJaR3BCZFU5VE5EQk5SR2RIUTJselIwRlJVVUpuTnpoM1FWRnZSVXRuZDI5YVIwNXJXbFJhYlU1cWF6Rk5WMVV3Q2sxRVFYbFBWMGt6VFcxUk5VNVVXbWxPVkdjeldtcFpOVmx0Vm14TlYwa3hXa1JSZWxwRVFXUkNaMjl5UW1kRlJVRlpUeTlOUVVWTVFrRTRUVVJYWkhBS1pFZG9NVmxwTVc5aU0wNHdXbGRSZDA5QldVdExkMWxDUWtGSFJIWjZRVUpFUVZGeFJFTm9iMlJJVW5kamVtOTJUREprY0dSSGFERlphVFZxWWpJd2RncGtWelZ3V1RKc01HVlRNV2hqTTFKNVlWZFJkbGxZVGpCamJXeHJUVVJuUjBOcGMwZEJVVkZDWnpjNGQwRlJNRVZMWjNkdldrZE9hMXBVV20xT2Ftc3hDazFYVlRCTlJFRjVUMWRKTTAxdFVUVk9WRnBwVGxSbk0xcHFXVFZaYlZac1RWZEpNVnBFVVhwYVJFRm5RbWR2Y2tKblJVVkJXVTh2VFVGRlQwSkNTVTBLUlVoS2JGcHVUWFprUjBadVkzazVNazFETkRWTWFsRjNSMmRaUzB0M1dVSkNRVWRFZG5wQlFrUjNVVTFFUVc5NFRWUlZORTU2YXpWTmVtdDVUVVJGUndwRGFYTkhRVkZSUW1jM09IZEJVa0ZGU1hkM2FHRklVakJqU0UwMlRIazVibUZZVW05a1YwbDFXVEk1ZEV3elZuVmhWMDV3WkVocmRGbFlUakJqYld4ckNrMUNhMGREYVhOSFFWRlJRbWMzT0hkQlVrVkZRM2QzU2sxcVdYaE9lbEYzVFhwUmVFMUhZMGREYVhOSFFWRlJRbWMzT0hkQlVrbEZWMUY0V0dGSVVqQUtZMGhOTmt4NU9XNWhXRkp2WkZkSmRWa3lPWFJNTTFaMVlWZE9jR1JJYTNSWldFNHdZMjFzYTB3eVJucGtTRXB3V2tNNGRWb3liREJoU0ZacFRETmtkZ3BqYlhSdFlrYzVNMk41T1hsYVYzaHNXVmhPYkV4dWJIUmlSVUo1V2xkYWVrd3pVbWhhTTAxMlpHcEJkVTlUTkRCTlJHZEhRMmx6UjBGUlVVSm5OemgzQ2tGU1RVVkxaM2R2V2tkT2ExcFVXbTFPYW1zeFRWZFZNRTFFUVhsUFYwa3pUVzFSTlU1VVdtbE9WR2N6V21wWk5WbHRWbXhOVjBreFdrUlJlbHBFUVZVS1FtZHZja0puUlVWQldVOHZUVUZGVlVKQldVMUNTRUl4WXpKbmQxaEJXVXRMZDFsQ1FrRkhSSFo2UVVKR1VWSlBSRVY0YjJSSVVuZGplbTkyVERKa2NBcGtSMmd4V1drMWFtSXlNSFprVnpWd1dUSnNNR1ZUTVdoak0xSjVZVmRSZGxsWVRqQmpiV3hyVERKR2FtUkhiSFppYmsxMlkyNVdkV041T0hsUFZFRXdDazVxWjNsTmFsRjZUV2s1YUdSSVVteGlXRUl3WTNrNGVFMUNXVWREYVhOSFFWRlJRbWMzT0hkQlVsbEZRMEYzUjJOSVZtbGlSMnhxVFVKalIwTnBjMGNLUVZGUlFtYzNPSGRCVW1ORlExRjNTR050Vm5OYVYwWjZXbFJCSzBKbmIzSkNaMFZGUVZsUEwwMUJSVmxDUkVGTlRHNUtiR05IT0Raa1Z6VndXVEpzTUFwbFV6Rm9Zek5TZVdGWFVYWlpXRTR3WTIxc2EwOXRWblZrYld4NVlqSTFkRnBYTlRCUGJrcHNZa2RXYUdNeVZYZG5XV3RIUTJselIwRlJVVUl4Ym10RENrSkJTVVZsZDFJMVFVaGpRV1JSUkdSUVZFSnhlSE5qVWsxdFRWcElhSGxhV25walEyOXJjR1YxVGpRNGNtWXJTR2x1UzBGTWVXNTFhbWRCUVVGYU9Va0thbU5OUmtGQlFVVkJkMEpIVFVWUlEwbEhWbWxwUlhwQ1lqZGhXV1lyZDNSSWJIWnpjRzF3UTNabGRITk1PVTFGTkM5aE1Yb3pZak5JVkRjMVFXbEJOZ3BXU0d0R1VHb3hkbWRNY2pSbmQzUnBkMmhaUzNVdlFrWnhSVzQwY2pOWlpVRllRbkp2Um1aSVpucEJTMEpuWjNGb2EycFBVRkZSUkVGM1RtOUJSRUpzQ2tGcVFpdG9UbWhMY0c5eE9EaFBVRzlIWjFacmQwczJSR1J4UTAxRE4xcGpiR2xCWmxKeWNrVjZNMEYyTVRGSmRtNVBRbVJoWlhWWU1FMXRPWE5UUTFrS04zSXdRMDFSUkhSWGRtNHllRmxyT1dWR2EwcElUV3MwVFhsQmQxZFpRMVJ1T1dOblZuaHZWVmN2TUd4SU5XUXZjMU5JUzNsTVRIQkdTRUYwY0hoVGJBcGpaRmwxUlM5TlBRb3RMUzB0TFVWT1JDQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENnPT0ifX19fQ=="}], "timestampVerificationData":{"rfc3161Timestamps":[{"signedTimestamp":"MIICyTADAgEAMIICwAYJKoZIhvcNAQcCoIICsTCCAq0CAQMxDTALBglghkgBZQMEAgEwgbcGCyqGSIb3DQEJEAEEoIGnBIGkMIGhAgEBBgkrBgEEAYO/MAIwMTANBglghkgBZQMEAgEFAAQgXP3t01Bcu/BOmzqshyHZGZDflhtE6j+Z1mPduBdH8EUCFBelVFyfce3BWqLsDSRKRUZz1DCQGA8yMDI2MDcwOTIwMjQzOFowAwIBAaAypDAwLjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MRUwEwYDVQQDEwxzaWdzdG9yZS10c2GgADGCAdswggHXAgEBMFEwOTEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MSAwHgYDVQQDExdzaWdzdG9yZS10c2Etc2VsZnNpZ25lZAIUOhNULwyQYe68wUMvy4qOiyojiwwwCwYJYIZIAWUDBAIBoIH8MBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcNMjYwNzA5MjAyNDM4WjAvBgkqhkiG9w0BCQQxIgQg2jczqA7YsZF0Rj/UTL/Atb38JQUXgn0jMAocpKKthfQwgY4GCyqGSIb3DQEJEAIvMX8wfTB7MHkEIIX5J7wHq2LKw7RDVsEO/IGyxog/2nq55thw2dE6zQW3MFUwPaQ7MDkxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEgMB4GA1UEAxMXc2lnc3RvcmUtdHNhLXNlbGZzaWduZWQCFDoTVC8MkGHuvMFDL8uKjosqI4sMMAoGCCqGSM49BAMCBGcwZQIwT3WOLtSSL8s8Fe4oVkbpCvePcDGnfTdAIUuFGoMPBluk58dSr6YgkWTUeKUQMhCnAjEA+QwBh+/tBX42GcMnMZcmEMqLbFtuxtdMk0Z6v6eLWYtM5juN+FQ/EgMPrEYoqcU8"}]}}, "messageSignature":{"messageDigest":{"algorithm":"SHA2_256", "digest":"17vAYnNTKq3c0PmnNBrh1ubdIixIaNfd2oz/wa08rd0="}, "signature":"MEYCIQDQqLHFgznVKnKYVY7XYzXNBy2KqtgFPBHfkwQZD74emwIhAMQJUsWziczJaKoFcozierrak9Y4dn9+sjOLSs7Vtvmj"}} diff --git a/docs/self-update-security.md b/docs/self-update-security.md new file mode 100644 index 000000000..3d3e8b215 --- /dev/null +++ b/docs/self-update-security.md @@ -0,0 +1,45 @@ +# Self-update security + +`astrid update` applies an in-place update only when the running binary is +self-managed. Homebrew and Cargo installations remain owned by their package +managers and receive the corresponding upgrade command instead. + +For a self-managed install, the updater requires all of the following for the +exact platform archive: + +1. One archive asset with the canonical version and target name. +2. One `.sigstore.json` bundle whose certificate identity is exactly + Astrid's `release.yml` workflow at that version tag and whose issuer is + GitHub Actions. +3. Fresh Sigstore public-good trust material refreshed through TUF from the + pinned verifier's embedded production root. +4. One strict lowercase BLAKE3 entry for the archive in `BLAKE3SUMS.txt`. + +Publisher authentication happens before the independent BLAKE3 integrity +check. The archive is not written, extracted, or installed until both stages +have succeeded. Missing or duplicated assets, malformed evidence, identity or +issuer mismatches, trust refresh failures, and checksum mismatches all fail +closed. + +Release publishing has a differential gate before the GitHub release is +created. Cosign first verifies every generated asset against its bundle. The +updater's native production verifier then independently authenticates every +generated archive and bundle pair with the same exact identity, issuer, and +live TUF trust path used by `astrid update`; either verifier rejecting an +archive stops the release. + +`--source` and `ASTRID_UPDATE_REPO` can redirect release discovery to a mirror +or test server, but cannot change the required Astrid publisher identity, +issuer, workflow, repository, or tag. `ASTRID_UPDATE_API` likewise changes only +the metadata API endpoint. + +The release also publishes a signed SHA-256 compatibility manifest for package +managers and other downstream protocols. It is not accepted in place of the +BLAKE3 integrity manifest. GitHub build-provenance attestations are additional +evidence and are not the trust root used by the self-updater. + +The authentication guarantee starts with the first release that contains this +updater. An older binary cannot retroactively enforce policy while downloading +that first authenticated-updater release, so users crossing that boundary must +install through a package manager or independently verify the published +release evidence. From c3790419619c283c77eed9e83a8f6d02a1829f21 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 07:42:54 +0400 Subject: [PATCH 15/20] fix(update): reuse release trust root --- crates/astrid-cli/src/commands/update_auth.rs | 46 +++++++++++++------ .../src/commands/update_auth_tests.rs | 7 ++- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/crates/astrid-cli/src/commands/update_auth.rs b/crates/astrid-cli/src/commands/update_auth.rs index b3f3a6e46..bfd196a09 100644 --- a/crates/astrid-cli/src/commands/update_auth.rs +++ b/crates/astrid-cli/src/commands/update_auth.rs @@ -93,6 +93,36 @@ fn verify_with_root( .context("Sigstore evidence did not satisfy the release policy") } +struct PublisherAuthenticator { + root: TrustedRoot, +} + +impl PublisherAuthenticator { + async fn production() -> Result { + let config = TufConfig::production().without_cache(); + let root = tokio::time::timeout(TRUST_ROOT_TIMEOUT, TrustedRoot::from_tuf(config)) + .await + .map_err(|_| UpdateStageError::publisher("Sigstore trust refresh timed out"))? + .map_err(|_| UpdateStageError::publisher("Sigstore trust refresh failed"))?; + Ok(Self { root }) + } + + fn authenticate( + &self, + archive: Vec, + bundle_json: &[u8], + version: &str, + ) -> Result { + let bundle = parse_publisher_bundle(bundle_json)?; + verify_with_root(&archive, &bundle, version, &self.root).map_err(|_| { + UpdateStageError::publisher( + "archive signature or exact release identity did not verify", + ) + })?; + Ok(PublisherAuthenticatedArchive(archive)) + } +} + #[cfg(test)] fn authenticate_for_test( archive: Vec, @@ -118,19 +148,9 @@ pub(super) async fn authenticate_archive( bundle_json: &[u8], version: &str, ) -> Result { - let bundle = parse_publisher_bundle(bundle_json)?; - - let config = TufConfig::production().without_cache(); - let root = tokio::time::timeout(TRUST_ROOT_TIMEOUT, TrustedRoot::from_tuf(config)) - .await - .map_err(|_| UpdateStageError::publisher("Sigstore trust refresh timed out"))? - .map_err(|_| UpdateStageError::publisher("Sigstore trust refresh failed"))?; - - verify_with_root(&archive, &bundle, version, &root).map_err(|_| { - UpdateStageError::publisher("archive signature or exact release identity did not verify") - })?; - - Ok(PublisherAuthenticatedArchive(archive)) + PublisherAuthenticator::production() + .await? + .authenticate(archive, bundle_json, version) } /// Verify the authenticated archive against the one canonical BLAKE3 entry for diff --git a/crates/astrid-cli/src/commands/update_auth_tests.rs b/crates/astrid-cli/src/commands/update_auth_tests.rs index c550f703d..1917d1eba 100644 --- a/crates/astrid-cli/src/commands/update_auth_tests.rs +++ b/crates/astrid-cli/src/commands/update_auth_tests.rs @@ -235,6 +235,9 @@ async fn release_gate_authenticates_all_archives_with_production_policy() { .collect::>(); archives.sort(); assert!(!archives.is_empty(), "release gate found no archives"); + let authenticator = PublisherAuthenticator::production() + .await + .expect("refresh production Sigstore trust root"); for archive_path in archives { let archive_name = archive_path @@ -250,8 +253,8 @@ async fn release_gate_authenticates_all_archives_with_production_policy() { ) }); - authenticate_archive(archive, &bundle, &version) - .await + authenticator + .authenticate(archive, &bundle, &version) .unwrap_or_else(|error| { panic!( "native verifier rejected {} after Cosign accepted it: {error}", From 66c5ee5386eed13f01a554687cdbb91a573c6b31 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 21:42:10 +0400 Subject: [PATCH 16/20] fix(update): preserve bounded asset failures --- crates/astrid-cli/src/commands/self_update.rs | 4 +-- .../src/commands/self_update_tests.rs | 29 +++++++++++++++++++ crates/astrid-cli/src/commands/update_auth.rs | 6 ++++ .../src/commands/update_auth_tests.rs | 14 +++++++++ 4 files changed, 51 insertions(+), 2 deletions(-) diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index eabde6567..662a1db17 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -407,12 +407,12 @@ fn publisher_bundle_url<'a>( ) -> Result<&'a str, UpdateStageError> { let bundle_name = format!("{archive_name}.sigstore.json"); exact_asset_url(release, &bundle_name) - .map_err(|_| UpdateStageError::publisher(format!("release has no unique '{bundle_name}'"))) + .map_err(|error| UpdateStageError::publisher(error.to_string())) } fn integrity_manifest_url(release: &serde_json::Value) -> Result<&str, UpdateStageError> { exact_asset_url(release, "BLAKE3SUMS.txt") - .map_err(|_| UpdateStageError::integrity("release has no unique BLAKE3SUMS.txt")) + .map_err(|error| UpdateStageError::integrity(error.to_string())) } /// Stream a URL into memory under the size cap. diff --git a/crates/astrid-cli/src/commands/self_update_tests.rs b/crates/astrid-cli/src/commands/self_update_tests.rs index 589a8d89c..a611a060d 100644 --- a/crates/astrid-cli/src/commands/self_update_tests.rs +++ b/crates/astrid-cli/src/commands/self_update_tests.rs @@ -235,6 +235,7 @@ fn release_tags_are_canonical_and_identity_safe() { canonical_release_version("v1.2.3-rc.1").unwrap(), "1.2.3-rc.1" ); + for invalid in ["1.2.3", "vv1.2.3", "v01.2.3", "v1.2", "v1.2.3-01"] { assert!( canonical_release_version(invalid).is_err(), @@ -310,6 +311,34 @@ fn publisher_bundle_and_blake3_manifest_are_both_mandatory() { )); } +#[test] +fn staged_asset_selection_preserves_the_exact_failure() { + let bundle_name = "astrid-1.0.0-x.tar.gz.sigstore.json"; + let duplicate_bundle = serde_json::json!({ + "assets": [ + {"name": bundle_name, "browser_download_url": "https://example.com/one"}, + {"name": bundle_name, "browser_download_url": "https://example.com/two"} + ] + }); + let error = publisher_bundle_url(&duplicate_bundle, "astrid-1.0.0-x.tar.gz") + .unwrap_err() + .to_string(); + assert_eq!( + error, + format!( + "publisher authentication failed: release contains duplicate asset '{bundle_name}'" + ) + ); + + let oversized = serde_json::json!({ + "assets": vec![serde_json::json!({"name": "irrelevant"}); MAX_RELEASE_ASSETS + 1] + }); + assert_eq!( + integrity_manifest_url(&oversized).unwrap_err().to_string(), + "integrity check failed: release contains too many assets" + ); +} + #[test] fn backup_and_swap_replaces_and_keeps_backup() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/astrid-cli/src/commands/update_auth.rs b/crates/astrid-cli/src/commands/update_auth.rs index bfd196a09..859a38154 100644 --- a/crates/astrid-cli/src/commands/update_auth.rs +++ b/crates/astrid-cli/src/commands/update_auth.rs @@ -15,6 +15,7 @@ use sigstore_verify::{VerificationPolicy, verify}; const GITHUB_ACTIONS_ISSUER: &str = "https://token.actions.githubusercontent.com"; const TRUST_ROOT_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_MANIFEST_LINE_BYTES: usize = 1_024; #[derive(Debug, thiserror::Error)] #[error("{0}")] @@ -168,6 +169,11 @@ pub(super) fn verify_integrity( let line_number = index .checked_add(1) .ok_or_else(|| UpdateStageError::integrity("BLAKE3SUMS.txt contains too many lines"))?; + if line.len() > MAX_MANIFEST_LINE_BYTES { + return Err(UpdateStageError::integrity(format!( + "BLAKE3SUMS.txt line {line_number} exceeds {MAX_MANIFEST_LINE_BYTES} byte limit" + ))); + } let (hex, name) = line.split_once(" ").ok_or_else(|| { UpdateStageError::integrity(format!( "malformed BLAKE3SUMS.txt line {line_number}: expected ' '" diff --git a/crates/astrid-cli/src/commands/update_auth_tests.rs b/crates/astrid-cli/src/commands/update_auth_tests.rs index 1917d1eba..696f70afd 100644 --- a/crates/astrid-cli/src/commands/update_auth_tests.rs +++ b/crates/astrid-cli/src/commands/update_auth_tests.rs @@ -206,6 +206,20 @@ fn integrity_manifest_rejects_noncanonical_and_duplicate_entries() { .unwrap_err(), UpdateStageError::Integrity(_) )); + + let overlong = "a".repeat(MAX_MANIFEST_LINE_BYTES + 1); + let error = verify_integrity( + PublisherAuthenticatedArchive(b"archive".to_vec()), + &overlong, + asset, + ) + .unwrap_err(); + assert_eq!( + error.to_string(), + format!( + "integrity check failed: BLAKE3SUMS.txt line 1 exceeds {MAX_MANIFEST_LINE_BYTES} byte limit" + ) + ); } #[tokio::test] From f9fadc4645254b7e3a89e27cf79489e6f6df3e89 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 21:56:17 +0400 Subject: [PATCH 17/20] fix(kernel): tolerate read-only empty credential stores --- crates/astrid-kernel/src/invite.rs | 27 +++++++++++++++++++++++++- crates/astrid-kernel/src/pair_token.rs | 27 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/astrid-kernel/src/invite.rs b/crates/astrid-kernel/src/invite.rs index e488f03c5..633edc5d3 100644 --- a/crates/astrid-kernel/src/invite.rs +++ b/crates/astrid-kernel/src/invite.rs @@ -144,7 +144,13 @@ impl InviteStore { InviteStoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) })?; if text.trim().is_empty() { - self.save_to_disk(&[])?; + if let Err(error) = self.save_to_disk(&[]) { + warn!( + path = %self.path.display(), + %error, + "could not normalize empty invite store" + ); + } return Ok(Vec::new()); } let probe: SchemaProbe = toml::from_str(text).map_err(InviteStoreError::Toml)?; @@ -528,6 +534,25 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn read_only_empty_file_still_loads_as_empty_vec() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = InviteStore::new(dir.path().join("invites.toml")); + std::fs::write(&store.path, "").unwrap(); + + let original = std::fs::metadata(dir.path()).unwrap().permissions(); + let mut read_only = original.clone(); + read_only.set_mode(0o500); + std::fs::set_permissions(dir.path(), read_only).unwrap(); + let loaded = store.load(); + std::fs::set_permissions(dir.path(), original).unwrap(); + + assert_eq!(loaded.unwrap(), Vec::::new()); + } + #[cfg(unix)] #[test] fn save_writes_0600_perms() { diff --git a/crates/astrid-kernel/src/pair_token.rs b/crates/astrid-kernel/src/pair_token.rs index a8f1d7f3c..f36249e8c 100644 --- a/crates/astrid-kernel/src/pair_token.rs +++ b/crates/astrid-kernel/src/pair_token.rs @@ -146,7 +146,13 @@ impl PairTokenStore { PairTokenStoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)) })?; if text.trim().is_empty() { - self.save_to_disk(&[])?; + if let Err(error) = self.save_to_disk(&[]) { + warn!( + path = %self.path.display(), + %error, + "could not normalize empty pair-token store" + ); + } return Ok(Vec::new()); } let probe: SchemaProbe = toml::from_str(text).map_err(PairTokenStoreError::Toml)?; @@ -445,6 +451,25 @@ mod tests { assert_eq!(std::fs::read_to_string(path).unwrap(), malformed); } + #[cfg(unix)] + #[test] + fn read_only_empty_file_still_loads_as_empty_vec() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let store = PairTokenStore::new(dir.path().join("pair-tokens.toml")); + std::fs::write(&store.path, "").unwrap(); + + let original = std::fs::metadata(dir.path()).unwrap().permissions(); + let mut read_only = original.clone(); + read_only.set_mode(0o500); + std::fs::set_permissions(dir.path(), read_only).unwrap(); + let loaded = store.load(); + std::fs::set_permissions(dir.path(), original).unwrap(); + + assert_eq!(loaded.unwrap(), Vec::::new()); + } + #[test] fn prune_drops_expired() { let now = now_epoch(); From 7d87435bad7ba2fb1786538d89482481d16262a1 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 22:09:08 +0400 Subject: [PATCH 18/20] test(kernel): lock legacy store fail-closed behavior --- crates/astrid-kernel/src/invite.rs | 25 +++++++++++++++++++++++++ crates/astrid-kernel/src/pair_token.rs | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/astrid-kernel/src/invite.rs b/crates/astrid-kernel/src/invite.rs index 633edc5d3..8c9f30cc4 100644 --- a/crates/astrid-kernel/src/invite.rs +++ b/crates/astrid-kernel/src/invite.rs @@ -495,6 +495,31 @@ mod tests { assert!(store.load().unwrap().is_empty()); } + #[cfg(unix)] + #[test] + fn read_only_legacy_sha256_store_fails_closed_without_rewrite() { + use std::os::unix::fs::PermissionsExt; + + 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 original = std::fs::metadata(dir.path()).unwrap().permissions(); + let mut read_only = original.clone(); + read_only.set_mode(0o500); + std::fs::set_permissions(dir.path(), read_only).unwrap(); + let loaded = InviteStore::new(path.clone()).load(); + std::fs::set_permissions(dir.path(), original).unwrap(); + + assert!(loaded.is_err()); + assert_eq!(std::fs::read_to_string(path).unwrap(), legacy); + } + #[test] fn future_store_is_rejected_without_rewrite() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/astrid-kernel/src/pair_token.rs b/crates/astrid-kernel/src/pair_token.rs index f36249e8c..15c060b21 100644 --- a/crates/astrid-kernel/src/pair_token.rs +++ b/crates/astrid-kernel/src/pair_token.rs @@ -428,6 +428,31 @@ mod tests { assert!(store.load().unwrap().is_empty()); } + #[cfg(unix)] + #[test] + fn read_only_legacy_sha256_store_fails_closed_without_rewrite() { + use std::os::unix::fs::PermissionsExt; + + 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 original = std::fs::metadata(dir.path()).unwrap().permissions(); + let mut read_only = original.clone(); + read_only.set_mode(0o500); + std::fs::set_permissions(dir.path(), read_only).unwrap(); + let loaded = PairTokenStore::new(path.clone()).load(); + std::fs::set_permissions(dir.path(), original).unwrap(); + + assert!(loaded.is_err()); + assert_eq!(std::fs::read_to_string(path).unwrap(), legacy); + } + #[test] fn future_store_is_rejected_without_rewrite() { let dir = tempfile::tempdir().unwrap(); From d95c0d1a3113019a09ddc62434f3e3ee4c50b3a2 Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 22:17:10 +0400 Subject: [PATCH 19/20] fix(update): preserve download failure causes --- crates/astrid-cli/src/commands/self_update.rs | 12 +++++++++-- .../src/commands/self_update_tests.rs | 21 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index 662a1db17..05734c595 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -415,6 +415,14 @@ fn integrity_manifest_url(release: &serde_json::Value) -> Result<&str, UpdateSta .map_err(|error| UpdateStageError::integrity(error.to_string())) } +fn publisher_bundle_download_error(error: &anyhow::Error) -> UpdateStageError { + UpdateStageError::publisher(format!("could not download Sigstore bundle: {error}")) +} + +fn integrity_manifest_download_error(error: &anyhow::Error) -> UpdateStageError { + UpdateStageError::integrity(format!("could not download BLAKE3SUMS.txt: {error}")) +} + /// Stream a URL into memory under the size cap. async fn download_bounded( client: &reqwest::Client, @@ -694,7 +702,7 @@ async fn download_verify_extract( "publisher-authentication bundle", ) .await - .map_err(|_| UpdateStageError::publisher("could not download Sigstore bundle"))?; + .map_err(|error| publisher_bundle_download_error(&error))?; let authenticated = authenticate_archive(archive, &bundle, version).await?; println!("{}", Theme::dimmed("Publisher authenticated.")); @@ -708,7 +716,7 @@ async fn download_verify_extract( "BLAKE3 integrity manifest", ) .await - .map_err(|_| UpdateStageError::integrity("could not download BLAKE3SUMS.txt"))?; + .map_err(|error| integrity_manifest_download_error(&error))?; let sums_body = String::from_utf8(sums) .map_err(|_| UpdateStageError::integrity("BLAKE3SUMS.txt is not UTF-8"))?; let archive = verify_integrity(authenticated, &sums_body, &asset_name)?; diff --git a/crates/astrid-cli/src/commands/self_update_tests.rs b/crates/astrid-cli/src/commands/self_update_tests.rs index a611a060d..e0d11f801 100644 --- a/crates/astrid-cli/src/commands/self_update_tests.rs +++ b/crates/astrid-cli/src/commands/self_update_tests.rs @@ -339,6 +339,27 @@ fn staged_asset_selection_preserves_the_exact_failure() { ); } +#[test] +fn staged_download_failures_preserve_the_bounded_download_cause() { + let bundle_error = + anyhow::anyhow!("publisher-authentication bundle exceeds 10485760 byte limit"); + let bundle = publisher_bundle_download_error(&bundle_error); + assert_eq!( + bundle.to_string(), + "publisher authentication failed: could not download Sigstore bundle: \ + publisher-authentication bundle exceeds 10485760 byte limit" + ); + + let manifest_error = + anyhow::anyhow!("BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable"); + let manifest = integrity_manifest_download_error(&manifest_error); + assert_eq!( + manifest.to_string(), + "integrity check failed: could not download BLAKE3SUMS.txt: \ + BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable" + ); +} + #[test] fn backup_and_swap_replaces_and_keeps_backup() { let dir = tempfile::tempdir().unwrap(); From c44a3908aa3e34611540e5d82331171e218940ae Mon Sep 17 00:00:00 2001 From: "Joshua J. Bouw" Date: Wed, 15 Jul 2026 22:26:49 +0400 Subject: [PATCH 20/20] refactor(update): colocate staged error mapping --- crates/astrid-cli/src/commands/self_update.rs | 11 ++-------- .../src/commands/self_update_tests.rs | 21 ------------------- crates/astrid-cli/src/commands/update_auth.rs | 8 +++++++ .../src/commands/update_auth_tests.rs | 21 +++++++++++++++++++ 4 files changed, 31 insertions(+), 30 deletions(-) diff --git a/crates/astrid-cli/src/commands/self_update.rs b/crates/astrid-cli/src/commands/self_update.rs index 05734c595..3085612df 100644 --- a/crates/astrid-cli/src/commands/self_update.rs +++ b/crates/astrid-cli/src/commands/self_update.rs @@ -23,7 +23,8 @@ use crate::cli::UpdateArgs; use crate::theme::Theme; use super::update_auth::{ - UpdateStageError, authenticate_archive, extract_verified_archive, verify_integrity, + UpdateStageError, authenticate_archive, extract_verified_archive, + integrity_manifest_download_error, publisher_bundle_download_error, verify_integrity, }; /// Default Astrid release repository. Discovery overrides never widen the @@ -415,14 +416,6 @@ fn integrity_manifest_url(release: &serde_json::Value) -> Result<&str, UpdateSta .map_err(|error| UpdateStageError::integrity(error.to_string())) } -fn publisher_bundle_download_error(error: &anyhow::Error) -> UpdateStageError { - UpdateStageError::publisher(format!("could not download Sigstore bundle: {error}")) -} - -fn integrity_manifest_download_error(error: &anyhow::Error) -> UpdateStageError { - UpdateStageError::integrity(format!("could not download BLAKE3SUMS.txt: {error}")) -} - /// Stream a URL into memory under the size cap. async fn download_bounded( client: &reqwest::Client, diff --git a/crates/astrid-cli/src/commands/self_update_tests.rs b/crates/astrid-cli/src/commands/self_update_tests.rs index e0d11f801..a611a060d 100644 --- a/crates/astrid-cli/src/commands/self_update_tests.rs +++ b/crates/astrid-cli/src/commands/self_update_tests.rs @@ -339,27 +339,6 @@ fn staged_asset_selection_preserves_the_exact_failure() { ); } -#[test] -fn staged_download_failures_preserve_the_bounded_download_cause() { - let bundle_error = - anyhow::anyhow!("publisher-authentication bundle exceeds 10485760 byte limit"); - let bundle = publisher_bundle_download_error(&bundle_error); - assert_eq!( - bundle.to_string(), - "publisher authentication failed: could not download Sigstore bundle: \ - publisher-authentication bundle exceeds 10485760 byte limit" - ); - - let manifest_error = - anyhow::anyhow!("BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable"); - let manifest = integrity_manifest_download_error(&manifest_error); - assert_eq!( - manifest.to_string(), - "integrity check failed: could not download BLAKE3SUMS.txt: \ - BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable" - ); -} - #[test] fn backup_and_swap_replaces_and_keeps_backup() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/astrid-cli/src/commands/update_auth.rs b/crates/astrid-cli/src/commands/update_auth.rs index 859a38154..17745823f 100644 --- a/crates/astrid-cli/src/commands/update_auth.rs +++ b/crates/astrid-cli/src/commands/update_auth.rs @@ -47,6 +47,14 @@ impl UpdateStageError { } } +pub(super) fn publisher_bundle_download_error(error: &anyhow::Error) -> UpdateStageError { + UpdateStageError::publisher(format!("could not download Sigstore bundle: {error}")) +} + +pub(super) fn integrity_manifest_download_error(error: &anyhow::Error) -> UpdateStageError { + UpdateStageError::integrity(format!("could not download BLAKE3SUMS.txt: {error}")) +} + /// Archive bytes whose Sigstore bundle authenticated the exact Astrid release /// workflow and tag. Construction is private to this module. #[derive(Debug)] diff --git a/crates/astrid-cli/src/commands/update_auth_tests.rs b/crates/astrid-cli/src/commands/update_auth_tests.rs index 696f70afd..cab096106 100644 --- a/crates/astrid-cli/src/commands/update_auth_tests.rs +++ b/crates/astrid-cli/src/commands/update_auth_tests.rs @@ -32,6 +32,27 @@ fn release_identity_is_exact_and_tag_bound() { ); } +#[test] +fn staged_download_failures_preserve_the_bounded_download_cause() { + let bundle_error = + anyhow::anyhow!("publisher-authentication bundle exceeds 10485760 byte limit"); + let bundle = publisher_bundle_download_error(&bundle_error); + assert_eq!( + bundle.to_string(), + "publisher authentication failed: could not download Sigstore bundle: \ + publisher-authentication bundle exceeds 10485760 byte limit" + ); + + let manifest_error = + anyhow::anyhow!("BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable"); + let manifest = integrity_manifest_download_error(&manifest_error); + assert_eq!( + manifest.to_string(), + "integrity check failed: could not download BLAKE3SUMS.txt: \ + BLAKE3 integrity manifest download failed: HTTP 503 Service Unavailable" + ); +} + #[test] fn real_bundle_verifies_with_its_historical_identity_and_signed_time() { // The leaf certificate expired ten minutes after v0.9.4 was signed. A