This document specifies the wire protocol for FROSTR peer-to-peer communication, including message formats, schemas, and validation requirements.
FROSTR uses Nostr relays as the transport layer. All protocol messages are:
- Serialized as JSON
- Encrypted with ChaCha20-Poly1305 (ECDH-derived keys)
- Wrapped in Nostr events
- Routed via tag-based dispatch
┌─────────────────────────────────────────────────────────────────┐
│ Message Transport │
└─────────────────────────────────────────────────────────────────┘
Application Bifrost Nostr Relay
│ │ │
│ req.sign() │ │
│──────────────────>│ │
│ │ Serialize JSON │
│ │ Encrypt │
│ │ Wrap in event │
│ │──────────────────>│
│ │ │ Store & forward
│ │ │──────────> Peers
Protocol messages are sent as Nostr events (kind 20000+):
{
"id": "<event_id>",
"pubkey": "<sender_pubkey>",
"created_at": 1234567890,
"kind": 20000,
"tags": [
["p", "<recipient_pubkey>"]
],
"content": "<encrypted_payload>",
"sig": "<schnorr_signature>"
}The content field contains:
nonce (12 bytes) || ciphertext || auth_tag (16 bytes)
Decryption requires the shared secret derived via ECDH between sender and recipient.
After decryption, messages have the structure:
{
"tag": "/sign/req",
"data": { ... },
"env": {
"pubkey": "<sender_pubkey>",
"created_at": 1234567890
}
}| Tag | Direction | Purpose |
|---|---|---|
/ping/req |
Request | Peer discovery, nonce exchange |
/ping/res |
Response | Connection confirmation, nonces |
/sign/req |
Request | Threshold signing request |
/sign/res |
Response | Partial signature |
/ecdh/req |
Request | Threshold ECDH request |
/ecdh/res |
Response | ECDH share |
/echo/req |
Request | Relay connectivity test |
/echo/res |
Response | Echo reply |
/onboard/req |
Request | New member onboarding |
/onboard/res |
Response | Group package for new member |
Initiates a threshold signing session.
Schema:
{
gid: string // 32-byte hex - Group ID
sid: string // 32-byte hex - Session ID
members: number[] // Participating member indices (sorted)
hashes: string[][] // Sighash vectors: [[sighash, ...tweaks], ...]
nonces: [{ // Public nonces for all members
idx: number // Member index
binder_pn: string // 33-byte hex - Binder public nonce
hidden_pn: string // 33-byte hex - Hidden public nonce
code: string // 32-byte hex - Derivation code
}, ...]
content: string|null // Optional metadata
type: string // Session type (e.g., "message")
stamp: number // Unix timestamp
replenish?: [{...}] // Optional nonce replenishment
}Example:
{
"gid": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"sid": "b2c3d4e5f6789012345678901234567890123456789012345678901234abcde1",
"members": [1, 2],
"hashes": [
["c3d4e5f67890123456789012345678901234567890123456789012345678901234"]
],
"nonces": [
{
"idx": 1,
"binder_pn": "02abc123...",
"hidden_pn": "03def456...",
"code": "d4e5f6..."
},
{
"idx": 2,
"binder_pn": "02ghi789...",
"hidden_pn": "03jkl012...",
"code": "e5f6g7..."
}
],
"content": null,
"type": "message",
"stamp": 1704067200
}Returns a partial signature.
Schema:
{
idx: number // Signer's member index
psigs: [ // Partial signatures
[string, string], // [sighash, partial_sig]
...
]
pubkey: string // 33-byte hex - Signer's public key
sid: string // 32-byte hex - Session ID (must match request)
nonce_code?: string // 32-byte hex - Nonce code used (for verification)
replenish?: [{...}] // Optional nonce replenishment
}Example:
{
"idx": 2,
"psigs": [
[
"c3d4e5f67890123456789012345678901234567890123456789012345678901234",
"f6g7h8i9..."
]
],
"pubkey": "02xyz...",
"sid": "b2c3d4e5f6789012345678901234567890123456789012345678901234abcde1"
}Requests threshold ECDH with one or more public keys.
Schema:
{
gid: string // 32-byte hex - Group ID
members: number[] // Participating member indices
ecdh_pks: string[] // Target public keys (33-byte hex each)
}Example:
{
"gid": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd",
"members": [1, 2],
"ecdh_pks": [
"02target_pubkey_1...",
"03target_pubkey_2..."
]
}Returns ECDH key shares.
Schema:
{
idx: number // Responder's member index
members: number[] // Participating members (must match request)
entries: [{ // One entry per requested pubkey
ecdh_pk: string // 33-byte hex - Target public key
keyshare: string // 33-byte hex - ECDH key share
}, ...]
}Example:
{
"idx": 2,
"members": [1, 2],
"entries": [
{
"ecdh_pk": "02target_pubkey_1...",
"keyshare": "02share_for_pk1..."
},
{
"ecdh_pk": "03target_pubkey_2...",
"keyshare": "03share_for_pk2..."
}
]
}Discovers peers and exchanges nonces.
Schema:
{
nonces?: [{ // Optional nonce package
binder_pn: string // 33-byte hex
hidden_pn: string // 33-byte hex
code: string // 32-byte hex
}, ...]
}Example:
{
"nonces": [
{
"binder_pn": "02abc...",
"hidden_pn": "03def...",
"code": "1234..."
}
]
}Confirms connectivity and returns nonces.
Schema:
{
nonces?: [{ // Optional nonce package
binder_pn: string
hidden_pn: string
code: string
}, ...]
}Tests relay connectivity (sent to self).
Schema:
{
challenge: string // Random challenge value
}Returns the challenge (proves message round-trip).
Schema:
{
challenge: string // Must match request
}Requests group information for a new member.
Schema:
{
pubkey: string // New member's public key
}Returns group package for the new member.
Schema:
{
group: string // Bech32m-encoded GroupPackage
}This section describes the nonce pool protocol used for threshold signing operations.
Security Note: Nonce reuse is catastrophic for Schnorr signatures - it leaks the secret key. See Cryptographic Foundations - Nonce Security for security properties.
The nonce pool system manages the generation, distribution, and consumption of nonces between peers in a threshold signing group.
-
HMAC-Based Derivation: Instead of storing full secret nonces (64 bytes), we store only a 32-byte derivation code. Secrets are re-derived on-demand during signing using HMAC.
-
Simplified Design: Uses
codeas the unique identifier (no separateidfield), member index is implicit from context. -
No Spent Tracking: Deletion from the pool equals consumption - no separate spent sets needed.
-
Unified Wire Format: Single
noncesarray in signing requests contains all information (idx, code, public points).
// Base public nonce (binder_pn + hidden_pn only)
interface PublicNonce {
binder_pn: string // 33 bytes hex
hidden_pn: string // 33 bytes hex
}
// With derivation code (pool storage, nonce packages)
interface DerivedPublicNonce extends PublicNonce {
code: string // 32 bytes hex
}
// With member index (signing wire format only)
interface MemberPublicNonce extends DerivedPublicNonce {
idx: number // member index
}
// Secret pair for signing
interface SecretNoncePair {
code: string
binder_sn: string // 32 bytes hex
hidden_sn: string // 32 bytes hex
}
// Nonce package for ping/replenish (just array, no wrapper)
type NoncePackage = DerivedPublicNonce[]The sender/target is implicit from the P2P message context.
When generating nonces for a peer:
1. Generate random 32-byte derivation code
2. Derive secret nonces via HMAC:
- binder_sn = HMAC-SHA256(share_secret, code || "bifrost/nonce/binder/v1")
- hidden_sn = HMAC-SHA256(share_secret, code || "bifrost/nonce/hidden/v1")
3. Compute public nonces:
- binder_pn = G * binder_sn
- hidden_pn = G * hidden_sn
4. Store the nonce by code in outgoing pool
5. Send DerivedPublicNonce (binder_pn, hidden_pn, code) to peer
Nonces are distributed via two mechanisms:
During Ping:
- Check
should_send_nonces_to(peer_idx)- does our OUTGOING pool for this peer have fewer thanmin_thresholdactive nonces? - If yes, generate and send
replenish_countnew nonces asNoncePackage(array of DerivedPublicNonce)
During Sign Requests:
- Sign requests can include optional
replenishpackages - Allows proactive replenishment during normal operations
Outgoing Pool (what we generated for peers):
// Map<peer_idx, Map<code, DerivedPublicNonce>>
{
nonces: Map<string, DerivedPublicNonce> // code -> nonce
}Incoming Pool (what peers sent us):
// Map<peer_idx, Map<code, DerivedPublicNonce>>
{
nonces: Map<string, DerivedPublicNonce> // code -> nonce
}When initiating a sign request:
- For each peer, consume a nonce from our INCOMING pool
- The consumed nonce is returned as
MemberPublicNonce(with peer's idx) - Generate our own nonce for the session
- Send sign request with unified
noncesarray:- Each entry is a
MemberPublicNonce(idx + code + public points)
- Each entry is a
When handling a sign request:
- Find our nonce in
session.noncesby our idx - Verify code exists in our outgoing pool for the requester
- Re-derive secret using:
derive_secret_nonce(share_secret, code) - Sign with derived secret
- Delete nonce from outgoing pool (deletion = spent)
- Once a nonce is used in a signature, it's deleted from the pool
- No separate spent tracking needed
- Deletion from the map serves as the consumption marker
Node A Node B
| |
|---[ping req + nonces]------------>|
| (if should_send_nonces_to(B)) |
| nonces: DerivedPublicNonce[] |
| |
|<--[ping res + nonces]-------------|
| (if should_send_nonces_to(A)) |
| nonces: DerivedPublicNonce[] |
| |
Requester Signer
| |
|-- Consume nonce from pool -------|
| (returns MemberPublicNonce) |
| |
|---[sign req]-------------------->|
| nonces: MemberPublicNonce[] |
| |
| |-- Find our nonce by idx
| |-- Verify code in outgoing pool
| |-- Derive secret from code
| |-- Sign with derived secret
| |-- Delete nonce from pool
| |
|<--[partial sig]------------------|
| |
{
pool_size: 100, // Target pool size per peer
min_threshold: 20, // Trigger replenishment below this
critical_threshold: 5, // Refuse signing below this
replenish_count: 50 // How many nonces to send during replenishment
}Nonces must NEVER be reused. The protocol prevents this via:
- Single-use consumption from pools (deletion on use)
- Fresh random codes for each nonce
- Pool state ensures uniqueness
The HMAC-based derivation ensures:
- Different codes produce different secrets
- Only the share holder can derive secrets
- Secrets are never transmitted or stored persistently
- Compromised code without share secret is useless
The protocol ensures proper direction checking:
should_send_nonces_to(peer_idx)checks OUTGOING pool- "Does this peer need nonces FROM ME?"
- Prevents over-sending to peers with full pools
- Prevents under-sending to peers with empty pools
The protocol is self-healing:
- If a peer has too few nonces, they request more
- No explicit acknowledgment needed
- Transient failures don't cause permanent state divergence
gid = SHA256(
group_pk[33 bytes] ||
threshold[4 bytes, little-endian] ||
member_pubkeys[33 bytes each, sorted by idx]
)
sid = SHA256(
gid[32 bytes] ||
members[4 bytes each, sorted] ||
hashes[variable, concatenated] ||
content[variable, or 0x00 if null] ||
type[variable, UTF-8] ||
stamp[4 bytes, little-endian]
)
Used for nonce binding:
bind_hash = SHA256(
sid[32 bytes] ||
idx[4 bytes, little-endian] ||
sighash[32 bytes]
)
All messages are validated using Zod schemas before processing:
Base Types:
const hex32 = z.string().regex(/^[0-9a-f]{64}$/) // 32-byte hex
const hex33 = z.string().regex(/^[0-9a-f]{66}$/) // 33-byte hex (compressed point)
const num = z.number().int().nonnegative()
const str = z.string()Key files: src/schema/*.ts
gidmatches computed group IDsidmatches computed session IDmembersincludes only valid member indicesmembers.length >= thresholdnoncesincludes entry for each member inmembers- Each nonce code exists in outgoing pool for requester
sidmatches request session IDpubkeyis a valid group memberpsigsincludes entry for each sighash in request- Each partial signature is cryptographically valid
gidmatches computed group IDmembersincludes only valid member indicesmembers.length >= threshold- All
ecdh_pksare valid secp256k1 points
membersmatches requestentriesincludes entry for each requested pubkey- All
keysharevalues are valid secp256k1 points
When a request is rejected, the handler emits an event with a reason:
| Reason | Meaning |
|---|---|
unauthorized |
Sender not in peer allow list |
invalid_session |
Session ID verification failed |
invalid_gid |
Group ID doesn't match |
unknown_member |
Member index not in group |
threshold_not_met |
Not enough members for threshold |
nonce_not_found |
Required nonce not in pool |
nonce_invalid |
Nonce verification failed |
signature_invalid |
Partial signature verification failed |
Rejections are not sent as explicit messages. Instead:
- Handler emits rejection event locally
- Request times out on sender side
- Sender receives timeout error
This prevents information leakage about rejection reasons to potentially malicious peers.
All timestamps are Unix timestamps (seconds since epoch).
Default timeout for requests is configurable. If responses aren't received within the timeout, the request fails.
Session IDs include timestamps. Receivers should reject sessions with:
- Timestamps too far in the past (stale)
- Timestamps in the future (clock skew)
Recommended tolerance: ±5 minutes.
| Message | Required Fields |
|---|---|
/sign/req |
gid, sid, members, hashes, nonces, stamp, type |
/sign/res |
idx, psigs, pubkey, sid |
/ecdh/req |
gid, members, ecdh_pks |
/ecdh/res |
idx, members, entries |
/ping/req |
(none, nonces optional) |
/ping/res |
(none, nonces optional) |
/echo/req |
challenge |
/echo/res |
challenge |
/onboard/req |
pubkey |
/onboard/res |
group |
The wire protocol uses implicit versioning through field presence:
- v1 (legacy): Static nonces in GroupPackage/SharePackage
- v2 (current): Dynamic nonces with MemberPublicNonce
The library automatically detects and handles both formats for backward compatibility.