Skip to main content

pubky_common/
encryption_keys.rs

1//! Stable encryption keys derived from an identity secret and canonical path.
2//!
3//! Directory keys are subtree seeds; file keys are terminal content keys.
4//! Neither key type distinguishes read from write permissions. Derivation is
5//! local and does not authenticate an imported key or authorize storage access.
6//! [`ScopedEncryptionKeyBundle`] transports multiple scopes with their derivation
7//! version. Serialization exposes secret bytes: encrypt and authenticate the
8//! payload before delivery through a relay.
9//!
10//! # Derivation format
11//! All outputs are 32 bytes. The root uses HKDF-SHA-256 extract-and-expand with
12//! no salt (RFC 5869's 32 zero bytes) and `info` equal to
13//! `pubky/hierarchical-keys/v1/root`. Each child uses HKDF-Expand-SHA-256 with
14//! the parent directory seed as the PRK and `info` equal to its purpose label,
15//! one zero byte, and the canonical segment's UTF-8 bytes. Purpose labels are
16//! `pubky/hierarchical-keys/v1/directory` and
17//! `pubky/hierarchical-keys/v1/file-content`.
18//!
19//! Paths are already decoded: literal percent signs are not decoded again.
20//! Renaming a file changes its key. Changing these labels or their encoding
21//! requires a new derivation version. The v1 labels and byte encoding are
22//! permanent recovery inputs, independent of SDK release versions. Paths retain
23//! their UTF-8 bytes: NFC and NFD spellings derive different keys.
24//!
25//! ```
26//! use pubky_common::{
27//!     StoragePath,
28//!     encryption_keys::ScopedEncryptionKeyBundle,
29//! };
30//!
31//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
32//! // The identity secret remains with the signer.
33//! let scopes = [StoragePath::new("/pub/app/")?, StoragePath::new("/priv/chat/")?];
34//! let approved_keys = ScopedEncryptionKeyBundle::from_identity_secret(&[7; 32], &scopes);
35//!
36//! // Encode/decode the key bundle inside an encrypted, authenticated approval.
37//! let payload = postcard::to_allocvec(&approved_keys)?;
38//! let app_keys: ScopedEncryptionKeyBundle = postcard::from_bytes(&payload)?;
39//! let file = StoragePath::new("/pub/app/messages.json")?;
40//! let content_key = app_keys.derive_for_path(&file)?;
41//! let expected = ScopedEncryptionKeyBundle::from_identity_secret(
42//!     &[7; 32], std::slice::from_ref(&file),
43//! );
44//! assert_eq!(*content_key, *expected.derive_for_path(&file)?);
45//! # Ok(())
46//! # }
47//! # example().unwrap();
48//! ```
49
50use std::fmt;
51
52use hkdf::Hkdf;
53use serde::{Deserialize, Serialize};
54use sha2::Sha256;
55use subtle::ConstantTimeEq;
56use zeroize::Zeroizing;
57
58use crate::StoragePath;
59
60mod secret_encoding;
61
62/// Version of the derivation format implemented by this module.
63pub const DERIVATION_VERSION: &str = "v1";
64
65const ROOT_INFO: &[u8] = b"pubky/hierarchical-keys/v1/root";
66const DIRECTORY_INFO: &[u8] = b"pubky/hierarchical-keys/v1/directory";
67const FILE_INFO: &[u8] = b"pubky/hierarchical-keys/v1/file-content";
68
69/// One wire entry binding a directory seed or file secret to its canonical scope.
70/// Named fields preserve the wire format and keep paths paired with their keys.
71#[derive(Clone, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73struct KeyEntry {
74    scope: StoragePath,
75    // Keep bytes in stable storage: vector growth must only move the pointer.
76    // The inner Zeroizing wipes the allocation before Box releases it.
77    #[serde(with = "secret_encoding")]
78    secret: Box<Zeroizing<[u8; 32]>>,
79}
80
81impl fmt::Debug for KeyEntry {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.debug_struct("KeyEntry")
84            .field("scope", &self.scope)
85            .field("secret", &"<redacted>")
86            .finish()
87    }
88}
89
90/// Scoped secrets delivered together for one approval, outside its grant JWS.
91///
92/// The wire format is `{ "version": "v1", "keys": [...] }`. Unsupported
93/// versions and inconsistent overlapping keys are rejected on deserialization.
94/// JSON secrets are unpadded base64url strings; binary secrets are 32 raw bytes.
95/// Empty bundles and identical duplicate entries are valid. Entry order is
96/// preserved, and debug output redacts every secret.
97///
98/// Serialization is plaintext and needs an encrypted, authenticated envelope.
99/// Decoding validates canonical paths, 32-byte secrets, and consistent overlaps.
100/// Disjoint scopes cannot be checked against each other without an ancestor key.
101/// Approval and identity checks belong to the auth flow. Secret storage is
102/// zeroized on drop; serialized copies need their own secret handling.
103#[derive(Clone, Debug, Serialize)]
104pub struct ScopedEncryptionKeyBundle {
105    version: DerivationVersion,
106    keys: Vec<KeyEntry>,
107}
108
109impl ScopedEncryptionKeyBundle {
110    /// Derive an approval bundle from the signer's high-entropy identity secret.
111    ///
112    /// Use the canonical 32-byte secret returned by `Keypair::secret()`, not a
113    /// password or an encoded key string. Only approved scopes are retained;
114    /// the temporary root seed is zeroized on return.
115    /// Entries preserve request order, including overlapping or repeated scopes.
116    /// The caller must limit the scopes to those approved by the user.
117    pub fn from_identity_secret<'a>(
118        identity_secret: &[u8; 32],
119        scopes: impl IntoIterator<Item = &'a StoragePath>,
120    ) -> Self {
121        let root_scope = StoragePath::root();
122        let root_secret = derive_root_secret(identity_secret);
123        let keys = scopes
124            .into_iter()
125            .map(|scope| KeyEntry {
126                scope: scope.clone(),
127                secret: Box::new(derive_path(&root_scope, &root_secret, scope)),
128            })
129            .collect();
130
131        // Deriving every entry from one source guarantees consistent overlaps.
132        Self {
133            version: DerivationVersion::V1,
134            keys,
135        }
136    }
137
138    /// Borrow approved scopes in delivery order, without exposing their entries.
139    pub fn scopes(&self) -> impl ExactSizeIterator<Item = &StoragePath> {
140        self.keys.iter().map(|key| &key.scope)
141    }
142
143    /// Derive an owned file content key using the first covering key.
144    ///
145    /// Directory paths, including `/`, are rejected. Directory seeds remain
146    /// internal to the bundle. Returned bytes are zeroized on drop. Do not log
147    /// them or put them in public metadata.
148    ///
149    /// Overlapping entries agree, so entry order cannot change the result.
150    /// File entries cannot cover descendants or directories.
151    ///
152    /// # Errors
153    /// Returns [`KeyDerivationError::DirectoryPath`] for directory paths and
154    /// [`KeyDerivationError::OutsideScope`] if no bundled key covers the file,
155    /// including when the bundle is empty.
156    pub fn derive_for_path(
157        &self,
158        path: &StoragePath,
159    ) -> Result<Zeroizing<[u8; 32]>, KeyDerivationError> {
160        if path.is_directory() {
161            return Err(KeyDerivationError::DirectoryPath {
162                requested: path.clone(),
163            });
164        }
165        self.derive_scoped_path(path)
166    }
167
168    // Also supports directory seeds for tests of the derivation hierarchy.
169    fn derive_scoped_path(
170        &self,
171        path: &StoragePath,
172    ) -> Result<Zeroizing<[u8; 32]>, KeyDerivationError> {
173        let entry = self
174            .keys
175            .iter()
176            .find(|entry| entry.scope.covers_path(path))
177            .ok_or_else(|| KeyDerivationError::OutsideScope {
178                requested: path.clone(),
179            })?;
180        Ok(derive_path(&entry.scope, &entry.secret, path))
181    }
182}
183
184impl<'de> Deserialize<'de> for ScopedEncryptionKeyBundle {
185    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
186        #[derive(Deserialize)]
187        #[serde(deny_unknown_fields)]
188        struct WireBundle {
189            version: DerivationVersion,
190            keys: Vec<KeyEntry>,
191        }
192
193        let wire = WireBundle::deserialize(deserializer)?;
194        validate_overlap_consistency(&wire.keys).map_err(serde::de::Error::custom)?;
195        Ok(Self {
196            version: wire.version,
197            keys: wire.keys,
198        })
199    }
200}
201
202// A closed enum makes unsupported wire versions fail during Serde decoding
203// without accepting an arbitrary string and interpreting its keys as v1.
204#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
205enum DerivationVersion {
206    #[serde(rename = "v1")]
207    V1,
208}
209
210/// A requested path cannot yield a file content key.
211#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
212pub enum KeyDerivationError {
213    /// Directory seeds are internal and cannot be derived through the public API.
214    #[error("file content keys require a file path, got directory {requested}")]
215    DirectoryPath {
216        /// Requested directory path.
217        requested: StoragePath,
218    },
219    /// No bundled key covers the requested file.
220    #[error("no bundled key covers requested path {requested}")]
221    OutsideScope {
222        /// Requested file path.
223        requested: StoragePath,
224    },
225}
226
227/// Overlapping imported entries disagree with their common hierarchy.
228#[derive(Debug, thiserror::Error)]
229#[error("inconsistent keys for overlapping scopes {scope} and {other_scope}")]
230struct ConflictingKeys {
231    scope: StoragePath,
232    other_scope: StoragePath,
233}
234
235fn validate_overlap_consistency(keys: &[KeyEntry]) -> Result<(), ConflictingKeys> {
236    // Approval bundles contain few scopes. Check all overlapping pairs, even
237    // those separated by unrelated entries, so lookup has one consistent result.
238    for (index, entry) in keys.iter().enumerate() {
239        for other in &keys[..index] {
240            let (covering, covered) = if entry.scope.covers_path(&other.scope) {
241                (entry, other)
242            } else if other.scope.covers_path(&entry.scope) {
243                (other, entry)
244            } else {
245                continue;
246            };
247            let expected = derive_path(&covering.scope, &covering.secret, &covered.scope);
248            if !bool::from(expected.as_ref().ct_eq(covered.secret.as_slice())) {
249                return Err(ConflictingKeys {
250                    scope: covering.scope.clone(),
251                    other_scope: covered.scope.clone(),
252                });
253            }
254        }
255    }
256    Ok(())
257}
258
259/// Derive the root seed with HKDF-SHA-256 extract and one expand block.
260fn derive_root_secret(identity_secret: &[u8; 32]) -> Zeroizing<[u8; 32]> {
261    let hkdf = Hkdf::<Sha256>::new(None, identity_secret);
262    let mut secret = Zeroizing::new([0; 32]);
263    hkdf.expand(ROOT_INFO, secret.as_mut_slice())
264        .expect("32-byte output is within the HKDF limit");
265    secret
266}
267
268/// Derive bytes after the constructor, lookup, or overlap check proves coverage.
269fn derive_path(
270    scope: &StoragePath,
271    seed: &Zeroizing<[u8; 32]>,
272    path: &StoragePath,
273) -> Zeroizing<[u8; 32]> {
274    assert!(
275        scope.covers_path(path),
276        "coverage must be checked before derivation"
277    );
278    // Coverage guarantees a directory prefix ending at a UTF-8 boundary.
279    let relative_path = &path.as_str()[scope.as_str().len()..];
280    let mut secret = seed.clone();
281    // Retaining separators makes each segment's role explicit. An empty
282    // relative path has no segments and returns the existing seed unchanged.
283    for segment in relative_path.split_inclusive('/') {
284        let (segment, info) = match segment.strip_suffix('/') {
285            Some(directory) => (directory, DIRECTORY_INFO),
286            None => (segment, FILE_INFO),
287        };
288        // The parent is already a PRK; do not run HKDF extract again.
289        let hkdf = Hkdf::<Sha256>::from_prk(secret.as_slice()).expect("SHA-256 PRKs are 32 bytes");
290        let mut child = Zeroizing::new([0; 32]);
291        hkdf.expand_multi_info(&[info, b"\0", segment.as_bytes()], child.as_mut_slice())
292            .expect("32-byte output is within the HKDF limit");
293        secret = child;
294    }
295    secret
296}
297
298#[cfg(test)]
299mod tests;