use std::collections::{BTreeMap, BTreeSet};
use affinidi_bbs as bbs;
use affinidi_rdf_encoding::{jsonld, nquads, rdfc1};
use hmac::{Hmac, Mac};
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::DataIntegrityError;
type HmacSha256 = Hmac<Sha256>;
const URN_BNID: &str = "urn:bnid:";
const CBOR_PREFIX_BASE: [u8; 3] = [0xd9, 0x5d, 0x02];
pub fn create_base_proof_value(
document: &Value,
proof_config: &Value,
mandatory_pointers: &[&str],
sk: &bbs::SecretKey,
pk: &bbs::PublicKey,
hmac_key: &[u8],
) -> Result<String, DataIntegrityError> {
let proof_hash = proof_hash(proof_config)?;
let grouped = canonicalize_and_group(document, mandatory_pointers, hmac_key)?;
let mut bbs_header = proof_hash.to_vec();
bbs_header.extend_from_slice(&grouped.mandatory_hash);
let non_mandatory = grouped.non_mandatory();
let messages: Vec<&[u8]> = non_mandatory.iter().map(|s| s.as_bytes()).collect();
let signature =
bbs::sign(sk, pk, &bbs_header, &messages).map_err(DataIntegrityError::signing)?;
serialize_base_proof_value(
&signature.to_bytes(),
&bbs_header,
&pk.to_bytes(),
hmac_key,
mandatory_pointers,
)
}
#[allow(clippy::too_many_arguments)]
pub fn sign_base_document(
document: &Value,
mandatory_pointers: &[&str],
verification_method: &str,
created: &str,
sk: &bbs::SecretKey,
pk: &bbs::PublicKey,
hmac_key: &[u8],
) -> Result<Value, DataIntegrityError> {
let conformance = |m: &str| DataIntegrityError::Conformance(m.to_string());
let context = document
.get("@context")
.cloned()
.ok_or_else(|| conformance("document must have an @context"))?;
let proof_config = serde_json::json!({
"type": "DataIntegrityProof",
"cryptosuite": "bbs-2023",
"created": created,
"verificationMethod": verification_method,
"proofPurpose": "assertionMethod",
"@context": context,
});
let proof_value = create_base_proof_value(
document,
&proof_config,
mandatory_pointers,
sk,
pk,
hmac_key,
)?;
let mut proof = proof_config;
let obj = proof.as_object_mut().expect("proof config is an object");
obj.remove("@context");
obj.insert("proofValue".to_string(), Value::String(proof_value));
let mut base = document.clone();
base.as_object_mut()
.ok_or_else(|| conformance("document must be an object"))?
.insert("proof".to_string(), proof);
Ok(base)
}
pub fn serialize_base_proof_value(
bbs_signature: &[u8],
bbs_header: &[u8],
public_key: &[u8],
hmac_key: &[u8],
mandatory_pointers: &[&str],
) -> Result<String, DataIntegrityError> {
let components = ciborium::Value::Array(vec![
ciborium::Value::Bytes(bbs_signature.to_vec()),
ciborium::Value::Bytes(bbs_header.to_vec()),
ciborium::Value::Bytes(public_key.to_vec()),
ciborium::Value::Bytes(hmac_key.to_vec()),
ciborium::Value::Array(
mandatory_pointers
.iter()
.map(|p| ciborium::Value::Text((*p).to_string()))
.collect(),
),
]);
let mut buf = CBOR_PREFIX_BASE.to_vec();
ciborium::into_writer(&components, &mut buf)
.map_err(|e| DataIntegrityError::MalformedProof(format!("CBOR encode: {e}")))?;
Ok(multibase::encode(multibase::Base::Base64Url, &buf))
}
const CBOR_PREFIX_DERIVED: [u8; 3] = [0xd9, 0x5d, 0x03];
struct DerivedProofValue {
bbs_proof: Vec<u8>,
label_map: BTreeMap<String, String>,
mandatory_indexes: Vec<usize>,
selective_indexes: Vec<usize>,
presentation_header: Vec<u8>,
}
fn parse_derived_proof_value(proof_value: &str) -> Result<DerivedProofValue, DataIntegrityError> {
let malformed = |m: &str| DataIntegrityError::MalformedProof(m.to_string());
if !proof_value.starts_with('u') {
return Err(malformed("proofValue must be multibase base64url ('u')"));
}
let (_base, bytes) =
multibase::decode(proof_value).map_err(|e| malformed(&format!("multibase: {e}")))?;
if bytes.len() < 3 || bytes[..3] != CBOR_PREFIX_DERIVED {
return Err(malformed("proofValue is not a bbs-2023 derived proof"));
}
let value: ciborium::Value =
ciborium::from_reader(&bytes[3..]).map_err(|e| malformed(&format!("CBOR: {e}")))?;
let arr = value
.as_array()
.ok_or_else(|| malformed("derived proofValue must be a CBOR array"))?;
if arr.len() != 5 {
return Err(malformed("derived proofValue must have 5 elements"));
}
let bbs_proof = arr[0]
.as_bytes()
.ok_or_else(|| malformed("bbsProof must be bytes"))?
.clone();
let mut label_map = BTreeMap::new();
for (k, v) in arr[1]
.as_map()
.ok_or_else(|| malformed("labelMap must be a CBOR map"))?
{
let n = cbor_int(k).ok_or_else(|| malformed("labelMap key"))?;
let m = cbor_int(v).ok_or_else(|| malformed("labelMap value"))?;
label_map.insert(format!("c14n{n}"), format!("b{m}"));
}
let mandatory_indexes =
cbor_index_array(&arr[2]).ok_or_else(|| malformed("mandatoryIndexes"))?;
let selective_indexes =
cbor_index_array(&arr[3]).ok_or_else(|| malformed("selectiveIndexes"))?;
let presentation_header = arr[4]
.as_bytes()
.ok_or_else(|| malformed("presentationHeader must be bytes"))?
.clone();
Ok(DerivedProofValue {
bbs_proof,
label_map,
mandatory_indexes,
selective_indexes,
presentation_header,
})
}
fn cbor_int(v: &ciborium::Value) -> Option<usize> {
let i: i128 = v.as_integer()?.into();
usize::try_from(i).ok()
}
fn cbor_index_array(v: &ciborium::Value) -> Option<Vec<usize>> {
v.as_array()?.iter().map(cbor_int).collect()
}
pub fn verify_derived_proof(
reveal_document: &Value,
pk: &bbs::PublicKey,
) -> Result<bool, DataIntegrityError> {
let malformed = |m: &str| DataIntegrityError::MalformedProof(m.to_string());
let proof = reveal_document
.get("proof")
.ok_or_else(|| malformed("reveal document has no proof"))?;
let proof_value = proof
.get("proofValue")
.and_then(Value::as_str)
.ok_or_else(|| malformed("proof has no proofValue"))?;
let parsed = parse_derived_proof_value(proof_value)?;
let mut proof_config = proof.clone();
let cfg = proof_config
.as_object_mut()
.ok_or_else(|| malformed("proof must be an object"))?;
cfg.remove("proofValue");
if let Some(ctx) = reveal_document.get("@context") {
cfg.insert("@context".to_string(), ctx.clone());
}
let proof_hash = proof_hash(&proof_config)?;
let mut doc = reveal_document.clone();
doc.as_object_mut()
.ok_or_else(|| malformed("document must be an object"))?
.remove("proof");
let dataset = jsonld::expand_and_to_rdf(&doc).map_err(canon_err)?;
let (canonical_c14n, _) = rdfc1::canonicalize_with_label_map(&dataset).map_err(canon_err)?;
let labeled = lines_with_newline(&relabel_and_sort(&canonical_c14n, &parsed.label_map));
let mandatory_set: BTreeSet<usize> = parsed.mandatory_indexes.iter().copied().collect();
let mut mandatory = Vec::new();
let mut non_mandatory = Vec::new();
for (i, nq) in labeled.iter().enumerate() {
if mandatory_set.contains(&i) {
mandatory.push(nq.clone());
} else {
non_mandatory.push(nq.clone());
}
}
let mut hasher = Sha256::new();
for m in &mandatory {
hasher.update(m.as_bytes());
}
let mandatory_hash: [u8; 32] = hasher.finalize().into();
let mut bbs_header = proof_hash.to_vec();
bbs_header.extend_from_slice(&mandatory_hash);
let disclosed: Vec<&[u8]> = non_mandatory.iter().map(|s| s.as_bytes()).collect();
let bbs_proof = bbs::Proof::from_bytes(&parsed.bbs_proof);
bbs::proof_verify(
pk,
&bbs_proof,
&bbs_header,
&parsed.presentation_header,
&disclosed,
&parsed.selective_indexes,
)
.map_err(|e| {
tracing::debug!("bbs-2023 derived proof verification failed: {e}");
DataIntegrityError::InvalidSignature {
suite: crate::crypto_suites::CryptoSuite::Bbs2023,
reason: crate::error::SignatureFailure::Invalid,
}
})
}
#[derive(Debug)]
pub struct GroupedStatements {
pub canonical: Vec<String>,
pub mandatory_indexes: Vec<usize>,
pub non_mandatory_indexes: Vec<usize>,
pub mandatory_hash: [u8; 32],
}
impl GroupedStatements {
pub fn mandatory(&self) -> Vec<&str> {
self.mandatory_indexes
.iter()
.map(|&i| self.canonical[i].as_str())
.collect()
}
pub fn non_mandatory(&self) -> Vec<&str> {
self.non_mandatory_indexes
.iter()
.map(|&i| self.canonical[i].as_str())
.collect()
}
}
struct CanonicalizedHmac {
skolemized: Value,
canonical: Vec<String>,
input_to_b: BTreeMap<String, String>,
}
fn canonicalize_hmac(
document: &Value,
hmac_key: &[u8],
) -> Result<CanonicalizedHmac, DataIntegrityError> {
let skolemized = skolemize_compact(document);
let deskolemized = to_deskolemized_nquads(&skolemized)?;
let joined: String = deskolemized.iter().cloned().collect();
let dataset = nquads::parse(&joined).map_err(canon_err)?;
let (canonical_c14n, input_to_c14n) =
rdfc1::canonicalize_with_label_map(&dataset).map_err(canon_err)?;
let c14n_to_b = hmac_label_map(&canonical_c14n, hmac_key)?;
let input_to_b: BTreeMap<String, String> = input_to_c14n
.iter()
.filter_map(|(input, c14n)| c14n_to_b.get(c14n).map(|b| (input.clone(), b.clone())))
.collect();
let canonical = lines_with_newline(&relabel_and_sort(&canonical_c14n, &c14n_to_b));
Ok(CanonicalizedHmac {
skolemized,
canonical,
input_to_b,
})
}
fn select_indices(
c: &CanonicalizedHmac,
pointers: &[&str],
) -> Result<Vec<usize>, DataIntegrityError> {
if pointers.is_empty() {
return Ok(Vec::new());
}
let selection =
select_json_ld(&c.skolemized, pointers).ok_or_else(|| canon_err("empty selection"))?;
let sel_nquads = to_deskolemized_nquads(&selection)?;
let mut idx = BTreeSet::new();
for line in &sel_nquads {
let relabeled = relabel_blank_line(line, &c.input_to_b);
let pos = c
.canonical
.iter()
.position(|x| *x == relabeled)
.ok_or_else(|| canon_err(format!("selected statement not found: {relabeled:?}")))?;
idx.insert(pos);
}
Ok(idx.into_iter().collect())
}
pub fn canonicalize_and_group(
document: &Value,
mandatory_pointers: &[&str],
hmac_key: &[u8],
) -> Result<GroupedStatements, DataIntegrityError> {
let c = canonicalize_hmac(document, hmac_key)?;
let mandatory_indexes = select_indices(&c, mandatory_pointers)?;
let mandatory_set: BTreeSet<usize> = mandatory_indexes.iter().copied().collect();
let non_mandatory_indexes: Vec<usize> = (0..c.canonical.len())
.filter(|i| !mandatory_set.contains(i))
.collect();
let mut hasher = Sha256::new();
for &i in &mandatory_indexes {
hasher.update(c.canonical[i].as_bytes());
}
let mandatory_hash: [u8; 32] = hasher.finalize().into();
Ok(GroupedStatements {
canonical: c.canonical,
mandatory_indexes,
non_mandatory_indexes,
mandatory_hash,
})
}
fn canon_err(e: impl std::fmt::Display) -> DataIntegrityError {
DataIntegrityError::Canonicalization(e.to_string())
}
struct BaseProofValue {
bbs_signature: Vec<u8>,
bbs_header: Vec<u8>,
hmac_key: Vec<u8>,
mandatory_pointers: Vec<String>,
}
fn parse_base_proof_value(proof_value: &str) -> Result<BaseProofValue, DataIntegrityError> {
let malformed = |m: &str| DataIntegrityError::MalformedProof(m.to_string());
if !proof_value.starts_with('u') {
return Err(malformed("proofValue must be multibase base64url ('u')"));
}
let (_base, bytes) =
multibase::decode(proof_value).map_err(|e| malformed(&format!("multibase: {e}")))?;
if bytes.len() < 3 || bytes[..3] != CBOR_PREFIX_BASE {
return Err(malformed("proofValue is not a bbs-2023 base proof"));
}
let value: ciborium::Value =
ciborium::from_reader(&bytes[3..]).map_err(|e| malformed(&format!("CBOR: {e}")))?;
let arr = value
.as_array()
.ok_or_else(|| malformed("base proofValue must be a CBOR array"))?;
if arr.len() != 5 {
return Err(malformed("base proofValue must have 5 elements"));
}
let bytes_at = |i: usize, what: &str| {
arr[i]
.as_bytes()
.cloned()
.ok_or_else(|| malformed(&format!("{what} must be bytes")))
};
let mandatory_pointers = arr[4]
.as_array()
.ok_or_else(|| malformed("mandatoryPointers must be an array"))?
.iter()
.map(|p| {
p.as_text()
.map(str::to_string)
.ok_or_else(|| malformed("mandatory pointer must be text"))
})
.collect::<Result<_, _>>()?;
Ok(BaseProofValue {
bbs_signature: bytes_at(0, "bbsSignature")?,
bbs_header: bytes_at(1, "bbsHeader")?,
hmac_key: bytes_at(3, "hmacKey")?,
mandatory_pointers,
})
}
pub fn create_derived_proof(
base_document: &Value,
selective_pointers: &[&str],
presentation_header: &[u8],
pk: &bbs::PublicKey,
) -> Result<Value, DataIntegrityError> {
let malformed = |m: &str| DataIntegrityError::MalformedProof(m.to_string());
let proof = base_document
.get("proof")
.ok_or_else(|| malformed("base document has no proof"))?;
let base = parse_base_proof_value(
proof
.get("proofValue")
.and_then(Value::as_str)
.ok_or_else(|| malformed("proof has no proofValue"))?,
)?;
let mut document = base_document.clone();
document
.as_object_mut()
.ok_or_else(|| malformed("document must be an object"))?
.remove("proof");
let mandatory_ptrs: Vec<&str> = base.mandatory_pointers.iter().map(String::as_str).collect();
let combined_ptrs: Vec<&str> = mandatory_ptrs
.iter()
.copied()
.chain(selective_pointers.iter().copied())
.collect();
let c = canonicalize_hmac(&document, &base.hmac_key)?;
let mandatory_indexes = select_indices(&c, &mandatory_ptrs)?;
let selective_indexes = select_indices(&c, selective_pointers)?;
let combined_indexes = select_indices(&c, &combined_ptrs)?;
let mandatory_set: BTreeSet<usize> = mandatory_indexes.iter().copied().collect();
let selective_set: BTreeSet<usize> = selective_indexes.iter().copied().collect();
let non_mandatory_indexes: Vec<usize> = (0..c.canonical.len())
.filter(|i| !mandatory_set.contains(i))
.collect();
let adj_mandatory: Vec<usize> = mandatory_indexes
.iter()
.map(|m| {
combined_indexes
.iter()
.position(|x| x == m)
.expect("mandatory ⊆ combined")
})
.collect();
let adj_selective: Vec<usize> = non_mandatory_indexes
.iter()
.enumerate()
.filter(|(_, nm)| selective_set.contains(nm))
.map(|(pos, _)| pos)
.collect();
let non_mandatory: Vec<&str> = non_mandatory_indexes
.iter()
.map(|&i| c.canonical[i].as_str())
.collect();
let messages: Vec<&[u8]> = non_mandatory.iter().map(|s| s.as_bytes()).collect();
let signature = bbs::Signature::from_bytes(&base.bbs_signature)
.map_err(|e| malformed(&format!("decode bbsSignature: {e}")))?;
let bbs_proof = bbs::proof_gen(
pk,
&signature,
&base.bbs_header,
presentation_header,
&messages,
&adj_selective,
)
.map_err(DataIntegrityError::signing)?;
let label_map = build_derived_label_map(&c, &combined_ptrs)?;
let proof_value = serialize_derived_proof_value(
bbs_proof.to_bytes(),
&label_map,
&adj_mandatory,
&adj_selective,
presentation_header,
)?;
let mut reveal = select_json_ld(&document, &combined_ptrs)
.ok_or_else(|| malformed("empty reveal selection"))?;
let mut proof_obj = proof.clone();
proof_obj
.as_object_mut()
.ok_or_else(|| malformed("proof must be an object"))?
.insert("proofValue".to_string(), Value::String(proof_value));
reveal
.as_object_mut()
.ok_or_else(|| malformed("reveal must be an object"))?
.insert("proof".to_string(), proof_obj);
Ok(reveal)
}
fn build_derived_label_map(
c: &CanonicalizedHmac,
combined_ptrs: &[&str],
) -> Result<BTreeMap<String, String>, DataIntegrityError> {
let selection =
select_json_ld(&c.skolemized, combined_ptrs).ok_or_else(|| canon_err("empty selection"))?;
let sel_nquads = to_deskolemized_nquads(&selection)?;
let joined: String = sel_nquads.concat();
let dataset = nquads::parse(&joined).map_err(canon_err)?;
let (_canonical, reveal_input_to_c14n) =
rdfc1::canonicalize_with_label_map(&dataset).map_err(canon_err)?;
let mut label_map = BTreeMap::new();
for (reveal_input, reveal_c14n) in &reveal_input_to_c14n {
if let Some(b) = c.input_to_b.get(reveal_input) {
label_map.insert(reveal_c14n.clone(), b.clone());
}
}
Ok(label_map)
}
fn serialize_derived_proof_value(
bbs_proof: &[u8],
label_map: &BTreeMap<String, String>,
mandatory_indexes: &[usize],
selective_indexes: &[usize],
presentation_header: &[u8],
) -> Result<String, DataIntegrityError> {
let malformed = |m: &str| DataIntegrityError::MalformedProof(m.to_string());
let mut compressed = Vec::with_capacity(label_map.len());
for (k, v) in label_map {
let n: i64 = k
.strip_prefix("c14n")
.and_then(|s| s.parse().ok())
.ok_or_else(|| malformed("label map key"))?;
let m: i64 = v
.strip_prefix('b')
.and_then(|s| s.parse().ok())
.ok_or_else(|| malformed("label map value"))?;
compressed.push((
ciborium::Value::Integer(n.into()),
ciborium::Value::Integer(m.into()),
));
}
let index_array = |idx: &[usize]| {
ciborium::Value::Array(
idx.iter()
.map(|&i| ciborium::Value::Integer((i as i64).into()))
.collect(),
)
};
let payload = ciborium::Value::Array(vec![
ciborium::Value::Bytes(bbs_proof.to_vec()),
ciborium::Value::Map(compressed),
index_array(mandatory_indexes),
index_array(selective_indexes),
ciborium::Value::Bytes(presentation_header.to_vec()),
]);
let mut buf = CBOR_PREFIX_DERIVED.to_vec();
ciborium::into_writer(&payload, &mut buf)
.map_err(|e| malformed(&format!("CBOR encode: {e}")))?;
Ok(multibase::encode(multibase::Base::Base64Url, &buf))
}
pub fn proof_hash(proof_config: &Value) -> Result<[u8; 32], DataIntegrityError> {
let dataset = jsonld::expand_and_to_rdf(proof_config).map_err(canon_err)?;
let canonical = rdfc1::canonicalize(&dataset).map_err(canon_err)?;
Ok(Sha256::digest(canonical.as_bytes()).into())
}
pub fn hmac_canonicalize(document: &Value, hmac_key: &[u8]) -> Result<String, DataIntegrityError> {
let dataset = jsonld::expand_and_to_rdf(document).map_err(canon_err)?;
let canonical = rdfc1::canonicalize(&dataset).map_err(canon_err)?;
let label_map = hmac_label_map(&canonical, hmac_key)?;
Ok(relabel_and_sort(&canonical, &label_map))
}
fn hmac_label_map(
canonical: &str,
hmac_key: &[u8],
) -> Result<BTreeMap<String, String>, DataIntegrityError> {
let labels = distinct_c14n_labels(canonical);
let mut keyed: Vec<(String, String)> = labels
.into_iter()
.map(|label| {
let mut mac = HmacSha256::new_from_slice(hmac_key)
.map_err(|e| canon_err(format!("HMAC key: {e}")))?;
mac.update(label.as_bytes());
let digest = mac.finalize().into_bytes();
let sort_key = multibase::encode(multibase::Base::Base64Url, digest);
Ok((sort_key, label))
})
.collect::<Result<_, DataIntegrityError>>()?;
keyed.sort();
Ok(keyed
.into_iter()
.enumerate()
.map(|(i, (_, label))| (label, format!("b{i}")))
.collect())
}
fn distinct_c14n_labels(canonical: &str) -> Vec<String> {
let mut set = std::collections::BTreeSet::new();
for line in canonical.lines() {
let mut rest = line;
while let Some(pos) = rest.find("_:c14n") {
let after = &rest[pos + 2..]; let end = after
.char_indices()
.find(|(_, c)| !(c.is_ascii_alphanumeric()))
.map(|(i, _)| i)
.unwrap_or(after.len());
set.insert(after[..end].to_string());
rest = &after[end..];
}
}
set.into_iter().collect()
}
fn relabel_and_sort(canonical: &str, label_map: &BTreeMap<String, String>) -> String {
let mut lines: Vec<String> = canonical
.lines()
.map(|line| {
let mut out = String::with_capacity(line.len() + 1);
let mut rest = line;
while let Some(pos) = rest.find("_:c14n") {
out.push_str(&rest[..pos]);
let after = &rest[pos + 2..]; let end = after
.char_indices()
.find(|(_, c)| !c.is_ascii_alphanumeric())
.map(|(i, _)| i)
.unwrap_or(after.len());
let label = &after[..end];
out.push_str("_:");
out.push_str(label_map.get(label).map(String::as_str).unwrap_or(label));
rest = &after[end..];
}
out.push_str(rest);
out.push('\n');
out
})
.collect();
lines.sort();
lines.concat()
}
fn lines_with_newline(joined: &str) -> Vec<String> {
joined
.lines()
.map(|l| {
let mut s = l.to_string();
s.push('\n');
s
})
.collect()
}
fn skolemize_compact(document: &Value) -> Value {
let mut counter = 0u64;
let mut out = document.clone();
skolemize_value(&mut out, &mut counter);
out
}
fn skolemize_value(v: &mut Value, counter: &mut u64) {
match v {
Value::Object(map) => {
if map.contains_key("@value") {
return;
}
for (k, val) in map.iter_mut() {
if k.starts_with('@') && k != "@list" {
continue;
}
skolemize_value(val, counter);
}
if !map.contains_key("@id") && !map.contains_key("id") {
let id = format!("{URN_BNID}_skolem_{counter}");
*counter += 1;
map.insert("@id".to_string(), Value::String(id));
}
}
Value::Array(arr) => {
for item in arr.iter_mut() {
skolemize_value(item, counter);
}
}
_ => {}
}
}
fn to_deskolemized_nquads(document: &Value) -> Result<Vec<String>, DataIntegrityError> {
let dataset = jsonld::expand_and_to_rdf(document).map_err(canon_err)?;
let mut lines: Vec<String> = dataset
.quads()
.iter()
.map(|q| {
let mut line = deskolemize_line(&nquads::serialize_quad(q));
line.push('\n');
line
})
.collect();
lines.sort();
Ok(lines)
}
fn deskolemize_line(line: &str) -> String {
let needle = format!("<{URN_BNID}");
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(pos) = rest.find(&needle) {
out.push_str(&rest[..pos]);
let after = &rest[pos + needle.len()..];
let end = after.find('>').unwrap_or(after.len());
out.push_str("_:");
out.push_str(&after[..end]);
rest = if end < after.len() {
&after[end + 1..]
} else {
""
};
}
out.push_str(rest);
out
}
fn relabel_blank_line(line: &str, label_map: &BTreeMap<String, String>) -> String {
let mut out = String::with_capacity(line.len());
let mut rest = line;
while let Some(pos) = rest.find("_:") {
out.push_str(&rest[..pos]);
let after = &rest[pos + 2..];
let end = after
.find(|c: char| c.is_whitespace())
.unwrap_or(after.len());
let label = &after[..end];
out.push_str("_:");
out.push_str(label_map.get(label).map(String::as_str).unwrap_or(label));
rest = &after[end..];
}
out.push_str(rest);
out
}
fn parse_pointer(pointer: &str) -> Vec<String> {
pointer
.split('/')
.skip(1)
.map(|p| p.replace("~1", "/").replace("~0", "~"))
.collect()
}
fn select_json_ld(document: &Value, pointers: &[&str]) -> Option<Value> {
if pointers.is_empty() {
return None;
}
let mut selection = Map::new();
if let Some(ctx) = document.get("@context") {
selection.insert("@context".to_string(), ctx.clone());
}
init_selection(&mut selection, document);
let mut selection = Value::Object(selection);
for pointer in pointers {
let paths = parse_pointer(pointer);
if paths.is_empty() {
return Some(document.clone());
}
select_path(document, &paths, &mut selection);
}
compact_arrays(&mut selection);
Some(selection)
}
fn compact_arrays(v: &mut Value) {
match v {
Value::Array(arr) => {
arr.retain(|x| !x.is_null());
arr.iter_mut().for_each(compact_arrays);
}
Value::Object(map) => map.values_mut().for_each(compact_arrays),
_ => {}
}
}
fn init_selection(selection: &mut Map<String, Value>, source: &Value) {
for id_key in ["@id", "id"] {
if let Some(id) = source.get(id_key).and_then(Value::as_str)
&& !id.starts_with("_:")
{
selection.insert(id_key.to_string(), Value::String(id.to_string()));
}
}
for type_key in ["@type", "type"] {
if let Some(t) = source.get(type_key) {
selection.insert(type_key.to_string(), t.clone());
}
}
}
fn select_path(source: &Value, paths: &[String], selection: &mut Value) {
let path = &paths[0];
let child_source = match index_value(source, path) {
Some(v) => v,
None => return, };
if paths.len() == 1 {
let new_value = match child_source {
Value::Object(obj) => {
let mut merged = index_value(selection, path)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
for (k, v) in obj {
merged.insert(k.clone(), v.clone());
}
Value::Object(merged)
}
other => other.clone(),
};
set_index(selection, path, new_value);
} else {
ensure_intermediate(selection, path, child_source);
let child_sel = index_value_mut(selection, path).expect("intermediate exists");
select_path(child_source, &paths[1..], child_sel);
}
}
fn index_value<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
match value {
Value::Object(map) => map.get(path),
Value::Array(arr) => path.parse::<usize>().ok().and_then(|i| arr.get(i)),
_ => None,
}
}
fn index_value_mut<'a>(value: &'a mut Value, path: &str) -> Option<&'a mut Value> {
match value {
Value::Object(map) => map.get_mut(path),
Value::Array(arr) => path.parse::<usize>().ok().and_then(|i| arr.get_mut(i)),
_ => None,
}
}
fn set_index(selected: &mut Value, path: &str, new_value: Value) {
match selected {
Value::Object(map) => {
map.insert(path.to_string(), new_value);
}
Value::Array(arr) => {
if let Ok(i) = path.parse::<usize>() {
while arr.len() <= i {
arr.push(Value::Null);
}
arr[i] = new_value;
}
}
_ => {}
}
}
fn ensure_intermediate(selected: &mut Value, path: &str, source: &Value) {
if index_value(selected, path).is_some() {
return;
}
let init = if source.is_array() {
Value::Array(Vec::new())
} else {
let mut node = Map::new();
init_selection(&mut node, source);
Value::Object(node)
};
set_index(selected, path, init);
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture(name: &str) -> String {
std::fs::read_to_string(format!(
"{}/tests/fixtures/vc-di-bbs/{}",
env!("CARGO_MANIFEST_DIR"),
name
))
.unwrap()
}
fn json(name: &str) -> Value {
serde_json::from_str(&fixture(name)).unwrap()
}
#[test]
fn proof_hash_matches_w3c_vector() {
let got = proof_hash(&json("addProofConfig.json")).unwrap();
let expected = json("addHashData.json")["proofHash"]
.as_str()
.unwrap()
.to_string();
assert_eq!(hex_lower(&got), expected);
}
#[test]
fn hmac_canonicalize_matches_w3c_vector() {
let key_hex = json("BBSKeyMaterial.json")["hmacKeyString"]
.as_str()
.unwrap()
.to_string();
let key = hex_decode(&key_hex);
let got = hmac_canonicalize(&json("windDoc.json"), &key).unwrap();
let expected: String =
serde_json::from_str::<Vec<String>>(&fixture("addBaseDocHMACCanon.json"))
.unwrap()
.concat();
assert_eq!(
got, expected,
"HMAC canonicalization diverges from the W3C vc-di-bbs vector"
);
}
#[test]
fn canonicalize_and_group_matches_w3c_vector() {
let key = hex_decode(
json("BBSKeyMaterial.json")["hmacKeyString"]
.as_str()
.unwrap(),
);
let transform = json("addBaseTransform.json");
let pointers: Vec<String> = transform["mandatoryPointers"]
.as_array()
.unwrap()
.iter()
.map(|p| p.as_str().unwrap().to_string())
.collect();
let refs: Vec<&str> = pointers.iter().map(String::as_str).collect();
let grouped = canonicalize_and_group(&json("windDoc.json"), &refs, &key).unwrap();
let idxs = |v: &Value| -> Vec<usize> {
v["value"]
.as_array()
.unwrap()
.iter()
.map(|e| e[0].as_u64().unwrap() as usize)
.collect()
};
assert_eq!(
grouped.mandatory_indexes,
idxs(&transform["mandatory"]),
"mandatory indices"
);
assert_eq!(
grouped.non_mandatory_indexes,
idxs(&transform["nonMandatory"]),
"non-mandatory indices"
);
assert_eq!(
hex_lower(&grouped.mandatory_hash),
json("addHashData.json")["mandatoryHash"].as_str().unwrap(),
"mandatoryHash"
);
}
#[test]
fn create_base_proof_value_matches_w3c_vector() {
let km = json("BBSKeyMaterial.json");
let sk_bytes: [u8; 32] = hex_decode(km["privateKeyHex"].as_str().unwrap())
.try_into()
.unwrap();
let pk_bytes: [u8; 96] = hex_decode(km["publicKeyHex"].as_str().unwrap())
.try_into()
.unwrap();
let sk = bbs::SecretKey::from_bytes(&sk_bytes).unwrap();
let pk = bbs::PublicKey::from_bytes(&pk_bytes).unwrap();
let hmac_key = hex_decode(km["hmacKeyString"].as_str().unwrap());
let pointers: Vec<String> = json("addBaseTransform.json")["mandatoryPointers"]
.as_array()
.unwrap()
.iter()
.map(|p| p.as_str().unwrap().to_string())
.collect();
let refs: Vec<&str> = pointers.iter().map(String::as_str).collect();
let proof_value = create_base_proof_value(
&json("windDoc.json"),
&json("addProofConfig.json"),
&refs,
&sk,
&pk,
&hmac_key,
)
.unwrap();
let expected = json("addSignedSDBase.json")["proof"]["proofValue"]
.as_str()
.unwrap()
.to_string();
assert_eq!(
proof_value, expected,
"base proofValue diverges from the W3C vc-di-bbs vector"
);
}
#[test]
fn verify_derived_proof_accepts_w3c_reference_proof() {
let pk_bytes: [u8; 96] = hex_decode(
json("BBSKeyMaterial.json")["publicKeyHex"]
.as_str()
.unwrap(),
)
.try_into()
.unwrap();
let pk = bbs::PublicKey::from_bytes(&pk_bytes).unwrap();
let reveal = json("derivedRevealDocument.json");
let ok = verify_derived_proof(&reveal, &pk).unwrap();
assert!(ok, "must verify the reference-generated derived proof");
}
#[test]
fn verify_derived_proof_rejects_tampered_claim() {
let pk_bytes: [u8; 96] = hex_decode(
json("BBSKeyMaterial.json")["publicKeyHex"]
.as_str()
.unwrap(),
)
.try_into()
.unwrap();
let pk = bbs::PublicKey::from_bytes(&pk_bytes).unwrap();
let mut reveal = json("derivedRevealDocument.json");
reveal["credentialSubject"]["sailNumber"] = Value::String("Tampered".into());
let r = verify_derived_proof(&reveal, &pk);
assert!(
!matches!(r, Ok(true)),
"tampered claim must not verify: {r:?}"
);
}
#[test]
fn create_derived_proof_matches_w3c_structure_and_round_trips() {
let pk_bytes: [u8; 96] = hex_decode(
json("BBSKeyMaterial.json")["publicKeyHex"]
.as_str()
.unwrap(),
)
.try_into()
.unwrap();
let pk = bbs::PublicKey::from_bytes(&pk_bytes).unwrap();
let ph = hex_decode(
json("BBSDeriveMaterial.json")["presentationHeaderHex"]
.as_str()
.unwrap(),
);
let selective: Vec<String> = serde_json::from_value(json("windSelective.json")).unwrap();
let refs: Vec<&str> = selective.iter().map(String::as_str).collect();
let reveal = create_derived_proof(&json("addSignedSDBase.json"), &refs, &ph, &pk).unwrap();
assert!(
verify_derived_proof(&reveal, &pk).unwrap(),
"round-trip derive→verify must hold"
);
let parsed =
parse_derived_proof_value(reveal["proof"]["proofValue"].as_str().unwrap()).unwrap();
let dd = json("derivedDisclosureData.json");
let exp_label: BTreeMap<String, String> = dd["labelMap"]["value"]
.as_array()
.unwrap()
.iter()
.map(|e| {
(
e[0].as_str().unwrap().to_string(),
e[1].as_str().unwrap().to_string(),
)
})
.collect();
assert_eq!(parsed.label_map, exp_label, "derived label map");
let usizes = |v: &Value| -> Vec<usize> {
v.as_array()
.unwrap()
.iter()
.map(|x| x.as_u64().unwrap() as usize)
.collect()
};
assert_eq!(
parsed.mandatory_indexes,
usizes(&dd["mandatoryIndexes"]),
"adjusted mandatory indexes"
);
assert_eq!(
parsed.selective_indexes,
usizes(&dd["adjSelectiveIndexes"]),
"adjusted selective indexes"
);
assert_eq!(parsed.presentation_header, ph, "presentation header");
}
#[test]
fn end_to_end_sign_derive_verify_round_trip() {
let km = json("BBSKeyMaterial.json");
let sk = bbs::SecretKey::from_bytes(
&hex_decode(km["privateKeyHex"].as_str().unwrap())
.try_into()
.unwrap(),
)
.unwrap();
let pk = bbs::PublicKey::from_bytes(
&hex_decode(km["publicKeyHex"].as_str().unwrap())
.try_into()
.unwrap(),
)
.unwrap();
let hmac_key = [0x42u8; 32];
let doc = json("windDoc.json");
let mandatory = ["/issuer", "/credentialSubject/sailNumber"];
let base = sign_base_document(
&doc,
&mandatory,
"did:key:zHolder#bbs",
"2024-01-01T00:00:00Z",
&sk,
&pk,
&hmac_key,
)
.unwrap();
assert_eq!(base["proof"]["cryptosuite"], "bbs-2023");
let reveal =
create_derived_proof(&base, &["/credentialSubject/boards/0"], b"nonce-xyz", &pk)
.unwrap();
assert!(verify_derived_proof(&reveal, &pk).unwrap());
let cs = &reveal["credentialSubject"];
assert_eq!(cs["sailNumber"], "Earth101"); assert!(
cs["boards"].as_array().unwrap()[0]
.get("boardName")
.is_some()
); assert!(
cs.get("sails").is_none(),
"undisclosed sails must be absent"
);
let mut bad = reveal.clone();
bad["credentialSubject"]["sailNumber"] = Value::String("Mallory".into());
assert!(!matches!(verify_derived_proof(&bad, &pk), Ok(true)));
}
fn hex_lower(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn hex_decode(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
}