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
2 changes: 2 additions & 0 deletions .cargo/config.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[alias]
xtask = "run -p xtask --"
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ jobs:
cargo build --release --locked
--package auths-cli
--package auths-mcp-gateway
--package auths-witness-node
--target ${{ matrix.target }}

- name: Package (Unix)
Expand All @@ -116,6 +117,7 @@ jobs:
cp target/${{ matrix.target }}/release/auths staging/ || true
cp target/${{ matrix.target }}/release/auths-sign staging/ || true
cp target/${{ matrix.target }}/release/auths-verify staging/ || true
cp target/${{ matrix.target }}/release/witness-node staging/ || true
# No `|| true`: a silently absent gateway is how it shipped to nobody.
cp target/${{ matrix.target }}/release/auths-mcp-gateway staging/
tar -czf ${{ matrix.asset_name }}${{ matrix.ext }} -C staging .
Expand All @@ -128,6 +130,7 @@ jobs:
Copy-Item target/${{ matrix.target }}/release/auths.exe staging/ -ErrorAction SilentlyContinue
Copy-Item target/${{ matrix.target }}/release/auths-sign.exe staging/ -ErrorAction SilentlyContinue
Copy-Item target/${{ matrix.target }}/release/auths-verify.exe staging/ -ErrorAction SilentlyContinue
Copy-Item target/${{ matrix.target }}/release/witness-node.exe staging/ -ErrorAction SilentlyContinue
# No SilentlyContinue: a silently absent gateway is how it shipped to nobody.
Copy-Item target/${{ matrix.target }}/release/auths-mcp-gateway.exe staging/
Compress-Archive -Path staging/* -DestinationPath ${{ matrix.asset_name }}${{ matrix.ext }}
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,10 @@ RustAgentDemo/*.a
concat_output.txt
CLAUDE.md
AGENTS.md
.cargo/
# Ignore local .cargo contents (audit.toml, logs) but track the committed
# `cargo xtask` alias so a fresh clone resolves the shorthand the docs print.
.cargo/*
!.cargo/config.toml
my-artifact.txt
my-artifact.txt.auths.json

Expand Down
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.

13 changes: 9 additions & 4 deletions crates/auths-anchor/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,16 @@ thiserror.workspace = true
getrandom = { version = "0.2", features = ["js"] }

[features]
# No features: the whole crate is the pure protocol core. The verification half
# (freshness, finalized-anchor verification, duplicity-proof verification) is
# WASM-safe; the acceptance rule is pure but node-facing. Kept flag-free so
# `cargo check --target wasm32-unknown-unknown` exercises the real surface.
# No default features: the whole crate is the pure protocol core. The
# verification half (freshness, finalized-anchor verification, duplicity-proof
# verification) is WASM-safe; the acceptance rule is pure but node-facing. Kept
# flag-free so `cargo check --target wasm32-unknown-unknown` exercises the real
# surface.
default = []
# Exposes the deterministic, fixed-seed finalized-anchor fixtures (the same
# builders the in-crate unit tests use) to downstream test crates such as
# `auths-evidence`. Never enabled in a production build.
test-support = []

[dev-dependencies]
# Deterministic (fixed-seed) key material for tests — no RNG, so vectors are
Expand Down
33 changes: 27 additions & 6 deletions crates/auths-anchor/src/accept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub enum Acceptance {
/// The request is well-ordered and authorized — the caller should sign,
/// CAS-store, log, and gossip it.
CoSign(Box<Anchor>),
/// The request exactly matches the last co-signed anchor — accept as a
/// no-op; the caller must NOT re-store or re-cosign it. This is an
/// idempotent replay, not a rollback and not equivocation.
AlreadyAnchored(Box<Anchor>),
/// The request equivocates against the prior anchor at the same index — the
/// caller must refuse and publish the proof.
Duplicity(Box<DuplicityProof>),
Expand Down Expand Up @@ -69,13 +73,16 @@ pub fn accept_anchor(
}
verify_party_signature(req, keys)?;
if let Some(last) = prior {
if req.index == last.index && req.head != last.head {
if req.index == last.index {
if req.head == last.head {
return Ok(Acceptance::AlreadyAnchored(Box::new(last.clone())));
}
return Ok(Acceptance::Duplicity(Box::new(DuplicityProof::new(
last, req,
)?)));
}
if req.index <= last.index {
return Err(AnchorError::NonMonotoneIndex {
if req.index < last.index {
return Err(AnchorError::IndexRollback {
got: req.index,
prior: last.index,
});
Expand Down Expand Up @@ -149,21 +156,35 @@ mod tests {
let fork = sample_anchor_with_head(5, [2u8; 32]);
match accept_anchor(&fork, &keys, Some(&prior), now()).unwrap() {
Acceptance::Duplicity(proof) => proof.verify().unwrap(),
Acceptance::CoSign(_) => panic!("expected duplicity"),
Acceptance::CoSign(_) => panic!("expected duplicity, got a co-sign"),
Acceptance::AlreadyAnchored(_) => {
panic!("expected duplicity, got an idempotent replay")
}
}
}

#[test]
fn index_regression_is_rejected() {
fn lower_index_is_rollback() {
let prior = sample_anchor(5);
let keys = controller_keys_for(&prior);
let stale = sample_anchor(3);
assert!(matches!(
accept_anchor(&stale, &keys, Some(&prior), now()),
Err(AnchorError::NonMonotoneIndex { got: 3, prior: 5 })
Err(AnchorError::IndexRollback { got: 3, prior: 5 })
));
}

#[test]
fn exact_replay_is_already_anchored_not_error() {
let prior = sample_anchor(4);
let keys = controller_keys_for(&prior);
let replay = prior.clone();
match accept_anchor(&replay, &keys, Some(&prior), now()).unwrap() {
Acceptance::AlreadyAnchored(anchor) => assert_eq!(anchor.index, 4),
other => panic!("expected an idempotent replay, got {other:?}"),
}
}

#[test]
fn tampered_signature_is_rejected() {
let mut req = sample_anchor(1);
Expand Down
8 changes: 5 additions & 3 deletions crates/auths-anchor/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ pub enum AnchorError {
#[error("party key is not a current key of the controller")]
PartyKeyNotCurrent,

/// A new anchor's index did not strictly increase over the prior anchor.
#[error("non-monotone index: {got} does not exceed prior {prior}")]
NonMonotoneIndex {
/// A new anchor's index regressed below the prior anchor — a rollback
/// attempt. An exact-index, exact-head resubmission is a benign no-op and is
/// reported separately, so only a true rewind reaches this variant.
#[error("index {got} regresses below prior {prior} — fetch the latest anchor and extend it")]
IndexRollback {
/// The submitted index.
got: u64,
/// This witness's last co-signed index for the seed.
Expand Down
37 changes: 37 additions & 0 deletions crates/auths-anchor/src/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -315,4 +315,41 @@ mod tests {
Err(AnchorError::SetInvalid(_))
));
}

#[test]
fn flipped_cosig_byte_rejected() {
// A single flipped byte in a cosignature no longer verifies over the
// cosign message — the quorum fails whole.
let mut finalized = finalized_sample(3, 2);
let mut bytes = *finalized.cosignatures[0].signature.as_bytes();
bytes[0] ^= 0x01;
finalized.cosignatures[0].signature = auths_verifier::Ed25519Signature::from_bytes(bytes);
assert!(matches!(
verify_finalized(&finalized, None),
Err(AnchorError::CosignatureInvalid { .. })
));
}

#[test]
fn foreign_witness_cosig_rejected() {
// A cosignature attributed to a name outside the declared set is refused
// — cosigners must be inside the declared witness set.
let mut finalized = finalized_sample(3, 2);
finalized.cosignatures[0].witness_name = "outsider".to_string();
assert!(matches!(
verify_finalized(&finalized, None),
Err(AnchorError::CosignerOutsideSet { .. })
));
}

#[test]
fn threshold_above_member_count_rejected() {
// A threshold above the declared member count is a structurally invalid
// set — refused before any key it declares is trusted.
let finalized = finalized_sample(3, 5);
assert!(matches!(
verify_finalized(&finalized, None),
Err(AnchorError::SetInvalid(_))
));
}
}
87 changes: 70 additions & 17 deletions crates/auths-anchor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ pub use auths_crypto::CurveType;
/// consumer building a [`FinalizedAnchor`] needs only one dependency.
pub use auths_transparency::{Checkpoint, InclusionProof, SignedCheckpoint, WitnessCosignature};

#[cfg(test)]
pub(crate) mod test_support {
//! Deterministic, fixed-seed fixtures for the in-crate unit tests.
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
//! Deterministic, fixed-seed fixtures for the in-crate unit tests and, under
//! the `test-support` feature, for downstream test crates (`auths-evidence`).
// Fixtures are test-only material built from fixed seeds, so these unwraps are
// infallible; the `#[cfg(test)]` clippy exemption does not reach the
// feature-gated build, so allow them explicitly here. Never in production.
#![allow(clippy::unwrap_used, clippy::expect_used)]

use crate::types::{
Anchor, ControllerKeys, CurrentKey, FinalizedAnchor, Head, OperatorInfo, PartySignature,
Expand All @@ -76,12 +81,12 @@ pub(crate) mod test_support {
}

/// An Ed25519-signed anchor at `index` with a deterministic head.
pub(crate) fn sample_anchor(index: u64) -> Anchor {
pub fn sample_anchor(index: u64) -> Anchor {
sample_anchor_with_head(index, [index as u8; 32])
}

/// An Ed25519-signed anchor at `index` with an explicit head.
pub(crate) fn sample_anchor_with_head(index: u64, head: [u8; 32]) -> Anchor {
pub fn sample_anchor_with_head(index: u64, head: [u8; 32]) -> Anchor {
let sk = party_sk();
let vk = sk.verifying_key();
let mut anchor = Anchor {
Expand All @@ -106,7 +111,7 @@ pub(crate) mod test_support {
}

/// The controller-keys view that authorizes `anchor`'s party signature.
pub(crate) fn controller_keys_for(anchor: &Anchor) -> ControllerKeys {
pub fn controller_keys_for(anchor: &Anchor) -> ControllerKeys {
ControllerKeys {
current: vec![CurrentKey {
curve: anchor.sig_party.curve,
Expand All @@ -115,13 +120,37 @@ pub(crate) mod test_support {
}
}

/// A finalized anchor over `n` witnesses with the given `threshold`.
/// The raw Ed25519 seed the sample / `finalized_*` anchors are party-signed
/// with. Hand it to a document signer so the embedded-anchor party-key check
/// (party key ∈ the agent's current keys) passes.
pub fn party_seed_bytes() -> [u8; 32] {
[9u8; 32]
}

/// The Ed25519 verifying-key bytes of [`party_seed_bytes`] — the party key
/// every sample anchor is signed under.
pub fn party_verifying_key_bytes() -> [u8; 32] {
party_sk().verifying_key().to_bytes()
}

/// A finalized anchor over `n` witnesses at `threshold` whose tuple restates
/// a CALLER-chosen `(seed_id, index, head, cumulative)`. Everything the
/// embedded-anchor check restates against a document is caller-controlled, so
/// an `activity/v1` verifier test can build a document this anchor
/// legitimately restates. Party-signed by [`party_seed_bytes`].
///
/// The declared set is genuinely self-addressing (its SAID is computed from
/// content and committed into the party-signed anchor), and every cosigner
/// carries a member-signed checkpoint rooting its inclusion proof — the
/// same material a real node must produce.
pub(crate) fn finalized_sample(n: u8, threshold: u32) -> FinalizedAnchor {
/// carries a member-signed checkpoint rooting its inclusion proof — the same
/// material a real node must produce.
pub fn finalized_matching(
seed_id: SeedId,
index: u64,
head: [u8; 32],
cumulative: u128,
n: u8,
threshold: u32,
) -> FinalizedAnchor {
let members: Vec<WitnessRef> = (0..n)
.map(|i| WitnessRef {
name: format!("witness-{i}"),
Expand All @@ -142,12 +171,23 @@ pub(crate) mod test_support {
};
witness_set.said = witness_set.computed_said().unwrap();

let mut anchor = sample_anchor(1);
anchor.witness_set = WitnessSetRef {
said: witness_set.said.clone(),
threshold,
};
let sk = party_sk();
let mut anchor = Anchor {
seed_id,
index,
head: Head::from_bytes(head),
cumulative,
timestamp: ts(index),
witness_set: WitnessSetRef {
said: witness_set.said.clone(),
threshold,
},
sig_party: PartySignature {
curve: CurveType::Ed25519,
public_key: sk.verifying_key().as_bytes().to_vec(),
signature: Vec::new(),
},
};
let message = anchor.party_signing_bytes().unwrap();
anchor.sig_party.signature = sk.sign(&message).to_bytes().to_vec();

Expand Down Expand Up @@ -177,9 +217,22 @@ pub(crate) mod test_support {
}
}

/// A finalized anchor over `n` witnesses with the given `threshold`, on the
/// default sample spend chain (`sample_anchor(1)`'s tuple).
pub fn finalized_sample(n: u8, threshold: u32) -> FinalizedAnchor {
finalized_matching(
SeedId::derive("did:keri:root", "did:keri:agent", "ESeal"),
1,
[1u8; 32],
100,
n,
threshold,
)
}

/// A member-signed checkpoint over a one-leaf log containing `leaf`, with
/// the inclusion proof rooted in it.
pub(crate) fn logged_inclusion_for(
pub fn logged_inclusion_for(
name: &str,
witness_key: &SigningKey,
leaf: auths_transparency::MerkleHash,
Expand Down Expand Up @@ -217,7 +270,7 @@ pub(crate) mod test_support {
}

/// Keep only the first `k` cosignatures (to fall below a threshold).
pub(crate) fn with_cosigners(mut finalized: FinalizedAnchor, k: usize) -> FinalizedAnchor {
pub fn with_cosigners(mut finalized: FinalizedAnchor, k: usize) -> FinalizedAnchor {
finalized.cosignatures.truncate(k);
finalized
}
Expand Down
26 changes: 26 additions & 0 deletions crates/auths-anchor/tests/cases/invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,35 @@ fn i_dup_1_never_two_heads_one_index() {
match accept_anchor(&fork, &keys, Some(&prior), now()).unwrap() {
Acceptance::Duplicity(proof) => proof.verify().unwrap(),
Acceptance::CoSign(_) => panic!("co-signed a fork — I-DUP-1 violated"),
Acceptance::AlreadyAnchored(_) => panic!("treated a fork as an idempotent replay"),
}
}

/// A duplicity proof survives a JSON round-trip and still verifies, while a
/// single flipped head byte makes the re-hydrated proof unverifiable — the
/// valid-then-tampered control a counterparty relies on.
#[test]
fn duplicity_proof_round_trips_and_rejects_tampering() {
let a = signed_anchor(1, [1u8; 32], 2, "EWitSet");
let b = signed_anchor(1, [2u8; 32], 2, "EWitSet");
let proof = DuplicityProof::new(&a, &b).unwrap();

let wire = serde_json::to_string(&proof).unwrap();
let rehydrated: DuplicityProof = serde_json::from_str(&wire).unwrap();
rehydrated.verify().unwrap();

let mut tampered: DuplicityProof = serde_json::from_str(&wire).unwrap();
let mut head = *tampered.anchor_b.head.as_bytes();
head[0] ^= 0xff;
tampered.anchor_b.head = auths_anchor::Head::from_bytes(head);
let reserialized = serde_json::to_string(&tampered).unwrap();
let reloaded: DuplicityProof = serde_json::from_str(&reserialized).unwrap();
assert!(matches!(
reloaded.verify(),
Err(AnchorError::InvalidDuplicityProof(_))
));
}

/// I-DUP-2: a duplicity proof is self-contained and verifies offline by a
/// stranger who reconstructs it from the two anchors alone.
#[test]
Expand Down
Loading
Loading