Skip to main content

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}