use anyhow::Context;
use astrid_crypto::{KeyPair, PublicKey, Signature};
use super::lock::DistroLock;
const SIG_DOMAIN_TAG: &[u8] = b"astrid-distro-lock-sig-v1\x00";
pub(crate) fn canonical_lock_bytes(lock: &DistroLock) -> anyhow::Result<Vec<u8>> {
serde_json::to_vec(lock).context("failed to canonicalize Distro.lock for signing")
}
pub(crate) fn lock_signing_digest(lock: &DistroLock) -> anyhow::Result<[u8; 32]> {
let bytes = canonical_lock_bytes(lock)?;
let mut hasher = blake3::Hasher::new();
hasher.update(SIG_DOMAIN_TAG);
hasher.update(&bytes);
Ok(*hasher.finalize().as_bytes())
}
pub(crate) fn sign_lock(lock: &DistroLock, keypair: &KeyPair) -> anyhow::Result<String> {
let digest = lock_signing_digest(lock)?;
let sig = keypair.sign(&digest);
Ok(sig.to_hex())
}
pub(crate) fn parse_pubkey(wire: &str) -> anyhow::Result<PublicKey> {
let b64 = wire.strip_prefix("ed25519:").ok_or_else(|| {
anyhow::anyhow!("public key must be in 'ed25519:<base64>' form, got {wire:?}")
})?;
PublicKey::from_base64(b64).map_err(|e| anyhow::anyhow!("invalid ed25519 public key: {e}"))
}
pub(crate) fn pubkey_to_wire(pk: &PublicKey) -> String {
format!("ed25519:{}", pk.to_base64())
}
pub(crate) fn verify_lock(
lock: &DistroLock,
sig_hex: &str,
pubkey: &PublicKey,
) -> anyhow::Result<()> {
let sig = Signature::from_hex(sig_hex.trim())
.map_err(|e| anyhow::anyhow!("malformed Distro.sig (expected 64-byte hex): {e}"))?;
let digest = lock_signing_digest(lock)?;
pubkey
.verify(&digest, &sig)
.map_err(|_| anyhow::anyhow!("distro signature verification failed"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::commands::distro::lock::{DistroLock, DistroLockMeta, LockedCapsule};
fn sample_lock() -> DistroLock {
DistroLock {
schema_version: 1,
distro: DistroLockMeta {
id: "test".into(),
version: "0.1.0".into(),
resolved_at: "2026-01-01T00:00:00Z".into(),
},
capsules: vec![LockedCapsule {
name: "astrid-capsule-cli".into(),
version: "0.1.0".into(),
source: "@org/cli".into(),
hash: "blake3:abc".into(),
resolved_ref: Some("v0.1.0".into()),
}],
manifest_hash: Some("blake3:def".into()),
}
}
#[test]
fn canonical_bytes_are_stable() {
let lock = sample_lock();
assert_eq!(
canonical_lock_bytes(&lock).unwrap(),
canonical_lock_bytes(&sample_lock()).unwrap()
);
}
#[test]
fn sign_then_verify_roundtrips() {
let kp = KeyPair::generate();
let lock = sample_lock();
let sig = sign_lock(&lock, &kp).unwrap();
let pk = kp.export_public_key();
assert!(verify_lock(&lock, &sig, &pk).is_ok());
}
#[test]
fn verify_fails_on_tampered_lock() {
let kp = KeyPair::generate();
let lock = sample_lock();
let sig = sign_lock(&lock, &kp).unwrap();
let pk = kp.export_public_key();
let mut tampered = sample_lock();
tampered.capsules[0].hash = "blake3:TAMPERED".into();
assert!(verify_lock(&tampered, &sig, &pk).is_err());
}
#[test]
fn signature_over_untagged_digest_does_not_verify() {
let kp = KeyPair::generate();
let lock = sample_lock();
let untagged = *blake3::hash(&canonical_lock_bytes(&lock).unwrap()).as_bytes();
let untagged_sig = kp.sign(&untagged).to_hex();
let pk = kp.export_public_key();
assert!(
verify_lock(&lock, &untagged_sig, &pk).is_err(),
"untagged signature must be rejected by the domain-separated verifier"
);
let tagged_sig = sign_lock(&lock, &kp).unwrap();
assert!(verify_lock(&lock, &tagged_sig, &pk).is_ok());
}
#[test]
fn verify_fails_on_wrong_key() {
let kp = KeyPair::generate();
let other = KeyPair::generate();
let lock = sample_lock();
let sig = sign_lock(&lock, &kp).unwrap();
assert!(verify_lock(&lock, &sig, &other.export_public_key()).is_err());
}
#[test]
fn pubkey_wire_roundtrips() {
let kp = KeyPair::generate();
let pk = kp.export_public_key();
let wire = pubkey_to_wire(&pk);
assert!(wire.starts_with("ed25519:"));
let parsed = parse_pubkey(&wire).unwrap();
assert_eq!(parsed, pk);
}
#[test]
fn parse_pubkey_rejects_missing_prefix() {
assert!(parse_pubkey("AAAA").is_err());
}
}