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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 0 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions crates/astrid-cli/src/commands/invite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:<hex>` fingerprint (from `invite list`).
#[arg(allow_hyphen_values = true)]
pub token_or_fingerprint: String,
}
Expand Down Expand Up @@ -225,12 +225,12 @@ async fn run_list(args: ListArgs) -> Result<ExitCode> {
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,
Expand Down
185 changes: 158 additions & 27 deletions crates/astrid-cli/src/commands/keypair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -160,7 +160,7 @@ struct KeyMeta {
bound_principal: Option<String>,
}

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.
Expand Down Expand Up @@ -237,7 +237,7 @@ fn run_generate(args: GenerateArgs) -> Result<ExitCode> {

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)
Expand Down Expand Up @@ -283,13 +283,13 @@ fn run_list(args: &ListArgs) -> Result<ExitCode> {
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,
);
}
Expand All @@ -302,7 +302,7 @@ fn run_show(args: &ShowArgs) -> Result<ExitCode> {
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(|_| "<missing>".to_string());
println!("name: {}", args.name);
println!("fingerprint: {}", meta.fingerprint);
Expand Down Expand Up @@ -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(())
Expand Down Expand Up @@ -466,18 +466,51 @@ fn read_public(path: &Path) -> Result<String> {
Ok(trimmed)
}

fn read_meta(path: &Path) -> Result<KeyMeta> {
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
fn read_meta(paths: &KeyPaths) -> Result<KeyMeta> {
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)
}

Expand Down Expand Up @@ -520,11 +553,14 @@ fn scan_keys() -> Result<Vec<KeyEntry>> {
else {
continue;
};
match read_meta(&path) {
Ok(meta) => out.push(KeyEntry {
name: name.to_string(),
meta,
}),
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, meta }),
Err(e) => {
tracing::warn!(name = %name, error = %e, "skipping unreadable keypair meta");
},
Expand Down Expand Up @@ -565,10 +601,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<String> {
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:<base64>`
Expand Down Expand Up @@ -637,12 +673,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]
Expand Down
2 changes: 1 addition & 1 deletion crates/astrid-config/src/defaults.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # Optional: verify binary integrity
#
# # Restart on failure with max 5 retries (exponential backoff applied):
# [servers.react.restart_policy]
Expand Down
Loading
Loading