anza-cryptography-source/experimental/ed25519-pokos/src/lib.rs

464 lines
17 KiB
Rust
Raw Normal View History

//! Proof of Knowledge of Seed (POKOS) for Ed25519.
//!
//! This crate proves, in zero knowledge, that the prover knows a seed that was used
//! to derive an Ed25519 secret key — without revealing the seed or the key itself.
//! The proof covers the full SHA-512 derivation chain and is authenticated with an
//! Ed25519 signature from the derived key.
//!
//! # Protocol shape
//!
//! The public statement is `(commit_of_seed, hash_of_sk)`. The prover holds the
//! secret `seed` and proves the following chain in zero knowledge:
//!
//! ```text
//! commit_of_seed = SHA512(domain_commit || seed)
//! prf_output = SHA512(domain_derive || seed)
//! sk_seed = prf_output[0..32]
//! hash_of_sk = SHA512(domain_hash_sk || sk_seed)
//! ```
//!
//! All three SHA-512 evaluations are proved by a single Plonky3 STARK over the
//! SHA-512 compression function. The outer envelope is then signed by the Ed25519
//! key derived from `sk_seed`, binding the proof to the key owner without exposing
//! either the seed or the secret key.
//!
//! # Security note
//!
//! This is a *temporary* construction. It does not prove `sk -> pk` inside the
//! circuit; that link is enforced externally by the Ed25519 signature. A fully
//! sound end-to-end statement would expose `pk` as a public input and prove the
//! complete chain `seed -> sk -> pk` in circuit. That stronger version is deferred
//! because in-circuit Ed25519 group operations currently exceed practical circuit
//! sizes. See [`readme.md`](../readme.md) for the full design discussion.
//!
//! # Quick start
//!
//! ```rust,ignore
//! use ed25519_pokos::{Seed, gen_pokos, verify_pokos, serialize_proof, deserialize_proof};
//!
//! let seed: Seed = [7_u8; 32];
//!
//! // Prover side
//! let proof = gen_pokos(seed)?;
//! let bytes = serialize_proof(&proof);
//!
//! // Verifier side
//! let proof = deserialize_proof(&bytes)?;
//! verify_pokos(&proof)?;
//! # Ok::<(), String>(())
//! ```
use curve25519::ed_sigs::{Signature, VerificationKeyBytes};
use sha2::{Digest, Sha512};
// ─── Byte-length constants ────────────────────────────────────────────────────
/// Byte length of a SHA-512 digest (64 bytes = 512 bits).
pub const SHA512_DIGEST_LEN: usize = 64;
/// Byte length of an Ed25519 seed (32 bytes).
pub const ED25519_SEED_LEN: usize = 32;
/// Byte length of an Ed25519 compressed public key (32 bytes).
pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
/// Byte length of an Ed25519 signature (64 bytes).
pub const ED25519_SIGNATURE_LEN: usize = 64;
// ─── SHA-512 block-layout constants ──────────────────────────────────────────
/// Byte length of a domain-separation prefix within a fixed seed-chain block.
///
/// Each SHA-512 block in the seed chain is structured as
/// `domain[0..32] || payload[0..32] || SHA-512 padding`.
/// This constant names the domain portion's length.
pub const DOMAIN_LEN: usize = 32;
/// Total byte length of the message before padding (`domain || payload`).
///
/// Equals `DOMAIN_LEN + ED25519_SEED_LEN` = 64 bytes.
pub const FIXED_MESSAGE_LEN: usize = DOMAIN_LEN + ED25519_SEED_LEN;
/// Number of 64-bit words in a single 128-byte SHA-512 input block (= 16).
pub const FIXED_BLOCK_WORDS: usize = 16;
/// Word index within the block where the 32-byte payload begins.
///
/// The payload (seed or sk_seed) is stored as four 64-bit words at positions
/// 4..7 of the block word array (bytes 32..63).
pub const PAYLOAD_WORD_START: usize = 4;
/// Number of 64-bit words occupied by the 32-byte payload (= 4).
pub const PAYLOAD_WORD_COUNT: usize = 4;
/// Word index of the SHA-512 message-length field within the fixed block.
///
/// SHA-512 padding places the 64-bit big-endian bit-length in the last word
/// of the 128-byte block (word index 15, bytes 120..127).
pub const LENGTH_WORD_INDEX: usize = 15;
// ─── Domain-separation labels ─────────────────────────────────────────────────
/// Domain prefix used when hashing the seed to produce `commit_of_seed`.
const COMMIT_OF_SEED_DOMAIN: [u8; DOMAIN_LEN] = domain32(b"ed25519-pokos/commit/v1");
/// Domain prefix used in the PRF step that derives `sk_seed` from the seed.
const DERIVE_SK_DOMAIN: [u8; DOMAIN_LEN] = domain32(b"ed25519-pokos/derive-sk/v1");
/// Domain prefix used when hashing `sk_seed` to produce `hash_of_sk`.
const HASH_OF_SK_DOMAIN: [u8; DOMAIN_LEN] = domain32(b"ed25519-pokos/hash-sk/v1");
/// Domain prefix for the authentication transcript signed by the derived key.
const AUTH_TRANSCRIPT_DOMAIN: &[u8] = b"ed25519-pokos/auth-transcript/v1";
/// Magic bytes at the start of a serialized [`SeedChainProofEnvelope`].
///
/// "EPKOS001" — Ed25519 POKOS format version 1.
const PROOF_FORMAT_MAGIC: &[u8; 8] = b"EPKOS001";
pub mod private_seed_chain;
pub mod prover;
mod sha512;
pub mod verifier;
// ─── Type aliases ─────────────────────────────────────────────────────────────
/// A 32-byte Ed25519 seed.
pub type Seed = [u8; ED25519_SEED_LEN];
/// A 64-byte SHA-512 digest.
pub type DigestBytes = [u8; SHA512_DIGEST_LEN];
/// A SHA-512 digest of a seed, used as the public commitment.
///
/// Computed as `SHA512(COMMIT_OF_SEED_DOMAIN || seed)`.
pub type CommitOfSeedDigest = DigestBytes;
/// A SHA-512 digest of a derived Ed25519 secret key seed.
///
/// Computed as `SHA512(HASH_OF_SK_DOMAIN || sk_seed)`.
pub type HashOfSkDigest = DigestBytes;
// ─── Public types ─────────────────────────────────────────────────────────────
/// The public statement proved by a [`SeedChainProofEnvelope`].
///
/// Both fields are SHA-512 digests that are computable from the private `seed`
/// alone. Together they form the statement: "I know a seed such that
/// `commit_of_seed` and `hash_of_sk` were computed from it via the prescribed
/// derivation chain."
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct SeedChainStatement {
/// `SHA512(COMMIT_OF_SEED_DOMAIN || seed)` — public commitment to the seed.
pub commit_of_seed: CommitOfSeedDigest,
/// `SHA512(HASH_OF_SK_DOMAIN || sk_seed)` — public commitment to the derived key.
pub hash_of_sk: HashOfSkDigest,
}
/// All secret material derived from a seed during proof generation.
///
/// This struct is produced by [`derive_secret_key_material`] and is the
/// prover's private witness. Fields are intentionally not `Copy` or
/// `zeroize`d here — callers are responsible for handling them with
/// appropriate care.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DerivedSecretKeyMaterial {
/// Full output of `SHA512(DERIVE_SK_DOMAIN || seed)`. The first 32 bytes
/// become `sk_seed`; the remaining 32 bytes are currently unused.
pub prf_output: DigestBytes,
/// First 32 bytes of `prf_output`, used as the Ed25519 signing seed.
pub sk_seed: Seed,
/// `SHA512(HASH_OF_SK_DOMAIN || sk_seed)` — the `hash_of_sk` public value.
pub hash_of_sk: DigestBytes,
/// Ed25519 verification key (public key) derived from `sk_seed`.
pub authentication_key: VerificationKeyBytes,
}
/// A sealed (serialized) Plonky3 STARK proof for the three-segment SHA-512 chain.
///
/// The `sealed_proof` bytes are the full STARK proof bundle produced by the
/// Plonky3 prover. The format is internal to this crate; use
/// [`serialize_proof`] / [`deserialize_proof`] to transport the outer envelope.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Sha512ProofBundle {
/// Raw bytes of the serialized Plonky3 STARK proof.
pub sealed_proof: Vec<u8>,
}
/// A complete, self-contained proof envelope for the POKOS statement.
///
/// This type bundles all three components a verifier needs:
/// 1. The public statement (`commit_of_seed`, `hash_of_sk`).
/// 2. The STARK proof that the statement was honestly derived from some hidden seed.
/// 3. An Ed25519 signature from the derived key, binding the proof to the key owner.
///
/// Use [`gen_pokos`] to produce an envelope and [`verify_pokos`] to check it.
/// For wire transport, see [`serialize_proof`] and [`deserialize_proof`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SeedChainProofEnvelope {
/// The public outputs of the seed-chain derivation.
pub statement: SeedChainStatement,
/// The STARK proof for the three SHA-512 steps in the derivation chain.
pub sha512_proof: Sha512ProofBundle,
/// Ed25519 verification key corresponding to the signing key derived from `sk_seed`.
pub authentication_key: VerificationKeyBytes,
/// Ed25519 signature over the `authentication_transcript` of `statement`,
/// produced by the signing key derived from `sk_seed`.
pub authentication_signature: Signature,
}
/// Errors returned by [`verify_pokos`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum VerifyError {
/// The STARK proof for the SHA-512 seed-chain is invalid or does not match
/// the public statement.
InvalidSkDerivationProof,
/// The Ed25519 signature over the proof transcript is invalid. Either the
/// signature bytes are malformed or the signature was made with a different key.
AuthenticationSignatureInvalid,
}
/// Errors returned by [`deserialize_proof`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DeserializeError {
/// The byte slice ends before all expected fields have been read.
Truncated,
/// The leading magic bytes do not match `PROOF_FORMAT_MAGIC`.
InvalidMagic,
/// The Ed25519 signature bytes are structurally invalid.
InvalidSignature,
/// The byte slice contains extra data after the last expected field.
TrailingBytes,
}
pub use prover::{commit_of_seed, derive_secret_key_material, gen_pokos, statement_from_seed};
pub use verifier::{deserialize_proof, serialize_proof, verify_pokos};
// ─── Crate-internal helpers ───────────────────────────────────────────────────
/// Computes a raw SHA-512 digest of `message`.
fn sha512(message: &[u8]) -> DigestBytes {
let digest = Sha512::digest(message);
let mut bytes = [0_u8; SHA512_DIGEST_LEN];
bytes.copy_from_slice(&digest);
bytes
}
/// Pads a byte-string label to a 32-byte domain-separation prefix.
///
/// Copies `label` into the first `label.len()` bytes of a zero-filled
/// `[u8; 32]`. Panics at compile time if `label.len() > 32`.
const fn domain32(label: &[u8]) -> [u8; DOMAIN_LEN] {
let mut out = [0_u8; DOMAIN_LEN];
let mut i = 0;
while i < label.len() {
out[i] = label[i];
i += 1;
}
out
}
/// Builds the single padded 128-byte SHA-512 block for a fixed-layout seed-chain segment.
///
/// The block layout is:
/// ```text
/// [domain: 32 bytes] [payload: 32 bytes] [0x80] [zeros] [bit_length: 8 bytes]
/// ```
/// `payload` must be exactly [`ED25519_SEED_LEN`] (32) bytes.
///
/// The resulting block is a valid, fully padded SHA-512 message that fits in
/// exactly one block, which is the structural invariant the AIR relies on.
pub(crate) fn fixed_single_block(domain: &[u8; DOMAIN_LEN], payload: &[u8]) -> [u8; 128] {
assert_eq!(
payload.len(),
ED25519_SEED_LEN,
"fixed seed-chain block expects a 32-byte payload"
);
let mut block = [0_u8; 128];
block[..DOMAIN_LEN].copy_from_slice(domain);
block[DOMAIN_LEN..FIXED_MESSAGE_LEN].copy_from_slice(payload);
block[FIXED_MESSAGE_LEN] = 0x80;
block[120..128].copy_from_slice(&((FIXED_MESSAGE_LEN as u64) * 8).to_be_bytes());
block
}
/// Parses a 128-byte SHA-512 block into 16 big-endian 64-bit words.
pub(crate) fn block_words(block: [u8; 128]) -> [u64; FIXED_BLOCK_WORDS] {
let mut words = [0_u64; FIXED_BLOCK_WORDS];
for (i, chunk) in block.chunks_exact(8).enumerate() {
words[i] = u64::from_be_bytes(chunk.try_into().expect("chunk size"));
}
words
}
/// Concatenates a 32-byte domain prefix and an arbitrary payload into one message.
///
/// Used by the prover to form the input to each domain-separated SHA-512 call
/// before passing it to the standard `sha2` crate (outside the circuit).
pub(crate) fn encode_domain_message(domain: &[u8; DOMAIN_LEN], payload: &[u8]) -> Vec<u8> {
let mut message = Vec::with_capacity(FIXED_MESSAGE_LEN);
message.extend_from_slice(domain);
message.extend_from_slice(payload);
message
}
/// Builds the transcript that the authentication signature covers.
///
/// Layout:
/// ```text
/// AUTH_TRANSCRIPT_DOMAIN || 0x00 || commit_of_seed[64] || hash_of_sk[64]
/// ```
/// The `0x00` byte acts as a length delimiter between the domain string
/// and the two fixed-length fields.
pub(crate) fn authentication_transcript(statement: SeedChainStatement) -> Vec<u8> {
let mut transcript =
Vec::with_capacity(AUTH_TRANSCRIPT_DOMAIN.len() + 1 + SHA512_DIGEST_LEN * 2);
transcript.extend_from_slice(AUTH_TRANSCRIPT_DOMAIN);
transcript.push(0);
transcript.extend_from_slice(&statement.commit_of_seed);
transcript.extend_from_slice(&statement.hash_of_sk);
transcript
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_seed() -> Seed {
[7_u8; ED25519_SEED_LEN]
}
#[test]
fn round_trip_proof_verifies() {
let proof = gen_pokos(sample_seed()).unwrap();
assert_eq!(verify_pokos(&proof), Ok(()));
}
#[test]
fn proof_serialization_round_trip() {
let proof = gen_pokos(sample_seed()).unwrap();
let encoded = serialize_proof(&proof);
let decoded = deserialize_proof(&encoded).unwrap();
assert_eq!(decoded, proof);
assert_eq!(verify_pokos(&decoded), Ok(()));
}
#[test]
fn rejects_wrong_commitment() {
let mut proof = gen_pokos(sample_seed()).unwrap();
proof.statement.commit_of_seed[0] ^= 1;
assert_eq!(
verify_pokos(&proof),
Err(VerifyError::InvalidSkDerivationProof)
);
}
#[test]
fn rejects_wrong_authentication_key() {
let mut proof = gen_pokos(sample_seed()).unwrap();
let other_seed = [9_u8; ED25519_SEED_LEN];
proof.authentication_key = derive_secret_key_material(other_seed).authentication_key;
assert_eq!(
verify_pokos(&proof),
Err(VerifyError::AuthenticationSignatureInvalid)
);
}
#[test]
fn rejects_tampered_sha512_proof() {
let mut proof = gen_pokos(sample_seed()).unwrap();
proof.sha512_proof.sealed_proof[0] ^= 1;
assert_eq!(
verify_pokos(&proof),
Err(VerifyError::InvalidSkDerivationProof)
);
}
#[test]
fn active_path_depends_only_on_sealed_proof() {
let proof = gen_pokos(sample_seed()).unwrap();
let encoded = serialize_proof(&proof);
let decoded = deserialize_proof(&encoded).unwrap();
assert_eq!(verify_pokos(&decoded), Ok(()));
}
#[test]
fn rejects_broken_signature() {
let mut proof = gen_pokos(sample_seed()).unwrap();
let mut sig_bytes = proof.authentication_signature.to_bytes();
sig_bytes[0] ^= 1;
proof.authentication_signature = Signature::from_slice(&sig_bytes).unwrap();
assert_eq!(
verify_pokos(&proof),
Err(VerifyError::AuthenticationSignatureInvalid)
);
}
#[test]
fn rejects_invalid_serialized_magic() {
let proof = gen_pokos(sample_seed()).unwrap();
let mut encoded = serialize_proof(&proof);
encoded[0] ^= 1;
assert_eq!(
deserialize_proof(&encoded),
Err(DeserializeError::InvalidMagic)
);
}
#[test]
fn rejects_trailing_bytes() {
let proof = gen_pokos(sample_seed()).unwrap();
let mut encoded = serialize_proof(&proof);
encoded.push(0);
assert_eq!(
deserialize_proof(&encoded),
Err(DeserializeError::TrailingBytes)
);
}
#[test]
fn rejects_overflowing_sha512_bundle_length() {
let mut encoded = Vec::new();
encoded.extend_from_slice(PROOF_FORMAT_MAGIC);
encoded.extend_from_slice(&[0_u8; SHA512_DIGEST_LEN]);
encoded.extend_from_slice(&[0_u8; SHA512_DIGEST_LEN]);
encoded.extend_from_slice(&u64::MAX.to_be_bytes());
assert_eq!(
deserialize_proof(&encoded),
Err(DeserializeError::Truncated)
);
}
#[test]
fn fixed_block_layout_is_stable() {
let seed = sample_seed();
let block = fixed_single_block(&COMMIT_OF_SEED_DOMAIN, &seed);
let words = block_words(block);
let mut expected_word = [0_u8; 8];
expected_word.copy_from_slice(&COMMIT_OF_SEED_DOMAIN[..8]);
assert_eq!(words[0], u64::from_be_bytes(expected_word));
let payload_words = &words[PAYLOAD_WORD_START..PAYLOAD_WORD_START + PAYLOAD_WORD_COUNT];
for (i, word) in payload_words.iter().enumerate() {
let mut expected = [0_u8; 8];
expected.copy_from_slice(&seed[i * 8..(i + 1) * 8]);
assert_eq!(*word, u64::from_be_bytes(expected));
}
assert_eq!(words[8], 0x8000_0000_0000_0000);
assert_eq!(words[LENGTH_WORD_INDEX], (FIXED_MESSAGE_LEN as u64) * 8);
}
}