ssh_cli/secrets/aead.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted.
3#![forbid(unsafe_code)]
4//! ChaCha20-Poly1305 sealing of at-rest secrets, and the context that binds them.
5//!
6//! Split out of `secrets.rs` so the cryptographic core of the product is one
7//! auditable unit rather than a middle third of a 740-line file that also handles
8//! keyring lookup, XDG paths, CLI flag state and status reporting. In a security
9//! CLI the question "what exactly does the encryption do" should be answerable by
10//! reading one module.
11//!
12//! # Blob versions
13//!
14//! See [`crate::secrets`] for the `v1` / `v2` compatibility rules. In short: `v1`
15//! was sealed without associated data, so its tag proved only "encrypted with this
16//! key" and a ciphertext could be relocated between hosts or between fields
17//! undetected. `v2` binds the tag to a [`SecretContext`].
18
19use super::{PrimaryKey, ENC_PREFIX, ENC_PREFIX_V2};
20use crate::constants::{AEAD_NONCE_LEN_BYTES, AEAD_TAG_LEN_BYTES};
21use crate::errors::{SshCliError, SshCliResult};
22use chacha20poly1305::aead::{Aead, KeyInit, Payload};
23use chacha20poly1305::{ChaCha20Poly1305, Nonce};
24use zeroize::Zeroize;
25
26/// Domain separator of the associated-data encoding (A7).
27///
28/// Included so a `v2` AAD can never collide with any other AEAD usage that a
29/// future version might add under the same key.
30const AAD_DOMAIN: &[u8] = b"ssh-cli:secret-aad:v2";
31
32/// Host name used when the call site cannot name the owning host yet.
33///
34/// See the module docs: blobs sealed under this context stay relocatable, which
35/// is exactly the `v1` guarantee and therefore not a regression.
36const UNBOUND_NAME: &str = "";
37
38/// Where a secret belongs: the host that owns it and the field that holds it.
39///
40/// A7: this pair is fed to the AEAD as associated data, so the tag proves not
41/// only "encrypted with this key" but also "sealed for *this* slot". Moving a
42/// ciphertext between hosts or between `password` and `su_password` then fails
43/// verification instead of silently decrypting.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct SecretContext<'a> {
46 host: &'a str,
47 field: &'a str,
48}
49
50impl<'a> SecretContext<'a> {
51 /// Binds a secret to `host` (record name) and `field` (e.g. `password`).
52 #[must_use]
53 pub const fn new(host: &'a str, field: &'a str) -> Self {
54 Self { host, field }
55 }
56
57 /// Context for call sites that do not carry host/field yet.
58 #[must_use]
59 pub const fn unbound() -> Self {
60 Self {
61 host: UNBOUND_NAME,
62 field: UNBOUND_NAME,
63 }
64 }
65
66 /// True when this context carries no binding at all.
67 #[must_use]
68 pub const fn is_unbound(&self) -> bool {
69 self.host.is_empty() && self.field.is_empty()
70 }
71
72 /// Encodes the context as unambiguous associated data.
73 ///
74 /// Length-prefixed rather than delimiter-joined: a host literally named
75 /// `a:b` must not be able to impersonate the pair `(a, b)`.
76 pub(super) fn aad(&self) -> Vec<u8> {
77 let host = self.host.as_bytes();
78 let field = self.field.as_bytes();
79 let mut out = Vec::with_capacity(AAD_DOMAIN.len() + 8 + host.len() + field.len());
80 out.extend_from_slice(AAD_DOMAIN);
81 // Lengths are bounded by validated record names; the cast cannot wrap in
82 // practice and saturating keeps the encoding total either way.
83 out.extend_from_slice(&u32::try_from(host.len()).unwrap_or(u32::MAX).to_be_bytes());
84 out.extend_from_slice(host);
85 out.extend_from_slice(&u32::try_from(field.len()).unwrap_or(u32::MAX).to_be_bytes());
86 out.extend_from_slice(field);
87 out
88 }
89}
90
91/// Seals `plaintext` as a `v2` blob bound to `ctx` (A7).
92pub fn encrypt_secret(
93 key: &PrimaryKey,
94 plaintext: &str,
95 ctx: SecretContext<'_>,
96) -> SshCliResult<String> {
97 let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice())
98 .map_err(|_| SshCliError::crypto("aead_key"))?;
99 let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
100 // G-ERR-R01: same reasoning as `generate_hex_key` — RNG failure is exit 70.
101 getrandom::fill(&mut nonce_bytes).map_err(|_| SshCliError::software("rng"))?;
102 // A3: `aead` 0.6 replaced the deprecated `Nonce::from_slice` with infallible
103 // `From<[u8; N]>` for a fixed-size array, which is exactly what we have here.
104 let nonce = Nonce::from(nonce_bytes);
105 let aad = ctx.aad();
106 let ciphertext = cipher
107 .encrypt(
108 &nonce,
109 Payload {
110 msg: plaintext.as_bytes(),
111 aad: &aad,
112 },
113 )
114 .map_err(|_| SshCliError::crypto("encrypt"))?;
115 let mut packed = Vec::with_capacity(AEAD_NONCE_LEN_BYTES + ciphertext.len());
116 packed.extend_from_slice(&nonce_bytes);
117 packed.extend_from_slice(&ciphertext);
118 Ok(format!(
119 "{ENC_PREFIX_V2}{}",
120 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
121 ))
122}
123
124/// Opens a `v1` (no AAD) or `v2` (AAD) blob.
125///
126/// For `v2` the bound context is tried first and the unbound context second:
127/// the fallback only helps blobs that were themselves written without a binding
128/// (see module docs), and never lets a bound blob be read from another slot.
129pub fn decrypt_secret(
130 key: &PrimaryKey,
131 blob: &str,
132 ctx: SecretContext<'_>,
133) -> SshCliResult<String> {
134 let (b64, versioned) = match blob.strip_prefix(ENC_PREFIX_V2) {
135 Some(rest) => (rest, true),
136 None => (
137 blob.strip_prefix(ENC_PREFIX)
138 .ok_or_else(|| SshCliError::crypto("blob_parse"))?,
139 false,
140 ),
141 };
142 let packed = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
143 .map_err(|_| SshCliError::crypto("blob_b64"))?;
144 if packed.len() < AEAD_NONCE_LEN_BYTES + AEAD_TAG_LEN_BYTES {
145 return Err(SshCliError::Config("encrypted blob too short".to_string()));
146 }
147 let (nonce_bytes, ct) = packed.split_at(AEAD_NONCE_LEN_BYTES);
148 let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice())
149 .map_err(|_| SshCliError::crypto("aead_key"))?;
150 // The length was already checked above, so this conversion cannot fail; keeping it
151 // fallible rather than panicking means a future edit to that check degrades into a
152 // typed error instead of aborting the process.
153 let nonce = Nonce::try_from(nonce_bytes).map_err(|_| SshCliError::crypto("blob_nonce"))?;
154
155 let plain = if versioned {
156 let aad = ctx.aad();
157 match cipher.decrypt(&nonce, Payload { msg: ct, aad: &aad }) {
158 Ok(p) => p,
159 Err(_) if !ctx.is_unbound() => {
160 // Migration path only: a blob written before the call site knew
161 // its host/field carries the unbound AAD.
162 let legacy = SecretContext::unbound().aad();
163 cipher
164 .decrypt(
165 &nonce,
166 Payload {
167 msg: ct,
168 aad: &legacy,
169 },
170 )
171 .map_err(|_| SshCliError::crypto("decrypt"))?
172 }
173 Err(_) => return Err(SshCliError::crypto("decrypt")),
174 }
175 } else {
176 // `v1`: sealed without associated data, hence relocatable. Read-only.
177 cipher
178 .decrypt(&nonce, ct)
179 .map_err(|_| SshCliError::crypto("decrypt"))?
180 };
181
182 match String::from_utf8(plain) {
183 Ok(s) => Ok(s),
184 Err(e) => {
185 // from_utf8 failure keeps bytes in the error — scrub before drop.
186 let mut bad = e.into_bytes();
187 bad.zeroize();
188 Err(SshCliError::Config(
189 "decrypted secret is not valid UTF-8".to_string(),
190 ))
191 }
192 }
193}