use super::html;
use ed25519_dalek::{Signer, SigningKey};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
pub const FORMAT: &str = "kshana-evidence/1";
pub const CHAIN_DOMAIN: &[u8] = b"kshana-evidence-chain/1";
pub const DISCLAIMER: &str = "This pack is a technical record of what the Kshana engine \
computed from the stated receiver log with the stated configuration. It is not a legal \
opinion, not a finding of fact about any event or its cause, and not a certification.";
pub type Files = BTreeMap<String, Vec<u8>>;
pub const ARTIFACTS: [(&str, &str); 4] = [
("log-slice.bin", "raw receiver-log bytes for the window"),
("config.json", "configuration and every threshold"),
("epochs.json", "per-epoch results and reasons"),
("summary.html", "human-readable summary"),
];
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Window {
pub from_s: f64,
pub to_s: f64,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SliceKind {
ByteRange,
WholeLog,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SliceRecord {
pub kind: SliceKind,
pub start: u64,
pub end: u64,
pub sha256: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LogRecord {
pub format: String,
pub file_name: String,
pub full_sha256: String,
pub full_bytes: u64,
pub start_label: Option<String>,
pub slice: SliceRecord,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SignerRecord {
pub algorithm: String,
pub public_key: String,
pub fingerprint: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Artifact {
pub name: String,
pub role: String,
pub bytes: u64,
pub sha256: String,
pub link: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
pub format: String,
pub title: String,
pub engine_version: String,
pub created_utc: Option<String>,
pub disclaimer: String,
pub window: Window,
pub epochs_in_window: usize,
pub log: LogRecord,
pub signer: SignerRecord,
pub artifacts: Vec<Artifact>,
pub chain_head: String,
}
#[derive(Debug, PartialEq)]
pub enum EvidenceError {
BadSlice(String),
BadWindow(String),
BadCreated(String),
BadTimestamp(String),
Serialise(String),
}
impl std::fmt::Display for EvidenceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EvidenceError::BadSlice(s) => write!(f, "bad log slice: {s}"),
EvidenceError::BadWindow(s) => write!(f, "bad window: {s}"),
EvidenceError::BadCreated(s) => write!(f, "bad creation time: {s}"),
EvidenceError::BadTimestamp(s) => write!(f, "bad timestamp token: {s}"),
EvidenceError::Serialise(s) => write!(f, "cannot serialise: {s}"),
}
}
}
impl std::error::Error for EvidenceError {}
pub struct EvidenceInput<'a> {
pub title: &'a str,
pub engine_version: &'a str,
pub log_format: &'a str,
pub log_file_name: &'a str,
pub log_bytes: &'a [u8],
pub start_label: Option<&'a str>,
pub slice: Option<(usize, usize)>,
pub window: Window,
pub config: Value,
pub epochs: Vec<Value>,
pub created_utc: Option<&'a str>,
}
pub fn slice_for_window(
epochs: impl IntoIterator<Item = (f64, Option<[usize; 2]>)>,
from_s: f64,
to_s: f64,
) -> Option<(usize, usize)> {
let mut range: Option<(usize, usize)> = None;
for (t, span) in epochs {
if t < from_s || t > to_s {
continue;
}
let [a, b] = span?;
range = Some(match range {
None => (a, b),
Some((x, y)) => (x.min(a), y.max(b)),
});
}
range
}
pub fn sha256_hex(b: &[u8]) -> String {
hex::encode(Sha256::digest(b))
}
pub fn chain_link(prev: &[u8; 32], name: &str, file_sha256: &[u8; 32]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(prev);
h.update(file_sha256);
h.update((name.len() as u32).to_be_bytes());
h.update(name.as_bytes());
h.finalize().into()
}
pub fn chain_start() -> [u8; 32] {
Sha256::digest(CHAIN_DOMAIN).into()
}
pub fn fingerprint(public_key: &[u8]) -> String {
sha256_hex(public_key)[..32].to_string()
}
pub fn generate_seed() -> [u8; 32] {
use rand::RngCore;
let mut s = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut s);
s
}
pub fn sign_detached(seed: &[u8; 32], msg: &[u8]) -> [u8; 64] {
SigningKey::from_bytes(seed).sign(msg).to_bytes()
}
pub fn public_key_hex(seed: &[u8; 32]) -> String {
hex::encode(SigningKey::from_bytes(seed).verifying_key().to_bytes())
}
fn json_bytes(v: &impl Serialize) -> Result<Vec<u8>, EvidenceError> {
let mut b =
serde_json::to_vec_pretty(v).map_err(|e| EvidenceError::Serialise(e.to_string()))?;
b.push(b'\n');
Ok(b)
}
pub fn create_bundle(
input: &EvidenceInput<'_>,
seed: &[u8; 32],
timestamp_token: Option<&[u8]>,
) -> Result<Files, EvidenceError> {
if !(input.window.from_s.is_finite() && input.window.to_s.is_finite())
|| input.window.from_s > input.window.to_s
{
return Err(EvidenceError::BadWindow(format!(
"from {} to {}",
input.window.from_s, input.window.to_s
)));
}
if let Some(c) = input.created_utc {
if !crate::telemetry::time::is_rfc3339_utc_z(c) {
return Err(EvidenceError::BadCreated(format!(
"`{c}` is not a UTC time like 2026-01-02T03:04:05Z"
)));
}
}
let (kind, start, end) = match input.slice {
Some((s, e)) => {
if s > e || e > input.log_bytes.len() {
return Err(EvidenceError::BadSlice(format!(
"{s}..{e} in a log of {} bytes",
input.log_bytes.len()
)));
}
(SliceKind::ByteRange, s, e)
}
None => (SliceKind::WholeLog, 0, input.log_bytes.len()),
};
let slice_bytes = input.log_bytes[start..end].to_vec();
let file_name = input
.log_file_name
.rsplit(['/', '\\'])
.next()
.unwrap_or_default();
let config = json_bytes(&input.config)?;
let epochs = json_bytes(&input.epochs)?;
let key = SigningKey::from_bytes(seed);
let pk = key.verifying_key().to_bytes();
let signer = SignerRecord {
algorithm: "ed25519".into(),
public_key: hex::encode(pk),
fingerprint: fingerprint(&pk),
};
let log = LogRecord {
format: input.log_format.to_string(),
file_name: file_name.to_string(),
full_sha256: sha256_hex(input.log_bytes),
full_bytes: input.log_bytes.len() as u64,
start_label: input.start_label.map(str::to_string),
slice: SliceRecord {
kind,
start: start as u64,
end: end as u64,
sha256: sha256_hex(&slice_bytes),
},
};
let summary = html::render(&html::SummaryInput {
title: input.title,
engine_version: input.engine_version,
created_utc: input.created_utc,
window: input.window,
log: &log,
signer: &signer,
config: &input.config,
epochs: &input.epochs,
});
let summary = summary.into_bytes();
let contents: [&Vec<u8>; 4] = [&slice_bytes, &config, &epochs, &summary];
let mut artifacts = Vec::new();
let mut prev = chain_start();
for ((name, role), bytes) in ARTIFACTS.iter().zip(contents) {
let digest: [u8; 32] = Sha256::digest(bytes).into();
prev = chain_link(&prev, name, &digest);
artifacts.push(Artifact {
name: (*name).to_string(),
role: (*role).to_string(),
bytes: bytes.len() as u64,
sha256: hex::encode(digest),
link: hex::encode(prev),
});
}
let manifest = Manifest {
format: FORMAT.into(),
title: input.title.to_string(),
engine_version: input.engine_version.to_string(),
created_utc: input.created_utc.map(str::to_string),
disclaimer: DISCLAIMER.into(),
window: input.window,
epochs_in_window: input.epochs.len(),
log,
signer,
artifacts,
chain_head: hex::encode(prev),
};
let manifest_bytes = json_bytes(&manifest)?;
if let Some(t) = timestamp_token {
let info = super::tsr::parse_token(t).map_err(EvidenceError::BadTimestamp)?;
let want = super::tsr::imprint_of(&info.hash_alg, &manifest_bytes).unwrap_or_default();
if want != info.imprint {
return Err(EvidenceError::BadTimestamp(
"its imprint is not the hash of this pack's manifest.json (a token can only be \
obtained after the pack exists: use `kshana evidence attach-timestamp`)"
.into(),
));
}
}
let sig = sign_detached(seed, &manifest_bytes);
let mut files = Files::new();
for ((name, _), bytes) in ARTIFACTS.iter().zip(contents) {
files.insert((*name).to_string(), bytes.clone());
}
files.insert("manifest.json".into(), manifest_bytes);
files.insert(
"manifest.sig".into(),
format!("{}\n", hex::encode(sig)).into_bytes(),
);
if let Some(t) = timestamp_token {
files.insert("timestamp.tsr".into(), t.to_vec());
}
Ok(files)
}
#[cfg(test)]
mod tests {
use super::slice_for_window;
#[test]
fn window_slice_spans_the_epochs_inside_it() {
let e = [
(0.0, Some([0, 10])),
(1.0, Some([10, 25])),
(2.0, Some([25, 31])),
(3.0, Some([31, 40])),
];
assert_eq!(slice_for_window(e, 1.0, 2.0), Some((10, 31)));
assert_eq!(
slice_for_window(e, 0.5, 0.9),
None,
"no epoch in the window"
);
}
#[test]
fn a_missing_span_inside_the_window_forces_the_whole_log() {
let e = [(0.0, Some([0, 10])), (1.0, None), (2.0, Some([20, 30]))];
assert_eq!(slice_for_window(e, 0.0, 2.0), None);
assert_eq!(slice_for_window(e, 2.0, 2.0), Some((20, 30)));
}
}