1use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
23use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
24use zeroize::Zeroizing;
25
26const SIGNING_KDF_CONTEXT: &str = "murk.vault.signing.ed25519.v1";
31
32pub 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
41pub fn verifying_key_b64(sk: &SigningKey) -> String {
43 BASE64.encode(sk.verifying_key().to_bytes())
44}
45
46pub fn sign(sk: &SigningKey, message: &[u8]) -> String {
48 BASE64.encode(sk.sign(message).to_bytes())
49}
50
51pub 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
75pub 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 let seed = Zeroizing::new(keypair.private.to_bytes());
92 Some(SigningKey::from_bytes(&seed))
93}
94
95pub 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
104pub 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 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 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 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 assert!(!ssh_ed25519_key_eq("ssh-rsa AAAA", "ssh-rsa AAAA"));
236 }
237}