Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/cog-anchor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
177 changes: 177 additions & 0 deletions crates/cog-anchor/src/anchorer.rs
Original file line number Diff line number Diff line change
@@ -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<std::io::Error> for AnchorError {
fn from(err: std::io::Error) -> Self {
Self::Io(err)
}
}

impl From<LogError> for AnchorError {
fn from(err: LogError) -> Self {
Self::Log(err)
}
}

/// Appends Deadbolt anchor events to the magpie log.
pub struct Anchorer {
writer: LogWriter<FileStore>,
}

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<Self, AnchorError> {
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<VerifyingKey, AnchorError> {
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)
Comment on lines +121 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict generated seed file permissions

On Unix hosts with a permissive umask such as 022, this OpenOptions create path uses the platform default create mode, so the generated Ed25519 seed file can end up group/world-readable (for example 0644). Because that seed is sufficient to append anchors as deadbolt, the generator should create it with restrictive permissions such as 0600 on Unix instead of relying on the caller's umask.

Useful? React with 👍 / 👎.

.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<SigningKey, AnchorError> {
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<u8, AnchorError> {
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
}
26 changes: 16 additions & 10 deletions crates/cog-anchor/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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};
Loading