Skip to content
Open
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ kanon verify payment.json --network eip155:84532 --seen-nonce 0xabc...
kanon verify - < payment.json
```

`--now` sets the verification time in unix seconds and defaults to the system clock.`--no-time` omits the verification time and skips the temporal checks. `--seen-nonce` (repeatable) and `--seen-nonces <file>` supply the consumed-nonce set for the replay check.`--network` sets the target network for the network-mismatch check.
`--now` sets the verification time in unix seconds and defaults to the system clock. `--no-time` omits the verification time and skips the temporal checks. `--seen-nonce` (repeatable) and `--seen-nonces <file>` supply the consumed-nonce set for the replay check; each value must be `0x`-prefixed 32-byte hex, duplicates are normalized once, and the CLI accepts at most 100,000 unique entries. `--network` sets the target network for the network-mismatch check.

For a vector file, the context and target network come from the vector itself and the clock flags are ignored, so the verdict is reproducible.

Expand Down Expand Up @@ -103,4 +103,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md). New vectors must cite their provenance,

## License

Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE).
Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE).
3 changes: 2 additions & 1 deletion crates/kanon-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! parses arguments, resolves the system clock, and maps outcomes to exit codes. No verification
//! logic lives here beyond calling `kanon_core::verify`.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context as _, Result};
Expand All @@ -19,7 +20,7 @@ pub struct BareOptions {
/// The verification time, or `None` to skip temporal checks.
pub verification_time: Option<i64>,
/// The consumed-nonce set for the replay check.
pub seen_nonces: Vec<String>,
pub seen_nonces: HashSet<[u8; 32]>,
/// The target network for the network-mismatch check, or `None` to skip it.
pub target_network: Option<String>,
}
Expand Down
22 changes: 17 additions & 5 deletions crates/kanon-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! arguments, resolves the system clock for bare payloads, calls the library half, and maps the
//! outcome to a stable exit code. It contains no verification logic of its own.

use std::collections::HashSet;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
Expand All @@ -12,9 +13,11 @@ use std::time::{SystemTime, UNIX_EPOCH};
use clap::{Args, Parser, Subcommand};

use kanon_cli::{check_corpus, generate_corpus, verify_json, BareOptions};
use kanon_core::parse_seen_nonce;

const DEFAULT_OUT: &str = "vectors/x402/exact/evm/eip3009";
const DEFAULT_CORPUS_DIR: &str = "vectors";
const MAX_SEEN_NONCES: usize = 100_000;

/// Generate and verify Kanon x402 v2 exact / EVM / EIP-3009 test vectors.
#[derive(Parser)]
Expand Down Expand Up @@ -51,10 +54,10 @@ struct VerifyArgs {
/// Omit the verification time entirely, skipping temporal checks (bare payloads only).
#[arg(long)]
no_time: bool,
/// A consumed nonce for the replay check, repeatable (bare payloads only).
/// A consumed nonce for the replay check, repeatable (bare payloads only; max 100,000 unique).
#[arg(long = "seen-nonce")]
seen_nonce: Vec<String>,
/// File of newline delimited consumed nonces (bare payloads only).
/// File of newline delimited consumed nonces (bare payloads only; max 100,000 unique).
#[arg(long = "seen-nonces")]
seen_nonces: Option<PathBuf>,
/// Target CAIP-2 network for the network mismatch check (bare payloads only).
Expand Down Expand Up @@ -185,19 +188,28 @@ fn read_source(path: &str) -> anyhow::Result<String> {
}

/// Collects the consumed-nonce set from repeated flags and an optional file.
fn collect_seen_nonces(args: &VerifyArgs) -> anyhow::Result<Vec<String>> {
fn collect_seen_nonces(args: &VerifyArgs) -> anyhow::Result<HashSet<[u8; 32]>> {
use anyhow::Context as _;
let mut nonces = args.seen_nonce.clone();
let mut values = args.seen_nonce.clone();
if let Some(path) = &args.seen_nonces {
let text =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
for line in text.lines() {
let trimmed = line.trim();
if !trimmed.is_empty() {
nonces.push(trimmed.to_string());
values.push(trimmed.to_string());
}
}
}
let mut nonces = HashSet::with_capacity(values.len().min(MAX_SEEN_NONCES));
for value in values {
nonces.insert(parse_seen_nonce(&value)?);
if nonces.len() > MAX_SEEN_NONCES {
return Err(anyhow::anyhow!(
"seen nonce set exceeds the CLI limit of {MAX_SEEN_NONCES} unique entries"
));
}
}
Ok(nonces)
}

Expand Down
14 changes: 14 additions & 0 deletions crates/kanon-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,5 +237,19 @@ fn binary_exit_codes() {
.code();
assert_eq!(code, Some(2));

// malformed consumed nonce -> 2 before verification
let code = Command::new(bin)
.args([
"verify",
bare_path,
"--no-time",
"--seen-nonce",
"0xdeadbeef",
])
.status()
.expect("run")
.code();
assert_eq!(code, Some(2));

std::fs::remove_dir_all(&dir).ok();
}
3 changes: 3 additions & 0 deletions crates/kanon-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ pub enum VerifyError {
/// A field that must be hex was not valid hex.
#[error("malformed hex in field `{0}`")]
Hex(&'static str),
/// A consumed nonce was not exactly 32 bytes of hexadecimal data.
#[error("seen nonce must be 0x-prefixed 32 byte hex")]
SeenNonce,
/// A field that must be an EVM address was not a valid address.
#[error("invalid address in field `{0}`")]
Address(&'static str),
Expand Down
3 changes: 2 additions & 1 deletion crates/kanon-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ mod tests;

pub use error::VerifyError;
pub use model::{
Accepted, Authorization, Context, ExactPayload, Expected, Extra, Input, ReasonCode, Vector,
parse_seen_nonce, Accepted, Authorization, Context, ExactPayload, Expected, Extra, Input,
ReasonCode, Vector,
};
pub use verify::verify;
38 changes: 35 additions & 3 deletions crates/kanon-core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
//! The requirements travel inside `input.accepted`, exactly as on the wire. The top-level
//! `network` is the verifier's target and is compared against `input.accepted.network`.

use serde::{Deserialize, Serialize};
use std::collections::HashSet;

use alloy_primitives::B256;
use serde::{de::Error as _, Deserialize, Deserializer, Serialize};

use crate::error::VerifyError;

/// A single Kanon test vector, reduced to the fields the verifier consumes.
#[derive(Debug, Clone, Deserialize)]
Expand Down Expand Up @@ -89,8 +94,35 @@ pub struct Context {
#[serde(default)]
pub verification_time: Option<i64>,
/// Nonces already consumed or settled before this verification.
#[serde(default)]
pub seen_nonces: Vec<String>,
#[serde(default, deserialize_with = "deserialize_seen_nonces")]
pub seen_nonces: HashSet<[u8; 32]>,
}

/// Parses one consumed nonce into its normalized binary representation.
///
/// # Errors
///
/// Returns [`VerifyError::SeenNonce`] unless `value` is a `0x`-prefixed 32-byte hexadecimal
/// nonce. Hexadecimal case is ignored by decoding, so equivalent strings share one set entry.
pub fn parse_seen_nonce(value: &str) -> Result<[u8; 32], VerifyError> {
if !value.starts_with("0x") && !value.starts_with("0X") {
return Err(VerifyError::SeenNonce);
}
value
.parse::<B256>()
.map(<[u8; 32]>::from)
.map_err(|_| VerifyError::SeenNonce)
}

fn deserialize_seen_nonces<'de, D>(deserializer: D) -> Result<HashSet<[u8; 32]>, D::Error>
where
D: Deserializer<'de>,
{
let values = Vec::<String>::deserialize(deserializer)?;
values
.into_iter()
.map(|value| parse_seen_nonce(&value).map_err(D::Error::custom))
.collect()
}

/// A verdict, the pair of an accept flag and a reason code.
Expand Down
43 changes: 40 additions & 3 deletions crates/kanon-core/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
//! own digest only to exercise the recovery path. The cross implementation check, generator
//! against verifier, lives in the kanon-cli self check.

use std::collections::HashSet;

use alloy_primitives::{hex, Address, Signature, B256, U256};
use alloy_signer::SignerSync;
use alloy_signer_local::PrivateKeySigner;
Expand Down Expand Up @@ -85,7 +87,7 @@ fn input(chain_id: u64, contract: &str) -> Input {
fn ctx_at(time: i64) -> Context {
Context {
verification_time: Some(time),
seen_nonces: Vec::new(),
seen_nonces: HashSet::new(),
}
}

Expand Down Expand Up @@ -165,12 +167,47 @@ fn at_valid_before_is_expired() {
fn seen_nonce_is_replay() {
let ctx = Context {
verification_time: Some(INSIDE_WINDOW),
seen_nonces: vec![NONCE.to_string()],
seen_nonces: HashSet::from([crate::parse_seen_nonce(NONCE).unwrap()]),
};
let verdict = verify(&input(84532, ASSET), &ctx, Some(NETWORK)).unwrap();
assert_eq!(verdict.reason_code, ReasonCode::NonceReplay);
}

#[test]
fn replay_lookup_handles_a_large_consumed_set() {
let mut seen_nonces = HashSet::with_capacity(100_001);
for value in 0..100_000_u64 {
let mut nonce = [0_u8; 32];
nonce[24..].copy_from_slice(&value.to_be_bytes());
seen_nonces.insert(nonce);
}
seen_nonces.insert(crate::parse_seen_nonce(NONCE).unwrap());
let ctx = Context {
verification_time: Some(INSIDE_WINDOW),
seen_nonces,
};
let verdict = verify(&input(84532, ASSET), &ctx, Some(NETWORK)).unwrap();
assert_eq!(verdict.reason_code, ReasonCode::NonceReplay);
}

#[test]
fn context_rejects_malformed_seen_nonce_during_construction() {
let json = r#"{"verification_time":1740672100,"seen_nonces":["0xdeadbeef"]}"#;
let err = serde_json::from_str::<Context>(json).expect_err("short nonce must be rejected");
assert!(
err.to_string().contains("32 byte"),
"error must explain the nonce width, got: {err}"
);
}

#[test]
fn context_normalizes_and_deduplicates_seen_nonces_once() {
let upper = NONCE.to_ascii_uppercase();
let json = format!(r#"{{"seen_nonces":["{NONCE}","{upper}"]}}"#);
let ctx = serde_json::from_str::<Context>(&json).expect("valid nonce set");
assert_eq!(ctx.seen_nonces.len(), 1);
}

#[test]
fn underpayment_is_amount_insufficient() {
let mut i = input(84532, ASSET);
Expand Down Expand Up @@ -257,7 +294,7 @@ fn malformed_input_never_panics() {
// Negative verification time.
let ctx = Context {
verification_time: Some(-1),
seen_nonces: Vec::new(),
seen_nonces: HashSet::new(),
};
assert!(verify(&base, &ctx, Some(NETWORK)).is_err());
}
19 changes: 3 additions & 16 deletions crates/kanon-core/src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,9 @@ pub fn verify(
}
}

// Nonce replay: comparing the normalized nonce against the consumed set.
let nonce_norm = normalize_hex(&auth.nonce);
if ctx
.seen_nonces
.iter()
.any(|seen| normalize_hex(seen) == nonce_norm)
{
// Nonce replay: the context normalizes and deduplicates once at construction, so verification
// is one O(1) binary-set lookup with no per-entry string allocation.
if ctx.seen_nonces.contains(&<[u8; 32]>::from(nonce)) {
return Ok(reject(ReasonCode::NonceReplay));
}

Expand All @@ -118,15 +114,6 @@ fn reject(reason_code: ReasonCode) -> Expected {
}
}

/// Lowercases a hex value and drops any `0x` prefix so nonces compare case insensitively.
fn normalize_hex(value: &str) -> String {
value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
.unwrap_or(value)
.to_ascii_lowercase()
}

/// Parses an EVM address case insensitively.
fn parse_address(value: &str, field: &'static str) -> Result<Address, VerifyError> {
value
Expand Down