#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const SPEC_VERSION: &str = "0.1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Organ {
Awake,
Identity,
Perception,
Memory,
Deliberation,
Action,
Vigilance,
Learning,
Audit,
Sovereignty,
}
impl Organ {
pub const ALL: [Organ; 10] = [
Organ::Awake,
Organ::Identity,
Organ::Perception,
Organ::Memory,
Organ::Deliberation,
Organ::Action,
Organ::Vigilance,
Organ::Learning,
Organ::Audit,
Organ::Sovereignty,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
Pass,
Fail,
Optional,
ControlOk,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckResult {
pub id: String,
pub organ: Organ,
pub status: Status,
#[serde(default)]
pub evidence: serde_json::Value,
#[serde(default)]
pub control: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verdict {
Conformant,
Partial,
Nonconformant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Subject {
pub name: String,
pub version: String,
pub host: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signature {
pub scheme: String,
pub pubkey: String,
pub sig: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attestation {
pub spec: String,
pub subject: Subject,
pub timestamp: String,
pub checks: Vec<CheckResult>,
pub verdict: Verdict,
#[serde(skip_serializing_if = "Option::is_none")]
pub signature: Option<Signature>,
}
#[must_use]
pub fn verdict_for(checks: &[CheckResult]) -> Verdict {
if checks.is_empty() {
return Verdict::Nonconformant;
}
for c in checks.iter().filter(|c| c.control) {
if c.status == Status::Pass {
return Verdict::Nonconformant;
}
}
let mut organs_passed = [false; 10];
let mut any_fail = false;
for c in checks {
match c.status {
Status::Pass => {
if let Some(i) = Organ::ALL.iter().position(|o| *o == c.organ) {
organs_passed[i] = true;
}
}
Status::Fail if !c.control => any_fail = true,
_ => {}
}
}
if organs_passed.iter().all(|p| *p) && !any_fail {
Verdict::Conformant
} else {
Verdict::Partial
}
}
#[must_use]
pub fn canonical_bytes(doc: &Attestation) -> Vec<u8> {
let mut v = serde_json::to_value(doc).expect("attestation serializes");
if let Some(obj) = v.as_object_mut() {
obj.remove("signature");
}
canonical_json(&v).into_bytes()
}
#[must_use]
pub fn document_hash(doc: &Attestation) -> [u8; 32] {
let mut h = Sha256::new();
h.update(canonical_bytes(doc));
h.finalize().into()
}
#[derive(Debug)]
pub enum ValidationError {
BadSpec,
NoChecks,
DuplicateCheckId(String),
VerdictMismatch {
claimed: Verdict,
actual: Verdict,
},
BadSignature,
BadTimestamp,
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::BadSpec => write!(f, "spec field missing or not touchstone/<version>"),
Self::NoChecks => write!(f, "attestation has no checks"),
Self::DuplicateCheckId(id) => write!(f, "duplicate check id: {id}"),
Self::VerdictMismatch { claimed, actual } => {
write!(f, "claimed verdict {claimed:?} != recomputed {actual:?}")
}
Self::BadSignature => write!(f, "signature malformed"),
Self::BadTimestamp => write!(f, "timestamp missing or not RFC3339"),
}
}
}
impl std::error::Error for ValidationError {}
impl Attestation {
pub fn validate(&self) -> Result<(), Vec<ValidationError>> {
let mut errs = Vec::new();
if !self.spec.starts_with("touchstone/") || self.spec.len() <= "touchstone/".len() {
errs.push(ValidationError::BadSpec);
}
if self.checks.is_empty() {
errs.push(ValidationError::NoChecks);
}
let mut seen = std::collections::HashSet::new();
for c in &self.checks {
if !seen.insert(&c.id) {
errs.push(ValidationError::DuplicateCheckId(c.id.clone()));
}
}
let actual = verdict_for(&self.checks);
if actual != self.verdict {
errs.push(ValidationError::VerdictMismatch {
claimed: self.verdict,
actual,
});
}
if let Some(sig) = &self.signature {
let hex_ok =
|s: &str, n: usize| s.len() == n && s.chars().all(|c| c.is_ascii_hexdigit());
if !hex_ok(&sig.pubkey, 64) || !hex_ok(&sig.sig, 128) {
errs.push(ValidationError::BadSignature);
}
}
if chrono::DateTime::parse_from_rfc3339(&self.timestamp).is_err() {
errs.push(ValidationError::BadTimestamp);
}
if errs.is_empty() {
Ok(())
} else {
Err(errs)
}
}
}
fn canonical_json(v: &serde_json::Value) -> String {
match v {
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let inner: Vec<String> = keys
.into_iter()
.map(|k| {
format!(
"{}:{}",
serde_json::to_string(k).unwrap(),
canonical_json(&map[k])
)
})
.collect();
format!("{{{}}}", inner.join(","))
}
serde_json::Value::Array(a) => {
let inner: Vec<String> = a.iter().map(canonical_json).collect();
format!("[{}]", inner.join(","))
}
other => serde_json::to_string(other).unwrap(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn check(id: &str, organ: Organ, status: Status) -> CheckResult {
CheckResult {
id: id.into(),
organ,
status,
evidence: serde_json::Value::Null,
control: false,
}
}
#[test]
fn conformant_when_all_organs_pass() {
let checks: Vec<CheckResult> = Organ::ALL
.iter()
.map(|o| check("x", *o, Status::Pass))
.collect();
assert_eq!(verdict_for(&checks), Verdict::Conformant);
}
#[test]
fn partial_when_an_organ_missing() {
let checks: Vec<CheckResult> = Organ::ALL[..9]
.iter()
.map(|o| check("x", *o, Status::Pass))
.collect();
assert_eq!(verdict_for(&checks), Verdict::Partial);
}
#[test]
fn nonconformant_when_control_passes() {
let mut checks: Vec<CheckResult> = Organ::ALL
.iter()
.map(|o| check("x", *o, Status::Pass))
.collect();
checks.push(CheckResult {
control: true,
..check("planted", Organ::Audit, Status::Pass)
});
assert_eq!(verdict_for(&checks), Verdict::Nonconformant);
}
#[test]
fn control_fail_is_fine() {
let mut checks: Vec<CheckResult> = Organ::ALL
.iter()
.map(|o| check("x", *o, Status::Pass))
.collect();
checks.push(CheckResult {
control: true,
..check("planted", Organ::Audit, Status::Fail)
});
assert_eq!(verdict_for(&checks), Verdict::Conformant);
}
#[test]
fn empty_is_nonconformant() {
assert_eq!(verdict_for(&[]), Verdict::Nonconformant);
}
#[test]
fn canonical_hash_stable() {
let doc = Attestation {
spec: "touchstone/0.1".into(),
subject: Subject {
name: "x".into(),
version: "0".into(),
host: "h".into(),
},
timestamp: "t".into(),
checks: vec![check("a.b", Organ::Awake, Status::Pass)],
verdict: Verdict::Partial,
signature: None,
};
assert_eq!(document_hash(&doc), document_hash(&doc));
let mut signed = doc.clone();
signed.signature = Some(Signature {
scheme: "ed25519".into(),
pubkey: "00".into(),
sig: "ff".into(),
});
assert_eq!(document_hash(&doc), document_hash(&signed));
}
}