use std::collections::BTreeMap;
use metamorphic_crypto::hash::sha3_512_with_context;
use crate::commitment::{COMMITMENT_LEN, COMMITMENT_OPENING_LEN, Commitment, Opening};
use crate::error::{Error, Result};
use crate::vrf::{Vrf, VrfProof, VrfPublicKey, VrfSecretKey};
const TREE_DEPTH: usize = 256;
const NODE_LEN: usize = 64;
const INDEX_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Namespace(String);
impl Namespace {
pub fn parse(namespace: &str) -> Result<Self> {
if namespace.is_empty() || !namespace.bytes().all(|b| b.is_ascii_graphic() && b != b'/') {
return Err(Error::MalformedNamespace(format!(
"namespace must be non-empty printable ASCII without '/': {namespace:?}"
)));
}
Ok(Self(namespace.to_string()))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn commitment_label(&self) -> String {
format!("{}/coniks-commitment/v1", self.0)
}
fn leaf_label(&self) -> String {
format!("{}/coniks-leaf/v1", self.0)
}
fn node_label(&self) -> String {
format!("{}/coniks-node/v1", self.0)
}
fn empty_label(&self) -> String {
format!("{}/coniks-empty/v1", self.0)
}
#[must_use]
pub fn vrf_input(&self, identity: &[u8]) -> Vec<u8> {
let mut input = Vec::with_capacity(4 + self.0.len() + identity.len());
input.extend_from_slice(&(self.0.len() as u32).to_be_bytes());
input.extend_from_slice(self.0.as_bytes());
input.extend_from_slice(identity);
input
}
}
fn index_bit(index: &[u8; INDEX_LEN], i: usize) -> u8 {
(index[i / 8] >> (7 - (i % 8))) & 1
}
struct TreeHasher {
leaf_label: String,
node_label: String,
suite_id: u8,
empty: Vec<[u8; NODE_LEN]>,
}
impl TreeHasher {
fn new(namespace: &Namespace, suite_id: u8) -> Self {
let empty_leaf: [u8; NODE_LEN] = sha3_512_with_context(&namespace.empty_label(), &[]);
let node_label = namespace.node_label();
let mut empty = vec![[0u8; NODE_LEN]; TREE_DEPTH + 1];
empty[TREE_DEPTH] = empty_leaf;
for d in (0..TREE_DEPTH).rev() {
empty[d] = node_hash(&node_label, &empty[d + 1], &empty[d + 1]);
}
Self {
leaf_label: namespace.leaf_label(),
node_label,
suite_id,
empty,
}
}
fn leaf_hash(&self, index: &[u8; INDEX_LEN], commitment: &Commitment) -> [u8; NODE_LEN] {
let mut buf = [0u8; 1 + INDEX_LEN + COMMITMENT_LEN];
buf[0] = self.suite_id;
buf[1..1 + INDEX_LEN].copy_from_slice(index);
buf[1 + INDEX_LEN..].copy_from_slice(commitment.as_bytes());
sha3_512_with_context(&self.leaf_label, &buf)
}
fn empty_leaf(&self) -> [u8; NODE_LEN] {
self.empty[TREE_DEPTH]
}
fn subtree(&self, depth: usize, leaves: &[(&[u8; INDEX_LEN], &Commitment)]) -> [u8; NODE_LEN] {
if leaves.is_empty() {
return self.empty[depth];
}
if depth == TREE_DEPTH {
let (index, commitment) = leaves[0];
return self.leaf_hash(index, commitment);
}
let (left, right): (Vec<_>, Vec<_>) = leaves
.iter()
.partition(|(index, _)| index_bit(index, depth) == 0);
let l = self.subtree(depth + 1, &left);
let r = self.subtree(depth + 1, &right);
node_hash(&self.node_label, &l, &r)
}
fn collect_path(
&self,
depth: usize,
target: &[u8; INDEX_LEN],
leaves: &[(&[u8; INDEX_LEN], &Commitment)],
out: &mut Vec<Option<[u8; NODE_LEN]>>,
) {
if depth == TREE_DEPTH {
return;
}
let (left, right): (Vec<_>, Vec<_>) = leaves
.iter()
.copied()
.partition(|(index, _)| index_bit(index, depth) == 0);
let (on_path, sibling) = if index_bit(target, depth) == 0 {
(left, right)
} else {
(right, left)
};
let sibling_hash = self.subtree(depth + 1, &sibling);
out.push(if sibling_hash == self.empty[depth + 1] {
None
} else {
Some(sibling_hash)
});
self.collect_path(depth + 1, target, &on_path, out);
}
fn recompute_root(
&self,
target: &[u8; INDEX_LEN],
leaf_node: [u8; NODE_LEN],
siblings: &[Option<[u8; NODE_LEN]>],
) -> [u8; NODE_LEN] {
let mut current = leaf_node;
for depth in (0..TREE_DEPTH).rev() {
let sibling = siblings[depth].unwrap_or(self.empty[depth + 1]);
current = if index_bit(target, depth) == 0 {
node_hash(&self.node_label, ¤t, &sibling)
} else {
node_hash(&self.node_label, &sibling, ¤t)
};
}
current
}
}
fn node_hash(node_label: &str, left: &[u8; NODE_LEN], right: &[u8; NODE_LEN]) -> [u8; NODE_LEN] {
let mut buf = [0u8; 2 * NODE_LEN];
buf[..NODE_LEN].copy_from_slice(left);
buf[NODE_LEN..].copy_from_slice(right);
sha3_512_with_context(node_label, &buf)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthPath {
siblings: Vec<Option<[u8; NODE_LEN]>>,
}
impl AuthPath {
fn to_bytes(&self) -> Vec<u8> {
let mut bitmap = [0u8; TREE_DEPTH / 8];
let mut hashes = Vec::new();
for (d, sibling) in self.siblings.iter().enumerate() {
if let Some(h) = sibling {
bitmap[d / 8] |= 1 << (7 - (d % 8));
hashes.extend_from_slice(h);
}
}
let mut out = Vec::with_capacity(bitmap.len() + hashes.len());
out.extend_from_slice(&bitmap);
out.extend_from_slice(&hashes);
out
}
fn parse(bytes: &[u8]) -> Result<(Self, usize)> {
let bitmap_len = TREE_DEPTH / 8;
if bytes.len() < bitmap_len {
return Err(Error::MalformedConiksProof(
"authentication path shorter than its bitmap".into(),
));
}
let bitmap = &bytes[..bitmap_len];
let present: usize = bitmap.iter().map(|b| b.count_ones() as usize).sum();
let needed = bitmap_len + present * NODE_LEN;
if bytes.len() < needed {
return Err(Error::MalformedConiksProof(format!(
"authentication path: bitmap implies {present} sibling hashes ({needed} bytes), \
only {} available",
bytes.len()
)));
}
let mut siblings = Vec::with_capacity(TREE_DEPTH);
let mut offset = bitmap_len;
for d in 0..TREE_DEPTH {
let set = (bitmap[d / 8] >> (7 - (d % 8))) & 1 == 1;
if set {
let mut h = [0u8; NODE_LEN];
h.copy_from_slice(&bytes[offset..offset + NODE_LEN]);
offset += NODE_LEN;
siblings.push(Some(h));
} else {
siblings.push(None);
}
}
Ok((Self { siblings }, needed))
}
}
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn read_lp<'a>(bytes: &'a [u8], offset: usize, what: &str) -> Result<(&'a [u8], usize)> {
if bytes.len() < offset + 4 {
return Err(Error::MalformedConiksProof(format!(
"{what}: missing 4-byte length prefix"
)));
}
let len = u32::from_be_bytes(bytes[offset..offset + 4].try_into().unwrap()) as usize;
let start = offset + 4;
if bytes.len() < start + len {
return Err(Error::MalformedConiksProof(format!(
"{what}: length prefix {len} overruns available bytes"
)));
}
Ok((&bytes[start..start + len], start + len))
}
const TAG_ABSENCE: u8 = 0x00;
const TAG_PRESENCE: u8 = 0x01;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LookupProof {
vrf_proof: VrfProof,
value: Vec<u8>,
opening: Opening,
auth_path: AuthPath,
}
impl LookupProof {
#[must_use]
pub fn value(&self) -> &[u8] {
&self.value
}
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = vec![TAG_PRESENCE];
push_lp(&mut out, self.vrf_proof.as_bytes());
push_lp(&mut out, &self.value);
out.extend_from_slice(self.opening.as_bytes());
out.extend_from_slice(&self.auth_path.to_bytes());
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let Some((&tag, rest)) = bytes.split_first() else {
return Err(Error::MalformedConiksProof("empty proof".into()));
};
if tag != TAG_PRESENCE {
return Err(Error::MalformedConiksProof(format!(
"expected presence tag 0x01, got {tag:#04x}"
)));
}
let (vrf_proof, off) = read_lp(rest, 0, "vrf proof")?;
let (value, off) = read_lp(rest, off, "value")?;
if rest.len() < off + COMMITMENT_OPENING_LEN {
return Err(Error::MalformedConiksProof("missing opening".into()));
}
let mut opening = [0u8; COMMITMENT_OPENING_LEN];
opening.copy_from_slice(&rest[off..off + COMMITMENT_OPENING_LEN]);
let (auth_path, consumed) = AuthPath::parse(&rest[off + COMMITMENT_OPENING_LEN..])?;
if off + COMMITMENT_OPENING_LEN + consumed != rest.len() {
return Err(Error::MalformedConiksProof(
"trailing bytes after authentication path".into(),
));
}
Ok(Self {
vrf_proof: VrfProof::from_bytes(vrf_proof.to_vec()),
value: value.to_vec(),
opening: Opening::from_bytes(opening),
auth_path,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AbsenceProof {
vrf_proof: VrfProof,
auth_path: AuthPath,
}
impl AbsenceProof {
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = vec![TAG_ABSENCE];
push_lp(&mut out, self.vrf_proof.as_bytes());
out.extend_from_slice(&self.auth_path.to_bytes());
out
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let Some((&tag, rest)) = bytes.split_first() else {
return Err(Error::MalformedConiksProof("empty proof".into()));
};
if tag != TAG_ABSENCE {
return Err(Error::MalformedConiksProof(format!(
"expected absence tag 0x00, got {tag:#04x}"
)));
}
let (vrf_proof, off) = read_lp(rest, 0, "vrf proof")?;
let (auth_path, consumed) = AuthPath::parse(&rest[off..])?;
if off + consumed != rest.len() {
return Err(Error::MalformedConiksProof(
"trailing bytes after authentication path".into(),
));
}
Ok(Self {
vrf_proof: VrfProof::from_bytes(vrf_proof.to_vec()),
auth_path,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LookupResult {
Present(LookupProof),
Absent(AbsenceProof),
}
struct DirectoryEntry {
value: Vec<u8>,
opening: Opening,
}
pub struct ConiksDirectory {
namespace: Namespace,
vrf: Box<dyn Vrf>,
vrf_secret: VrfSecretKey,
vrf_public: VrfPublicKey,
hasher: TreeHasher,
entries: BTreeMap<Vec<u8>, DirectoryEntry>,
leaves: BTreeMap<[u8; INDEX_LEN], Commitment>,
}
impl ConiksDirectory {
#[must_use]
pub fn new(namespace: Namespace, vrf: Box<dyn Vrf>) -> Self {
let (vrf_secret, vrf_public) = vrf.generate_keypair();
Self::with_secret_key(namespace, vrf, vrf_secret, vrf_public)
}
pub fn from_secret_key(
namespace: Namespace,
vrf: Box<dyn Vrf>,
vrf_secret: VrfSecretKey,
) -> Result<Self> {
let vrf_public = vrf.derive_public_key(&vrf_secret)?;
Ok(Self::with_secret_key(
namespace, vrf, vrf_secret, vrf_public,
))
}
fn with_secret_key(
namespace: Namespace,
vrf: Box<dyn Vrf>,
vrf_secret: VrfSecretKey,
vrf_public: VrfPublicKey,
) -> Self {
let hasher = TreeHasher::new(&namespace, vrf.suite_id());
Self {
namespace,
vrf,
vrf_secret,
vrf_public,
hasher,
entries: BTreeMap::new(),
leaves: BTreeMap::new(),
}
}
#[must_use]
pub fn namespace(&self) -> &Namespace {
&self.namespace
}
#[must_use]
pub fn vrf_public_key(&self) -> &VrfPublicKey {
&self.vrf_public
}
pub fn insert(&mut self, identity: &[u8], value: &[u8]) -> Result<()> {
let alpha = self.namespace.vrf_input(identity);
let proof = self.vrf.prove(&self.vrf_secret, &alpha)?;
let index = self.vrf.proof_to_output(&proof)?.index();
let (commitment, opening) =
crate::commitment::commit(&self.namespace.commitment_label(), value);
self.leaves.insert(index, commitment);
self.entries.insert(
identity.to_vec(),
DirectoryEntry {
value: value.to_vec(),
opening,
},
);
Ok(())
}
#[must_use]
pub fn root(&self) -> [u8; NODE_LEN] {
let leaves: Vec<_> = self.leaves.iter().collect();
self.hasher.subtree(0, &leaves)
}
pub fn lookup(&self, identity: &[u8]) -> Result<LookupResult> {
let alpha = self.namespace.vrf_input(identity);
let vrf_proof = self.vrf.prove(&self.vrf_secret, &alpha)?;
let index = self.vrf.proof_to_output(&vrf_proof)?.index();
let all: Vec<_> = self.leaves.iter().collect();
let mut siblings = Vec::with_capacity(TREE_DEPTH);
self.hasher.collect_path(0, &index, &all, &mut siblings);
let auth_path = AuthPath { siblings };
match self.entries.get(identity) {
Some(entry) => Ok(LookupResult::Present(LookupProof {
vrf_proof,
value: entry.value.clone(),
opening: entry.opening.clone(),
auth_path,
})),
None => Ok(LookupResult::Absent(AbsenceProof {
vrf_proof,
auth_path,
})),
}
}
}
fn verified_index(
vrf: &dyn Vrf,
namespace: &Namespace,
vrf_public: &VrfPublicKey,
identity: &[u8],
vrf_proof: &VrfProof,
) -> Result<[u8; INDEX_LEN]> {
let alpha = namespace.vrf_input(identity);
match vrf.verify(vrf_public, &alpha, vrf_proof)? {
Some(output) => Ok(output.index()),
None => Err(Error::VrfProofInvalid),
}
}
pub fn verify_lookup(
vrf: &dyn Vrf,
namespace: &Namespace,
vrf_public: &VrfPublicKey,
root: &[u8; NODE_LEN],
identity: &[u8],
proof: &LookupProof,
) -> Result<Vec<u8>> {
let index = verified_index(vrf, namespace, vrf_public, identity, &proof.vrf_proof)?;
let commitment = crate::commitment::commit_with_opening(
&namespace.commitment_label(),
&proof.value,
&proof.opening,
);
let hasher = TreeHasher::new(namespace, vrf.suite_id());
let leaf = hasher.leaf_hash(&index, &commitment);
let recomputed = hasher.recompute_root(&index, leaf, &proof.auth_path.siblings);
if &recomputed == root {
Ok(proof.value.clone())
} else {
Err(Error::ConiksRootMismatch)
}
}
pub fn verify_absence(
vrf: &dyn Vrf,
namespace: &Namespace,
vrf_public: &VrfPublicKey,
root: &[u8; NODE_LEN],
identity: &[u8],
proof: &AbsenceProof,
) -> Result<()> {
let index = verified_index(vrf, namespace, vrf_public, identity, &proof.vrf_proof)?;
let hasher = TreeHasher::new(namespace, vrf.suite_id());
let recomputed = hasher.recompute_root(&index, hasher.empty_leaf(), &proof.auth_path.siblings);
if &recomputed == root {
Ok(())
} else {
Err(Error::ConiksRootMismatch)
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::vrf::Ecvrf;
fn dir() -> ConiksDirectory {
ConiksDirectory::new(Namespace::parse("acme").unwrap(), Box::new(Ecvrf))
}
#[test]
fn namespace_validation() {
assert!(Namespace::parse("acme").is_ok());
assert!(Namespace::parse("mosslet").is_ok());
assert!(Namespace::parse("").is_err());
assert!(Namespace::parse("has/slash").is_err());
assert!(Namespace::parse("has space").is_err());
}
#[test]
fn present_lookup_verifies() {
let mut d = dir();
d.insert(b"alice", b"alice-value").unwrap();
d.insert(b"bob", b"bob-value").unwrap();
let root = d.root();
let LookupResult::Present(proof) = d.lookup(b"alice").unwrap() else {
panic!("alice should be present");
};
let value = verify_lookup(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"alice",
&proof,
)
.unwrap();
assert_eq!(value, b"alice-value");
}
#[test]
fn absent_lookup_verifies() {
let mut d = dir();
d.insert(b"alice", b"v").unwrap();
let root = d.root();
let LookupResult::Absent(proof) = d.lookup(b"carol").unwrap() else {
panic!("carol should be absent");
};
verify_absence(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"carol",
&proof,
)
.unwrap();
}
#[test]
fn empty_directory_absence_verifies() {
let d = dir();
let root = d.root();
let LookupResult::Absent(proof) = d.lookup(b"nobody").unwrap() else {
panic!("absent");
};
verify_absence(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"nobody",
&proof,
)
.unwrap();
}
#[test]
fn tampered_value_is_rejected() {
let mut d = dir();
d.insert(b"alice", b"real").unwrap();
let root = d.root();
let LookupResult::Present(mut proof) = d.lookup(b"alice").unwrap() else {
panic!("present");
};
proof.value = b"forged".to_vec();
assert_eq!(
verify_lookup(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"alice",
&proof
),
Err(Error::ConiksRootMismatch)
);
}
#[test]
fn absence_proof_for_present_identity_is_rejected() {
let mut d = dir();
d.insert(b"alice", b"v").unwrap();
let root = d.root();
let LookupResult::Present(present) = d.lookup(b"alice").unwrap() else {
panic!("present");
};
let forged = AbsenceProof {
vrf_proof: VrfProof::from_bytes(present.to_bytes()[5..5].to_vec()),
auth_path: present.auth_path.clone(),
};
assert!(
verify_absence(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"alice",
&forged
)
.is_err()
);
}
#[test]
fn cross_namespace_proof_is_rejected() {
let mut d = dir();
d.insert(b"alice", b"v").unwrap();
let root = d.root();
let LookupResult::Present(proof) = d.lookup(b"alice").unwrap() else {
panic!("present");
};
let other = Namespace::parse("evil").unwrap();
assert!(
verify_lookup(&Ecvrf, &other, d.vrf_public_key(), &root, b"alice", &proof).is_err()
);
}
#[test]
fn wrong_root_is_rejected() {
let mut d = dir();
d.insert(b"alice", b"v").unwrap();
let LookupResult::Present(proof) = d.lookup(b"alice").unwrap() else {
panic!("present");
};
let bad_root = [0u8; NODE_LEN];
assert_eq!(
verify_lookup(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&bad_root,
b"alice",
&proof
),
Err(Error::ConiksRootMismatch)
);
}
#[test]
fn proofs_round_trip_through_bytes() {
let mut d = dir();
d.insert(b"alice", b"alice-value").unwrap();
let root = d.root();
let LookupResult::Present(present) = d.lookup(b"alice").unwrap() else {
panic!("present");
};
let reparsed = LookupProof::from_bytes(&present.to_bytes()).unwrap();
assert_eq!(reparsed, present);
assert_eq!(
verify_lookup(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"alice",
&reparsed
)
.unwrap(),
b"alice-value"
);
let LookupResult::Absent(absent) = d.lookup(b"carol").unwrap() else {
panic!("absent");
};
let reparsed_absent = AbsenceProof::from_bytes(&absent.to_bytes()).unwrap();
assert_eq!(reparsed_absent, absent);
verify_absence(
&Ecvrf,
d.namespace(),
d.vrf_public_key(),
&root,
b"carol",
&reparsed_absent,
)
.unwrap();
}
#[test]
fn malformed_proof_bytes_are_rejected() {
assert!(LookupProof::from_bytes(&[]).is_err());
assert!(LookupProof::from_bytes(&[TAG_ABSENCE]).is_err()); assert!(AbsenceProof::from_bytes(&[TAG_PRESENCE]).is_err()); assert!(AbsenceProof::from_bytes(&[TAG_ABSENCE, 0, 0]).is_err()); }
#[test]
fn index_is_namespace_scoped() {
let ns_a = Namespace::parse("a").unwrap();
let ns_b = Namespace::parse("b").unwrap();
assert_ne!(ns_a.vrf_input(b"alice"), ns_b.vrf_input(b"alice"));
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(24))]
#[test]
fn random_directory_presence_and_absence(
present_ids in proptest::collection::vec(any::<u64>(), 1..9),
absent_id: u64,
) {
prop_assume!(!present_ids.contains(&absent_id));
let mut d = dir();
for id in &present_ids {
d.insert(&id.to_be_bytes(), format!("v{id}").as_bytes()).unwrap();
}
let root = d.root();
for id in &present_ids {
let key = id.to_be_bytes();
let LookupResult::Present(proof) = d.lookup(&key).unwrap() else {
prop_assert!(false, "expected present");
unreachable!();
};
let value = verify_lookup(
&Ecvrf, d.namespace(), d.vrf_public_key(), &root, &key, &proof,
).unwrap();
prop_assert_eq!(value, format!("v{id}").into_bytes());
}
let key = absent_id.to_be_bytes();
let LookupResult::Absent(proof) = d.lookup(&key).unwrap() else {
prop_assert!(false, "expected absent");
unreachable!();
};
verify_absence(
&Ecvrf, d.namespace(), d.vrf_public_key(), &root, &key, &proof,
).unwrap();
}
}
}