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.lock

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

9 changes: 9 additions & 0 deletions crates/auths-crypto/src/aws_lc_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ impl AwsLcProvider {
Ok((SecureSeed::new(seed), pubkey_bytes))
}

/// Generate an Ed25519 keypair. Returns `(seed, public_key)`.
pub fn ed25519_generate() -> Result<(SecureSeed, [u8; 32]), CryptoError> {
use p256::elliptic_curve::rand_core::{OsRng, RngCore};
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let public_key = Self::ed25519_public_key(&seed)?;
Ok((SecureSeed::new(seed), public_key))
}

/// Sign with P-256 via aws-lc-rs (FIPS-validated). Returns 64-byte r||s.
pub fn p256_sign(seed: &[u8; 32], message: &[u8]) -> Result<Vec<u8>, CryptoError> {
use aws_lc_rs::signature::{ECDSA_P256_SHA256_FIXED_SIGNING, EcdsaKeyPair};
Expand Down
32 changes: 32 additions & 0 deletions crates/auths-crypto/src/cnsa_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,38 @@ impl CnsaProvider {
crate::ring_provider::RingCryptoProvider::ed25519_sign(seed, message)
}

/// Inherent sync shim for `key_ops::generate`. See [`Self::p256_sign`].
pub fn p256_generate() -> Result<(SecureSeed, Vec<u8>), CryptoError> {
Err(CryptoError::OperationFailed(
"P-256 keygen is rejected under --features cnsa; use P-384".into(),
))
}

/// Inherent sync shim for `key_ops::verify`. See [`Self::p256_sign`].
pub fn p256_verify(
_pubkey: &[u8],
_message: &[u8],
_signature: &[u8],
) -> Result<(), CryptoError> {
Err(CryptoError::OperationFailed(
"P-256 verify is rejected under --features cnsa; use P-384".into(),
))
}

/// Inherent sync Ed25519 verify (Ed25519 stays available under CNSA).
pub fn ed25519_verify(
pubkey: &[u8],
message: &[u8],
signature: &[u8],
) -> Result<(), CryptoError> {
crate::ring_provider::RingCryptoProvider::ed25519_verify(pubkey, message, signature)
}

/// Inherent sync Ed25519 keypair generation (delegates to the ring path).
pub fn ed25519_generate() -> Result<(SecureSeed, [u8; 32]), CryptoError> {
crate::ring_provider::RingCryptoProvider::ed25519_generate()
}

/// Inherent sync Ed25519 public key derivation.
pub fn ed25519_public_key(seed: &[u8; 32]) -> Result<[u8; 32], CryptoError> {
crate::ring_provider::RingCryptoProvider::ed25519_public_key(seed)
Expand Down
53 changes: 53 additions & 0 deletions crates/auths-crypto/src/key_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,59 @@ pub fn public_key(seed: &TypedSeed) -> Result<Vec<u8>, CryptoError> {
}
}

/// Verify a signature under the named curve — the sync twin of [`sign`], for
/// callers outside async provider contexts. Same dispatcher shape: one `match`,
/// each arm a cfg-swappable provider inherent method.
///
/// Args:
/// * `curve`: the signature's curve, carried explicitly (never inferred from length).
/// * `public_key`: 32 raw bytes (Ed25519) or SEC1 bytes (P-256).
/// * `message`: the signed bytes.
/// * `signature`: the raw signature.
///
/// Usage:
/// ```ignore
/// verify(CurveType::P256, &pubkey, msg, &sig)?;
/// ```
#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
pub fn verify(
curve: CurveType,
public_key: &[u8],
message: &[u8],
signature: &[u8],
) -> Result<(), CryptoError> {
match curve {
CurveType::Ed25519 => SyncProvider::ed25519_verify(public_key, message, signature),
CurveType::P256 => SyncProvider::p256_verify(public_key, message, signature),
}
}

/// Generate a fresh keypair on the named curve.
///
/// Returns the curve-tagged seed plus the public key ([`public_key`]'s
/// encoding). Same dispatcher shape as [`sign`].
///
/// Args:
/// * `curve`: the curve to generate on.
///
/// Usage:
/// ```ignore
/// let (seed, public_key) = generate(CurveType::P256)?;
/// ```
#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
pub fn generate(curve: CurveType) -> Result<(TypedSeed, Vec<u8>), CryptoError> {
match curve {
CurveType::Ed25519 => {
let (seed, public_key) = SyncProvider::ed25519_generate()?;
Ok((TypedSeed::Ed25519(*seed.as_bytes()), public_key.to_vec()))
}
CurveType::P256 => {
let (seed, public_key) = SyncProvider::p256_generate()?;
Ok((TypedSeed::P256(*seed.as_bytes()), public_key))
}
}
}

/// Parsed signing key with curve carried explicitly — the authoritative owner
/// of a private-key + curve pair across sign / verify / PKCS8 export / CESR
/// encoding flows.
Expand Down
5 changes: 4 additions & 1 deletion crates/auths-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ pub use hash256::Hash256;
pub use key_material::{build_ed25519_pkcs8_v2, parse_ed25519_key_material, parse_ed25519_seed};
pub use key_ops::{ParsedKey, TypedSeed, TypedSignerKey, normalize_verkey, parse_key_material};
#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
pub use key_ops::{public_key as typed_public_key, sign as typed_sign};
pub use key_ops::{
generate as typed_generate, public_key as typed_public_key, sign as typed_sign,
verify as typed_verify,
};
pub use pkcs8::Pkcs8Der;
#[cfg(all(feature = "native", not(target_arch = "wasm32")))]
pub use provider::default_provider;
Expand Down
9 changes: 9 additions & 0 deletions crates/auths-crypto/src/ring_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ impl RingCryptoProvider {
}

/// Derive compressed public key from P-256 seed.
/// Generate an Ed25519 keypair. Returns `(seed, public_key)`.
pub fn ed25519_generate() -> Result<(SecureSeed, [u8; 32]), CryptoError> {
use p256::elliptic_curve::rand_core::{OsRng, RngCore};
let mut seed = [0u8; 32];
OsRng.fill_bytes(&mut seed);
let public_key = Self::ed25519_public_key(&seed)?;
Ok((SecureSeed::new(seed), public_key))
}

pub fn p256_public_key_from_seed(seed: &[u8; 32]) -> Result<Vec<u8>, CryptoError> {
use p256::ecdsa::SigningKey;

Expand Down
179 changes: 157 additions & 22 deletions crates/auths-mcp-core/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,18 +252,92 @@ pub fn spend_log_path(repo: &Path, delegation: &str) -> PathBuf {
.join(format!("{}.jsonl", safe_key(delegation)))
}

/// The delegation's ROTATED spend-log directory: `spend-log/<delegation>/` holding
/// one period-named file per UTC month. Rotation bounds any single file without
/// weakening tamper evidence — the `Auths-Prev` chain runs across files, so a
/// missing middle period still breaks re-derivation.
///
/// Args:
/// * `repo`: the verifier registry root.
/// * `delegation`: the agent delegation the log belongs to.
///
/// Usage:
/// ```ignore
/// let dir = spend_log_dir(repo, "did:keri:Eagent…");
/// ```
pub fn spend_log_dir(repo: &Path, delegation: &str) -> PathBuf {
repo.join("spend-log").join(safe_key(delegation))
}

/// The period file a record stamped `at` lands in (UTC month, lexicographically
/// ordered so a sorted directory walk replays in append order).
///
/// Args:
/// * `repo`: the verifier registry root.
/// * `delegation`: the agent delegation.
/// * `at`: the record's own timestamp (injected clock).
///
/// Usage:
/// ```ignore
/// let file = spend_log_period_path(repo, delegation, now);
/// ```
pub fn spend_log_period_path(
repo: &Path,
delegation: &str,
at: chrono::DateTime<chrono::Utc>,
) -> PathBuf {
spend_log_dir(repo, delegation).join(format!("{}.jsonl", at.format("%Y-%m")))
}

/// Resolve where a delegation's log actually lives: the rotated directory when it
/// exists, else the legacy single file.
///
/// Args:
/// * `repo`: the verifier registry root.
/// * `delegation`: the agent delegation.
///
/// Usage:
/// ```ignore
/// let records = read_spend_log(&resolve_spend_log(repo, delegation))?;
/// ```
pub fn resolve_spend_log(repo: &Path, delegation: &str) -> PathBuf {
let dir = spend_log_dir(repo, delegation);
if dir.is_dir() {
dir
} else {
spend_log_path(repo, delegation)
}
}

/// Read every [`SpendLogRecord`] from a delegation's spend log, in order (for the offline
/// auditor). A blank trailing line is ignored; a non-blank line that fails to parse is
/// `InvalidData` — a corrupted or edited log fails closed rather than silently dropping a call.
/// auditor). `path` may be a single JSONL file or a ROTATED directory of period files —
/// a directory is walked in sorted (chronological) order and its files concatenate into
/// one record stream, which the `Auths-Prev` chain then proves complete across the
/// rotation boundary. A blank trailing line is ignored; a non-blank line that fails to
/// parse is `InvalidData` — a corrupted or edited log fails closed rather than silently
/// dropping a call.
pub fn read_spend_log(path: &Path) -> std::io::Result<Vec<SpendLogRecord>> {
let raw = std::fs::read_to_string(path)?;
raw.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| {
serde_json::from_str::<SpendLogRecord>(l)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
})
.collect()
let files: Vec<PathBuf> = if path.is_dir() {
let mut period_files: Vec<PathBuf> = std::fs::read_dir(path)?
.filter_map(|entry| entry.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|ext| ext == "jsonl"))
.collect();
period_files.sort();
period_files
} else {
vec![path.to_path_buf()]
};
let mut records = Vec::new();
for file in files {
let raw = std::fs::read_to_string(&file)?;
for line in raw.lines().filter(|l| !l.trim().is_empty()) {
records.push(
serde_json::from_str::<SpendLogRecord>(line)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?,
);
}
}
Ok(records)
}

/// A filesystem-safe single component from a delegation id: strip the `did:keri:` scheme and map
Expand Down Expand Up @@ -308,12 +382,70 @@ pub async fn audit_spend_log(
now: i64,
counter: &CounterRef,
facilitator_pubkey: Option<&[u8]>,
) -> AuditVerdict {
audit_spend_log_resumed(
records,
agent_kel,
delegator_kel,
pinned_roots,
now,
counter,
facilitator_pubkey,
&AuditResume::genesis(),
)
.await
}

/// Where a resumed audit picks up: the state a PRIOR full verification of the log's
/// prefix established. Sound because the `Auths-Prev` chain forces the suffix's
/// first record to link to `prior_binding`, and every suffix signature is still
/// re-verified — only work already proven by the caller's own earlier audit is
/// skipped, never trust in the operator.
#[derive(Debug, Clone)]
pub struct AuditResume {
/// Records already verified (the suffix's index offset in the full log).
pub prior_records: usize,
/// The verified prefix's final commit binding (`Auths-Prev` for the next record).
pub prior_binding: String,
/// The settled total the verified prefix re-derived.
pub prior_settled_cents: Cents,
}

impl AuditResume {
/// A from-the-top audit: genesis binding, zero prior spend.
///
/// Usage:
/// ```ignore
/// let verdict = audit_spend_log_resumed(records, …, &AuditResume::genesis()).await;
/// ```
pub fn genesis() -> AuditResume {
AuditResume {
prior_records: 0,
prior_binding: SPEND_LOG_GENESIS.to_string(),
prior_settled_cents: Cents::ZERO,
}
}
}

/// [`audit_spend_log`], resuming after an already-verified prefix: `records` is the
/// SUFFIX, and `resume` carries the prefix's proven end state. The final verdict
/// reports whole-log totals (prefix + suffix).
#[allow(clippy::too_many_arguments)]
pub async fn audit_spend_log_resumed(
records: &[SpendLogRecord],
agent_kel: &[Event],
delegator_kel: &[Event],
pinned_roots: &[String],
now: i64,
counter: &CounterRef,
facilitator_pubkey: Option<&[u8]>,
resume: &AuditResume,
) -> AuditVerdict {
let provider = auths_crypto::default_provider();
let mut settled = Cents::ZERO;
// The binding each record's `Auths-Prev` must match — the prior record's commit hash, or the
// genesis sentinel for the first record.
let mut expected_prev = SPEND_LOG_GENESIS.to_string();
let mut settled = resume.prior_settled_cents;
// The binding each record's `Auths-Prev` must match — the prior record's commit hash, the
// resumed prefix's final binding, or the genesis sentinel for a from-the-top audit.
let mut expected_prev = resume.prior_binding.clone();
for (i, rec) in records.iter().enumerate() {
// Re-verify the SIGNED proof bytes — the gate's own authenticity check, re-run offline.
let commit_verdict = auths_verifier::verify_commit_against_kel_scoped(
Expand Down Expand Up @@ -493,15 +625,15 @@ pub async fn audit_spend_log(
}
}
// The operator's claimed cross-rail total is the last record's cumulative — an UNTRUSTED hint we
// compare against the cost we re-derived from the rail responses.
let claimed = records
.last()
.map(|r| r.receipt.cumulative_cents)
.unwrap_or(Cents::ZERO);
if settled != claimed {
// compare against the cost we re-derived from the rail responses. An EMPTY resumed suffix has
// no new claim to compare (the prefix's claim was checked when it was verified); the durable
// counter below still cross-checks the carried total.
if let Some(last) = records.last()
&& settled != last.receipt.cumulative_cents
{
return AuditVerdict::BudgetMismatch {
recomputed_cents: settled,
claimed_cents: claimed,
claimed_cents: last.receipt.cumulative_cents,
};
}
// Cross-check the re-derived total against the DURABLE verifier-held counter the wire advanced —
Expand All @@ -525,7 +657,10 @@ pub async fn audit_spend_log(
claimed_cents: durable.get(),
};
}
AuditVerdict::Consistent(ConsistentProof::new(records.len(), settled))
AuditVerdict::Consistent(ConsistentProof::new(
resume.prior_records + records.len(),
settled,
))
}

/// The first record in a spend log has no predecessor; its signed `Auths-Prev` trailer carries this
Expand Down
Loading
Loading