libsignify_rs/
file.rs

1//
2// signify-rs: cryptographically sign and verify files
3// lib/src/file.rs: File utility functions
4//
5// Copyright (c) 2025, 2026 Ali Polatel <alip@chesswob.org>
6// Based in part upon OpenBSD's signify which is:
7//   Copyright (c) 2013 Ted Unangst <tedu@openbsd.org>
8//   Copyright (c) 2016 Marc Espie <espie@openbsd.org>
9//   Copyright (c) 2019 Adrian Perez de Castro <aperez@igalia.com>
10//   Copyright (c) 2019 Scott Bennett and other contributors
11//   SPDX-License-Identifier: ISC
12//
13// SPDX-License-Identifier: ISC
14
15use crate::crypto::{CHECKSUM_LEN, KDFALG, KEYNUMLEN, PKALG, SALT_LEN};
16use crate::error::{Error, Result};
17use base64ct::{Base64, Encoding as _};
18use core::str;
19use memchr::memchr;
20use static_assertions::assert_eq_size;
21use std::fs::File;
22use std::fs::OpenOptions;
23use std::io::stdin;
24use std::io::stdout;
25use std::io::{Read, Write};
26use std::path::Path;
27use zeroize::Zeroize;
28use zeroize::Zeroizing;
29
30/// Untrusted comment header.
31pub const COMMENTHDR: &str = "untrusted comment: ";
32/// Max comment length.
33pub const MAX_COMMENT_LEN: usize = 1024;
34
35/// Encrypted secret key structure.
36///
37/// Stores the encrypted secret key along with KDF parameters and checksums.
38#[repr(C)]
39#[derive(Debug)]
40pub struct EncKey {
41    /// Public key algorithm (must be "Ed").
42    pub pkalg: [u8; 2],
43    /// KDF algorithm (must be "BK").
44    pub kdfalg: [u8; 2],
45    /// Number of KDF rounds.
46    pub kdfrounds: u32,
47    /// Salt for KDF.
48    pub salt: [u8; SALT_LEN],
49    /// Checksum of the decrypted key.
50    pub checksum: [u8; CHECKSUM_LEN],
51    /// Key ID (`KeyNum`).
52    pub keynum: [u8; KEYNUMLEN],
53    /// Encrypted secret key data.
54    pub seckey: Zeroizing<[u8; 64]>,
55}
56
57impl Zeroize for EncKey {
58    fn zeroize(&mut self) {
59        self.pkalg.zeroize();
60        self.kdfalg.zeroize();
61        self.kdfrounds.zeroize();
62        self.salt.zeroize();
63        self.checksum.zeroize();
64        self.keynum.zeroize();
65        self.seckey.zeroize();
66    }
67}
68
69impl Drop for EncKey {
70    fn drop(&mut self) {
71        self.zeroize();
72    }
73}
74
75/// Public key structure.
76#[repr(C)]
77#[derive(Debug, Clone, Copy)]
78pub struct PubKey {
79    /// Public key algorithm (must be "Ed").
80    pub pkalg: [u8; 2],
81    /// Key ID (`KeyNum`).
82    pub keynum: [u8; KEYNUMLEN],
83    /// Public key data.
84    pub pubkey: [u8; 32],
85}
86
87/// Signature structure.
88#[derive(Debug, Clone, Copy)]
89pub struct Sig {
90    /// Public key algorithm (must be "Ed").
91    pub pkalg: [u8; 2],
92    /// Key ID (`KeyNum`).
93    pub keynum: [u8; KEYNUMLEN],
94    /// Signature data.
95    pub sig: [u8; 64],
96}
97
98impl EncKey {
99    /// Parse `EncKey` from raw bytes.
100    ///
101    /// # Errors
102    ///
103    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
104    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
105    /// Returns `Error::UnsupportedKdfAlgo` if algorithm is not BK.
106    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
107        let pkalg = bytes
108            .get(0..2)
109            .ok_or(Error::InvalidKeyLength)?
110            .try_into()
111            .map_err(|_e| Error::InvalidKeyLength)?;
112        let kdfalg = bytes
113            .get(2..4)
114            .ok_or(Error::InvalidKeyLength)?
115            .try_into()
116            .map_err(|_e| Error::InvalidKeyLength)?;
117        let kdfrounds_bytes = bytes.get(4..8).ok_or(Error::InvalidKeyLength)?;
118        let kdfrounds = u32::from_be_bytes(
119            kdfrounds_bytes
120                .try_into()
121                .map_err(|_e| Error::InvalidKeyLength)?,
122        );
123        let salt = bytes
124            .get(8..24)
125            .ok_or(Error::InvalidKeyLength)?
126            .try_into()
127            .map_err(|_e| Error::InvalidKeyLength)?;
128        let checksum = bytes
129            .get(24..32)
130            .ok_or(Error::InvalidKeyLength)?
131            .try_into()
132            .map_err(|_e| Error::InvalidKeyLength)?;
133        let keynum = bytes
134            .get(32..40)
135            .ok_or(Error::InvalidKeyLength)?
136            .try_into()
137            .map_err(|_e| Error::InvalidKeyLength)?;
138        let seckey = Zeroizing::new(
139            bytes
140                .get(40..104)
141                .ok_or(Error::InvalidKeyLength)?
142                .try_into()
143                .map_err(|_e| Error::InvalidKeyLength)?,
144        );
145
146        // Strict length check
147        if bytes.len() != 104 {
148            return Err(Error::InvalidKeyLength);
149        }
150
151        if pkalg != PKALG {
152            return Err(Error::UnsupportedPkAlgo);
153        }
154        if kdfalg != KDFALG {
155            return Err(Error::UnsupportedKdfAlgo);
156        }
157
158        Ok(Self {
159            pkalg,
160            kdfalg,
161            kdfrounds,
162            salt,
163            checksum,
164            keynum,
165            seckey,
166        })
167    }
168
169    /// Serialize `EncKey` to raw bytes.
170    #[must_use]
171    pub fn to_bytes(&self) -> Zeroizing<Vec<u8>> {
172        let mut out = Zeroizing::new(Vec::with_capacity(104));
173        out.extend_from_slice(&self.pkalg);
174        out.extend_from_slice(&self.kdfalg);
175        out.extend_from_slice(&self.kdfrounds.to_be_bytes());
176        out.extend_from_slice(&self.salt);
177        out.extend_from_slice(&self.checksum);
178        out.extend_from_slice(&self.keynum);
179        out.extend_from_slice(self.seckey.as_ref());
180        out
181    }
182}
183
184impl PubKey {
185    /// Parse `PubKey` from bytes.
186    ///
187    /// # Errors
188    ///
189    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
190    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
191    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
192        let pkalg = bytes
193            .get(0..2)
194            .ok_or(Error::InvalidKeyLength)?
195            .try_into()
196            .map_err(|_e| Error::InvalidKeyLength)?;
197        let keynum = bytes
198            .get(2..10)
199            .ok_or(Error::InvalidKeyLength)?
200            .try_into()
201            .map_err(|_e| Error::InvalidKeyLength)?;
202        let pubkey = bytes
203            .get(10..42)
204            .ok_or(Error::InvalidKeyLength)?
205            .try_into()
206            .map_err(|_e| Error::InvalidKeyLength)?;
207
208        if bytes.len() != 42 {
209            return Err(Error::InvalidKeyLength);
210        }
211
212        if pkalg != PKALG {
213            return Err(Error::UnsupportedPkAlgo);
214        }
215
216        Ok(Self {
217            pkalg,
218            keynum,
219            pubkey,
220        })
221    }
222
223    /// Serialize `PubKey` to bytes.
224    #[must_use]
225    pub fn to_bytes(&self) -> Vec<u8> {
226        let mut out = Vec::with_capacity(42);
227        out.extend_from_slice(&self.pkalg);
228        out.extend_from_slice(&self.keynum);
229        out.extend_from_slice(&self.pubkey);
230        out
231    }
232}
233
234impl Sig {
235    /// Parse `Sig` from bytes.
236    ///
237    /// # Errors
238    ///
239    /// Returns `Error::InvalidKeyLength` if byte length is incorrect.
240    /// Returns `Error::UnsupportedPkAlgo` if algorithm is not Ed.
241    pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
242        let pkalg = bytes
243            .get(0..2)
244            .ok_or(Error::InvalidKeyLength)?
245            .try_into()
246            .map_err(|_e| Error::InvalidKeyLength)?;
247        let keynum = bytes
248            .get(2..10)
249            .ok_or(Error::InvalidKeyLength)?
250            .try_into()
251            .map_err(|_e| Error::InvalidKeyLength)?;
252        let sig = bytes
253            .get(10..74)
254            .ok_or(Error::InvalidKeyLength)?
255            .try_into()
256            .map_err(|_e| Error::InvalidKeyLength)?;
257
258        if bytes.len() != 74 {
259            return Err(Error::InvalidKeyLength);
260        }
261
262        if pkalg != PKALG {
263            return Err(Error::UnsupportedPkAlgo);
264        }
265
266        Ok(Self { pkalg, keynum, sig })
267    }
268
269    /// Serialize `Sig` to bytes.
270    #[must_use]
271    pub fn to_bytes(&self) -> Vec<u8> {
272        let mut out = Vec::with_capacity(74);
273        out.extend_from_slice(&self.pkalg);
274        out.extend_from_slice(&self.keynum);
275        out.extend_from_slice(&self.sig);
276        out
277    }
278}
279
280/// Parse a signify file from a stream.
281///
282/// Reads from `reader`, parses the OpenBSD-compatible format
283/// (comment line + base64 line), and decodes the object using `parse_fn`.
284///
285/// # Errors
286///
287/// Returns `Error::Io` if file cannot be read.
288/// Returns `Error::InvalidCommentHeader` if header is missing or malformed.
289/// Returns `Error::InvalidSignatureUtf8` if base64 is invalid utf8.
290/// Returns `Error::Base64Decode` if decoding fails.
291/// Returns various errors from `parse_fn`.
292pub fn parse_stream<F, R, T>(mut reader: R, parse_fn: F) -> Result<(T, Vec<u8>)>
293where
294    R: Read,
295    F: Fn(&[u8]) -> Result<T>,
296{
297    // Bounded read for header (comment + base64).
298    // Keys are small (104 bytes encoded ~ 140 bytes + comment).
299    // Signatures are small (74 bytes encoded ~ 100 bytes + comment).
300    // 4KB is generous.
301    const HEADER_LIMIT: usize = 4096;
302    let mut header_buf = vec![0_u8; HEADER_LIMIT];
303
304    // Read up to HEADER_LIMIT.
305    let mut total_read = 0;
306    while total_read < HEADER_LIMIT {
307        let n = reader
308            .read(&mut header_buf[total_read..])
309            .map_err(Error::Io)?;
310        if n == 0 {
311            break;
312        }
313        total_read = total_read.checked_add(n).ok_or(Error::Overflow)?;
314    }
315    header_buf.truncate(total_read);
316
317    // Find first newline.
318    let n1 = memchr(b'\n', &header_buf).ok_or(Error::InvalidCommentHeader)?;
319    let header_bytes = &header_buf[..n1];
320
321    // Strict verify prefix.
322    let prefix = COMMENTHDR.as_bytes();
323    if !header_bytes.starts_with(prefix) {
324        return Err(Error::InvalidCommentHeader);
325    }
326    let comment = header_bytes[prefix.len()..].to_vec();
327
328    let n2_start = n1.checked_add(1).ok_or(Error::Overflow)?;
329    let n2 = memchr(b'\n', &header_buf[n2_start..])
330        .unwrap_or_else(|| header_buf.len().saturating_sub(n2_start));
331
332    let b64_start = n2_start;
333    let b64_end = b64_start.checked_add(n2).ok_or(Error::Overflow)?;
334
335    if b64_end > header_buf.len() {
336        return Err(Error::InvalidCommentHeader);
337    }
338
339    let b64_bytes = &header_buf[b64_start..b64_end];
340
341    // Base64 decode.
342    let b64_str = str::from_utf8(b64_bytes).map_err(|_e| Error::InvalidSignatureUtf8)?;
343    let decoded = Base64::decode_vec(b64_str.trim()).map_err(Error::Base64Decode)?;
344
345    let obj = parse_fn(&decoded)?;
346    Ok((obj, comment))
347}
348
349/// Parse a signify file.
350///
351/// Reads file at `path` (or stdin if "-"), parses the OpenBSD-compatible format
352/// (comment line + base64 line), and decodes the object using `parse_fn`.
353///
354/// # Errors
355///
356/// Returns `Error::Io` if file cannot be read.
357/// Returns `Error::InvalidCommentHeader` if header is missing or malformed.
358/// Returns `Error::InvalidSignatureUtf8` if base64 is invalid utf8.
359/// Returns `Error::Base64Decode` if decoding fails.
360/// Returns various errors from `parse_fn`.
361pub fn parse<T, F>(path: &Path, parse_fn: F) -> Result<(T, Vec<u8>)>
362where
363    F: Fn(&[u8]) -> Result<T>,
364{
365    let reader: Box<dyn Read> = if path.to_str() == Some("-") {
366        Box::new(stdin())
367    } else {
368        Box::new(open(path, false)?)
369    };
370
371    parse_stream(reader, parse_fn)
372}
373
374/// Write a signify file to a stream.
375///
376/// Writes `comment` and base64-encoded `data` to `writer`.
377///
378/// # Errors
379///
380/// Returns `Error::Io` on write failure.
381pub fn write_stream(mut writer: impl Write, comment: &[u8], data: &[u8]) -> Result<()> {
382    let encoded = Base64::encode_string(data);
383
384    let mut content = Vec::new();
385    content.extend_from_slice(COMMENTHDR.as_bytes());
386    content.extend_from_slice(comment);
387    content.push(b'\n');
388    content.extend_from_slice(encoded.as_bytes());
389    content.push(b'\n');
390
391    writer.write_all(&content).map_err(Error::Io)?;
392    Ok(())
393}
394
395/// Write a signify file.
396///
397/// Writes `comment` and base64-encoded `data` to `path` (or stdout if "-").
398///
399/// # Errors
400///
401/// Returns `Error::Io` on file creation or write failure.
402pub fn write(path: &Path, comment: &[u8], data: &[u8]) -> Result<()> {
403    let writer: Box<dyn Write> = if path.to_str() == Some("-") {
404        Box::new(stdout())
405    } else {
406        Box::new(open(path, true)?)
407    };
408
409    write_stream(writer, comment, data)
410}
411
412/// Open a file for read or write.
413///
414/// If `write` the file is created new with mode 600.
415pub fn open(path: &Path, write: bool) -> Result<File> {
416    let mut opts = OpenOptions::new();
417    if write {
418        opts.write(true).create_new(true);
419        #[cfg(unix)]
420        {
421            use std::os::unix::fs::OpenOptionsExt;
422            opts.mode(0o600);
423        }
424    } else {
425        opts.read(true);
426    }
427
428    opts.open(path).map_err(Error::Io)
429}
430
431assert_eq_size!(EncKey, [u8; 104]);
432assert_eq_size!(PubKey, [u8; 42]);
433assert_eq_size!(Sig, [u8; 74]);
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_enckey_serialization() -> crate::error::Result<()> {
441        let enc = EncKey {
442            pkalg: PKALG,
443            kdfalg: KDFALG,
444            kdfrounds: 42,
445            salt: [1u8; SALT_LEN],
446            checksum: [2u8; CHECKSUM_LEN],
447            keynum: [3u8; KEYNUMLEN],
448            seckey: Zeroizing::new([4u8; 64]),
449        };
450        let bytes = enc.to_bytes();
451        let enc2 = EncKey::from_bytes(&bytes)?;
452        assert_eq!(enc.kdfrounds, enc2.kdfrounds);
453        assert_eq!(enc.salt, enc2.salt);
454
455        // Test invalid lengths.
456        assert!(matches!(
457            EncKey::from_bytes(&bytes[..103]),
458            Err(Error::InvalidKeyLength)
459        ));
460
461        let mut long = bytes.clone();
462        long.push(0);
463        assert!(matches!(
464            EncKey::from_bytes(&long),
465            Err(Error::InvalidKeyLength)
466        ));
467
468        // Test bad PKALG.
469        let mut bad_alg = bytes.clone();
470        bad_alg[0] = b'X';
471        assert!(matches!(
472            EncKey::from_bytes(&bad_alg),
473            Err(Error::UnsupportedPkAlgo)
474        ));
475
476        // Test bad KDFALG.
477        let mut bad_kdf = bytes.clone();
478        bad_kdf[2] = b'X';
479        assert!(matches!(
480            EncKey::from_bytes(&bad_kdf),
481            Err(Error::UnsupportedKdfAlgo)
482        ));
483
484        Ok(())
485    }
486
487    #[test]
488    fn test_pubkey_serialization() -> crate::error::Result<()> {
489        let pubk = PubKey {
490            pkalg: PKALG,
491            keynum: [1u8; KEYNUMLEN],
492            pubkey: [2u8; 32],
493        };
494        let bytes = pubk.to_bytes();
495        let pubk2 = PubKey::from_bytes(&bytes)?;
496        assert_eq!(pubk.keynum, pubk2.keynum);
497
498        // Test invalid lengths.
499        assert!(matches!(
500            PubKey::from_bytes(&bytes[..41]),
501            Err(Error::InvalidKeyLength)
502        ));
503        let mut long = bytes.clone();
504        long.push(0);
505        assert!(matches!(
506            PubKey::from_bytes(&long),
507            Err(Error::InvalidKeyLength)
508        ));
509
510        // Test bad PKALG.
511        let mut bad_alg = bytes.clone();
512        bad_alg[0] = b'X';
513        assert!(matches!(
514            PubKey::from_bytes(&bad_alg),
515            Err(Error::UnsupportedPkAlgo)
516        ));
517
518        Ok(())
519    }
520
521    #[test]
522    fn test_sig_serialization() -> crate::error::Result<()> {
523        let sig = Sig {
524            pkalg: PKALG,
525            keynum: [1u8; KEYNUMLEN],
526            sig: [0u8; 64],
527        };
528        let bytes = sig.to_bytes();
529        let sig2 = Sig::from_bytes(&bytes)?;
530        assert_eq!(sig.keynum, sig2.keynum);
531
532        // Test invalid lengths.
533        assert!(matches!(
534            Sig::from_bytes(&bytes[..73]),
535            Err(Error::InvalidKeyLength)
536        ));
537
538        let mut long = bytes.clone();
539        long.push(0);
540        assert!(matches!(
541            Sig::from_bytes(&long),
542            Err(Error::InvalidKeyLength)
543        ));
544
545        // Test bad PKALG.
546        let mut bad_alg = bytes.clone();
547        bad_alg[0] = b'X';
548        assert!(matches!(
549            Sig::from_bytes(&bad_alg),
550            Err(Error::UnsupportedPkAlgo)
551        ));
552
553        Ok(())
554    }
555
556    #[test]
557    #[cfg_attr(any(target_arch = "wasm32", target_arch = "wasm64"), ignore)]
558    fn test_file_io() -> std::result::Result<(), Box<dyn std::error::Error>> {
559        let dir = tempfile::tempdir()?;
560        let path = dir.path().join("secret.key");
561        let data = b"secret data";
562
563        write(&path, b"mycomment", data)?;
564
565        let (read_data, comment) = parse::<Vec<u8>, _>(&path, |b| Ok(b.to_vec()))?;
566        assert_eq!(read_data, data);
567        assert_eq!(comment, b"mycomment");
568
569        // Test missing file.
570        let missing = dir.path().join("missing");
571        assert!(matches!(
572            parse::<Vec<u8>, _>(&missing, |_| Ok(vec![])),
573            Err(Error::Io(_))
574        ));
575
576        // Test invalid prefix.
577        let bad_prefix = dir.path().join("bad_prefix");
578        let mut f = OpenOptions::new()
579            .write(true)
580            .create_new(true)
581            .open(&bad_prefix)?;
582        f.write_all(b"invalid header\n")?;
583        assert!(matches!(
584            parse::<Vec<u8>, _>(&bad_prefix, |_| Ok(vec![])),
585            Err(Error::InvalidCommentHeader)
586        ));
587
588        // Test missing newline.
589        let no_newline = dir.path().join("no_newline");
590        let mut f = OpenOptions::new()
591            .write(true)
592            .create_new(true)
593            .open(&no_newline)?;
594        f.write_all(b"untrusted comment: foo")?;
595        assert!(matches!(
596            parse::<Vec<u8>, _>(&no_newline, |_| Ok(vec![])),
597            Err(Error::InvalidCommentHeader)
598        ));
599
600        // Test invalid base64 (not utf8).
601        // Create valid structure first.
602        // Overwrite base64 part with garbage.
603        let bad_utf8 = dir.path().join("bad_utf8");
604        write(&bad_utf8, b"comment", b"")?;
605        let mut f = OpenOptions::new().write(true).open(&bad_utf8)?;
606        f.write_all(b"untrusted comment: comment\n\xFF\xFF\n")?;
607        assert!(matches!(
608            parse::<Vec<u8>, _>(&bad_utf8, |_| Ok(vec![])),
609            Err(Error::InvalidSignatureUtf8)
610        ));
611
612        Ok(())
613    }
614}