Skip to content
Draft
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
6 changes: 6 additions & 0 deletions Cargo.lock

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

41 changes: 32 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,37 @@ path = "src/lib.rs"
# these through `ant_protocol::{transport, pqc, evm}` re-exports so the
# version is pinned in exactly one place. Changing these versions is a
# breaking change for both downstream crates.
saorsa-core = "0.26.4"
saorsa-pqc = "0.5"
evmlib = "0.9.0"
saorsa-core = { version = "0.26.4", optional = true }
saorsa-pqc = { version = "0.5", optional = true }
evmlib = { version = "0.9.0", optional = true }

# Content addressing (BLAKE3)
blake3 = "1"

# Browser WebRTC application-session protection. The complete handshake and
# record layer live here so native nodes and WASM clients share one wire
# implementation instead of duplicating cryptography in JavaScript.
chacha20poly1305 = { version = "0.10.1", default-features = false, features = ["alloc"], optional = true }
fips203 = { version = "0.4.3", default-features = false, features = ["default-rng", "ml-kem-768"], optional = true }
zeroize = { version = "1.8", default-features = false, optional = true }

# Async runtime (used by send_and_await_chunk_response)
tokio = { version = "1.35", features = ["sync", "time", "rt"] }
tokio = { version = "1.35", features = ["sync", "time", "rt"], optional = true }

# Serialization
serde = { version = "1", features = ["derive"] }
postcard = { version = "1.1.3", features = ["use-std"] }
rmp-serde = "1"
rmp-serde = { version = "1", optional = true }

# Byte utilities
bytes = { version = "1", features = ["serde"] }
hex = "0.4"
tiny-keccak = { version = "2", features = ["keccak"] }

# Verification-only ML-DSA backend for targets where the native Saorsa stack
# is unavailable (notably wasm32 browsers). The wrapper lives in this crate so
# downstream protocol consumers never select crypto implementations themselves.
fips204 = { version = "0.4.6", default-features = false, features = ["ml-dsa-65"], optional = true }

# Logging (optional — behind `logging` feature flag, mirroring ant-node)
tracing = { version = "0.1", optional = true }
Expand All @@ -52,11 +65,21 @@ serial_test = "3"
tokio = { version = "1.35", features = ["full"] }

[features]
# Default: logging enabled so downstream tests and dev builds emit diagnostics.
# Release consumers can strip logging with `--no-default-features`.
default = ["logging"]
# Preserve the existing native API by default. Portable consumers can compile
# the wire types and verification logic without Tokio, EVM, or Saorsa transport
# using `--no-default-features --features portable`.
default = ["native", "logging"]
# Native networking, EVM payment, and signing APIs.
native = ["dep:chacha20poly1305", "dep:evmlib", "dep:fips203", "dep:rmp-serde", "dep:saorsa-core", "dep:saorsa-pqc", "dep:tokio", "dep:zeroize"]
# Cross-platform verification backend used by browser/WASM clients.
portable = ["dep:chacha20poly1305", "dep:fips203", "dep:fips204", "dep:zeroize"]
# Enable `tracing` macros inside the crate.
logging = ["tracing"]
logging = ["dep:tracing"]

[target.'cfg(target_arch = "wasm32")'.dependencies]
# fips203's default RNG reaches getrandom 0.2. Enable its Web Crypto backend
# when ant-protocol is built directly for a browser target.
getrandom = { version = "0.2", features = ["js"] }

[lints.rust]
unsafe_code = "deny"
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ ML-DSA-65 signing scheme.
| Module | Contents |
|---|---|
| `chunk` | Chunk protocol messages (`ChunkMessage`, PUT/GET/Quote/MerkleCandidateQuote request+response), protocol constants (`CHUNK_PROTOCOL_ID`, `MAX_CHUNK_SIZE`, `MAX_WIRE_MESSAGE_SIZE`, `CLOSE_GROUP_SIZE`, `CLOSE_GROUP_MAJORITY`), `ProtocolError`, proof type tags |
| `crypto` | Cross-platform ML-DSA-65 verification with native and portable backends |
| `data_types` | Address helpers — `compute_address` (BLAKE3), `xor_distance`, `peer_id_to_xor_name` — and `DataChunk` |
| `chunk_protocol` | `send_and_await_chunk_response`: the subscribe/send/poll helper used to exchange chunk messages on a `P2PNode` |
| `payment` | On-wire payment artifacts: `PaymentProof`, `SingleNodePayment` (with `pay` and `verify`), and ML-DSA-65 signature verification for quotes and merkle candidates |
Expand Down Expand Up @@ -39,7 +40,20 @@ ant-protocol = "2"

| Feature | Default | Description |
|---|---|---|
| `logging` | yes | Re-exports the `tracing` macros. Disable with `--no-default-features` for minimum-overhead builds; the macros then expand to no-ops. |
| `native` | yes | Enables EVM payments, Saorsa transport/PQC re-exports, and the Tokio request helper. |
| `logging` | yes | Re-exports the `tracing` macros; without it the macros expand to no-ops. |
| `portable` | no | Enables verification-only FIPS-204 crypto without Tokio, EVM, Saorsa transport, or `saorsa-pqc`; intended for browser/WASM and other portable clients. |

For a browser build:

```toml
[dependencies]
ant-protocol = { version = "2", default-features = false, features = ["portable"] }
```

The portable surface includes chunk wire types, address helpers, storage
commitments, canonical commitment/quote signing bytes, quote hashes, the `u128`
pricing curve, and ML-DSA-65 verification.

## Compatibility

Expand Down
62 changes: 62 additions & 0 deletions src/crypto.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//! Cross-platform post-quantum verification used by protocol consumers.
//!
//! Native builds retain the Saorsa PQC facade. Portable builds use the same
//! underlying FIPS-204 primitive directly, behind this stable protocol API.
//! Downstream crates therefore share one verifier and do not need target-
//! specific cryptography branches.

#[cfg(all(not(feature = "native"), feature = "portable"))]
use fips204::{
ml_dsa_65,
traits::{SerDes as _, Verifier as _},
};
#[cfg(feature = "native")]
use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant};

/// Verify an ML-DSA-65 signature and return `false` for malformed input.
///
/// `context` is the FIPS-204 context string. Pass an empty slice for protocol
/// signatures that do not use domain separation.
#[must_use]
pub fn verify_ml_dsa_65(
public_key: &[u8],
signature: &[u8],
message: &[u8],
context: &[u8],
) -> bool {
verify_ml_dsa_65_inner(public_key, signature, message, context).unwrap_or(false)
}

#[cfg(feature = "native")]
fn verify_ml_dsa_65_inner(
public_key: &[u8],
signature: &[u8],
message: &[u8],
context: &[u8],
) -> Result<bool, String> {
let public_key = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, public_key)
.map_err(|error| error.to_string())?;
let signature = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, signature)
.map_err(|error| error.to_string())?;
ml_dsa_65()
.verify_with_context(&public_key, message, &signature, context)
.map_err(|error| error.to_string())
}

#[cfg(all(not(feature = "native"), feature = "portable"))]
fn verify_ml_dsa_65_inner(
public_key: &[u8],
signature: &[u8],
message: &[u8],
context: &[u8],
) -> Result<bool, String> {
let public_key: [u8; ml_dsa_65::PK_LEN] = public_key
.try_into()
.map_err(|_| "invalid ML-DSA-65 public key length".to_string())?;
let signature: [u8; ml_dsa_65::SIG_LEN] = signature
.try_into()
.map_err(|_| "invalid ML-DSA-65 signature length".to_string())?;
let public_key =
ml_dsa_65::PublicKey::try_from_bytes(public_key).map_err(ToString::to_string)?;
Ok(public_key.verify(message, &signature, context))
}
16 changes: 14 additions & 2 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
//!
//! This crate is the contract between `ant-client` and `ant-node`:
//! wire message types, serialization, content addressing, and the
//! pure-verification halves of the post-quantum signing scheme. Both
//! crates depend on `ant-protocol` and on nothing else from each other.
//! pure-verification halves of the post-quantum signing scheme. The portable
//! surface compiles for browsers without pulling in Tokio, EVM, or a native
//! transport runtime.
//!
//! ## Scope
//!
Expand Down Expand Up @@ -42,12 +43,17 @@
#![cfg_attr(not(feature = "logging"), allow(unused_variables, unused_assignments))]

pub mod chunk;
#[cfg(feature = "native")]
pub mod chunk_protocol;
#[cfg(any(feature = "native", feature = "portable"))]
pub mod crypto;
pub mod data_types;
#[cfg(feature = "native")]
pub mod devnet_manifest;
pub mod error;
pub mod logging;
pub mod payment;
pub mod web_rtc;

// =============================================================================
// Public surface re-exports
Expand All @@ -60,10 +66,13 @@ pub use chunk::{
CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE,
PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, XORNAME_LEN,
};
#[cfg(feature = "native")]
pub use chunk_protocol::send_and_await_chunk_response;
pub use data_types::{compute_address, peer_id_to_xor_name, xor_distance, ChunkStats, DataChunk};
#[cfg(feature = "native")]
pub use devnet_manifest::{DevnetEvmInfo, DevnetManifest};
pub use error::{Error, Result};
#[cfg(feature = "native")]
pub use payment::{
deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof,
serialize_single_node_proof, verify_merkle_candidate_signature, verify_quote_content,
Expand All @@ -90,6 +99,7 @@ pub use payment::{
/// Use `ant_protocol::evm::…` in downstream crates instead of a direct
/// `evmlib` dependency. This guarantees client and node always link the
/// same `evmlib` major version.
#[cfg(feature = "native")]
pub mod evm {
pub use evmlib::common::{Address, Amount, QuoteHash, TxHash, U256};
pub use evmlib::merkle_batch_payment::PoolCommitment;
Expand Down Expand Up @@ -130,6 +140,7 @@ pub mod evm {
///
/// Use `ant_protocol::transport::…` in downstream crates instead of a
/// direct `saorsa-core` dependency.
#[cfg(feature = "native")]
pub mod transport {
pub use saorsa_core::identity::{NodeIdentity, PeerId};
pub use saorsa_core::{
Expand All @@ -145,6 +156,7 @@ pub mod transport {
/// by the node and by this crate's own verification code.
/// - `ant_protocol::pqc::api::*` (higher-level `api::sig::*` module) —
/// used by the client's binary-update signature verification.
#[cfg(feature = "native")]
pub mod pqc {
/// Lower-level `pqc::*` API (types + `MlDsaOperations` trait).
pub mod ops {
Expand Down
33 changes: 17 additions & 16 deletions src/payment/commitment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
//! the client never builds or signs a commitment, it only verifies one.

use blake3::Hasher;
use saorsa_pqc::api::sig::{ml_dsa_65, MlDsaPublicKey, MlDsaSignature, MlDsaVariant};
use serde::{Deserialize, Serialize};

#[cfg(any(feature = "native", feature = "portable"))]
use crate::crypto::verify_ml_dsa_65;

/// Domain-separation tag for the commitment signature.
///
/// Signed payload is verified under this context tag.
Expand Down Expand Up @@ -100,7 +102,8 @@ pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> {
///
/// `sender_public_key` is length-prefixed and included so an adversary cannot
/// keep the body and re-sign under a different key.
fn commitment_signed_payload(
#[must_use]
pub fn storage_commitment_bytes_for_signing(
root: &[u8; 32],
key_count: u32,
sender_peer_id: &[u8; 32],
Expand All @@ -121,39 +124,37 @@ fn commitment_signed_payload(
/// callers that need it (the client, the node) check it separately so the same
/// function serves both the "trust the embedded key" and "bind to a peer" uses.
#[must_use]
#[cfg(any(feature = "native", feature = "portable"))]
pub fn verify_commitment_signature(c: &StorageCommitment) -> bool {
let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key)
else {
return false;
};
let payload = commitment_signed_payload(
let payload = storage_commitment_bytes_for_signing(
&c.root,
c.key_count,
&c.sender_peer_id,
&c.sender_public_key,
);
let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else {
return false;
};
ml_dsa_65()
.verify_with_context(&public_key, &payload, &sig, DOMAIN_COMMITMENT)
.unwrap_or(false)
verify_ml_dsa_65(
&c.sender_public_key,
&c.signature,
&payload,
DOMAIN_COMMITMENT,
)
}

#[cfg(test)]
#[cfg(all(test, feature = "native"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use saorsa_pqc::api::sig::ml_dsa_65;

/// Build a genuinely-signed commitment (fresh ML-DSA-65 keypair, signed over
/// the exact `commitment_signed_payload` under `DOMAIN_COMMITMENT`) — the same
/// the exact `storage_commitment_bytes_for_signing` under
/// `DOMAIN_COMMITMENT`) — the same
/// thing the node produces. Returns the commitment and the keypair's public
/// bytes so tamper tests can key-swap.
fn signed_commitment(root: [u8; 32], key_count: u32, peer_id: [u8; 32]) -> StorageCommitment {
let (pk, sk) = ml_dsa_65().generate_keypair().unwrap();
let pk_bytes = pk.to_bytes();
let payload = commitment_signed_payload(&root, key_count, &peer_id, &pk_bytes);
let payload = storage_commitment_bytes_for_signing(&root, key_count, &peer_id, &pk_bytes);
let sig = ml_dsa_65()
.sign_with_context(&sk, &payload, DOMAIN_COMMITMENT)
.unwrap();
Expand Down
16 changes: 15 additions & 1 deletion src/payment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,33 @@ pub mod commitment;
/// Quadratic storage-pricing formula shared by node and client (ADR-0004).
pub mod pricing;
/// Payment proof serialization and type tagging.
#[cfg(feature = "native")]
pub mod proof;
/// Portable quote signing bytes and EVM hash construction.
pub mod quote;
/// `SingleNodePayment` construction, on-chain payment, and verification.
#[cfg(feature = "native")]
pub mod single_node;
/// Pure ML-DSA-65 verification helpers for quotes and merkle candidates.
#[cfg(feature = "native")]
pub mod verify;

#[cfg(any(feature = "native", feature = "portable"))]
pub use commitment::verify_commitment_signature;
pub use commitment::{
commitment_hash, verify_commitment_signature, StorageCommitment, MAX_COMMITMENT_KEY_COUNT,
commitment_hash, storage_commitment_bytes_for_signing, StorageCommitment, DOMAIN_COMMITMENT,
DOMAIN_COMMITMENT_HASH, MAX_COMMITMENT_KEY_COUNT, MAX_COMMITMENT_SIDECAR_BYTES,
};
pub use pricing::calculate_price_wei;
#[cfg(feature = "native")]
pub use pricing::{calculate_price, derive_records_stored_from_price};
#[cfg(feature = "native")]
pub use proof::{
deserialize_merkle_proof, deserialize_proof, detect_proof_type, serialize_merkle_proof,
serialize_single_node_proof, PaymentProof, ProofType,
};
pub use quote::{payment_quote_bytes_for_signing, payment_quote_hash};
#[cfg(feature = "native")]
pub use single_node::{QuotePaymentInfo, SingleNodePayment};
#[cfg(feature = "native")]
pub use verify::{verify_merkle_candidate_signature, verify_quote_content, verify_quote_signature};
Loading