Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions konclave-wasm/Cargo.lock

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

4 changes: 4 additions & 0 deletions konclave-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ crate-type = ["cdylib", "rlib"]
[dependencies]
# FROST-redpallas signing round (proven wasm-clean: wasm-signer-spike)
reddsa = { version = "0.5.2", features = ["frost"] }
# The rerandomized-FROST `sign` that takes an explicit randomizer (the Orchard alpha), which reddsa's
# curated `rerandomized` module doesn't re-export. Pinned to reddsa 0.5.2's transitive version so the
# ciphersuite types unify.
frost-rerandomized = "=3.0.0"
# Orchard action verification + digest surface (proven wasm-clean: wasm-orchard-probe)
orchard = { version = "0.14", default-features = false, features = ["unstable-frost"] }
# ZIP-244 sig_digest recompute (proven wasm-clean: wasm-sighash-probe)
Expand Down
170 changes: 170 additions & 0 deletions konclave-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,69 @@ pub mod ceremony {
.is_ok())
}

// ---- signing with a SPECIFIC Orchard randomizer (alpha), for real transactions ----
// An Orchard spend must be signed under the note's own re-randomization: the alpha carried in
// the PCZT (read via pczt_bridge::extract_randomizers), NOT a commitment-derived seed. These
// three functions take that alpha explicitly, so the FROST signature verifies under ak+alpha and
// can be injected into the transaction (pczt_bridge::inject_sigs) and broadcast. The seed-based
// functions above stay for the self-contained demo; these are the real-transaction path.

/// Participant round 2, signing with the given Orchard randomizer (alpha), 32 canonical bytes.
// frost-rerandomized deprecates the explicit-randomizer `sign` in favour of a seed the
// coordinator generates — but an Orchard spend's alpha is FIXED by the transaction (read from the
// PCZT), not freely chosen, so the seed path cannot express it. Signing under this exact
// randomizer is required and correct here.
#[allow(deprecated)]
pub fn participant_round2_with_randomizer(
sp_bytes: &[u8],
nonces: &SigningNonces,
kp: &KeyPackage,
randomizer: &[u8],
) -> Result<Vec<u8>, E> {
let sp = SigningPackage::deserialize(sp_bytes).map_err(e)?;
let r = rerandomized::Randomizer::deserialize(randomizer).map_err(e)?;
let share = frost_rerandomized::sign(&sp, nonces, kp, r).map_err(e)?;
Ok(share.serialize())
}

/// Coordinator: aggregate the shares under the given randomizer (alpha) into a group signature.
pub fn coordinator_aggregate_with_randomizer(
sp_bytes: &[u8],
group_vk: &VerifyingKey,
randomizer: &[u8],
shares: &[(Vec<u8>, Vec<u8>)],
pubkeys: &PublicKeyPackage,
) -> Result<Vec<u8>, E> {
let sp = SigningPackage::deserialize(sp_bytes).map_err(e)?;
let r = rerandomized::Randomizer::deserialize(randomizer).map_err(e)?;
let params = RandomizedParams::from_randomizer(group_vk, r);
let mut map = std::collections::BTreeMap::new();
for (id_b, s_b) in shares {
let id = Identifier::deserialize(id_b).map_err(e)?;
let s = SignatureShare::deserialize(s_b).map_err(e)?;
map.insert(id, s);
}
let sig = rerandomized::aggregate(&sp, &map, pubkeys, &params).map_err(e)?;
sig.serialize().map_err(e)
}

/// Verify the group signature under the key re-randomized by the given alpha (what an Orchard
/// spend commits to). This is the check `pczt_bridge::inject_sigs` will pass on-chain.
pub fn verify_with_randomizer(
group_vk: &VerifyingKey,
randomizer: &[u8],
message: &[u8],
sig_bytes: &[u8],
) -> Result<bool, E> {
let r = rerandomized::Randomizer::deserialize(randomizer).map_err(e)?;
let params = RandomizedParams::from_randomizer(group_vk, r);
let sig = frost::Signature::deserialize(sig_bytes).map_err(e)?;
Ok(params
.randomized_verifying_key()
.verify(message, &sig)
.is_ok())
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -286,6 +349,69 @@ pub mod ceremony {
"the aggregated signature must verify against the randomized group key"
);
}

#[test]
fn signs_with_a_real_orchard_randomizer() {
// The alpha of Konclave's real mainnet DKG-vault spend (aab00f90…) — a valid Orchard
// randomizer. This is the path a real transaction takes: sign under the PCZT's alpha,
// not a commitment-derived seed, so the signature can be injected and broadcast.
let alpha =
hex::decode("b2ad61e8bf0de877dd01c52356526adf39b036ffed2e0217ece19407e1717624")
.unwrap();
let (shares, pubkeys) = frost::keys::generate_with_dealer(
3,
2,
frost::keys::IdentifierList::Default,
OsRng,
)
.unwrap();
let kps: std::collections::BTreeMap<_, _> = shares
.into_iter()
.map(|(id, s)| (id, KeyPackage::try_from(s).unwrap()))
.collect();
let group_vk = *pubkeys.verifying_key();
let message = b"konclave: a real Orchard shielded sighash";

let signers: Vec<_> = kps.iter().take(2).collect();
let mut local_nonces = Vec::new();
let mut wire_commitments = Vec::new();
for (id, kp) in &signers {
let (nonces, commit_bytes) = participant_round1(kp);
local_nonces.push((**id, nonces));
wire_commitments.push((id.serialize(), commit_bytes));
}
let sp_bytes = coordinator_signing_package(&wire_commitments, message).unwrap();

// Round 2 with the EXPLICIT Orchard alpha (no seed).
let mut wire_shares = Vec::new();
for ((id, kp), (_id2, nonces)) in signers.iter().zip(local_nonces.iter()) {
let share =
participant_round2_with_randomizer(&sp_bytes, nonces, kp, &alpha).unwrap();
wire_shares.push((id.serialize(), share));
}
let sig = coordinator_aggregate_with_randomizer(
&sp_bytes,
&group_vk,
&alpha,
&wire_shares,
&pubkeys,
)
.unwrap();

// Verifies under ak+alpha (what an Orchard spend commits to)...
assert!(
verify_with_randomizer(&group_vk, &alpha, message, &sig).unwrap(),
"signature must verify under the key randomized by the Orchard alpha"
);
// ...and a DIFFERENT randomizer must NOT verify — the alpha binds the signature.
let other =
hex::decode("557c4ff828ed56eb33e8ba7f508a43915338ccf3ad71d1ecedc98e6e861bfc0f")
.unwrap();
assert!(
!verify_with_randomizer(&group_vk, &other, message, &sig).unwrap(),
"a mismatched randomizer must fail"
);
}
}
}

Expand Down Expand Up @@ -1240,6 +1366,21 @@ mod js {
ceremony::participant_round2(sp, &nonces, &kp, seed).map_err(je)
}

/// Participant device, round 2 (JS), REAL-TRANSACTION path: sign with the given Orchard
/// randomizer (the 32-byte alpha from pczt_bridge.extractRandomizers) instead of a seed, so the
/// signature can be injected into the PCZT and broadcast.
#[wasm_bindgen(js_name = participantRound2WithRandomizer)]
pub fn participant_round2_with_randomizer(
sp: &[u8],
nonces_bytes: &[u8],
kp_bytes: &[u8],
randomizer: &[u8],
) -> Result<Vec<u8>, JsValue> {
let nonces = SigningNonces::deserialize(nonces_bytes).map_err(je)?;
let kp = KeyPackage::deserialize(kp_bytes).map_err(je)?;
ceremony::participant_round2_with_randomizer(sp, &nonces, &kp, randomizer).map_err(je)
}

/// Coordinator (JS): accumulates the public wire material and produces the signature.
#[wasm_bindgen]
pub struct Coordinator {
Expand Down Expand Up @@ -1298,6 +1439,35 @@ mod js {
let vk = frost::VerifyingKey::deserialize(&self.group_vk).map_err(je)?;
ceremony::verify(&vk, &self.sp, &self.seed, &self.message, sig).map_err(je)
}

/// REAL-TRANSACTION path: aggregate the accumulated shares under the given Orchard randomizer
/// (alpha) instead of the seed. The message must be the shielded sighash and the shares must
/// have been produced by `participantRound2WithRandomizer` with the SAME alpha.
#[wasm_bindgen(js_name = aggregateWithRandomizer)]
pub fn aggregate_with_randomizer(&self, randomizer: &[u8]) -> Result<Vec<u8>, JsValue> {
let vk = frost::VerifyingKey::deserialize(&self.group_vk).map_err(je)?;
let pubkeys = frost::keys::PublicKeyPackage::deserialize(&self.pubkeys).map_err(je)?;
ceremony::coordinator_aggregate_with_randomizer(
&self.sp,
&vk,
randomizer,
&self.shares,
&pubkeys,
)
.map_err(je)
}

/// Verify a group signature under the key re-randomized by the given alpha — the exact check
/// an Orchard spend passes on-chain.
#[wasm_bindgen(js_name = verifyWithRandomizer)]
pub fn verify_with_randomizer(
&self,
randomizer: &[u8],
sig: &[u8],
) -> Result<bool, JsValue> {
let vk = frost::VerifyingKey::deserialize(&self.group_vk).map_err(je)?;
ceremony::verify_with_randomizer(&vk, randomizer, &self.message, sig).map_err(je)
}
}
}

Expand Down
113 changes: 113 additions & 0 deletions ui/src/net-flow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/// <reference types="node" />
// Integration test of the /net multi-device flow, in one process, driving the exact WASM API the
// NetVault screen calls over the relay: a real 3-party DKG (2-of-3), then a signing ceremony over
// the REAL Orchard sighash NetVault signs, with the on-device describeOutputs check — and the
// real-transaction path that signs under the PCZT's Orchard randomizer (alpha), the piece a real
// broadcast needs. Closes the automated-test gap for the live /net ceremony (only the relay
// transport + React rendering are not exercised here; the cryptography is end-to-end).
import { readFileSync } from 'node:fs'
import { beforeAll, describe, expect, it } from 'vitest'
import init, {
DkgSession,
Coordinator,
identifierBytes,
participantRound1,
participantRound2,
participantRound2WithRandomizer,
verifyRedpallas,
describeOutputs,
} from './wasm-pkg/konclave_wasm.js'
import { bytesEqual } from './net'
import { dkgProvenPczt, DKG_SIGHASH } from './demo-vector'

const hexToBytes = (s: string) => new Uint8Array(s.match(/../g)!.map((b) => parseInt(b, 16)))

// The alpha of Konclave's real mainnet DKG-vault spend (aab00f90…) — a valid Orchard randomizer.
const DKG_ALPHA = hexToBytes('b2ad61e8bf0de877dd01c52356526adf39b036ffed2e0217ece19407e1717624')

beforeAll(async () => {
await init(readFileSync(new URL('./wasm-pkg/konclave_wasm_bg.wasm', import.meta.url)))
})

// Run a real 3-party DKG (2-of-3) in one process, exactly as /net does across devices over the
// relay. Returns the two quorum sessions, their ids, and the shared group material.
function dkg2of3() {
const N = 3
const T = 2
const ids = [1, 2, 3].map((i) => identifierBytes(i))
const sessions = ids.map((id) => new DkgSession(id, N, T))

const r1 = sessions.map((s) => s.round1Package())
sessions.forEach((s, i) => r1.forEach((pkg, j) => { if (i !== j) s.addRound1(ids[j]!, pkg) }))

sessions.forEach((s) => s.part2())
sessions.forEach((s, i) => {
for (let k = 0; k < s.round2Count(); k++) {
const j = ids.findIndex((id) => bytesEqual(id, s.round2Recipient(k)))
sessions[j]!.addRound2(ids[i]!, s.round2Package(k))
}
})
sessions.forEach((s) => s.part3())

const [s0, s1, s2] = sessions as [DkgSession, DkgSession, DkgSession]
const [id0, id1] = ids as [Uint8Array, Uint8Array]
return { s0, s1, s2, id0, id1, groupVk: s0.groupVk(), pubkeys: s0.pubkeys() }
}

describe('/net multi-device flow (DKG → sign → verify)', () => {
it('a 2-of-3 DKG-born vault signs the real Orchard sighash and every device verifies', () => {
const { s0, s1, s2, id0, id1, groupVk, pubkeys } = dkg2of3()

// Every device derived the SAME group verifying key.
expect(bytesEqual(s1.groupVk(), groupVk)).toBe(true)
expect(bytesEqual(s2.groupVk(), groupVk)).toBe(true)

// Sign the REAL Orchard sighash (the message /net signs), with devices 1 and 2 (quorum = 2).
const msg = hexToBytes(DKG_SIGHASH)
const a = participantRound1(s0.keyPackage())
const b = participantRound1(s1.keyPackage())
const coord = new Coordinator(groupVk, pubkeys, msg)
coord.addCommitment(id0, a.commitment())
coord.addCommitment(id1, b.commitment())
coord.prepare()
const sp = coord.signingPackage()
const seed = coord.seed()
coord.addShare(id0, participantRound2(sp, a.nonces(), s0.keyPackage(), seed))
coord.addShare(id1, participantRound2(sp, b.nonces(), s1.keyPackage(), seed))
const sig = coord.aggregate()

expect(coord.verify(sig)).toBe(true)
expect(verifyRedpallas(groupVk, sp, seed, msg, sig)).toBe(true)
})

it('signs under the PCZT Orchard randomizer (alpha) — the real-transaction path', () => {
// A DKG-born vault signs the sighash under a SPECIFIC Orchard alpha (from extractRandomizers),
// not a commitment-derived seed. This is what lets the signature be injected into the PCZT and
// broadcast. The signature must verify under ak+alpha — the exact check an Orchard spend passes.
const { s0, s1, id0, id1, groupVk, pubkeys } = dkg2of3()
const msg = hexToBytes(DKG_SIGHASH)
const a = participantRound1(s0.keyPackage())
const b = participantRound1(s1.keyPackage())
const coord = new Coordinator(groupVk, pubkeys, msg)
coord.addCommitment(id0, a.commitment())
coord.addCommitment(id1, b.commitment())
coord.prepare()
const sp = coord.signingPackage()
coord.addShare(id0, participantRound2WithRandomizer(sp, a.nonces(), s0.keyPackage(), DKG_ALPHA))
coord.addShare(id1, participantRound2WithRandomizer(sp, b.nonces(), s1.keyPackage(), DKG_ALPHA))
const sig = coord.aggregateWithRandomizer(DKG_ALPHA)

// Verifies under the key randomized by this alpha...
expect(coord.verifyWithRandomizer(DKG_ALPHA, sig)).toBe(true)
// ...and a DIFFERENT alpha does not — the randomizer binds the signature to the spend.
const otherAlpha = hexToBytes('557c4ff828ed56eb33e8ba7f508a43915338ccf3ad71d1ecedc98e6e861bfc0f')
expect(coord.verifyWithRandomizer(otherAlpha, sig)).toBe(false)
})

it('describeOutputs surfaces what the device is signing (recipient + value), as /net shows', () => {
const outs = JSON.parse(describeOutputs(dkgProvenPczt())) as { address: string | null; value: number | null }[]
const recipient = outs.find((o) => o.address !== null)
expect(recipient?.value).toBe(100000) // 0.001 ZEC — what /net renders before signing
expect(recipient?.address).toMatch(/^u1/) // a real Orchard unified address
})
})
Loading
Loading