Skip to main content

murk_cli/
signing.rs

1//! Ed25519 signatures over the vault — integrity against an active attacker
2//! with write access to the repo.
3//!
4//! The keyed BLAKE3 MAC (see `lib.rs`) binds ciphertexts together but does NOT
5//! authenticate the *author*. The MAC key lives inside the age-encrypted `meta`
6//! blob, and age encryption needs only the recipients' public keys — which sit
7//! in the plaintext header. So anyone who can write to the repo can mint a fresh
8//! MAC key, recompute a valid MAC over tampered content, and re-encrypt `meta`;
9//! the MAC then verifies clean. See `THREAT_MODEL.md`.
10//!
11//! Signatures close this for non-recipient attackers: a writer signs the vault
12//! with an Ed25519 key derived from the same BIP39 seed as their age key, and
13//! loaders verify the signature against the signer's registered verifying key.
14//! An attacker holding no recipient private key cannot forge a valid signature.
15//!
16//! "Sign-when-capable": native age keys (the `murk init` default) derive a
17//! signing key deterministically, and ssh-ed25519 keys sign with the key itself
18//! (see the ssh-ed25519 section below). `ssh-rsa` and hardware/plugin identities
19//! cannot sign, so their saves are left unsigned (a warning, not an error). A
20//! *present* signature must verify — an invalid one is tampering and hard-fails.
21
22use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
23use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
24use zeroize::Zeroizing;
25
26/// Domain-separation context for deriving the Ed25519 signing seed from the raw
27/// age x25519 key bytes. Versioned so the derivation can change without silently
28/// colliding with a future scheme. Using a KDF (not the raw key bytes) keeps the
29/// signing key cryptographically independent of the encryption key.
30const SIGNING_KDF_CONTEXT: &str = "murk.vault.signing.ed25519.v1";
31
32/// Derive an Ed25519 signing key from the raw 32-byte age x25519 secret.
33///
34/// The BIP39 mnemonic encodes these same 32 bytes, so the signing key recovers
35/// for free from the recovery phrase — no extra words to back up.
36pub fn signing_key_from_age_bytes(age_key_bytes: &[u8]) -> SigningKey {
37    let seed = Zeroizing::new(blake3::derive_key(SIGNING_KDF_CONTEXT, age_key_bytes));
38    SigningKey::from_bytes(&seed)
39}
40
41/// The base64-encoded Ed25519 verifying (public) key for a signing key.
42pub fn verifying_key_b64(sk: &SigningKey) -> String {
43    BASE64.encode(sk.verifying_key().to_bytes())
44}
45
46/// Sign a message, returning the base64-encoded 64-byte signature.
47pub fn sign(sk: &SigningKey, message: &[u8]) -> String {
48    BASE64.encode(sk.sign(message).to_bytes())
49}
50
51/// Verify a base64-encoded signature against a base64-encoded verifying key.
52///
53/// Returns false on any decode error, wrong length, or signature mismatch —
54/// callers treat a present-but-invalid signature as tampering.
55pub fn verify(verifying_key_b64: &str, signature_b64: &str, message: &[u8]) -> bool {
56    let Ok(vk_bytes) = BASE64.decode(verifying_key_b64) else {
57        return false;
58    };
59    let Ok(vk_arr) = <[u8; 32]>::try_from(vk_bytes.as_slice()) else {
60        return false;
61    };
62    let Ok(vk) = VerifyingKey::from_bytes(&vk_arr) else {
63        return false;
64    };
65    let Ok(sig_bytes) = BASE64.decode(signature_b64) else {
66        return false;
67    };
68    let Ok(sig_arr) = <[u8; 64]>::try_from(sig_bytes.as_slice()) else {
69        return false;
70    };
71    let sig = Signature::from_bytes(&sig_arr);
72    vk.verify(message, &sig).is_ok()
73}
74
75// -- ssh-ed25519 signing --
76//
77// Unlike age keys, an ssh-ed25519 key IS an Ed25519 signing key, so we sign with
78// the key itself rather than a derived one. The tradeoff is deliberate: the
79// verifying key is then recoverable from the `ssh-ed25519 …` recipient string
80// (self-authenticating — no registry entry, no TOFU pin needed). age won't hand
81// us the SSH scalar, so we parse it from the retained OpenSSH PEM.
82
83/// Parse an Ed25519 signing key from an OpenSSH private-key PEM.
84///
85/// Returns `None` for non-ed25519 keys (e.g. ssh-rsa), encrypted keys, or any
86/// parse failure — the caller then leaves the vault unsigned.
87pub fn ed25519_signing_key_from_openssh(pem: &str) -> Option<SigningKey> {
88    let key = ssh_key::PrivateKey::from_openssh(pem).ok()?;
89    let keypair = key.key_data().ed25519()?;
90    // The 32-byte Ed25519 seed. Zeroized after `SigningKey` copies it.
91    let seed = Zeroizing::new(keypair.private.to_bytes());
92    Some(SigningKey::from_bytes(&seed))
93}
94
95/// Extract the base64 Ed25519 verifying key from an `ssh-ed25519 <base64> [comment]`
96/// recipient string. Tolerates a trailing comment. Returns `None` for non-ed25519
97/// or unparseable input. The encoding matches what [`verify`] expects.
98pub fn ed25519_verifying_key_b64_from_ssh_recipient(recipient: &str) -> Option<String> {
99    let key = ssh_key::PublicKey::from_openssh(recipient).ok()?;
100    let pk = key.key_data().ed25519()?;
101    Some(BASE64.encode(pk.as_ref()))
102}
103
104/// Whether two strings name the same ssh-ed25519 key, ignoring any trailing
105/// comment. Both must be `ssh-ed25519 <base64> [comment]`; only the key type and
106/// base64 blob are compared. Needed because recipients may be stored with a
107/// comment while an identity's `pubkey_string()` drops it.
108pub fn ssh_ed25519_key_eq(a: &str, b: &str) -> bool {
109    fn key_blob(s: &str) -> Option<&str> {
110        let mut it = s.split_whitespace();
111        (it.next()? == "ssh-ed25519").then(|| it.next()).flatten()
112    }
113    match (key_blob(a), key_blob(b)) {
114        (Some(x), Some(y)) => x == y,
115        _ => false,
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn bytes(seed: u8) -> [u8; 32] {
124        [seed; 32]
125    }
126
127    #[test]
128    fn derivation_is_deterministic() {
129        let a = signing_key_from_age_bytes(&bytes(7));
130        let b = signing_key_from_age_bytes(&bytes(7));
131        assert_eq!(a.to_bytes(), b.to_bytes());
132    }
133
134    #[test]
135    fn different_age_keys_yield_different_signing_keys() {
136        let a = signing_key_from_age_bytes(&bytes(1));
137        let b = signing_key_from_age_bytes(&bytes(2));
138        assert_ne!(a.to_bytes(), b.to_bytes());
139    }
140
141    #[test]
142    fn signing_seed_is_not_the_raw_age_key() {
143        // The KDF must not pass the age key through unchanged — reusing the
144        // encryption scalar as a signing scalar is the cross-protocol footgun
145        // the domain-separated derivation exists to avoid.
146        let raw = bytes(9);
147        let sk = signing_key_from_age_bytes(&raw);
148        assert_ne!(sk.to_bytes(), raw);
149    }
150
151    #[test]
152    fn sign_and_verify_roundtrip() {
153        let sk = signing_key_from_age_bytes(&bytes(3));
154        let vk = verifying_key_b64(&sk);
155        let sig = sign(&sk, b"the vault bytes");
156        assert!(verify(&vk, &sig, b"the vault bytes"));
157    }
158
159    #[test]
160    fn verify_rejects_tampered_message() {
161        let sk = signing_key_from_age_bytes(&bytes(3));
162        let vk = verifying_key_b64(&sk);
163        let sig = sign(&sk, b"the vault bytes");
164        assert!(!verify(&vk, &sig, b"the vault bytes (tampered)"));
165    }
166
167    #[test]
168    fn verify_rejects_wrong_key() {
169        let sk = signing_key_from_age_bytes(&bytes(3));
170        let other = verifying_key_b64(&signing_key_from_age_bytes(&bytes(4)));
171        let sig = sign(&sk, b"msg");
172        assert!(!verify(&other, &sig, b"msg"));
173    }
174
175    #[test]
176    fn verify_rejects_garbage_inputs() {
177        let sk = signing_key_from_age_bytes(&bytes(3));
178        let vk = verifying_key_b64(&sk);
179        let sig = sign(&sk, b"msg");
180        assert!(!verify("not base64!!!", &sig, b"msg"));
181        assert!(!verify(&vk, "not base64!!!", b"msg"));
182        assert!(!verify(&vk, &BASE64.encode([0u8; 10]), b"msg"));
183    }
184
185    // A real unencrypted ssh-ed25519 keypair (also used in crypto.rs tests).
186    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-----";
187    const SSH_ED25519_PK: &str =
188        "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHsKLqeplhpW+uObz5dvMgjz1OxfM/XXUB+VHtZ6isGN";
189
190    #[test]
191    fn ssh_signing_key_vk_matches_recipient() {
192        // The vk the SSH signing key produces must equal the vk embedded in the
193        // recipient string — that equality is what makes SSH signatures
194        // self-authenticating (no registry).
195        let sk = ed25519_signing_key_from_openssh(SSH_ED25519_SK).unwrap();
196        let from_sk = verifying_key_b64(&sk);
197        let from_pk = ed25519_verifying_key_b64_from_ssh_recipient(SSH_ED25519_PK).unwrap();
198        assert_eq!(from_sk, from_pk);
199    }
200
201    #[test]
202    fn ssh_sign_verify_roundtrip_against_recipient() {
203        let sk = ed25519_signing_key_from_openssh(SSH_ED25519_SK).unwrap();
204        let vk = ed25519_verifying_key_b64_from_ssh_recipient(SSH_ED25519_PK).unwrap();
205        let sig = sign(&sk, b"vault bytes");
206        assert!(verify(&vk, &sig, b"vault bytes"));
207        assert!(!verify(&vk, &sig, b"tampered"));
208    }
209
210    #[test]
211    fn ssh_recipient_vk_tolerates_trailing_comment() {
212        let with_comment = format!("{SSH_ED25519_PK} someone@host");
213        assert_eq!(
214            ed25519_verifying_key_b64_from_ssh_recipient(SSH_ED25519_PK),
215            ed25519_verifying_key_b64_from_ssh_recipient(&with_comment),
216        );
217    }
218
219    #[test]
220    fn ssh_helpers_reject_non_ed25519_and_garbage() {
221        assert!(ed25519_signing_key_from_openssh("not a key").is_none());
222        assert!(ed25519_verifying_key_b64_from_ssh_recipient("ssh-rsa AAAAB3xyz").is_none());
223        assert!(ed25519_verifying_key_b64_from_ssh_recipient("garbage").is_none());
224    }
225
226    #[test]
227    fn ssh_key_eq_ignores_comment_requires_ed25519() {
228        let with_comment = format!("{SSH_ED25519_PK} comment@host");
229        assert!(ssh_ed25519_key_eq(SSH_ED25519_PK, &with_comment));
230        assert!(!ssh_ed25519_key_eq(
231            SSH_ED25519_PK,
232            "ssh-ed25519 AAAADIFFERENTKEYBLOB"
233        ));
234        // Non-ed25519 never matches, even if identical.
235        assert!(!ssh_ed25519_key_eq("ssh-rsa AAAA", "ssh-rsa AAAA"));
236    }
237}