use std::fmt;
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
use crate::StoragePath;
mod secret_encoding;
pub const DERIVATION_VERSION: &str = "v1";
const ROOT_INFO: &[u8] = b"pubky/hierarchical-keys/v1/root";
const DIRECTORY_INFO: &[u8] = b"pubky/hierarchical-keys/v1/directory";
const FILE_INFO: &[u8] = b"pubky/hierarchical-keys/v1/file-content";
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct KeyEntry {
scope: StoragePath,
#[serde(with = "secret_encoding")]
secret: Box<Zeroizing<[u8; 32]>>,
}
impl fmt::Debug for KeyEntry {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("KeyEntry")
.field("scope", &self.scope)
.field("secret", &"<redacted>")
.finish()
}
}
#[derive(Clone, Debug, Serialize)]
pub struct ScopedEncryptionKeyBundle {
version: DerivationVersion,
keys: Vec<KeyEntry>,
}
impl ScopedEncryptionKeyBundle {
pub fn from_identity_secret<'a>(
identity_secret: &[u8; 32],
scopes: impl IntoIterator<Item = &'a StoragePath>,
) -> Self {
let root_scope = StoragePath::root();
let root_secret = derive_root_secret(identity_secret);
let keys = scopes
.into_iter()
.map(|scope| KeyEntry {
scope: scope.clone(),
secret: Box::new(derive_path(&root_scope, &root_secret, scope)),
})
.collect();
Self {
version: DerivationVersion::V1,
keys,
}
}
pub fn scopes(&self) -> impl ExactSizeIterator<Item = &StoragePath> {
self.keys.iter().map(|key| &key.scope)
}
pub fn derive_for_path(
&self,
path: &StoragePath,
) -> Result<Zeroizing<[u8; 32]>, KeyDerivationError> {
if path.is_directory() {
return Err(KeyDerivationError::DirectoryPath {
requested: path.clone(),
});
}
self.derive_scoped_path(path)
}
fn derive_scoped_path(
&self,
path: &StoragePath,
) -> Result<Zeroizing<[u8; 32]>, KeyDerivationError> {
let entry = self
.keys
.iter()
.find(|entry| entry.scope.covers_path(path))
.ok_or_else(|| KeyDerivationError::OutsideScope {
requested: path.clone(),
})?;
Ok(derive_path(&entry.scope, &entry.secret, path))
}
}
impl<'de> Deserialize<'de> for ScopedEncryptionKeyBundle {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WireBundle {
version: DerivationVersion,
keys: Vec<KeyEntry>,
}
let wire = WireBundle::deserialize(deserializer)?;
validate_overlap_consistency(&wire.keys).map_err(serde::de::Error::custom)?;
Ok(Self {
version: wire.version,
keys: wire.keys,
})
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
enum DerivationVersion {
#[serde(rename = "v1")]
V1,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum KeyDerivationError {
#[error("file content keys require a file path, got directory {requested}")]
DirectoryPath {
requested: StoragePath,
},
#[error("no bundled key covers requested path {requested}")]
OutsideScope {
requested: StoragePath,
},
}
#[derive(Debug, thiserror::Error)]
#[error("inconsistent keys for overlapping scopes {scope} and {other_scope}")]
struct ConflictingKeys {
scope: StoragePath,
other_scope: StoragePath,
}
fn validate_overlap_consistency(keys: &[KeyEntry]) -> Result<(), ConflictingKeys> {
for (index, entry) in keys.iter().enumerate() {
for other in &keys[..index] {
let (covering, covered) = if entry.scope.covers_path(&other.scope) {
(entry, other)
} else if other.scope.covers_path(&entry.scope) {
(other, entry)
} else {
continue;
};
let expected = derive_path(&covering.scope, &covering.secret, &covered.scope);
if !bool::from(expected.as_ref().ct_eq(covered.secret.as_slice())) {
return Err(ConflictingKeys {
scope: covering.scope.clone(),
other_scope: covered.scope.clone(),
});
}
}
}
Ok(())
}
fn derive_root_secret(identity_secret: &[u8; 32]) -> Zeroizing<[u8; 32]> {
let hkdf = Hkdf::<Sha256>::new(None, identity_secret);
let mut secret = Zeroizing::new([0; 32]);
hkdf.expand(ROOT_INFO, secret.as_mut_slice())
.expect("32-byte output is within the HKDF limit");
secret
}
fn derive_path(
scope: &StoragePath,
seed: &Zeroizing<[u8; 32]>,
path: &StoragePath,
) -> Zeroizing<[u8; 32]> {
assert!(
scope.covers_path(path),
"coverage must be checked before derivation"
);
let relative_path = &path.as_str()[scope.as_str().len()..];
let mut secret = seed.clone();
for segment in relative_path.split_inclusive('/') {
let (segment, info) = match segment.strip_suffix('/') {
Some(directory) => (directory, DIRECTORY_INFO),
None => (segment, FILE_INFO),
};
let hkdf = Hkdf::<Sha256>::from_prk(secret.as_slice()).expect("SHA-256 PRKs are 32 bytes");
let mut child = Zeroizing::new([0; 32]);
hkdf.expand_multi_info(&[info, b"\0", segment.as_bytes()], child.as_mut_slice())
.expect("32-byte output is within the HKDF limit");
secret = child;
}
secret
}
#[cfg(test)]
mod tests;