Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
319 changes: 319 additions & 0 deletions conformance/frames.v1.json

Large diffs are not rendered by default.

162 changes: 162 additions & 0 deletions conformance/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const deviceA = hex("11".repeat(SHA256_BYTE_LENGTH)); // issuer / coordinator
const deviceB = hex("22".repeat(SHA256_BYTE_LENGTH)); // bearer of the root token / delegator
const deviceC = hex("33".repeat(SHA256_BYTE_LENGTH)); // bearer of the delegated token
const deviceD = hex("44".repeat(SHA256_BYTE_LENGTH)); // handle-record subject
const deviceGroup = hex("55".repeat(SHA256_BYTE_LENGTH)); // exadev.io/threshold's own group device-id -- SHA-256(group verifying key), an ordinary identity.cddl device-id derivation applied to a FROST-issued Ed25519 key, distinct from any single participant's own device-id
const deviceGroupKeyBytes = hex("66".repeat(ED25519_PUBLIC_KEY_BYTE_LENGTH)); // synthetic group verifying-key bytes, reused by threshold-keygen-round1's existing-group-key and threshold-keygen-confirm's group-key

const publicKeyEs256A = hex(
"04" +
Expand Down Expand Up @@ -661,6 +663,166 @@ const frameVectors: Vector[] = [
"transfer-id": hex("aa".repeat(TOKEN_ID_BYTE_LENGTH)),
digest: hex("cd".repeat(SHA256_BYTE_LENGTH)), // digest is a SHA-256 hash, the same 32-byte length device-id derivation already uses
}),
// exadev.io/threshold (wire-mesh#29/#171) -- FROST(Ed25519) threshold signing's two-round commit/sign protocol, session abort, and the collapsed DKG/reshare keygen-round1/round2/confirm triplet. The group itself (deviceGroup) is a synthetic Ed25519 device-id distinct from deviceA/B/C, which here play the role of the group's own committing/signing participants.
vector("manage_request_v1_threshold_commit", {
type: "manage-request",
"request-id": 17,
command: {
verb: "exadev.io/threshold:sign",
params: {
verb: "threshold.commit",
"session-id": 1,
group: deviceGroup,
subject: {
kind: "capability-token",
protected: hex(wireHex({ 1: -7, 4: deviceGroup })),
payload: hex(wireHex(rootTokenClaims)),
},
deadline: 1893456060000,
},
},
scope: { kind: "group" },
token: roomMemberRootToken,
}),
// manage-ok extended per threshold-commit's own comment: "manage-ok extended with: participant: device-id, hiding: bstr, binding: bstr" -- returning a commitment IS the participant's act of authorisation.
vector("manage_response_v1_threshold_commit_ok", {
type: "manage-response",
"request-id": 17,
outcome: {
result: "ok",
participant: deviceB,
hiding: hex("aa11"),
binding: hex("bb22"),
},
}),
vector("manage_request_v1_threshold_sign", {
type: "manage-request",
"request-id": 18,
command: {
verb: "exadev.io/threshold:sign",
params: {
verb: "threshold.sign",
"session-id": 1,
commitments: [
{ participant: deviceB, hiding: hex("aa11"), binding: hex("bb22") },
{ participant: deviceC, hiding: hex("aa33"), binding: hex("bb44") },
],
},
},
scope: { kind: "group" },
token: roomMemberRootToken,
}),
// manage-ok extended per threshold-sign's own comment: "manage-ok extended with: share: bstr .cbor threshold-share-envelope" -- the released share, self-certifying under the releasing participant's own PERSONAL key (never the group's).
vector("manage_response_v1_threshold_sign_ok", {
type: "manage-response",
"request-id": 18,
outcome: {
result: "ok",
share: hex(
wireHex([
hex(wireHex({ 1: -8, 4: deviceB })),
{},
hex(
wireHex({
"session-id": 1,
group: deviceGroup,
share: hex("ee01"),
issuer: deviceB,
"issuer-key": { alg: -8, "public-key": publicKeyEd25519D },
}),
),
signatureFiller,
]),
),
},
}),
vector("manage_request_v1_threshold_abort_with_reason", {
type: "manage-request",
"request-id": 19,
command: {
verb: "exadev.io/threshold:sign",
params: {
verb: "threshold.abort",
"session-id": 1,
reason: "participant unavailable before the deadline",
},
},
scope: { kind: "group" },
token: roomMemberRootToken,
}),
vector("manage_request_v1_threshold_abort_without_reason", {
type: "manage-request",
"request-id": 20,
command: {
verb: "exadev.io/threshold:sign",
params: { verb: "threshold.abort", "session-id": 1 },
},
scope: { kind: "group" },
token: roomMemberRootToken,
}),
// Fresh DKG: existing-group-key absent, proof-of-knowledge REQUIRED and present.
vector("manage_request_v1_threshold_keygen_round1_fresh_dkg", {
type: "manage-request",
"request-id": 21,
command: {
verb: "exadev.io/threshold:keygen",
params: {
verb: "threshold.keygen-round1",
"session-id": 2,
threshold: 2,
participants: [deviceA, deviceB, deviceC],
commitment: [hex("c001"), hex("c002")],
"proof-of-knowledge": hex("a0f0"),
},
},
scope: { kind: "group" },
}),
// Reshare: existing-group-key present (the group being reshared), proof-of-knowledge MAY be omitted -- see threshold.cddl's own comment on why the rogue-key attack doesn't apply here.
vector("manage_request_v1_threshold_keygen_round1_reshare", {
type: "manage-request",
"request-id": 22,
command: {
verb: "exadev.io/threshold:reshare",
params: {
verb: "threshold.keygen-round1",
"session-id": 3,
threshold: 2,
participants: [deviceA, deviceB, deviceC, deviceD],
commitment: [hex("c003")],
"existing-group-key": deviceGroupKeyBytes,
},
},
scope: { kind: "group" },
}),
// Pairwise, confidential -- MUST travel only over an end-to-end-confidential connection (threshold.cddl's own comment).
vector("manage_request_v1_threshold_keygen_round2", {
type: "manage-request",
"request-id": 23,
command: {
verb: "exadev.io/threshold:keygen",
params: {
verb: "threshold.keygen-round2",
"session-id": 2,
share: hex("5ba2e0"),
},
},
scope: { kind: "group" },
}),
// The mandatory echo-broadcast confirmation round: every participant exchanges a digest over the full ordered round-1 package set plus the derived group key.
vector("manage_request_v1_threshold_keygen_confirm", {
type: "manage-request",
"request-id": 24,
command: {
verb: "exadev.io/threshold:keygen",
params: {
verb: "threshold.keygen-confirm",
"session-id": 2,
"transcript-digest": hex("7d".repeat(SHA256_BYTE_LENGTH)),
"group-key": deviceGroupKeyBytes,
},
},
scope: { kind: "group" },
}),
];

// -----------------------------------------------------------------------
Expand Down
182 changes: 180 additions & 2 deletions rust/crates/wire-mesh-threshold-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,62 @@ pub fn dkg_round3(
})
}

#[wasm_bindgen]
pub struct SplitRound1PackageOutput {
commitment: Array,
proof_of_knowledge: Vec<u8>,
}

#[wasm_bindgen]
impl SplitRound1PackageOutput {
#[wasm_bindgen(getter)]
pub fn commitment(&self) -> Array {
self.commitment.clone()
}

#[wasm_bindgen(getter, js_name = proofOfKnowledge)]
pub fn proof_of_knowledge(&self) -> Vec<u8> {
self.proof_of_knowledge.clone()
}
}

/// Splits a serialized DKG round-1 `Package` (`dkg_round1`'s own `package`
/// output) into `threshold-keygen-round1`'s own wire shape: the Feldman
/// commitment as an array of independently-serialized coefficients
/// (`commitment: [* bstr]`) and the Schnorr proof of knowledge as a
/// separate byte string -- unlike frost-core's own combined-blob
/// serialization. The inverse of [`combine_round1_package`].
#[wasm_bindgen]
pub fn split_round1_package(package: Vec<u8>) -> Result<SplitRound1PackageOutput, JsValue> {
let package = dkg_round1_types::Package::deserialize(&package).map_err(js_err)?;
let (commitment, proof_of_knowledge) =
wire_mesh_threshold::dkg::split_round1_package(&package).map_err(js_err)?;
let commitment_array = Array::new();
for coefficient in &commitment {
commitment_array.push(&Uint8Array::from(coefficient.as_slice()));
}
Ok(SplitRound1PackageOutput {
commitment: commitment_array,
proof_of_knowledge,
})
}

/// Reconstructs a serialized DKG round-1 `Package` (the same combined-blob
/// shape `dkg_round2`/`dkg_round3`/`dkg_transcript_digest` expect) from the
/// two independently-serialized wire fields `threshold-keygen-round1`
/// carries. The inverse of [`split_round1_package`].
#[wasm_bindgen]
pub fn combine_round1_package(
commitment: Array,
proof_of_knowledge: Vec<u8>,
) -> Result<Vec<u8>, JsValue> {
let commitment_bytes = array_to_bytes_vec(&commitment)?;
let package =
wire_mesh_threshold::dkg::combine_round1_package(&commitment_bytes, &proof_of_knowledge)
.map_err(js_err)?;
package.serialize().map_err(js_err)
}

#[wasm_bindgen]
pub fn dkg_transcript_digest(
all_round1_ids: Array,
Expand Down Expand Up @@ -338,6 +394,49 @@ pub fn signing_round1_commit(own_key_package: Vec<u8>) -> Result<SigningRound1Ou
})
}

#[wasm_bindgen]
pub struct SplitCommitmentsOutput {
hiding: Vec<u8>,
binding: Vec<u8>,
}

#[wasm_bindgen]
impl SplitCommitmentsOutput {
#[wasm_bindgen(getter)]
pub fn hiding(&self) -> Vec<u8> {
self.hiding.clone()
}

#[wasm_bindgen(getter)]
pub fn binding(&self) -> Vec<u8> {
self.binding.clone()
}
}

/// Splits a serialized `SigningCommitments` (`signing_round1_commit`'s own
/// `commitments` output) into the two independently-serialized halves
/// `threshold-commitment`'s wire shape carries (`hiding: bstr, binding:
/// bstr`) -- unlike frost-core's own combined-blob serialization, which is
/// opaque and not spec-shaped. The inverse of [`combine_commitments`].
#[wasm_bindgen]
pub fn split_commitments(commitments: Vec<u8>) -> Result<SplitCommitmentsOutput, JsValue> {
let commitments = SigningCommitments::deserialize(&commitments).map_err(js_err)?;
let (hiding, binding) =
wire_mesh_threshold::signing::split_commitments(&commitments).map_err(js_err)?;
Ok(SplitCommitmentsOutput { hiding, binding })
}

/// Reconstructs a serialized `SigningCommitments` (the same combined-blob
/// shape [`signing_build_package`] and [`signing_round2_sign`] expect) from
/// the two independently-serialized halves `threshold-commitment` carries
/// on the wire. The inverse of [`split_commitments`].
#[wasm_bindgen]
pub fn combine_commitments(hiding: Vec<u8>, binding: Vec<u8>) -> Result<Vec<u8>, JsValue> {
let commitments =
wire_mesh_threshold::signing::combine_commitments(&hiding, &binding).map_err(js_err)?;
commitments.serialize().map_err(js_err)
}

/// Coordinator-side: builds the `SigningPackage` bytes every participant's
/// round 2 is computed against.
#[wasm_bindgen]
Expand Down Expand Up @@ -481,6 +580,35 @@ pub fn reshare_round1(
})
}

/// Splits a survivor's serialized broadcast commitment (`reshare_round1`'s
/// own `commitment` output, a whole-blob serialization) into
/// `threshold-keygen-round1`'s own wire shape: an array of independently-
/// serialized coefficients (`commitment: [* bstr]`). The inverse of
/// [`reshare_combine_commitment_parts`].
#[wasm_bindgen]
pub fn reshare_split_commitment(commitment: Vec<u8>) -> Result<Array, JsValue> {
let commitment =
VerifiableSecretSharingCommitment::deserialize_whole(&commitment).map_err(js_err)?;
let parts = wire_mesh_threshold::reshare::split_commitment(&commitment).map_err(js_err)?;
let out = Array::new();
for part in &parts {
out.push(&Uint8Array::from(part.as_slice()));
}
Ok(out)
}

/// Reconstructs a survivor's serialized broadcast commitment (the same
/// whole-blob shape [`reshare_combine_commitments`] expects each entry of
/// its own `commitments` array to be) from the wire's own per-coefficient
/// array. The inverse of [`reshare_split_commitment`].
#[wasm_bindgen]
pub fn reshare_combine_commitment_parts(parts: Array) -> Result<Vec<u8>, JsValue> {
let part_bytes = array_to_bytes_vec(&parts)?;
let commitment =
wire_mesh_threshold::reshare::combine_commitment_parts(&part_bytes).map_err(js_err)?;
commitment.serialize_whole().map_err(js_err)
}

#[wasm_bindgen]
pub fn reshare_combine_commitments(commitments: Array) -> Result<Vec<u8>, JsValue> {
let bytes = array_to_bytes_vec(&commitments)?;
Expand All @@ -493,11 +621,58 @@ pub fn reshare_combine_commitments(commitments: Array) -> Result<Vec<u8>, JsValu
combined.serialize_whole().map_err(js_err)
}

/// The echo-broadcast transcript digest a member of the new participant set
/// sends on `threshold-keygen-confirm` for a reshare -- the reshare analogue
/// of `dkg_transcript_digest`, structurally distinct because a reshare's own
/// survivor commitment (`survivor_commitments`' own entries, each the same
/// whole-blob shape `reshare_round1`'s own `commitment` output is) carries
/// no proof-of-knowledge component: deserializing one AS a DKG round-1
/// `Package` fails outright, since the two are different wire shapes
/// entirely.
#[wasm_bindgen]
pub fn reshare_transcript_digest(
survivor_ids: Array,
survivor_commitments: Array,
group_verifying_key: Vec<u8>,
) -> Result<Vec<u8>, JsValue> {
let commitments = parallel_arrays_to_map(&survivor_ids, &survivor_commitments, |bytes| {
VerifiableSecretSharingCommitment::deserialize_whole(bytes).map_err(js_err)
})?;
let group_key = VerifyingKey::deserialize(&group_verifying_key).map_err(js_err)?;
let digest = wire_mesh_threshold::reshare::transcript_digest(&commitments, &group_key)
.map_err(js_err)?;
Ok(digest.to_vec())
}

#[wasm_bindgen]
pub struct ReshareDerivePublicKeyPackageOutput {
public_key_package: Vec<u8>,
group_verifying_key: Vec<u8>,
}

#[wasm_bindgen]
impl ReshareDerivePublicKeyPackageOutput {
#[wasm_bindgen(getter, js_name = publicKeyPackage)]
pub fn public_key_package(&self) -> Vec<u8> {
self.public_key_package.clone()
}

#[wasm_bindgen(getter, js_name = groupVerifyingKey)]
pub fn group_verifying_key(&self) -> Vec<u8> {
self.group_verifying_key.clone()
}
}

/// `groupVerifyingKey` is what `threshold-keygen-confirm`'s own `group-key`
/// field carries and what a reshare's own verifier obligation checks
/// against `existing-group-key` -- exposed alongside the whole
/// `PublicKeyPackage` blob (mirroring `DkgRound3Output`'s identical shape
/// for fresh DKG) rather than requiring a second call to extract it.
#[wasm_bindgen]
pub fn reshare_derive_public_key_package(
combined_commitment: Vec<u8>,
new_participant_device_ids: Array,
) -> Result<Vec<u8>, JsValue> {
) -> Result<ReshareDerivePublicKeyPackageOutput, JsValue> {
let commitment = VerifiableSecretSharingCommitment::deserialize_whole(&combined_commitment)
.map_err(js_err)?;
let bytes = array_to_bytes_vec(&new_participant_device_ids)?;
Expand All @@ -507,7 +682,10 @@ pub fn reshare_derive_public_key_package(
.collect::<Result<Vec<_>, _>>()?;
let pkp = wire_mesh_threshold::reshare::derive_public_key_package(&commitment, &ids)
.map_err(js_err)?;
pkp.serialize().map_err(js_err)
Ok(ReshareDerivePublicKeyPackageOutput {
public_key_package: pkp.serialize().map_err(js_err)?,
group_verifying_key: pkp.verifying_key().serialize().map_err(js_err)?,
})
}

#[wasm_bindgen]
Expand Down
Loading