Skip to main content

pubhubs/misc/
crypto.rs

1use aead::{Aead as _, Generate as _, KeyInit as _};
2use anyhow::Context as _;
3use base64ct::{Base64Url, Encoding as _};
4use chacha20poly1305::XChaCha20Poly1305;
5use rand::TryRng as _;
6
7/// Key used by [`seal`] and co.
8///
9// TODO: nice-to-have: make SealingKey zeroize::Zeroize.  Now possible: chacha20poly1305 v0.11 moved
10// to hybrid-array, whose `Array` is Zeroize when its element type is - it just needs hybrid-array's
11// `zeroize` feature turned on.
12pub type SealingKey = chacha20poly1305::Key;
13
14/// Generates a random 22 character alphanumeric string (`[a-zA-Z0-9]{22}`),
15/// having > 128 bits of randomness.
16pub fn random_alphanumeric() -> String {
17    use rand::RngExt as _;
18
19    rand::rand_core::UnwrapErr(rand::rngs::SysRng)
20        .sample_iter(&rand::distr::Alphanumeric)
21        .take(22)
22        .map(char::from)
23        .collect()
24}
25
26pub fn random_32_bytes() -> [u8; 32] {
27    random_bytes()
28}
29
30/// Wide enough to be reduced into a uniformly random [`curve25519_dalek::Scalar`] or
31/// [`curve25519_dalek::RistrettoPoint`] without bias; see `common::elgamal`.
32pub fn random_64_bytes() -> [u8; 64] {
33    random_bytes()
34}
35
36fn random_bytes<const N: usize>() -> [u8; N] {
37    let mut bytes: [u8; N] = [0; N];
38
39    rand::rngs::SysRng::try_fill_bytes(&mut rand::rngs::SysRng, bytes.as_mut_slice()).unwrap();
40
41    bytes
42}
43
44/// Like [`seal`], but returns an urlsafe base64 encoded string.
45/// and returns it as urlsafe base64 string.  Use [`url_unseal`] to revert.
46pub fn url_seal<T: serde::Serialize>(
47    obj: &T,
48    key: &SealingKey,
49    aad: impl AsRef<[u8]>,
50) -> anyhow::Result<String> {
51    let buf: Vec<u8> = seal(obj, key, aad)?;
52
53    Ok(Base64Url::encode_string(&buf))
54}
55
56/// Reverse of the [`url_seal`] operation.
57pub fn url_unseal<T: serde::de::DeserializeOwned>(
58    envelope: impl AsRef<str>,
59    key: &chacha20poly1305::Key,
60    aad: impl AsRef<[u8]>,
61) -> Result<T, crate::misc::error::Opaque> {
62    let buf = Base64Url::decode_vec(envelope.as_ref())?;
63
64    unseal(&buf, key, aad)
65}
66
67/// Encodes and encrypts the given `obj` with additional associated data (or `b""` if `None`).
68/// Use [`unseal`] to revert.
69///
70/// Uses a non self-describing encoding format for `T`, so [`seal`] is not suitable for long-lived
71/// data that might change.
72pub fn seal<T: serde::Serialize>(
73    obj: &T,
74    key: &SealingKey,
75    aad: impl AsRef<[u8]>,
76) -> anyhow::Result<Vec<u8>> {
77    let plaintext = postcard::to_stdvec(obj).context("serializing")?;
78
79    // NOTE: generally it's a bad idea to permit an unlimited amount of initialization vectors for
80    // the same AEAD key, but we use XChaCha20Poly1305, a variant of ChaCha20Poly1305 specifically
81    // made for this exact use case.
82    let nonce = aead::Nonce::<XChaCha20Poly1305>::generate();
83    let ciphertext = XChaCha20Poly1305::new(key)
84        .encrypt(
85            &nonce,
86            aead::Payload {
87                msg: plaintext.as_slice(),
88                aad: aad.as_ref(),
89            },
90        )
91        .map_err(|e| anyhow::anyhow!(e))
92        .context("encrypting")?;
93
94    let mut buf = Vec::with_capacity(nonce.len() + ciphertext.len());
95    buf.extend_from_slice(&nonce);
96    buf.extend_from_slice(&ciphertext);
97
98    Ok(buf)
99}
100
101/// Reverse of the [`seal`] operation.
102pub fn unseal<T: serde::de::DeserializeOwned>(
103    envelope: impl AsRef<[u8]>,
104    key: &SealingKey,
105    aad: impl AsRef<[u8]>,
106) -> Result<T, crate::misc::error::Opaque> {
107    let nonce_len: usize = size_of::<chacha20poly1305::XNonce>();
108    let envelope = envelope.as_ref();
109
110    if envelope.len() < nonce_len {
111        log::debug!("unseal: envelope does not contain nonce");
112        return Err(crate::misc::error::OPAQUE);
113    }
114
115    // NOTE: infallible - we just checked the length, and `Array` has no padding.
116    let nonce: &chacha20poly1305::XNonce = envelope[..nonce_len]
117        .try_into()
118        .expect("nonce slice of the correct length");
119
120    let plaintext = XChaCha20Poly1305::new(key)
121        .decrypt(
122            nonce,
123            aead::Payload {
124                msg: &envelope[nonce_len..],
125                aad: aad.as_ref(),
126            },
127        )
128        .map_err(|err| {
129            log::debug!("unseal: decrypting: {err}");
130            crate::misc::error::OPAQUE
131        })?;
132
133    postcard::from_bytes(&plaintext).map_err(|err| {
134        log::debug!("unseal: decoding: {err}");
135        crate::misc::error::OPAQUE
136    })
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// Sealed by the `aead` v0.5 / `chacha20poly1305` v0.10 version of [`seal`], to pin the
144    /// envelope format (`nonce || ciphertext`) across dependency bumps.  Sealed data outlives a
145    /// release, so this must keep unsealing.
146    const PINNED_SEALED: &str =
147        "GM71akKn6Pked-X642S2vP70zv9FioTJeni4r6U44xVDfT0IRcxoe12NcpDjCm9e0cuS6UzgK7c=";
148
149    fn pinned_key() -> SealingKey {
150        [7u8; 32].into()
151    }
152
153    #[test]
154    fn unseal_pinned() {
155        let obj: (u32, String) = url_unseal(PINNED_SEALED, &pinned_key(), b"test-aad").unwrap();
156
157        assert_eq!(obj, (1234, "hello pubhubs".to_string()));
158    }
159
160    #[test]
161    fn seal_unseal_roundtrip() {
162        let key = pinned_key();
163        let obj: (u32, String) = (1234, "hello pubhubs".to_string());
164
165        let sealed = url_seal(&obj, &key, b"test-aad").unwrap();
166
167        assert_eq!(
168            url_unseal::<(u32, String)>(&sealed, &key, b"test-aad").unwrap(),
169            obj
170        );
171    }
172
173    #[test]
174    fn unseal_rejects_wrong_aad_and_key() {
175        assert!(url_unseal::<(u32, String)>(PINNED_SEALED, &pinned_key(), b"other-aad").is_err());
176        assert!(
177            url_unseal::<(u32, String)>(PINNED_SEALED, &[8u8; 32].into(), b"test-aad").is_err()
178        );
179    }
180
181    #[test]
182    fn unseal_rejects_envelope_shorter_than_nonce() {
183        let short = Base64Url::encode_string(&[0u8; 8]);
184
185        assert!(url_unseal::<(u32, String)>(short, &pinned_key(), b"").is_err());
186    }
187}