Skip to main content

zlayer_secrets/
sealed.rs

1//! `NaCl` sealed-box wrapper for recipient-encrypted secret reads.
2//!
3//! Wire format is the standard libsodium sealed box (ephemeral pubkey || box(plaintext))
4//! so `PyNaCl`, `tweetnacl-js`, libsodium nacl/box, and Go nacl/box all decrypt.
5//! Pure `RustCrypto` — compiles to wasm32-unknown-unknown.
6
7use base64::engine::general_purpose::STANDARD as B64;
8use base64::Engine as _;
9use crypto_box::{PublicKey, SecretKey};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use zeroize::{Zeroize, ZeroizeOnDrop};
13
14/// A sealed secret payload — recipient-encrypted ciphertext plus identifying metadata.
15///
16/// The ciphertext is base64-encoded (standard alphabet, with padding) so it can be
17/// transported as a string and decrypted by any libsodium-compatible client.
18#[derive(Serialize, Deserialize, Debug, Clone)]
19pub struct SealedSecret {
20    /// The name of the secret this payload corresponds to.
21    pub name: String,
22    /// The version of the secret at the time of sealing.
23    pub version: u32,
24    /// Identifier (typically a fingerprint) of the recipient public key.
25    pub key_id: String,
26    /// Standard-base64 (with padding) libsodium sealed-box ciphertext.
27    pub ciphertext_b64: String,
28}
29
30/// Errors produced by sealed-box operations.
31#[derive(Debug, thiserror::Error)]
32pub enum SealedError {
33    /// Base64 decoding of an input string failed.
34    #[error("base64 decode failed: {0}")]
35    Decode(#[from] base64::DecodeError),
36    /// The decoded byte slice did not match the expected length for its role.
37    #[error("ciphertext invalid length: {0}")]
38    InvalidLength(usize),
39    /// Sealing (encryption) failed.
40    #[error("encryption failed")]
41    Encrypt,
42    /// Opening (decryption / authentication) failed.
43    #[error("decryption failed")]
44    Decrypt,
45}
46
47/// A 32-byte X25519 recipient public key.
48#[derive(Clone, Debug, PartialEq, Eq)]
49pub struct RecipientPublicKey([u8; 32]);
50
51impl RecipientPublicKey {
52    /// Construct a recipient public key directly from its 32 raw bytes.
53    #[must_use]
54    pub fn from_bytes(b: [u8; 32]) -> Self {
55        Self(b)
56    }
57
58    /// Decode a recipient public key from a standard-base64 (padded) string.
59    ///
60    /// # Errors
61    /// Returns `SealedError::Decode` if the string is not valid base64, or
62    /// `SealedError::InvalidLength` if it does not decode to exactly 32 bytes.
63    pub fn from_base64(s: &str) -> Result<Self, SealedError> {
64        let bytes = B64.decode(s)?;
65        if bytes.len() != 32 {
66            return Err(SealedError::InvalidLength(bytes.len()));
67        }
68        let mut buf = [0u8; 32];
69        buf.copy_from_slice(&bytes);
70        Ok(Self(buf))
71    }
72
73    /// Encode this public key as standard-base64 (with padding).
74    #[must_use]
75    pub fn to_base64(&self) -> String {
76        B64.encode(self.0)
77    }
78
79    /// Borrow the raw 32-byte representation.
80    #[must_use]
81    pub fn as_bytes(&self) -> &[u8; 32] {
82        &self.0
83    }
84}
85
86/// A 32-byte X25519 recipient private key.
87///
88/// The bytes are zeroed on drop. The type intentionally does **not** implement
89/// `Debug` or `Display` to prevent accidental disclosure via logging.
90#[derive(Clone, Zeroize, ZeroizeOnDrop)]
91pub struct RecipientPrivateKey([u8; 32]);
92
93impl RecipientPrivateKey {
94    /// Generate a fresh X25519 keypair using the operating system RNG.
95    #[must_use]
96    pub fn generate() -> (RecipientPrivateKey, RecipientPublicKey) {
97        let sk = SecretKey::generate(&mut crypto_box::aead::OsRng);
98        let pk = sk.public_key();
99        let sk_bytes: [u8; 32] = sk.to_bytes();
100        let pk_bytes: [u8; 32] = pk.as_bytes().to_owned();
101        (Self(sk_bytes), RecipientPublicKey(pk_bytes))
102    }
103
104    /// Construct a recipient private key directly from its 32 raw bytes.
105    #[must_use]
106    pub fn from_bytes(b: [u8; 32]) -> Self {
107        Self(b)
108    }
109
110    /// Decode a recipient private key from a standard-base64 (padded) string.
111    ///
112    /// # Errors
113    /// Returns `SealedError::Decode` if the string is not valid base64, or
114    /// `SealedError::InvalidLength` if it does not decode to exactly 32 bytes.
115    pub fn from_base64(s: &str) -> Result<Self, SealedError> {
116        let bytes = B64.decode(s)?;
117        if bytes.len() != 32 {
118            return Err(SealedError::InvalidLength(bytes.len()));
119        }
120        let mut buf = [0u8; 32];
121        buf.copy_from_slice(&bytes);
122        Ok(Self(buf))
123    }
124
125    /// Encode this private key as standard-base64 (with padding).
126    ///
127    /// Use with extreme care — exposing this string is equivalent to disclosing
128    /// the key. Prefer keeping the key in memory and zeroing it on drop.
129    #[must_use]
130    pub fn to_base64(&self) -> String {
131        B64.encode(self.0)
132    }
133
134    /// Derive the matching X25519 public key for this private key.
135    #[must_use]
136    pub fn public_key(&self) -> RecipientPublicKey {
137        let sk = SecretKey::from_bytes(self.0);
138        let pk = sk.public_key();
139        RecipientPublicKey(pk.as_bytes().to_owned())
140    }
141}
142
143/// Seal `plaintext` to `recipient_pub` using a libsodium-compatible sealed box.
144///
145/// Returns the standard-base64 (padded) encoding of the sealed-box ciphertext,
146/// which has the wire format `ephemeral_pubkey (32 bytes) || box(plaintext)`.
147///
148/// # Errors
149/// Returns `SealedError::Encrypt` if the underlying sealed-box construction fails.
150pub fn seal(plaintext: &[u8], recipient_pub: &RecipientPublicKey) -> Result<String, SealedError> {
151    let pk = PublicKey::from(*recipient_pub.as_bytes());
152    let ct = pk
153        .seal(&mut crypto_box::aead::OsRng, plaintext)
154        .map_err(|_| SealedError::Encrypt)?;
155    Ok(B64.encode(ct))
156}
157
158/// Open a base64-encoded libsodium sealed-box ciphertext with `recipient_priv`.
159///
160/// # Errors
161/// Returns `SealedError::Decode` for malformed base64 input, or
162/// `SealedError::Decrypt` if the ciphertext fails authentication or is
163/// otherwise malformed.
164pub fn open(
165    ciphertext_b64: &str,
166    recipient_priv: &RecipientPrivateKey,
167) -> Result<Vec<u8>, SealedError> {
168    let bytes = B64.decode(ciphertext_b64)?;
169    let sk = SecretKey::from_bytes(recipient_priv.0);
170    sk.unseal(&bytes).map_err(|_| SealedError::Decrypt)
171}
172
173/// Compute a stable, short fingerprint for a recipient public key.
174///
175/// Returns `sha256:<first-8-bytes-hex>` (16 hex chars). Useful as a stable
176/// `key_id` so a client knows which recipient key was used to seal a payload.
177#[must_use]
178pub fn fingerprint(public_key: &RecipientPublicKey) -> String {
179    let mut hasher = Sha256::new();
180    hasher.update(public_key.as_bytes());
181    let digest = hasher.finalize();
182    format!("sha256:{}", hex::encode(&digest[..8]))
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use rand::rngs::OsRng;
189    use rand::TryRngCore;
190
191    #[test]
192    fn roundtrip() {
193        let (sk, pk) = RecipientPrivateKey::generate();
194
195        let mut plaintext = [0u8; 32];
196        OsRng.try_fill_bytes(&mut plaintext).expect("OS RNG failed");
197
198        let ct = seal(&plaintext, &pk).expect("seal");
199        let pt = open(&ct, &sk).expect("open");
200        assert_eq!(pt, plaintext);
201    }
202
203    #[test]
204    fn tamper_byte_fails() {
205        let (sk, pk) = RecipientPrivateKey::generate();
206        let plaintext = b"super-secret-payload";
207
208        let ct_b64 = seal(plaintext, &pk).expect("seal");
209
210        // Decode, flip the last byte, re-encode.
211        let mut bytes = B64.decode(&ct_b64).expect("decode");
212        let last = bytes.last_mut().expect("non-empty ciphertext");
213        *last ^= 0xFF;
214        let tampered = B64.encode(&bytes);
215
216        let result = open(&tampered, &sk);
217        assert!(matches!(result, Err(SealedError::Decrypt)));
218    }
219
220    #[test]
221    fn wrong_key_fails() {
222        let (_sk_a, pk_a) = RecipientPrivateKey::generate();
223        let (sk_b, _pk_b) = RecipientPrivateKey::generate();
224
225        let plaintext = b"sealed to A only";
226        let ct = seal(plaintext, &pk_a).expect("seal");
227
228        let result = open(&ct, &sk_b);
229        assert!(result.is_err());
230    }
231
232    #[test]
233    fn fingerprint_stable() {
234        let (_sk, pk) = RecipientPrivateKey::generate();
235        let f1 = fingerprint(&pk);
236        let f2 = fingerprint(&pk);
237        assert_eq!(f1, f2);
238        assert!(f1.starts_with("sha256:"));
239    }
240
241    #[test]
242    fn pubkey_base64_roundtrip() {
243        let (_sk, pk) = RecipientPrivateKey::generate();
244        let s = pk.to_base64();
245        let pk2 = RecipientPublicKey::from_base64(&s).expect("decode");
246        assert_eq!(pk, pk2);
247    }
248}