From bbf3310e50faa4c657e053ab3ddafc4b1c56c525 Mon Sep 17 00:00:00 2001 From: JW Rami <106039941+JW-Rami@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:04:55 +0200 Subject: [PATCH] feat: add experimental generic FROST HD derivation --- Cargo.toml | 3 + frost-hd/CHANGELOG.md | 10 + frost-hd/Cargo.toml | 31 + frost-hd/README.md | 25 + frost-hd/docs/ARCHITECTURE.md | 69 ++ frost-hd/docs/ATTRIBUTION.md | 26 + frost-hd/docs/BITCOIN_PROFILE.md | 17 + frost-hd/docs/INVISIBLE_PROFILE.md | 28 + frost-hd/docs/RFC-0001.md | 384 +++++++++++ frost-hd/docs/SECURITY.md | 46 ++ frost-hd/docs/SOLANA_PROFILE.md | 21 + frost-hd/reference/frost_hd_ed25519.py | 533 +++++++++++++++ frost-hd/reference/test_frost_hd_ed25519.py | 143 +++++ frost-hd/src/error.rs | 69 ++ frost-hd/src/lib.rs | 676 ++++++++++++++++++++ frost-hd/src/path.rs | 255 ++++++++ frost-hd/tests/ciphersuites.rs | 177 +++++ frost-hd/tests/ed25519.rs | 174 +++++ frost-hd/tests/security_regression.rs | 21 + frost-hd/tests/vectors.rs | 67 ++ frost-hd/vectors/frost-hd-v1-ed25519.json | 75 +++ 21 files changed, 2850 insertions(+) create mode 100644 frost-hd/CHANGELOG.md create mode 100644 frost-hd/Cargo.toml create mode 100644 frost-hd/README.md create mode 100644 frost-hd/docs/ARCHITECTURE.md create mode 100644 frost-hd/docs/ATTRIBUTION.md create mode 100644 frost-hd/docs/BITCOIN_PROFILE.md create mode 100644 frost-hd/docs/INVISIBLE_PROFILE.md create mode 100644 frost-hd/docs/RFC-0001.md create mode 100644 frost-hd/docs/SECURITY.md create mode 100644 frost-hd/docs/SOLANA_PROFILE.md create mode 100755 frost-hd/reference/frost_hd_ed25519.py create mode 100644 frost-hd/reference/test_frost_hd_ed25519.py create mode 100644 frost-hd/src/error.rs create mode 100644 frost-hd/src/lib.rs create mode 100644 frost-hd/src/path.rs create mode 100644 frost-hd/tests/ciphersuites.rs create mode 100644 frost-hd/tests/ed25519.rs create mode 100644 frost-hd/tests/security_regression.rs create mode 100644 frost-hd/tests/vectors.rs create mode 100644 frost-hd/vectors/frost-hd-v1-ed25519.json diff --git a/Cargo.toml b/Cargo.toml index 73eecfb7..b095710a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "frost-core", + "frost-hd", "frost-ed448", "frost-ed25519", "frost-p256", @@ -45,6 +46,7 @@ document-features = "0.2.12" ed448-goldilocks = { version = "0.14.0-pre.15", default-features = false } ed25519-dalek = "3.0" hex = { package = "const-hex", version = "1.19", default-features = false, features = ["alloc"] } +hmac = { version = "0.13", default-features = false } insta = { version = "1.48", features = ["yaml"] } k256 = { version = "0.14", default-features = false, features = ["hash2curve"] } p256 = { version = "0.14", default-features = false, features = ["hash2curve"] } @@ -67,6 +69,7 @@ visibility = "0.1" zeroize = { version = "1.9", default-features = false, features = ["derive", "alloc"] } frost-core = { version = "3.0.0", path = "frost-core", default-features = false } +frost-hd = { version = "0.1.0", path = "frost-hd", default-features = false } frost-ed448 = { version = "3.0.0", path = "frost-ed448", default-features = false } frost-ed25519 = { version = "3.0.0", path = "frost-ed25519", default-features = false } frost-p256 = { version = "3.0.0", path = "frost-p256", default-features = false } diff --git a/frost-hd/CHANGELOG.md b/frost-hd/CHANGELOG.md new file mode 100644 index 00000000..ec089d39 --- /dev/null +++ b/frost-hd/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +## Unreleased + +- Add an experimental ciphersuite-agnostic non-hardened FROST key-family + derivation API. +- Add canonical typed paths and explicit hardened-path rejection. +- Add non-signable root wrappers and child public/private package agreement + checks. +- Document the parent-share recovery and cross-path compromise model. diff --git a/frost-hd/Cargo.toml b/frost-hd/Cargo.toml new file mode 100644 index 00000000..d2ac9634 --- /dev/null +++ b/frost-hd/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "frost-hd" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +description = "Experimental blockchain-agnostic non-hardened hierarchical key derivation for FROST" +documentation = "https://docs.rs/frost-hd" +readme = "README.md" +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords = ["cryptography", "threshold", "frost", "derivation", "wallet"] +categories.workspace = true +publish = false + +[dependencies] +frost-core = { workspace = true, features = ["internals"] } +hmac.workspace = true +sha2.workspace = true + +[dev-dependencies] +frost-ed25519.workspace = true +hex.workspace = true +frost-ristretto255.workspace = true +frost-secp256k1.workspace = true +rand_chacha.workspace = true +rand_core.workspace = true + +[features] +default = [] diff --git a/frost-hd/README.md b/frost-hd/README.md new file mode 100644 index 00000000..6268d316 --- /dev/null +++ b/frost-hd/README.md @@ -0,0 +1,25 @@ +# frost-hd + +Experimental, blockchain-agnostic, **non-hardened** hierarchical key +derivation for FROST key packages. + +The crate is generic over a FROST ciphersuite `C` and an explicitly reviewed +`DerivationProfile`. It does not provide blanket support for every +ciphersuite. + +The core transformation is: + +```text +s_i' = s_i + t +Y_i' = Y_i + tG +Y' = Y + tG +``` + +A descendant signing share plus public derivation metadata reveals the +corresponding ancestor share. See `../docs/SECURITY.md` before evaluating this +API. + +This crate is research code for private review. It is not audited or +production-ready. It intentionally excludes BIP32 xpubs, Taproot wallet +policy, Solana addresses, SLIP-0010 compatibility, and hardened threshold +derivation. diff --git a/frost-hd/docs/ARCHITECTURE.md b/frost-hd/docs/ARCHITECTURE.md new file mode 100644 index 00000000..cbb61d50 --- /dev/null +++ b/frost-hd/docs/ARCHITECTURE.md @@ -0,0 +1,69 @@ +# Contribution architecture + +## Recommended boundary + +The proposal is split into three layers. + +### 1. Generic `frost-hd` protocol + +This layer owns only: + +- canonical, typed derivation paths; +- application/network/purpose domain separation; +- deterministic edge KDF and retry rules; +- additive transformation of `KeyPackage` and `PublicKeyPackage`; +- non-signable root wrappers; +- security documentation and vectors. + +It is generic over `C: frost_core::Ciphersuite` and an explicit +`P: DerivationProfile`. There is no blanket profile for all ciphersuites. +This prevents the crate from silently claiming compatibility with suites whose +key normalization or signing semantics are not linear under persistent tweaks. + +The implementation follows the existing `frost-rerandomized` precedent by +using `frost-core/internals`. No raw secret scalar is exposed through the +public API. + +### 2. Reviewed ciphersuite profile + +A profile defines: + +```text +P.ID +P.hash_to_scalar(input) -> Scalar +``` + +The profile ID is included in every context and edge transcript. Changing the +profile ID or mapping creates a different key family. + +The research tests use domain-separated `C::H1` profiles for Ed25519, +Ristretto255, and ordinary secp256k1. These are explicit test profiles, not a +blanket production claim. Maintainers should decide whether reviewed profiles +use: + +1. a dedicated `HHD` ciphersuite function; +2. a standard ciphersuite-specific hash-to-field mapping; +3. domain-separated `C::H1` after cryptographic review. + +An x-only Taproot profile requires separate treatment of normalization and +tweak ordering. It is deliberately not instantiated by the generic tests. + +### 3. Blockchain and application adapters + +Solana adapters may Base58-encode a derived Ed25519 joint public key. They must +not claim SLIP-0010 compatibility. + +Bitcoin adapters may define BIP32 xpub fields, BIP340 parity normalization, +BIP341 Taproot tweak ordering, descriptors, and BIP32 conformance vectors. +Those rules remain outside the generic protocol. + +Application path allocation, backups, scanning, high-water marks, and policy +are also outside `frost-hd`. + +## Why not put everything in `frost-core` + +Persistent wallet derivation is a broader protocol than FROST signing. A +separate crate limits the API and misuse surface while reusing existing package +abstractions. A minimal core primitive remains an alternative if maintainers +want additive package tweaking shared by `frost-rerandomized`, Taproot, and HD +derivation. diff --git a/frost-hd/docs/ATTRIBUTION.md b/frost-hd/docs/ATTRIBUTION.md new file mode 100644 index 00000000..34703e03 --- /dev/null +++ b/frost-hd/docs/ATTRIBUTION.md @@ -0,0 +1,26 @@ +# Prior art and attribution + +This proposal builds on and must preserve attribution to: + +- Zcash Foundation FROST Discussion #636, "BIP32 Derivation of FROST Keys": + https://github.com/ZcashFoundation/frost/discussions/636 +- MatthewLM's observation that signing shares, verification shares, and the + joint key can receive the same additive tweak; +- Conduition's analysis and historical `taproot-bip32` prototype: + https://github.com/conduition/frost/tree/taproot-bip32 +- Jesse Posner's discussion of fixed/synthetic chain-code profiles and + MuSig2-related work; +- PR #584 and successor PR #730 for the Taproot ciphersuite context: + https://github.com/ZcashFoundation/frost/pull/584 + https://github.com/ZcashFoundation/frost/pull/730 +- BIP32's non-hardened derivation and parent-key-recovery warning: + https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki +- BIP328 synthetic xpub work for aggregate keys: + https://github.com/bitcoin/bips/blob/master/bip-0328.mediawiki +- threshold-HD research including ePrint 2023/312 and 2023/714: + https://eprint.iacr.org/2023/312 + https://eprint.iacr.org/2023/714 + +The historical prototype is treated as useful prior art but is not rebased +wholesale because it is Taproot/BIP32-specific and diverged from the current +FROST v3 architecture. diff --git a/frost-hd/docs/BITCOIN_PROFILE.md b/frost-hd/docs/BITCOIN_PROFILE.md new file mode 100644 index 00000000..1799c5a5 --- /dev/null +++ b/frost-hd/docs/BITCOIN_PROFILE.md @@ -0,0 +1,17 @@ +# Bitcoin / secp256k1 profile + +The generic protocol can derive ordinary FROST secp256k1 key packages through +an explicitly reviewed secp256k1 `DerivationProfile`. This does not embed +Bitcoin wallet policy and is not BIP32 compatibility by itself. + +Exact Bitcoin interoperability requires a separate adapter defining: + +- BIP32 non-hardened CKD and chain-code semantics, when exact compatibility is + a goal; +- xpub depth, parent fingerprint, child number, and version bytes; +- BIP340 x-only parity normalization; +- ordering relative to the BIP341 Taproot tweak; +- descriptor serialization and conformance vectors. + +The current generic crate does not instantiate `frost-secp256k1-tr`. X-only +normalization and Taproot tweak ordering require their own profile and proof. diff --git a/frost-hd/docs/INVISIBLE_PROFILE.md b/frost-hd/docs/INVISIBLE_PROFILE.md new file mode 100644 index 00000000..ef0d1156 --- /dev/null +++ b/frost-hd/docs/INVISIBLE_PROFILE.md @@ -0,0 +1,28 @@ +# Invisible profile: separate from the generic protocol + +Invisible uses FROST Ed25519 on Solana. Its application profile would set, for example: + +```text +application = "exchange.invisible" +network = "solana-mainnet" +purpose = "lp-reimbursement" +path = m/// +``` + +The derived Ed25519 joint public key may be Base58-encoded as a Solana address by Invisible. Base58 and Solana address handling do not belong in `frost-hd`. + +## Critical 2-of-2 implication + +If a TEE receives a derived client child share and knows the public cumulative tweak, it can compute the client's parent share. Combined with the TEE side of the 2-of-2 family, this gives the TEE control of the entire bounded family. The construction therefore reduces DKG operations but does not preserve child-level cryptographic separation after delegation. + +## Pilot constraints + +- no global protocol root; +- one root per bounded refill batch or short operational epoch; +- root never funded; +- initially 16 children, hard cap 32; +- atomic range reservation and sealed high-water mark; +- tombstone abandoned indices; no path reuse; +- rotate through a fresh independent DKG; +- keep independent-DKG fallback; +- do not deploy in V0 without a cryptographic review and end-to-end sealed-state tests. diff --git a/frost-hd/docs/RFC-0001.md b/frost-hd/docs/RFC-0001.md new file mode 100644 index 00000000..b3b5fa2a --- /dev/null +++ b/frost-hd/docs/RFC-0001.md @@ -0,0 +1,384 @@ +# RFC 0001: Generic non-hardened hierarchical key derivation for FROST + +- Status: Draft for private cryptographic and maintainer review +- Base: `ZcashFoundation/frost@0966bd1529aa062ad3b621af99e277f976b1c0f0` +- Protocol label: `FROST-HD-v1` +- Scope: blockchain-agnostic, ciphersuite-profiled, persistent, non-hardened child key families + +## 1. Summary + +This RFC defines a deterministic tree of FROST key packages from one FROST root key package. It applies the same public additive tweak to: + +```text +participant signing share: s_i' = s_i + t mod q +participant verifying share: Y_i' = Y_i + tG +joint verifying key: Y' = Y + tG +``` + +The construction is independent of Bitcoin, Solana, address encoding, and wallet path conventions. Blockchain-specific adapters MAY build on it, but are outside this protocol. + +The construction is non-hardened. Any holder of the extended public metadata can enumerate descendants. A compromised child signing share plus the public tweak reveals that participant's parent signing share. This related-key property is fundamental, not an implementation defect. + +## 2. Non-goals + +This RFC does not define: + +- BIP32 xpubs, fingerprints, version bytes, or Base58Check; +- BIP340 x-only parity or BIP341 Taproot tweaks; +- Solana Base58 addresses or SLIP-0010 mnemonic paths; +- hardened, private-only derivation; +- wallet discovery, gap limits, account schemas, or address allocation; +- DKG, refresh, resharing, or chain-code commit/reveal transport; +- a claim that derived addresses are unlinkable to an extended-public-key holder. + +## 3. Cryptographic setting + +Let `C` be a FROST ciphersuite over a prime-order group `G` of order `q` with generator `B`. Let `P` be an explicit derivation profile reviewed for `C`. The generic protocol does not provide a blanket implementation for every `Ciphersuite`. + +A threshold key has Shamir polynomial + +```text +f(X) = x + a_1 X + ... + a_(t-1) X^(t-1) +``` + +Participant identifier `alpha_i` owns + +```text +s_i = f(alpha_i) +Y_i = s_i B +``` + +and the joint public key is `Y = xB`. + +For a qualified signer set `S`, let `lambda_i^S` be the Lagrange coefficient at zero. Since interpolation reconstructs the constant polynomial `1`, + +```text +sum_{i in S} lambda_i^S = 1 mod q. +``` + +For a public scalar `t`, define `f_t(X) = f(X) + t`. Then + +```text +sum lambda_i^S (s_i + t) + = sum lambda_i^S s_i + t sum lambda_i^S + = x + t mod q. +``` + +Therefore the transformed shares are valid shares of the transformed group secret for every threshold and every qualified signing subset. + +## 4. Terminology and types + +### 4.1 Derivation key + +`DerivationKey` is exactly 32 bytes of authenticated family metadata. It plays a chain-code-like role but is deliberately not named `ChainCode`, because this protocol is not BIP32. + +The derivation key is not assumed secret. Its disclosure enables deterministic descendant enumeration and parent-share recovery when combined with one descendant signing share. + +### 4.2 Context + +A `DerivationContext` contains three opaque byte strings: + +```text +application +network +purpose +``` + +None is blockchain-hardcoded. Examples include: + +```text +application = "exchange.invisible" +network = "solana-mainnet" +purpose = "lp-reimbursement" +``` + +or: + +```text +application = "wallet.example" +network = "bitcoin-mainnet" +purpose = "taproot-receive" +``` + +Each component MUST contain at most 65535 bytes. Empty values are allowed only when the application specification explicitly assigns them meaning. + +### 4.3 Derivation profile + +`DerivationProfile` is an explicit opt-in for one scalar-mapping and ciphersuite combination: + +```text +P.ID +P.hash_to_scalar(input) -> Scalar +``` + +`P.ID` MUST be globally unique, versioned, and stable. It is included in the context and every edge transcript. A change to `P.ID` or its scalar mapping creates a different family. + +The scalar mapping MUST be deterministic and uniform to the security level required by `C`. A profile MUST NOT be implemented for a ciphersuite whose key normalization or signing semantics are not linear under persistent additive tweaks unless those semantics are specified and tested explicitly. + +### 4.4 Path segments + +A path segment is: + +```text +Normal(u32) +Hardened(u32) +``` + +The kind is encoded explicitly; the high bit is not overloaded. `Normal(0x80000000)` and `Hardened(0)` are distinct. + +`FROST-HD-v1` supports only `Normal`. A hardened segment MUST fail with `HardenedDerivationUnsupported` and MUST NOT be silently reinterpreted. + +Canonical text syntax is: + +```text +m/0/1/4294967295 +m/0'/1 # parses, but derivation is unsupported +``` + +Decimal indices MUST have no sign and no leading zero unless the value is exactly zero. Maximum depth is 64. + +### 4.5 Root and child wrappers + +The root MUST be represented by distinct non-signable wrappers: + +```text +HdRootKeyPackage +HdRootPublicKeyPackage +``` + +Derived children use: + +```text +ExtendedKeyPackage +ExtendedPublicKeyPackage +``` + +The API MUST NOT expose a direct signing method on a root wrapper. Applications MUST NOT fund or publish the root key as an on-chain address. + +## 5. Canonical framing + +All integers are unsigned big-endian. `LP16(x)` is `len(x)` as `u16` followed by `x`. + +```text +context_digest = SHA-256( + "FROST-HD-v1/context" + || version_u16 + || LP16(C.ID) + || LP16(P.ID) + || LP16(application) + || LP16(network) + || LP16(purpose) +) +``` + +A normal edge is encoded as: + +```text +segment = 0x00 || index_u32 +``` + +A hardened edge is encoded as: + +```text +segment = 0x01 || index_u32 +``` + +Unknown segment kinds MUST be rejected. + +## 6. Root derivation-key establishment + +The generic crate accepts an explicit authenticated 32-byte root `DerivationKey`. + +The generic crate MUST NOT silently derive it from only the joint public key or verification shares. Applications may choose one of these external profiles: + +1. random application-provided key, authenticated alongside the root public package; +2. multi-party commit/reveal contributions bound to the DKG transcript; +3. a deliberately public deterministic profile derived from the root public key. + +Profile 1 is the minimal API. Profile 2 prevents unilateral adaptive bias if at least one contribution is honest, but does not prevent selective abort. Profile 3 simplifies recovery but makes disclosure of the root public key sufficient to derive the entire public family. + +The selected root profile and its version MUST be included in the family descriptor. + +## 7. Child derivation + +Inputs: + +- parent joint public key `Y_parent`; +- parent derivation key `k_parent`; +- `context_digest`; +- normal child index `j`; +- ciphersuite `C`; +- explicitly reviewed derivation profile `P`. + +For retry counter `r = 0..65535`, compute: + +```text +common = + "FROST-HD-v1/edge" + || version_u16 + || LP16(C.ID) + || LP16(P.ID) + || context_digest + || LP16(SerializeElement(Y_parent)) + || 0x00 + || j_u32 + || r_u16 +``` + +```text +prf_t = HMAC-SHA-512( + key = k_parent, + data = "FROST-HD-v1/tweak-prf" || common +) +``` + +The explicit profile maps the PRF output into `C`'s scalar field: + +```text +t = P.hash_to_scalar("FROST-HD-v1/hash-to-scalar" || prf_t) +``` + +The test-only Ed25519, Ristretto255, and secp256k1 profiles reuse domain-separated `C.H1`. That choice is executable prior art, not a blanket production recommendation. A reviewed profile may instead use a dedicated `HHD` function or a ciphersuite-standard hash-to-field procedure. + +Derive the next public metadata key independently: + +```text +prf_k = HMAC-SHA-512( + key = k_parent, + data = "FROST-HD-v1/child-derivation-key" || common +) + +k_child = first_32_bytes(prf_k) +``` + +Accept the first retry where: + +```text +t != 0 +Y_parent + tB != identity +``` + +The retry decision depends only on public family state, so all participants deterministically agree. + +A participant then computes: + +```text +s_i_child = s_i_parent + t mod q +Y_i_child = Y_i_parent + tB +Y_child = Y_parent + tB +``` + +If an individual `Y_i_child` is the identity, derivation for that share epoch returns `DerivedVerifyingShareIdentity`. The implementation MUST NOT select a different retry based on verification-share contents, because doing so would make child group keys depend on refresh/resharing state. The caller must refresh/reshare or rotate the root family. This event has negligible probability for honestly generated keys. + +## 8. Path derivation + +A path is evaluated left to right: + +```text +derive(node, [a, b, c]) = derive(derive(derive(node, a), b), c) +``` + +There is no separate direct algorithm. Therefore direct and stepwise derivation are identical by definition. + +Each edge uses the immediate parent joint public key and immediate parent derivation key. Different branches therefore produce independent PRF inputs except with negligible collision probability. + +## 9. Public and private derivation + +An `ExtendedPublicKeyPackage` contains: + +```text +PublicKeyPackage +DerivationKey +DerivationContextDigest +DerivationPath +KeyFamilyVersion +DerivationProfileId +RootDerivationKeyProfileId +``` + +An `ExtendedKeyPackage` contains the matching `KeyPackage` plus the same public metadata. + +Public derivation transforms all verification shares and the joint verifying key. Private derivation transforms one signing share and verifies that its resulting verification share and joint key match the derived public package. + +All signers and the coordinator MUST bind the exact family version, context digest, path, and derived public package in the application protocol. Signing under a wrong path must fail before nonce generation where practical. + +## 10. Refresh and resharing + +If a share refresh preserves the root joint public key, context, derivation profile, derivation key, and path, then it preserves every descendant joint public key. + +The participant verification shares change, and derived child verification shares change accordingly. Applications SHOULD track a separate `share_epoch_id` that is not part of the family KDF. + +Packages from different share epochs MUST NOT be mixed in one signing session. + +If resharing changes the root joint public key, it creates a new family. + +## 11. Serialization + +Canonical binary path encoding: + +```text +version_u16 +segment_count_u16 +repeated segment_count times: + segment_kind_u8 + index_u32 +``` + +The decoder MUST reject: + +- unsupported versions; +- count greater than 64; +- truncated input; +- trailing bytes; +- unknown segment kinds; +- non-canonical textual representations; +- hardened derivation attempts. + +Stable serialization of extended key packages is deferred until maintainers agree whether the crate belongs upstream. The prototype serializes only the path and public family descriptor. + +## 12. Security properties and limitations + +### 12.1 Parent-share recovery + +For every participant: + +```text +s_i_parent = s_i_child - t mod q. +``` + +Therefore one child-share compromise plus extended public metadata reveals that participant's parent share. Compromises of distinct participants across distinct child paths accumulate toward the root threshold. + +### 12.2 Enumeration and linkage + +An observer holding only unrelated child public keys cannot efficiently identify the family under standard PRF/hash assumptions. A holder of the extended public metadata can enumerate configured paths and link all resulting descendants intentionally. + +### 12.3 Compartmentalization + +Independent DKG keys are cryptographically compartmentalized. HD-derived children share a common compromise root. This RFC reduces repeated DKG work and key-state volume; it does not provide the isolation of independent DKGs. + +### 12.4 Nonce separation + +FROST nonce generation already binds signing material and message context. Applications MUST never cache or reuse nonce pairs across root or child keys. Root wrappers are non-signable to reduce accidental cross-key nonce misuse. + +### 12.5 Hardened derivation + +Hardened derivation requires secret-dependent PRF evaluation without reconstructing the parent secret. That is a separate interactive MPC/threshold-PRF protocol and is not emulated by this RFC. + +## 13. Blockchain profiles + +### 13.1 Solana/Ed25519 + +A Solana adapter may Base58-encode the derived Ed25519 joint public key. It MUST NOT claim compatibility with SLIP-0010 or standard hardened Solana mnemonic paths. + +### 13.2 Bitcoin/secp256k1 + +A Bitcoin adapter may define BIP32-compatible xpub serialization, BIP340 parity handling, BIP341 Taproot tweaks, and descriptors. Those semantics MUST remain outside the generic crate and require their own conformance vectors. + +## 14. Open review questions + +1. Should reviewed profile implementations live in each ciphersuite crate or in separate adapter crates? +2. Should profiles use a dedicated `HHD`, a standard hash-to-field mapping, or domain-separated `C::H1`? +3. Does persistent HD derivation belong in the FROST workspace or a wallet-layer repository? +4. Should the generic crate accept only an explicit derivation key, leaving commit/reveal entirely application-owned? +5. Is the related-key leakage model acceptable for an upstream experimental crate? +6. Should stable extended-package serialization wait for a second PR? diff --git a/frost-hd/docs/SECURITY.md b/frost-hd/docs/SECURITY.md new file mode 100644 index 00000000..9e4586f9 --- /dev/null +++ b/frost-hd/docs/SECURITY.md @@ -0,0 +1,46 @@ +# Security review notes + +## Severity-defining property + +The construction is intentionally related-key: + +```text +parent participant share = child participant share - public cumulative tweak +``` + +This means the security boundary is one key family, not one child. An adversary may compromise participant A on path `/0` and participant B on path `/99/3`, normalize both shares to the same root epoch, and reach the threshold without ever compromising two shares on one child. + +## Threat model + +The proposal explicitly considers: + +- root or child signing-share compromise; +- derivation-key disclosure and path enumeration; +- cross-path accumulation of distinct participant compromises; +- malicious root derivation-key bias and selective abort; +- inconsistent public package, path, context, or retry views; +- refresh and resharing epoch confusion; +- signer-set and identifier changes; +- root and child nonce reuse; +- rollback, duplicate path allocation, and backup loss; +- malformed and non-canonical serialization; +- zero scalar, group identity, subgroup, and cofactor edge cases; +- accidental root signing or funding; +- downgrade to a weaker family/profile version. + +## Required application controls + +An application using this construction must: + +1. authenticate the root public package, root derivation key, context, and version; +2. reserve paths atomically and never reuse abandoned paths; +3. persist a monotonic high-water mark or equivalent allocation ledger; +4. bind path and family metadata to signing authorization; +5. separate share epochs and reject mixed-epoch signing; +6. rotate to a fresh independent DKG after a bounded number of children or any suspected compromise; +7. retain independent-DKG fallback; +8. avoid logging derivation keys and paths when they are treated as sensitive metadata. + +## No production claim + +The Rust code in this repository is a research prototype. It has not received an independent cryptographic audit and has not been compiled against the full upstream workspace in the current execution environment. diff --git a/frost-hd/docs/SOLANA_PROFILE.md b/frost-hd/docs/SOLANA_PROFILE.md new file mode 100644 index 00000000..bd308740 --- /dev/null +++ b/frost-hd/docs/SOLANA_PROFILE.md @@ -0,0 +1,21 @@ +# Solana / Ed25519 profile + +A Solana integration uses the generic protocol with the official +`FROST-ED25519-SHA512-v1` ciphersuite and an explicitly reviewed Ed25519 +`DerivationProfile`. + +Example application context and path policy: + +```text +application = "wallet.example" +network = "solana-mainnet" +purpose = "receive" +path = m///
+``` + +The resulting joint Ed25519 public key can be Base58-encoded as a Solana +address by the application layer. Base58 is not part of `frost-hd`. + +This construction is not SLIP-0010 and is not compatible with common hardened +Solana mnemonic paths such as `m/44'/501'/account'/0'`. It must be described as +a distinct FROST Ed25519 key-family protocol. diff --git a/frost-hd/reference/frost_hd_ed25519.py b/frost-hd/reference/frost_hd_ed25519.py new file mode 100755 index 00000000..4eb397ab --- /dev/null +++ b/frost-hd/reference/frost_hd_ed25519.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +"""Independent FROST-HD-v1 Ed25519 reference model. + +Research-only code. It implements the normative transcript and group equations +without depending on the Rust prototype. It is not constant-time and must not +be used with production secrets. +""" + +from __future__ import annotations + +import hashlib +import hmac +import itertools +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Sequence + +FIELD_P = 2**255 - 19 +SCALAR_Q = 2**252 + 27742317777372353535851937790883648493 +EDWARDS_D = (-121665 * pow(121666, FIELD_P - 2, FIELD_P)) % FIELD_P +SQRT_M1 = pow(2, (FIELD_P - 1) // 4, FIELD_P) +IDENTITY = (0, 1) + +KEY_FAMILY_VERSION = 1 +PATH_ENCODING_VERSION = 1 +MAX_PATH_DEPTH = 64 +MAX_EDGE_RETRIES = 2**16 +CIPHERSUITE_ID = b"FROST-ED25519-SHA512-v1" +PROFILE_ID = b"FROST-HD-ED25519-SHA512-H1-v1" + +DST_CONTEXT = b"FROST-HD-v1/context" +DST_EDGE = b"FROST-HD-v1/edge" +DST_TWEAK_PRF = b"FROST-HD-v1/tweak-prf" +DST_CHILD_KEY = b"FROST-HD-v1/child-derivation-key" +DST_HASH_TO_SCALAR = b"FROST-HD-v1/hash-to-scalar" + + +def _inverse(value: int) -> int: + value %= FIELD_P + if value == 0: + raise ZeroDivisionError("cannot invert zero") + return pow(value, FIELD_P - 2, FIELD_P) + + +def _recover_x(y: int, sign: int) -> int: + numerator = (y * y - 1) % FIELD_P + denominator = (EDWARDS_D * y * y + 1) % FIELD_P + x_squared = numerator * _inverse(denominator) % FIELD_P + x = pow(x_squared, (FIELD_P + 3) // 8, FIELD_P) + if (x * x - x_squared) % FIELD_P != 0: + x = x * SQRT_M1 % FIELD_P + if (x * x - x_squared) % FIELD_P != 0: + raise ValueError("invalid Edwards point") + if x & 1 != sign: + x = FIELD_P - x + return x + + +BASE_Y = 4 * _inverse(5) % FIELD_P +BASE_POINT = (_recover_x(BASE_Y, 0), BASE_Y) + + +def _to_extended(point: tuple[int, int]) -> tuple[int, int, int, int]: + x, y = point + return x, y, 1, x * y % FIELD_P + + +def _from_extended(point: tuple[int, int, int, int]) -> tuple[int, int]: + x, y, z, _ = point + inverse_z = _inverse(z) + return x * inverse_z % FIELD_P, y * inverse_z % FIELD_P + + +def _extended_add( + left: tuple[int, int, int, int], + right: tuple[int, int, int, int], +) -> tuple[int, int, int, int]: + x1, y1, z1, t1 = left + x2, y2, z2, t2 = right + a = (y1 - x1) * (y2 - x2) % FIELD_P + b = (y1 + x1) * (y2 + x2) % FIELD_P + c = 2 * EDWARDS_D * t1 * t2 % FIELD_P + d = 2 * z1 * z2 % FIELD_P + e = (b - a) % FIELD_P + f = (d - c) % FIELD_P + g = (d + c) % FIELD_P + h = (b + a) % FIELD_P + return e * f % FIELD_P, g * h % FIELD_P, f * g % FIELD_P, e * h % FIELD_P + + +def _extended_double(point: tuple[int, int, int, int]) -> tuple[int, int, int, int]: + x, y, z, _ = point + a = x * x % FIELD_P + b = y * y % FIELD_P + c = 2 * z * z % FIELD_P + d = -a % FIELD_P + e = ((x + y) * (x + y) - a - b) % FIELD_P + g = (d + b) % FIELD_P + f = (g - c) % FIELD_P + h = (d - b) % FIELD_P + return e * f % FIELD_P, g * h % FIELD_P, f * g % FIELD_P, e * h % FIELD_P + + +def point_add(left: tuple[int, int], right: tuple[int, int]) -> tuple[int, int]: + return _from_extended(_extended_add(_to_extended(left), _to_extended(right))) + + +def point_neg(point: tuple[int, int]) -> tuple[int, int]: + return (-point[0] % FIELD_P, point[1]) + + +def point_sub(left: tuple[int, int], right: tuple[int, int]) -> tuple[int, int]: + return point_add(left, point_neg(right)) + + +def scalar_mult( + scalar: int, + point: tuple[int, int] = BASE_POINT, + *, + reduce: bool = True, +) -> tuple[int, int]: + if reduce: + scalar %= SCALAR_Q + if scalar < 0: + return scalar_mult(-scalar, point_neg(point), reduce=False) + result = _to_extended(IDENTITY) + addend = _to_extended(point) + while scalar: + if scalar & 1: + result = _extended_add(result, addend) + addend = _extended_double(addend) + scalar >>= 1 + return _from_extended(result) + + +def encode_point(point: tuple[int, int]) -> bytes: + x, y = point + output = bytearray(y.to_bytes(32, "little")) + output[31] |= (x & 1) << 7 + return bytes(output) + + +def decode_point(encoded: bytes) -> tuple[int, int]: + if len(encoded) != 32: + raise ValueError("Ed25519 point encoding must be 32 bytes") + raw = bytearray(encoded) + sign = raw[31] >> 7 + raw[31] &= 0x7F + y = int.from_bytes(raw, "little") + if y >= FIELD_P: + raise ValueError("non-canonical Edwards y coordinate") + point = (_recover_x(y, sign), y) + if point == IDENTITY or scalar_mult(SCALAR_Q, point, reduce=False) != IDENTITY: + raise ValueError("point is not a non-identity prime-order element") + return point + + +def scalar_bytes(scalar: int) -> bytes: + return (scalar % SCALAR_Q).to_bytes(32, "little") + + +def lp16(value: bytes) -> bytes: + if len(value) > 0xFFFF: + raise ValueError("framed component exceeds u16") + return len(value).to_bytes(2, "big") + value + + +def h1_ed25519(message: bytes) -> int: + digest = hashlib.sha512(CIPHERSUITE_ID + b"rho" + message).digest() + return int.from_bytes(digest, "little") % SCALAR_Q + + +def h2_ed25519(message: bytes) -> int: + digest = hashlib.sha512(message).digest() + return int.from_bytes(digest, "little") % SCALAR_Q + + +def context_digest(application: bytes, network: bytes, purpose: bytes) -> bytes: + for component in (application, network, purpose, CIPHERSUITE_ID): + if len(component) > 0xFFFF: + raise ValueError("context component exceeds u16") + return hashlib.sha256( + DST_CONTEXT + + KEY_FAMILY_VERSION.to_bytes(2, "big") + + lp16(CIPHERSUITE_ID) + + lp16(PROFILE_ID) + + lp16(application) + + lp16(network) + + lp16(purpose) + ).digest() + + +@dataclass(frozen=True) +class PathSegment: + index: int + hardened: bool = False + + def __post_init__(self) -> None: + if not 0 <= self.index <= 0xFFFFFFFF: + raise ValueError("path index exceeds u32") + + @property + def kind(self) -> int: + return 1 if self.hardened else 0 + + def __str__(self) -> str: + suffix = "'" if self.hardened else "" + return f"{self.index}{suffix}" + + +@dataclass(frozen=True) +class DerivationPath: + segments: tuple[PathSegment, ...] = () + + def __post_init__(self) -> None: + if len(self.segments) > MAX_PATH_DEPTH: + raise ValueError("path too deep") + + @classmethod + def parse(cls, text: str) -> "DerivationPath": + if text == "m": + return cls() + if not text.startswith("m/"): + raise ValueError("non-canonical path") + tokens = text[2:].split("/") + if not tokens or any(not token for token in tokens): + raise ValueError("non-canonical path") + segments: list[PathSegment] = [] + for token in tokens: + hardened = token.endswith("'") + digits = token[:-1] if hardened else token + if not digits or not digits.isascii() or not digits.isdecimal(): + raise ValueError("non-canonical path") + if len(digits) > 1 and digits.startswith("0"): + raise ValueError("non-canonical path") + try: + index = int(digits, 10) + except ValueError as error: + raise ValueError("non-canonical path") from error + segments.append(PathSegment(index=index, hardened=hardened)) + path = cls(tuple(segments)) + if str(path) != text: + raise ValueError("non-canonical path") + return path + + def child(self, segment: PathSegment) -> "DerivationPath": + return DerivationPath(self.segments + (segment,)) + + def to_bytes(self) -> bytes: + output = bytearray(PATH_ENCODING_VERSION.to_bytes(2, "big")) + output.extend(len(self.segments).to_bytes(2, "big")) + for segment in self.segments: + output.append(segment.kind) + output.extend(segment.index.to_bytes(4, "big")) + return bytes(output) + + @classmethod + def from_bytes(cls, encoded: bytes) -> "DerivationPath": + if len(encoded) < 4: + raise ValueError("malformed path encoding") + version = int.from_bytes(encoded[0:2], "big") + if version != PATH_ENCODING_VERSION: + raise ValueError("unsupported path version") + count = int.from_bytes(encoded[2:4], "big") + if count > MAX_PATH_DEPTH or len(encoded) != 4 + count * 5: + raise ValueError("malformed path encoding") + segments: list[PathSegment] = [] + offset = 4 + for _ in range(count): + kind = encoded[offset] + index = int.from_bytes(encoded[offset + 1 : offset + 5], "big") + if kind not in (0, 1): + raise ValueError("unknown path segment kind") + segments.append(PathSegment(index=index, hardened=kind == 1)) + offset += 5 + return cls(tuple(segments)) + + def __str__(self) -> str: + return "m" + "".join(f"/{segment}" for segment in self.segments) + + +@dataclass(frozen=True) +class Node: + public_key: tuple[int, int] + derivation_key: bytes + context: bytes + path: DerivationPath = DerivationPath() + cumulative_tweak: int = 0 + + def __post_init__(self) -> None: + if len(self.derivation_key) != 32: + raise ValueError("derivation key must be 32 bytes") + if len(self.context) != 32: + raise ValueError("context digest must be 32 bytes") + + +def derive_child(node: Node, segment: PathSegment) -> Node: + if segment.hardened: + raise ValueError("hardened derivation is unsupported") + parent_encoded = encode_point(node.public_key) + for retry in range(MAX_EDGE_RETRIES): + common = ( + DST_EDGE + + KEY_FAMILY_VERSION.to_bytes(2, "big") + + lp16(CIPHERSUITE_ID) + + lp16(PROFILE_ID) + + node.context + + lp16(parent_encoded) + + bytes([segment.kind]) + + segment.index.to_bytes(4, "big") + + retry.to_bytes(2, "big") + ) + tweak_prf = hmac.new( + node.derivation_key, + DST_TWEAK_PRF + common, + hashlib.sha512, + ).digest() + tweak = h1_ed25519(DST_HASH_TO_SCALAR + tweak_prf) + if tweak == 0: + continue + child_public = point_add(node.public_key, scalar_mult(tweak)) + if child_public == IDENTITY: + continue + child_key = hmac.new( + node.derivation_key, + DST_CHILD_KEY + common, + hashlib.sha512, + ).digest()[:32] + return Node( + public_key=child_public, + derivation_key=child_key, + context=node.context, + path=node.path.child(segment), + cumulative_tweak=(node.cumulative_tweak + tweak) % SCALAR_Q, + ) + raise RuntimeError("derivation retry exhausted") + + +def derive_path(root: Node, path: DerivationPath) -> Node: + node = root + for segment in path.segments: + node = derive_child(node, segment) + return node + + +def polynomial_evaluate(coefficients: Sequence[int], x: int) -> int: + result = 0 + for coefficient in reversed(coefficients): + result = (result * x + coefficient) % SCALAR_Q + return result + + +def lagrange_at_zero(identifier: int, subset: Sequence[int]) -> int: + numerator = 1 + denominator = 1 + for other in subset: + if other == identifier: + continue + numerator = numerator * other % SCALAR_Q + denominator = denominator * (other - identifier) % SCALAR_Q + return numerator * pow(denominator % SCALAR_Q, SCALAR_Q - 2, SCALAR_Q) % SCALAR_Q + + +def interpolate_at_zero(shares: dict[int, int], subset: Sequence[int]) -> int: + return sum(lagrange_at_zero(identifier, subset) * shares[identifier] for identifier in subset) % SCALAR_Q + + +def deterministic_scalar(label: bytes) -> int: + scalar = int.from_bytes(hashlib.sha512(label).digest(), "little") % SCALAR_Q + return scalar or 1 + + +def make_shares(secret: int, threshold: int, participants: int, label: bytes) -> dict[int, int]: + if not 1 <= threshold <= participants: + raise ValueError("invalid threshold") + coefficients = [secret] + for index in range(1, threshold): + coefficients.append(deterministic_scalar(label + b"/coefficient/" + index.to_bytes(2, "big"))) + return { + identifier: polynomial_evaluate(coefficients, identifier) + for identifier in range(1, participants + 1) + } + + +def assert_threshold_invariant(shares: dict[int, int], threshold: int, expected: int) -> None: + for subset in itertools.combinations(sorted(shares), threshold): + assert sum(lagrange_at_zero(identifier, subset) for identifier in subset) % SCALAR_Q == 1 + assert interpolate_at_zero(shares, subset) == expected + + +def sign(secret: int, public_key: tuple[int, int], message: bytes, nonce_label: bytes) -> bytes: + nonce = deterministic_scalar(b"FROST-HD-v1/test-nonce/" + nonce_label + message) + commitment = scalar_mult(nonce) + challenge = h2_ed25519(encode_point(commitment) + encode_point(public_key) + message) + response = (nonce + challenge * secret) % SCALAR_Q + return encode_point(commitment) + scalar_bytes(response) + + +def verify(public_key: tuple[int, int], message: bytes, signature: bytes) -> bool: + if len(signature) != 64: + return False + try: + commitment = decode_point(signature[:32]) + except ValueError: + return False + response = int.from_bytes(signature[32:], "little") + if response >= SCALAR_Q: + return False + challenge = h2_ed25519(signature[:32] + encode_point(public_key) + message) + return scalar_mult(response) == point_add(commitment, scalar_mult(challenge, public_key)) + + +def build_vectors() -> dict[str, object]: + application = b"org.example.wallet" + network = b"solana-devnet" + purpose = b"threshold-child-keys" + context = context_digest(application, network, purpose) + root_derivation_key = bytes(range(32)) + root_secret = deterministic_scalar(b"FROST-HD-v1/reference/root-secret") + root_public = scalar_mult(root_secret) + root = Node(root_public, root_derivation_key, context) + message = b"generic FROST-HD reference signature" + paths = [ + DerivationPath.parse("m/0"), + DerivationPath.parse("m/1/42"), + DerivationPath.parse("m/7/3/999"), + DerivationPath.parse("m/4294967295"), + ] + + configurations = [(2, 2), (2, 3), (3, 5)] + root_shares: dict[str, dict[int, int]] = {} + for threshold, participants in configurations: + key = f"{threshold}-of-{participants}" + shares = make_shares(root_secret, threshold, participants, key.encode()) + assert_threshold_invariant(shares, threshold, root_secret) + root_shares[key] = shares + + path_vectors: list[dict[str, object]] = [] + seen_public: set[bytes] = set() + for path in paths: + child = derive_path(root, path) + stepwise = root + for segment in path.segments: + stepwise = derive_child(stepwise, segment) + assert child == stepwise + + child_secret = (root_secret + child.cumulative_tweak) % SCALAR_Q + assert scalar_mult(child_secret) == child.public_key + encoded_child = encode_point(child.public_key) + assert encoded_child not in seen_public + seen_public.add(encoded_child) + + threshold_checks: dict[str, str] = {} + for configuration, shares in root_shares.items(): + threshold = int(configuration.split("-")[0]) + child_shares = { + identifier: (share + child.cumulative_tweak) % SCALAR_Q + for identifier, share in shares.items() + } + assert_threshold_invariant(child_shares, threshold, child_secret) + for identifier, child_share in child_shares.items(): + expected_public = point_add( + scalar_mult(shares[identifier]), + scalar_mult(child.cumulative_tweak), + ) + assert scalar_mult(child_share) == expected_public + threshold_checks[configuration] = "pass" + + signature = sign(child_secret, child.public_key, message, str(path).encode()) + assert verify(child.public_key, message, signature) + assert not verify(root.public_key, message, signature) + + path_vectors.append( + { + "path": str(path), + "path_encoding_hex": path.to_bytes().hex(), + "cumulative_tweak_le_hex": scalar_bytes(child.cumulative_tweak).hex(), + "derivation_key_hex": child.derivation_key.hex(), + "public_key_hex": encoded_child.hex(), + "signature_hex": signature.hex(), + "threshold_checks": threshold_checks, + } + ) + + # Cross-path recovery of a 2-of-3 root from two leaked child shares. + shares_2_of_3 = root_shares["2-of-3"] + child_a = derive_path(root, paths[0]) + child_b = derive_path(root, paths[2]) + leaked_a = (shares_2_of_3[1] + child_a.cumulative_tweak) % SCALAR_Q + leaked_b = (shares_2_of_3[2] + child_b.cumulative_tweak) % SCALAR_Q + recovered = interpolate_at_zero( + { + 1: (leaked_a - child_a.cumulative_tweak) % SCALAR_Q, + 2: (leaked_b - child_b.cumulative_tweak) % SCALAR_Q, + }, + [1, 2], + ) + assert recovered == root_secret + + return { + "specification": "FROST-HD-v1 research draft", + "warning": "Deterministic test material only. Never use these secrets.", + "ciphersuite_id": CIPHERSUITE_ID.decode(), + "profile_id": PROFILE_ID.decode(), + "application": application.decode(), + "network": network.decode(), + "purpose": purpose.decode(), + "context_digest_hex": context.hex(), + "root_derivation_key_hex": root_derivation_key.hex(), + "root_secret_le_hex": scalar_bytes(root_secret).hex(), + "root_public_key_hex": encode_point(root_public).hex(), + "message_hex": message.hex(), + "paths": path_vectors, + "cross_path_recovery": { + "configuration": "2-of-3", + "participant_1_path": str(paths[0]), + "participant_2_path": str(paths[2]), + "recovered_root_secret_le_hex": scalar_bytes(recovered).hex(), + "matches_root": recovered == root_secret, + }, + } + + +def write_vectors(output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(build_vectors(), indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + default_output = Path(__file__).resolve().parents[1] / "vectors" / "frost-hd-v1-ed25519.json" + write_vectors(default_output) + print(f"wrote {default_output}") diff --git a/frost-hd/reference/test_frost_hd_ed25519.py b/frost-hd/reference/test_frost_hd_ed25519.py new file mode 100644 index 00000000..5b015ffa --- /dev/null +++ b/frost-hd/reference/test_frost_hd_ed25519.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import itertools +import json +import tempfile +import unittest +from pathlib import Path + +from frost_hd_ed25519 import ( + BASE_POINT, + DerivationPath, + Node, + PathSegment, + SCALAR_Q, + assert_threshold_invariant, + build_vectors, + context_digest, + derive_child, + derive_path, + encode_point, + interpolate_at_zero, + make_shares, + point_add, + scalar_mult, + write_vectors, +) + + +class PathTests(unittest.TestCase): + def test_text_and_binary_round_trip(self) -> None: + for text in ("m", "m/0", "m/1/4294967295", "m/0'/1"): + path = DerivationPath.parse(text) + self.assertEqual(str(path), text) + self.assertEqual(DerivationPath.from_bytes(path.to_bytes()), path) + + def test_non_canonical_paths_are_rejected(self) -> None: + for text in ("", "M", "m/", "m//1", "m/+1", "m/-1", "m/01", "m/1''"): + with self.subTest(text=text), self.assertRaises(ValueError): + DerivationPath.parse(text) + + def test_hardened_path_is_typed_but_not_derivable(self) -> None: + root = Node(BASE_POINT, bytes(32), bytes(32)) + with self.assertRaisesRegex(ValueError, "hardened"): + derive_path(root, DerivationPath.parse("m/0'/1")) + + +class AlgebraTests(unittest.TestCase): + def setUp(self) -> None: + self.root_secret = 123456789 % SCALAR_Q + self.root = Node( + scalar_mult(self.root_secret), + bytes(range(32)), + context_digest(b"app", b"network", b"purpose"), + ) + + def test_thresholds_and_all_qualified_subsets(self) -> None: + for threshold, participants in ((2, 2), (2, 3), (3, 5)): + shares = make_shares(self.root_secret, threshold, participants, b"case") + assert_threshold_invariant(shares, threshold, self.root_secret) + child = derive_path(self.root, DerivationPath.parse("m/1/42")) + child_secret = (self.root_secret + child.cumulative_tweak) % SCALAR_Q + child_shares = { + identifier: (share + child.cumulative_tweak) % SCALAR_Q + for identifier, share in shares.items() + } + assert_threshold_invariant(child_shares, threshold, child_secret) + + def test_public_and_private_derivation_agree(self) -> None: + shares = make_shares(self.root_secret, 2, 3, b"agreement") + child = derive_path(self.root, DerivationPath.parse("m/7/3/999")) + for identifier, parent_share in shares.items(): + child_share = (parent_share + child.cumulative_tweak) % SCALAR_Q + self.assertEqual( + scalar_mult(child_share), + point_add(scalar_mult(parent_share), scalar_mult(child.cumulative_tweak)), + identifier, + ) + + def test_direct_path_equals_stepwise(self) -> None: + path = DerivationPath.parse("m/1/42/9") + direct = derive_path(self.root, path) + stepwise = self.root + for segment in path.segments: + stepwise = derive_child(stepwise, segment) + self.assertEqual(direct, stepwise) + + def test_distinct_branches_are_distinct(self) -> None: + paths = [DerivationPath.parse(text) for text in ("m/0", "m/1", "m/1/0", "m/0/1")] + keys = {encode_point(derive_path(self.root, path).public_key) for path in paths} + self.assertEqual(len(keys), len(paths)) + + def test_wrong_context_or_derivation_key_changes_family(self) -> None: + path = DerivationPath.parse("m/1/42") + expected = derive_path(self.root, path) + wrong_context = Node(self.root.public_key, self.root.derivation_key, bytes([1]) * 32) + wrong_key = Node(self.root.public_key, bytes([2]) * 32, self.root.context) + self.assertNotEqual(derive_path(wrong_context, path).public_key, expected.public_key) + self.assertNotEqual(derive_path(wrong_key, path).public_key, expected.public_key) + + def test_cross_path_leaks_accumulate_to_root_threshold(self) -> None: + shares = make_shares(self.root_secret, 2, 3, b"cross-path") + child_a = derive_path(self.root, DerivationPath.parse("m/0")) + child_b = derive_path(self.root, DerivationPath.parse("m/99/3")) + leaked_child_1 = (shares[1] + child_a.cumulative_tweak) % SCALAR_Q + leaked_child_2 = (shares[2] + child_b.cumulative_tweak) % SCALAR_Q + normalized = { + 1: (leaked_child_1 - child_a.cumulative_tweak) % SCALAR_Q, + 2: (leaked_child_2 - child_b.cumulative_tweak) % SCALAR_Q, + } + self.assertEqual(interpolate_at_zero(normalized, [1, 2]), self.root_secret) + + def test_same_participant_multiple_paths_adds_no_shamir_rank(self) -> None: + shares = make_shares(self.root_secret, 2, 3, b"rank") + participant = 1 + recovered = set() + for text in ("m/0", "m/1", "m/7/3"): + child = derive_path(self.root, DerivationPath.parse(text)) + leaked = (shares[participant] + child.cumulative_tweak) % SCALAR_Q + recovered.add((leaked - child.cumulative_tweak) % SCALAR_Q) + self.assertEqual(recovered, {shares[participant]}) + + +class VectorTests(unittest.TestCase): + def test_vector_generation_is_deterministic(self) -> None: + first = build_vectors() + second = build_vectors() + self.assertEqual(first, second) + self.assertTrue(first["cross_path_recovery"]["matches_root"]) + + def test_checked_in_vectors_match_reference(self) -> None: + vector_path = Path(__file__).resolve().parents[1] / "vectors" / "frost-hd-v1-ed25519.json" + checked_in = json.loads(vector_path.read_text(encoding="utf-8")) + self.assertEqual(checked_in, build_vectors()) + + def test_write_vectors_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "vectors.json" + write_vectors(output) + self.assertEqual(json.loads(output.read_text()), build_vectors()) + + +if __name__ == "__main__": + unittest.main() diff --git a/frost-hd/src/error.rs b/frost-hd/src/error.rs new file mode 100644 index 00000000..d732dac9 --- /dev/null +++ b/frost-hd/src/error.rs @@ -0,0 +1,69 @@ +//! Error types. + +use core::fmt; + +/// Errors returned by the experimental HD derivation API. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + /// The application, network, purpose, or ciphersuite identifier exceeds + /// the canonical `u16` length bound. + ContextComponentTooLong, + /// A derivation path exceeds [`crate::MAX_PATH_DEPTH`]. + PathTooDeep, + /// A textual path is not in canonical form. + NonCanonicalPath, + /// A path index does not fit in a `u32`. + PathIndexOverflow, + /// A binary path uses an unsupported serialization version. + UnsupportedPathVersion, + /// A binary path uses an unknown segment kind. + UnknownSegmentKind, + /// A binary path is truncated or contains trailing bytes. + MalformedPathEncoding, + /// Root path `m` cannot be converted into a signable child package. + RootPathNotSignable, + /// `FROST-HD-v1` does not support hardened derivation. + HardenedDerivationUnsupported, + /// The parent joint verifying key could not be serialized canonically. + ParentKeySerialization, + /// HMAC initialization failed. HMAC accepts keys of any size, so this + /// indicates an unexpected library-level failure. + KdfInitialization, + /// Every deterministic retry candidate was invalid. + RetryExhausted, + /// A derived participant verifying share is the group identity. + DerivedVerifyingShareIdentity, + /// A private package is not internally consistent with its signing share. + InconsistentPrivatePackage, + /// A private child package does not match the expected public child package. + PrivatePublicMismatch, + /// Private and public roots do not bind to the same family metadata. + FamilyMetadataMismatch, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::ContextComponentTooLong => "derivation context component exceeds u16 length", + Self::PathTooDeep => "derivation path exceeds the maximum depth", + Self::NonCanonicalPath => "non-canonical derivation path", + Self::PathIndexOverflow => "derivation path index exceeds u32", + Self::UnsupportedPathVersion => "unsupported derivation path version", + Self::UnknownSegmentKind => "unknown derivation path segment kind", + Self::MalformedPathEncoding => "malformed derivation path encoding", + Self::RootPathNotSignable => "the HD root cannot be exposed as a signable child", + Self::HardenedDerivationUnsupported => "hardened derivation is unsupported", + Self::ParentKeySerialization => "failed to serialize the parent verifying key", + Self::KdfInitialization => "failed to initialize the derivation KDF", + Self::RetryExhausted => "deterministic derivation retry space exhausted", + Self::DerivedVerifyingShareIdentity => { + "derived participant verifying share is the identity" + } + Self::InconsistentPrivatePackage => "private key package is internally inconsistent", + Self::PrivatePublicMismatch => { + "derived private package does not match the public package" + } + Self::FamilyMetadataMismatch => "private and public family metadata do not match", + }) + } +} diff --git a/frost-hd/src/lib.rs b/frost-hd/src/lib.rs new file mode 100644 index 00000000..a72b0571 --- /dev/null +++ b/frost-hd/src/lib.rs @@ -0,0 +1,676 @@ +//! Experimental, ciphersuite-agnostic, non-hardened hierarchical key +//! derivation for FROST key packages. +//! +//! # Security model +//! +//! For a public child tweak `t`, this crate transforms the participant shares +//! and public keys as follows: +//! +//! ```text +//! s_i' = s_i + t +//! Y_i' = Y_i + tG +//! Y' = Y + tG +//! ``` +//! +//! This is a **related-key** construction. A child signing share and the public +//! derivation metadata reveal the corresponding parent signing share. Derived +//! children therefore do not provide the compromise compartmentalization of +//! independent DKG executions. +//! +//! This crate is an unaudited research prototype. It does not implement +//! hardened derivation, BIP32 serialization, Taproot wallet policy, Solana +//! address encoding, or SLIP-0010 compatibility. +#![no_std] +#![forbid(unsafe_code)] +#![deny(missing_docs)] +#![deny(clippy::unwrap_used)] +#![deny(clippy::indexing_slicing)] + +extern crate alloc; + +mod error; +mod path; + +use alloc::{collections::BTreeMap, vec::Vec}; +use core::{fmt, marker::PhantomData}; + +pub use error::Error; +use frost_core::{ + Ciphersuite, Field, Group, Scalar, VerifyingKey, + keys::{KeyPackage, PublicKeyPackage, SigningShare, VerifyingShare}, +}; +use hmac::{Hmac, KeyInit, Mac}; +pub use path::{DerivationPath, PathSegment}; +use sha2::{Digest, Sha256, Sha512}; + +/// The protocol version encoded in every KDF transcript. +pub const KEY_FAMILY_VERSION: u16 = 1; +/// The canonical path serialization version. +pub const PATH_ENCODING_VERSION: u16 = 1; +/// Maximum number of path segments accepted by the parser and derivation API. +pub const MAX_PATH_DEPTH: usize = 64; +/// Number of deterministic retry values available for one derivation edge. +pub const MAX_EDGE_RETRIES: u32 = u16::MAX as u32 + 1; + +const DST_CONTEXT: &[u8] = b"FROST-HD-v1/context"; +const DST_EDGE: &[u8] = b"FROST-HD-v1/edge"; +const DST_TWEAK_PRF: &[u8] = b"FROST-HD-v1/tweak-prf"; +const DST_CHILD_KEY: &[u8] = b"FROST-HD-v1/child-derivation-key"; +const DST_HASH_TO_SCALAR: &[u8] = b"FROST-HD-v1/hash-to-scalar"; + +type HmacSha512 = Hmac; + +/// Public 32-byte metadata used to derive a deterministic child family. +/// +/// This value is not assumed secret. Disclosing it intentionally permits +/// descendant enumeration and, when combined with a child signing share, +/// parent-share recovery. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct DerivationKey([u8; 32]); + +impl DerivationKey { + /// Creates a derivation key from authenticated application-provided bytes. + pub const fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + /// Returns the canonical bytes. + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl fmt::Debug for DerivationKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + // The key is public protocol metadata, but redaction avoids accidental + // correlation through logs and keeps applications honest about where + // family metadata is intentionally disclosed. + formatter.debug_tuple("DerivationKey").field(&"").finish() + } +} + +/// Blockchain-agnostic application context for one key family. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DerivationContext { + application: Vec, + network: Vec, + purpose: Vec, +} + +impl DerivationContext { + /// Creates a context from opaque application, network, and purpose labels. + pub fn new( + application: impl Into>, + network: impl Into>, + purpose: impl Into>, + ) -> Result { + let context = Self { + application: application.into(), + network: network.into(), + purpose: purpose.into(), + }; + for component in [&context.application, &context.network, &context.purpose] { + if component.len() > u16::MAX as usize { + return Err(Error::ContextComponentTooLong); + } + } + Ok(context) + } + + /// Returns the application label. + pub fn application(&self) -> &[u8] { + &self.application + } + + /// Returns the network label. + pub fn network(&self) -> &[u8] { + &self.network + } + + /// Returns the purpose label. + pub fn purpose(&self) -> &[u8] { + &self.purpose + } + + /// Computes the ciphersuite-bound canonical context digest. + pub fn digest(&self) -> Result + where + C: Ciphersuite, + P: DerivationProfile, + { + let mut hasher = Sha256::new(); + hasher.update(DST_CONTEXT); + hasher.update(KEY_FAMILY_VERSION.to_be_bytes()); + hash_lp16(&mut hasher, C::ID.as_bytes())?; + hash_lp16(&mut hasher, P::ID.as_bytes())?; + hash_lp16(&mut hasher, &self.application)?; + hash_lp16(&mut hasher, &self.network)?; + hash_lp16(&mut hasher, &self.purpose)?; + let output = hasher.finalize(); + let mut digest = [0u8; 32]; + digest.copy_from_slice(output.as_ref()); + Ok(ContextDigest(digest)) + } +} + +/// A ciphersuite-bound digest of the application context. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ContextDigest([u8; 32]); + +impl ContextDigest { + /// Returns the canonical digest bytes. + pub const fn to_bytes(self) -> [u8; 32] { + self.0 + } +} + +/// An explicit cryptographic profile for persistent HD derivation. +/// +/// Implementing this trait is an opt-in security decision. It prevents the +/// generic crate from silently claiming support for every FROST ciphersuite, +/// including ciphersuites whose key normalization or signing semantics are not +/// linear under a persistent additive tweak. +/// +/// A profile identifier is included in every context and edge transcript. +/// Changing the profile identifier or scalar mapping creates a distinct key +/// family. +pub trait DerivationProfile: Copy + fmt::Debug + Send + Sync + 'static { + /// Stable, globally unique identifier for the profile and its scalar + /// mapping, including a version suffix. + const ID: &'static str; + + /// Maps a domain-separated PRF output to a scalar in `C`'s scalar field. + /// + /// Implementations MUST be deterministic, uniformly distributed to the + /// security level required by the ciphersuite, and reviewed for the target + /// ciphersuite. Returning zero is allowed; the edge algorithm retries + /// deterministically. + fn hash_to_scalar(input: &[u8]) -> Scalar; +} + +/// Non-signable wrapper for one participant's root key package. +/// +/// This type intentionally exposes no accessor for the inner root package. +/// Applications can only derive descendants from it. +pub struct HdRootKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + key_package: KeyPackage, + derivation_key: DerivationKey, + context: ContextDigest, + _profile: PhantomData

, +} + +impl HdRootKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + /// Binds a root participant package to authenticated family metadata. + pub fn new( + key_package: KeyPackage, + derivation_key: DerivationKey, + context: ContextDigest, + ) -> Result { + validate_private_package(&key_package)?; + Ok(Self { + key_package, + derivation_key, + context, + _profile: PhantomData, + }) + } + + /// Derives one direct normal child. + pub fn derive_child(&self, index: u32) -> Result, Error> { + derive_private_node::( + &self.key_package, + self.derivation_key, + self.context, + &DerivationPath::root(), + PathSegment::Normal(index), + ) + } + + /// Derives a descendant by evaluating the canonical path left to right. + pub fn derive_path(&self, path: &DerivationPath) -> Result, Error> { + if path.depth() == 0 { + return Err(Error::RootPathNotSignable); + } + let mut current_package = self.key_package.clone(); + let mut current_key = self.derivation_key; + let mut current_path = DerivationPath::root(); + + for segment in path.segments() { + let derived = derive_private_node::( + ¤t_package, + current_key, + self.context, + ¤t_path, + *segment, + )?; + current_package = derived.key_package; + current_key = derived.derivation_key; + current_path = derived.path; + } + + Ok(ExtendedKeyPackage { + key_package: current_package, + derivation_key: current_key, + context: self.context, + path: current_path, + _profile: PhantomData, + }) + } +} + +/// Non-address wrapper for a root public package. +/// +/// The root public key is retained internally to derive descendants. The API +/// intentionally does not expose it as an ordinary child/address object. +#[derive(Clone)] +pub struct HdRootPublicKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + public_key_package: PublicKeyPackage, + derivation_key: DerivationKey, + context: ContextDigest, + _profile: PhantomData

, +} + +impl HdRootPublicKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + /// Binds a root public package to authenticated family metadata. + pub fn new( + public_key_package: PublicKeyPackage, + derivation_key: DerivationKey, + context: ContextDigest, + ) -> Self { + Self { + public_key_package, + derivation_key, + context, + _profile: PhantomData, + } + } + + /// Derives one direct normal public child. + pub fn derive_child(&self, index: u32) -> Result, Error> { + derive_public_node::( + &self.public_key_package, + self.derivation_key, + self.context, + &DerivationPath::root(), + PathSegment::Normal(index), + ) + } + + /// Derives a public descendant by evaluating the path left to right. + pub fn derive_path( + &self, + path: &DerivationPath, + ) -> Result, Error> { + if path.depth() == 0 { + return Err(Error::RootPathNotSignable); + } + let mut current_package = self.public_key_package.clone(); + let mut current_key = self.derivation_key; + let mut current_path = DerivationPath::root(); + + for segment in path.segments() { + let derived = derive_public_node::( + ¤t_package, + current_key, + self.context, + ¤t_path, + *segment, + )?; + current_package = derived.public_key_package; + current_key = derived.derivation_key; + current_path = derived.path; + } + + Ok(ExtendedPublicKeyPackage { + public_key_package: current_package, + derivation_key: current_key, + context: self.context, + path: current_path, + _profile: PhantomData, + }) + } +} + +/// One participant's derived signing package plus public family metadata. +pub struct ExtendedKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + key_package: KeyPackage, + derivation_key: DerivationKey, + context: ContextDigest, + path: DerivationPath, + _profile: PhantomData

, +} + +impl ExtendedKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + /// Returns the child key package that may be used with ordinary FROST + /// signing APIs. + pub fn key_package(&self) -> &KeyPackage { + &self.key_package + } + + /// Returns the current node's derivation key. + pub const fn derivation_key(&self) -> DerivationKey { + self.derivation_key + } + + /// Returns the ciphersuite-bound context digest. + pub const fn context(&self) -> ContextDigest { + self.context + } + + /// Returns the canonical path from the root. + pub fn path(&self) -> &DerivationPath { + &self.path + } + + /// Derives one direct descendant. + pub fn derive_child(&self, segment: PathSegment) -> Result { + derive_private_node::( + &self.key_package, + self.derivation_key, + self.context, + &self.path, + segment, + ) + } + + /// Checks that this participant package and an extended public package + /// describe the same family node and participant verification share. + pub fn matches_public(&self, public: &ExtendedPublicKeyPackage) -> Result<(), Error> { + if self.derivation_key != public.derivation_key + || self.context != public.context + || self.path != public.path + || self.key_package.verifying_key() != public.public_key_package.verifying_key() + { + return Err(Error::FamilyMetadataMismatch); + } + let expected = public + .public_key_package + .verifying_shares() + .get(self.key_package.identifier()) + .ok_or(Error::PrivatePublicMismatch)?; + if expected != self.key_package.verifying_share() { + return Err(Error::PrivatePublicMismatch); + } + Ok(()) + } +} + +/// A derived public package plus public family metadata. +#[derive(Clone)] +pub struct ExtendedPublicKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + public_key_package: PublicKeyPackage, + derivation_key: DerivationKey, + context: ContextDigest, + path: DerivationPath, + _profile: PhantomData

, +} + +impl ExtendedPublicKeyPackage +where + C: Ciphersuite, + P: DerivationProfile, +{ + /// Returns the public package used for aggregation and signature + /// verification at this child node. + pub fn public_key_package(&self) -> &PublicKeyPackage { + &self.public_key_package + } + + /// Returns the current node's derivation key. + pub const fn derivation_key(&self) -> DerivationKey { + self.derivation_key + } + + /// Returns the ciphersuite-bound context digest. + pub const fn context(&self) -> ContextDigest { + self.context + } + + /// Returns the canonical path from the root. + pub fn path(&self) -> &DerivationPath { + &self.path + } + + /// Derives one direct public descendant. + pub fn derive_child(&self, segment: PathSegment) -> Result { + derive_public_node::( + &self.public_key_package, + self.derivation_key, + self.context, + &self.path, + segment, + ) + } +} + +struct Edge { + tweak: Scalar, + tweak_element: ::Element, + child_derivation_key: DerivationKey, +} + +fn derive_private_node( + parent: &KeyPackage, + parent_derivation_key: DerivationKey, + context: ContextDigest, + parent_path: &DerivationPath, + segment: PathSegment, +) -> Result, Error> +where + C: Ciphersuite, + P: DerivationProfile, +{ + let edge = derive_edge::( + parent.verifying_key(), + parent_derivation_key, + context, + segment, + )?; + let signing_share = SigningShare::new(parent.signing_share().to_scalar() + edge.tweak); + let verifying_share_element = parent.verifying_share().to_element() + edge.tweak_element; + if verifying_share_element == ::identity() { + return Err(Error::DerivedVerifyingShareIdentity); + } + let verifying_share = VerifyingShare::new(verifying_share_element); + if VerifyingShare::from(signing_share) != verifying_share { + return Err(Error::InconsistentPrivatePackage); + } + let verifying_key = VerifyingKey::new(parent.verifying_key().to_element() + edge.tweak_element); + let key_package = KeyPackage::new( + *parent.identifier(), + signing_share, + verifying_share, + verifying_key, + *parent.min_signers(), + ); + + Ok(ExtendedKeyPackage { + key_package, + derivation_key: edge.child_derivation_key, + context, + path: parent_path.child(segment)?, + _profile: PhantomData, + }) +} + +fn derive_public_node( + parent: &PublicKeyPackage, + parent_derivation_key: DerivationKey, + context: ContextDigest, + parent_path: &DerivationPath, + segment: PathSegment, +) -> Result, Error> +where + C: Ciphersuite, + P: DerivationProfile, +{ + let edge = derive_edge::( + parent.verifying_key(), + parent_derivation_key, + context, + segment, + )?; + let mut verifying_shares = BTreeMap::new(); + for (identifier, verifying_share) in parent.verifying_shares() { + let element = verifying_share.to_element() + edge.tweak_element; + if element == ::identity() { + return Err(Error::DerivedVerifyingShareIdentity); + } + verifying_shares.insert(*identifier, VerifyingShare::new(element)); + } + let verifying_key = VerifyingKey::new(parent.verifying_key().to_element() + edge.tweak_element); + let public_key_package = + PublicKeyPackage::new(verifying_shares, verifying_key, parent.min_signers()); + + Ok(ExtendedPublicKeyPackage { + public_key_package, + derivation_key: edge.child_derivation_key, + context, + path: parent_path.child(segment)?, + _profile: PhantomData, + }) +} + +fn derive_edge( + parent_verifying_key: &VerifyingKey, + parent_derivation_key: DerivationKey, + context: ContextDigest, + segment: PathSegment, +) -> Result, Error> +where + C: Ciphersuite, + P: DerivationProfile, +{ + if segment.is_hardened() { + return Err(Error::HardenedDerivationUnsupported); + } + let serialized_parent = parent_verifying_key + .serialize() + .map_err(|_| Error::ParentKeySerialization)?; + if C::ID.len() > u16::MAX as usize + || P::ID.len() > u16::MAX as usize + || serialized_parent.len() > u16::MAX as usize + { + return Err(Error::ContextComponentTooLong); + } + + for retry in 0..MAX_EDGE_RETRIES { + let retry = retry as u16; + let mut common = Vec::with_capacity( + DST_EDGE.len() + + 2 + + 2 + + C::ID.len() + + 2 + + P::ID.len() + + 32 + + 2 + + serialized_parent.len() + + 1 + + 4 + + 2, + ); + common.extend_from_slice(DST_EDGE); + common.extend_from_slice(&KEY_FAMILY_VERSION.to_be_bytes()); + append_lp16(&mut common, C::ID.as_bytes())?; + append_lp16(&mut common, P::ID.as_bytes())?; + common.extend_from_slice(&context.0); + append_lp16(&mut common, &serialized_parent)?; + common.push(segment.kind()); + common.extend_from_slice(&segment.index().to_be_bytes()); + common.extend_from_slice(&retry.to_be_bytes()); + + let tweak_prf = hmac_sha512(parent_derivation_key.0, DST_TWEAK_PRF, &common)?; + let mut scalar_input = Vec::with_capacity(DST_HASH_TO_SCALAR.len() + tweak_prf.len()); + scalar_input.extend_from_slice(DST_HASH_TO_SCALAR); + scalar_input.extend_from_slice(&tweak_prf); + let tweak = P::hash_to_scalar(&scalar_input); + if tweak == <::Field as Field>::zero() { + continue; + } + let tweak_element = ::generator() * tweak; + let child_verifying_key = parent_verifying_key.to_element() + tweak_element; + if child_verifying_key == ::identity() { + continue; + } + + let child_prf = hmac_sha512(parent_derivation_key.0, DST_CHILD_KEY, &common)?; + let mut child_derivation_key = [0u8; 32]; + let (child_key_bytes, _) = child_prf.split_at(32); + child_derivation_key.copy_from_slice(child_key_bytes); + return Ok(Edge { + tweak, + tweak_element, + child_derivation_key: DerivationKey(child_derivation_key), + }); + } + Err(Error::RetryExhausted) +} + +fn validate_private_package(package: &KeyPackage) -> Result<(), Error> { + if VerifyingShare::from(*package.signing_share()) == *package.verifying_share() { + Ok(()) + } else { + Err(Error::InconsistentPrivatePackage) + } +} + +fn hmac_sha512( + key: [u8; 32], + domain: &[u8], + common: &[u8], +) -> Result<[u8; 64], Error> { + let mut mac = HmacSha512::new_from_slice(&key).map_err(|_| Error::KdfInitialization)?; + mac.update(domain); + mac.update(common); + let output = mac.finalize().into_bytes(); + let mut bytes = [0u8; 64]; + bytes.copy_from_slice(output.as_ref()); + Ok(bytes) +} + +fn hash_lp16(hasher: &mut Sha256, value: &[u8]) -> Result<(), Error> { + if value.len() > u16::MAX as usize { + return Err(Error::ContextComponentTooLong); + } + hasher.update((value.len() as u16).to_be_bytes()); + hasher.update(value); + Ok(()) +} + +fn append_lp16(output: &mut Vec, value: &[u8]) -> Result<(), Error> { + if value.len() > u16::MAX as usize { + return Err(Error::ContextComponentTooLong); + } + output.extend_from_slice(&(value.len() as u16).to_be_bytes()); + output.extend_from_slice(value); + Ok(()) +} diff --git a/frost-hd/src/path.rs b/frost-hd/src/path.rs new file mode 100644 index 00000000..a9cadc6f --- /dev/null +++ b/frost-hd/src/path.rs @@ -0,0 +1,255 @@ +//! Canonical derivation paths. + +use alloc::{string::ToString, vec::Vec}; +use core::{fmt, str::FromStr}; + +use crate::{Error, MAX_PATH_DEPTH, PATH_ENCODING_VERSION}; + +/// One explicitly typed derivation path segment. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PathSegment { + /// A publicly derivable child index. + Normal(u32), + /// A private-only child index. It parses and serializes canonically, but + /// derivation returns [`Error::HardenedDerivationUnsupported`]. + Hardened(u32), +} + +impl PathSegment { + /// Returns the numeric child index. + pub const fn index(self) -> u32 { + match self { + Self::Normal(index) | Self::Hardened(index) => index, + } + } + + /// Returns whether this segment requests hardened derivation. + pub const fn is_hardened(self) -> bool { + matches!(self, Self::Hardened(_)) + } + + pub(crate) const fn kind(self) -> u8 { + match self { + Self::Normal(_) => 0, + Self::Hardened(_) => 1, + } + } +} + +/// A canonical, versioned path rooted at `m`. +#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub struct DerivationPath { + segments: Vec, +} + +impl DerivationPath { + /// Creates the root path `m`. + pub const fn root() -> Self { + Self { + segments: Vec::new(), + } + } + + /// Creates a path from explicitly typed segments. + pub fn new(segments: Vec) -> Result { + if segments.len() > MAX_PATH_DEPTH { + return Err(Error::PathTooDeep); + } + Ok(Self { segments }) + } + + /// Returns the path segments. + pub fn segments(&self) -> &[PathSegment] { + &self.segments + } + + /// Returns the path depth. + pub fn depth(&self) -> usize { + self.segments.len() + } + + /// Returns a child path without mutating this path. + pub fn child(&self, segment: PathSegment) -> Result { + if self.segments.len() == MAX_PATH_DEPTH { + return Err(Error::PathTooDeep); + } + let mut segments = self.segments.clone(); + segments.push(segment); + Ok(Self { segments }) + } + + /// Serializes this path canonically. + /// + /// ```text + /// version_u16 || count_u16 || (kind_u8 || index_u32_be)* + /// ``` + pub fn to_bytes(&self) -> Vec { + let mut output = Vec::with_capacity(4 + self.segments.len() * 5); + output.extend_from_slice(&PATH_ENCODING_VERSION.to_be_bytes()); + output.extend_from_slice(&(self.segments.len() as u16).to_be_bytes()); + for segment in &self.segments { + output.push(segment.kind()); + output.extend_from_slice(&segment.index().to_be_bytes()); + } + output + } + + /// Parses the canonical binary path encoding. + pub fn from_bytes(input: &[u8]) -> Result { + if input.len() < 4 { + return Err(Error::MalformedPathEncoding); + } + let (header, body) = input.split_at(4); + let version = u16::from_be_bytes( + header + .get(0..2) + .ok_or(Error::MalformedPathEncoding)? + .try_into() + .map_err(|_| Error::MalformedPathEncoding)?, + ); + if version != PATH_ENCODING_VERSION { + return Err(Error::UnsupportedPathVersion); + } + let count = u16::from_be_bytes( + header + .get(2..4) + .ok_or(Error::MalformedPathEncoding)? + .try_into() + .map_err(|_| Error::MalformedPathEncoding)?, + ) as usize; + if count > MAX_PATH_DEPTH { + return Err(Error::PathTooDeep); + } + let expected_len = 4usize + .checked_add(count.checked_mul(5).ok_or(Error::MalformedPathEncoding)?) + .ok_or(Error::MalformedPathEncoding)?; + if input.len() != expected_len { + return Err(Error::MalformedPathEncoding); + } + + let mut segments = Vec::with_capacity(count); + for chunk in body.chunks_exact(5) { + let kind = *chunk.first().ok_or(Error::MalformedPathEncoding)?; + let index = u32::from_be_bytes( + chunk + .get(1..5) + .ok_or(Error::MalformedPathEncoding)? + .try_into() + .map_err(|_| Error::MalformedPathEncoding)?, + ); + let segment = match kind { + 0 => PathSegment::Normal(index), + 1 => PathSegment::Hardened(index), + _ => return Err(Error::UnknownSegmentKind), + }; + segments.push(segment); + } + Self::new(segments) + } +} + +impl fmt::Display for DerivationPath { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("m")?; + for segment in &self.segments { + match segment { + PathSegment::Normal(index) => write!(formatter, "/{index}")?, + PathSegment::Hardened(index) => write!(formatter, "/{index}'")?, + } + } + Ok(()) + } +} + +impl FromStr for DerivationPath { + type Err = Error; + + fn from_str(input: &str) -> Result { + if input == "m" { + return Ok(Self::root()); + } + let suffix = input.strip_prefix("m/").ok_or(Error::NonCanonicalPath)?; + if suffix.is_empty() { + return Err(Error::NonCanonicalPath); + } + + let mut segments = Vec::new(); + for token in suffix.split('/') { + if token.is_empty() { + return Err(Error::NonCanonicalPath); + } + let (digits, hardened) = match token.strip_suffix('\'') { + Some(digits) => (digits, true), + None => (token, false), + }; + if digits.is_empty() + || !digits.as_bytes().iter().all(u8::is_ascii_digit) + || (digits.len() > 1 && digits.starts_with('0')) + { + return Err(Error::NonCanonicalPath); + } + let index = digits.parse::().map_err(|_| Error::PathIndexOverflow)?; + segments.push(if hardened { + PathSegment::Hardened(index) + } else { + PathSegment::Normal(index) + }); + if segments.len() > MAX_PATH_DEPTH { + return Err(Error::PathTooDeep); + } + } + + let path = Self::new(segments)?; + // This equality check prevents future parser changes from accidentally + // accepting a second textual representation of the same path. + if path.to_string() != input { + return Err(Error::NonCanonicalPath); + } + Ok(path) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn canonical_text_and_binary_round_trip() { + for text in ["m", "m/0", "m/1/4294967295", "m/0'/1"] { + let path: DerivationPath = text.parse().expect("canonical path"); + assert_eq!(path.to_string(), text); + assert_eq!( + DerivationPath::from_bytes(&path.to_bytes()).expect("canonical bytes"), + path + ); + } + } + + #[test] + fn rejects_non_canonical_text() { + for text in ["", "M", "m/", "m//1", "m/+1", "m/-1", "m/01", "m/1''"] { + assert_eq!(text.parse::(), Err(Error::NonCanonicalPath)); + } + assert_eq!( + "m/4294967296".parse::(), + Err(Error::PathIndexOverflow) + ); + } + + #[test] + fn rejects_malformed_binary() { + assert_eq!( + DerivationPath::from_bytes(&[0, 1, 0]), + Err(Error::MalformedPathEncoding) + ); + assert_eq!( + DerivationPath::from_bytes(&[0, 2, 0, 0]), + Err(Error::UnsupportedPathVersion) + ); + assert_eq!( + DerivationPath::from_bytes(&[0, 1, 0, 1, 2, 0, 0, 0, 0]), + Err(Error::UnknownSegmentKind) + ); + } +} diff --git a/frost-hd/tests/ciphersuites.rs b/frost-hd/tests/ciphersuites.rs new file mode 100644 index 00000000..653448cd --- /dev/null +++ b/frost-hd/tests/ciphersuites.rs @@ -0,0 +1,177 @@ +//! Cross-ciphersuite regression tests proving that the core derivation API is +//! not tied to Bitcoin, Solana, or one concrete curve. + +use std::{collections::BTreeMap, marker::PhantomData}; + +use frost_core::{ + Ciphersuite, SigningPackage, + keys::{KeyPackage, generate_with_dealer}, +}; +use frost_hd::{ + DerivationContext, DerivationKey, DerivationPath, DerivationProfile, HdRootKeyPackage, + HdRootPublicKeyPackage, PathSegment, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +const ROOT_DERIVATION_KEY: DerivationKey = DerivationKey::new([ + 0x42, 0x19, 0x77, 0x2a, 0xd3, 0x05, 0x94, 0xee, 0x50, 0x68, 0xa1, 0xc4, 0x7b, 0x11, + 0x39, 0xfa, 0x08, 0x6d, 0x25, 0xb0, 0x9e, 0x3c, 0xf2, 0x61, 0x88, 0x0a, 0x57, 0x9b, + 0xcd, 0x31, 0x74, 0xe6, +]); +const MESSAGE: &[u8] = b"generic FROST-HD ciphersuite test"; + +#[derive(Clone, Copy, Debug)] +struct TestH1Profile(PhantomData); + +impl DerivationProfile for TestH1Profile { + const ID: &'static str = "FROST-HD-v1/test-h1-profile"; + + fn hash_to_scalar(input: &[u8]) -> frost_core::Scalar { + C::H1(input) + } +} + +fn context() -> frost_hd::ContextDigest { + DerivationContext::new( + b"org.example.threshold-wallet".to_vec(), + b"test-network".to_vec(), + b"interoperability".to_vec(), + ) + .expect("bounded context") + .digest::>() + .expect("ciphersuite-bound context digest") +} + +fn check_configuration(max_signers: u16, min_signers: u16) { + let seed = [ + max_signers as u8, + min_signers as u8, + 7, + 9, + 11, + 13, + 17, + 19, + 23, + 29, + 31, + 37, + 41, + 43, + 47, + 53, + 59, + 61, + 67, + 71, + 73, + 79, + 83, + 89, + 97, + 101, + 103, + 107, + 109, + 113, + 127, + 131, + ]; + let mut rng = ChaCha20Rng::from_seed(seed); + let (shares, public_package) = generate_with_dealer::( + max_signers, + min_signers, + frost_core::keys::IdentifierList::Default, + &mut rng, + ) + .expect("dealer generation"); + + let context = context::(); + let root_public = + HdRootPublicKeyPackage::>::new(public_package.clone(), ROOT_DERIVATION_KEY, context); + let path: DerivationPath = "m/1/42/7".parse().expect("canonical path"); + let derived_public = root_public + .derive_path(&path) + .expect("public derivation"); + + let mut derived_private = BTreeMap::new(); + for (identifier, share) in shares { + let key_package = KeyPackage::try_from(share).expect("valid dealer share"); + let root = HdRootKeyPackage::>::new(key_package, ROOT_DERIVATION_KEY, context) + .expect("internally consistent root package"); + let direct = root.derive_path(&path).expect("private derivation"); + + let first_path: DerivationPath = "m/1".parse().expect("canonical path"); + let stepwise = root + .derive_path(&first_path) + .expect("first edge") + .derive_child(PathSegment::Normal(42)) + .expect("second edge") + .derive_child(PathSegment::Normal(7)) + .expect("third edge"); + assert_eq!(direct.key_package(), stepwise.key_package()); + assert_eq!(direct.path(), stepwise.path()); + direct + .matches_public(&derived_public) + .expect("private and public derivation agree"); + derived_private.insert(identifier, direct); + } + + let mut nonces = BTreeMap::new(); + let mut commitments = BTreeMap::new(); + for identifier in derived_private.keys().take(min_signers as usize) { + let package = derived_private.get(identifier).expect("known participant"); + let (nonce, commitment) = + frost_core::round1::commit(package.key_package().signing_share(), &mut rng); + nonces.insert(*identifier, nonce); + commitments.insert(*identifier, commitment); + } + + let signing_package = SigningPackage::new(commitments, MESSAGE); + let mut signature_shares = BTreeMap::new(); + for (identifier, nonce) in &nonces { + let key_package = derived_private + .get(identifier) + .expect("known participant") + .key_package(); + let signature_share = frost_core::round2::sign(&signing_package, nonce, key_package) + .expect("derived signature share"); + signature_shares.insert(*identifier, signature_share); + } + + let signature = frost_core::aggregate( + &signing_package, + &signature_shares, + derived_public.public_key_package(), + ) + .expect("derived signature aggregation"); + derived_public + .public_key_package() + .verifying_key() + .verify(MESSAGE, &signature) + .expect("signature verifies under child key"); + assert!( + public_package + .verifying_key() + .verify(MESSAGE, &signature) + .is_err() + ); +} + +#[test] +fn ed25519_supports_multiple_thresholds() { + check_configuration::(2, 2); + check_configuration::(3, 2); + check_configuration::(5, 3); +} + +#[test] +fn ristretto255_uses_the_same_generic_protocol() { + check_configuration::(5, 3); +} + +#[test] +fn secp256k1_uses_the_same_generic_protocol_without_bip32() { + check_configuration::(3, 2); +} diff --git a/frost-hd/tests/ed25519.rs b/frost-hd/tests/ed25519.rs new file mode 100644 index 00000000..e7b18d39 --- /dev/null +++ b/frost-hd/tests/ed25519.rs @@ -0,0 +1,174 @@ +use std::collections::BTreeMap; + +use frost_ed25519 as frost; +use frost_hd::{ + DerivationContext, DerivationKey, DerivationPath, DerivationProfile, Error, + HdRootKeyPackage, HdRootPublicKeyPackage, PathSegment, +}; +use rand_chacha::ChaCha20Rng; +use rand_core::SeedableRng; + +#[derive(Clone, Copy, Debug)] +struct Ed25519H1Profile; + +impl DerivationProfile for Ed25519H1Profile { + const ID: &'static str = "FROST-HD-ED25519-SHA512-H1-v1"; + + fn hash_to_scalar(input: &[u8]) -> frost_core::Scalar { + ::H1(input) + } +} + +fn sign_child( + key_packages: &BTreeMap, + public_key_package: &frost::keys::PublicKeyPackage, + threshold: u16, + rng: &mut ChaCha20Rng, +) { + let mut nonces = BTreeMap::new(); + let mut commitments = BTreeMap::new(); + for identifier in key_packages.keys().take(threshold as usize) { + let (nonce, commitment) = + frost::round1::commit(key_packages[identifier].signing_share(), &mut *rng); + nonces.insert(*identifier, nonce); + commitments.insert(*identifier, commitment); + } + let message = b"generic FROST-HD child signature"; + let signing_package = frost::SigningPackage::new(commitments, message); + let mut signature_shares = BTreeMap::new(); + for (identifier, nonce) in &nonces { + signature_shares.insert( + *identifier, + frost::round2::sign(&signing_package, nonce, &key_packages[identifier]) + .expect("valid child signature share"), + ); + } + let signature = frost::aggregate(&signing_package, &signature_shares, public_key_package) + .expect("valid aggregate signature"); + public_key_package + .verifying_key() + .verify(message, &signature) + .expect("child signature verifies"); +} + +fn run_threshold_case(max_signers: u16, threshold: u16, seed: u8) { + let mut rng = ChaCha20Rng::from_seed([seed; 32]); + let (shares, root_public_package) = frost::keys::generate_with_dealer( + max_signers, + threshold, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .expect("root key generation"); + let root_packages: BTreeMap<_, _> = shares + .into_iter() + .map(|(identifier, share)| { + ( + identifier, + frost::keys::KeyPackage::try_from(share).expect("valid root share"), + ) + }) + .collect(); + + let context = DerivationContext::new( + b"org.example.wallet".to_vec(), + b"test-network".to_vec(), + b"receive".to_vec(), + ) + .expect("valid context") + .digest::() + .expect("context digest"); + let derivation_key = DerivationKey::new([0x42; 32]); + let public_root = + HdRootPublicKeyPackage::::new(root_public_package.clone(), derivation_key, context); + + for path_text in ["m/0", "m/1/42", "m/7/3/999"] { + let path: DerivationPath = path_text.parse().expect("canonical path"); + let child_public = public_root.derive_path(&path).expect("public derivation"); + let mut child_key_packages = BTreeMap::new(); + for (identifier, root_package) in &root_packages { + let root = HdRootKeyPackage::::new(root_package.clone(), derivation_key, context) + .expect("valid private root"); + let child = root.derive_path(&path).expect("private derivation"); + child + .matches_public(&child_public) + .expect("private/public agreement"); + child_key_packages.insert(*identifier, child.key_package().clone()); + } + sign_child( + &child_key_packages, + child_public.public_key_package(), + threshold, + &mut rng, + ); + } + + let direct = public_root.derive_path(&"m/1/42".parse().unwrap()).unwrap(); + let stepwise = public_root + .derive_child(1) + .unwrap() + .derive_child(PathSegment::Normal(42)) + .unwrap(); + assert_eq!( + direct.public_key_package().verifying_key(), + stepwise.public_key_package().verifying_key() + ); + assert_eq!(direct.derivation_key(), stepwise.derivation_key()); +} + +#[test] +fn derives_and_signs_ed25519_2_of_2() { + run_threshold_case(2, 2, 1); +} + +#[test] +fn derives_and_signs_ed25519_2_of_3() { + run_threshold_case(3, 2, 2); +} + +#[test] +fn derives_and_signs_ed25519_3_of_5() { + run_threshold_case(5, 3, 3); +} + +#[test] +fn hardened_segments_are_explicitly_rejected() { + let mut rng = ChaCha20Rng::from_seed([9; 32]); + let (_, root_public) = frost::keys::generate_with_dealer( + 2, + 2, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .unwrap(); + let context = DerivationContext::new(b"app".to_vec(), b"net".to_vec(), b"purpose".to_vec()) + .unwrap() + .digest::() + .unwrap(); + let root = HdRootPublicKeyPackage::::new(root_public, DerivationKey::new([7; 32]), context); + assert!(matches!( + root.derive_path(&"m/0'/1".parse().unwrap()), + Err(Error::HardenedDerivationUnsupported) + )); +} + +#[test] +fn root_path_cannot_be_exposed_as_a_signable_child() { + let mut rng = ChaCha20Rng::from_seed([10; 32]); + let (_, root_public) = frost::keys::generate_with_dealer( + 2, + 2, + frost::keys::IdentifierList::Default, + &mut rng, + ) + .unwrap(); + let context = DerivationContext::new(b"app".to_vec(), b"net".to_vec(), b"purpose".to_vec()) + .unwrap() + .digest::() + .unwrap(); + let root = HdRootPublicKeyPackage::::new(root_public, DerivationKey::new([8; 32]), context); + assert!(matches!( + root.derive_path(&DerivationPath::root()), + Err(Error::RootPathNotSignable) + )); +} diff --git a/frost-hd/tests/security_regression.rs b/frost-hd/tests/security_regression.rs new file mode 100644 index 00000000..ab8d39e1 --- /dev/null +++ b/frost-hd/tests/security_regression.rs @@ -0,0 +1,21 @@ +//! Executable documentation of the fundamental related-key property. + +use frost_core::{Ciphersuite, Field, Group}; +use frost_ed25519::{Ed25519Sha512, Ed25519ScalarField}; + +#[test] +fn public_child_tweak_recovers_the_parent_participant_share() { + type C = Ed25519Sha512; + let parent = ::one(); + let tweak = C::H1(b"public deterministic child tweak"); + let child = parent + tweak; + let recovered_parent = child - tweak; + assert_eq!(recovered_parent, parent); + + let parent_public = ::generator() * parent; + let child_public = parent_public + ::generator() * tweak; + assert_eq!( + child_public - ::generator() * tweak, + parent_public + ); +} diff --git a/frost-hd/tests/vectors.rs b/frost-hd/tests/vectors.rs new file mode 100644 index 00000000..003988d5 --- /dev/null +++ b/frost-hd/tests/vectors.rs @@ -0,0 +1,67 @@ +#![allow(clippy::unwrap_used)] + +use std::collections::BTreeMap; + +use frost_core::VerifyingKey; +use frost_ed25519::Ed25519Sha512; +use frost_hd::{ + DerivationContext, DerivationKey, DerivationPath, DerivationProfile, + HdRootPublicKeyPackage, +}; + +#[derive(Clone, Copy, Debug)] +struct Ed25519VectorProfile; + +impl DerivationProfile for Ed25519VectorProfile { + const ID: &'static str = "FROST-HD-ED25519-SHA512-H1-v1"; + + fn hash_to_scalar(input: &[u8]) -> frost_core::Scalar { + ::H1(input) + } +} + +fn decode_32(input: &str) -> [u8; 32] { + hex::decode(input).unwrap().try_into().unwrap() +} + +#[test] +fn matches_independent_ed25519_vector_for_m_0() { + let root_key = VerifyingKey::::deserialize(&decode_32( + "dc13105f01bc23b21759ff565c4b2f8fdfcbad0f8bd8cad803fe8e5e8342d2de", + )) + .unwrap(); + let public_package = + frost_core::keys::PublicKeyPackage::new(BTreeMap::new(), root_key, None); + let context = DerivationContext::new( + b"org.example.wallet".to_vec(), + b"solana-devnet".to_vec(), + b"threshold-child-keys".to_vec(), + ) + .unwrap() + .digest::() + .unwrap(); + assert_eq!( + context.to_bytes(), + decode_32("6ce4bd89a780a01736e4e7f008f8b3a0a39b20397580898f5bc95489e8d60c53") + ); + + let root = HdRootPublicKeyPackage::::new( + public_package, + DerivationKey::new(decode_32( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + )), + context, + ); + let child = root + .derive_path(&"m/0".parse::().unwrap()) + .unwrap(); + + assert_eq!( + child.public_key_package().verifying_key().serialize().unwrap(), + decode_32("6b31d60dbbd186e38aac90a020c5ae969135dd4d0b3314b36a5affe60ae913cf") + ); + assert_eq!( + child.derivation_key().to_bytes(), + decode_32("b84dc1c3fa2ae0c3572ca0c75131981f1202124b24775ff40a3a0f967e8df598") + ); +} diff --git a/frost-hd/vectors/frost-hd-v1-ed25519.json b/frost-hd/vectors/frost-hd-v1-ed25519.json new file mode 100644 index 00000000..b7f68b4f --- /dev/null +++ b/frost-hd/vectors/frost-hd-v1-ed25519.json @@ -0,0 +1,75 @@ +{ + "application": "org.example.wallet", + "ciphersuite_id": "FROST-ED25519-SHA512-v1", + "context_digest_hex": "6ce4bd89a780a01736e4e7f008f8b3a0a39b20397580898f5bc95489e8d60c53", + "cross_path_recovery": { + "configuration": "2-of-3", + "matches_root": true, + "participant_1_path": "m/0", + "participant_2_path": "m/7/3/999", + "recovered_root_secret_le_hex": "3eff1bf2b6165b48713bd976b63622ad8f7d1e842a63074413e6ec27aef4d401" + }, + "message_hex": "67656e657269632046524f53542d4844207265666572656e6365207369676e6174757265", + "network": "solana-devnet", + "paths": [ + { + "cumulative_tweak_le_hex": "9e9bd9f2513cb93b15f16c6f2943724585f38ae74df4195f851cfff61698a50b", + "derivation_key_hex": "b84dc1c3fa2ae0c3572ca0c75131981f1202124b24775ff40a3a0f967e8df598", + "path": "m/0", + "path_encoding_hex": "000100010000000000", + "public_key_hex": "6b31d60dbbd186e38aac90a020c5ae969135dd4d0b3314b36a5affe60ae913cf", + "signature_hex": "c5fb636b7a7f942540db748ec3862762a3cb390807fec809039b74898adb9207285e790b071d373ae6bdb4820a0a34b48c2dce87e66921492f8d2ea392c56109", + "threshold_checks": { + "2-of-2": "pass", + "2-of-3": "pass", + "3-of-5": "pass" + } + }, + { + "cumulative_tweak_le_hex": "a6a6150b51d6fd68cda46109c78cd127fa2300e20449531a4981857d07209102", + "derivation_key_hex": "5fa0b8574b0ef55a173470ce50393abe9226e9e01c06035b36c4229d17751151", + "path": "m/1/42", + "path_encoding_hex": "000100020000000001000000002a", + "public_key_hex": "03d61d85d4c60c783285e88ff311cda572cefc063cd3acc836f29fb14da99c4b", + "signature_hex": "98e6bd3d3af61e32a28dd33bf2f3dfa8b8704b03ceb7005681897d8dec73d6755642a6c2778cc0c36d0efba1a3f287fad30a787b7730d126221ac28493346f0c", + "threshold_checks": { + "2-of-2": "pass", + "2-of-3": "pass", + "3-of-5": "pass" + } + }, + { + "cumulative_tweak_le_hex": "4b579a6afc262704479dddfa00d8d8aae049eea833f237b113aa58c322b35303", + "derivation_key_hex": "119e01ad34208b76c1cad433bf8d68a00f3e99e940fa83908b990b469fc50c1c", + "path": "m/7/3/999", + "path_encoding_hex": "000100030000000007000000000300000003e7", + "public_key_hex": "5ab3efece63a617f1236c75786cccf94539c5215dbbd51751e745bebcc8469a6", + "signature_hex": "692cb449981e15814feac38f78218a26257699f7d2984488fdc2bfcbda02727c96cf655971fb168ead7177a4bd0e600c8bfbe486fc539b1cbee0a0bffde6df0a", + "threshold_checks": { + "2-of-2": "pass", + "2-of-3": "pass", + "3-of-5": "pass" + } + }, + { + "cumulative_tweak_le_hex": "0328aa2262450b7f5abfa26352c5f007a3258e97000f90eb1de0f14dad353504", + "derivation_key_hex": "b9d4e0bdf893277061eee8a84641d9e892e0a9161862d5e30c500cf36ddfc5ff", + "path": "m/4294967295", + "path_encoding_hex": "0001000100ffffffff", + "public_key_hex": "fecc80c79242b3e421ca0475b17c398679f39cba632824e6fdd697dceb0d3ab1", + "signature_hex": "375a489fdd3593214ee7902fef2dd74bcaf9ef4c7b187aea6477d915a884901724e576780176fea0c632d9e7e5af12760be356f0b783e5409f4ea630b632fc0b", + "threshold_checks": { + "2-of-2": "pass", + "2-of-3": "pass", + "3-of-5": "pass" + } + } + ], + "profile_id": "FROST-HD-ED25519-SHA512-H1-v1", + "purpose": "threshold-child-keys", + "root_derivation_key_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "root_public_key_hex": "dc13105f01bc23b21759ff565c4b2f8fdfcbad0f8bd8cad803fe8e5e8342d2de", + "root_secret_le_hex": "3eff1bf2b6165b48713bd976b63622ad8f7d1e842a63074413e6ec27aef4d401", + "specification": "FROST-HD-v1 research draft", + "warning": "Deterministic test material only. Never use these secrets." +}