Skip to main content

gunnar_crypt/
wrap.rs

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