Files
nq/PROTOCOL.md
T
2026-06-08 09:30:44 -07:00

358 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
NIP-XX
======
FROST Multisig Quorum Protocol
-------------------------------
`draft` `optional`
## Abstract
This NIP defines a protocol for creating and operating FROST threshold signature quorums over Nostr keys. A quorum is a group of n participants who collectively control a shared Nostr keypair via a (t,n) threshold signing scheme, where any t members can produce a valid signature but no fewer. The quorum's private key is never known to any single party.
## Participant Indexing
Each member is assigned a positive integer index. Indices are derived deterministically by sorting member pubkeys lexicographically as lowercase hex strings and assigning 1-based positions. This ordering must be consistent across all participants and all rounds of a session.
When a new member set is established (rotation), indices are re-derived from the new member list.
## Event Kinds
All events in this protocol are sent as the `content` payload of a NIP-59 gift wrap (kind 1059). The `content` is encrypted to the recipient's pubkey and the wrap is sent to the recipients inbox relays (kind 10050) per NIP 17.
## Quorum Creation
Creates a new quorum and derives its shared Nostr keypair. The private key is never known to any party.
### Phase 0 — Invitation (kind 7050)
The initiator sends each proposed member a gift-wrapped event:
```json
{
"kind": 7050,
"content": "<optional human-readable message>",
"tags": [
["session_id", "<32-byte random hex>"],
["threshold", "<signing threshold>"],
["member", "<pk_1>"],
["member", "<pk_n>"],
],
}
```
`session_id` is chosen by the initiator and identifies all subsequent events for this DKG session. The quorum's keypair does not yet exist; no key material is present in the invite.
Participation in Round 1 signals acceptance. A member may send a decline event, but is not required to.
### Round 1 — Commitments (kind 7051)
Each accepting participant Pᵢ:
1. Samples a random polynomial `fᵢ(x)` of degree `t1` over the secp256k1 scalar field, with random coefficients `aᵢ₀, aᵢ₁, …, aᵢ,ₜ₋₁`
2. Computes Feldman commitments: `Cᵢ = [aᵢ₀·G, aᵢ₁·G, …, aᵢ,ₜ₋₁·G]` (compressed 33-byte EC points as hex)
3. Computes a Schnorr proof of knowledge of `aᵢ₀`:
- Sample ephemeral scalar `k`; compute `R = k·G`
- `c = H("frost/dkg/round1" || session_id || pkᵢ || Cᵢ[0])`
- `s = k + aᵢ₀ · c (mod q)`
4. Sends to all other members (n1 gift wraps, identical payload):
```json
{
"kind": 7051,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["commit", "<aᵢ₀·G hex>"],
["commit", "<aᵢ₁·G hex>"],
["proof", "<R hex>", "<s hex>"]
]
}
```
### Round 2 — Share Distribution (kind 7052)
After receiving Round 1 from all other participants, Pᵢ:
1. Verifies each Pⱼ's PoK: `s·G == R + c·Cⱼ[0]` where `c` is recomputed from the proof
2. For each Pⱼ (j ≠ i), evaluates `sᵢⱼ = fᵢ(j)` (polynomial evaluated at Pⱼ's 1-based index)
3. Sends encrypted to Pⱼ only (one gift wrap per recipient):
```json
{
"kind": 7052,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["share", "<sᵢⱼ hex scalar>"]
]
}
```
### Finalization
Each Pⱼ, after receiving shares from all other participants:
1. Verifies each received share against Pᵢ's commitments:
`sᵢⱼ·G == Σₖ₌₀ᵗ⁻¹ ( j^k · Cᵢₖ )`
Abort if any check fails.
2. Computes secret shard: `xⱼ = Σᵢ sᵢⱼ (mod q)`
3. Computes group public key: `Y = Σᵢ Cᵢ₀` (sum of all participants' first commitments)
4. Computes own verification share: `Yⱼ = Σᵢ Σₖ₌₀ᵗ⁻¹ ( j^k · Cᵢₖ )`; verify `xⱼ·G == Yⱼ`
5. **BIP-340 normalization**: if `Y` has odd y-coordinate, negate `xⱼ` and `Yⱼ` and use the even-y form of `Y` as the quorum pubkey. This negation must be applied consistently across all subsequent operations.
6. Stores `(xⱼ, Y, Yⱼ, members, threshold, session_id, Round-1 commitments)` durably in IndexedDB. The Round-1 commitments are retained because they are needed to verify resharing participants' shards in Protocol 2.
7. Publishes a DKG confirmation event (kind 7053).
### Confirmation (kind 7053)
```json
{
"kind": 7053,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["transcript", "<H(session_id || sorted Round-1 commitments)>"]
]
}
```
`transcript_hash` enables equivocation detection: a malicious participant may send different Round-1 commitments to different members. If any two confirmations carry the same `session_id` but different `transcript_hash` or `quorum_pubkey`, all participants must abort.
The quorum is considered live once `t` confirmations with matching `transcript_hash` and `quorum_pubkey` have been observed.
---
## Protocol 2: Key Redistribution (Rotation)
Redistributes the existing quorum key to a new member set and/or threshold without reconstructing the private key. The quorum's Nostr pubkey `Y` is preserved, so the quorum's identity, profile, and event history are unaffected.
**Prerequisite**: A contributing set `S` of at least `t` current members must participate.
### Phase 0 — Resharing Proposal (kind 7054)
The initiator (a current member) sends to all current and prospective new members:
```json
{
"kind": 7054,
"content": "<optional human-readable message>",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["threshold", "<t'>"],
["old_member", "<pk_1>"],
["old_member", "<pk_n>"],
["member", "<pk_1'>"],
["member", "<pk_n'>"]
]
}
```
New member indices are derived by sorting `new_members` lexicographically (1-based), independently of the old index assignment.
Old members signal participation by contributing in Round 1. The contributing set `S` **must be fixed before shares are combined**, because each old member's Lagrange coefficient depends on the full set `S`. Implementations should establish `S` via a timeout or an explicit "I'm participating" acknowledgment step before proceeding to Round 2.
### Round 1 — Old Member Commitments (kind 7055)
Each participating old member Pᵢ (with index `i` from the original DKG and shard `xᵢ`):
1. Computes Lagrange coefficient over contributing set `S` at point 0:
`λᵢ = Πⱼ∈S, j≠i (-j) / (i - j) (mod q)`
2. Samples a new random polynomial `hᵢ(x)` of degree `t'1` with constant term `hᵢ(0) = λᵢ · xᵢ`
3. Computes commitments: `Dᵢ = [λᵢ·xᵢ·G, …]` (same structure as Round-1 commitments in Protocol 1)
4. The first commitment `Dᵢ[0]` must equal `λᵢ · Yᵢ`, where `Yᵢ` is Pᵢ's verification share from the original DKG (publicly computable from stored Round-1 commitments). This proves Pᵢ is resharing their actual shard.
5. Computes Schnorr PoK of `λᵢ · xᵢ` (same construction as Protocol 1 Round 1, using `"frost/resharing/round1"` as domain tag)
6. Sends to all new members (m gift wraps, identical payload):
```json
{
"kind": 7055,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["commit", "<λᵢ·xᵢ·G hex>"],
["commit", "<bᵢ₁·G hex>"],
["proof", "<R hex>", "<s hex>"]
]
}
```
New members verify before proceeding: `Σᵢ∈S Dᵢ[0] == Y`. Abort if this check fails.
### Round 2 — Share Distribution (kind 7056)
Each participating old member Pᵢ evaluates `hᵢ(j)` at each new member Qⱼ's index `j` and sends encrypted to Qⱼ only:
```json
{
"kind": 7056,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["share", "<hᵢ(j) hex scalar>"]
]
}
```
### Finalization
Each new member Qⱼ, after receiving shares from all members of `S`:
1. Verifies each received share against Dᵢ commitments:
`hᵢ(j)·G == Σₖ₌₀ᵗ'⁻¹ ( j^k · Dᵢₖ )`
Abort if any check fails.
2. Computes new shard: `x'ⱼ = Σᵢ∈S hᵢ(j) (mod q)`
3. Computes new verification share: `Y'ⱼ = Σᵢ∈S Σₖ ( j^k · Dᵢₖ )`; verify `x'ⱼ·G == Y'ⱼ`
4. BIP-340 normalization: `Y` is unchanged, so the same even-y convention applies. If `xᵢ` was negated during the original DKG finalization, `hᵢ(0) = λᵢ · xᵢ` already incorporates that negation. Qⱼ verifies against the same `Y` and does not re-negate.
5. Replaces stored quorum state with `(x'ⱼ, Y, Y'ⱼ, new_members, new_threshold, session_id, Round-1 commitments from this session)`.
6. Publishes resharing confirmation (kind 7057).
### Resharing Confirmation (kind 7057)
```json
{
"kind": 7057,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["transcript", "<H(session_id || sorted Round-1 commitments)>"]
]
}
```
Once `t'` new members have published matching confirmations, the rotation is considered complete. The quorum then publishes a signed `kind 0` metadata event under `Y` recording the new member list. This event is the on-chain rotation record and is used to gate NIP-17 chat display by membership at time of message.
---
## Protocol 3: Collaborative Signing (FROST)
Any quorum member may initiate a signing session. At least t members must participate to produce a valid signature.
### Sign Request (kind 7058)
The initiator sends to all members:
```json
{
"kind": 7058,
"content": "<JSON-stringified unsigned nostr event>",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"]
]
}
```
### Round 1 — Nonce Commitments (kind 7059)
Each willing signer Pᵢ:
1. Samples ephemeral nonce pair `(dᵢ, eᵢ)` uniformly at random (must never be reused)
2. Computes commitments `Dᵢ = dᵢ·G`, `Eᵢ = eᵢ·G`
3. Sends to all other participating signers:
```json
{
"kind": 7059,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["D", "<dᵢ·G hex>"],
["E", "<eᵢ·G hex>"]
]
}
```
### Round 2 — Signature Shares (kind 7060)
After collecting Round 1 from at least `t` signers, each Pᵢ:
1. Finalizes the signing set `S` (the participants whose commitments were collected)
2. Computes binding factors: `ρᵢ = H("frost/sign/rho" || i || msg || {(Dⱼ, Eⱼ)}ⱼ∈S)` for each `i ∈ S`
3. Computes group nonce: `R = Σᵢ∈S (Dᵢ + ρᵢ·Eᵢ)`; if `R` has odd y-coordinate, negate `dᵢ, eᵢ` and use the even-y `R`
4. Computes BIP-340 challenge: `c = H_BIP340("BIP0340/challenge" || R.x || Y || msg)`
5. Computes Lagrange coefficient `λᵢ` over `S` at point 0 (same formula as Protocol 2)
6. Computes signature share: `zᵢ = dᵢ + eᵢ·ρᵢ + λᵢ·xᵢ·c (mod q)` (using the BIP-340-normalized `xᵢ`)
7. Sends to coordinator:
```json
{
"kind": 7060,
"content": "",
"tags": [
["session_id", "<32-byte hex>"],
["quorum", "<Y x-only hex>"],
["z", "<zᵢ hex scalar>"]
]
}
```
### Aggregation
The coordinator (any member) aggregates:
- `z = Σᵢ∈S zᵢ (mod q)`
- Final signature: `(R.x, z)`
The coordinator verifies the signature against `Y` and `msg` using standard BIP-340 verification before publishing the event. The resulting signature is a valid BIP-340 Schnorr signature, indistinguishable from a single-key signature.
---
## Storage
Quorum state must be stored durably in IndexedDB per-quorum:
| Field | Description |
|-------|-------------|
| `quorum_pubkey` | `Y` as x-only hex — the quorum's Nostr pubkey |
| `shard` | `xⱼ` — this member's secret share (encrypted at rest) |
| `verification_share` | `Yⱼ` — public verification share for this member |
| `members` | Current member pubkeys and indices |
| `threshold` | Current signing threshold `t` |
| `dkg_commitments` | All participants' Round-1 commitments from the most recent DKG or resharing session |
| `rotation_records` | All signed rotation `kind 0` events in order |
`dkg_commitments` are retained permanently because they are required to verify shard authenticity during resharing (step 4 of Protocol 2 Round 1).
`rotation_records` are used to determine which members were active at a given time, which gates NIP-17 chat message display.
---
## Security Notes
**Equivocation**: A malicious participant can send different Round-1 commitments to different members, causing different members to derive different group keys. The `transcript_hash` in confirmation events provides detection. Implementations must abort if any two confirmations for the same session have different hashes.
**Abort and restart**: If any participant fails to complete their round within a timeout, the session must be fully aborted. Partial state (nonces, sub-shares) must be discarded. A new session with a new `session_id` must be started from Phase 0.
**Nonce reuse in signing**: Reusing `(dᵢ, eᵢ)` across two signing sessions leaks the shard `xᵢ`. Implementations must use fresh randomness for every session and must not persist signing nonces.
**Contributing set integrity**: In Protocol 2, the Lagrange coefficients and the integrity check `Σ Dᵢ[0] == Y` are only meaningful over the same set `S`. The set must be fixed and agreed upon before shares are combined. Any late-joining or aborting member after `S` is finalized requires a full session restart.
**BIP-340 y-coordinate normalization**: Nostr uses x-only public keys. Both the group key `Y` (finalized in DKG) and the signing nonce `R` (per signing session) require even-y normalization, which affects the sign of `xⱼ` and `(dᵢ, eᵢ)` respectively. These negations are independent and must both be applied correctly.