use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use zeroize::Zeroizing;
const SIGNING_KDF_CONTEXT: &str = "murk.vault.signing.ed25519.v1";
pub fn signing_key_from_age_bytes(age_key_bytes: &[u8]) -> SigningKey {
let seed = Zeroizing::new(blake3::derive_key(SIGNING_KDF_CONTEXT, age_key_bytes));
SigningKey::from_bytes(&seed)
}
pub fn verifying_key_b64(sk: &SigningKey) -> String {
BASE64.encode(sk.verifying_key().to_bytes())
}
pub fn sign(sk: &SigningKey, message: &[u8]) -> String {
BASE64.encode(sk.sign(message).to_bytes())
}
pub fn verify(verifying_key_b64: &str, signature_b64: &str, message: &[u8]) -> bool {
let Ok(vk_bytes) = BASE64.decode(verifying_key_b64) else {
return false;
};
let Ok(vk_arr) = <[u8; 32]>::try_from(vk_bytes.as_slice()) else {
return false;
};
let Ok(vk) = VerifyingKey::from_bytes(&vk_arr) else {
return false;
};
let Ok(sig_bytes) = BASE64.decode(signature_b64) else {
return false;
};
let Ok(sig_arr) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
return false;
};
let sig = Signature::from_bytes(&sig_arr);
vk.verify(message, &sig).is_ok()
}
pub fn ed25519_signing_key_from_openssh(pem: &str) -> Option<SigningKey> {
let key = ssh_key::PrivateKey::from_openssh(pem).ok()?;
let keypair = key.key_data().ed25519()?;
let seed = Zeroizing::new(keypair.private.to_bytes());
Some(SigningKey::from_bytes(&seed))
}
pub fn ed25519_verifying_key_b64_from_ssh_recipient(recipient: &str) -> Option<String> {
let key = ssh_key::PublicKey::from_openssh(recipient).ok()?;
let pk = key.key_data().ed25519()?;
Some(BASE64.encode(pk.as_ref()))
}
pub fn ssh_ed25519_key_eq(a: &str, b: &str) -> bool {
fn key_blob(s: &str) -> Option<&str> {
let mut it = s.split_whitespace();
(it.next()? == "ssh-ed25519").then(|| it.next()).flatten()
}
match (key_blob(a), key_blob(b)) {
(Some(x), Some(y)) => x == y,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bytes(seed: u8) -> [u8; 32] {
[seed; 32]
}
#[test]
fn derivation_is_deterministic() {
let a = signing_key_from_age_bytes(&bytes(7));
let b = signing_key_from_age_bytes(&bytes(7));
assert_eq!(a.to_bytes(), b.to_bytes());
}
#[test]
fn different_age_keys_yield_different_signing_keys() {
let a = signing_key_from_age_bytes(&bytes(1));
let b = signing_key_from_age_bytes(&bytes(2));
assert_ne!(a.to_bytes(), b.to_bytes());
}
#[test]
fn signing_seed_is_not_the_raw_age_key() {
let raw = bytes(9);
let sk = signing_key_from_age_bytes(&raw);
assert_ne!(sk.to_bytes(), raw);
}
#[test]
fn sign_and_verify_roundtrip() {
let sk = signing_key_from_age_bytes(&bytes(3));
let vk = verifying_key_b64(&sk);
let sig = sign(&sk, b"the vault bytes");
assert!(verify(&vk, &sig, b"the vault bytes"));
}
#[test]
fn verify_rejects_tampered_message() {
let sk = signing_key_from_age_bytes(&bytes(3));
let vk = verifying_key_b64(&sk);
let sig = sign(&sk, b"the vault bytes");
assert!(!verify(&vk, &sig, b"the vault bytes (tampered)"));
}
#[test]
fn verify_rejects_wrong_key() {
let sk = signing_key_from_age_bytes(&bytes(3));
let other = verifying_key_b64(&signing_key_from_age_bytes(&bytes(4)));
let sig = sign(&sk, b"msg");
assert!(!verify(&other, &sig, b"msg"));
}
#[test]
fn verify_rejects_garbage_inputs() {
let sk = signing_key_from_age_bytes(&bytes(3));
let vk = verifying_key_b64(&sk);
let sig = sign(&sk, b"msg");
assert!(!verify("not base64!!!", &sig, b"msg"));
assert!(!verify(&vk, "not base64!!!", b"msg"));
assert!(!verify(&vk, &BASE64.encode([0u8; 10]), b"msg"));
}
const SSH_ED25519_SK: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQAAAJCfEwtqnxML\nagAAAAtzc2gtZWQyNTUxOQAAACB7Ci6nqZYaVvrjm8+XbzII89TsXzP111AflR7WeorBjQ\nAAAEADBJvjZT8X6JRJI8xVq/1aU8nMVgOtVnmdwqWwrSlXG3sKLqeplhpW+uObz5dvMgjz\n1OxfM/XXUB+VHtZ6isGNAAAADHN0cjRkQGNhcmJvbgE=\n-----END OPENSSH PRIVATE KEY-----";
const SSH_ED25519_PK: &str =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
#[test]
fn ssh_signing_key_vk_matches_recipient() {
let sk = ed25519_signing_key_from_openssh(SSH_ED25519_SK).unwrap();
let from_sk = verifying_key_b64(&sk);
let from_pk = ed25519_verifying_key_b64_from_ssh_recipient(SSH_ED25519_PK).unwrap();
assert_eq!(from_sk, from_pk);
}
#[test]
fn ssh_sign_verify_roundtrip_against_recipient() {
let sk = ed25519_signing_key_from_openssh(SSH_ED25519_SK).unwrap();
let vk = ed25519_verifying_key_b64_from_ssh_recipient(SSH_ED25519_PK).unwrap();
let sig = sign(&sk, b"vault bytes");
assert!(verify(&vk, &sig, b"vault bytes"));
assert!(!verify(&vk, &sig, b"tampered"));
}
#[test]
fn ssh_recipient_vk_tolerates_trailing_comment() {
let with_comment = format!("{SSH_ED25519_PK} someone@host");
assert_eq!(
ed25519_verifying_key_b64_from_ssh_recipient(SSH_ED25519_PK),
ed25519_verifying_key_b64_from_ssh_recipient(&with_comment),
);
}
#[test]
fn ssh_helpers_reject_non_ed25519_and_garbage() {
assert!(ed25519_signing_key_from_openssh("not a key").is_none());
assert!(ed25519_verifying_key_b64_from_ssh_recipient("ssh-rsa AAAAB3xyz").is_none());
assert!(ed25519_verifying_key_b64_from_ssh_recipient("garbage").is_none());
}
#[test]
fn ssh_key_eq_ignores_comment_requires_ed25519() {
let with_comment = format!("{SSH_ED25519_PK} comment@host");
assert!(ssh_ed25519_key_eq(SSH_ED25519_PK, &with_comment));
assert!(!ssh_ed25519_key_eq(
SSH_ED25519_PK,
"ssh-ed25519 AAAADIFFERENTKEYBLOB"
));
assert!(!ssh_ed25519_key_eq("ssh-rsa AAAA", "ssh-rsa AAAA"));
}
}