Skip to main content

gunnar_crypt/
wrap.rs

1//! The key ring, wrapped to every member key with age, and unwrapped with an
2//! SSH private key FILE or a post-quantum identity file.
3//!
4//! **Two schemes, one trait** ([`WrapScheme`]), chosen per repository by the
5//! level in its members file ([`Level`]):
6//!
7//! * [`SshClassic`] — the members' SSH keys (below). A ring harvested today
8//!   opens the day a quantum computer breaks X25519 or RSA.
9//! * [`StandalonePq`] — age's hybrid ML-KEM-768 + X25519 recipient
10//!   (`age1pq1…`, [`crate::pq`]), a separate key per member device made by
11//!   `gunnar crypt keygen` and kept on it. The ring is wrapped to those keys
12//!   ONLY: one classical stanza beside them would be the weak link. An SSH key
13//!   cannot be such a recipient. (Deriving the hybrid key from an ed25519 SSH
14//!   key's seed would spare members the second key; it is a new construction
15//!   and is not built — it could be a third scheme after an external review.)
16//!
17//! age's SSH recipients do the work: an `ssh-ed25519` key is converted to an
18//! X25519 key (the "tweaked" scheme of age-encryption.org/v1), an `ssh-rsa`
19//! key uses RSA-OAEP, and an `age1…` key is plain X25519. One age file holds
20//! one stanza per key; adding a member is one more stanza.
21//!
22//! **Why age and not our own wrap**: `age` 0.12 (str4d/rage, MIT OR
23//! Apache-2.0) is maintained (0.12.1, 2026-07), has a published format spec,
24//! test vectors and fuzzing, and already speaks SSH keys — the one thing the
25//! owner asked for ("the ssh key decrypts"). A home-made X25519 + HKDF +
26//! ChaCha20-Poly1305 wrap would be shorter, and unreviewed.
27//!
28//! **ssh-agent cannot unwrap.** The agent protocol only signs; decryption
29//! needs the private scalar, so the CLI reads the key FILE and asks for its
30//! passphrase if it has one.
31
32use std::io::{Read, Write};
33
34/// age itself, for callers that hold identities.
35pub use age;
36
37use age::armor::{ArmoredReader, ArmoredWriter, Format};
38use zeroize::Zeroizing;
39
40use crate::keyring::{Keyring, KeyringError};
41pub use crate::members::Level;
42use crate::members::{KeyKind, MemberKey, Members};
43use crate::policy::{self, Refusal};
44
45/// Why the wrap failed.
46#[derive(Debug)]
47pub enum WrapError {
48    /// No member key to wrap to.
49    NoMembers,
50    /// A member key age would not take.
51    BadKey(String, String),
52    /// age itself failed.
53    Age(String),
54}
55
56impl std::fmt::Display for WrapError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            WrapError::NoMembers => write!(f, "the key ring needs at least one member key"),
60            WrapError::BadKey(name, why) => write!(f, "{name}'s key cannot be used: {why}"),
61            WrapError::Age(e) => write!(f, "age: {e}"),
62        }
63    }
64}
65impl std::error::Error for WrapError {}
66
67/// Why the unwrap failed.
68#[derive(Debug)]
69pub enum UnwrapError {
70    /// None of the given identities is a recipient of this key file: the
71    /// holder is not (or no longer) a member.
72    NotARecipient,
73    /// The key file is not a readable age file.
74    Malformed(String),
75    /// The payload decrypted, but it is not a key ring. `gunnar unlock`
76    /// releases nothing in that case.
77    NotAKeyring(KeyringError),
78    /// The ring's stanzas do not match the members file (a stanza for a key
79    /// nobody listed, a missing one, or a classical one in a quantum-safe
80    /// repository): refused before anything is unwrapped.
81    Mismatch(Refusal),
82}
83
84impl std::fmt::Display for UnwrapError {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        match self {
87            UnwrapError::NotARecipient => write!(
88                f,
89                "this SSH key is not one the key ring is wrapped to — you are not a member \
90                 of this encrypted repository (or were removed before this key ring)"
91            ),
92            UnwrapError::Malformed(e) => write!(f, "the key file is not a readable age file: {e}"),
93            UnwrapError::NotAKeyring(e) => write!(f, "the unwrapped payload is refused: {e}"),
94            UnwrapError::Mismatch(r) => write!(f, "the key ring is refused: {r}"),
95        }
96    }
97}
98impl std::error::Error for UnwrapError {}
99
100/// **How a repository's key ring is wrapped.** One implementation per
101/// [`Level`]; [`scheme`] picks it from the members file.
102pub trait WrapScheme {
103    /// The level this scheme gives a repository.
104    fn level(&self) -> Level;
105    /// The age recipient for one member key, or why this scheme cannot take
106    /// it (the words the member is shown).
107    fn recipient(&self, key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String>;
108}
109
110/// The members' SSH keys (`ssh-ed25519`, `ssh-rsa`) and an `age1…` recovery
111/// key: age's SSH and X25519 recipients.
112#[derive(Clone, Copy, Debug)]
113pub struct SshClassic;
114
115impl WrapScheme for SshClassic {
116    fn level(&self) -> Level {
117        Level::Classic
118    }
119    fn recipient(&self, key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String> {
120        match key.kind {
121            KeyKind::SshEd25519 | KeyKind::SshRsa => key
122                .key
123                .parse::<age::ssh::Recipient>()
124                .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
125                .map_err(|e| format!("{e:?}")),
126            KeyKind::Age => key
127                .key
128                .trim()
129                .parse::<age::x25519::Recipient>()
130                .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
131                .map_err(|e| e.to_string()),
132            KeyKind::AgePq => Err(
133                "an age1pq1… key is quantum-safe and this repository is classic; run \
134                 `gunnar crypt upgrade-pq` to make it quantum-safe"
135                    .to_string(),
136            ),
137        }
138    }
139}
140
141/// age's hybrid ML-KEM-768 + X25519 recipient (`age1pq1…`), one separate key
142/// per member device, and nothing else.
143#[derive(Clone, Copy, Debug)]
144pub struct StandalonePq;
145
146/// What a member is told when an SSH key is offered to a quantum-safe ring.
147pub const SSH_IS_NOT_PQ: &str = "SSH keys cannot be quantum-safe recipients: an ed25519 \
148    key's wrap is X25519 and an RSA key's is RSA-OAEP, and a quantum computer breaks both. \
149    Each member makes a separate quantum-safe key with `gunnar crypt keygen` (an age1pq1… \
150    key, ML-KEM-768 + X25519; its private half stays on their machine) and registers the \
151    public half with gunnar";
152
153impl WrapScheme for StandalonePq {
154    fn level(&self) -> Level {
155        Level::QuantumSafe
156    }
157    fn recipient(&self, key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String> {
158        match key.kind {
159            KeyKind::AgePq => key
160                .key
161                .parse::<crate::pq::Recipient>()
162                .map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
163                .map_err(str::to_string),
164            KeyKind::SshEd25519 | KeyKind::SshRsa | KeyKind::Age => Err(SSH_IS_NOT_PQ.to_string()),
165        }
166    }
167}
168
169/// The scheme of a level.
170pub fn scheme(level: Level) -> &'static dyn WrapScheme {
171    match level {
172        Level::Classic => &SshClassic,
173        Level::QuantumSafe => &StandalonePq,
174    }
175}
176
177/// Wrap `ring` to every key in `members`, with the scheme of its level;
178/// returns the armored age file (the content of `.gunnar-crypt/keys`).
179pub fn wrap(ring: &Keyring, members: &Members) -> Result<String, WrapError> {
180    if members.is_empty() {
181        return Err(WrapError::NoMembers);
182    }
183    let scheme = scheme(members.level());
184    let mut recipients = Vec::new();
185    for m in members.entries() {
186        recipients.push(
187            scheme
188                .recipient(m)
189                .map_err(|e| WrapError::BadKey(m.name.clone(), e))?,
190        );
191    }
192    let encryptor = age::Encryptor::with_recipients(
193        recipients.iter().map(|r| r.as_ref() as &dyn age::Recipient),
194    )
195    .map_err(|e| WrapError::Age(e.to_string()))?;
196    let mut out = Vec::new();
197    let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor)
198        .map_err(|e| WrapError::Age(e.to_string()))?;
199    let mut w = encryptor
200        .wrap_output(armor)
201        .map_err(|e| WrapError::Age(e.to_string()))?;
202    w.write_all(ring.to_text().as_bytes())
203        .map_err(|e| WrapError::Age(e.to_string()))?;
204    w.finish()
205        .and_then(|a| a.finish())
206        .map_err(|e| WrapError::Age(e.to_string()))?;
207    String::from_utf8(out).map_err(|e| WrapError::Age(e.to_string()))
208}
209
210/// Unwrap a key file with the given identities. The payload must be a key
211/// ring; anything else is refused and dropped (zeroed).
212pub fn unwrap(keys_file: &[u8], identities: &[&dyn age::Identity]) -> Result<Keyring, UnwrapError> {
213    // `new_buffered`, not `new`: age's unbuffered header reader asks for the
214    // bytes it still needs a few at a time and re-parses the header from its
215    // start after each read, which is quadratic in the header's size — a
216    // 500-member quantum-safe ring (a ~1 MB header) never finished
217    // (MEASURED 2026-10-06, `tests/pq_stress.rs` and `examples/pq_bench.rs`).
218    // The buffered reader parses what the buffer holds, once.
219    let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(keys_file))
220        .map_err(|e| UnwrapError::Malformed(e.to_string()))?;
221    let mut reader = match decryptor.decrypt(identities.iter().copied()) {
222        Ok(r) => r,
223        Err(age::DecryptError::NoMatchingKeys) => return Err(UnwrapError::NotARecipient),
224        Err(e) => return Err(UnwrapError::Malformed(e.to_string())),
225    };
226    let mut buf = Zeroizing::new(Vec::new());
227    reader
228        .read_to_end(&mut buf)
229        .map_err(|e| UnwrapError::Malformed(e.to_string()))?;
230    let text = std::str::from_utf8(&buf)
231        .map_err(|_| UnwrapError::NotAKeyring(KeyringError::NotAKeyring))?;
232    Keyring::parse(text).map_err(UnwrapError::NotAKeyring)
233}
234
235/// [`unwrap`], after the ring's stanzas are checked against the members file
236/// ([`policy::check_ring`]): one stanza per listed key, the SSH ones by their
237/// tags, no type the repository's level does not allow. A ring with a stanza
238/// for someone the members file does not name is refused before anything is
239/// unwrapped.
240pub fn unwrap_checked(
241    keys_file: &[u8],
242    members: &str,
243    identities: &[&dyn age::Identity],
244) -> Result<Keyring, UnwrapError> {
245    policy::check_ring(members, keys_file).map_err(UnwrapError::Mismatch)?;
246    unwrap(keys_file, identities)
247}
248
249/// A post-quantum identity file (`gunnar crypt keygen`, or `age-keygen -pq`)
250/// as an age identity.
251pub fn pq_identity(text: &str) -> Result<Box<dyn age::Identity + Send + Sync>, IdentityError> {
252    crate::pq::parse_identity_file(text)
253        .map(|id| Box::new(id) as Box<dyn age::Identity + Send + Sync>)
254        .map_err(|e| IdentityError::Read(e.to_string()))
255}
256
257/// A passphrase source for an encrypted SSH key file.
258pub trait Passphrase: Clone + Send + Sync + 'static {
259    /// Ask for the passphrase of the key `description` names; `None` gives up.
260    fn ask(&self, description: &str) -> Option<String>;
261}
262
263#[derive(Clone)]
264struct Callbacks<P: Passphrase>(P);
265
266impl<P: Passphrase> age::Callbacks for Callbacks<P> {
267    fn display_message(&self, message: &str) {
268        eprintln!("{message}");
269    }
270    fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
271        None
272    }
273    fn request_public_string(&self, _: &str) -> Option<String> {
274        None
275    }
276    fn request_passphrase(&self, description: &str) -> Option<age::secrecy::SecretString> {
277        self.0
278            .ask(description)
279            .map(age::secrecy::SecretString::from)
280    }
281}
282
283/// Why an SSH key file could not be used as an identity.
284#[derive(Debug)]
285pub enum IdentityError {
286    /// Could not read or parse the file.
287    Read(String),
288    /// A key type age cannot decrypt with (for example a security-key `sk-`
289    /// key, which only signs).
290    Unsupported(String),
291}
292
293impl std::fmt::Display for IdentityError {
294    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        match self {
296            IdentityError::Read(e) => write!(f, "{e}"),
297            IdentityError::Unsupported(e) => write!(
298                f,
299                "{e}. A security-key (sk-) SSH key can only sign and cannot decrypt; \
300                 use an ordinary ed25519 key"
301            ),
302        }
303    }
304}
305impl std::error::Error for IdentityError {}
306
307/// An SSH private key file as an age identity. An encrypted key asks
308/// `passphrase` when it is first needed.
309pub fn ssh_identity<P: Passphrase>(
310    pem: &[u8],
311    filename: Option<String>,
312    passphrase: P,
313) -> Result<Box<dyn age::Identity>, IdentityError> {
314    let id = age::ssh::Identity::from_buffer(pem, filename)
315        .map_err(|e| IdentityError::Read(e.to_string()))?;
316    if let age::ssh::Identity::Unsupported(u) = &id {
317        return Err(IdentityError::Unsupported(format!("{u:?}")));
318    }
319    Ok(Box::new(id.with_callbacks(Callbacks(passphrase))))
320}
321
322/// An SSH private key file as an age identity, decrypted NOW: an encrypted
323/// key asks `passphrase` once, here, rather than later in the middle of an
324/// unwrap. What `gunnar unlock` uses, so a passphrase is typed at start and
325/// never while a browser request is waiting.
326pub fn ssh_identity_unlocked(
327    pem: &[u8],
328    passphrase: impl Fn(&str) -> Option<String>,
329) -> Result<Box<dyn age::Identity + Send + Sync>, IdentityError> {
330    let id = age::ssh::Identity::from_buffer(pem, None)
331        .map_err(|e| IdentityError::Read(e.to_string()))?;
332    match id {
333        age::ssh::Identity::Unencrypted(_) => Ok(Box::new(id)),
334        age::ssh::Identity::Encrypted(enc) => {
335            let pass = passphrase("Passphrase for the SSH key")
336                .ok_or_else(|| IdentityError::Read("no passphrase given".into()))?;
337            let key = enc
338                .decrypt(age::secrecy::SecretString::from(pass))
339                .map_err(|_| IdentityError::Read("wrong passphrase".into()))?;
340            Ok(Box::new(age::ssh::Identity::from(key)))
341        }
342        age::ssh::Identity::Unsupported(u) => Err(IdentityError::Unsupported(format!("{u:?}"))),
343    }
344}
345
346/// A passphrase source that never answers: for key files without one.
347#[derive(Clone, Copy, Debug)]
348pub struct NoPassphrase;
349
350impl Passphrase for NoPassphrase {
351    fn ask(&self, _: &str) -> Option<String> {
352        None
353    }
354}