Repository navigation
Ticket 0007a: Anchorer core API in cog-anchor #317
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| .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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Unix hosts with a permissive umask such as
022, thisOpenOptionscreate path uses the platform default create mode, so the generated Ed25519 seed file can end up group/world-readable (for example0644). Because that seed is sufficient to append anchors asdeadbolt, the generator should create it with restrictive permissions such as0600on Unix instead of relying on the caller's umask.Useful? React with 👍 / 👎.