use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use base64::Engine;
use dig_urn_protocol::{Bytes32 as UrnBytes32, DigUrn, CANONICAL_CHAIN, DEFAULT_RESOURCE_KEY};
use wasm_bindgen::prelude::*;
use crate::imp::core::codec::Decode;
use crate::imp::core::crypto::{decrypt_chunk, derive_decryption_key, encrypt_chunk};
use crate::imp::core::{resource_leaf, Bytes32, MerkleProof, SecretSalt};
fn canonical_resource_urn(store_id: Bytes32, resource_key: &str) -> DigUrn {
DigUrn {
chain: CANONICAL_CHAIN.to_string(),
store_id: UrnBytes32(store_id.0),
root_hash: None,
resource_key: Some(resource_key.to_string()),
}
}
fn parse_store_id(store_id_hex: &str) -> Result<Bytes32, JsError> {
Bytes32::from_hex(store_id_hex.trim())
.map_err(|_| JsError::new("store_id must be 64 lowercase hex characters"))
}
fn parse_salt(salt_hex: Option<String>) -> Result<Option<[u8; 32]>, JsError> {
match salt_hex {
None => Ok(None),
Some(s) if s.trim().is_empty() => Ok(None),
Some(s) => {
let b = Bytes32::from_hex(s.trim())
.map_err(|_| JsError::new("secret salt must be 64 lowercase hex characters"))?;
Ok(Some(b.0))
}
}
}
fn decode_proof_b64(proof_b64: &str) -> Result<MerkleProof, JsError> {
let raw = base64::engine::general_purpose::STANDARD
.decode(proof_b64.trim().as_bytes())
.map_err(|_| JsError::new("inclusion proof is not valid base64"))?;
MerkleProof::from_bytes(&raw)
.map_err(|_| JsError::new("inclusion proof is not a valid merkle proof encoding"))
}
fn verify_inclusion_core(
ciphertext: &[u8],
proof: &MerkleProof,
trusted_root: &Bytes32,
) -> Result<(), &'static str> {
let computed_leaf = resource_leaf(ciphertext);
if computed_leaf != proof.leaf {
return Err("content does not match proof leaf (tampered ciphertext)");
}
if !proof.verify() {
return Err("merkle path does not resolve to the declared root");
}
if &proof.root != trusted_root {
return Err("merkle root does not match the chain-anchored trusted root");
}
Ok(())
}
#[wasm_bindgen(js_name = reconstructUrn)]
pub fn reconstruct_urn(store_id_hex: &str, resource_key: &str) -> Result<String, JsError> {
let store_id = parse_store_id(store_id_hex)?;
let key = if resource_key.is_empty() {
DEFAULT_RESOURCE_KEY
} else {
resource_key
};
Ok(canonical_resource_urn(store_id, key).canonical())
}
#[wasm_bindgen(js_name = reconstructUrnWithRoot)]
pub fn reconstruct_urn_with_root(
store_id_hex: &str,
root_hex: &str,
resource_key: &str,
) -> Result<String, JsError> {
let store_id = parse_store_id(store_id_hex)?;
let root = Bytes32::from_hex(root_hex.trim())
.map_err(|_| JsError::new("root must be 64 lowercase hex characters"))?;
let key = if resource_key.is_empty() {
DEFAULT_RESOURCE_KEY
} else {
resource_key
};
let urn = DigUrn {
chain: CANONICAL_CHAIN.to_string(),
store_id: UrnBytes32(store_id.0),
root_hash: Some(UrnBytes32(root.0)),
resource_key: Some(key.to_string()),
};
Ok(urn.canonical())
}
#[wasm_bindgen(js_name = retrievalKey)]
pub fn retrieval_key(store_id_hex: &str, resource_key: &str) -> Result<String, JsError> {
let store_id = parse_store_id(store_id_hex)?;
let key = if resource_key.is_empty() {
DEFAULT_RESOURCE_KEY
} else {
resource_key
};
Ok(canonical_resource_urn(store_id, key).content_key().to_hex())
}
#[wasm_bindgen(js_name = deriveKey)]
pub fn derive_key(
store_id_hex: &str,
resource_key: &str,
salt_hex: Option<String>,
) -> Result<String, JsError> {
let store_id = parse_store_id(store_id_hex)?;
let salt = parse_salt(salt_hex)?;
let key = if resource_key.is_empty() {
DEFAULT_RESOURCE_KEY
} else {
resource_key
};
let canonical = canonical_resource_urn(store_id, key).canonical();
let derived = derive_decryption_key(&canonical, salt.map(SecretSalt).as_ref());
Ok(hex::encode(derived))
}
#[wasm_bindgen(js_name = decryptChunk)]
pub fn decrypt_chunk_js(key_hex: &str, ciphertext: &[u8]) -> Result<Vec<u8>, JsError> {
let key = Bytes32::from_hex(key_hex.trim())
.map_err(|_| JsError::new("key must be 64 lowercase hex characters"))?;
decrypt_chunk(&key.0, ciphertext).map_err(|_| {
JsError::new("AES-256-GCM-SIV tag verification failed (wrong key or tampered ciphertext)")
})
}
#[wasm_bindgen(js_name = encryptResource)]
pub fn encrypt_resource(
store_id_hex: &str,
resource_key: &str,
plaintext: &[u8],
salt_hex: Option<String>,
) -> Result<Vec<u8>, JsError> {
let store_id = parse_store_id(store_id_hex)?;
let salt = parse_salt(salt_hex)?;
let key = if resource_key.is_empty() {
DEFAULT_RESOURCE_KEY
} else {
resource_key
};
let canonical = canonical_resource_urn(store_id, key).canonical();
let aes_key = derive_decryption_key(&canonical, salt.map(SecretSalt).as_ref());
Ok(encrypt_chunk(&aes_key, plaintext))
}
#[wasm_bindgen(js_name = decryptResource)]
#[allow(clippy::too_many_arguments)]
pub fn decrypt_resource(
store_id_hex: &str,
resource_key: &str,
ciphertext: &[u8],
proof_b64: &str,
trusted_root_hex: &str,
salt_hex: Option<String>,
chunk_lens: Option<Vec<u32>>,
) -> Result<Vec<u8>, JsError> {
let store_id = parse_store_id(store_id_hex)?;
let salt = parse_salt(salt_hex)?;
let trusted_root = Bytes32::from_hex(trusted_root_hex.trim())
.map_err(|_| JsError::new("trusted root must be 64 lowercase hex characters"))?;
let proof = decode_proof_b64(proof_b64)?;
verify_inclusion_core(ciphertext, &proof, &trusted_root).map_err(JsError::new)?;
let key = if resource_key.is_empty() {
DEFAULT_RESOURCE_KEY
} else {
resource_key
};
let canonical = canonical_resource_urn(store_id, key).canonical();
let aes_key = derive_decryption_key(&canonical, salt.map(SecretSalt).as_ref());
let plan: Vec<usize> = match chunk_lens {
Some(lens) if !lens.is_empty() => lens.into_iter().map(|l| l as usize).collect(),
_ => alloc::vec![ciphertext.len()],
};
let total: usize = plan.iter().sum();
if total != ciphertext.len() {
return Err(JsError::new(&format!(
"served ciphertext length {} does not match expected chunk total {}",
ciphertext.len(),
total
)));
}
let mut plaintext = Vec::with_capacity(ciphertext.len());
let mut p = 0usize;
for len in plan {
let ct = &ciphertext[p..p + len];
p += len;
let pt = decrypt_chunk(&aes_key, ct).map_err(|_| {
JsError::new(
"AES-256-GCM-SIV tag verification failed (wrong key/salt or tampered ciphertext)",
)
})?;
plaintext.extend_from_slice(&pt);
}
Ok(plaintext)
}
#[wasm_bindgen(js_name = decryptResourceToText)]
pub fn decrypt_resource_to_text(
store_id_hex: &str,
resource_key: &str,
ciphertext: &[u8],
proof_b64: &str,
trusted_root_hex: &str,
salt_hex: Option<String>,
chunk_lens: Option<Vec<u32>>,
) -> Result<String, JsError> {
let bytes = decrypt_resource(
store_id_hex,
resource_key,
ciphertext,
proof_b64,
trusted_root_hex,
salt_hex,
chunk_lens,
)?;
String::from_utf8(bytes).map_err(|_| JsError::new("decrypted resource is not valid UTF-8 text"))
}
#[wasm_bindgen(js_name = verifyInclusion)]
pub fn verify_inclusion(
ciphertext: &[u8],
proof_b64: &str,
trusted_root_hex: &str,
) -> Result<bool, JsError> {
let trusted_root = Bytes32::from_hex(trusted_root_hex.trim())
.map_err(|_| JsError::new("trusted root must be 64 lowercase hex characters"))?;
let proof = decode_proof_b64(proof_b64)?;
Ok(verify_inclusion_core(ciphertext, &proof, &trusted_root).is_ok())
}
#[wasm_bindgen(js_name = readPublicManifest)]
pub fn read_public_manifest(blob: &[u8]) -> Result<Option<String>, JsError> {
match crate::imp::core::datasection::read_public_manifest(blob) {
Ok(Some(pm)) => Ok(Some(pm.to_json())),
Ok(None) => Ok(None),
Err(_) => Err(JsError::new("data-section blob is malformed")),
}
}
#[wasm_bindgen(js_name = version)]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[wasm_bindgen(start)]
pub fn install_global() {
let global = js_sys::global();
let obj = js_sys::Object::new();
macro_rules! set {
($name:literal, $f:expr) => {{
let closure: JsValue = $f.into_js_value();
let _ = js_sys::Reflect::set(&obj, &JsValue::from_str($name), &closure);
}};
}
set!(
"reconstructUrn",
Closure::<dyn Fn(String, String) -> Result<String, JsError>>::new(
|s: String, r: String| reconstruct_urn(&s, &r)
)
);
set!(
"retrievalKey",
Closure::<dyn Fn(String, String) -> Result<String, JsError>>::new(
|s: String, r: String| retrieval_key(&s, &r)
)
);
set!(
"deriveKey",
Closure::<dyn Fn(String, String, Option<String>) -> Result<String, JsError>>::new(
|s: String, r: String, salt: Option<String>| derive_key(&s, &r, salt)
)
);
set!(
"verifyInclusion",
Closure::<dyn Fn(Vec<u8>, String, String) -> Result<bool, JsError>>::new(
|ct: Vec<u8>, p: String, root: String| verify_inclusion(&ct, &p, &root)
)
);
set!(
"decryptResource",
Closure::<
dyn Fn(
String,
String,
Vec<u8>,
String,
String,
Option<String>,
Option<Vec<u32>>,
) -> Result<Vec<u8>, JsError>,
>::new(
|s: String,
r: String,
ct: Vec<u8>,
p: String,
root: String,
salt: Option<String>,
lens: Option<Vec<u32>>| decrypt_resource(
&s, &r, &ct, &p, &root, salt, lens
)
)
);
set!(
"decryptResourceToText",
Closure::<
dyn Fn(
String,
String,
Vec<u8>,
String,
String,
Option<String>,
Option<Vec<u32>>,
) -> Result<String, JsError>,
>::new(
|s: String,
r: String,
ct: Vec<u8>,
p: String,
root: String,
salt: Option<String>,
lens: Option<Vec<u32>>| decrypt_resource_to_text(
&s, &r, &ct, &p, &root, salt, lens
)
)
);
set!(
"readPublicManifest",
Closure::<dyn Fn(Vec<u8>) -> Result<Option<String>, JsError>>::new(|blob: Vec<u8>| {
read_public_manifest(&blob)
})
);
set!("version", Closure::<dyn Fn() -> String>::new(version));
let _ = js_sys::Reflect::set(&global, &JsValue::from_str("digClient"), &obj);
}