diff --git a/Cargo.lock b/Cargo.lock index f944364..e0119e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,7 +200,10 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "cog-anchor" version = "0.1.0" dependencies = [ + "getrandom 0.4.2", "magpie-log", + "serde", + "serde_json", ] [[package]] diff --git a/crates/cog-anchor/Cargo.toml b/crates/cog-anchor/Cargo.toml index 0b05b69..1892a6a 100644 --- a/crates/cog-anchor/Cargo.toml +++ b/crates/cog-anchor/Cargo.toml @@ -13,3 +13,6 @@ license.workspace = true # { path = "../../../magpie/crates/magpie-log" } — resolves locally but can # never pass CI. See ticket 0006 findings. magpie-log = { git = "https://github.com/noctem-o/magpie.git", rev = "7c7f0432b8543f4ef3600411908530ef6dedd032" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +getrandom = "0.4" diff --git a/crates/cog-anchor/src/anchorer.rs b/crates/cog-anchor/src/anchorer.rs new file mode 100644 index 0000000..e4ec85a --- /dev/null +++ b/crates/cog-anchor/src/anchorer.rs @@ -0,0 +1,177 @@ +use magpie_log::{FileStore, LogError, LogWriter, Payload, Provenance, SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +/// A request to append one `SegmentAnchored` event to the Deadbolt anchor log. +/// +/// The canonicalization profile is the foreign profile that produced the +/// witness root. Anchors record it verbatim and never relabel themselves as a +/// magpie profile. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AnchorRequest { + pub bundle_kind: String, + pub witness_root: String, + pub witness_algorithm: String, + pub canonicalization_profile: String, + pub run_id: String, +} + +/// Errors returned by the anchor log API. +#[derive(Debug)] +pub enum AnchorError { + Io(std::io::Error), + Key(String), + Log(LogError), +} + +impl fmt::Display for AnchorError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Io(err) => write!(f, "I/O error: {err}"), + Self::Key(err) => write!(f, "anchor key error: {err}"), + Self::Log(err) => write!(f, "anchor log error: {err}"), + } + } +} + +impl std::error::Error for AnchorError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(err) => Some(err), + Self::Key(_) => None, + Self::Log(err) => Some(err), + } + } +} + +impl From for AnchorError { + fn from(err: std::io::Error) -> Self { + Self::Io(err) + } +} + +impl From for AnchorError { + fn from(err: LogError) -> Self { + Self::Log(err) + } +} + +/// Appends Deadbolt anchor events to the magpie log. +pub struct Anchorer { + writer: LogWriter, +} + +impl Anchorer { + /// The sole construction site of a magpie `LogWriter` in the Deadbolt + /// estate. Opens, creating or appending, the log at `log_path` with the + /// signing key read from `key_path`. + /// + /// This never truncates or overwrites an existing log. Opening an existing + /// log with the wrong key surfaces the underlying `LogError`. + pub fn open(log_path: &Path, key_path: &Path) -> Result { + let signing_key = read_signing_key(key_path)?; + let writer = LogWriter::open(FileStore::new(log_path), signing_key)?; + Ok(Self { writer }) + } + + /// Append one `SegmentAnchored` event. + /// + /// Provenance agent is always `deadbolt`; `provenance_source` names the + /// calling command. + pub fn anchor( + &mut self, + provenance_source: &str, + request: AnchorRequest, + ) -> Result<(), AnchorError> { + self.writer.append( + Provenance::new("deadbolt", provenance_source), + Payload::SegmentAnchored { + bundle_kind: request.bundle_kind, + witness_root: request.witness_root, + witness_algorithm: request.witness_algorithm, + canonicalization_profile: request.canonicalization_profile, + run_id: request.run_id, + }, + )?; + Ok(()) + } +} + +/// Generate a dev-grade Ed25519 seed file for the anchor log. +/// +/// The file is exactly 64 lowercase hex characters: the 32-byte Ed25519 seed. +/// Existing files are refused and never overwritten. This is local development +/// file custody only, not production key management. +pub fn generate_key_file(key_path: &Path) -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|err| { + AnchorError::Io(std::io::Error::other(format!( + "system entropy source failed: {err}" + ))) + })?; + + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + let seed_hex = encode_lower_hex(&seed); + + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(key_path) + .map_err(|err| { + if err.kind() == std::io::ErrorKind::AlreadyExists { + AnchorError::Key(format!("key file already exists: {}", key_path.display())) + } else { + AnchorError::Io(err) + } + })?; + file.write_all(seed_hex.as_bytes())?; + Ok(verifying_key) +} + +fn read_signing_key(key_path: &Path) -> Result { + let key = std::fs::read_to_string(key_path)?; + let seed = decode_seed_hex(&key)?; + Ok(SigningKey::from_bytes(&seed)) +} + +fn decode_seed_hex(key: &str) -> Result<[u8; 32], AnchorError> { + let key = key.trim_end(); + if key.len() != 64 { + return Err(AnchorError::Key( + "key file must contain exactly 64 lowercase hex characters".into(), + )); + } + + let mut seed = [0u8; 32]; + for (out, pair) in seed.iter_mut().zip(key.as_bytes().chunks_exact(2)) { + let high = decode_lower_hex_nibble(pair[0])?; + let low = decode_lower_hex_nibble(pair[1])?; + *out = (high << 4) | low; + } + Ok(seed) +} + +fn decode_lower_hex_nibble(byte: u8) -> Result { + match byte { + b'0'..=b'9' => Ok(byte - b'0'), + b'a'..=b'f' => Ok(byte - b'a' + 10), + _ => Err(AnchorError::Key( + "key file must use lowercase hex characters only".into(), + )), + } +} + +fn encode_lower_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} diff --git a/crates/cog-anchor/src/lib.rs b/crates/cog-anchor/src/lib.rs index 2557f0b..5a74043 100644 --- a/crates/cog-anchor/src/lib.rs +++ b/crates/cog-anchor/src/lib.rs @@ -1,14 +1,20 @@ -//! # cog-anchor — the seam to magpie +//! # cog-anchor — the anchoring seam to magpie //! //! This crate is the ONLY place in the deadbolt workspace that may depend on //! `magpie-log`; `cargo tree -i magpie-log` must print exactly one dependent, -//! and it is this crate (CLAUDE.md law 3). Once the emitter lands, -//! `Anchorer::open` here becomes the sole construction site of a `LogWriter` -//! in the entire estate: the capability of appending to the record has -//! exactly one home, and that home is at the harness layer, not inside the -//! (to-be-)verified core. +//! and it is this crate (CLAUDE.md law 3). `Anchorer::open` is the sole +//! construction site of a `LogWriter` in the Deadbolt estate: the capability +//! of appending to the record has exactly one home, and that home is at the +//! harness layer, not inside the verified core. //! -//! Ticket 0006 scope: dependency probe plus smoke test only. There is -//! deliberately no `Anchorer` API yet — that is the emitter ticket's job. -//! The integration test under `tests/` exercises the append-and-verify path -//! end to end against the pinned magpie-log revision. +//! The key-file primitive in this crate is dev-grade file custody only, in the +//! same spirit as the local DSSE development keys. It is not production key +//! management and provides no transparency receipt or external custody proof. + +// The identity-creation crate of a least-authority estate carries a +// compile-time no-unsafe guarantee. +#![forbid(unsafe_code)] + +mod anchorer; + +pub use anchorer::{generate_key_file, AnchorError, AnchorRequest, Anchorer}; diff --git a/crates/cog-anchor/tests/anchorer.rs b/crates/cog-anchor/tests/anchorer.rs new file mode 100644 index 0000000..7650058 --- /dev/null +++ b/crates/cog-anchor/tests/anchorer.rs @@ -0,0 +1,223 @@ +use cog_anchor::{generate_key_file, AnchorError, AnchorRequest, Anchorer}; +use magpie_log::{FileStore, LogError, LogReader, SigningKey}; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn temp_dir(name: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + path.push(format!( + "cog-anchor-anchorer-{name}-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&path).unwrap(); + path +} + +fn request(run_id: &str) -> AnchorRequest { + AnchorRequest { + bundle_kind: "kernel-decision-witness".into(), + witness_root: "ab".repeat(32), + witness_algorithm: "sha256".into(), + canonicalization_profile: "phase5-interim-jcs-like-v1".into(), + run_id: run_id.into(), + } +} + +fn verify_len(log_path: &Path, key_path: &Path) -> u64 { + let seed = seed_from_key_file(key_path); + let verifying = SigningKey::from_bytes(&seed).verifying_key(); + let reader = LogReader::open(FileStore::new(log_path), verifying); + reader.verify_chain().unwrap() +} + +fn seed_from_key_file(key_path: &Path) -> [u8; 32] { + let key = std::fs::read_to_string(key_path).unwrap(); + let key = key.trim_end_matches('\n'); + assert_eq!(key.len(), 64); + let mut seed = [0u8; 32]; + for (out, pair) in seed.iter_mut().zip(key.as_bytes().chunks_exact(2)) { + *out = (nibble(pair[0]) << 4) | nibble(pair[1]); + } + seed +} + +fn nibble(byte: u8) -> u8 { + match byte { + b'0'..=b'9' => byte - b'0', + b'a'..=b'f' => byte - b'a' + 10, + _ => panic!("expected lowercase hex byte, got {byte}"), + } +} + +fn anchor_payload(log_path: &Path, event_index: usize) -> Map { + let log = std::fs::read_to_string(log_path).unwrap(); + let event: Value = serde_json::from_str(log.lines().nth(event_index).unwrap()).unwrap(); + find_segment_payload(&event) + .cloned() + .expect("SegmentAnchored payload fields must be present") +} + +fn find_segment_payload(value: &Value) -> Option<&Map> { + match value { + Value::Object(map) => { + let has_segment_fields = [ + "bundle_kind", + "witness_root", + "witness_algorithm", + "canonicalization_profile", + "run_id", + ] + .iter() + .all(|field| map.contains_key(*field)); + if has_segment_fields { + Some(map) + } else { + map.values().find_map(find_segment_payload) + } + } + Value::Array(values) => values.iter().find_map(find_segment_payload), + _ => None, + } +} + +fn assert_payload_matches(payload: &Map, expected: &AnchorRequest) { + assert_eq!(payload["bundle_kind"], expected.bundle_kind); + assert_eq!(payload["witness_root"], expected.witness_root); + assert_eq!(payload["witness_algorithm"], expected.witness_algorithm); + assert_eq!( + payload["canonicalization_profile"], + expected.canonicalization_profile + ); + assert_eq!(payload["run_id"], expected.run_id); +} + +#[test] +fn anchor_roundtrip_records_segment_fields() { + let dir = temp_dir("roundtrip"); + let log_path = dir.join("anchors.jsonl"); + let key_path = dir.join("anchor.key"); + let verifying = generate_key_file(&key_path).unwrap(); + let req = request("run-0007a-roundtrip"); + + { + let mut anchorer = Anchorer::open(&log_path, &key_path).unwrap(); + anchorer + .anchor("cog-anchor/roundtrip", req.clone()) + .unwrap(); + } + + let reader = LogReader::open(FileStore::new(&log_path), verifying); + assert_eq!(reader.verify_chain().unwrap(), 2, "genesis + one anchor"); + assert_payload_matches(&anchor_payload(&log_path, 1), &req); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn duplicate_anchor_requests_append_duplicate_events() { + let dir = temp_dir("duplicates"); + let log_path = dir.join("anchors.jsonl"); + let key_path = dir.join("anchor.key"); + generate_key_file(&key_path).unwrap(); + let req = request("run-0007a-duplicate"); + + { + let mut anchorer = Anchorer::open(&log_path, &key_path).unwrap(); + anchorer + .anchor("cog-anchor/duplicates", req.clone()) + .unwrap(); + anchorer.anchor("cog-anchor/duplicates", req).unwrap(); + } + + assert_eq!( + verify_len(&log_path, &key_path), + 3, + "genesis + two duplicate anchors" + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn malformed_witness_root_is_log_error_and_does_not_append() { + let dir = temp_dir("malformed-root"); + let log_path = dir.join("anchors.jsonl"); + let key_path = dir.join("anchor.key"); + generate_key_file(&key_path).unwrap(); + let mut req = request("run-0007a-hostile-root"); + req.witness_root = "AB".repeat(32); + + { + let mut anchorer = Anchorer::open(&log_path, &key_path).unwrap(); + let err = anchorer.anchor("cog-anchor/hostile-root", req).unwrap_err(); + assert!( + matches!(err, AnchorError::Log(LogError::ChainBroken { .. })), + "malformed witness_root must surface LogError::ChainBroken, got: {err:?}" + ); + } + + assert_eq!( + verify_len(&log_path, &key_path), + 1, + "refused anchor must not reach the store" + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn opening_existing_log_with_wrong_key_fails() { + let dir = temp_dir("wrong-key"); + let log_path = dir.join("anchors.jsonl"); + let key_path = dir.join("anchor.key"); + let wrong_key_path = dir.join("wrong-anchor.key"); + generate_key_file(&key_path).unwrap(); + generate_key_file(&wrong_key_path).unwrap(); + + { + let _anchorer = Anchorer::open(&log_path, &key_path).unwrap(); + } + + let err = match Anchorer::open(&log_path, &wrong_key_path) { + Ok(_) => panic!("opening an existing log with the wrong key must fail"), + Err(err) => err, + }; + assert!( + matches!(err, AnchorError::Log(_)), + "wrong key must surface the underlying LogError, got: {err:?}" + ); + + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn generate_key_file_writes_lower_hex_seed_and_refuses_overwrite() { + let dir = temp_dir("key-init"); + let key_path = dir.join("anchor.key"); + + let verifying = generate_key_file(&key_path).unwrap(); + let bytes_before = std::fs::read(&key_path).unwrap(); + let key = std::str::from_utf8(&bytes_before).unwrap(); + assert_eq!(key.len(), 64); + assert!( + key.bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "key file must contain only lowercase hex" + ); + let seed = seed_from_key_file(&key_path); + assert_eq!(SigningKey::from_bytes(&seed).verifying_key(), verifying); + + let err = generate_key_file(&key_path).unwrap_err(); + assert!( + matches!(err, AnchorError::Key(_)), + "existing key file must be refused as key error, got: {err:?}" + ); + assert_eq!(std::fs::read(&key_path).unwrap(), bytes_before); + + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/cog-anchor/tests/smoke.rs b/crates/cog-anchor/tests/smoke.rs index 913fb43..1bb80c3 100644 --- a/crates/cog-anchor/tests/smoke.rs +++ b/crates/cog-anchor/tests/smoke.rs @@ -1,8 +1,9 @@ -//! Smoke test for the magpie seam: open a log, watch the genesis get -//! written, append one anchor, verify the chain — and confirm the writer -//! refuses a malformed witness root before signing it. +//! Smoke test for the magpie seam through `Anchorer`: open a log, append one +//! anchor, verify the chain — and confirm a malformed witness root is refused +//! before signing it. -use magpie_log::{FileStore, LogError, LogReader, LogWriter, Payload, Provenance, SigningKey}; +use cog_anchor::{AnchorError, AnchorRequest, Anchorer}; +use magpie_log::{FileStore, LogError, LogReader, SigningKey}; // Dev-grade test material only (see CLAUDE.md "Keys"): a fixed-seed // throwaway key. Never represents production custody. @@ -18,8 +19,22 @@ fn temp_log_path(name: &str) -> std::path::PathBuf { path } -fn anchor(witness_root: &str) -> Payload { - Payload::SegmentAnchored { +fn temp_key_path(name: &str) -> std::path::PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "cog-anchor-smoke-{name}-{}.key", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + path +} + +fn write_seed_key(path: &std::path::Path) { + std::fs::write(path, "2a".repeat(32)).unwrap(); +} + +fn anchor(witness_root: &str) -> AnchorRequest { + AnchorRequest { bundle_kind: "kernel-decision-witness".into(), witness_root: witness_root.into(), witness_algorithm: "sha256".into(), @@ -33,18 +48,15 @@ fn anchor(witness_root: &str) -> Payload { #[test] fn genesis_anchor_and_verify_roundtrip() { let path = temp_log_path("roundtrip"); + let key_path = temp_key_path("roundtrip"); + write_seed_key(&key_path); let key = SigningKey::from_bytes(&SEED); let verifying = key.verifying_key(); { - let mut writer = LogWriter::open(FileStore::new(&path), key).unwrap(); - // Opening on an empty store already wrote the genesis event (seq 0). - assert_eq!(writer.len(), 1, "genesis must be written on empty open"); - writer - .append( - Provenance::new("deadbolt", "cog-anchor/smoke"), - anchor(&"ab".repeat(32)), - ) + let mut anchorer = Anchorer::open(&path, &key_path).unwrap(); + anchorer + .anchor("cog-anchor/smoke", anchor(&"ab".repeat(32))) .unwrap(); } @@ -52,26 +64,26 @@ fn genesis_anchor_and_verify_roundtrip() { assert_eq!(reader.verify_chain().unwrap(), 2, "genesis + one anchor"); let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&key_path); } #[test] fn writer_refuses_malformed_witness_root_before_signing() { let path = temp_log_path("hostile"); + let key_path = temp_key_path("hostile"); + write_seed_key(&key_path); let key = SigningKey::from_bytes(&SEED); let verifying = key.verifying_key(); - let mut writer = LogWriter::open(FileStore::new(&path), key).unwrap(); - let err = writer - .append( - Provenance::new("deadbolt", "cog-anchor/smoke"), - anchor(&"AB".repeat(32)), - ) + let mut anchorer = Anchorer::open(&path, &key_path).unwrap(); + let err = anchorer + .anchor("cog-anchor/smoke", anchor(&"AB".repeat(32))) .unwrap_err(); assert!( - matches!(err, LogError::ChainBroken { .. }), + matches!(err, AnchorError::Log(LogError::ChainBroken { .. })), "uppercase witness_root must be rejected before signing, got: {err:?}" ); - drop(writer); + drop(anchorer); let reader = LogReader::open(FileStore::new(&path), verifying); assert_eq!( @@ -81,4 +93,5 @@ fn writer_refuses_malformed_witness_root_before_signing() { ); let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(&key_path); }