From 0a1b8c06ef7a89a0d0b4c644c359c69db3a3d9dd Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:38:30 +0530 Subject: [PATCH 1/3] feat(keystem): scaffold crate, upstream some functionality to sealring --- crates/keystem/Cargo.toml | 83 +++++ crates/keystem/LICENSE-APACHE | 1 + crates/keystem/LICENSE-MIT | 1 + crates/keystem/README.md | 182 ++++++++++ crates/keystem/benches/spend.rs | 138 +++++++ crates/keystem/benches/viewing.rs | 93 +++++ crates/keystem/examples/wallet_keys.rs | 124 +++++++ crates/keystem/hack.toml | 4 + crates/keystem/src/adapters.rs | 9 + crates/keystem/src/adapters/k256.rs | 31 ++ crates/keystem/src/adapters/x25519.rs | 62 ++++ crates/keystem/src/authority.rs | 47 +++ crates/keystem/src/curves.rs | 5 + crates/keystem/src/curves/bn254.rs | 61 ++++ crates/keystem/src/encoding.rs | 213 +++++++++++ crates/keystem/src/error.rs | 56 +++ crates/keystem/src/family.rs | 15 + crates/keystem/src/hex.rs | 10 + crates/keystem/src/kem_ops.rs | 24 ++ crates/keystem/src/lib.rs | 86 +++++ crates/keystem/src/spend.rs | 206 +++++++++++ crates/keystem/src/test_util.rs | 110 ++++++ crates/keystem/src/viewing.rs | 163 +++++++++ crates/keystem/tests/golden.rs | 32 ++ crates/keystem/tests/golden/poseidon1.hex | 1 + crates/keystem/tests/proptest_canonical.rs | 28 ++ crates/keystem/tests/second_field.rs | 121 +++++++ crates/keystem/tests/spend.rs | 400 +++++++++++++++++++++ crates/keystem/tests/viewing_k256.rs | 123 +++++++ crates/keystem/tests/viewing_x25519.rs | 181 ++++++++++ crates/sealring/Cargo.toml | 2 +- crates/sealring/README.md | 2 +- crates/sealring/src/adapters/grumpkin.rs | 28 +- crates/sealring/src/adapters/k256.rs | 9 +- crates/sealring/src/adapters/x25519.rs | 12 +- crates/sealring/src/kem.rs | 9 + crates/sealring/src/test_util.rs | 42 ++- crates/sealring/tests/adapter_grumpkin.rs | 2 + crates/sealring/tests/adapter_k256.rs | 2 + crates/sealring/tests/adapter_x25519.rs | 2 + crates/sealring/tests/scan_alloc.rs | 7 +- 41 files changed, 2689 insertions(+), 38 deletions(-) create mode 100644 crates/keystem/Cargo.toml create mode 120000 crates/keystem/LICENSE-APACHE create mode 120000 crates/keystem/LICENSE-MIT create mode 100644 crates/keystem/README.md create mode 100644 crates/keystem/benches/spend.rs create mode 100644 crates/keystem/benches/viewing.rs create mode 100644 crates/keystem/examples/wallet_keys.rs create mode 100644 crates/keystem/hack.toml create mode 100644 crates/keystem/src/adapters.rs create mode 100644 crates/keystem/src/adapters/k256.rs create mode 100644 crates/keystem/src/adapters/x25519.rs create mode 100644 crates/keystem/src/authority.rs create mode 100644 crates/keystem/src/curves.rs create mode 100644 crates/keystem/src/curves/bn254.rs create mode 100644 crates/keystem/src/encoding.rs create mode 100644 crates/keystem/src/error.rs create mode 100644 crates/keystem/src/family.rs create mode 100644 crates/keystem/src/hex.rs create mode 100644 crates/keystem/src/kem_ops.rs create mode 100644 crates/keystem/src/lib.rs create mode 100644 crates/keystem/src/spend.rs create mode 100644 crates/keystem/src/test_util.rs create mode 100644 crates/keystem/src/viewing.rs create mode 100644 crates/keystem/tests/golden.rs create mode 100644 crates/keystem/tests/golden/poseidon1.hex create mode 100644 crates/keystem/tests/proptest_canonical.rs create mode 100644 crates/keystem/tests/second_field.rs create mode 100644 crates/keystem/tests/spend.rs create mode 100644 crates/keystem/tests/viewing_k256.rs create mode 100644 crates/keystem/tests/viewing_x25519.rs diff --git a/crates/keystem/Cargo.toml b/crates/keystem/Cargo.toml new file mode 100644 index 0000000..487eb38 --- /dev/null +++ b/crates/keystem/Cargo.toml @@ -0,0 +1,83 @@ +[package] +name = "keystem" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +readme = "README.md" +description = "shielded key material for selective disclosure: spend authority and view authority as distinct key types" +include = [ + "src/**/*.rs", + "benches/**/*.rs", + "examples/**/*.rs", + "tests/**/*.rs", + "tests/golden/*.hex", + "README.md", + "LICENSE-MIT", + "LICENSE-APACHE", +] + +[dependencies] +rand_core = { version = "0.10", default-features = false, optional = true } +zeroize = { version = "1.8", default-features = false, features = ["derive"], optional = true } +ark-ff = { version = "0.5", default-features = false, optional = true } +ark-bn254 = { version = "0.5", default-features = false, features = ["scalar_field"], optional = true } +light-poseidon = { version = "0.4", optional = true } +sealring = { version = "0.4", path = "../sealring", default-features = false, optional = true } +k256 = { version = "0.14", default-features = false, features = ["ecdh", "arithmetic"], optional = true } +x25519-dalek = { version = "3.0", default-features = false, features = ["static_secrets", "zeroize"], optional = true } +serde = { workspace = true, features = ["alloc"], optional = true } +include-utils = { workspace = true, optional = true } + +[dev-dependencies] +ark-bls12-381 = { version = "0.5", default-features = false, features = ["curve"] } +ark-serialize = { version = "0.5", default-features = false } +criterion = { workspace = true, features = ["cargo_bench_support", "html_reports", "plotters"] } +proptest = { workspace = true, features = ["std", "bit-set", "fork", "timeout"] } +rand_chacha = "0.10" +serde_json = "1" + +[features] +default = ["std", "poseidon"] +std = [ + "ark-bn254?/std", + "ark-ff?/std", + "k256?/std", + "sealring?/std", + "serde?/std", + "zeroize?/std", +] +spend = ["dep:ark-ff", "dep:rand_core", "dep:zeroize"] +bn254 = ["dep:ark-bn254", "spend"] +poseidon = ["bn254", "dep:light-poseidon", "std"] +viewing = ["dep:rand_core", "dep:sealring", "dep:zeroize"] +k256 = ["dep:k256", "sealring/k256", "viewing"] +x25519 = ["dep:x25519-dalek", "sealring/x25519", "viewing"] +serde = ["dep:serde"] +expose-secret-serde = ["serde"] +test-helpers = [] +docs = ["dep:include-utils"] + +# the lib carries no #[bench] functions, and cargo would otherwise run it as a +# bench target under the libtest harness, which rejects criterion's flags. +[lib] +bench = false + +[[bench]] +name = "spend" +harness = false +required-features = ["poseidon"] + +[[bench]] +name = "viewing" +harness = false +required-features = ["k256", "std", "x25519"] + +[[example]] +name = "wallet_keys" +required-features = ["poseidon", "serde", "test-helpers", "x25519"] + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] diff --git a/crates/keystem/LICENSE-APACHE b/crates/keystem/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/keystem/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/keystem/LICENSE-MIT b/crates/keystem/LICENSE-MIT new file mode 120000 index 0000000..b2cfbdc --- /dev/null +++ b/crates/keystem/LICENSE-MIT @@ -0,0 +1 @@ +../../LICENSE-MIT \ No newline at end of file diff --git a/crates/keystem/README.md b/crates/keystem/README.md new file mode 100644 index 0000000..56606b2 --- /dev/null +++ b/crates/keystem/README.md @@ -0,0 +1,182 @@ +# keystem + +Shielded key material for selective disclosure: spend authority and view authority as distinct key types, with hygiene enforced by the type system instead of by convention. + + + +`keystem` packages a key schedule that various proof-of-concept crates reimplement: a spending key that authorizes spends, an owner pubkey derived from it that appears inside note commitments, and viewing keypairs that grant read access without spend authority. The derivation rule is the same everywhere it appears: + +```text +owner_pubkey = Poseidon1(spending_key) +``` + +That uniformity is what makes this packaging rather than design. The construction is the Sapling pattern, spend authority separated from view authority, the public spend credential a one way image of the secret. + +### how it works + +`keystem` ships one generator, rejection sampling, and one hygiene policy: every secret zeroizes on drop, `Debug` on a secret prints `REDACTED`, and serde on a secret is an explicit `expose-secret-serde` opt-in rather than a default. + +- **spend**: a `SpendingKey` is canonical by construction, decoded or drawn only through checked paths, and never through a raw byte cast. `derive_owner_pubkey` runs the shared Poseidon1 permutation and returns the public credential. +- **custody**: `SpendAuthority` distinguishes between the key and whatever holds it. The in-memory `SpendingKey` answers both `owner_pubkey` and `scalar`; a non-exporting custodian answers the pubkey and returns `NotExportable` from `scalar`. +- **view**: a `ViewingKey` wraps a KEM keypair from a curve family `K`, tagged with a disclosure channel `F`. `Incoming`, `Compliance`, and `Audit` are distinct ZSTs, so a value built for one channel cannot be handed to code expecting another. + + + +```mermaid +flowchart LR + SK["SpendingKey<F>
rejection sampled"] -->|Poseidon1| OP["OwnerPubkey<F>
note commitment credential"] + SK -.->|scalar via SpendAuthority| WIT["circuit witness"] + CUST["non-exporting custody
enrolled(owner_pubkey)"] -->|owner_pubkey| OP + CUST -.->|scalar| NX["NotExportable"] + + subgraph viewing + VKI["ViewingKey<K, Incoming>"] --> VPI["ViewingPubkey<K, Incoming>"] + VKC["ViewingKey<K, Compliance>"] --> VPC["ViewingPubkey<K, Compliance>"] + end + VPI -.->|distinct type, will not unify with| VKC +``` + +The viewing side is generic over sealring's `Kem` trait rather than over a curve directly. Consumers who need only spend authority take `keystem` with the `viewing` feature off and no sealring in their dependency tree. + +Poseidon's permutation state is not zeroized, so a spending key stays recoverable from process memory until the next derivation on that thread. + + + +## rationale + +Three crates carry the spending-key and owner-pubkey pair with the same derivation rule and near-identical code. Two more carry the encryption-key side on x25519 instead of k256, and one of those derives its spending key in a way that destroys most of its entropy. Fixing each crate in place would fix the bug in one place and leave the same construction free to drift again in the next PoC; packaging the schedule once removes the chance to reintroduce a truncated spending key or a serde derive on a secret. + +Rejection sampling is the one randomness policy: a candidate fills the modulus byte width, masks its top byte down to `MODULUS_BIT_SIZE`, and re-draws on rejection. That mask is what keeps acceptance at or above one half for every field inside the width bound, rather than collapsing toward zero for a field whose modulus sits far below the 256-bit ceiling. BN254 accepts about 76% of draws. + +Canonical by construction runs through every type that holds a field element: a `SpendingKey`, an `OwnerPubkey`, or a `SecretScalar` that exists encodes a valid field element, full stop. The check applies to an imported counterparty key exactly as it applies to a locally derived one, which is what keeps `Eq` and `Hash` on `OwnerPubkey` honest, two byte strings that decode to the same field element can only ever be the same value. The check itself decides on the encoding as it arrives: reject a byte set above the field's limb width, then reject an integer at or above the modulus. Neither rejection builds a field element, because every caller keeps the bytes. + +## Design decisions + +- **one generator, one hygiene policy.** The 216-bit byte-zeroing generator and the 64-bit truncating generator both retire behind rejection sampling. Every secret zeroizes on drop and redacts `Debug`; serde on a secret is the `expose-secret-serde` opt-in, so persisting a spending key is a visible line in a consumer's manifest. +- **`SpendAuthority` is the custody boundary: operations out, key material stays put.** Sync by design, matching PKCS#11 and card stacks. `SpendingKey` is the in-memory impl with `Error = Infallible`; a non-exporting custodian returns `NotExportable` from `scalar()`. +- **the `viewing` feature depends on sealring** sealring already owns the `Kem` abstraction and its curve adapters; it borrows the secret key it is handed and assigns storage hygiene to the consumer holding it. +- **`ViewingKey` carries a zero-sized family marker** so distinct disclosure channels are distinct types the compiler keeps apart. Mixing a compliance-viewing branch with an ordinary incoming-viewing branch is the correlation risk this rule exists to prevent. The marker is `PhantomData F>`, so auto traits and variance ignore it and the marker type itself needs no bounds; a consumer adds a channel by defining its own empty type. A `compile_fail` doctest in `src/adapters/x25519.rs` proves two channels do not unify. +- **shipped families are k256 and x25519** + +## Features + +| feature | pulls in | notes | +|---|---|---| +| `default` | `std` and `poseidon` | spend authority over BN254, ready to use out of the box | +| `spend` | ark-ff | the generic core: key types, the custody trait, randomness, canonical import; no_std capable | +| `bn254` | ark-bn254 | `curves::bn254` aliases the generic types over `Fr`, so consumer code keeps its current spelling | +| `poseidon` | light-poseidon | `derive_owner_pubkey` and the `SpendAuthority` impl for the BN254 key; implies `std` while light-poseidon does | +| `viewing` | sealring | viewing types, family markers, KEM key traits | +| `k256`, `x25519` | sealring's matching adapter plus the curve crate | the shipped adapter impls | +| `serde` | serde | serde on `OwnerPubkey` and `ViewingPubkey` | +| `expose-secret-serde` | | serde on `SpendingKey`; implies `serde` | +| `test-helpers` | | `SealedCustody` and the adapter conformance suite | +| `std` | | forwards std to dependencies | + +`default` carries `poseidon`, unlike sealring's `default = []`: BN254 plus Poseidon1 is the schedule every code-proven consumer already runs, so shipping it by default matches how the crate is actually used. Two builds are the intended minimal shapes: `default` for spend authority alone, and `default-features = false, features = ["viewing", "k256"]` for the encryption side alone. + + + + + +## Usage + +```rust,ignore +use keystem::{ + SpendAuthority, + ViewingKey, + curves::bn254::SpendingKey, + family::Incoming, + test_util::SealedCustody, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use sealring::X25519; + +let mut rng = ChaCha20Rng::seed_from_u64(42); + +// spend authority: rejection sampled, canonical by construction. +let spending_key = SpendingKey::random(&mut rng); +let owner_pubkey = spending_key.derive_owner_pubkey(); + +// a non-exporting custodian answers the same pubkey and refuses the scalar. +let custody = SealedCustody::enrolled(owner_pubkey); +assert!(custody.scalar().is_err()); + +// view authority: one channel, kept distinct from any other by its type. +let incoming: ViewingKey = ViewingKey::random(&mut rng); +let viewing_pubkey = incoming.derive_pubkey(); +``` + +The snippet above is illustrative; `examples/wallet_keys.rs` is the compiled version. It runs this end to end and adds the parts that need more than a few lines: it prints `SpendingKey`'s redacted `Debug`, builds both an `Incoming` and a `Compliance` viewing channel from the same curve to show the two are different types, seals a note to the incoming channel with `sealring::seal` and opens it with `sealring::open`, and round-trips `OwnerPubkey` and `ViewingPubkey` through `serde_json`, the two credentials a wallet actually publishes. + +```sh +cargo run -p keystem --example wallet_keys --features poseidon,serde,x25519,test-helpers +``` + + + +## Development + +### Prerequisites + +- [cargo-hack](https://github.com/taiki-e/cargo-hack?tab=readme-ov-file#installation): to test all combinations of feature flags +- [cargo-nextest](https://nexte.st/): rust test runner + +### Check + +```sh +cargo hack check -p keystem $(python3 ../../.github/scripts/hack-flags.py .) +``` + +`hack.toml` carries `at-least-one-of = ["spend", "viewing"]`, because a build enabling neither compiles to an empty crate and `lib.rs` rejects it with a `compile_error!` naming the two features. The bare `--feature-powerset` would generate that combination and fail on it. + +### Clippy + +```sh +cargo hack clippy -p keystem $(python3 ../../.github/scripts/hack-flags.py .) -- -D warnings +``` + +### Format + +```sh +cargo +nightly fmt -p keystem +``` + +### Testing + +```sh +cargo hack nextest run -p keystem $(python3 ../../.github/scripts/hack-flags.py .) +cargo test --doc -p keystem --all-features +``` + +### Benchmarks + +Measured on aarch64 Linux, Rust 1.95.0, release profile with fat LTO. Absolute numbers will move with your machine; the ratios are the point. + +| operation | k256 | x25519 | +|---|---|---| +| `ViewingKey::random` | 11.9 us | 7.72 us | +| `from_sk_bytes` | 12.0 us | 7.58 us | +| `derive_pubkey` | 3.25 ns | 1.52 ns | +| `to_sk_bytes` | 5.59 ns | 5.70 ns | +| `ViewingPubkey::to_bytes` | 33.7 ns | 1.49 ns | +| `ViewingPubkey::from_bytes` | 3.37 us | 2.40 ns | + +| spend operation | time | +|---|---| +| `derive_owner_pubkey` | 16.6 us | +| `SpendingKey::random` | 90.0 ns | +| `from_canonical_bytes`, accepted | 11.7 ns | +| `from_canonical_bytes`, rejected | 3.07 ns | +| `OwnerPubkey::to_field` | 14.1 ns | +| `OwnerPubkey::from_field` | 7.83 ns | +| `SpendingKey::scalar` | 9.10 ns | + +`benches/spend.rs` (feature `poseidon`) covers key generation and Poseidon1 derivation, and `benches/viewing.rs` (features `k256`, `std`, `x25519`) covers viewing keypair generation and pubkey derivation across both shipped curve families. + +```sh +cargo bench -p keystem -- --list +``` + +See the [Cargo.toml entry](Cargo.toml) for the exact feature flags each bench target requires. diff --git a/crates/keystem/benches/spend.rs b/crates/keystem/benches/spend.rs new file mode 100644 index 0000000..a59d0fc --- /dev/null +++ b/crates/keystem/benches/spend.rs @@ -0,0 +1,138 @@ +use std::{ + collections::HashSet, + hint::black_box, +}; + +use criterion::{ + BenchmarkGroup, + Criterion, + Throughput, + criterion_group, + criterion_main, + measurement::WallTime, +}; +use keystem::curves::bn254::{ + OwnerPubkey, + SpendingKey, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +/// Seed for the rng behind the one fixed key most benchmarks derive from. +const FIXED_KEY_SEED: u64 = 0x5eed_5eed_5eed_5eed; + +/// Seed for the rng hoisted into the `random` benchmark's timed loop. +const RANDOM_SEED: u64 = 0x1234_5678_9abc_def0; + +/// Seed for the rng that draws the distinct keys in the batch benchmark. +const BATCH_SEED: u64 = 0x8888_7777_6666_5555; + +/// Keys derived in one timed iteration of the batch benchmark, enough to +/// carry the thread-local hasher past its one-time construction so the +/// steady-state per-derivation cost sits next to the single-shot number. +const DERIVATIONS_PER_BATCH: usize = 64; + +/// All-ones encoding. BN254's scalar field is 254 bits wide, so this string +/// sits above the modulus and the reduce-and-compare check always rejects it. +const REJECTED_ENCODING: [u8; 32] = [0xffu8; 32]; + +type SpendGroup<'a> = BenchmarkGroup<'a, WallTime>; + +/// The Poseidon1 permutation, the dominant cost on this side of the crate. +fn bench_derivation(group: &mut SpendGroup<'_>, key: &SpendingKey) { + assert_ne!(key.derive_owner_pubkey().to_bytes(), [0u8; 32]); + + group.bench_function("derive_owner_pubkey", |b| { + b.iter(|| black_box(black_box(key).derive_owner_pubkey())); + }); +} + +/// Rejection sampling, one draw per iteration. +fn bench_generation(group: &mut SpendGroup<'_>) { + let mut rng = ChaCha20Rng::seed_from_u64(RANDOM_SEED); + + let _ = black_box(SpendingKey::random(&mut rng)); + + group.bench_function("random", |b| { + b.iter(|| black_box(SpendingKey::random(&mut rng))); + }); +} + +/// The checked import path, on both the accepted and the rejected encoding. +/// A consumer validating untrusted counterparty keys pays the reject side. +fn bench_decode(group: &mut SpendGroup<'_>, key: &SpendingKey) { + let accepted = *key.scalar().expose_bytes(); + assert!(SpendingKey::from_canonical_bytes(accepted).is_ok()); + assert!(SpendingKey::from_canonical_bytes(REJECTED_ENCODING).is_err()); + + group.bench_function("from_canonical_bytes_accept", |b| { + b.iter(|| black_box(SpendingKey::from_canonical_bytes(black_box(accepted)))); + }); + + group.bench_function("from_canonical_bytes_reject", |b| { + b.iter(|| { + black_box(SpendingKey::from_canonical_bytes(black_box( + REJECTED_ENCODING, + ))) + }); + }); +} + +fn bench_field_utils(group: &mut SpendGroup<'_>, key: &SpendingKey) { + let pubkey = key.derive_owner_pubkey(); + let value = pubkey.to_field(); + + group.bench_function("owner_pubkey_to_field", |b| { + b.iter(|| black_box(black_box(&pubkey).to_field())); + }); + + group.bench_function("owner_pubkey_from_field", |b| { + b.iter(|| black_box(OwnerPubkey::from_field(black_box(value)))); + }); + + group.bench_function("scalar", |b| { + b.iter(|| black_box(black_box(key).scalar())); + }); +} + +fn bench_derivation_batch(group: &mut SpendGroup<'_>) { + let mut rng = ChaCha20Rng::seed_from_u64(BATCH_SEED); + let keys: Vec = (0..DERIVATIONS_PER_BATCH) + .map(|_| SpendingKey::random(&mut rng)) + .collect(); + + let distinct: HashSet<[u8; 32]> = keys + .iter() + .map(|key| key.derive_owner_pubkey().to_bytes()) + .collect(); + assert_eq!( + distinct.len(), + DERIVATIONS_PER_BATCH, + "batch keys must derive distinct owner pubkeys" + ); + + group.throughput(Throughput::Elements(DERIVATIONS_PER_BATCH as u64)); + group.bench_function("derive_owner_pubkey_batch", |b| { + b.iter(|| { + for key in &keys { + black_box(black_box(key).derive_owner_pubkey()); + } + }); + }); +} + +fn bench_spend(c: &mut Criterion) { + let mut group = c.benchmark_group("keystem::spend"); + let key = SpendingKey::random(&mut ChaCha20Rng::seed_from_u64(FIXED_KEY_SEED)); + + bench_derivation(&mut group, &key); + bench_generation(&mut group); + bench_decode(&mut group, &key); + bench_field_utils(&mut group, &key); + bench_derivation_batch(&mut group); + + group.finish(); +} + +criterion_group!(benches, bench_spend); +criterion_main!(benches); diff --git a/crates/keystem/benches/viewing.rs b/crates/keystem/benches/viewing.rs new file mode 100644 index 0000000..3614330 --- /dev/null +++ b/crates/keystem/benches/viewing.rs @@ -0,0 +1,93 @@ +use std::hint::black_box; + +use criterion::{ + Criterion, + criterion_group, + criterion_main, +}; +use keystem::{ + KemKeyOps, + ViewingKey, + ViewingPubkey, + family::Incoming, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use sealring::{ + K256, + X25519, +}; + +/// Seed for the rng behind the one fixed keypair most benchmarks reuse. +const FIXED_KEY_SEED: u64 = 0x4242_4242_4242_4242; + +/// Seed for the rng hoisted into the `random` benchmark's timed loop. +const RANDOM_SEED: u64 = 0x9009_9009_9009_9009; + +fn bench_curve(c: &mut Criterion, curve: &str) +where + K: KemKeyOps, + K::PublicKey: Clone, +{ + let mut group = c.benchmark_group(format!("keystem::viewing/curve={curve}")); + + let mut random_rng = ChaCha20Rng::seed_from_u64(RANDOM_SEED); + + let _ = black_box(ViewingKey::::random(&mut random_rng)); + + group.bench_function("random", |b| { + b.iter(|| black_box(ViewingKey::::random(&mut random_rng))); + }); + + let mut key_rng = ChaCha20Rng::seed_from_u64(FIXED_KEY_SEED); + let fixed_key = ViewingKey::::random(&mut key_rng); + + group.bench_function("derive_pubkey", |b| { + b.iter(|| black_box(black_box(&fixed_key).derive_pubkey())); + }); + + group.bench_function("to_sk_bytes", |b| { + b.iter(|| black_box(black_box(&fixed_key).to_sk_bytes())); + }); + + let sk_bytes = fixed_key.to_sk_bytes(); + assert!(ViewingKey::::from_sk_bytes(sk_bytes.as_ref()).is_ok()); + + group.bench_function("from_sk_bytes", |b| { + b.iter(|| { + black_box(ViewingKey::::from_sk_bytes(black_box( + sk_bytes.as_ref(), + ))) + }); + }); + + let fixed_pubkey = fixed_key.derive_pubkey(); + + group.bench_function("to_bytes", |b| { + b.iter(|| black_box(black_box(&fixed_pubkey).to_bytes())); + }); + + let pk_bytes = fixed_pubkey.to_bytes(); + assert!(ViewingPubkey::::from_bytes(pk_bytes.as_ref()).is_ok()); + + group.bench_function("from_bytes", |b| { + b.iter(|| { + black_box(ViewingPubkey::::from_bytes(black_box( + pk_bytes.as_ref(), + ))) + }); + }); + + group.finish(); +} + +fn bench_k256(c: &mut Criterion) { + bench_curve::(c, "k256"); +} + +fn bench_x25519(c: &mut Criterion) { + bench_curve::(c, "x25519"); +} + +criterion_group!(benches, bench_k256, bench_x25519); +criterion_main!(benches); diff --git a/crates/keystem/examples/wallet_keys.rs b/crates/keystem/examples/wallet_keys.rs new file mode 100644 index 0000000..b517edd --- /dev/null +++ b/crates/keystem/examples/wallet_keys.rs @@ -0,0 +1,124 @@ +//! Draws a spending key, shows two custody shapes for the same credential, +//! builds two viewing channels, seals a note to one of them, and republishes +//! the two credentials a wallet hands out as JSON. +//! +//! `cargo run -p keystem --example wallet_keys --features poseidon,serde,x25519,test-helpers` + +use std::convert::Infallible; + +use keystem::{ + SpendAuthority, + ViewingKey, + ViewingPubkey, + curves::bn254::{ + Fr, + OwnerPubkey, + SpendingKey, + }, + family::{ + Compliance, + Incoming, + }, + test_util::SealedCustody, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use sealring::{ + Domain, + X25519, + open, + seal, +}; + +/// Fixed seed, so the printed credentials are reproducible. A wallet draws +/// from an OS CSPRNG. +const SEED: u64 = 42; + +/// Bound into the envelope and checked on open. +const AAD: &[u8] = b"wallet-keys-example/v1"; + +struct WalletDomain; + +impl Domain for WalletDomain { + type Error = Infallible; + type Note = Vec; + + const DOMAIN_TAG: &'static str = "keystem-example/v1"; + + fn encode_note(note: &Self::Note, out: &mut Vec) -> Result<(), Self::Error> { + out.extend_from_slice(note); + Ok(()) + } + + fn decode_note(bytes: &[u8]) -> Result { + Ok(bytes.to_vec()) + } +} + +/// Prints the credential, then either the scalar or the reason custody +/// withheld it. +fn describe_authority(label: &str, authority: &impl SpendAuthority) { + let owner_pubkey = authority + .owner_pubkey() + .expect("both custody shapes here know their credential"); + println!("{label} owner pubkey: {owner_pubkey:?}"); + match authority.scalar() { + Ok(_) => println!("{label} scalar: exportable"), + Err(err) => println!("{label} scalar: {err}"), + } +} + +fn main() { + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + + // rejection-sampled spending key and the Poseidon1 credential it derives. + let spending_key = SpendingKey::random(&mut rng); + println!("spending key debug: {spending_key:?}"); + println!("owner pubkey: {:?}", spending_key.derive_owner_pubkey()); + + // the in-memory key exports its scalar on demand. + describe_authority("in-memory", &spending_key); + + // a non-exporting custodian answers the same credential and refuses the + // scalar, the shape a PKCS#11 token with CKA_EXTRACTABLE = FALSE presents. + let custody = SealedCustody::enrolled(spending_key.derive_owner_pubkey()); + describe_authority("sealed custody", &custody); + + // Incoming and Compliance are distinct types, so neither stands in for the + // other at any call site that names one. + let incoming: ViewingKey = ViewingKey::random(&mut rng); + let compliance: ViewingKey = ViewingKey::random(&mut rng); + let incoming_pubkey = incoming.derive_pubkey(); + println!("incoming viewing pubkey: {incoming_pubkey:?}"); + println!( + "compliance viewing pubkey: {:?}", + compliance.derive_pubkey() + ); + + // seal to the incoming channel's credential, open with its recipient. + let note = b"pay alice 5 units".to_vec(); + let envelope = + seal::(incoming_pubkey.public_key(), ¬e, AAD, &mut rng) + .expect("sealing a note to a freshly derived credential succeeds"); + let opened = open::(incoming.recipient(), &envelope, AAD) + .expect("the envelope is well formed") + .expect("the incoming key opens its own envelope"); + assert_eq!(opened, note); + println!("opened note: {}", String::from_utf8_lossy(&opened)); + + // the two credentials a wallet publishes, round-tripped through JSON. + let owner_pubkey = spending_key.derive_owner_pubkey(); + let owner_json = + serde_json::to_string(&owner_pubkey).expect("a 32-byte credential serializes"); + let owner_back: OwnerPubkey = + serde_json::from_str(&owner_json).expect("our own encoding decodes"); + assert_eq!(owner_pubkey, owner_back); + println!("owner pubkey json: {owner_json}"); + + let viewing_json = + serde_json::to_string(&incoming_pubkey).expect("a curve point serializes"); + let viewing_back: ViewingPubkey = + serde_json::from_str(&viewing_json).expect("our own encoding decodes"); + assert_eq!(incoming_pubkey, viewing_back); + println!("incoming viewing pubkey json: {viewing_json}"); +} diff --git a/crates/keystem/hack.toml b/crates/keystem/hack.toml new file mode 100644 index 0000000..4eebdac --- /dev/null +++ b/crates/keystem/hack.toml @@ -0,0 +1,4 @@ +depth = 2 +skip = ["docs"] +at-least-one-of = ["spend", "viewing"] +exclude-no-default-features = true diff --git a/crates/keystem/src/adapters.rs b/crates/keystem/src/adapters.rs new file mode 100644 index 0000000..1990788 --- /dev/null +++ b/crates/keystem/src/adapters.rs @@ -0,0 +1,9 @@ +//! Feature-gated key operations for sealring's shipped `Kem` adapters. + +#[cfg(feature = "k256")] +#[cfg_attr(docsrs, doc(cfg(feature = "k256")))] +pub mod k256; + +#[cfg(feature = "x25519")] +#[cfg_attr(docsrs, doc(cfg(feature = "x25519")))] +pub mod x25519; diff --git a/crates/keystem/src/adapters/k256.rs b/crates/keystem/src/adapters/k256.rs new file mode 100644 index 0000000..ce1af7c --- /dev/null +++ b/crates/keystem/src/adapters/k256.rs @@ -0,0 +1,31 @@ +//! secp256k1 key operations + +use k256::{ + SecretKey, + elliptic_curve::Generate, +}; +use rand_core::CryptoRng; +use sealring::K256; + +use crate::kem_ops::KemKeyOps; + +/// Byte length of a secp256k1 scalar. +const K256_SK_LEN: usize = 32; + +impl KemKeyOps for K256 { + type SkBytes = [u8; K256_SK_LEN]; + + fn generate_sk(rng: &mut impl CryptoRng) -> SecretKey { + SecretKey::generate_from_rng(rng) + } + + fn encode_sk(sk: &SecretKey) -> Self::SkBytes { + sk.to_bytes().into() + } + + /// Rejects zero and anything at or above the group order. + fn decode_sk(bytes: &[u8]) -> Option { + let bytes: &Self::SkBytes = bytes.try_into().ok()?; + SecretKey::from_slice(bytes).ok() + } +} diff --git a/crates/keystem/src/adapters/x25519.rs b/crates/keystem/src/adapters/x25519.rs new file mode 100644 index 0000000..0948e78 --- /dev/null +++ b/crates/keystem/src/adapters/x25519.rs @@ -0,0 +1,62 @@ +//! X25519 key operations +//! +//! Building an incoming-viewing keypair and handing its credential to a +//! sender: +//! +//! ``` +//! use keystem::{ViewingKey, family::Incoming}; +//! use rand_chacha::ChaCha20Rng; +//! use rand_core::SeedableRng; +//! use sealring::X25519; +//! +//! fn seal_to_incoming(_: &keystem::ViewingPubkey) {} +//! +//! let mut rng = ChaCha20Rng::seed_from_u64(7); +//! let incoming: ViewingKey = ViewingKey::random(&mut rng); +//! seal_to_incoming(&incoming.derive_pubkey()); +//! ``` +//! +//! The compliance channel is a different type, so it cannot reach that sender +//! by accident. This is the correlation-risk rule, enforced by the compiler: +//! +//! ```compile_fail +//! use keystem::{ViewingKey, family::{Compliance, Incoming}}; +//! use rand_chacha::ChaCha20Rng; +//! use rand_core::SeedableRng; +//! use sealring::X25519; +//! +//! fn seal_to_incoming(_: &keystem::ViewingPubkey) {} +//! +//! let mut rng = ChaCha20Rng::seed_from_u64(7); +//! let compliance: ViewingKey = ViewingKey::random(&mut rng); +//! seal_to_incoming(&compliance.derive_pubkey()); +//! ``` + +use rand_core::CryptoRng; +use sealring::X25519; +use x25519_dalek::StaticSecret; + +use crate::kem_ops::KemKeyOps; + +/// Byte length of an X25519 scalar. +const X25519_KEY_LEN: usize = 32; + +impl KemKeyOps for X25519 { + type SkBytes = [u8; X25519_KEY_LEN]; + + fn generate_sk(rng: &mut impl CryptoRng) -> StaticSecret { + StaticSecret::random_from_rng(rng) + } + + fn encode_sk(sk: &StaticSecret) -> Self::SkBytes { + sk.to_bytes() + } + + /// Every 32-byte string is a valid scalar once clamped, so only the length + /// can fail. + fn decode_sk(bytes: &[u8]) -> Option { + Some(StaticSecret::from( + <[u8; X25519_KEY_LEN]>::try_from(bytes).ok()?, + )) + } +} diff --git a/crates/keystem/src/authority.rs b/crates/keystem/src/authority.rs new file mode 100644 index 0000000..87b3bba --- /dev/null +++ b/crates/keystem/src/authority.rs @@ -0,0 +1,47 @@ +use ark_ff::PrimeField; + +use crate::spend::{ + OwnerPubkey, + SecretScalar, +}; + +/// Custody boundary: operations out, key material stays put. +/// +/// Sync by design, matching PKCS#11 and card stacks; a networked custodian +/// blocks inside its own adapter. The in-memory [`SpendingKey`] is one +/// implementation, and HSM, smartcard, and enclave-resident keys are the +/// others, so consumer code never names the storage. +/// +/// [`SpendingKey`]: crate::SpendingKey +pub trait SpendAuthority { + /// Primefield instantiation + type Field: PrimeField; + + /// Matchable failure; [`Infallible`](core::convert::Infallible) for + /// in-memory keys. + type Error: core::error::Error; + + /// The public spend credential. An impl may compute it, cache it from + /// enrollment, or query the device. + fn owner_pubkey(&self) -> Result, Self::Error>; + + /// The one fallible scalar egress, for consumers whose circuits take the + /// spending key as a private witness. Non-exporting custody returns its + /// [`NotExportable`] error here. + /// + /// [`NotExportable`]: crate::NotExportable + fn scalar(&self) -> Result, Self::Error>; +} + +impl SpendAuthority for &A { + type Error = A::Error; + type Field = A::Field; + + fn owner_pubkey(&self) -> Result, Self::Error> { + (**self).owner_pubkey() + } + + fn scalar(&self) -> Result, Self::Error> { + (**self).scalar() + } +} diff --git a/crates/keystem/src/curves.rs b/crates/keystem/src/curves.rs new file mode 100644 index 0000000..b169f21 --- /dev/null +++ b/crates/keystem/src/curves.rs @@ -0,0 +1,5 @@ +//! Curve instantiations of the generic spend types. + +#[cfg(feature = "bn254")] +#[cfg_attr(docsrs, doc(cfg(feature = "bn254")))] +pub mod bn254; diff --git a/crates/keystem/src/curves/bn254.rs b/crates/keystem/src/curves/bn254.rs new file mode 100644 index 0000000..c88da62 --- /dev/null +++ b/crates/keystem/src/curves/bn254.rs @@ -0,0 +1,61 @@ +//! BN254 instantiation + +#[cfg(feature = "poseidon")] +use core::convert::Infallible; +#[cfg(feature = "poseidon")] +use std::cell::RefCell; + +pub use ark_bn254::Fr; +#[cfg(feature = "poseidon")] +use light_poseidon::{ + Poseidon, + PoseidonHasher, +}; + +#[cfg(feature = "poseidon")] +use crate::authority::SpendAuthority; + +/// Master spend secret over BN254's scalar field. +pub type SpendingKey = crate::spend::SpendingKey; + +/// Public spend credential over BN254's scalar field. +pub type OwnerPubkey = crate::spend::OwnerPubkey; + +/// One revealed BN254 scalar. +pub type SecretScalar = crate::spend::SecretScalar; + +#[cfg(feature = "poseidon")] +thread_local! { + static POSEIDON1: RefCell> = RefCell::new( + Poseidon::::new_circom(1) + .expect("width 2 is inside light-poseidon's circom parameter range"), + ); +} + +#[cfg(feature = "poseidon")] +#[cfg_attr(docsrs, doc(cfg(feature = "poseidon")))] +impl SpendingKey { + pub fn derive_owner_pubkey(&self) -> OwnerPubkey { + let image = POSEIDON1.with_borrow_mut(|hasher| { + hasher + .hash(&[self.scalar().expose_field()]) + .expect("a width-2 hasher takes exactly one input") + }); + OwnerPubkey::from_field(image) + } +} + +#[cfg(feature = "poseidon")] +#[cfg_attr(docsrs, doc(cfg(feature = "poseidon")))] +impl SpendAuthority for SpendingKey { + type Error = Infallible; + type Field = Fr; + + fn owner_pubkey(&self) -> Result { + Ok(self.derive_owner_pubkey()) + } + + fn scalar(&self) -> Result { + Ok(SpendingKey::scalar(self)) + } +} diff --git a/crates/keystem/src/encoding.rs b/crates/keystem/src/encoding.rs new file mode 100644 index 0000000..d5561ea --- /dev/null +++ b/crates/keystem/src/encoding.rs @@ -0,0 +1,213 @@ +use ark_ff::{ + BigInteger, + PrimeField, +}; +use rand_core::CryptoRng; + +use crate::error::NonCanonical; + +/// Byte width of every key encoding in this crate. +pub(crate) const KEY_LEN: usize = 32; + +/// Limbs an encoding holds, one per 64-bit word. +const KEY_LIMBS: usize = KEY_LEN / 8; + +/// Widest modulus the encoding holds. Must stay `KEY_LEN` bytes of bits, or +/// the two width asserts below stop agreeing with each other. +const MAX_MODULUS_BITS: u32 = 256; + +/// Width bound, checked at the instantiation that asks for it: a field whose +/// modulus or limb count outgrows the encoding fails to compile, so the limb +/// walks below never truncate. +fn assert_within_width() { + const { + assert!( + F::MODULUS_BIT_SIZE <= MAX_MODULUS_BITS, + "field modulus is wider than the 256-bit key encoding" + ); + assert!( + ::NUM_LIMBS <= KEY_LIMBS, + "field limb count is wider than the 256-bit key encoding" + ); + } +} + +/// Canonical big-endian encoding of `value`, zero-padded to [`KEY_LEN`]. +pub(crate) fn to_canonical_bytes(value: F) -> [u8; KEY_LEN] { + assert_within_width::(); + let mut bytes = [0u8; KEY_LEN]; + // limbs run least significant first, so the encoding fills from the back. + for (chunk, limb) in bytes.rchunks_exact_mut(8).zip(value.into_bigint().as_ref()) { + chunk.copy_from_slice(&limb.to_be_bytes()) + } + bytes +} + +/// Bytes the encoding pads with above the field's integer form. The width +/// bound keeps this non-negative. +const fn pad_len() -> usize { + KEY_LEN - ::NUM_LIMBS * 8 +} + +/// The big-endian encoding read into the field's integer representation. +/// +/// Limbs run least significant first, so the read walks the encoding from the +/// back and never reaches the pad. +fn to_bigint(bytes: &[u8; KEY_LEN]) -> F::BigInt { + let mut integer = F::BigInt::default(); + for (chunk, limb) in bytes.rchunks_exact(8).zip(integer.as_mut()) { + *limb = + u64::from_be_bytes(chunk.try_into().expect("rchunks_exact(8) yields 8 bytes")) + } + integer +} + +/// The field element for the canonical encoding +pub(crate) fn to_field(bytes: &[u8; KEY_LEN]) -> F { + assert_within_width::(); + F::from_bigint(to_bigint::(bytes)) + .expect("a canonical encoding sits below the modulus") +} + +/// Rejects an encoding that is not a part of the field. +pub(crate) fn check_canonical( + bytes: &[u8; KEY_LEN], +) -> Result<(), NonCanonical> { + assert_within_width::(); + let canonical = bytes[..pad_len::()].iter().all(|byte| *byte == 0) + && to_bigint::(bytes) < F::MODULUS; + canonical.then_some(()).ok_or(NonCanonical) +} + +/// Uniform canonical encoding of a field element drawn by rejection sampling +pub(crate) fn sample_canonical(rng: &mut impl CryptoRng) -> [u8; KEY_LEN] { + let bits = F::MODULUS_BIT_SIZE as usize; + let mut bytes = [0u8; KEY_LEN]; + loop { + let body = &mut bytes[KEY_LEN - bits.div_ceil(8)..]; + rng.fill_bytes(body); + body[0] &= high_byte_mask(bits); + if check_canonical::(&bytes).is_ok() { + return bytes; + } + } +} + +fn high_byte_mask(bits: usize) -> u8 { + match bits % 8 { + 0 => u8::MAX, + used => (1u8 << used) - 1, + } +} + +#[cfg(all(test, feature = "bn254"))] +mod tests { + use ark_bn254::Fr; + use ark_ff::{ + One, + Zero, + }; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + use super::*; + + #[test] + fn canonical_bytes_round_trip_through_the_field() { + // given the multiplicative identity of BN254's scalar field + let value = Fr::one(); + // when its encoding is checked and read back + let bytes = to_canonical_bytes(value); + // then the check accepts and the field element survives unchanged + assert_eq!(check_canonical::(&bytes), Ok(())); + assert_eq!(to_field::(&bytes), value); + } + + #[test] + fn to_field_inverts_the_canonical_encoding() { + // given the two boundary elements and one interior one + let values = [Fr::zero(), Fr::one(), -Fr::one(), Fr::from(123_456_789u64)]; + // when each is encoded and read back through the unchecked decode + let recovered = values.map(|value| to_field::(&to_canonical_bytes(value))); + // then every element survives the round trip + assert_eq!(recovered, values); + } + + #[test] + fn to_field_agrees_with_the_reducing_decode() { + // given canonical encodings drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(0xBEEF); + let samples: [[u8; KEY_LEN]; 100] = + core::array::from_fn(|_| sample_canonical::(&mut rng)); + // when each is read both by the limb walk and by a full reduction + let agree = samples + .iter() + .all(|s| to_field::(s) == Fr::from_be_bytes_mod_order(s)); + // then skipping the reduction names the same field element + assert!(agree); + } + + #[test] + fn zero_encodes_to_an_all_zero_string() { + // given the additive identity + let value = Fr::zero(); + // when it is encoded + let bytes = to_canonical_bytes(value); + // then every byte of the fixed-width encoding is zero + assert_eq!(bytes, [0u8; KEY_LEN]); + } + + #[test] + fn the_modulus_itself_is_rejected() { + // given the modulus, the smallest non-canonical encoding + let mut bytes = [0u8; KEY_LEN]; + bytes.copy_from_slice(&Fr::MODULUS.to_bytes_be()); + // when it is checked + let checked = check_canonical::(&bytes); + // then the modulus comparison rejects it + assert_eq!(checked, Err(NonCanonical)); + } + + #[test] + fn one_below_the_modulus_is_accepted() { + // given the largest canonical field element + let bytes = to_canonical_bytes(-Fr::one()); + // when it is checked and read back + let checked = check_canonical::(&bytes); + // then it is accepted and names the field element it encodes + assert_eq!(checked, Ok(())); + assert_eq!(to_field::(&bytes), -Fr::one()); + } + + #[test] + fn an_all_ones_string_is_rejected() { + // given a byte string far above the modulus + let bytes = [0xffu8; KEY_LEN]; + // when it is checked + let checked = check_canonical::(&bytes); + // then it is rejected rather than silently reduced + assert_eq!(checked, Err(NonCanonical)); + } + + #[test] + fn sampling_yields_canonical_encodings() { + // given a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(0xC0FFEE); + // when a hundred candidates are drawn + let samples: [[u8; KEY_LEN]; 100] = + core::array::from_fn(|_| sample_canonical::(&mut rng)); + // then every one decodes, and the draw is not a constant + assert!(samples.iter().all(|s| check_canonical::(s).is_ok())); + assert!(samples.windows(2).any(|pair| pair[0] != pair[1])); + } + + #[test] + fn the_high_byte_mask_covers_every_modulus_width() { + // given the bit widths a 32-byte encoding can hold + let widths = [1usize, 7, 8, 9, 254, 255, 256]; + // when each is masked + let masks = widths.map(high_byte_mask); + // then a whole-byte width keeps all eight bits and the rest truncate + assert_eq!(masks, [0x01, 0x7f, 0xff, 0x01, 0x3f, 0x7f, 0xff]); + } +} diff --git a/crates/keystem/src/error.rs b/crates/keystem/src/error.rs new file mode 100644 index 0000000..367f696 --- /dev/null +++ b/crates/keystem/src/error.rs @@ -0,0 +1,56 @@ +#[cfg(any(feature = "spend", feature = "viewing"))] +use core::fmt; + +/// Bytes at or above the field modulus. +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NonCanonical; + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +impl fmt::Display for NonCanonical { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "bytes are not a canonical field element") + } +} + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +impl core::error::Error for NonCanonical {} + +/// Returned or embedded by custody that refuses to reveal its scalar. +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NotExportable; + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +impl fmt::Display for NotExportable { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "custody refuses to export the spending scalar") + } +} + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +impl core::error::Error for NotExportable {} + +/// Bytes that decode to no valid key for the chosen KEM. +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidKey; + +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +impl fmt::Display for InvalidKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "bytes are not a valid key for this kem") + } +} + +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +impl core::error::Error for InvalidKey {} diff --git a/crates/keystem/src/family.rs b/crates/keystem/src/family.rs new file mode 100644 index 0000000..75293fa --- /dev/null +++ b/crates/keystem/src/family.rs @@ -0,0 +1,15 @@ +//! Disclosure-channel markers. +//! +//! Mixing a compliance-viewing branch with an ordinary incoming-viewing branch +//! is a correlation risk, so each channel is its own type and the compiler +//! keeps them apart. Consumers add channels by defining their own empty type; +//! nothing here is a closed set. + +/// Ordinary incoming-viewing channel. +pub enum Incoming {} + +/// Owner-side compliance channel. +pub enum Compliance {} + +/// Audit-committee channel. +pub enum Audit {} diff --git a/crates/keystem/src/hex.rs b/crates/keystem/src/hex.rs new file mode 100644 index 0000000..439b6d6 --- /dev/null +++ b/crates/keystem/src/hex.rs @@ -0,0 +1,10 @@ +use core::fmt; + +/// Writes `bytes` as lowercase hex, the form every consumer already logs +/// public key material in. +pub(crate) fn write_hex(f: &mut fmt::Formatter<'_>, bytes: &[u8]) -> fmt::Result { + for byte in bytes { + write!(f, "{byte:02x}")? + } + Ok(()) +} diff --git a/crates/keystem/src/kem_ops.rs b/crates/keystem/src/kem_ops.rs new file mode 100644 index 0000000..848b252 --- /dev/null +++ b/crates/keystem/src/kem_ops.rs @@ -0,0 +1,24 @@ +use rand_core::CryptoRng; +use sealring::Kem; +use zeroize::Zeroize; + +/// Software-resident secret keys: generation and byte codecs. +/// +/// A hardware KEM whose secret key is a device handle implements [`Kem`] alone +/// and skips this trait, so no impl is ever forced to stub out an operation its +/// custody cannot honor. +pub trait KemKeyOps: Kem { + /// Secret-key encoding, fixed-size per curve, zeroizable so the + /// persistence egress can wrap it. + type SkBytes: AsRef<[u8]> + Zeroize; + + /// Draws a secret key from `rng`. + fn generate_sk(rng: &mut impl CryptoRng) -> Self::SecretKey; + + /// Encodes `sk` for persistence. + fn encode_sk(sk: &Self::SecretKey) -> Self::SkBytes; + + /// Decodes an encoding [`encode_sk`](Self::encode_sk) produced, returning + /// `None` for byte strings that name no secret key. + fn decode_sk(bytes: &[u8]) -> Option; +} diff --git a/crates/keystem/src/lib.rs b/crates/keystem/src/lib.rs new file mode 100644 index 0000000..d6db988 --- /dev/null +++ b/crates/keystem/src/lib.rs @@ -0,0 +1,86 @@ +#![cfg_attr(feature = "docs", doc = include_utils::include_md!("README.md:intro"))] +#![cfg_attr(feature = "docs", doc = include_utils::include_md!("README.md:design"))] +#![cfg_attr(feature = "docs", doc = include_utils::include_md!("README.md:usage"))] +#![cfg_attr(not(test), deny(clippy::cast_possible_truncation))] +#![cfg_attr(docsrs, feature(doc_cfg))] +#![deny(unused_crate_dependencies)] +#![deny(warnings)] +#![cfg_attr(not(feature = "std"), no_std)] + +#[cfg(all(not(feature = "std"), feature = "serde", feature = "viewing"))] +#[cfg_attr(docsrs, doc(cfg(not(feature = "std"))))] +extern crate alloc; + +#[cfg(not(any(feature = "spend", feature = "viewing")))] +compile_error!( + "keystem needs at least one of the `spend` and `viewing` features; \ + `default` enables `spend` through `poseidon`, and the viewing-only shape is \ + `default-features = false, features = [\"viewing\"]`" +); + +// dev-only crates linked into the test harness build. +#[cfg(test)] +use { + ark_bls12_381 as _, + ark_serialize as _, + criterion as _, + proptest as _, + rand_chacha as _, + serde_json as _, +}; + +mod error; +#[cfg(any(feature = "spend", feature = "viewing"))] +mod hex; + +#[cfg(feature = "spend")] +mod authority; +#[cfg(feature = "spend")] +mod encoding; +#[cfg(feature = "spend")] +mod spend; + +#[cfg(feature = "viewing")] +mod kem_ops; +#[cfg(feature = "viewing")] +mod viewing; + +pub mod adapters; +pub mod curves; + +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub mod family; + +#[cfg(any(test, feature = "test-helpers"))] +#[cfg_attr(docsrs, doc(cfg(feature = "test-helpers")))] +pub mod test_util; + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +pub use authority::SpendAuthority; +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub use error::InvalidKey; +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +pub use error::{ + NonCanonical, + NotExportable, +}; +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub use kem_ops::KemKeyOps; +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +pub use spend::{ + OwnerPubkey, + SecretScalar, + SpendingKey, +}; +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub use viewing::{ + ViewingKey, + ViewingPubkey, +}; diff --git a/crates/keystem/src/spend.rs b/crates/keystem/src/spend.rs new file mode 100644 index 0000000..3dcc8c6 --- /dev/null +++ b/crates/keystem/src/spend.rs @@ -0,0 +1,206 @@ +use core::{ + fmt, + marker::PhantomData, +}; + +use ark_ff::PrimeField; +use rand_core::CryptoRng; +use zeroize::{ + Zeroize, + ZeroizeOnDrop, +}; + +use crate::{ + encoding::{ + self, + KEY_LEN, + }, + error::NonCanonical, + hex::write_hex, +}; + +/// Master spend secret over any prime field within the 256-bit width bound. +/// +/// Holds the field's canonical big-endian encoding and is canonical by +/// construction: rejection sampling or checked decode are the only ways in. +/// Zeroized on drop, `Debug` prints `REDACTED`. +/// ``` +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SpendingKey { + bytes: [u8; KEY_LEN], + #[zeroize(skip)] + field: PhantomData F>, +} + +impl SpendingKey { + /// Uniform over `F` by rejection sampling. + pub fn random(rng: &mut impl CryptoRng) -> Self { + Self::from_canonical(encoding::sample_canonical::(rng)) + } + + /// Total decode + pub fn from_canonical_bytes(bytes: [u8; KEY_LEN]) -> Result { + encoding::check_canonical::(&bytes)?; + Ok(Self::from_canonical(bytes)) + } + + /// Infallible reveal. + pub fn scalar(&self) -> SecretScalar { + SecretScalar { + bytes: self.bytes, + field: PhantomData, + } + } + + /// Wraps bytes a caller has already proven canonical. + fn from_canonical(bytes: [u8; KEY_LEN]) -> Self { + Self { + bytes, + field: PhantomData, + } + } +} + +impl fmt::Debug for SpendingKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SpendingKey(REDACTED)") + } +} + +/// Public spend credential, the value note commitments carry. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +#[repr(transparent)] +pub struct OwnerPubkey { + bytes: [u8; KEY_LEN], + field: PhantomData F>, +} + +impl OwnerPubkey { + /// Total decode, the counterparty-import path. Rejects bytes at or above + /// the modulus. + pub fn from_canonical_bytes(bytes: [u8; KEY_LEN]) -> Result { + encoding::check_canonical::(&bytes)?; + Ok(Self { + bytes, + field: PhantomData, + }) + } + + /// The seam for consumers whose derivation lives outside this crate: + /// `scalar` to `expose_field` to their hash to `from_field`. + pub fn from_field(value: F) -> Self { + Self { + bytes: encoding::to_canonical_bytes(value), + field: PhantomData, + } + } + + /// The canonical big-endian encoding. + pub fn to_bytes(&self) -> [u8; KEY_LEN] { + self.bytes + } + + /// Infallible: the canonical invariant holds by construction. + pub fn to_field(&self) -> F { + encoding::to_field(&self.bytes) + } +} + +impl fmt::Debug for OwnerPubkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OwnerPubkey(")?; + write_hex(f, &self.bytes)?; + f.write_str(")") + } +} + +/// One revealed scalar with one owner: zeroizing, canonical. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct SecretScalar { + bytes: [u8; KEY_LEN], + #[zeroize(skip)] + field: PhantomData F>, +} + +impl SecretScalar { + /// Total decode + pub fn from_canonical_bytes(bytes: [u8; KEY_LEN]) -> Result { + encoding::check_canonical::(&bytes)?; + Ok(Self { + bytes, + field: PhantomData, + }) + } + + /// The canonical big-endian encoding of the revealed scalar. + pub fn expose_bytes(&self) -> &[u8; KEY_LEN] { + &self.bytes + } + + /// The revealed scalar as a field element. + pub fn expose_field(&self) -> F { + encoding::to_field(&self.bytes) + } +} + +impl fmt::Debug for SecretScalar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretScalar(REDACTED)") + } +} + +#[cfg(feature = "serde")] +mod owner_pubkey_serde { + use serde::{ + Deserialize, + Deserializer, + Serialize, + Serializer, + de::Error, + }; + + use super::*; + + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] + impl Serialize for OwnerPubkey { + fn serialize(&self, serializer: S) -> Result { + self.bytes.serialize(serializer) + } + } + + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] + impl<'de, F: PrimeField> Deserialize<'de> for OwnerPubkey { + fn deserialize>(deserializer: D) -> Result { + let bytes = <[u8; KEY_LEN]>::deserialize(deserializer)?; + Self::from_canonical_bytes(bytes).map_err(D::Error::custom) + } + } +} + +#[cfg(feature = "expose-secret-serde")] +mod spending_key_serde { + use serde::{ + Deserialize, + Deserializer, + Serialize, + Serializer, + de::Error, + }; + + use super::*; + + #[cfg_attr(docsrs, doc(cfg(feature = "expose-secret-serde")))] + impl Serialize for SpendingKey { + fn serialize(&self, serializer: S) -> Result { + self.bytes.serialize(serializer) + } + } + + #[cfg_attr(docsrs, doc(cfg(feature = "expose-secret-serde")))] + impl<'de, F: PrimeField> Deserialize<'de> for SpendingKey { + fn deserialize>(deserializer: D) -> Result { + let bytes = <[u8; KEY_LEN]>::deserialize(deserializer)?; + Self::from_canonical_bytes(bytes).map_err(D::Error::custom) + } + } +} diff --git a/crates/keystem/src/test_util.rs b/crates/keystem/src/test_util.rs new file mode 100644 index 0000000..225a5da --- /dev/null +++ b/crates/keystem/src/test_util.rs @@ -0,0 +1,110 @@ +//! Test helpers: custody that refuses export, and a conformance suite third +//! party [`KemKeyOps`] adapters should run against their own keys. + +#[cfg(feature = "spend")] +use ark_ff::PrimeField; +#[cfg(feature = "viewing")] +use rand_core::CryptoRng; + +#[cfg(feature = "viewing")] +use crate::kem_ops::KemKeyOps; +#[cfg(feature = "spend")] +use crate::{ + authority::SpendAuthority, + error::NotExportable, + spend::{ + OwnerPubkey, + SecretScalar, + }, +}; + +/// Longest secret-key encoding the conformance probes cover. +#[cfg(feature = "viewing")] +const MAX_PROBE_LEN: usize = 256; + +/// Custody that enrolls a credential and refuses every reveal, the shape an +/// HSM presents to a circuit that wants the spending key as a witness. +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +pub struct SealedCustody { + owner_pubkey: OwnerPubkey, +} + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +impl SealedCustody { + /// Enrolls a credential whose scalar the device will never release. + pub fn enrolled(owner_pubkey: OwnerPubkey) -> Self { + Self { owner_pubkey } + } +} + +#[cfg(feature = "spend")] +#[cfg_attr(docsrs, doc(cfg(feature = "spend")))] +impl SpendAuthority for SealedCustody { + type Error = NotExportable; + type Field = F; + + fn owner_pubkey(&self) -> Result, NotExportable> { + Ok(self.owner_pubkey) + } + + fn scalar(&self) -> Result, NotExportable> { + Err(NotExportable) + } +} + +/// Asserts `decode_sk` inverts `encode_sk` for `sk`, and that the recovered +/// key derives the same public key. +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub fn conformance_sk_codec_roundtrips(sk: &K::SecretKey) { + let encoded = K::encode_sk(sk); + let decoded = K::decode_sk(encoded.as_ref()).expect("own sk encoding must decode"); + assert_eq!( + K::encode_sk(&decoded).as_ref(), + encoded.as_ref(), + "decode_sk must invert encode_sk" + ); + assert_eq!( + K::encode_pk(&K::derive_pk(&decoded)).as_ref(), + K::encode_pk(&K::derive_pk(sk)).as_ref(), + "a decoded sk must derive the public key its encoding came from" + ); +} + +/// Asserts a generated key survives both codecs and that two draws from one +/// generator differ. +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub fn conformance_generate_sk_draws(rng: &mut impl CryptoRng) { + let first = K::generate_sk(rng); + let second = K::generate_sk(rng); + conformance_sk_codec_roundtrips::(&first); + assert_ne!( + K::encode_sk(&first).as_ref(), + K::encode_sk(&second).as_ref(), + "two draws from one generator must differ" + ); +} + +/// Asserts byte strings of the wrong length decode to no secret key: the +/// empty string, and lengths shorter and longer than the encoding. +#[cfg(feature = "viewing")] +#[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] +pub fn conformance_wrong_length_fails(sk: &K::SecretKey) { + let len = K::encode_sk(sk).as_ref().len(); + assert!( + (1..MAX_PROBE_LEN).contains(&len), + "conformance probes cover secret-key encodings up to {MAX_PROBE_LEN} bytes" + ); + + let probe = [0xAAu8; MAX_PROBE_LEN]; + for case in [&probe[..0], &probe[..len - 1], &probe[..len + 1]] { + assert!( + K::decode_sk(case).is_none(), + "an sk encoding of {} bytes must not decode", + case.len() + ); + } +} diff --git a/crates/keystem/src/viewing.rs b/crates/keystem/src/viewing.rs new file mode 100644 index 0000000..dd8b420 --- /dev/null +++ b/crates/keystem/src/viewing.rs @@ -0,0 +1,163 @@ +use core::{ + fmt, + marker::PhantomData, +}; + +use rand_core::CryptoRng; +use sealring::{ + Kem, + Recipient, +}; +use zeroize::Zeroizing; + +use crate::{ + error::InvalidKey, + hex::write_hex, + kem_ops::KemKeyOps, +}; + +/// Viewing keypair in disclosure family `F`. +pub struct ViewingKey { + recipient: Recipient, + family: PhantomData F>, +} + +impl ViewingKey { + /// Custody-agnostic constructor. Hardware ECDH enters here with a consumer + /// [`Kem`] impl whose `SecretKey` is a device handle. + pub fn from_recipient(recipient: Recipient) -> Self { + Self { + recipient, + family: PhantomData, + } + } + + /// The recipient sealring's open and scan paths consume. + pub fn recipient(&self) -> &Recipient { + &self.recipient + } + + /// The distributable read credential for this channel. + pub fn derive_pubkey(&self) -> ViewingPubkey + where + K::PublicKey: Clone, + { + ViewingPubkey { + public_key: self.recipient.public_key().clone(), + family: PhantomData, + } + } +} + +impl ViewingKey { + /// Draws a fresh keypair for this channel from `rng`. + pub fn random(rng: &mut impl CryptoRng) -> Self { + Self::from_recipient(Recipient::new(K::generate_sk(rng))) + } + + /// Total decode of a persisted secret key. + pub fn from_sk_bytes(bytes: &[u8]) -> Result { + let secret_key = K::decode_sk(bytes).ok_or(InvalidKey)?; + Ok(Self::from_recipient(Recipient::new(secret_key))) + } + + /// Persistence egress, in a wrapper that wipes the encoding on drop. + pub fn to_sk_bytes(&self) -> Zeroizing { + Zeroizing::new(K::encode_sk(self.recipient.secret_key())) + } +} + +impl fmt::Debug for ViewingKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ViewingKey(REDACTED)") + } +} + +/// Distributable read credential in family `F`. +pub struct ViewingPubkey { + public_key: K::PublicKey, + family: PhantomData F>, +} + +impl ViewingPubkey { + /// The seal-to handle `sealring::seal` consumes. + pub fn public_key(&self) -> &K::PublicKey { + &self.public_key + } + + /// The wire encoding, in the same format the envelope carries its + /// ephemeral key. + pub fn to_bytes(&self) -> K::Epk { + K::encode_pk(&self.public_key) + } + + /// Counterparty import, the sender-side decode. + pub fn from_bytes(bytes: &[u8]) -> Result { + let public_key = K::decode_pk(bytes).ok_or(InvalidKey)?; + Ok(Self { + public_key, + family: PhantomData, + }) + } +} + +impl Clone for ViewingPubkey +where + K::PublicKey: Clone, +{ + fn clone(&self) -> Self { + Self { + public_key: self.public_key.clone(), + family: PhantomData, + } + } +} + +impl PartialEq for ViewingPubkey { + fn eq(&self, other: &Self) -> bool { + self.to_bytes().as_ref() == other.to_bytes().as_ref() + } +} + +impl Eq for ViewingPubkey {} + +impl fmt::Debug for ViewingPubkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("ViewingPubkey(")?; + write_hex(f, self.to_bytes().as_ref())?; + f.write_str(")") + } +} + +#[cfg(feature = "serde")] +mod viewing_pubkey_serde { + #[cfg(not(feature = "std"))] + use alloc::vec::Vec; + #[cfg(feature = "std")] + use std::vec::Vec; + + use serde::{ + Deserialize, + Deserializer, + Serialize, + Serializer, + de::Error, + }; + + use super::*; + + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] + impl Serialize for ViewingPubkey { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(self.to_bytes().as_ref()) + } + } + + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] + impl<'de, K: Kem, F> Deserialize<'de> for ViewingPubkey { + fn deserialize>(deserializer: D) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + Self::from_bytes(&bytes).map_err(D::Error::custom) + } + } +} diff --git a/crates/keystem/tests/golden.rs b/crates/keystem/tests/golden.rs new file mode 100644 index 0000000..11d972f --- /dev/null +++ b/crates/keystem/tests/golden.rs @@ -0,0 +1,32 @@ +#![cfg(feature = "poseidon")] + +use keystem::curves::bn254::SpendingKey; + +fn decode_hex(hex: &str) -> Vec { + let hex = hex.trim(); + (0..hex.len()) + .step_by(2) + .map(|i| { + u8::from_str_radix(&hex[i..i + 2], 16).expect("golden vector is valid hex") + }) + .collect() +} + +fn encode_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Vector is circomlib's `poseidon([1])` over BN254 +#[test] +fn derive_owner_pubkey_matches_the_circomlib_poseidon_one_vector() { + // given a spending key built from the canonical encoding of the field element one + let mut bytes = [0u8; 32]; + bytes[31] = 0x01; + let key = SpendingKey::from_canonical_bytes(bytes) + .expect("one is a canonical field element"); + // when the owner pubkey is derived + let pubkey = key.derive_owner_pubkey(); + // then its bytes match circomlib's frozen poseidon([1]) vector + let expected = decode_hex(include_str!("golden/poseidon1.hex")); + assert_eq!(encode_hex(&pubkey.to_bytes()), encode_hex(&expected)); +} diff --git a/crates/keystem/tests/golden/poseidon1.hex b/crates/keystem/tests/golden/poseidon1.hex new file mode 100644 index 0000000..0759a51 --- /dev/null +++ b/crates/keystem/tests/golden/poseidon1.hex @@ -0,0 +1 @@ +29176100eaa962bdc1fe6c654d6a3c130e96a4d1168b33848b897dc502820133 diff --git a/crates/keystem/tests/proptest_canonical.rs b/crates/keystem/tests/proptest_canonical.rs new file mode 100644 index 0000000..40b3042 --- /dev/null +++ b/crates/keystem/tests/proptest_canonical.rs @@ -0,0 +1,28 @@ +#![cfg(feature = "bn254")] + +use ark_serialize::CanonicalDeserialize; +use keystem::curves::bn254::{ + Fr, + OwnerPubkey, +}; +use proptest::prelude::*; + +proptest! { + /// keystem decodes big-endian; arkworks' canonical serialization is little-endian. + /// Reversing the byte string aligns the two codecs on the same bit pattern, and + /// they must accept or reject it together, agreeing on the field element when both do. + #[test] + fn owner_pubkey_canonicity_matches_arkworks_little_endian_deserialization(bytes in any::<[u8; 32]>()) { + // given 32 arbitrary bytes and their byte-reversed form + let mut reversed = bytes; + reversed.reverse(); + // when both codecs attempt to decode the same bit pattern + let via_keystem = OwnerPubkey::from_canonical_bytes(bytes); + let via_arkworks = Fr::deserialize_compressed(&reversed[..]); + // then the two codecs agree on acceptance, and on the value when both accept + prop_assert_eq!(via_keystem.is_ok(), via_arkworks.is_ok()); + if let (Ok(from_keystem), Ok(from_arkworks)) = (via_keystem, via_arkworks) { + prop_assert_eq!(from_keystem.to_field(), from_arkworks); + } + } +} diff --git a/crates/keystem/tests/second_field.rs b/crates/keystem/tests/second_field.rs new file mode 100644 index 0000000..1cf690b --- /dev/null +++ b/crates/keystem/tests/second_field.rs @@ -0,0 +1,121 @@ +#![cfg(feature = "spend")] + +use ark_bls12_381::Fr; +use ark_ff::{ + BigInteger, + PrimeField, + fields::{ + Fp64, + MontBackend, + MontConfig, + }, +}; +use keystem::{ + OwnerPubkey, + SecretScalar, + SpendingKey, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +/// Seed for every generator drawn in this file, replayed on failure. +const SEED: u64 = 0xB151_2381; + +#[test] +fn spending_key_random_over_bls12_381_yields_canonical_and_distinct_scalars() { + // given a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + // when two BLS12-381 spending keys are drawn from it + let first = SpendingKey::::random(&mut rng); + let second = SpendingKey::::random(&mut rng); + // then both scalars re-encode canonically and the two draws differ + let first_bytes = *first.scalar().expose_bytes(); + let second_bytes = *second.scalar().expose_bytes(); + assert!(SecretScalar::::from_canonical_bytes(first_bytes).is_ok()); + assert!(SecretScalar::::from_canonical_bytes(second_bytes).is_ok()); + assert_ne!(first_bytes, second_bytes); +} + +#[test] +fn spending_key_from_canonical_bytes_over_bls12_381_accepts_the_modulus_minus_one() { + // given the canonical encoding of BLS12-381's largest scalar, the modulus minus one + let bytes = OwnerPubkey::::from_field(-Fr::from(1u64)).to_bytes(); + // when it is decoded as a spending key + let decoded = SpendingKey::::from_canonical_bytes(bytes); + // then it is accepted + assert!(decoded.is_ok()); +} + +#[test] +fn spending_key_from_canonical_bytes_over_bls12_381_rejects_the_modulus() { + // given the big-endian encoding of BLS12-381's scalar field modulus + let bytes: [u8; 32] = Fr::MODULUS + .to_bytes_be() + .try_into() + .expect("BLS12-381's scalar modulus is 32 bytes wide"); + // when it is decoded as a spending key + let decoded = SpendingKey::::from_canonical_bytes(bytes); + // then the modulus comparison rejects it + assert!(decoded.is_err()); +} + +#[test] +fn owner_pubkey_from_field_closes_the_external_derivation_seam_over_bls12_381() { + // given a spending key's revealed scalar, squared as a stand-in for a caller-side hash + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let key = SpendingKey::::random(&mut rng); + let scalar = key.scalar(); + let transformed = scalar.expose_field() * scalar.expose_field(); + // when the transformed value is wrapped as an owner pubkey and re-decoded + let pubkey = OwnerPubkey::::from_field(transformed); + let decoded = OwnerPubkey::::from_canonical_bytes(pubkey.to_bytes()); + // then the seam closes: the re-decoded pubkey matches the one built from the transform + assert_eq!(decoded, Ok(pubkey)); +} + +/// Goldilocks, `2^64 - 2^32 + 1`. Its integer form is one limb, so a 32-byte +/// encoding pads 24 bytes above it, the width a four-limb field never leaves. +#[derive(MontConfig)] +#[modulus = "18446744069414584321"] +#[generator = "7"] +pub struct GoldilocksConfig; + +type Goldilocks = Fp64>; + +/// Pad bytes in a Goldilocks encoding. +const GOLDILOCKS_PAD: usize = 24; + +#[test] +fn encodings_over_a_one_limb_field_clear_the_pad_and_round_trip() { + // given the largest Goldilocks element + let value = -Goldilocks::from(1u64); + // when it is encoded to the fixed width and decoded back + let bytes = OwnerPubkey::::from_field(value).to_bytes(); + let decoded = SecretScalar::::from_canonical_bytes(bytes); + // then the pad is clear and the element survives + assert_eq!(bytes[..GOLDILOCKS_PAD], [0u8; GOLDILOCKS_PAD]); + assert_eq!(decoded.map(|scalar| scalar.expose_field()), Ok(value)); +} + +#[test] +fn spending_key_from_canonical_bytes_over_a_one_limb_field_rejects_a_set_pad_byte() { + // given a canonical Goldilocks encoding with its leading pad byte set + let mut bytes = + OwnerPubkey::::from_field(Goldilocks::from(7u64)).to_bytes(); + bytes[0] = 1; + // when it is decoded as a spending key + let decoded = SpendingKey::::from_canonical_bytes(bytes); + // then the pad check rejects it instead of reading the low limb alone + assert!(decoded.is_err()); +} + +#[test] +fn spending_key_from_canonical_bytes_over_a_one_limb_field_rejects_the_modulus() { + // given the big-endian encoding of the Goldilocks modulus, pad clear + let mut bytes = [0u8; 32]; + bytes[GOLDILOCKS_PAD..].copy_from_slice(&Goldilocks::MODULUS.to_bytes_be()); + // when it is decoded as a spending key + let decoded = SpendingKey::::from_canonical_bytes(bytes); + // then the modulus comparison rejects it + assert!(decoded.is_err()); +} diff --git a/crates/keystem/tests/spend.rs b/crates/keystem/tests/spend.rs new file mode 100644 index 0000000..c7ae3e1 --- /dev/null +++ b/crates/keystem/tests/spend.rs @@ -0,0 +1,400 @@ +#![cfg(feature = "poseidon")] + +use std::collections::HashSet; + +use ark_ff::{ + BigInteger, + PrimeField, +}; +use keystem::{ + SpendAuthority, + curves::bn254::{ + Fr, + OwnerPubkey, + SecretScalar, + SpendingKey, + }, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use zeroize::ZeroizeOnDrop; + +/// Seed for every generator drawn in this file, replayed on failure. +const SEED: u64 = 0xC0FFEE; + +/// Lowercase hex encoding of `bytes`, matching the crate's own `Debug` output. +fn hex_of(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// The canonical big-endian encoding of BN254's largest scalar, modulus minus one. +fn modulus_minus_one_bytes() -> [u8; 32] { + OwnerPubkey::from_field(-Fr::from(1u64)).to_bytes() +} + +/// The big-endian encoding of BN254's scalar field modulus itself. +fn modulus_bytes() -> [u8; 32] { + Fr::MODULUS + .to_bytes_be() + .try_into() + .expect("BN254's scalar modulus is 32 bytes wide") +} + +#[test] +fn spending_key_from_canonical_bytes_accepts_the_modulus_minus_one() { + // given the canonical encoding of BN254's largest scalar + let bytes = modulus_minus_one_bytes(); + // when it is decoded as a spending key + let decoded = SpendingKey::from_canonical_bytes(bytes); + // then it is accepted + assert!(decoded.is_ok()); +} + +#[test] +fn spending_key_from_canonical_bytes_rejects_the_modulus() { + // given the big-endian encoding of the scalar field modulus + let bytes = modulus_bytes(); + // when it is decoded as a spending key + let decoded = SpendingKey::from_canonical_bytes(bytes); + // then the reduce-and-compare check rejects it + assert!(decoded.is_err()); +} + +#[test] +fn spending_key_from_canonical_bytes_rejects_an_all_ones_string() { + // given a byte string far above the modulus + let bytes = [0xffu8; 32]; + // when it is decoded as a spending key + let decoded = SpendingKey::from_canonical_bytes(bytes); + // then it is rejected rather than silently reduced + assert!(decoded.is_err()); +} + +#[test] +fn owner_pubkey_from_canonical_bytes_accepts_the_modulus_minus_one() { + // given the canonical encoding of BN254's largest scalar + let bytes = modulus_minus_one_bytes(); + // when it is decoded as an owner pubkey + let decoded = OwnerPubkey::from_canonical_bytes(bytes); + // then it is accepted + assert!(decoded.is_ok()); +} + +#[test] +fn owner_pubkey_from_canonical_bytes_rejects_the_modulus() { + // given the big-endian encoding of the scalar field modulus + let bytes = modulus_bytes(); + // when it is decoded as an owner pubkey + let decoded = OwnerPubkey::from_canonical_bytes(bytes); + // then the reduce-and-compare check rejects it + assert!(decoded.is_err()); +} + +#[test] +fn owner_pubkey_from_canonical_bytes_rejects_an_all_ones_string() { + // given a byte string far above the modulus + let bytes = [0xffu8; 32]; + // when it is decoded as an owner pubkey + let decoded = OwnerPubkey::from_canonical_bytes(bytes); + // then it is rejected rather than silently reduced + assert!(decoded.is_err()); +} + +#[test] +fn secret_scalar_from_canonical_bytes_accepts_the_modulus_minus_one() { + // given the canonical encoding of BN254's largest scalar + let bytes = modulus_minus_one_bytes(); + // when it is decoded as a secret scalar + let decoded = SecretScalar::from_canonical_bytes(bytes); + // then it is accepted + assert!(decoded.is_ok()); +} + +#[test] +fn secret_scalar_from_canonical_bytes_rejects_the_modulus() { + // given the big-endian encoding of the scalar field modulus + let bytes = modulus_bytes(); + // when it is decoded as a secret scalar + let decoded = SecretScalar::from_canonical_bytes(bytes); + // then the reduce-and-compare check rejects it + assert!(decoded.is_err()); +} + +#[test] +fn secret_scalar_from_canonical_bytes_rejects_an_all_ones_string() { + // given a byte string far above the modulus + let bytes = [0xffu8; 32]; + // when it is decoded as a secret scalar + let decoded = SecretScalar::from_canonical_bytes(bytes); + // then it is rejected rather than silently reduced + assert!(decoded.is_err()); +} + +#[test] +fn spending_key_random_draws_are_canonical_and_distinct() { + // given a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + // when two spending keys are drawn from it + let first = SpendingKey::random(&mut rng); + let second = SpendingKey::random(&mut rng); + // then both scalars re-encode canonically and the two draws differ + let first_bytes = *first.scalar().expose_bytes(); + let second_bytes = *second.scalar().expose_bytes(); + assert!(SecretScalar::from_canonical_bytes(first_bytes).is_ok()); + assert!(SecretScalar::from_canonical_bytes(second_bytes).is_ok()); + assert_ne!(first_bytes, second_bytes); +} + +#[test] +fn spending_key_debug_redacts_the_scalar() { + // given a spending key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let key = SpendingKey::random(&mut rng); + let scalar_hex = hex_of(key.scalar().expose_bytes()); + // when it is rendered with Debug + let rendered = format!("{key:?}"); + // then it shows only the redaction marker and leaks no byte of the scalar + assert_eq!(rendered, "SpendingKey(REDACTED)"); + assert!(!rendered.contains(&scalar_hex[..8])); +} + +#[test] +fn secret_scalar_debug_redacts_the_revealed_bytes() { + // given the scalar revealed by a spending key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let scalar = SpendingKey::random(&mut rng).scalar(); + let scalar_hex = hex_of(scalar.expose_bytes()); + // when it is rendered with Debug + let rendered = format!("{scalar:?}"); + // then it shows only the redaction marker and leaks no byte of the revealed scalar + assert_eq!(rendered, "SecretScalar(REDACTED)"); + assert!(!rendered.contains(&scalar_hex[..8])); +} + +#[test] +fn owner_pubkey_debug_renders_lowercase_hex_of_its_bytes() { + // given an owner pubkey built from the field element one + let pubkey = OwnerPubkey::from_field(Fr::from(1u64)); + // when it is rendered with Debug + let rendered = format!("{pubkey:?}"); + // then it wraps the 64-character lowercase hex encoding of to_bytes + assert_eq!( + rendered, + format!("OwnerPubkey({})", hex_of(&pubkey.to_bytes())) + ); +} + +#[test] +fn secret_scalar_expose_bytes_round_trips_through_from_canonical_bytes() { + // given the scalar revealed by a freshly drawn spending key + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let scalar = SpendingKey::random(&mut rng).scalar(); + // when its exposed bytes are re-decoded + let decoded = SecretScalar::from_canonical_bytes(*scalar.expose_bytes()) + .expect("a freshly drawn scalar's own bytes are canonical"); + // then the recovered field element matches the exposed field value + assert_eq!(decoded.expose_field(), scalar.expose_field()); +} + +#[test] +fn secret_scalar_expose_field_agrees_with_owner_pubkey_to_field_for_the_same_bytes() { + // given the canonical encoding of the field element seven + let bytes = OwnerPubkey::from_field(Fr::from(7u64)).to_bytes(); + // when the same bytes are decoded once as a secret scalar and once as an owner pubkey + let scalar = + SecretScalar::from_canonical_bytes(bytes).expect("seven's encoding is canonical"); + let pubkey = + OwnerPubkey::from_canonical_bytes(bytes).expect("seven's encoding is canonical"); + // then both expose the same field element + assert_eq!(scalar.expose_field(), pubkey.to_field()); +} + +#[test] +fn derive_owner_pubkey_is_deterministic_for_one_key() { + // given a spending key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let key = SpendingKey::random(&mut rng); + // when the owner pubkey is derived twice + let first = key.derive_owner_pubkey(); + let second = key.derive_owner_pubkey(); + // then both derivations agree + assert_eq!(first, second); +} + +#[test] +fn derive_owner_pubkey_differs_across_distinct_keys() { + // given two spending keys drawn from one seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let first_key = SpendingKey::random(&mut rng); + let second_key = SpendingKey::random(&mut rng); + // when their owner pubkeys are derived + let first_pubkey = first_key.derive_owner_pubkey(); + let second_pubkey = second_key.derive_owner_pubkey(); + // then the two distinct keys produce distinct credentials + assert_ne!(first_pubkey, second_pubkey); +} + +#[test] +fn owner_pubkey_from_field_round_trips_through_to_field() { + // given the field element three + let value = Fr::from(3u64); + // when it is wrapped as an owner pubkey and unwrapped again + let recovered = OwnerPubkey::from_field(value).to_field(); + // then the field element survives unchanged + assert_eq!(recovered, value); +} + +#[test] +fn owner_pubkey_from_canonical_bytes_of_from_field_closes_the_derivation_seam() { + // given the owner pubkey built from the field element three + let pubkey = OwnerPubkey::from_field(Fr::from(3u64)); + // when its bytes are decoded again through from_canonical_bytes + let decoded = OwnerPubkey::from_canonical_bytes(pubkey.to_bytes()); + // then the external-derivation seam closes on the same pubkey + assert_eq!(decoded, Ok(pubkey)); +} + +#[test] +fn owner_pubkey_eq_and_hash_agree_across_construction_routes() { + // given one field element reached by from_field and by from_canonical_bytes + let via_field = OwnerPubkey::from_field(Fr::from(9u64)); + let via_bytes = OwnerPubkey::from_canonical_bytes(via_field.to_bytes()) + .expect("from_field always yields a canonical encoding"); + // when both are inserted into a hash set + let mut set = HashSet::new(); + set.insert(via_field); + set.insert(via_bytes); + // then they compare equal and collapse to one entry + assert_eq!(via_field, via_bytes); + assert_eq!(set.len(), 1); +} + +/// Reads the credential off any spend authority whose field is BN254's `Fr`, +/// proving the forwarding impl on `&A` is usable wherever `A` is. +fn credential_of(authority: A) -> OwnerPubkey +where + A: SpendAuthority, + A::Error: core::fmt::Debug, +{ + authority + .owner_pubkey() + .expect("an in-memory spend authority never fails to derive") +} + +#[test] +fn spend_authority_on_spending_key_and_its_reference_agree_with_derive_owner_pubkey() { + // given a spending key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let key = SpendingKey::random(&mut rng); + let expected = key.derive_owner_pubkey(); + // when the credential is read through SpendAuthority, once by reference and once by value + let via_reference = credential_of(&key); + let via_value = credential_of(key); + // then both agree with the inherent derivation + assert_eq!(via_reference, expected); + assert_eq!(via_value, expected); +} + +#[test] +fn spend_authority_scalar_on_spending_key_matches_the_inherent_scalar() { + // given a spending key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let key = SpendingKey::random(&mut rng); + // when the scalar is read through SpendAuthority instead of the inherent method + let via_trait = + SpendAuthority::scalar(&key).expect("an in-memory key always reveals its scalar"); + // then it matches the inherent scalar's exposed field element + assert_eq!(via_trait.expose_field(), key.scalar().expose_field()); +} + +#[test] +fn secret_types_wipe_themselves_on_drop() { + // given a bound only a type with a zeroizing Drop can satisfy + fn wipes_on_drop() {} + // when it is applied to the two secret-bearing spend types + wipes_on_drop::(); + wipes_on_drop::(); + // then both carry the impl, so a dropped key does not leave its bytes behind +} + +#[cfg(feature = "test-helpers")] +mod sealed_custody { + use keystem::{ + NotExportable, + SpendAuthority, + curves::bn254::{ + Fr, + OwnerPubkey, + }, + test_util::SealedCustody, + }; + + #[test] + fn sealed_custody_returns_its_enrolled_pubkey_and_refuses_to_export_the_scalar() { + // given custody enrolled with a known owner pubkey + let owner_pubkey = OwnerPubkey::from_field(Fr::from(5u64)); + let custody = SealedCustody::enrolled(owner_pubkey); + // when its credential and its scalar are both requested + let returned_pubkey = custody.owner_pubkey(); + let returned_scalar = custody.scalar(); + // then the pubkey comes back and the scalar reveal is refused + assert_eq!(returned_pubkey, Ok(owner_pubkey)); + assert_eq!(returned_scalar.unwrap_err(), NotExportable); + } +} + +#[cfg(feature = "serde")] +mod owner_pubkey_serde { + use keystem::curves::bn254::{ + Fr, + OwnerPubkey, + }; + + use super::modulus_bytes; + + #[test] + fn owner_pubkey_round_trips_through_serde_json() { + // given an owner pubkey built from the field element two + let pubkey = OwnerPubkey::from_field(Fr::from(2u64)); + // when it is serialized to JSON and deserialized back + let json = serde_json::to_string(&pubkey).expect("owner pubkeys serialize"); + let decoded: OwnerPubkey = + serde_json::from_str(&json).expect("its own JSON deserializes"); + // then the recovered pubkey matches the original + assert_eq!(decoded, pubkey); + } + + #[test] + fn owner_pubkey_deserialize_rejects_a_json_encoding_of_the_modulus() { + // given a JSON byte array encoding the scalar field modulus + let json = + serde_json::to_string(&modulus_bytes()).expect("a byte array serializes"); + // when it is deserialized as an owner pubkey + let decoded: Result = serde_json::from_str(&json); + // then the checked decode rejects it as a deserialization error + assert!(decoded.is_err()); + } +} + +#[cfg(feature = "expose-secret-serde")] +mod spending_key_serde { + use keystem::curves::bn254::SpendingKey; + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + + use super::SEED; + + #[test] + fn spending_key_round_trips_through_serde_json_and_derives_the_same_owner_pubkey() { + // given a spending key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(SEED); + let key = SpendingKey::random(&mut rng); + let expected = key.derive_owner_pubkey(); + // when it is serialized to JSON and deserialized back + let json = serde_json::to_string(&key).expect("spending keys serialize"); + let recovered: SpendingKey = + serde_json::from_str(&json).expect("its own JSON deserializes"); + // then the recovered key derives the same owner pubkey + assert_eq!(recovered.derive_owner_pubkey(), expected); + } +} diff --git a/crates/keystem/tests/viewing_k256.rs b/crates/keystem/tests/viewing_k256.rs new file mode 100644 index 0000000..945c6c1 --- /dev/null +++ b/crates/keystem/tests/viewing_k256.rs @@ -0,0 +1,123 @@ +#![cfg(all(feature = "k256", feature = "test-helpers"))] + +use keystem::{ + InvalidKey, + KemKeyOps, + ViewingKey, + ViewingPubkey, + family::Incoming, + test_util::{ + conformance_generate_sk_draws, + conformance_sk_codec_roundtrips, + conformance_wrong_length_fails, + }, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use sealring::K256; + +/// Seed for the k256 conformance run. +const K256_CONFORMANCE_SEED: u64 = 41; +/// Seed for the k256 sk-bytes round trip. +const K256_SK_ROUNDTRIP_SEED: u64 = 101; +/// Seed for the k256 pubkey-bytes round trip. +const K256_PUBKEY_ROUNDTRIP_SEED: u64 = 111; +/// Seed for the k256 wrong-length rejection. +const K256_WRONG_LENGTH_SEED: u64 = 151; +/// Seed for the Debug redaction check. +const DEBUG_SEED: u64 = 201; + +#[test] +fn k256_satisfies_the_kem_key_ops_conformance_suite() { + // given a seeded generator and a fresh secp256k1 secret key drawn from it + let mut rng = ChaCha20Rng::seed_from_u64(K256_CONFORMANCE_SEED); + let sk = K256::generate_sk(&mut rng); + // when the shared conformance probes run against it + conformance_sk_codec_roundtrips::(&sk); + conformance_wrong_length_fails::(&sk); + conformance_generate_sk_draws::(&mut rng); + // then every codec, wrong-length, and draw-distinctness probe holds without panicking +} + +#[test] +fn viewing_key_to_sk_bytes_then_from_sk_bytes_round_trips_for_k256() { + // given a k256 viewing key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(K256_SK_ROUNDTRIP_SEED); + let original: ViewingKey = ViewingKey::random(&mut rng); + let expected = original.derive_pubkey(); + // when its secret bytes are persisted through to_sk_bytes and reloaded through from_sk_bytes + let sk_bytes = original.to_sk_bytes(); + let reloaded: ViewingKey = + ViewingKey::from_sk_bytes(sk_bytes.as_ref()) + .expect("a freshly encoded sk decodes"); + // then the reloaded key derives the same viewing pubkey + assert_eq!(reloaded.derive_pubkey(), expected); +} + +#[test] +fn viewing_pubkey_to_bytes_then_from_bytes_round_trips_for_k256() { + // given a k256 viewing pubkey derived from a freshly drawn viewing key + let mut rng = ChaCha20Rng::seed_from_u64(K256_PUBKEY_ROUNDTRIP_SEED); + let key: ViewingKey = ViewingKey::random(&mut rng); + let pubkey = key.derive_pubkey(); + // when its wire encoding is decoded back through from_bytes + let decoded: ViewingPubkey = + ViewingPubkey::from_bytes(pubkey.to_bytes().as_ref()) + .expect("its own encoding decodes"); + // then the recovered pubkey equals the original + assert_eq!(decoded, pubkey); +} + +#[test] +fn viewing_key_from_sk_bytes_rejects_a_wrong_length_string_for_k256() { + // given a k256 secret-key encoding with one extra byte appended + let mut rng = ChaCha20Rng::seed_from_u64(K256_WRONG_LENGTH_SEED); + let sk = K256::generate_sk(&mut rng); + let mut encoded = K256::encode_sk(&sk).as_ref().to_vec(); + encoded.push(0); + // when it is decoded as a viewing key + let decoded = ViewingKey::::from_sk_bytes(&encoded); + // then the wrong-length string is rejected + assert_eq!(decoded.err(), Some(InvalidKey)); +} + +#[test] +fn viewing_key_debug_redacts_the_secret_key() { + // given a k256 viewing key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(DEBUG_SEED); + let key: ViewingKey = ViewingKey::random(&mut rng); + // when it is rendered with Debug + let rendered = format!("{key:?}"); + // then it prints only the redaction marker + assert_eq!(rendered, "ViewingKey(REDACTED)"); +} + +#[cfg(feature = "serde")] +mod viewing_pubkey_serde { + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + use sealring::K256; + + use super::{ + Incoming, + ViewingKey, + ViewingPubkey, + }; + + /// Seed for the k256 serde round trip. + const K256_SERDE_SEED: u64 = 301; + + #[test] + fn viewing_pubkey_round_trips_through_serde_json_for_k256() { + // given a k256 viewing pubkey derived from a freshly drawn viewing key + let mut rng = ChaCha20Rng::seed_from_u64(K256_SERDE_SEED); + let key: ViewingKey = ViewingKey::random(&mut rng); + let pubkey = key.derive_pubkey(); + // when it is serialized to JSON and deserialized back + let json = serde_json::to_string(&pubkey).expect("viewing pubkeys serialize"); + let decoded: ViewingPubkey = + serde_json::from_str(&json).expect("its own JSON deserializes"); + // then the recovered pubkey equals the original + assert_eq!(decoded, pubkey); + } +} diff --git a/crates/keystem/tests/viewing_x25519.rs b/crates/keystem/tests/viewing_x25519.rs new file mode 100644 index 0000000..75b6653 --- /dev/null +++ b/crates/keystem/tests/viewing_x25519.rs @@ -0,0 +1,181 @@ +#![cfg(all(feature = "x25519", feature = "test-helpers"))] + +use keystem::{ + InvalidKey, + KemKeyOps, + ViewingKey, + ViewingPubkey, + family::{ + Compliance, + Incoming, + }, + test_util::{ + conformance_generate_sk_draws, + conformance_sk_codec_roundtrips, + conformance_wrong_length_fails, + }, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use sealring::{ + Domain, + X25519, + open, + seal, +}; + +/// Seed for the x25519 conformance run. +const X25519_CONFORMANCE_SEED: u64 = 43; +/// Seed for the x25519 sk-bytes round trip. +const X25519_SK_ROUNDTRIP_SEED: u64 = 103; +/// Seed for the x25519 pubkey-bytes round trip. +const X25519_PUBKEY_ROUNDTRIP_SEED: u64 = 113; +/// Seed for the x25519 wrong-length rejection. +const X25519_WRONG_LENGTH_SEED: u64 = 153; +/// Seed for the family-separation check. +const FAMILY_SEPARATION_SEED: u64 = 211; +/// Seed for the seal/open interop check. +const SEAL_OPEN_SEED: u64 = 221; + +#[test] +fn x25519_satisfies_the_kem_key_ops_conformance_suite() { + // given a seeded generator and a fresh x25519 secret key drawn from it + let mut rng = ChaCha20Rng::seed_from_u64(X25519_CONFORMANCE_SEED); + let sk = X25519::generate_sk(&mut rng); + // when the shared conformance probes run against it + conformance_sk_codec_roundtrips::(&sk); + conformance_wrong_length_fails::(&sk); + conformance_generate_sk_draws::(&mut rng); + // then every codec, wrong-length, and draw-distinctness probe holds without panicking +} + +#[test] +fn viewing_key_to_sk_bytes_then_from_sk_bytes_round_trips_for_x25519() { + // given an x25519 viewing key drawn from a seeded generator + let mut rng = ChaCha20Rng::seed_from_u64(X25519_SK_ROUNDTRIP_SEED); + let original: ViewingKey = ViewingKey::random(&mut rng); + let expected = original.derive_pubkey(); + // when its secret bytes are persisted through to_sk_bytes and reloaded through from_sk_bytes + let sk_bytes = original.to_sk_bytes(); + let reloaded: ViewingKey = + ViewingKey::from_sk_bytes(sk_bytes.as_ref()) + .expect("a freshly encoded sk decodes"); + // then the reloaded key derives the same viewing pubkey + assert_eq!(reloaded.derive_pubkey(), expected); +} + +#[test] +fn viewing_pubkey_to_bytes_then_from_bytes_round_trips_for_x25519() { + // given an x25519 viewing pubkey derived from a freshly drawn viewing key + let mut rng = ChaCha20Rng::seed_from_u64(X25519_PUBKEY_ROUNDTRIP_SEED); + let key: ViewingKey = ViewingKey::random(&mut rng); + let pubkey = key.derive_pubkey(); + // when its wire encoding is decoded back through from_bytes + let decoded: ViewingPubkey = + ViewingPubkey::from_bytes(pubkey.to_bytes().as_ref()) + .expect("its own encoding decodes"); + // then the recovered pubkey equals the original + assert_eq!(decoded, pubkey); +} + +#[test] +fn viewing_key_from_sk_bytes_rejects_a_wrong_length_string_for_x25519() { + // given an x25519 secret-key encoding with one extra byte appended + let mut rng = ChaCha20Rng::seed_from_u64(X25519_WRONG_LENGTH_SEED); + let sk = X25519::generate_sk(&mut rng); + let mut encoded = X25519::encode_sk(&sk).as_ref().to_vec(); + encoded.push(0); + // when it is decoded as a viewing key + let decoded = ViewingKey::::from_sk_bytes(&encoded); + // then the wrong-length string is rejected + assert_eq!(decoded.err(), Some(InvalidKey)); +} + +#[test] +fn incoming_and_compliance_viewing_keys_from_the_same_sk_bytes_derive_equal_wire_pubkeys() +{ + // given the same x25519 secret-key bytes handed to two different disclosure families + let mut rng = ChaCha20Rng::seed_from_u64(FAMILY_SEPARATION_SEED); + let sk_bytes = X25519::encode_sk(&X25519::generate_sk(&mut rng)); + let incoming: ViewingKey = + ViewingKey::from_sk_bytes(&sk_bytes).expect("freshly generated sk bytes decode"); + let compliance: ViewingKey = + ViewingKey::from_sk_bytes(&sk_bytes).expect("freshly generated sk bytes decode"); + // when both derive their viewing pubkeys and encode them to wire bytes + let incoming_bytes = incoming.derive_pubkey().to_bytes(); + let compliance_bytes = compliance.derive_pubkey().to_bytes(); + // then the two families derive equal wire encodings, so the marker costs nothing on the wire + assert_eq!(incoming_bytes.as_ref(), compliance_bytes.as_ref()); +} + +/// Note domain for the seal/open interop check: every byte string is a valid note. +struct KeystemTestDomain; + +impl Domain for KeystemTestDomain { + type Error = core::convert::Infallible; + type Note = Vec; + + const DOMAIN_TAG: &'static str = "keystem-test/v1"; + + fn encode_note(note: &Self::Note, out: &mut Vec) -> Result<(), Self::Error> { + out.extend_from_slice(note); + Ok(()) + } + + fn decode_note(bytes: &[u8]) -> Result { + Ok(bytes.to_vec()) + } +} + +#[test] +fn sealing_a_note_to_an_x25519_viewing_pubkey_opens_with_the_matching_viewing_key() { + // given an x25519 viewing key and a note a sender wants to deliver to it + let mut rng = ChaCha20Rng::seed_from_u64(SEAL_OPEN_SEED); + let viewing_key: ViewingKey = ViewingKey::random(&mut rng); + let viewing_pubkey = viewing_key.derive_pubkey(); + let note = vec![9u8, 8, 7, 6]; + let aad = b"keystem-viewing-test-aad"; + // when the note is sealed to the pubkey and opened with the viewing key's own recipient + let envelope = seal::( + viewing_pubkey.public_key(), + ¬e, + aad, + &mut rng, + ) + .expect("sealing to a valid pubkey succeeds"); + let opened = + open::(viewing_key.recipient(), &envelope, aad) + .expect("opening with the matching recipient succeeds"); + // then the note comes back unchanged + assert_eq!(opened, Some(note)); +} + +#[cfg(feature = "serde")] +mod viewing_pubkey_serde { + use rand_chacha::ChaCha20Rng; + use rand_core::SeedableRng; + use sealring::X25519; + + use super::{ + Incoming, + ViewingKey, + ViewingPubkey, + }; + + /// Seed for the x25519 serde round trip. + const X25519_SERDE_SEED: u64 = 303; + + #[test] + fn viewing_pubkey_round_trips_through_serde_json_for_x25519() { + // given an x25519 viewing pubkey derived from a freshly drawn viewing key + let mut rng = ChaCha20Rng::seed_from_u64(X25519_SERDE_SEED); + let key: ViewingKey = ViewingKey::random(&mut rng); + let pubkey = key.derive_pubkey(); + // when it is serialized to JSON and deserialized back + let json = serde_json::to_string(&pubkey).expect("viewing pubkeys serialize"); + let decoded: ViewingPubkey = + serde_json::from_str(&json).expect("its own JSON deserializes"); + // then the recovered pubkey equals the original + assert_eq!(decoded, pubkey); + } +} diff --git a/crates/sealring/Cargo.toml b/crates/sealring/Cargo.toml index 2b363a2..f17a489 100644 --- a/crates/sealring/Cargo.toml +++ b/crates/sealring/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sealring" -version = "0.3.0" +version = "0.4.0" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/sealring/README.md b/crates/sealring/README.md index 474b6c9..7c96f89 100644 --- a/crates/sealring/README.md +++ b/crates/sealring/README.md @@ -61,7 +61,7 @@ Batching alone is worth about 6% on k256 and nothing on x25519, which has no inv - the AEAD nonce is derived from the KDF. It binds the nonce to the same transcript as the key, so a key-schedule edit that changes one changes both, and an implementer never picks a nonce by hand. It buys nothing against an RNG replay: a replayed ephemeral key reproduces the shared secret, hence the key and the nonce alike. Callers who need to survive a VM snapshot or fork must rotate the recipient key or bind a counter into the AAD. - every envelope carries a key-commitment tag (`commit`), a third HKDF-Expand output over the same transcript as the key and nonce. It commits to that transcript, not to the ciphertext the way CTX does, which is enough here because the AEAD tag already binds the ciphertext. ChaCha20-Poly1305 is not key-committing on its own, so one crafted ciphertext could otherwise open validly under two different recipients' keys to two different plaintexts, and a trial-decryption scanner would accept it automatically. `open` recomputes `commit` and compares it in constant time before the AEAD ever runs. - anonymous sender by design: there is no sender authentication anywhere in the envelope, and none is planned. -- adapter correctness is enforced by a conformance test suite (`test-helpers` feature), not by trait bounds: garbage byte strings, low-order points, the identity point, and all-zero Diffie-Hellman outputs must all fail to decapsulate, and `derive_pk` must reproduce the public key belonging to a secret key. Third-party `Kem` implementations are expected to run it. +- adapter correctness is enforced by a conformance test suite (`test-helpers` feature), not by trait bounds: garbage byte strings, low-order points, the identity point, and all-zero Diffie-Hellman outputs must all fail to decapsulate, `derive_pk` must reproduce the public key belonging to a secret key, and `decode_pk` must invert `encode_pk` while rejecting the same garbage `decap` rejects. Third-party `Kem` implementations are expected to run it. - secret hygiene is not decorative: `SecretKey` and `SharedSecret` carry no `Debug`, `Clone`, or derived `PartialEq`; commit comparison and other secret-adjacent equality checks go through `subtle`; every `SharedSecret` is zeroized once consumed, including scanner scratch buffers, derived key material, and batch out-slots. One residue is upstream: `hkdf` 0.13 keeps the extracted PRK inside an HMAC state that has no `Zeroize` impl, so that copy lives until the allocation is reused. diff --git a/crates/sealring/src/adapters/grumpkin.rs b/crates/sealring/src/adapters/grumpkin.rs index 02a8a98..dcf93b6 100644 --- a/crates/sealring/src/adapters/grumpkin.rs +++ b/crates/sealring/src/adapters/grumpkin.rs @@ -172,14 +172,7 @@ impl Kem for Grumpkin { } fn decap(sk: &Self::SecretKey, epk: &[u8]) -> Option { - if epk.len() != GRUMPKIN_EPK_LEN { - return None; - } - let point = Affine::deserialize_compressed(epk).ok()?; - if point.is_zero() { - return None; - } - let shared = (point * *sk).into_affine(); + let shared = (Self::decode_pk(epk)? * *sk).into_affine(); Some(GrumpkinSharedSecret(x_coordinate(&shared)?)) } @@ -191,6 +184,17 @@ impl Kem for Grumpkin { compress(pk) } + /// Cofactor 1 means an on-curve decode needs no subgroup check, so only + /// the identity is rejected. The length check is what stops a longer + /// string from decoding through its first 32 bytes. + fn decode_pk(bytes: &[u8]) -> Option { + if bytes.len() != GRUMPKIN_EPK_LEN { + return None; + } + let point = Affine::deserialize_compressed(bytes).ok()?; + (!point.is_zero()).then_some(point) + } + /// Scalar-multiplies every epk in the chunk, then converts the whole /// batch out of Jacobian coordinates at once. /// @@ -211,15 +215,9 @@ impl Kem for Grumpkin { for (i, (epk, slot)) in epks.iter().zip(out.iter_mut()).enumerate() { *slot = None; - if epk.len() != GRUMPKIN_EPK_LEN { - continue; - } - let Ok(point) = Affine::deserialize_compressed(*epk) else { + let Some(point) = Self::decode_pk(epk) else { continue; }; - if point.is_zero() { - continue; - } let shared = point * *sk; // An identity result has no x-coordinate to report, and // Montgomery's trick needs every element it inverts non-zero. diff --git a/crates/sealring/src/adapters/k256.rs b/crates/sealring/src/adapters/k256.rs index d2bb76c..6d908b1 100644 --- a/crates/sealring/src/adapters/k256.rs +++ b/crates/sealring/src/adapters/k256.rs @@ -59,7 +59,7 @@ impl Kem for K256 { } fn decap(sk: &Self::SecretKey, epk: &[u8]) -> Option { - let pk = k256::PublicKey::from_sec1_bytes(epk).ok()?; + let pk = Self::decode_pk(epk)?; let shared = k256::ecdh::diffie_hellman(sk.to_nonzero_scalar(), pk.as_affine()); let bytes: &[u8; 32] = shared.raw_secret_bytes().as_ref(); Some(K256SharedSecret(*bytes)) @@ -76,6 +76,11 @@ impl Kem for K256 { epk } + /// SEC1 decoding rejects the identity and off-curve points. + fn decode_pk(bytes: &[u8]) -> Option { + k256::PublicKey::from_sec1_bytes(bytes).ok() + } + fn decap_batch( sk: &Self::SecretKey, epks: &[&[u8]], @@ -89,7 +94,7 @@ impl Kem for K256 { for (i, (epk, slot)) in epks.iter().zip(out.iter_mut()).enumerate() { *slot = None; - let Ok(pk) = k256::PublicKey::from_sec1_bytes(epk) else { + let Some(pk) = Self::decode_pk(epk) else { continue; }; indices[valid] = i; diff --git a/crates/sealring/src/adapters/x25519.rs b/crates/sealring/src/adapters/x25519.rs index 50ab632..56435b1 100644 --- a/crates/sealring/src/adapters/x25519.rs +++ b/crates/sealring/src/adapters/x25519.rs @@ -57,8 +57,7 @@ impl Kem for X25519 { } fn decap(sk: &Self::SecretKey, epk: &[u8]) -> Option { - let epk_bytes: [u8; X25519_EPK_LEN] = epk.try_into().ok()?; - let shared = sk.diffie_hellman(&PublicKey::from(epk_bytes)); + let shared = sk.diffie_hellman(&Self::decode_pk(epk)?); let shared_bytes = shared.to_bytes(); let is_zero: bool = shared_bytes .as_slice() @@ -77,4 +76,13 @@ impl Kem for X25519 { fn encode_pk(pk: &Self::PublicKey) -> Self::Epk { pk.to_bytes() } + + /// Every 32-byte string names a point. Low-order ones produce an all-zero + /// Diffie-Hellman output, which `decap` rejects, so the check that matters + /// happens where the secret is. + fn decode_pk(bytes: &[u8]) -> Option { + Some(PublicKey::from( + <[u8; X25519_EPK_LEN]>::try_from(bytes).ok()?, + )) + } } diff --git a/crates/sealring/src/kem.rs b/crates/sealring/src/kem.rs index 5f6de46..4a64e15 100644 --- a/crates/sealring/src/kem.rs +++ b/crates/sealring/src/kem.rs @@ -48,6 +48,15 @@ pub trait Kem { /// Encodes `pk` in the same format as `Epk`. fn encode_pk(pk: &Self::PublicKey) -> Self::Epk; + /// Decodes what [`encode_pk`](Self::encode_pk) produced, returning `None` + /// for byte strings that name no public key. + /// + /// The sender's import path for a counterparty key. It reads the same + /// encoding [`decap`](Self::decap) reads, so it rejects the same inputs. + /// An adapter proves both properties with `conformance_pk_codec_roundtrips` + /// and `conformance_garbage_fails` from the `test-helpers` suite. + fn decode_pk(bytes: &[u8]) -> Option; + /// Decapsulates a batch of ephemeral keys, one output slot per input. /// /// The default is a scalar loop over [`decap`](Self::decap); adapters diff --git a/crates/sealring/src/test_util.rs b/crates/sealring/src/test_util.rs index cb5083d..d6e5e3e 100644 --- a/crates/sealring/src/test_util.rs +++ b/crates/sealring/src/test_util.rs @@ -88,6 +88,10 @@ impl Kem for MockKem { fn encode_pk(pk: &Self::PublicKey) -> Self::Epk { *pk } + + fn decode_pk(bytes: &[u8]) -> Option { + bytes.try_into().ok() + } } /// Toy domain: notes are raw bytes, tag `"sealring-test"`. @@ -109,20 +113,36 @@ impl Domain for TestDomain { } } -/// Asserts that decapsulating structurally invalid epk byte strings under -/// `sk` yields `None`: the empty string, and lengths shorter and longer -/// than `K::EPK_LEN`. +/// Asserts structurally invalid byte strings reach no key: the empty string, +/// and lengths shorter and longer than `K::EPK_LEN`. `decap` and `decode_pk` +/// read the same encoding, so both must reject all three. pub fn conformance_garbage_fails(sk: &K::SecretKey) { - assert!(K::decap(sk, &[]).is_none(), "empty epk must fail decap"); let too_short = vec![0xAAu8; K::EPK_LEN.saturating_sub(1)]; - assert!( - K::decap(sk, &too_short).is_none(), - "short epk must fail decap" - ); let too_long = vec![0xAAu8; K::EPK_LEN + 1]; - assert!( - K::decap(sk, &too_long).is_none(), - "long epk must fail decap" + + for case in [[].as_slice(), &too_short, &too_long] { + let len = case.len(); + assert!( + K::decap(sk, case).is_none(), + "garbage epk of {len} bytes must fail decap" + ); + assert!( + K::decode_pk(case).is_none(), + "garbage of {len} bytes must decode to no public key" + ); + } +} + +/// Asserts `decode_pk` inverts `encode_pk`, the property a sender rests on +/// when it imports a counterparty's key from the wire. An adapter that gets +/// this wrong seals to a key nobody holds. +pub fn conformance_pk_codec_roundtrips(pk: &K::PublicKey) { + let encoded = K::encode_pk(pk); + let decoded = K::decode_pk(encoded.as_ref()).expect("own pk encoding must decode"); + assert_eq!( + K::encode_pk(&decoded).as_ref(), + encoded.as_ref(), + "decode_pk must invert encode_pk" ); } diff --git a/crates/sealring/tests/adapter_grumpkin.rs b/crates/sealring/tests/adapter_grumpkin.rs index d53c63f..c945d62 100644 --- a/crates/sealring/tests/adapter_grumpkin.rs +++ b/crates/sealring/tests/adapter_grumpkin.rs @@ -27,6 +27,7 @@ use sealring::{ conformance_derive_pk_agrees, conformance_garbage_fails, conformance_low_order_fails, + conformance_pk_codec_roundtrips, conformance_roundtrip, }, }; @@ -71,6 +72,7 @@ fn encap_decap_roundtrip_agrees() { let (sk, pk) = keypair(&mut rng); conformance_roundtrip::(&mut rng, &pk, &sk); conformance_derive_pk_agrees::(&pk, &sk); + conformance_pk_codec_roundtrips::(&pk); } #[test] diff --git a/crates/sealring/tests/adapter_k256.rs b/crates/sealring/tests/adapter_k256.rs index c15e5f5..aa2f7e1 100644 --- a/crates/sealring/tests/adapter_k256.rs +++ b/crates/sealring/tests/adapter_k256.rs @@ -16,6 +16,7 @@ use sealring::{ conformance_derive_pk_agrees, conformance_garbage_fails, conformance_low_order_fails, + conformance_pk_codec_roundtrips, conformance_roundtrip, }, }; @@ -33,6 +34,7 @@ fn conformance_suite() { conformance_low_order_fails::(&sk, &[IDENTITY_EPK]); conformance_roundtrip::(&mut rng, &pk, &sk); conformance_derive_pk_agrees::(&pk, &sk); + conformance_pk_codec_roundtrips::(&pk); } #[test] diff --git a/crates/sealring/tests/adapter_x25519.rs b/crates/sealring/tests/adapter_x25519.rs index c44d797..20edff0 100644 --- a/crates/sealring/tests/adapter_x25519.rs +++ b/crates/sealring/tests/adapter_x25519.rs @@ -12,6 +12,7 @@ use sealring::{ conformance_derive_pk_agrees, conformance_garbage_fails, conformance_low_order_fails, + conformance_pk_codec_roundtrips, conformance_roundtrip, }, }; @@ -83,6 +84,7 @@ fn conformance_suite() { conformance_roundtrip::(&mut rng, &pk, &sk); conformance_derive_pk_agrees::(&pk, &sk); + conformance_pk_codec_roundtrips::(&pk); } #[test] diff --git a/crates/sealring/tests/scan_alloc.rs b/crates/sealring/tests/scan_alloc.rs index cbf3ddf..2306913 100644 --- a/crates/sealring/tests/scan_alloc.rs +++ b/crates/sealring/tests/scan_alloc.rs @@ -12,13 +12,8 @@ use std::{ Mutex, MutexGuard, atomic::{ - AtomicBool, AtomicUsize, - Ordering::{ - Acquire, - Relaxed, - Release, - }, + Ordering::Relaxed, }, }, }; From 110f82d2be3b0cca46628345473afae3502161c6 Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:48:24 +0530 Subject: [PATCH 2/3] fix: trailing comment --- crates/keystem/src/spend.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/keystem/src/spend.rs b/crates/keystem/src/spend.rs index 3dcc8c6..610aa77 100644 --- a/crates/keystem/src/spend.rs +++ b/crates/keystem/src/spend.rs @@ -24,7 +24,6 @@ use crate::{ /// Holds the field's canonical big-endian encoding and is canonical by /// construction: rejection sampling or checked decode are the only ways in. /// Zeroized on drop, `Debug` prints `REDACTED`. -/// ``` #[derive(Zeroize, ZeroizeOnDrop)] pub struct SpendingKey { bytes: [u8; KEY_LEN], From 59f964f4a7a3c6d4a69be0a4ec51facfbc3244a5 Mon Sep 17 00:00:00 2001 From: rymnc <43716372+rymnc@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:27:40 +0530 Subject: [PATCH 3/3] fix: some audit findings --- crates/keystem/README.md | 1 + crates/keystem/src/address.rs | 121 ++++++++++++++++++++++++ crates/keystem/src/error.rs | 18 ++++ crates/keystem/src/lib.rs | 10 +- crates/keystem/tests/address.rs | 66 +++++++++++++ crates/sealring/src/adapters/x25519.rs | 24 +++-- crates/sealring/src/kem.rs | 5 - crates/sealring/tests/adapter_x25519.rs | 33 +++++++ 8 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 crates/keystem/src/address.rs create mode 100644 crates/keystem/tests/address.rs diff --git a/crates/keystem/README.md b/crates/keystem/README.md index 56606b2..3cbb306 100644 --- a/crates/keystem/README.md +++ b/crates/keystem/README.md @@ -19,6 +19,7 @@ That uniformity is what makes this packaging rather than design. The constructio - **spend**: a `SpendingKey` is canonical by construction, decoded or drawn only through checked paths, and never through a raw byte cast. `derive_owner_pubkey` runs the shared Poseidon1 permutation and returns the public credential. - **custody**: `SpendAuthority` distinguishes between the key and whatever holds it. The in-memory `SpendingKey` answers both `owner_pubkey` and `scalar`; a non-exporting custodian answers the pubkey and returns `NotExportable` from `scalar`. - **view**: a `ViewingKey` wraps a KEM keypair from a curve family `K`, tagged with a disclosure channel `F`. `Incoming`, `Compliance`, and `Audit` are distinct ZSTs, so a value built for one channel cannot be handed to code expecting another. +- **address**: an `Address` carries the owner pubkey and a viewing pubkey as one value, so a consumer authenticates one credential. diff --git a/crates/keystem/src/address.rs b/crates/keystem/src/address.rs new file mode 100644 index 0000000..9baed54 --- /dev/null +++ b/crates/keystem/src/address.rs @@ -0,0 +1,121 @@ +#[cfg(not(feature = "std"))] +use alloc::vec::Vec; +use core::fmt; +#[cfg(feature = "std")] +use std::vec::Vec; + +use ark_ff::PrimeField; +use sealring::Kem; + +use crate::{ + encoding::KEY_LEN, + error::InvalidAddress, + spend::OwnerPubkey, + viewing::ViewingPubkey, +}; + +/// The two credentials a wallet publishes, as one value. +/// +/// Held apart, an owner pubkey and a viewing pubkey are two things a consumer +/// authenticates separately, or forgets to: swapping the viewing half leaves +/// the spend half intact and nothing in the types notices. Held together, +/// substituting either half produces a different `Address`, so there is one +/// value to authenticate instead of two. +/// +/// That is the whole property. An address is not self-certifying: checking a +/// viewing pubkey against an owner pubkey needs the spending key, and the owner +/// pubkey is a one-way image of it. Where an address came from is still an +/// out-of-band question. +pub struct Address { + owner: OwnerPubkey, + viewing: ViewingPubkey, +} + +impl Address { + /// Pairs the credentials of one wallet. + pub fn new(owner: OwnerPubkey, viewing: ViewingPubkey) -> Self { + Self { owner, viewing } + } + + /// The public spend credential note commitments carry. + pub fn owner_pubkey(&self) -> OwnerPubkey { + self.owner + } + + /// The read credential for this disclosure channel. + pub fn viewing_pubkey(&self) -> &ViewingPubkey { + &self.viewing + } + + /// The wire encoding: the owner pubkey, then the viewing pubkey. + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(KEY_LEN + K::EPK_LEN); + bytes.extend_from_slice(&self.owner.to_bytes()); + bytes.extend_from_slice(self.viewing.to_bytes().as_ref()); + bytes + } + + /// Total decode. Both halves are checked by the type that owns them. + pub fn from_bytes(bytes: &[u8]) -> Result { + let (owner, viewing) = + bytes.split_first_chunk::().ok_or(InvalidAddress)?; + Ok(Self::new( + OwnerPubkey::from_canonical_bytes(*owner).map_err(|_| InvalidAddress)?, + ViewingPubkey::from_bytes(viewing).map_err(|_| InvalidAddress)?, + )) + } +} + +impl Clone for Address +where + K::PublicKey: Clone, +{ + fn clone(&self) -> Self { + Self::new(self.owner, self.viewing.clone()) + } +} + +impl PartialEq for Address { + fn eq(&self, other: &Self) -> bool { + self.owner == other.owner && self.viewing == other.viewing + } +} + +impl Eq for Address {} + +impl fmt::Debug for Address { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Address") + .field("owner", &self.owner) + .field("viewing", &self.viewing) + .finish() + } +} + +#[cfg(feature = "serde")] +mod address_serde { + use serde::{ + Deserialize, + Deserializer, + Serialize, + Serializer, + de::Error, + }; + + use super::*; + + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] + impl Serialize for Address { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_bytes(&self.to_bytes()) + } + } + + #[cfg_attr(docsrs, doc(cfg(feature = "serde")))] + impl<'de, F: PrimeField, K: Kem, Fam> Deserialize<'de> for Address { + fn deserialize>(deserializer: D) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + Self::from_bytes(&bytes).map_err(D::Error::custom) + } + } +} diff --git a/crates/keystem/src/error.rs b/crates/keystem/src/error.rs index 367f696..9317245 100644 --- a/crates/keystem/src/error.rs +++ b/crates/keystem/src/error.rs @@ -54,3 +54,21 @@ impl fmt::Display for InvalidKey { #[cfg(feature = "viewing")] #[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] impl core::error::Error for InvalidKey {} + +/// Bytes that decode to no owner-and-viewing credential pair. +#[cfg(all(feature = "spend", feature = "viewing"))] +#[cfg_attr(docsrs, doc(cfg(all(feature = "spend", feature = "viewing"))))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidAddress; + +#[cfg(all(feature = "spend", feature = "viewing"))] +#[cfg_attr(docsrs, doc(cfg(all(feature = "spend", feature = "viewing"))))] +impl fmt::Display for InvalidAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "bytes are not an owner and viewing credential pair") + } +} + +#[cfg(all(feature = "spend", feature = "viewing"))] +#[cfg_attr(docsrs, doc(cfg(all(feature = "spend", feature = "viewing"))))] +impl core::error::Error for InvalidAddress {} diff --git a/crates/keystem/src/lib.rs b/crates/keystem/src/lib.rs index d6db988..2d40479 100644 --- a/crates/keystem/src/lib.rs +++ b/crates/keystem/src/lib.rs @@ -7,7 +7,7 @@ #![deny(warnings)] #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(all(not(feature = "std"), feature = "serde", feature = "viewing"))] +#[cfg(all(not(feature = "std"), feature = "viewing"))] #[cfg_attr(docsrs, doc(cfg(not(feature = "std"))))] extern crate alloc; @@ -33,6 +33,8 @@ mod error; #[cfg(any(feature = "spend", feature = "viewing"))] mod hex; +#[cfg(all(feature = "spend", feature = "viewing"))] +mod address; #[cfg(feature = "spend")] mod authority; #[cfg(feature = "spend")] @@ -56,9 +58,15 @@ pub mod family; #[cfg_attr(docsrs, doc(cfg(feature = "test-helpers")))] pub mod test_util; +#[cfg(all(feature = "spend", feature = "viewing"))] +#[cfg_attr(docsrs, doc(cfg(all(feature = "spend", feature = "viewing"))))] +pub use address::Address; #[cfg(feature = "spend")] #[cfg_attr(docsrs, doc(cfg(feature = "spend")))] pub use authority::SpendAuthority; +#[cfg(all(feature = "spend", feature = "viewing"))] +#[cfg_attr(docsrs, doc(cfg(all(feature = "spend", feature = "viewing"))))] +pub use error::InvalidAddress; #[cfg(feature = "viewing")] #[cfg_attr(docsrs, doc(cfg(feature = "viewing")))] pub use error::InvalidKey; diff --git a/crates/keystem/tests/address.rs b/crates/keystem/tests/address.rs new file mode 100644 index 0000000..a87cb75 --- /dev/null +++ b/crates/keystem/tests/address.rs @@ -0,0 +1,66 @@ +#![cfg(all(feature = "poseidon", feature = "x25519"))] + +use ark_bn254::Fr; +use keystem::{ + Address, + ViewingKey, + curves::bn254::SpendingKey, + family::Incoming, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; +use sealring::X25519; + +type Addr = Address; + +fn address(seed: u64) -> Addr { + let mut rng = ChaCha20Rng::seed_from_u64(seed); + Address::new( + SpendingKey::random(&mut rng).derive_owner_pubkey(), + ViewingKey::::random(&mut rng).derive_pubkey(), + ) +} + +#[test] +fn round_trips_through_bytes() { + let alice = address(1); + + assert_eq!(Addr::from_bytes(&alice.to_bytes()).unwrap(), alice); +} + +#[test] +fn substituting_the_viewing_half_changes_the_address() { + let alice = address(1); + let mallory = address(2); + let swapped = Address::new(alice.owner_pubkey(), mallory.viewing_pubkey().clone()); + + assert_eq!(swapped.owner_pubkey(), alice.owner_pubkey()); + assert_ne!(swapped, alice); + assert_ne!(swapped.to_bytes(), alice.to_bytes()); +} + +#[test] +fn a_truncated_or_non_canonical_encoding_is_rejected() { + let mut bytes = address(1).to_bytes(); + + assert!(Addr::from_bytes(&bytes[..bytes.len() - 1]).is_err()); + assert!(Addr::from_bytes(&bytes[..16]).is_err()); + + // bit 255 of the viewing half: a second encoding of the same point. + let last = bytes.len() - 1; + bytes[last] |= 0x80; + assert!(Addr::from_bytes(&bytes).is_err()); +} + +#[cfg(feature = "serde")] +mod address_serde { + use super::*; + + #[test] + fn round_trips_through_serde_json() { + let alice = address(1); + let json = serde_json::to_vec(&alice).unwrap(); + + assert_eq!(serde_json::from_slice::(&json).unwrap(), alice); + } +} diff --git a/crates/sealring/src/adapters/x25519.rs b/crates/sealring/src/adapters/x25519.rs index 56435b1..b674cf8 100644 --- a/crates/sealring/src/adapters/x25519.rs +++ b/crates/sealring/src/adapters/x25519.rs @@ -12,7 +12,16 @@ use crate::kem::Kem; /// Byte length of a Montgomery-form X25519 point. const X25519_EPK_LEN: usize = 32; -/// X25519 KEM. Every 32-byte string is a valid input; low-order points +/// Canonical little-endian encoding of `p = 2^255 - 19`. +#[rustfmt::skip] +const P: [u8; X25519_EPK_LEN] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, +]; + +/// X25519 KEM. Inputs are the 32-byte strings below `p`; low-order points /// produce an all-zero Diffie-Hellman output, rejected in constant time. pub struct X25519; @@ -77,12 +86,13 @@ impl Kem for X25519 { pk.to_bytes() } - /// Every 32-byte string names a point. Low-order ones produce an all-zero - /// Diffie-Hellman output, which `decap` rejects, so the check that matters - /// happens where the secret is. + /// Rejects the non-canonical encodings dalek would otherwise accept fn decode_pk(bytes: &[u8]) -> Option { - Some(PublicKey::from( - <[u8; X25519_EPK_LEN]>::try_from(bytes).ok()?, - )) + let bytes = <[u8; X25519_EPK_LEN]>::try_from(bytes).ok()?; + bytes + .iter() + .rev() + .lt(P.iter().rev()) + .then(|| PublicKey::from(bytes)) } } diff --git a/crates/sealring/src/kem.rs b/crates/sealring/src/kem.rs index 4a64e15..9c62e5e 100644 --- a/crates/sealring/src/kem.rs +++ b/crates/sealring/src/kem.rs @@ -50,11 +50,6 @@ pub trait Kem { /// Decodes what [`encode_pk`](Self::encode_pk) produced, returning `None` /// for byte strings that name no public key. - /// - /// The sender's import path for a counterparty key. It reads the same - /// encoding [`decap`](Self::decap) reads, so it rejects the same inputs. - /// An adapter proves both properties with `conformance_pk_codec_roundtrips` - /// and `conformance_garbage_fails` from the `test-helpers` suite. fn decode_pk(bytes: &[u8]) -> Option; /// Decapsulates a batch of ephemeral keys, one output slot per input. diff --git a/crates/sealring/tests/adapter_x25519.rs b/crates/sealring/tests/adapter_x25519.rs index 20edff0..ee1df74 100644 --- a/crates/sealring/tests/adapter_x25519.rs +++ b/crates/sealring/tests/adapter_x25519.rs @@ -3,6 +3,7 @@ use rand_chacha::ChaCha20Rng; use rand_core::SeedableRng; use sealring::{ + Kem, Recipient, X25519, open, @@ -71,6 +72,16 @@ const LOW_ORDER_POINTS: &[[u8; 32]] = &[ ], ]; +/// Canonical little-endian encoding of `p = 2^255 - 19`, the first byte string +/// that is not a canonical point encoding. +#[rustfmt::skip] +const P: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, +]; + #[test] fn conformance_suite() { let mut rng = ChaCha20Rng::seed_from_u64(11); @@ -115,3 +126,25 @@ fn seal_open_round_trips() { assert_eq!(opened, Some(note)); } + +#[test] +fn decode_pk_accepts_only_canonical_encodings() { + // The boundary, which a bit-255 mask alone would let through: `p` and + // `p + 1` are second encodings of points that already have one. + let mut neighbour = P; + neighbour[0] -= 1; + assert!( + X25519::decode_pk(&neighbour).is_some(), + "p - 1 is canonical" + ); + assert!(X25519::decode_pk(&P).is_none(), "p is not"); + neighbour[0] += 2; + assert!(X25519::decode_pk(&neighbour).is_none(), "p + 1 is not"); + + // Setting bit 255 of a real key is the same credential on the wire. + let mut rng = ChaCha20Rng::seed_from_u64(44); + let mut pk = PublicKey::from(&StaticSecret::random_from_rng(&mut rng)).to_bytes(); + assert!(X25519::decode_pk(&pk).is_some()); + pk[31] |= 0x80; + assert!(X25519::decode_pk(&pk).is_none(), "bit 255 must be rejected"); +}