mod chain;
mod merkle;
mod sign;
mod verify;
pub use chain::{LogEntry, SessionLog};
pub use merkle::{ProofStep, merkle_proof, merkle_root, verify_merkle_proof};
pub use sign::{SealMetadata, SessionSigner};
pub use verify::{VerificationReport, verify_attestation};
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DOM_GENESIS: &[u8] = b"oxigdal.attest.v1.genesis";
pub const DOM_ENTRY: &[u8] = b"oxigdal.attest.v1.entry";
pub const DOM_PARAMS: &[u8] = b"oxigdal.attest.v1.params";
pub const DOM_LEAF: &[u8] = b"oxigdal.attest.v1.leaf";
pub const DOM_NODE: &[u8] = b"oxigdal.attest.v1.node";
pub const DOM_SEAL: &[u8] = b"oxigdal.attest.v1.seal";
pub const DOM_EMPTY: &[u8] = b"oxigdal.attest.v1.empty";
pub const ATTESTATION_VERSION: u32 = 1;
pub const ATTESTATION_FORMAT: &str = "oxigdal-attestation";
#[derive(Debug, Error)]
pub enum AttestationError {
#[error("random number generation failed: {0}")]
Rng(String),
#[error("hash chain broken at sequence {seq}")]
ChainBroken {
seq: u64,
},
#[error("merkle root mismatch")]
RootMismatch,
#[error("signature verification failed")]
SignatureInvalid,
#[error("malformed attestation: {0}")]
Malformed(String),
#[error("index {0} out of range")]
IndexOutOfRange(usize),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttestedOperation {
pub seq: u64,
pub ts_ms: u64,
pub op: String,
pub params: String,
pub prev_hash: String,
pub entry_hash: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
pub format: String,
pub version: u32,
pub session_id: String,
pub app_name: String,
pub app_version: String,
pub started_at_ms: u64,
pub ended_at_ms: u64,
pub policy: String,
pub bytes_egressed: u64,
pub bytes_ingressed: u64,
pub operations: Vec<AttestedOperation>,
pub merkle_root: String,
pub head_hash: String,
pub public_key: String,
pub signature: String,
}
pub(crate) fn hash_parts(parts: &[&[u8]]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
for part in parts {
hasher.update(part);
}
*hasher.finalize().as_bytes()
}
pub(crate) fn genesis_hash(session_id: &[u8; 16]) -> [u8; 32] {
hash_parts(&[DOM_GENESIS, session_id])
}
pub(crate) fn params_digest(params_json: &str) -> [u8; 32] {
hash_parts(&[DOM_PARAMS, params_json.as_bytes()])
}
pub(crate) fn compute_entry_hash(
seq: u64,
ts_ms: u64,
op: &str,
params_digest: &[u8; 32],
prev_hash: &[u8; 32],
) -> [u8; 32] {
let op_bytes = op.as_bytes();
let mut buf = Vec::with_capacity(8 + 8 + 4 + op_bytes.len() + 32 + 32);
buf.extend_from_slice(&seq.to_le_bytes());
buf.extend_from_slice(&ts_ms.to_le_bytes());
buf.extend_from_slice(&(op_bytes.len() as u32).to_le_bytes());
buf.extend_from_slice(op_bytes);
buf.extend_from_slice(params_digest);
buf.extend_from_slice(prev_hash);
hash_parts(&[DOM_ENTRY, &buf])
}
pub(crate) fn leaf_hash(entry_hash: &[u8; 32]) -> [u8; 32] {
hash_parts(&[DOM_LEAF, entry_hash])
}
pub(crate) fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
hash_parts(&[DOM_NODE, left, right])
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn seal_bytes(
version: u32,
session_id: &[u8; 16],
started_ms: u64,
ended_ms: u64,
entry_count: u64,
merkle_root: &[u8; 32],
head_hash: &[u8; 32],
bytes_egressed: u64,
bytes_ingressed: u64,
policy_digest: &[u8; 32],
) -> Vec<u8> {
let mut buf = Vec::with_capacity(DOM_SEAL.len() + 4 + 16 + 8 * 5 + 32 * 3);
buf.extend_from_slice(DOM_SEAL);
buf.extend_from_slice(&version.to_le_bytes());
buf.extend_from_slice(session_id);
buf.extend_from_slice(&started_ms.to_le_bytes());
buf.extend_from_slice(&ended_ms.to_le_bytes());
buf.extend_from_slice(&entry_count.to_le_bytes());
buf.extend_from_slice(merkle_root);
buf.extend_from_slice(head_hash);
buf.extend_from_slice(&bytes_egressed.to_le_bytes());
buf.extend_from_slice(&bytes_ingressed.to_le_bytes());
buf.extend_from_slice(policy_digest);
buf
}
const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
pub(crate) 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 hex_nibble(c: u8) -> Result<u8, AttestationError> {
match c {
b'0'..=b'9' => Ok(c - b'0'),
b'a'..=b'f' => Ok(c - b'a' + 10),
b'A'..=b'F' => Ok(c - b'A' + 10),
other => Err(AttestationError::Malformed(format!(
"invalid hex character: {:?}",
other as char
))),
}
}
pub(crate) fn from_hex<const N: usize>(s: &str) -> Result<[u8; N], AttestationError> {
if s.len() != N * 2 {
return Err(AttestationError::Malformed(format!(
"expected {} hex characters, got {}",
N * 2,
s.len()
)));
}
let mut out = [0u8; N];
for (slot, chunk) in out.iter_mut().zip(s.as_bytes().chunks_exact(2)) {
let hi = hex_nibble(chunk[0])?;
let lo = hex_nibble(chunk[1])?;
*slot = (hi << 4) | lo;
}
Ok(out)
}