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. ## Session References Each protocol flow is initiated by a single event (the invite, resharing proposal, or sign request). All subsequent events in that flow reference the initiating event's inner ID via a standard `["e", ""]` tag. The inner ID is computed from the unsigned rumor before gift-wrapping and is identical across all per-recipient gift wraps of the same event. ## Event Kinds All events in this protocol are sent as the `content` payload of a NIP-59 gift wrap (kind 1059) with at least 16 bits of proof-of-work per NIP-13. Each wrapper carries a `["t", "b7ed"]` tag identifying this sub-protocol, allowing recipients to filter their inbox efficiently. The `content` is encrypted to the recipient's pubkey and the wrap is sent to the recipient's 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": "", "tags": [ ["threshold", ""], ["member", ""], ["member", ""] ] } ``` The event's inner ID identifies the DKG session; all subsequent events in this flow reference it via `["e", ""]`. The quorum's keypair does not yet exist; no key material is present in the invite. Participation in Round 1 signals acceptance. A member who will not participate should send a decline event (kind 7061) so the initiator knows not to wait. ### Round 1 — Commitments (kind 7051) Each accepting participant Pᵢ: 1. Samples a random polynomial `fᵢ(x)` of degree `t−1` 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ᵢ || R || Cᵢ[0])` - `s = k + aᵢ₀ · c (mod q)` 4. Sends to all other members (n−1 gift wraps, identical payload): ```json { "kind": 7051, "content": "", "tags": [ ["e", ""], ["commit", ""], ["commit", ""], ["proof", "", ""] ] } ``` ### 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": [ ["e", ""], ["share", ""] ] } ``` ### 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, Round-1 commitments)` durably. The Round-1 commitments are retained because they are needed to verify resharing participants' shards in Protocol 2. 7. Sends a DKG confirmation event (kind 7053) to all other n−1 members. ### Confirmation (kind 7053) ```json { "kind": 7053, "content": "", "tags": [ ["e", ""], ["quorum", ""], ["transcript", ""] ] } ``` `transcript` enables equivocation detection: a malicious participant may send different Round-1 commitments to different members. If any two confirmations reference the same initiating event but carry different `transcript` or `quorum` values, all participants must abort. The quorum is considered live once all `n` members have sent confirmations with matching `transcript` and `quorum` values, or until a timeout elapses and all respondents match. Any single conflicting confirmation must trigger an immediate abort, regardless of how many matching confirmations have already been received. ## Key Redistribution 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. The `contributor` list in the proposal is the finalized set `S`. Every listed contributor must complete Round 1 or the flow stalls; any contributor or prospective new member who will not participate should send a decline event (kind 7061). The initiator may restart with a new proposal and a different contributor set. ### Phase 0 — Resharing Proposal (kind 7054) The initiator (a current member) sends to all current and prospective new members: ```json { "kind": 7054, "content": "", "tags": [ ["quorum", ""], ["threshold", ""], ["contributor", ""], ["contributor", ""], ["member", ""], ["member", ""], ["dkg_commit", "", "", "", "…"], ["dkg_commit", "", "", "", "…"] ] } ``` The event's inner ID identifies the resharing session; all subsequent events in this flow reference it via `["e", ""]`. `contributor` tags list the exact contributing set `S` — the old members who will reshare their shards. `member` tags list the new member set after rotation. A retained member appears in both. New member indices are derived by sorting `member` pubkeys lexicographically (1-based); `contributor` pubkeys retain their original indices from the prior DKG session, which are used for Lagrange coefficient computation. Because `S` is fixed in the proposal, Lagrange coefficients can be computed immediately without waiting for Round 1 responses. `dkg_commit` tags carry the Feldman commitments from the most recent DKG or resharing session for **every current quorum member** (not just contributors). One tag per member: the first value is the member's pubkey, followed by their ordered commitments `[C₀, C₁, …, Cₜ₋₁]`. New-only members use these to verify each contributor's first commitment in Round 1. ### Round 1 — Old Member Commitments (kind 7055) Each contributing member Pᵢ (index `i` from the original DKG, 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": [ ["e", ""], ["quorum", ""], ["commit", "<λᵢ·xᵢ·G hex>"], ["commit", ""], ["proof", "", ""] ] } ``` New members verify before proceeding using the `dkg_commit` data from the proposal: 1. Determine the BIP-340 sign factor: let `σ_Y = −1` iff `Σⱼ Cⱼ₀` (the raw group key from the `dkg_commit` entries) has odd y-coordinate; otherwise `σ_Y = 1`. 2. For each contributor Pᵢ in S, compute Pᵢ's raw verification share from the original DKG: `Ỹᵢ = Σⱼ Σₖ₌₀ᵗ⁻¹ ( i^k · Cⱼₖ )` (summing over **all** original members' `dkg_commit` entries) Apply the same normalization used during DKG finalization: `Yᵢ = σ_Y · Ỹᵢ`. 3. Verify `Dᵢ[0] == λᵢ · Yᵢ` for each contributor. Abort if any check fails. If all per-contributor checks pass, `Σᵢ∈S Dᵢ[0] == Y` holds automatically (Lagrange identity). ### 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": [ ["e", ""], ["quorum", ""], ["share", ""] ] } ``` ### 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, Round-1 commitments from this session)`. 6. Sends resharing confirmation (kind 7057) to all other n'−1 new members. ### Resharing Confirmation (kind 7057) ```json { "kind": 7057, "content": "", "tags": [ ["e", ""], ["quorum", ""], ["transcript", ""] ] } ``` `transcript` enables equivocation detection for the resharing round, the same way it does for DKG confirmations. If any two kind 7057 events reference the same initiating event but carry different `transcript` or `quorum` values, all participants must abort. The rotation is considered complete once all `n'` new members have sent kind 7057 confirmations with matching `transcript` and `quorum` values, or until a timeout elapses and all respondents match. Any single conflicting confirmation must trigger an immediate abort. Clients retain all kind 7057 confirmation sets in order to determine which members were active at a given time, which gates NIP-17 chat message display. ## Collaborative Signing 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. Any member who will not sign should respond with a decline event (kind 7061) so the initiator knows not to wait. If fewer than `t` members are willing to sign, the session cannot proceed. ```json { "kind": 7058, "content": "", "tags": [ ["quorum", ""] ] } ``` The event's inner ID identifies the signing session; all subsequent events in this flow reference it via `["e", ""]`. `msg` in all subsequent computations is the 32-byte NIP-01 event id of the unsigned event: the SHA-256 hash of its canonical NIP-01 serialisation `[0,pubkey,created_at,kind,tags,content]`. ### 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": [ ["e", ""], ["quorum", ""], ["D", ""], ["E", ""] ] } ``` ### Round 2 — Signature Shares (kind 7060) **Signing set finalisation**: After collecting at least `t` Round-1 nonces, the coordinator constructs the signing set `S` — the indices of exactly those participants whose nonces were received, ordered ascending — and distributes the complete finalized nonce list `{(j, Dⱼ, Eⱼ)}ⱼ∈S` to all Round-2 participants. Signers must not begin Round 2 before receiving this distribution. After receiving the finalized `S` and nonce list from the coordinator, each Pᵢ: 1. Uses the signing set `S` and nonce list as distributed by the coordinator 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": [ ["e", ""], ["quorum", ""], ["z", ""], ["signer", ""], ["signer", ""] ] } ``` The `signer` tags list every member of `S` by index. The coordinator rejects aggregation and aborts the session if any two received Round-2 events carry different signer sets. ### Aggregation The coordinator first verifies each received share individually. Let `σ_Y = −1` iff the raw group key `Σⱼ Cⱼ₀` from stored DKG commitments has odd y-coordinate; otherwise `σ_Y = 1`. Let `σ_R = −1` iff the pre-normalization group nonce `R̃ = Σᵢ∈S (Dᵢ + ρᵢ·Eᵢ)` had odd y-coordinate; otherwise `σ_R = 1`. For each Pᵢ in S, compute `Ỹᵢ = Σⱼ Σₖ ( i^k · Cⱼₖ )` from stored DKG commitments, set `Yᵢ = σ_Y · Ỹᵢ`, and check: `zᵢ·G == σ_R · (Dᵢ + ρᵢ·Eᵢ) + λᵢ · c · Yᵢ` If any share fails, abort the session — the failing signer is identified and may be excluded from future attempts. The coordinator then 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. ## Decline (kind 7061) Any participant may decline an invitation or request by sending a kind 7061 event to the initiator. The `e` tag references the inner ID of the initiating event. The `quorum` tag is included where the quorum pubkey is already known (resharing and signing); it is omitted for quorum creation invites where the key does not yet exist. ```json { "kind": 7061, "content": "", "tags": [ ["e", ""], ["quorum", ""] ] } ``` A decline is informational. It does not itself abort a session, but receiving one signals the initiator that the session cannot complete as proposed and should be restarted. ## Storage Quorum state must be stored durably 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` | Ordered list of completed rotation sessions, each stored as the quorum's kind 7057 confirmation set (all `t'` matching confirmations) | `dkg_commitments` are retained permanently because they are required to verify shard authenticity during resharing (step 4 of the Key Redistribution Round 1). `rotation_records` are used to determine which members were active at a given point in time, which gates NIP-17 chat message display. Each entry is identified by the kind 7054 inner ID and stores the full set of kind 7057 confirmations that completed that rotation. ## 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` tag in confirmation events (kind 7053 and 7057) provides detection. Implementations must abort if any two confirmations referencing the same initiating event carry different `transcript` or `quorum` values. **Abort and restart**: If any participant fails to complete their round, the session must be fully aborted. Partial state (nonces, sub-shares) must be discarded. The initiator starts a new session from Phase 0, producing a new initiating event with a new inner ID. **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 valid over the exact set `S` fixed in the proposal. If any contributor fails to complete Round 1, the session stalls and must be restarted with a new proposal. **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.