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 sha2::{Digest, Sha256};
11use zeroize::{Zeroize, ZeroizeOnDrop};
12
13pub use zlayer_types::secrets::sealed::{RecipientPublicKey, SealedError, SealedSecret};
14
15/// A 32-byte X25519 recipient private key.
16///
17/// The bytes are zeroed on drop. The type implements a sanitizing `Debug`
18/// impl that only prints `RecipientPrivateKey(<redacted>)` — the raw bytes
19/// are never disclosed via formatting. `Display` is intentionally not
20/// implemented for the same reason.
21#[derive(Clone, Zeroize, ZeroizeOnDrop)]
22pub struct RecipientPrivateKey([u8; 32]);
23
24impl std::fmt::Debug for RecipientPrivateKey {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_tuple("RecipientPrivateKey")
27            .field(&"<redacted>")
28            .finish()
29    }
30}
31
32impl RecipientPrivateKey {
33    /// Generate a fresh X25519 keypair using the operating system RNG.
34    #[must_use]
35    pub fn generate() -> (RecipientPrivateKey, RecipientPublicKey) {
36        let sk = SecretKey::generate(&mut crypto_box::aead::OsRng);
37        let pk = sk.public_key();
38        let sk_bytes: [u8; 32] = sk.to_bytes();
39        let pk_bytes: [u8; 32] = pk.as_bytes().to_owned();
40        (Self(sk_bytes), RecipientPublicKey::from_bytes(pk_bytes))
41    }
42
43    /// Construct a recipient private key directly from its 32 raw bytes.
44    #[must_use]
45    pub fn from_bytes(b: [u8; 32]) -> Self {
46        Self(b)
47    }
48
49    /// Decode a recipient private key from a standard-base64 (padded) string.
50    ///
51    /// # Errors
52    /// Returns `SealedError::Decode` if the string is not valid base64, or
53    /// `SealedError::InvalidLength` if it does not decode to exactly 32 bytes.
54    pub fn from_base64(s: &str) -> Result<Self, SealedError> {
55        let bytes = B64.decode(s)?;
56        if bytes.len() != 32 {
57            return Err(SealedError::InvalidLength(bytes.len()));
58        }
59        let mut buf = [0u8; 32];
60        buf.copy_from_slice(&bytes);
61        Ok(Self(buf))
62    }
63
64    /// Encode this private key as standard-base64 (with padding).
65    ///
66    /// Use with extreme care — exposing this string is equivalent to disclosing
67    /// the key. Prefer keeping the key in memory and zeroing it on drop.
68    #[must_use]
69    pub fn to_base64(&self) -> String {
70        B64.encode(self.0)
71    }
72
73    /// Derive the matching X25519 public key for this private key.
74    #[must_use]
75    pub fn public_key(&self) -> RecipientPublicKey {
76        let sk = SecretKey::from_bytes(self.0);
77        let pk = sk.public_key();
78        RecipientPublicKey::from_bytes(pk.as_bytes().to_owned())
79    }
80}
81
82/// Seal `plaintext` to `recipient_pub` using a libsodium-compatible sealed box.
83///
84/// Returns the standard-base64 (padded) encoding of the sealed-box ciphertext,
85/// which has the wire format `ephemeral_pubkey (32 bytes) || box(plaintext)`.
86///
87/// # Errors
88/// Returns `SealedError::Encrypt` if the underlying sealed-box construction fails.
89pub fn seal(plaintext: &[u8], recipient_pub: &RecipientPublicKey) -> Result<String, SealedError> {
90    let pk = PublicKey::from(*recipient_pub.as_bytes());
91    let ct = pk
92        .seal(&mut crypto_box::aead::OsRng, plaintext)
93        .map_err(|_| SealedError::Encrypt)?;
94    Ok(B64.encode(ct))
95}
96
97/// Open a base64-encoded libsodium sealed-box ciphertext with `recipient_priv`.
98///
99/// # Errors
100/// Returns `SealedError::Decode` for malformed base64 input, or
101/// `SealedError::Decrypt` if the ciphertext fails authentication or is
102/// otherwise malformed.
103pub fn open(
104    ciphertext_b64: &str,
105    recipient_priv: &RecipientPrivateKey,
106) -> Result<Vec<u8>, SealedError> {
107    let bytes = B64.decode(ciphertext_b64)?;
108    let sk = SecretKey::from_bytes(recipient_priv.0);
109    sk.unseal(&bytes).map_err(|_| SealedError::Decrypt)
110}
111
112/// Seal `plaintext` to `recipient_pub` and return the **raw** libsodium
113/// sealed-box ciphertext bytes (no base64 encoding, no `SealedSecret`
114/// metadata wrapper).
115///
116/// Wire format is the standard libsodium sealed box:
117/// `ephemeral_pubkey (32 bytes) || box(plaintext)`.
118///
119/// Used by [`crate::cluster_dek::ClusterDek`] to produce per-node DEK wraps
120/// that go straight into [`zlayer_types::storage::WrappedDek::wraps`] as
121/// `Vec<u8>` values.
122///
123/// # Errors
124/// Returns `SealedError::Encrypt` if the underlying sealed-box construction fails.
125pub fn seal_raw(
126    plaintext: &[u8],
127    recipient_pub: &RecipientPublicKey,
128) -> Result<Vec<u8>, SealedError> {
129    let pk = PublicKey::from(*recipient_pub.as_bytes());
130    pk.seal(&mut crypto_box::aead::OsRng, plaintext)
131        .map_err(|_| SealedError::Encrypt)
132}
133
134/// Open a raw libsodium sealed-box ciphertext (as produced by [`seal_raw`])
135/// with `recipient_priv`.
136///
137/// # Errors
138/// Returns `SealedError::Decrypt` if the ciphertext fails authentication or
139/// is otherwise malformed.
140pub fn open_raw(
141    ciphertext: &[u8],
142    recipient_priv: &RecipientPrivateKey,
143) -> Result<Vec<u8>, SealedError> {
144    let sk = SecretKey::from_bytes(recipient_priv.0);
145    sk.unseal(ciphertext).map_err(|_| SealedError::Decrypt)
146}
147
148/// Compute a stable, short fingerprint for a recipient public key.
149///
150/// Returns `sha256:<first-8-bytes-hex>` (16 hex chars). Useful as a stable
151/// `key_id` so a client knows which recipient key was used to seal a payload.
152#[must_use]
153pub fn fingerprint(public_key: &RecipientPublicKey) -> String {
154    let mut hasher = Sha256::new();
155    hasher.update(public_key.as_bytes());
156    let digest = hasher.finalize();
157    format!("sha256:{}", hex::encode(&digest[..8]))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use rand::rngs::OsRng;
164    use rand::TryRngCore;
165
166    #[test]
167    fn roundtrip() {
168        let (sk, pk) = RecipientPrivateKey::generate();
169
170        let mut plaintext = [0u8; 32];
171        OsRng.try_fill_bytes(&mut plaintext).expect("OS RNG failed");
172
173        let ct = seal(&plaintext, &pk).expect("seal");
174        let pt = open(&ct, &sk).expect("open");
175        assert_eq!(pt, plaintext);
176    }
177
178    #[test]
179    fn tamper_byte_fails() {
180        let (sk, pk) = RecipientPrivateKey::generate();
181        let plaintext = b"super-secret-payload";
182
183        let ct_b64 = seal(plaintext, &pk).expect("seal");
184
185        // Decode, flip the last byte, re-encode.
186        let mut bytes = B64.decode(&ct_b64).expect("decode");
187        let last = bytes.last_mut().expect("non-empty ciphertext");
188        *last ^= 0xFF;
189        let tampered = B64.encode(&bytes);
190
191        let result = open(&tampered, &sk);
192        assert!(matches!(result, Err(SealedError::Decrypt)));
193    }
194
195    #[test]
196    fn wrong_key_fails() {
197        let (_sk_a, pk_a) = RecipientPrivateKey::generate();
198        let (sk_b, _pk_b) = RecipientPrivateKey::generate();
199
200        let plaintext = b"sealed to A only";
201        let ct = seal(plaintext, &pk_a).expect("seal");
202
203        let result = open(&ct, &sk_b);
204        assert!(result.is_err());
205    }
206
207    #[test]
208    fn fingerprint_stable() {
209        let (_sk, pk) = RecipientPrivateKey::generate();
210        let f1 = fingerprint(&pk);
211        let f2 = fingerprint(&pk);
212        assert_eq!(f1, f2);
213        assert!(f1.starts_with("sha256:"));
214    }
215
216    #[test]
217    fn pubkey_base64_roundtrip() {
218        let (_sk, pk) = RecipientPrivateKey::generate();
219        let s = pk.to_base64();
220        let pk2 = RecipientPublicKey::from_base64(&s).expect("decode");
221        assert_eq!(pk, pk2);
222    }
223}