From ddf07864091f8d8e56c8e65ead25e1ed8bd5cbea Mon Sep 17 00:00:00 2001 From: pgherveou Date: Fri, 17 Jul 2026 19:07:23 +0200 Subject: [PATCH] feat(core): implement ring-vrf signing --- Cargo.lock | 390 ++++++++++++- deny.toml | 4 + rust/crates/truapi-server/Cargo.toml | 2 + rust/crates/truapi-server/README.md | 16 +- rust/crates/truapi-server/src/host_core.rs | 9 +- .../truapi-server/src/runtime/signing_host.rs | 200 ++++++- .../src/runtime/signing_host/ring_vrf.rs | 517 ++++++++++++++++++ .../src/runtime/statement_store.rs | 2 +- 8 files changed, 1112 insertions(+), 28 deletions(-) create mode 100644 rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs diff --git a/Cargo.lock b/Cargo.lock index 5c8b247b..8b848555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,194 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bls12-381" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df4dcc01ff89867cd86b0da835f23c3f02738353aaee7dde7495af71363b8d5" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-serialize", + "ark-std", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint", + "num-integer", + "num-traits", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ed-on-bls12-381-bandersnatch" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1786b2e3832f6f0f7c8d62d5d5a282f6952a1ab99981c54cd52b6ac1d8f02df5" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ff", + "ark-std", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm", + "ark-ff-macros", + "ark-serialize", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "rayon", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff", + "ark-serialize", + "ark-std", + "educe", + "fnv", + "hashbrown 0.15.5", + "rayon", +] + +[[package]] +name = "ark-scale" +version = "0.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985c81a9c7b23a72f62b7b20686d5326d2a9956806f37de9ee35cb1238faf0c0" +dependencies = [ + "ark-serialize", + "ark-std", + "parity-scale-codec", + "scale-info", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-serialize-derive", + "ark-std", + "arrayvec 0.7.6", + "digest 0.10.7", + "num-bigint", + "rayon", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ark-std" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" +dependencies = [ + "num-traits", + "rand", + "rayon", +] + +[[package]] +name = "ark-transcript" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c1c928edb9d8ff24cb5dcb7651d3a98494fff3099eee95c2404cd813a9139f" +dependencies = [ + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "rand_core", + "sha3", +] + +[[package]] +name = "ark-vrf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9bd02dbd2f282fe742d51f681adb2745a79dc8a025bc8d16cac9b255d55bf7" +dependencies = [ + "ark-bls12-381", + "ark-ec", + "ark-ed-on-bls12-381-bandersnatch", + "ark-ff", + "ark-serialize", + "ark-std", + "digest 0.10.7", + "generic-array", + "rayon", + "sha2 0.10.9", + "w3f-ring-proof", + "zeroize", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -407,6 +595,19 @@ dependencies = [ "piper", ] +[[package]] +name = "bounded-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dee8eddd066a8825ec5570528e6880471210fd5d88cb6abbe1cfdd51ca249c33" +dependencies = [ + "jam-codec", + "log", + "parity-scale-codec", + "scale-info", + "serde", +] + [[package]] name = "bs58" version = "0.5.1" @@ -646,6 +847,25 @@ dependencies = [ "libc", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -899,6 +1119,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.16.0" @@ -924,6 +1156,26 @@ dependencies = [ "zeroize", ] +[[package]] +name = "enum-ordinalize" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +dependencies = [ + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1240,6 +1492,7 @@ version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea1015b5a70616b688dc230cfe50c8af89d972cb132d5a622814d29773b10b9" dependencies = [ + "rand", "rand_core", ] @@ -1326,6 +1579,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -1610,6 +1864,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1625,6 +1888,34 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jam-codec" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb948eace373d99de60501a02fb17125d30ac632570de20dccc74370cdd611b9" +dependencies = [ + "arrayvec 0.7.6", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "jam-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "jam-codec-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "319af585c4c8a6b5552a52b7787a1ab3e4d59df7614190b1f85b9b842488789d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "jni" version = "0.21.1" @@ -2133,6 +2424,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -2388,6 +2685,26 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3000,7 +3317,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -3056,7 +3373,7 @@ dependencies = [ "hashbrown 0.16.1", "hex", "hmac 0.12.1", - "itertools", + "itertools 0.14.0", "libm", "libsecp256k1", "merlin", @@ -3105,7 +3422,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hex", - "itertools", + "itertools 0.14.0", "log", "lru", "parking_lot", @@ -3704,6 +4021,7 @@ dependencies = [ "nanoid", "p256", "parity-scale-codec", + "scale-decode", "scale-info", "schnorrkel", "send_wrapper 0.6.0", @@ -3721,6 +4039,7 @@ dependencies = [ "truapi-platform", "unicode-normalization", "url", + "verifiable", "wasm-bindgen", "wasm-bindgen-futures", "wasm-bindgen-test", @@ -3838,12 +4157,77 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "verifiable" +version = "0.5.0" +source = "git+https://github.com/paritytech/verifiable.git?rev=19b03deb89b8dea484b143afc8bbe048a74bc1ff#19b03deb89b8dea484b143afc8bbe048a74bc1ff" +dependencies = [ + "ark-scale", + "ark-serialize", + "ark-vrf", + "bounded-collections", + "parity-scale-codec", + "scale-info", + "sha2 0.10.9", + "smallvec", + "spin", +] + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "w3f-pcs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ea1046a1deb6d26c34ba2d1f1bab4222d695d126502ee765f80b021753cb674" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "merlin", + "rayon", +] + +[[package]] +name = "w3f-plonk-common" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30408cda37b81bd7257319942584c794c5784d00d749757bc664656749a1472a" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "getrandom_or_panic", + "rand_core", + "rayon", + "w3f-pcs", +] + +[[package]] +name = "w3f-ring-proof" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cbfc4cb881a934e6f33c25927bf955d0cb18e52b94528bbc5fa28dddedb4cd1" +dependencies = [ + "ark-ec", + "ark-ff", + "ark-poly", + "ark-serialize", + "ark-std", + "ark-transcript", + "rayon", + "w3f-pcs", + "w3f-plonk-common", +] + [[package]] name = "walkdir" version = "2.5.0" diff --git a/deny.toml b/deny.toml index 9305a03f..1bdbccea 100644 --- a/deny.toml +++ b/deny.toml @@ -19,6 +19,10 @@ confidence-threshold = 0.8 # dependency, which MPL-2.0 permits without affecting the MIT outbound licence. # Scoped per crate so MPL-2.0 stays disallowed everywhere else. exceptions = [ + # Ring-VRF implementation shared with the Individuality runtimes and Nova. + # The Classpath exception permits linking it without changing this crate's + # MIT outbound licence. + { name = "verifiable", allow = ["GPL-3.0-or-later WITH Classpath-exception-2.0"] }, { name = "uniffi", allow = ["MPL-2.0"] }, { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, { name = "uniffi_core", allow = ["MPL-2.0"] }, diff --git a/rust/crates/truapi-server/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index bc068de5..1d23a276 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -39,6 +39,7 @@ derive_more = { version = "2", features = ["debug", "display", "error"] } futures = "0.3" futures-timer = "3" parity-scale-codec = { version = "3", features = ["derive"] } +scale-decode = { version = "0.16", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" @@ -60,6 +61,7 @@ tracing = "0.1" # `registry` + `std` only: pulls the Registry + per-layer filter/reload, but # not `env-filter` (which drags in `regex`, heavy on wasm). tracing-subscriber = { version = "0.3", default-features = false, features = ["registry", "std"] } +verifiable = { git = "https://github.com/paritytech/verifiable.git", rev = "19b03deb89b8dea484b143afc8bbe048a74bc1ff" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] subxt = { version = "0.50.2", default-features = false, features = ["native"] } diff --git a/rust/crates/truapi-server/README.md b/rust/crates/truapi-server/README.md index a69dca86..cabe2e22 100644 --- a/rust/crates/truapi-server/README.md +++ b/rust/crates/truapi-server/README.md @@ -84,9 +84,9 @@ stage in the frame path; the host's `Platform` impl is the syscall floor. `ProductRuntimeHost` handles everything role-neutral (id normalization, permission gating, confirmation, soft product-key derivation), then delegates the wallet-authority tail (`sign_*`, `create_transaction`, `account_alias`, -`allocate_resources`, `derive_entropy`) through an `Arc` -handle with an `AuthoritySession` snapshot the role revalidates before touching -key material. +`create_proof`, `allocate_resources`, `derive_entropy`) through an +`Arc` handle with an `AuthoritySession` snapshot the +role revalidates before touching key material. ### Permission flow @@ -185,9 +185,13 @@ role-specific lifecycle, so no method exists on a role that can't mean it: signing-host liveness monitoring. - **`SigningHost`** (wallet-local): signs on device from local BIP-39 entropy, no pairing flow. `signing_host/local_activation.rs` establishes a session - from host-held secret material. Extrinsic signing / transaction construction / - ring-VRF aliases / resource allocation currently return `Unavailable` pending - chain-metadata and on-chain support. + from host-held secret material. It derives the same full- and lite-person + Bandersnatch keys as Nova, resolves RFC-0004 `RingLocation` values against + the chain's `Members` pallet, and pins membership, ring pages, exponent, and + revision reads to one finalized block before creating an alias or proof. + Full personhood is preferred over lite personhood. Extrinsic-payload signing + and resource allocation still return `Unavailable` pending chain-metadata + and on-chain support. `host_logic` stays pure: the orchestrators above call into it for codecs, session/SSO crypto, key derivation, and permission policy, while all I/O diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index 1dbae792..69da527f 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -185,9 +185,10 @@ impl PairingHostAdmin for PairingHostRuntime { /// Owns the shared services plus signing-host state. There is no pairing flow, /// so pairing cancellation is not present here. /// -/// Raw-bytes signing and product entropy are implemented; extrinsic-payload -/// signing, transaction construction, ring-VRF aliases, and resource allocation -/// return an `Unavailable` error pending chain-metadata and on-chain support. +/// Raw-bytes signing, transaction construction, product entropy, and RFC-0004 +/// ring-VRF aliases/proofs are implemented; extrinsic-payload signing and +/// resource allocation return an `Unavailable` error pending chain-metadata +/// and on-chain support. pub struct SigningHostRuntime { services: Arc, signing_host: Arc, @@ -207,7 +208,7 @@ impl SigningHostRuntime { config.bulletin_chain_genesis_hash, spawner, ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(services.clone()); Self { services, signing_host, diff --git a/rust/crates/truapi-server/src/runtime/signing_host.rs b/rust/crates/truapi-server/src/runtime/signing_host.rs index 886853b8..afe09e97 100644 --- a/rust/crates/truapi-server/src/runtime/signing_host.rs +++ b/rust/crates/truapi-server/src/runtime/signing_host.rs @@ -7,6 +7,7 @@ //! for the session, zeroized on disconnect. mod local_activation; +mod ring_vrf; use std::sync::{Arc, Mutex}; @@ -18,7 +19,7 @@ use super::authority::{ SignPayloadAuthorityRequest, SignRawAuthorityRequest, StatementStoreAllowanceKey, authority_session, require_current_session, }; -use super::connected_session_ui_info; +use super::{RuntimeServices, connected_session_ui_info}; use crate::host_logic::entropy::derive_product_entropy; use crate::host_logic::extrinsic::{Sr25519Signer, build_signed_extrinsic_v4}; use crate::host_logic::product_account::{ @@ -28,10 +29,16 @@ use crate::host_logic::product_account::{ use crate::host_logic::session::SessionState; use crate::host_logic::sso::messages::RingVrfError; use crate::runtime::auth_state::AuthStateMachine; +use ring_vrf::{ + ChainRingResolver, MemberCandidate, PersonKey, RingResolver, alias_from_entropy, context_bytes, + create_proof, key_for_collection, member_from_entropy, person_entropy, +}; use truapi::versioned::account::{HostRequestLoginError, HostRequestLoginResponse}; use truapi::{CallContext, CallError, v01}; -use truapi_platform::{Platform, ProductContext, normalize_product_identifier}; +#[cfg(test)] +use truapi_platform::Platform; +use truapi_platform::{ProductContext, normalize_product_identifier}; use zeroize::Zeroizing; const BYTES_WRAP_PREFIX: &[u8] = b""; @@ -41,15 +48,32 @@ const BYTES_WRAP_SUFFIX: &[u8] = b""; pub(crate) struct SigningHost { session_state: Arc, auth_state: AuthStateMachine, + ring_resolver: Arc, /// Root BIP-39 entropy held only while a session is active. root_entropy: Mutex>>>, } impl SigningHost { - pub(crate) fn new(platform: Arc) -> Arc { + pub(crate) fn new(services: Arc) -> Arc { + let platform = services.platform.clone(); + let ring_resolver = ChainRingResolver::new(services.chain.clone()); + Arc::new(Self { + session_state: SessionState::new(), + auth_state: AuthStateMachine::new(platform), + ring_resolver, + root_entropy: Mutex::new(None), + }) + } + + #[cfg(test)] + fn new_with_ring_resolver( + platform: Arc, + ring_resolver: Arc, + ) -> Arc { Arc::new(Self { session_state: SessionState::new(), auth_state: AuthStateMachine::new(platform), + ring_resolver, root_entropy: Mutex::new(None), }) } @@ -88,6 +112,34 @@ impl SigningHost { derive_product_keypair(&root, &product_id, account.derivation_index) .map_err(product_authority_error) } + + fn person_entropy( + &self, + session: &AuthoritySession, + key: PersonKey, + ) -> Result, RingVrfError> { + require_current_session(&self.session_state, session)?; + let root = self.root_entropy()?; + Ok(person_entropy(&root, key)) + } + + fn member_candidates( + &self, + session: &AuthoritySession, + ) -> Result<[MemberCandidate; 2], RingVrfError> { + let full_entropy = self.person_entropy(session, PersonKey::Full)?; + let lite_entropy = self.person_entropy(session, PersonKey::Lite)?; + Ok([ + MemberCandidate { + key: PersonKey::Full, + member: member_from_entropy(&full_entropy)?, + }, + MemberCandidate { + key: PersonKey::Lite, + member: member_from_entropy(&lite_entropy)?, + }, + ]) + } } #[async_trait::async_trait] @@ -213,22 +265,44 @@ impl ProductAuthority for SigningHost { async fn account_alias( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: AccountAliasAuthorityRequest, + session: &AuthoritySession, + request: AccountAliasAuthorityRequest, ) -> Result { - Err(RingVrfError::Unknown { - reason: "signing host: ring-VRF alias derivation not yet implemented".to_string(), + require_current_session(&self.session_state, session)?; + let collection = self.ring_resolver.validate(&request.ring_location).await?; + let context = context_bytes(&request.context); + let entropy = self.person_entropy(session, key_for_collection(&collection))?; + let alias = alias_from_entropy(&entropy, &context)?; + Ok(v01::ContextualAlias { + context, + alias: alias.to_vec(), }) } async fn create_proof( &self, _cx: &CallContext, - _session: &AuthoritySession, - _request: CreateProofAuthorityRequest, + session: &AuthoritySession, + request: CreateProofAuthorityRequest, ) -> Result { - Err(RingVrfError::Unknown { - reason: "signing host: ring-VRF proof generation not yet implemented".to_string(), + let candidates = self.member_candidates(session)?; + let resolved = self + .ring_resolver + .resolve(&request.ring_location, &candidates) + .await?; + // Reject a stale request if the local session disconnected or changed + // while its chain snapshot was being resolved. + let entropy = self.person_entropy(session, resolved.selected.key)?; + let context = context_bytes(&request.context); + let (proof, alias) = create_proof(&entropy, &resolved, &context, &request.message)?; + Ok(v01::HostAccountCreateProofResponse { + proof, + contextual_alias: v01::ContextualAlias { + context, + alias: alias.to_vec(), + }, + ring_index: resolved.ring_index, + ring_revision: resolved.ring_revision, }) } @@ -382,10 +456,16 @@ mod tests { use std::sync::Arc; use super::super::authority::{ - AuthorityError, CreateTransactionAuthorityRequest, SignRawAuthorityRequest, + AccountAliasAuthorityRequest, AuthorityError, CreateProofAuthorityRequest, + CreateTransactionAuthorityRequest, SignRawAuthorityRequest, }; use super::super::{ProductAuthority, ProductRuntimeHost, RuntimeServices, SigningHostRole}; - use super::{BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, raw_payload_bytes}; + use super::ring_vrf::{ + MemberCandidate, PersonKey, ResolvedRing, RingResolver, member_from_entropy, person_entropy, + }; + use super::{ + BYTES_WRAP_PREFIX, BYTES_WRAP_SUFFIX, LocalActivation, RingVrfError, raw_payload_bytes, + }; use crate::host_logic::extrinsic::tests::split_v4; use crate::host_logic::product_account::{ derive_product_keypair, derive_root_keypair_from_entropy, @@ -397,9 +477,35 @@ mod tests { use truapi::versioned::signing::{HostSignRawError, HostSignRawRequest, HostSignRawResponse}; use truapi::{CallContext, CallError, v01}; use truapi_platform::{HostInfo, PlatformInfo, ProductContext, SigningHostConfig}; + use verifiable::ring::RingDomainSize; const ENTROPY: [u8; 16] = [0xAB; 16]; + #[derive(Clone)] + struct StubRingResolver { + collection: [u8; 32], + ring: ResolvedRing, + } + + #[async_trait::async_trait] + impl RingResolver for StubRingResolver { + async fn validate(&self, _location: &v01::RingLocation) -> Result<[u8; 32], RingVrfError> { + Ok(self.collection) + } + + async fn resolve( + &self, + _location: &v01::RingLocation, + candidates: &[MemberCandidate], + ) -> Result { + assert!( + candidates.contains(&self.ring.selected), + "signing host offered the selected person key" + ); + Ok(self.ring.clone()) + } + } + fn signing_runtime() -> (Arc, Arc) { // Auto-confirm raw signing so the role-neutral confirmation gate does // not reject before reaching the signing authority. @@ -424,7 +530,7 @@ mod tests { config.bulletin_chain_genesis_hash, test_spawner(), ); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(services.clone()); (services, signing_host) } @@ -451,6 +557,72 @@ mod tests { ) } + #[test] + fn ring_alias_and_proof_share_the_selected_person_key() { + let full_entropy = person_entropy(&ENTROPY, PersonKey::Full); + let full_member = member_from_entropy(&full_entropy).expect("full-person member"); + let selected = MemberCandidate { + key: PersonKey::Full, + member: full_member, + }; + let resolver = Arc::new(StubRingResolver { + collection: *b"pop:polkadot.network/people ", + ring: ResolvedRing { + selected, + ring_index: 7, + ring_revision: 11, + domain_size: RingDomainSize::Domain11, + members: vec![full_member], + }, + }); + let platform: Arc = Arc::new(StubPlatform::default()); + let authority = SigningHostRole::new_with_ring_resolver(platform, resolver); + futures::executor::block_on(authority.activate_local_session(ENTROPY.to_vec())) + .expect("activation succeeds"); + let session = authority.current_session().expect("active session"); + let cx = CallContext::new(); + let context = v01::ProductProofContext { + product_id: "myapp.dot".to_string(), + suffix: b"account".to_vec(), + }; + let ring_location = v01::RingLocation { + chain_id: [0x22; 32], + junctions: vec![ + v01::RingLocationJunction::PalletInstance(42), + v01::RingLocationJunction::CollectionId( + b"pop:polkadot.network/people ".to_vec(), + ), + ], + }; + + let alias = futures::executor::block_on(authority.account_alias( + &cx, + &session, + AccountAliasAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context: context.clone(), + ring_location: ring_location.clone(), + }, + )) + .expect("alias succeeds"); + let proof = futures::executor::block_on(authority.create_proof( + &cx, + &session, + CreateProofAuthorityRequest { + calling_product_id: "myapp.dot".to_string(), + context, + ring_location, + message: b"prove me".to_vec(), + }, + )) + .expect("proof succeeds"); + + assert!(!proof.proof.is_empty()); + assert_eq!(proof.contextual_alias, alias); + assert_eq!(proof.ring_index, 7); + assert_eq!(proof.ring_revision, 11); + } + #[test] fn activate_then_sign_raw_verifies_against_derived_product_key() { let (services, activation) = signing_runtime(); diff --git a/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs new file mode 100644 index 00000000..cf2bb939 --- /dev/null +++ b/rust/crates/truapi-server/src/runtime/signing_host/ring_vrf.rs @@ -0,0 +1,517 @@ +//! Ring location resolution and Bandersnatch ring-VRF primitives. +//! +//! The resolver mirrors Nova's RFC-0004 implementation: it validates the +//! requested Members pallet from runtime metadata, pins every storage read to +//! one finalized block, selects the full-person key before the lite-person key, +//! and returns the ring members, exponent, and revision from that snapshot. + +use std::sync::Arc; + +use async_trait::async_trait; +use subxt::dynamic; +use subxt::ext::scale_decode::DecodeAsType; +use truapi::v01::{ProductProofContext, RingLocation, RingLocationJunction}; +use verifiable::GenerateVerifiable; +use verifiable::ring::RingDomainSize; +use verifiable::ring::bandersnatch::BandersnatchVrfVerifiable; +use zeroize::Zeroizing; + +use crate::chain_runtime::ChainRuntime; +use crate::host_logic::sso::messages::RingVrfError; + +const MEMBERS_PALLET: &str = "Members"; +const FULL_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people "; +const LITE_PERSON_COLLECTION: [u8; 32] = *b"pop:polkadot.network/people-lite"; +const FULL_PERSON_ENTROPY_KEY: &[u8] = b"candidate"; + +type RingMember = ::Member; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum PersonKey { + Full, + Lite, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct MemberCandidate { + pub(super) key: PersonKey, + pub(super) member: [u8; 32], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ResolvedRing { + pub(super) selected: MemberCandidate, + pub(super) ring_index: u32, + pub(super) ring_revision: u32, + pub(super) domain_size: RingDomainSize, + pub(super) members: Vec<[u8; 32]>, +} + +#[async_trait] +pub(super) trait RingResolver: Send + Sync { + /// Validate the chain and Members pallet, returning the requested + /// collection (or the RFC-0004 full-person fallback). + async fn validate(&self, location: &RingLocation) -> Result<[u8; 32], RingVrfError>; + + /// Resolve a current, single-block ring snapshot and select the first + /// candidate with an active membership. + async fn resolve( + &self, + location: &RingLocation, + candidates: &[MemberCandidate], + ) -> Result; +} + +pub(super) struct ChainRingResolver { + chain: ChainRuntime, +} + +impl ChainRingResolver { + pub(super) fn new(chain: ChainRuntime) -> Arc { + Arc::new(Self { chain }) + } + + async fn at_ring( + &self, + location: &RingLocation, + ) -> Result< + subxt::client::OnlineClientAtBlock, + RingVrfError, + > { + let client = self + .chain + .online_client(&location.chain_id) + .await + .map_err(unknown)?; + let at_block = client.at_current_block().await.map_err(unknown)?; + let Some(pallet) = at_block.metadata_ref().pallet_by_name(MEMBERS_PALLET) else { + return Err(RingVrfError::RingNotFound); + }; + if let Some(expected) = pallet_instance(location) + && pallet.call_index() != expected + { + return Err(RingVrfError::RingNotFound); + } + Ok(at_block) + } +} + +#[async_trait] +impl RingResolver for ChainRingResolver { + async fn validate(&self, location: &RingLocation) -> Result<[u8; 32], RingVrfError> { + self.at_ring(location).await?; + collection_id(location) + } + + async fn resolve( + &self, + location: &RingLocation, + candidates: &[MemberCandidate], + ) -> Result { + let collection = collection_id(location)?; + let at_block = self.at_ring(location).await?; + let storage = at_block.storage(); + + let mut selected = None; + for candidate in candidates { + let address = + dynamic::storage::<([u8; 32], [u8; 32]), RingPosition>(MEMBERS_PALLET, "Members"); + let Some(position) = storage + .try_fetch(address, (collection, candidate.member)) + .await + .map_err(unknown)? + else { + continue; + }; + let RingPosition::Included { + ring_index, + ring_position, + .. + } = position.decode().map_err(unknown)? + else { + continue; + }; + + let status_address = + dynamic::storage::<([u8; 32], u32), RingStatus>(MEMBERS_PALLET, "RingKeysStatus"); + let status = storage + .fetch(status_address, (collection, ring_index)) + .await + .map_err(unknown)? + .decode() + .map_err(unknown)?; + if status.included > ring_position { + selected = Some((*candidate, ring_index, status.included)); + break; + } + } + + let Some((selected, ring_index, included_count)) = selected else { + return Err(RingVrfError::NotMember); + }; + + let collection_address = + dynamic::storage::<([u8; 32],), CollectionInfo>(MEMBERS_PALLET, "Collections"); + let Some(collection_info) = storage + .try_fetch(collection_address, (collection,)) + .await + .map_err(unknown)? + else { + return Err(RingVrfError::RingNotFound); + }; + let collection_info = collection_info.decode().map_err(unknown)?; + + let ring_keys_address = + dynamic::storage::<([u8; 32], u32, u32), BoundedMembers>(MEMBERS_PALLET, "RingKeys"); + let mut pages = storage + .iter(ring_keys_address, (collection, ring_index)) + .await + .map_err(unknown)?; + let mut members_by_page = Vec::new(); + while let Some(entry) = pages.next().await { + let entry = entry.map_err(unknown)?; + let page_index = entry + .key() + .map_err(unknown)? + .part(2) + .ok_or_else(|| RingVrfError::Unknown { + reason: "Members.RingKeys returned a key without a page index".to_string(), + })? + .decode_as::() + .map_err(unknown)? + .ok_or_else(|| RingVrfError::Unknown { + reason: "Members.RingKeys page index is not recoverable".to_string(), + })?; + let members = entry.value().decode().map_err(unknown)?.0; + members_by_page.push((page_index, members)); + } + members_by_page.sort_unstable_by_key(|(page, _)| *page); + let mut members: Vec<_> = members_by_page + .into_iter() + .flat_map(|(_, members)| members) + .collect(); + let included_count = usize::try_from(included_count).map_err(unknown)?; + if members.len() < included_count { + return Err(RingVrfError::Unknown { + reason: format!( + "Members.RingKeys contains {} keys but RingKeysStatus includes {included_count}", + members.len() + ), + }); + } + members.truncate(included_count); + if !members.contains(&selected.member) { + return Err(RingVrfError::NotMember); + } + + let root_address = dynamic::storage::<([u8; 32], u32), RingRoot>(MEMBERS_PALLET, "Root"); + let Some(root) = storage + .try_fetch(root_address, (collection, ring_index)) + .await + .map_err(unknown)? + else { + return Err(RingVrfError::RingNotFound); + }; + let ring_revision = root.decode().map_err(unknown)?.revision; + + Ok(ResolvedRing { + selected, + ring_index, + ring_revision, + domain_size: collection_info.ring_size.domain_size(), + members, + }) + } +} + +pub(super) fn context_bytes(context: &ProductProofContext) -> [u8; 32] { + let mut input = Vec::with_capacity(9 + context.product_id.len() + context.suffix.len()); + input.extend_from_slice(b"product/"); + input.extend_from_slice(context.product_id.as_bytes()); + input.push(b'/'); + input.extend_from_slice(&context.suffix); + blake2b_256(&input, None) +} + +pub(super) fn person_entropy(root_entropy: &[u8], key: PersonKey) -> Zeroizing<[u8; 32]> { + let key = match key { + PersonKey::Full => Some(FULL_PERSON_ENTROPY_KEY), + PersonKey::Lite => None, + }; + Zeroizing::new(blake2b_256(root_entropy, key)) +} + +pub(super) fn member_from_entropy(entropy: &[u8; 32]) -> Result<[u8; 32], RingVrfError> { + use parity_scale_codec::Encode; + + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + BandersnatchVrfVerifiable::member_from_secret(&secret) + .encode() + .try_into() + .map_err(|member: Vec| RingVrfError::Unknown { + reason: format!( + "Bandersnatch member encoded to {} bytes instead of 32", + member.len() + ), + }) +} + +pub(super) fn alias_from_entropy( + entropy: &[u8; 32], + context: &[u8], +) -> Result<[u8; 32], RingVrfError> { + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + BandersnatchVrfVerifiable::alias_in_context(&secret, context).map_err(unknown) +} + +pub(super) fn create_proof( + entropy: &[u8; 32], + resolved: &ResolvedRing, + context: &[u8], + message: &[u8], +) -> Result<(Vec, [u8; 32]), RingVrfError> { + use parity_scale_codec::Decode; + + let mut selected_bytes = &resolved.selected.member[..]; + let selected = RingMember::decode(&mut selected_bytes).map_err(unknown)?; + let members = resolved + .members + .iter() + .map(|member| { + let mut bytes = &member[..]; + RingMember::decode(&mut bytes).map_err(unknown) + }) + .collect::, _>>()?; + + let secret = BandersnatchVrfVerifiable::new_secret(*entropy); + let prover = + BandersnatchVrfVerifiable::open(resolved.domain_size, &selected, members.into_iter()) + .map_err(|_| RingVrfError::NotMember)?; + let (proof, alias) = + BandersnatchVrfVerifiable::create(prover, &secret, context, message).map_err(unknown)?; + Ok((proof.to_vec(), alias)) +} + +pub(super) fn key_for_collection(collection: &[u8; 32]) -> PersonKey { + if collection == &LITE_PERSON_COLLECTION { + PersonKey::Lite + } else { + PersonKey::Full + } +} + +fn collection_id(location: &RingLocation) -> Result<[u8; 32], RingVrfError> { + location + .junctions + .iter() + .find_map(|junction| match junction { + RingLocationJunction::CollectionId(value) => Some(value), + RingLocationJunction::PalletInstance(_) => None, + }) + .map_or(Ok(FULL_PERSON_COLLECTION), |value| { + value + .as_slice() + .try_into() + .map_err(|_| RingVrfError::RingNotFound) + }) +} + +fn pallet_instance(location: &RingLocation) -> Option { + location + .junctions + .iter() + .find_map(|junction| match junction { + RingLocationJunction::PalletInstance(index) => Some(*index), + RingLocationJunction::CollectionId(_) => None, + }) +} + +fn blake2b_256(input: &[u8], key: Option<&[u8]>) -> [u8; 32] { + let mut params = blake2b_simd::Params::new(); + params.hash_length(32); + if let Some(key) = key { + params.key(key); + } + let hash = params.hash(input); + let mut output = [0u8; 32]; + output.copy_from_slice(hash.as_bytes()); + output +} + +fn unknown(error: impl std::fmt::Debug) -> RingVrfError { + RingVrfError::Unknown { + reason: format!("{error:?}"), + } +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +enum RingPosition { + Onboarding {}, + Included { ring_index: u32, ring_position: u32 }, + Suspended, +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct RingStatus { + included: u32, +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct CollectionInfo { + ring_size: RingExponent, +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +enum RingExponent { + R2e9, + R2e10, + R2e14, +} + +impl RingExponent { + fn domain_size(self) -> RingDomainSize { + match self { + Self::R2e9 => RingDomainSize::Domain11, + Self::R2e10 => RingDomainSize::Domain12, + Self::R2e14 => RingDomainSize::Domain16, + } + } +} + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct BoundedMembers(Vec<[u8; 32]>); + +#[derive(Debug, PartialEq, Eq, DecodeAsType)] +struct RingRoot { + revision: u32, +} + +#[cfg(test)] +mod tests { + use parity_scale_codec::Encode; + use scale_info::TypeInfo; + + use super::*; + + fn decode_as(source: A) -> B + where + A: Encode + TypeInfo + 'static, + B: DecodeAsType, + { + let mut registry = scale_info::Registry::new(); + let type_id = registry.register_type(&scale_info::meta_type::()).id; + let types: scale_info::PortableRegistry = registry.into(); + B::decode_as_type(&mut &*source.encode(), type_id, &types).expect("dynamic decode succeeds") + } + + #[test] + fn context_matches_rfc_0004_vector() { + let context = ProductProofContext { + product_id: "example.dot".to_string(), + suffix: b"login".to_vec(), + }; + assert_eq!( + hex::encode(context_bytes(&context)), + "be397823154bdcc0f4d86938af932cd4d5c49d0793e0138663ccdb3d8e0062eb" + ); + } + + #[test] + fn collection_selects_corresponding_person_key() { + assert_eq!(key_for_collection(&FULL_PERSON_COLLECTION), PersonKey::Full); + assert_eq!(key_for_collection(&LITE_PERSON_COLLECTION), PersonKey::Lite); + assert_eq!(key_for_collection(&[0xff; 32]), PersonKey::Full); + } + + #[test] + fn missing_collection_defaults_to_full_personhood() { + let location = RingLocation { + chain_id: [0; 32], + junctions: vec![RingLocationJunction::PalletInstance(42)], + }; + assert_eq!(collection_id(&location), Ok(FULL_PERSON_COLLECTION)); + } + + #[test] + fn storage_projections_decode_runtime_shapes() { + #[derive(Encode, TypeInfo)] + enum SourceRingPosition { + Onboarding { + queue_page: u32, + queued_at: u64, + }, + Included { + ring_index: u32, + ring_page: u32, + ring_position: u32, + }, + Suspended, + } + #[derive(Encode, TypeInfo)] + enum SourceRingExponent { + R2e9, + R2e10, + R2e14, + } + #[derive(Encode, TypeInfo)] + struct SourceCollectionInfo { + owner: u8, + mode: u8, + ring_size: SourceRingExponent, + self_inclusion_delay: Option, + } + #[derive(Encode, TypeInfo)] + struct SourceBoundedMembers(Vec<[u8; 32]>); + #[derive(Encode, TypeInfo)] + struct SourceRingRoot { + root: [u8; 4], + revision: u32, + intermediate: [u8; 8], + } + + assert_eq!( + decode_as::<_, RingPosition>(SourceRingPosition::Included { + ring_index: 7, + ring_page: 3, + ring_position: 19, + }), + RingPosition::Included { + ring_index: 7, + ring_position: 19, + } + ); + assert_eq!( + decode_as::<_, CollectionInfo>(SourceCollectionInfo { + owner: 1, + mode: 0, + ring_size: SourceRingExponent::R2e10, + self_inclusion_delay: Some(3_600), + }), + CollectionInfo { + ring_size: RingExponent::R2e10, + } + ); + assert_eq!( + decode_as::<_, BoundedMembers>(SourceBoundedMembers(vec![[0x11; 32], [0x22; 32]])), + BoundedMembers(vec![[0x11; 32], [0x22; 32]]) + ); + assert_eq!( + decode_as::<_, RingRoot>(SourceRingRoot { + root: [0x33; 4], + revision: 12, + intermediate: [0x44; 8], + }), + RingRoot { revision: 12 } + ); + + // Keep every runtime variant in the type registry so this test also + // checks their names remain aligned with the partial decoder. + let _ = SourceRingPosition::Onboarding { + queue_page: 0, + queued_at: 0, + }; + let _ = SourceRingPosition::Suspended; + let _ = SourceRingExponent::R2e9; + let _ = SourceRingExponent::R2e14; + } +} diff --git a/rust/crates/truapi-server/src/runtime/statement_store.rs b/rust/crates/truapi-server/src/runtime/statement_store.rs index b55d34d4..a8adf8a9 100644 --- a/rust/crates/truapi-server/src/runtime/statement_store.rs +++ b/rust/crates/truapi-server/src/runtime/statement_store.rs @@ -420,7 +420,7 @@ mod tests { fn signing_host_runtime(product_id: &str) -> (ProductRuntimeHost, Arc) { let platform: Arc = Arc::new(StubPlatform::default()); let services = RuntimeServices::new(platform.clone(), [0; 32], [0xbb; 32], test_spawner()); - let signing_host = SigningHostRole::new(platform); + let signing_host = SigningHostRole::new(services.clone()); futures::executor::block_on(signing_host.activate_local_session(ENTROPY.to_vec())) .expect("activation succeeds"); let host = ProductRuntimeHost::from_services(