gunnar_crypt/skwrap.rs
1//! **The security key's wrap v2** (CRYPT-PQ-SK, owner 2026-10-30): the text
2//! form, which the server checks without a key and the client opens.
3//!
4//! ```text
5//! gunnar-crypt user-key wrap v2 <salt> <nonce> <sealed> (one line, base64 without padding)
6//! ```
7//!
8//! * `salt`: 32 random bytes, new for every wrap.
9//! * `key = HKDF-SHA256(salt, the security key's PRF / hmac-secret output,
10//! info "gunnar-crypt user-key wrap v2")`: 32 bytes.
11//! * `sealed = XChaCha20-Poly1305(key, nonce, the user key's payload)` with a
12//! 24-byte random nonce and the associated data `"gunnar-crypt user-key wrap
13//! v2" ‖ 0x00 ‖ credential id`, so a wrap opens under its own credential only.
14//!
15//! The PRF output is a symmetric secret the authenticator keeps; nothing in
16//! the wrap is a public-key step, so a quantum computer gains only Grover's
17//! square root against 256-bit keys: the wrap is quantum-safe. v1 (an age
18//! X25519 stanza to a recipient derived from the same PRF output) still opens
19//! and is re-wrapped to v2 on the next unlock.
20//!
21//! This module is format only, compiled in every build: the server checks a
22//! wrap's shape with it and holds no key.
23
24use base64::engine::general_purpose::STANDARD_NO_PAD;
25use base64::Engine as _;
26
27/// The first word group of a v2 wrap.
28pub const TAG: &str = "gunnar-crypt user-key wrap v2";
29/// The HKDF `info` (owner's words, CRYPT-PQ-SK).
30pub const INFO: &[u8] = b"gunnar-crypt user-key wrap v2";
31/// Bytes of salt.
32pub const SALT_BYTES: usize = 32;
33/// Bytes of XChaCha20-Poly1305 nonce.
34pub const NONCE_BYTES: usize = 24;
35/// The Poly1305 tag.
36pub const TAG_BYTES: usize = 16;
37/// The largest sealed payload anyone parses (a hybrid user key's payload is ~100 bytes).
38pub const MAX_SEALED: usize = 4 << 10;
39
40/// The three parts of a v2 wrap.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Parts {
43 /// The HKDF salt.
44 pub salt: [u8; SALT_BYTES],
45 /// The XChaCha20-Poly1305 nonce.
46 pub nonce: [u8; NONCE_BYTES],
47 /// Ciphertext and tag.
48 pub sealed: Vec<u8>,
49}
50
51/// Does `text` claim to be a v2 wrap (it may still be malformed)?
52pub fn is_v2(text: &str) -> bool {
53 text.trim_start().starts_with(TAG)
54}
55
56/// The parts of a well-formed v2 wrap; `None` for anything else.
57pub fn parse(text: &str) -> Option<Parts> {
58 let rest = text.trim().strip_prefix(TAG)?.strip_prefix(' ')?;
59 let words: Vec<&str> = rest.split(' ').collect();
60 let [salt, nonce, sealed] = words.as_slice() else {
61 return None;
62 };
63 let salt: [u8; SALT_BYTES] = STANDARD_NO_PAD.decode(salt).ok()?.try_into().ok()?;
64 let nonce: [u8; NONCE_BYTES] = STANDARD_NO_PAD.decode(nonce).ok()?.try_into().ok()?;
65 if sealed.len() > MAX_SEALED * 2 {
66 return None;
67 }
68 let sealed = STANDARD_NO_PAD.decode(sealed).ok()?;
69 (sealed.len() > TAG_BYTES && sealed.len() <= MAX_SEALED).then_some(Parts {
70 salt,
71 nonce,
72 sealed,
73 })
74}
75
76/// The text form of `parts` (one line, with its newline).
77pub fn format(parts: &Parts) -> String {
78 format!(
79 "{TAG} {} {} {}\n",
80 STANDARD_NO_PAD.encode(parts.salt),
81 STANDARD_NO_PAD.encode(parts.nonce),
82 STANDARD_NO_PAD.encode(&parts.sealed)
83 )
84}
85
86/// The associated data of a wrap under `credential_id`.
87pub fn aad(credential_id: &[u8]) -> Vec<u8> {
88 let mut a = Vec::with_capacity(INFO.len() + 1 + credential_id.len());
89 a.extend_from_slice(INFO);
90 a.push(0);
91 a.extend_from_slice(credential_id);
92 a
93}