#![cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
use oxigdal_security::attestation::{
AttestationError, SealMetadata, SessionLog, SessionSigner, verify_attestation,
};
use thiserror::Error;
const APP_NAME: &str = "geovault";
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Error)]
pub enum VaultError {
#[error("session sealed: the ledger is immutable after seal()")]
Sealed,
#[error("random number generation failed: {0}")]
Rng(String),
#[error(transparent)]
Attestation(#[from] AttestationError),
#[error("serialization failed: {0}")]
Serialization(String),
}
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for &b in bytes {
out.push(HEX_CHARS[(b >> 4) as usize] as char);
out.push(HEX_CHARS[(b & 0x0f) as usize] as char);
}
out
}
fn traffic_total_to_u64(total: f64) -> u64 {
total as u64
}
pub struct VaultSessionCore {
log: SessionLog,
signer: SessionSigner,
session_id: [u8; 16],
policy_json: String,
started_at_ms: u64,
bytes_ingressed: u64,
bytes_egressed: u64,
sealed: bool,
}
impl VaultSessionCore {
pub fn new(
session_id: [u8; 16],
signer: SessionSigner,
policy_json: &str,
started_at_ms: u64,
) -> Self {
Self {
log: SessionLog::new(session_id),
signer,
session_id,
policy_json: policy_json.to_string(),
started_at_ms,
bytes_ingressed: 0,
bytes_egressed: 0,
sealed: false,
}
}
pub fn session_id_hex(&self) -> String {
to_hex(&self.session_id)
}
pub fn public_key_hex(&self) -> String {
to_hex(&self.signer.public_key_bytes())
}
pub fn head_hash_hex(&self) -> String {
to_hex(&self.log.head_hash())
}
pub fn operation_count(&self) -> u32 {
u32::try_from(self.log.entries().len()).unwrap_or(u32::MAX)
}
pub fn log_operation(
&mut self,
ts_ms: u64,
op: &str,
params_json: &str,
) -> Result<u64, VaultError> {
if self.sealed {
return Err(VaultError::Sealed);
}
Ok(self.log.append(ts_ms, op, params_json))
}
pub fn note_traffic(&mut self, ingress_total: u64, egress_total: u64) {
if self.sealed {
return;
}
self.bytes_ingressed = ingress_total;
self.bytes_egressed = egress_total;
}
pub fn seal(&mut self, ended_at_ms: u64) -> Result<String, VaultError> {
if self.sealed {
return Err(VaultError::Sealed);
}
let meta = SealMetadata {
started_at_ms: self.started_at_ms,
ended_at_ms,
bytes_egressed: self.bytes_egressed,
bytes_ingressed: self.bytes_ingressed,
policy_json: self.policy_json.clone(),
app_name: APP_NAME.to_string(),
app_version: APP_VERSION.to_string(),
};
let attestation = self.signer.seal(&self.log, &meta)?;
let json = serde_json::to_string_pretty(&attestation)
.map_err(|err| VaultError::Serialization(err.to_string()))?;
self.sealed = true;
Ok(json)
}
#[cfg(test)]
fn is_sealed(&self) -> bool {
self.sealed
}
#[cfg(test)]
fn traffic(&self) -> (u64, u64) {
(self.bytes_ingressed, self.bytes_egressed)
}
}
pub fn verify_report_json(attestation_json: &str) -> Result<String, VaultError> {
let report = verify_attestation(attestation_json)?;
serde_json::to_string(&report).map_err(|err| VaultError::Serialization(err.to_string()))
}
pub fn file_digest_hex_string(bytes: &[u8]) -> String {
to_hex(blake3::hash(bytes).as_bytes())
}
#[cfg(target_arch = "wasm32")]
mod js {
use wasm_bindgen::prelude::*;
use super::{VaultError, VaultSessionCore, traffic_total_to_u64};
use oxigdal_security::attestation::SessionSigner;
fn now_ms() -> u64 {
js_sys::Date::now() as u64
}
fn js_err(err: VaultError) -> JsValue {
JsValue::from_str(&err.to_string())
}
#[wasm_bindgen]
pub struct WasmVaultSession {
core: VaultSessionCore,
}
#[wasm_bindgen]
impl WasmVaultSession {
#[wasm_bindgen(constructor)]
pub fn new(policy_json: &str) -> Result<WasmVaultSession, JsValue> {
let mut session_id = [0u8; 16];
getrandom::fill(&mut session_id)
.map_err(|err| js_err(VaultError::Rng(err.to_string())))?;
let signer = SessionSigner::generate().map_err(|err| js_err(VaultError::from(err)))?;
Ok(WasmVaultSession {
core: VaultSessionCore::new(session_id, signer, policy_json, now_ms()),
})
}
#[wasm_bindgen(js_name = sessionIdHex)]
pub fn session_id_hex(&self) -> String {
self.core.session_id_hex()
}
#[wasm_bindgen(js_name = publicKeyHex)]
pub fn public_key_hex(&self) -> String {
self.core.public_key_hex()
}
#[wasm_bindgen(js_name = logOperation)]
pub fn log_operation(&mut self, op: &str, params_json: &str) -> Result<u64, JsValue> {
self.core
.log_operation(now_ms(), op, params_json)
.map_err(js_err)
}
#[wasm_bindgen(js_name = noteTraffic)]
pub fn note_traffic(&mut self, ingress_total: f64, egress_total: f64) {
self.core.note_traffic(
traffic_total_to_u64(ingress_total),
traffic_total_to_u64(egress_total),
);
}
#[wasm_bindgen(js_name = operationCount)]
pub fn operation_count(&self) -> u32 {
self.core.operation_count()
}
#[wasm_bindgen(js_name = headHashHex)]
pub fn head_hash_hex(&self) -> String {
self.core.head_hash_hex()
}
pub fn seal(&mut self) -> Result<String, JsValue> {
self.core.seal(now_ms()).map_err(js_err)
}
}
#[wasm_bindgen(js_name = verifyAttestation)]
pub fn verify_attestation_js(json: &str) -> Result<String, JsValue> {
super::verify_report_json(json).map_err(js_err)
}
#[wasm_bindgen(js_name = fileDigestHex)]
pub fn file_digest_hex(bytes: &[u8]) -> String {
super::file_digest_hex_string(bytes)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
const FIXTURE_SESSION_ID: [u8; 16] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15];
const FIXTURE_SEED: [u8; 32] = [42u8; 32];
const FIXTURE_POLICY: &str =
r#"{"csp":"default-src 'self'","enforcement":["csp-meta","fetch-hook"]}"#;
const FIXTURE_STARTED_MS: u64 = 1_700_000_000_000;
const FIXTURE_ENDED_MS: u64 = 1_700_000_002_000;
const GOLDEN_MERKLE_ROOT: &str =
"0c01f0a50229873c9dd4c7b3f0602cad195ef47879a70379dc0044e56080ecb4";
const GOLDEN_HEAD_HASH: &str =
"fafefbd83e49dcd1454494696a23d85f65ec6385ee2c634565a4620d47278e17";
const GOLDEN_PUBLIC_KEY: &str =
"197f6b23e16c8532c6abc838facd5ea789be0c76b2920334039bfa8b3d368d61";
const GOLDEN_SIGNATURE: &str = "e71fe98169e0c0beb5038f5756ae102be1fcbd871dedf2acce6b32844a2cde691527f6fcf8675ff7db71aabd944145c028415a2f59cf2f1779be51e9a443ef04";
fn fixture_core() -> VaultSessionCore {
let signer = SessionSigner::from_seed(FIXTURE_SEED);
let mut core = VaultSessionCore::new(
FIXTURE_SESSION_ID,
signer,
FIXTURE_POLICY,
FIXTURE_STARTED_MS,
);
core.log_operation(
1_700_000_000_000,
"session.start",
r#"{"policy":"default-src 'self'"}"#,
)
.unwrap();
core.log_operation(
1_700_000_000_500,
"file.open",
r#"{"name":"site-k7.tif","size":1048576}"#,
)
.unwrap();
core.log_operation(
1_700_000_001_000,
"terrain.hillshade",
r#"{"azimuth":315,"altitude":45}"#,
)
.unwrap();
core.log_operation(
1_700_000_001_500,
"anomaly.detect",
r#"{"method":"modified_zscore","threshold":3.5,"count":642}"#,
)
.unwrap();
core.note_traffic(1_052_672, 0);
core
}
fn sealed_fixture_json() -> String {
fixture_core().seal(FIXTURE_ENDED_MS).unwrap()
}
fn parse(json: &str) -> serde_json::Value {
serde_json::from_str(json).unwrap()
}
#[test]
fn regenerated_fixture_matches_golden_values() {
let attestation = parse(&sealed_fixture_json());
assert_eq!(attestation["format"], "oxigdal-attestation");
assert_eq!(attestation["version"], 1);
assert_eq!(
attestation["session_id"],
"000102030405060708090a0b0c0d0e0f"
);
assert_eq!(attestation["merkle_root"], GOLDEN_MERKLE_ROOT);
assert_eq!(attestation["head_hash"], GOLDEN_HEAD_HASH);
assert_eq!(attestation["public_key"], GOLDEN_PUBLIC_KEY);
assert_eq!(attestation["signature"], GOLDEN_SIGNATURE);
assert_eq!(attestation["bytes_ingressed"], 1_052_672);
assert_eq!(attestation["bytes_egressed"], 0);
assert_eq!(attestation["operations"].as_array().unwrap().len(), 4);
}
#[test]
fn fixture_verifies_through_wrapped_code_path() {
let report = parse(&verify_report_json(&sealed_fixture_json()).unwrap());
assert_eq!(report["chain_ok"], true);
assert_eq!(report["merkle_ok"], true);
assert_eq!(report["signature_ok"], true);
assert_eq!(report["entry_count"], 4);
assert_eq!(report["session_id"], "000102030405060708090a0b0c0d0e0f");
assert_eq!(report["bytes_egressed"], 0);
assert_eq!(report["public_key"], GOLDEN_PUBLIC_KEY);
}
#[test]
fn log_after_seal_is_rejected() {
let mut core = fixture_core();
core.seal(FIXTURE_ENDED_MS).unwrap();
assert!(core.is_sealed());
let err = core
.log_operation(FIXTURE_ENDED_MS + 1, "late.op", "{}")
.unwrap_err();
assert!(matches!(err, VaultError::Sealed));
assert_eq!(core.operation_count(), 4);
assert_eq!(core.head_hash_hex(), GOLDEN_HEAD_HASH);
}
#[test]
fn double_seal_is_rejected() {
let mut core = fixture_core();
core.seal(FIXTURE_ENDED_MS).unwrap();
assert!(matches!(
core.seal(FIXTURE_ENDED_MS + 5).unwrap_err(),
VaultError::Sealed
));
}
#[test]
fn note_traffic_after_seal_is_ignored() {
let mut core = fixture_core();
core.seal(FIXTURE_ENDED_MS).unwrap();
core.note_traffic(u64::MAX, u64::MAX);
assert_eq!(core.traffic(), (1_052_672, 0));
}
#[test]
fn tampered_params_trips_only_chain_flag() {
let mut attestation = parse(&sealed_fixture_json());
attestation["operations"][1]["params"] =
serde_json::Value::String(r#"{"name":"evil.tif","size":1048576}"#.to_string());
let tampered = serde_json::to_string(&attestation).unwrap();
let report = parse(&verify_report_json(&tampered).unwrap());
assert_eq!(report["chain_ok"], false);
assert_eq!(report["merkle_ok"], true);
assert_eq!(report["signature_ok"], true);
}
#[test]
fn flipped_signature_trips_only_signature_flag() {
let mut attestation = parse(&sealed_fixture_json());
let mut signature = GOLDEN_SIGNATURE.to_string();
let first = if signature.starts_with('0') { "1" } else { "0" };
signature.replace_range(0..1, first);
attestation["signature"] = serde_json::Value::String(signature);
let tampered = serde_json::to_string(&attestation).unwrap();
let report = parse(&verify_report_json(&tampered).unwrap());
assert_eq!(report["chain_ok"], true);
assert_eq!(report["merkle_ok"], true);
assert_eq!(report["signature_ok"], false);
}
#[test]
fn malformed_json_is_a_typed_error() {
let err = verify_report_json("{ not json").unwrap_err();
assert!(matches!(
err,
VaultError::Attestation(AttestationError::Malformed(_))
));
}
#[test]
fn empty_log_seal_verifies() {
let signer = SessionSigner::from_seed([7u8; 32]);
let mut core = VaultSessionCore::new([9u8; 16], signer, "{}", 1_000);
let json = core.seal(2_000).unwrap();
let report = parse(&verify_report_json(&json).unwrap());
assert_eq!(report["chain_ok"], true);
assert_eq!(report["merkle_ok"], true);
assert_eq!(report["signature_ok"], true);
assert_eq!(report["entry_count"], 0);
}
#[test]
fn head_hash_and_count_progress_per_operation() {
let signer = SessionSigner::from_seed([1u8; 32]);
let mut core = VaultSessionCore::new([2u8; 16], signer, "{}", 0);
let genesis = core.head_hash_hex();
assert_eq!(core.operation_count(), 0);
assert_eq!(core.log_operation(1, "a", "{}").unwrap(), 0);
let head_one = core.head_hash_hex();
assert_ne!(head_one, genesis);
assert_eq!(core.log_operation(2, "b", "{}").unwrap(), 1);
assert_ne!(core.head_hash_hex(), head_one);
assert_eq!(core.operation_count(), 2);
}
#[test]
fn identity_hex_forms_are_lowercase_and_sized() {
let core = fixture_core();
assert_eq!(core.session_id_hex(), "000102030405060708090a0b0c0d0e0f");
assert_eq!(core.public_key_hex(), GOLDEN_PUBLIC_KEY);
let head = core.head_hash_hex();
assert_eq!(head.len(), 64);
assert!(
head.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn file_digest_matches_known_blake3_vector() {
assert_eq!(
file_digest_hex_string(b""),
"af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
);
let digest = file_digest_hex_string(b"GeoVault");
assert_eq!(digest.len(), 64);
assert_ne!(digest, file_digest_hex_string(b""));
assert_eq!(digest, file_digest_hex_string(b"GeoVault"));
}
#[test]
fn traffic_total_conversion_is_saturating() {
assert_eq!(traffic_total_to_u64(f64::NAN), 0);
assert_eq!(traffic_total_to_u64(-1024.0), 0);
assert_eq!(traffic_total_to_u64(1.9), 1);
assert_eq!(traffic_total_to_u64(1_052_672.0), 1_052_672);
assert_eq!(traffic_total_to_u64(f64::INFINITY), u64::MAX);
}
#[test]
fn sealed_json_is_pretty_printed_for_download() {
let json = sealed_fixture_json();
assert!(json.contains('\n'));
assert!(json.starts_with('{'));
let value = parse(&json);
assert_eq!(value["app_name"], APP_NAME);
assert_eq!(value["app_version"], APP_VERSION);
}
}