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;