Skip to main content

wdl_modules/
signing.rs

1//! Ed25519 signing and verification, plus the `module.sig` file format.
2
3use std::fmt;
4use std::io;
5use std::io::Write;
6use std::str::FromStr;
7
8use base64::Engine as _;
9use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
10use ed25519_dalek::Signer as _;
11use ed25519_dalek::Verifier as _;
12use serde::Deserialize;
13use serde::Serialize;
14use thiserror::Error;
15
16use crate::ContentHash;
17
18/// An error parsing an Ed25519 key.
19#[derive(Debug, Error)]
20pub enum KeyError {
21    /// The key text could not be parsed as an OpenSSH key.
22    #[error("invalid OpenSSH key: {0}")]
23    InvalidOpenSshKey(String),
24
25    /// The key parsed but is not an Ed25519 key.
26    #[error("OpenSSH key is not an Ed25519 key")]
27    WrongAlgorithm,
28}
29
30/// An error parsing an Ed25519 signature.
31#[derive(Debug, Error)]
32pub enum SignatureError {
33    /// The signature is not valid base64.
34    #[error("signature is not valid base64")]
35    InvalidBase64,
36
37    /// The signature is not 64 bytes.
38    #[error("signature must be exactly 64 bytes; got {0}")]
39    WrongLength(usize),
40}
41
42/// An error parsing or writing a `module.sig` file.
43#[derive(Debug, Error)]
44pub enum SignatureFileError {
45    /// The file is not valid JSON.
46    #[error("invalid `module.sig` JSON")]
47    InvalidJson(#[from] serde_json::Error),
48
49    /// The `public_key` field could not be parsed as an OpenSSH Ed25519
50    /// public key.
51    #[error(transparent)]
52    Key(#[from] KeyError),
53
54    /// The `signature` field could not be parsed as a base64-encoded
55    /// 64-byte Ed25519 signature.
56    #[error(transparent)]
57    Signature(#[from] SignatureError),
58}
59
60/// An error verifying an Ed25519 signature against a content hash.
61#[derive(Debug, Error)]
62#[error("Ed25519 signature does not match the supplied content hash")]
63pub struct VerifyError;
64
65/// An Ed25519 signing key.
66#[derive(Clone, Debug)]
67pub struct SigningKey(ed25519_dalek::SigningKey);
68
69impl SigningKey {
70    /// Parses an OpenSSH-format Ed25519 private key (the contents of the
71    /// file produced by `ssh-keygen -t ed25519`).
72    pub fn from_openssh(text: &str) -> Result<Self, KeyError> {
73        let key = ssh_key::PrivateKey::from_openssh(text)
74            .map_err(|e| KeyError::InvalidOpenSshKey(e.to_string()))?;
75        let ed = key.key_data().ed25519().ok_or(KeyError::WrongAlgorithm)?;
76        let bytes: &[u8; 32] = &ed.private.to_bytes();
77        Ok(Self(ed25519_dalek::SigningKey::from_bytes(bytes)))
78    }
79
80    /// Returns the corresponding [`VerifyingKey`].
81    pub fn verifying_key(&self) -> VerifyingKey {
82        VerifyingKey(self.0.verifying_key())
83    }
84
85    /// Signs the raw 32-byte content digest of a [`ContentHash`].
86    pub fn sign(&self, digest: &ContentHash) -> Signature {
87        Signature(self.0.sign(digest.as_bytes()))
88    }
89}
90
91/// An Ed25519 verifying key.
92#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(into = "String", try_from = "String")]
94pub struct VerifyingKey(ed25519_dalek::VerifyingKey);
95
96impl VerifyingKey {
97    /// Parses an OpenSSH-format Ed25519 public key (the single-line
98    /// `ssh-ed25519 <base64-blob> [comment]` form produced by
99    /// `ssh-keygen -t ed25519` in the corresponding `.pub` file). Trailing
100    /// comments are not significant.
101    pub fn from_openssh(text: &str) -> Result<Self, KeyError> {
102        let key = ssh_key::PublicKey::from_openssh(text.trim())
103            .map_err(|e| KeyError::InvalidOpenSshKey(e.to_string()))?;
104        let ed = key.key_data().ed25519().ok_or(KeyError::WrongAlgorithm)?;
105        let inner = ed25519_dalek::VerifyingKey::from_bytes(&ed.0)
106            .map_err(|e| KeyError::InvalidOpenSshKey(e.to_string()))?;
107        Ok(Self(inner))
108    }
109
110    /// Returns the canonical OpenSSH form `ssh-ed25519 <base64-blob>`,
111    /// without a trailing comment.
112    pub fn to_openssh(&self) -> String {
113        let ed = ssh_key::public::Ed25519PublicKey(*self.0.as_bytes());
114        let key = ssh_key::PublicKey::from(ssh_key::public::KeyData::Ed25519(ed));
115        // SAFETY: encoding a freshly-constructed in-memory Ed25519
116        // `PublicKey` into OpenSSH form cannot fail.
117        key.to_openssh().unwrap()
118    }
119
120    /// Verifies an Ed25519 [`Signature`] over the raw 32-byte digest of a
121    /// [`ContentHash`].
122    pub fn verify(&self, digest: &ContentHash, sig: &Signature) -> Result<(), VerifyError> {
123        self.0
124            .verify(digest.as_bytes(), &sig.0)
125            .map_err(|_| VerifyError)
126    }
127
128    /// Returns the raw 32-byte public key.
129    pub fn as_bytes(&self) -> &[u8; 32] {
130        self.0.as_bytes()
131    }
132}
133
134impl fmt::Display for VerifyingKey {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(&self.to_openssh())
137    }
138}
139
140impl FromStr for VerifyingKey {
141    type Err = KeyError;
142
143    fn from_str(s: &str) -> Result<Self, Self::Err> {
144        Self::from_openssh(s)
145    }
146}
147
148impl From<VerifyingKey> for String {
149    fn from(key: VerifyingKey) -> Self {
150        key.to_openssh()
151    }
152}
153
154impl TryFrom<String> for VerifyingKey {
155    type Error = KeyError;
156
157    fn try_from(s: String) -> Result<Self, Self::Error> {
158        Self::from_openssh(&s)
159    }
160}
161
162/// An Ed25519 signature over a [`ContentHash`].
163#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
164#[serde(into = "String", try_from = "String")]
165pub struct Signature(ed25519_dalek::Signature);
166
167impl Signature {
168    /// Parses a base64-encoded 64-byte Ed25519 signature.
169    pub fn from_base64(s: &str) -> Result<Self, SignatureError> {
170        let bytes = BASE64_STANDARD
171            .decode(s)
172            .map_err(|_| SignatureError::InvalidBase64)?;
173        let array: [u8; 64] = bytes
174            .as_slice()
175            .try_into()
176            .map_err(|_| SignatureError::WrongLength(bytes.len()))?;
177        Ok(Self(ed25519_dalek::Signature::from_bytes(&array)))
178    }
179
180    /// Returns the signature in base64 form.
181    pub fn to_base64(&self) -> String {
182        BASE64_STANDARD.encode(self.0.to_bytes())
183    }
184}
185
186impl fmt::Display for Signature {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.write_str(&self.to_base64())
189    }
190}
191
192impl FromStr for Signature {
193    type Err = SignatureError;
194
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        Self::from_base64(s)
197    }
198}
199
200impl From<Signature> for String {
201    fn from(sig: Signature) -> Self {
202        sig.to_base64()
203    }
204}
205
206impl TryFrom<String> for Signature {
207    type Error = SignatureError;
208
209    fn try_from(s: String) -> Result<Self, Self::Error> {
210        Self::from_base64(&s)
211    }
212}
213
214/// The contents of a `module.sig` file.
215#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(deny_unknown_fields)]
217pub struct ModuleSignature {
218    /// The signer's Ed25519 public key in OpenSSH format.
219    pub public_key: VerifyingKey,
220    /// The Ed25519 signature over the module's raw 32-byte content hash.
221    pub signature: Signature,
222}
223
224impl ModuleSignature {
225    /// Parses a `module.sig` JSON document.
226    pub fn parse(bytes: &[u8]) -> Result<Self, SignatureFileError> {
227        Ok(crate::strict_json::from_slice(bytes)?)
228    }
229
230    /// Writes the signature as JSON to `w`.
231    pub fn write(&self, w: impl Write) -> io::Result<()> {
232        serde_json::to_writer_pretty(w, self).map_err(io::Error::other)
233    }
234
235    /// Verifies that `signature` is a valid signature of `digest` under
236    /// `public_key`.
237    pub fn verify(&self, digest: &ContentHash) -> Result<(), VerifyError> {
238        self.public_key.verify(digest, &self.signature)
239    }
240}
241
242/// Helpers for tests.
243#[cfg(any(test, feature = "test-utils"))]
244pub mod test_utils {
245    use sha2::Digest;
246    use sha2::Sha256;
247
248    use super::*;
249
250    /// Generates a deterministic [`SigningKey`] from a `u64` seed.
251    ///
252    /// Available only with the `test-utils` cargo feature; not part of the
253    /// production public API. Production callers should generate keys with
254    /// `ssh-keygen -t ed25519` and load them via
255    /// [`SigningKey::from_openssh`].
256    pub fn signing_key_from_seed(seed: u64) -> SigningKey {
257        let mut hasher = Sha256::new();
258        hasher.update(seed.to_le_bytes());
259        let bytes: [u8; 32] = hasher.finalize().into();
260        SigningKey(ed25519_dalek::SigningKey::from_bytes(&bytes))
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::test_utils::signing_key_from_seed;
267    use super::*;
268
269    #[test]
270    fn signs_and_verifies_round_trip() {
271        let signer = signing_key_from_seed(42);
272        let verifier = signer.verifying_key();
273        let digest = ContentHash::from([0xAB; 32]);
274
275        let sig = signer.sign(&digest);
276        verifier.verify(&digest, &sig).unwrap();
277    }
278
279    #[test]
280    fn detects_tampered_signature() {
281        let signer = signing_key_from_seed(42);
282        let verifier = signer.verifying_key();
283        let digest = ContentHash::from([0xAB; 32]);
284
285        let sig = signer.sign(&digest);
286        let tampered = ContentHash::from([0xAC; 32]);
287        assert!(verifier.verify(&tampered, &sig).is_err());
288    }
289
290    #[test]
291    fn verifying_key_round_trips_through_openssh() {
292        let signer = signing_key_from_seed(1);
293        let key = signer.verifying_key();
294        let openssh = key.to_openssh();
295        assert!(openssh.starts_with("ssh-ed25519 "));
296        let parsed = VerifyingKey::from_openssh(&openssh).unwrap();
297        assert_eq!(parsed.as_bytes(), key.as_bytes());
298    }
299
300    #[test]
301    fn verifying_key_accepts_openssh_with_comment() {
302        let signer = signing_key_from_seed(2);
303        let key = signer.verifying_key();
304        let with_comment = format!("{} user@example.com", key.to_openssh());
305        let parsed = VerifyingKey::from_openssh(&with_comment).unwrap();
306        assert_eq!(parsed.as_bytes(), key.as_bytes());
307    }
308
309    #[test]
310    fn signature_round_trips_through_base64() {
311        let signer = signing_key_from_seed(3);
312        let digest = ContentHash::from([0x11; 32]);
313        let sig = signer.sign(&digest);
314        let b64 = sig.to_base64();
315        let parsed = Signature::from_base64(&b64).unwrap();
316        assert_eq!(parsed, sig);
317    }
318
319    #[test]
320    fn module_signature_round_trips_through_json() {
321        let signer = signing_key_from_seed(4);
322        let digest = ContentHash::from([0x22; 32]);
323        let module_sig = ModuleSignature {
324            public_key: signer.verifying_key(),
325            signature: signer.sign(&digest),
326        };
327
328        let mut buf = Vec::new();
329        module_sig.write(&mut buf).unwrap();
330        let parsed = ModuleSignature::parse(&buf).unwrap();
331        assert_eq!(parsed, module_sig);
332        parsed.verify(&digest).unwrap();
333    }
334
335    #[test]
336    fn module_signature_rejects_unknown_keys() {
337        let signer = signing_key_from_seed(5);
338        let digest = ContentHash::from([0x33; 32]);
339        let json = format!(
340            r#"{{
341                "public_key": {},
342                "signature": {},
343                "unexpected": true
344            }}"#,
345            serde_json::to_string(&signer.verifying_key()).unwrap(),
346            serde_json::to_string(&signer.sign(&digest)).unwrap()
347        );
348
349        assert!(ModuleSignature::parse(json.as_bytes()).is_err());
350    }
351
352    #[test]
353    fn module_signature_rejects_duplicate_keys() {
354        let signer = signing_key_from_seed(6);
355        let digest = ContentHash::from([0x44; 32]);
356        let json = format!(
357            r#"{{
358                "public_key": {},
359                "public_key": {},
360                "signature": {}
361            }}"#,
362            serde_json::to_string(&signer.verifying_key()).unwrap(),
363            serde_json::to_string(&signer.verifying_key()).unwrap(),
364            serde_json::to_string(&signer.sign(&digest)).unwrap()
365        );
366
367        let err = ModuleSignature::parse(json.as_bytes()).unwrap_err();
368        assert!(
369            err.to_string().contains("invalid `module.sig` JSON"),
370            "wrong error: {err}"
371        );
372    }
373}