feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+824
View File
@@ -0,0 +1,824 @@
NIP-AB
======
Device Pairing
--------------
`draft` `optional`
## Versions
This NIP is versioned to allow future algorithm upgrades without breaking existing implementations.
Currently defined versions:
| Version | Status | Description |
|---------|--------|-------------|
| `1` | Active | secp256k1 ECDH, HKDF-SHA256, SAS-6digit, NIP-44 v2 encryption |
The version is communicated in two places:
1. **QR URI**: `nostrpair://<pubkey>?secret=<hex>&relay=<url>&v=1`
- The `v` parameter defaults to `1` if absent (backward compatibility).
- _target_ MUST reject URIs with an unrecognized `v` value and display a human-readable error: "This QR code requires a newer version of [App]. Please update."
2. **Offer message**: the `offer` JSON MUST include a `version` field:
```jsonc
{
"type": "offer",
"version": 1,
"session_id": "<hex, 32 bytes>"
}
```
_source_ MUST reject offers with a `version` it does not support.
Implementations MUST NOT silently ignore an unrecognized version — they MUST surface an error to the user.
This NIP defines a protocol for securely transferring secrets between two devices over standard Nostr relays using QR-code-initiated, end-to-end encrypted channels with visual confirmation.
## Motivation
Users need their Nostr identity on multiple devices. Today the options are:
- Paste a raw `nsec` — insecure, no authentication, no encryption in transit
- Use [NIP-46](46.md) remote signing — requires the signer device to be online for every operation
- Enter a [NIP-06](06.md) mnemonic — manual, error-prone, not all clients support it
NIP-46 solves *ongoing delegation*: the key stays on one device and signs remotely. This NIP solves *one-time transfer*: the key moves to the new device, which then operates independently. They are complementary — this NIP can even bootstrap a NIP-46 session as one of its payload types.
This NIP provides a secure, authenticated channel between two devices that can carry any secret payload — a private key, a [NIP-46](46.md) session bootstrap, or application-specific data — without trusting the relay.
## Terminology
- **source**: The device that holds the secret and initiates pairing (e.g., a desktop app).
- **target**: The device that wants to receive the secret (e.g., a mobile phone).
- **pairing relay**: Any [NIP-01](01.md) compliant relay used to route pairing events. The relay learns nothing about the payload.
- **session secret**: A 32-byte random value shared via QR code, used to derive encryption keys.
- **SAS (Short Authentication String)**: A short code displayed on both devices for the user to visually confirm, preventing man-in-the-middle attacks.
## Overview
1. _source_ generates an ephemeral keypair and a session secret, encodes them in a QR code.
2. _target_ scans the QR code, generates its own ephemeral keypair.
3. Both devices connect to the pairing relay and exchange ephemeral public keys via `kind:24134` events.
4. Both devices derive a shared secret via ECDH and display a SAS code for the user to confirm.
5. After confirmation, _source_ sends the encrypted payload via a `kind:24134` event.
6. _target_ decrypts and imports the payload.
All events use ephemeral keypairs that are discarded after the session. The relay sees only opaque ciphertext addressed to throwaway public keys.
## Limitations
This NIP provides a secure one-time transfer channel. It does not provide:
- **No ongoing security**: once the payload is transferred, this NIP's security guarantees end. The transferred key's security depends entirely on the receiving device's storage and the user's operational security.
- **No key revocation**: there is no mechanism to invalidate a completed pairing. If the _target_ device is later compromised, the transferred key is compromised.
- **No multi-device coordination**: this NIP transfers a key to one device at a time. Managing keys across N devices requires N separate pairing sessions.
- **No relay confidentiality**: the pairing relay learns the timing and approximate frequency of pairing events, even though it cannot read the payload. For high-risk users, a private relay is recommended.
- **No post-quantum security**: the ECDH key exchange is vulnerable to a sufficiently powerful quantum computer. The NIP-44 encryption layer inherits the same limitation.
- **Physical presence assumption**: SAS verification requires the user to visually compare codes on two physical screens. An attacker with physical access to both devices simultaneously can bypass this check.
- **QR code window**: the session secret is exposed in the QR code for up to 120 seconds. Screen capture, shoulder surfing, or a compromised camera can expose it.
- **Single-use only**: this protocol is not designed for repeated or automated transfers. Each transfer requires a new QR scan and user confirmation.
For ongoing remote signing without key transfer, use [NIP-46](46.md) instead.
## QR Code Format
The _source_ generates:
- An ephemeral secp256k1 keypair (`source_ephemeral_privkey`, `source_ephemeral_pubkey`)
- A 32-byte cryptographically random `session_secret`
The QR code encodes a URI:
```
nostrpair://<source_ephemeral_pubkey_hex>?secret=<session_secret_hex>&relay=<wss://relay.example.com>&v=1
```
- `source_ephemeral_pubkey_hex`: 64-character lowercase hex-encoded 32-byte x-only public key (as used throughout Nostr per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki))
- `session_secret_hex`: 64-character lowercase hex-encoded 32 random bytes
- `relay`: percent-encoded WebSocket URL of the pairing relay. MUST appear at least once. MAY appear multiple times (see §Multi-Relay Considerations).
- `v`: protocol version integer (see §Versions). Defaults to `1` if absent.
The total URI length MUST NOT exceed 2048 characters. Reject any URI that exceeds this limit (prevents DoS via QR scanning).
Implementations MUST validate the QR URI before processing:
- `source_ephemeral_pubkey_hex` MUST be exactly 64 lowercase hex characters (32 bytes). Reject if not.
- `session_secret_hex` MUST be exactly 64 lowercase hex characters (32 bytes). Reject if not.
- `relay` MUST be a valid WebSocket URL beginning with `wss://` or `ws://`. Reject if not.
- Implementations MUST NOT process a `nostrpair://` URI that fails any of the above checks.
Both _source_ and _target_ connect to the relay specified in the QR URI. If the relay is unreachable, the session MUST be aborted. There is no relay discovery mechanism; the QR code is the authoritative relay list.
The QR code MUST NOT contain any private key material. If intercepted, an attacker obtains only an ephemeral public key and a session secret, which are useless without completing the handshake within the session timeout.
Clients MAY support additional query parameters for forward compatibility. Unknown parameters MUST be ignored.
## Event Kind
All pairing messages use a single event kind:
```
kind: 24134
```
This kind is in the ephemeral event range. Relays SHOULD treat these events as ephemeral and MAY delete them after delivery or after a short TTL (e.g., 5 minutes). Relays do not need any special handling for this kind — standard NIP-01 event routing is sufficient.
## Event Structure
All `kind:24134` events follow this structure:
```jsonc
{
"id": "<sha256 hash per NIP-01>",
"pubkey": "<sender's ephemeral pubkey>",
"kind": 24134,
"content": "<NIP-44 encrypted JSON>",
"tags": [["p", "<recipient's ephemeral pubkey>"]],
"created_at": <unix timestamp>,
"sig": "<schnorr signature per NIP-01>"
}
```
The `content` field is always encrypted using **NIP-44 version 2** (the `0x02` algorithm: secp256k1 ECDH, HKDF, padding, ChaCha20, HMAC-SHA256), as specified in [NIP-44](44.md). The conversation key is derived from the sender's ephemeral private key and the recipient's ephemeral public key. Implementations MUST use NIP-44 v2 and MUST reject events whose NIP-44 version byte is not `0x02`.
NIP-AB does not negotiate encryption versions. If a future NIP-44 version is required, this NIP will be updated with a new version indicator. Implementations MUST NOT silently fall back to an older NIP-44 version.
The encrypted plaintext is always a JSON object containing a `type` field that identifies the message:
```jsonc
{
"type": "<message_type>",
// ... type-specific fields
}
```
Message types are: `offer`, `sas-confirm`, `payload`, `complete`, `abort`.
There are no unencrypted type indicators in tags or other visible fields. The relay sees only the `p` tag (an ephemeral pubkey with no link to any real identity) and opaque ciphertext.
## Event Validation
Before processing any `kind:24134` event, implementations MUST:
1. Validate the event `id` and `sig` per [NIP-01](01.md).
2. Validate that `pubkey` is a valid, non-zero secp256k1 curve point per [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki).
3. Validate that the event contains a `p` tag whose value matches the local device's ephemeral public key. This guards against misdelivery by a malicious or buggy relay.
4. Validate that `pubkey` matches the expected peer for the current session state:
- _source_ expects events from `target_ephemeral_pubkey` (learned from the first valid `offer`).
- _target_ expects events from `source_ephemeral_pubkey` (learned from the QR code).
- Before the first valid `offer`, _source_ accepts events from any `pubkey` (since `target_ephemeral_pubkey` is not yet known), but MUST lock to that pubkey after accepting.
5. Decrypt `content` per [NIP-44](44.md). The `content` field MUST be a valid NIP-44 v2 payload (base64, 13287472 characters per NIP-44). Events with `content` outside this range MUST be silently discarded.
6. Parse the decrypted JSON and validate the `type` field against the expected message for the current state.
7. **Out-of-order messages**: A message whose `type` does not match the expected message for the current protocol state is considered out-of-order. Out-of-order messages MUST be silently discarded; the session state MUST NOT advance. Implementations MUST NOT send an `abort` in response to an out-of-order message, as doing so would allow a relay to probe session state.
The valid `type` for each state is:
| State | Role | Expected `type` |
|-------|------|-----------------|
| `Waiting` | Source | `offer` |
| `Confirming` | Source | *(awaiting user; no inbound expected)* |
| `Confirming` | Target | `sas-confirm` |
| `AwaitingConfirmation` | Target | `payload` *(buffer until user confirms SAS; do not process until state advances to `Transferring`)* |
| `Transferring` | Target | `payload` |
| `PayloadExchanged` | Source | `complete` |
`abort` is valid in any non-terminal state from a known peer (see §Abort). All other combinations are out-of-order and MUST be discarded.
Events that fail any validation step MUST be silently discarded. Implementations MUST NOT reveal validation failure details to the relay or to the sender.
### Duplicate Event Handling
Relays MAY deliver the same event more than once (e.g., on reconnect or when multiple relay connections are active). Implementations MUST handle duplicate delivery idempotently.
An event is a duplicate if its `id` matches an event already successfully processed in the current session. Implementations MUST track the `id` of each successfully processed event and MUST silently discard any event whose `id` has already been processed.
Implementations SHOULD maintain a per-session set of processed event IDs. This set need not persist beyond the session lifetime (120 seconds maximum).
A duplicate `offer` event (same `id`) received after the source has already accepted an offer MUST be discarded, not treated as a new session attempt. A duplicate `payload` event received after the target has already imported the payload MUST be discarded; the target MUST NOT re-import or re-send `complete`.
## Pairing Protocol
### Step 1: Source Subscribes
After displaying the QR code, _source_ subscribes to the pairing relay for events tagged to its ephemeral public key:
```json
["REQ", "<sub_id>", {"kinds": [24134], "#p": ["<source_ephemeral_pubkey>"]}]
```
### Step 2: Target Sends Offer
_target_ scans the QR code, generates its own ephemeral secp256k1 keypair (`target_ephemeral_privkey`, `target_ephemeral_pubkey`), and publishes an `offer` event:
```jsonc
{
"kind": 24134,
"pubkey": "<target_ephemeral_pubkey>",
"content": "<NIP-44 encrypted>",
"tags": [["p", "<source_ephemeral_pubkey>"]],
"created_at": <unix_timestamp>,
// id, sig per NIP-01
}
```
Encrypted plaintext:
```jsonc
{
"type": "offer",
"version": 1,
"session_id": "<hex, 32 bytes>"
}
```
Where `session_id` is derived as:
```
session_id = HKDF-SHA256(
IKM = session_secret, // 32 bytes from QR code
salt = "", // empty
info = "nostr-pair-session-id",
L = 32
)
```
The `session_id` proves the _target_ possesses the QR code's `session_secret` without revealing the secret on the wire.
_source_ MUST verify the `session_id` matches its own derivation. _source_ MUST accept at most one valid `offer` per session. After accepting an offer, _source_ MUST ignore all subsequent `offer` events and MUST record `target_ephemeral_pubkey` as the only valid peer for the remainder of the session.
### Step 3: SAS Verification
Both devices now have each other's ephemeral public keys. Both compute:
```
ecdh_shared = ECDH(own_ephemeral_privkey, other_ephemeral_pubkey)
```
Where `ecdh_shared` is the 32-byte x-coordinate of the shared point (unhashed), as produced by standard secp256k1 scalar multiplication.
Then:
```
sas_input = HKDF-SHA256(
IKM = ecdh_shared, // 32 bytes
salt = session_secret, // 32 bytes from QR code
info = "nostr-pair-sas-v1",
L = 32
)
sas_code = be_u32(sas_input[0..4]) mod 1000000
```
Where `be_u32(bytes)` interprets the first 4 bytes of `sas_input` as a big-endian unsigned 32-bit integer.
Both devices display the `sas_code` as a zero-padded 6-digit decimal string (e.g., `"047291"`). The user MUST visually confirm the codes match on both screens before proceeding.
**UX requirement**: The confirmation prompt MUST clearly state what is being authorized. Example: *"You are about to transfer your Nostr identity to another device. Does your other device show: **047291**?"* with prominent Confirm and Deny buttons. If the user denies the SAS on either device, that device MUST immediately send `abort` with reason `"user_denied"`, discard all session state, and terminate the session. SAS denial is the primary MITM defense — implementations MUST NOT allow the protocol to continue after a denial.
After the user confirms on the _source_ device, _source_ publishes a `sas-confirm` event:
```jsonc
{
"kind": 24134,
"pubkey": "<source_ephemeral_pubkey>",
"content": "<NIP-44 encrypted>",
"tags": [["p", "<target_ephemeral_pubkey>"]],
// ...
}
```
Encrypted plaintext:
```jsonc
{
"type": "sas-confirm",
"transcript_hash": "<hex, 32 bytes>"
}
```
Where `transcript_hash` binds the confirmation to the full session transcript:
```
transcript = session_id
|| source_ephemeral_pubkey // 32 bytes, x-coordinate
|| target_ephemeral_pubkey // 32 bytes, x-coordinate
|| sas_input // 32 bytes
transcript_hash = HKDF-SHA256(
IKM = transcript, // 128 bytes
salt = session_secret,
info = "nostr-pair-transcript-v1",
L = 32
)
```
_target_ MUST compute the same `transcript_hash` and verify it matches before proceeding. Implementations MUST use constant-time comparison when checking `transcript_hash` to prevent timing side-channels. A mismatch indicates session inconsistency or parameter tampering; _target_ MUST send `abort` with reason `"sas_mismatch"`, discard any payload received in this session, and terminate. Note: because _source_ sends the payload immediately after `sas-confirm` (without waiting for an acknowledgment), the payload may already be in transit or delivered when the mismatch is detected. The transcript hash is a **detection** mechanism, not a prevention gate — MITM prevention relies on the user's visual SAS comparison on the _source_ device *before* the source confirms and sends the payload.
After verifying the transcript hash, _target_ enters the `AwaitingConfirmation` state. _target_ transitions to `Transferring` when the user confirms the SAS on the target device. _target_ MUST NOT import, process, or act on the secret material within any received `payload` event until **both** the transcript hash has been verified **and** the user has confirmed the SAS on the target device. (Implementations may NIP-44-decrypt the event content to validate the message `type` for state-machine routing. However, implementations MUST NOT deserialize, extract, log, persist, or act on the `payload` field within a `payload`-type message until both conditions are met. If early decryption is used, the decrypted content MUST be treated as opaque for all purposes other than `type` classification, and MUST be zeroized if the session is aborted before dual consent. The safest implementation strategy — and the one closest to the formal proof — is to buffer the raw NIP-44 ciphertext and defer all decryption until after dual consent.)
### Step 4: Payload Transfer
After the user confirms the SAS on the _source_ device, _source_ publishes the `sas-confirm` event (Step 3) followed immediately by a `payload` event:
Encrypted plaintext:
```jsonc
{
"type": "payload",
"payload_type": "<string>",
"payload": "<string>"
}
```
Defined payload types:
| `payload_type` | Description | `payload` format |
|----------------|-------------|------------------|
| `nsec` | Private key transfer | [NIP-49](49.md) `ncryptsec1...` string (recommended) or `nsec1...` bech32 |
| `bunker` | NIP-46 signer-initiated session | `bunker://...` URI as defined in [NIP-46](46.md) |
| `connect` | NIP-46 client-initiated session | `nostrconnect://...` URI as defined in [NIP-46](46.md) |
| `custom` | Application-specific data | String (see §Custom Payloads) |
**Payload size limits**: The total serialized JSON plaintext of a `kind:24134` event's decrypted content MUST NOT exceed 65,535 bytes (the NIP-44 v2 plaintext limit). For `payload` messages, this means the `payload` field plus JSON envelope overhead (typically 5080 bytes depending on `payload_type` and JSON escaping) must fit within this limit. In practice, `payload` values up to 65,400 bytes are safe. Implementations MUST reject (silently discard) `payload` events where the decrypted plaintext JSON exceeds 65,535 bytes.
For the defined payload types (`nsec`, `bunker`, `connect`), payloads are expected to be well under 1,024 bytes. Implementations MAY enforce a stricter limit of 4,096 bytes for these types and SHOULD document any custom limit for `custom` payloads.
_Source_ implementations MUST NOT construct a `payload` event whose plaintext JSON exceeds 65,535 bytes; doing so will cause NIP-44 encryption to fail.
### Custom Payloads
The `custom` payload type carries application-defined data. The `payload` field MUST be a string. Applications that need to transfer structured data SHOULD encode it as JSON and then serialize the JSON object to a string (i.e., JSON-in-string, consistent with Nostr convention).
To prevent cross-application misinterpretation, applications using `custom` payloads SHOULD include an application identifier in the payload. The RECOMMENDED format is:
```jsonc
{
"type": "payload",
"payload_type": "custom",
"payload": "{\"app\":\"com.example.myapp\",\"version\":1,\"data\":\"...\"}"
}
```
The `app` field SHOULD use reverse-DNS notation to namespace the payload. Implementations that receive a `custom` payload with an unrecognized `app` value SHOULD surface this to the user rather than silently discarding it.
`custom` payloads are subject to the general 65,535-byte plaintext limit (65,400 bytes is a safe practical bound for the `payload` field). Applications SHOULD document their expected payload size. Applications with payloads larger than 4,096 bytes SHOULD consider whether NIP-AB is the appropriate transport — NIP-AB is designed for short secrets, not bulk data transfer.
NIP-AB does not provide a mechanism for _target_ to reject a `custom` payload based on its content. If _target_ does not understand the payload, it SHOULD send `complete` with `success: false` and inform the user.
For `nsec` payloads using [NIP-49](49.md) `ncryptsec` format, clients SHOULD set `KEY_SECURITY_BYTE = 0x02` (client does not track provenance) unless the client can positively assert the key has never been handled insecurely, in which case `0x01` MAY be used.
### Step 5: Completion
_target_ decrypts the payload, imports the secret into secure storage, and SHOULD publish a `complete` event:
```jsonc
{ "type": "complete", "success": true }
```
**`complete` is advisory, not required for security.** The payload transfer is complete when _target_ successfully decrypts and stores the payload. `complete` is a best-effort acknowledgment that allows _source_ to display a success confirmation to the user.
**If _target_ crashes or disconnects after importing but before sending `complete`**: The import has succeeded. _target_ MUST NOT re-request the payload. On next launch, _target_ SHOULD display a success state (the key is present in storage). _source_ will time out waiting for `complete` and MAY display an ambiguous state ("Transfer may have succeeded — check your other device").
**`success: false`**: _target_ SHOULD send `complete` with `success: false` if it successfully received and decrypted the payload but failed to import it into secure storage (e.g., keychain write failed). This allows _source_ to inform the user of a partial failure. _source_ MUST NOT retry sending the payload in response to `success: false` — the session is over. The user must initiate a new pairing.
**Source timeout for `complete`**: _source_ SHOULD wait up to 30 seconds for `complete` after sending `payload`. If `complete` is not received within this window, _source_ SHOULD display an ambiguous confirmation ("Transfer sent — verify on your other device") rather than an error. _source_ MUST NOT re-send `payload`.
_source_ MUST process at most one `complete` event per session. Subsequent `complete` events MUST be silently discarded.
Both devices MUST close their subscriptions and discard their ephemeral keypairs after either (a) receiving `complete`, (b) the per-step timeout expires, or (c) the session timeout (120 seconds) expires. Implementations MUST zero the ephemeral private keys, session secret, and any decrypted payload plaintext from memory before freeing. On the _target_ side, the decrypted payload MUST be zeroed from working memory once it has been committed to platform-secure storage.
### Implementation Pseudocode
The following Python-like pseudocode is normative. Implementations MUST produce identical outputs for identical inputs.
```python
# --- Key Derivation ---
def derive_session_id(session_secret: bytes) -> bytes:
# session_secret: 32 bytes from QR code
assert len(session_secret) == 32
return hkdf_sha256(IKM=session_secret, salt=b"", info=b"nostr-pair-session-id", L=32)
def derive_sas_input(ecdh_shared: bytes, session_secret: bytes) -> bytes:
# ecdh_shared: 32-byte x-coordinate of secp256k1 shared point (unhashed)
assert len(ecdh_shared) == 32
assert len(session_secret) == 32
return hkdf_sha256(IKM=ecdh_shared, salt=session_secret, info=b"nostr-pair-sas-v1", L=32)
def derive_sas_code(sas_input: bytes) -> str:
# Returns zero-padded 6-digit decimal string
n = int.from_bytes(sas_input[0:4], byteorder='big')
return str(n % 1_000_000).zfill(6)
def derive_transcript_hash(
session_id: bytes,
source_pubkey: bytes, # 32-byte x-coordinate
target_pubkey: bytes, # 32-byte x-coordinate
sas_input: bytes,
session_secret: bytes
) -> bytes:
assert all(len(x) == 32 for x in [session_id, source_pubkey, target_pubkey, sas_input, session_secret])
transcript = session_id + source_pubkey + target_pubkey + sas_input # 128 bytes
return hkdf_sha256(IKM=transcript, salt=session_secret, info=b"nostr-pair-transcript-v1", L=32)
# --- Message Encryption (wraps NIP-44) ---
def encrypt_message(msg: dict, sender_privkey: bytes, recipient_pubkey: bytes) -> str:
# msg: dict with "type" field and type-specific fields
plaintext = json_encode(msg) # UTF-8 JSON, no trailing whitespace
conversation_key = nip44_get_conversation_key(sender_privkey, recipient_pubkey)
nonce = secure_random_bytes(32)
return nip44_encrypt(plaintext, conversation_key, nonce)
def decrypt_message(ciphertext: str, recipient_privkey: bytes, sender_pubkey: bytes) -> dict:
conversation_key = nip44_get_conversation_key(recipient_privkey, sender_pubkey)
plaintext = nip44_decrypt(ciphertext, conversation_key)
return json_decode(plaintext)
# --- Usage example ---
# session_secret = secure_random_bytes(32)
# session_id = derive_session_id(session_secret)
# ecdh_shared = secp256k1_ecdh(own_privkey, peer_pubkey) # x-coordinate, unhashed
# sas_input = derive_sas_input(ecdh_shared, session_secret)
# sas_code = derive_sas_code(sas_input) # display to user, e.g. "047291"
# transcript_hash = derive_transcript_hash(session_id, source_pub, target_pub, sas_input, session_secret)
# --- Transcript Verification (target side) ---
# After receiving sas-confirm:
# expected = derive_transcript_hash(session_id, source_pub, target_pub, sas_input, session_secret)
# if not constant_time_equal(received_hash, expected):
# discard_buffered_payload() # payload may have arrived early
# send_abort(reason="sas_mismatch")
# raise TranscriptMismatchError
```
### Abort
Either device MAY send an `abort` message at any point during the protocol:
Encrypted plaintext:
```jsonc
{
"type": "abort",
"reason": "<string>"
}
```
Defined reason strings:
| `reason` | Meaning |
|----------|---------|
| `"sas_mismatch"` | SAS codes did not match, or transcript hash verification failed |
| `"user_denied"` | User explicitly denied the pairing |
| `"timeout"` | Session timed out |
| `"protocol_error"` | Local fatal condition (e.g., internal state corruption, unrecoverable implementation error). MUST NOT be sent in response to a peer's out-of-order or validation-failing event — those MUST be silently discarded per §Event Validation. |
Upon receiving an `abort`, the other device MUST terminate the session, discard ephemeral keys, and inform the user. Implementations MAY define additional reason strings; unknown reasons SHOULD be treated as `"protocol_error"`.
## Protocol Diagram
```
Source (Desktop) Relay Target (Phone)
──────────────── ───── ───────────────
Generate ephemeral keypair
Generate session_secret
Display QR code
Subscribe: kind:24134
#p: source_ephemeral_pubkey ──────►
Scan QR code
Generate ephemeral keypair
◄─────────────────────── Publish offer
{type:"offer", session_id}
◄──────────────────────────────────
Validate sig, pubkey, session_id
Accept offer, lock to this peer
Compute SAS code ◄─────────────────────────────────────────► Compute SAS code
Display: "047291" Display: "047291"
[User confirms SAS on source]
Publish sas-confirm ──────────────►
{type:"sas-confirm", ──────────────────────► Verify transcript_hash
transcript_hash}
Publish payload ──────────────────► (sent immediately;
{type:"payload", source does not wait
payload_type:"nsec", for target)
payload:"ncryptsec1..."} ──────────────────────► Buffer payload
[User confirms SAS on target]
Decrypt payload
Import to secure storage
◄─────────────────────── Publish complete
◄────────────────────────────────── {type:"complete"}
Discard ephemeral keys Discard ephemeral keys
Zero session_secret Zero session_secret
```
## Security Considerations
### Man-in-the-Middle Attacks
An attacker who intercepts the QR code (e.g., by photographing the screen or creating a fake QR code) could attempt to race the legitimate _target_ and establish their own session. The SAS verification step prevents this: the attacker's ECDH shared secret will differ from the legitimate pair, producing a different SAS code. The user will observe mismatched codes and abort.
This is the same defense used by Matrix (emoji verification), Bluetooth Secure Simple Pairing, and ZRTP. Signal's device linking omitted SAS verification and was subsequently exploited by state-level attackers who created fake QR codes to silently link unauthorized devices.
Clients MUST display an unambiguous confirmation prompt. The prompt MUST explicitly state what is being authorized and display the SAS code prominently with a clear option to deny.
### Relay Compromise
A compromised relay can:
- **Drop events** (denial of service) — mitigated by session timeout and retry with alternate relays
- **Delay events** — mitigated by session timeout
- **Attempt MITM** — defeated by SAS verification (relay does not possess ephemeral private keys)
A compromised relay **cannot**:
- Read the payload (NIP-44 encrypted with ECDH keys the relay does not possess)
- Forge events (events are signed by ephemeral keys; signatures are validated before processing)
- Correlate pairing sessions with real user identities (ephemeral keys are unlinked to real identities)
### QR Code Exposure
The QR code contains only an ephemeral public key and a session secret. If an attacker captures the QR code and races the legitimate _target_ to send the first `offer`, the _source_ will accept the attacker's offer and compute a SAS using the attacker's ephemeral key. However:
1. The _source_ displays a SAS code derived from the ECDH shared secret with the attacker.
2. The user's physical phone (the legitimate _target_) either (a) failed to connect (if the attacker's offer was accepted first) and shows an error, or (b) is not displaying any SAS code at all.
3. The user observes that their phone does not show the expected SAS code and denies the pairing on the _source_.
The defense is **user verification against their physical device**, not cryptographic impossibility. This is the same security model as Bluetooth Secure Simple Pairing and ZRTP: the SAS step converts a network-level MITM into a physical-presence requirement.
The _source_ MUST reject additional `offer` events after accepting one. If the legitimate _target_'s offer arrives after an attacker's, the _target_ will receive no response and SHOULD time out.
### Session Timeout
Implementations MUST enforce a session timeout (recommended: 120 seconds from QR display). After timeout, the _source_ MUST discard the ephemeral keypair and session secret. A new QR code MUST be generated for a new attempt.
### Key Material on Two Devices
After an `nsec` transfer, the private key exists on both devices. This is an inherent tradeoff of key transfer versus remote signing ([NIP-46](46.md)). Clients MUST store imported keys in platform-secure storage (iOS Keychain, Android Keystore, OS-level credential managers).
### Replay Protection
Session secrets are random and single-use. Ephemeral keypairs are generated per session. Two independent mechanisms prevent cross-session replay:
**1. `p` tag binding**: Every event carries a `p` tag containing the recipient's ephemeral public key. The recipient validates that this tag matches their own ephemeral public key (§Event Validation, step 3). A replayed event from session A has `p` = `source_A_ephemeral_pubkey`; session B's source has a different ephemeral key and will reject it at the `p` tag check, before any decryption is attempted.
**2. NIP-44 key binding**: Even if the `p` tag check were bypassed, NIP-44 decryption would fail. The conversation key is derived from `ECDH(own_ephemeral_privkey, sender_pubkey)`. A replayed event encrypted for session A's keypair cannot be decrypted by session B's keypair.
These two mechanisms are independent; either alone is sufficient to prevent cross-session replay. Together they provide defense in depth.
**Within-session replay**: The state machine provides within-session replay protection. Once a message type has been processed and the state has advanced, a replayed copy of the same message is out-of-order and MUST be discarded (§Event Validation, item 7). The duplicate event ID check (§Duplicate Event Handling) provides an additional layer.
### Metadata Privacy
All pairing events use ephemeral pubkeys that are unlinked to the user's real Nostr identity. The relay cannot determine which real user is pairing devices.
Implementations SHOULD set `created_at` to the current time minus a random value between 0 and 30 seconds. This provides metadata privacy (obscuring the exact time of each protocol step) while remaining within the timestamp acceptance window of all known relay implementations.
Implementations MUST NOT set `created_at` to a future time. Implementations MUST NOT set `created_at` more than 60 seconds in the past, as some relays enforce a `created_at_lower_limit` (per NIP-11) and may reject events with timestamps too far in the past.
If a relay rejects an event with an `invalid: event creation date` error (NIP-01 `OK` message), the implementation SHOULD retry with `created_at` set to the current time (no jitter). The privacy benefit of jitter is secondary to successful delivery.
## Design Rationale
### Why HKDF for `session_id` instead of a direct hash?
`session_id = HKDF(session_secret, ...)` rather than `SHA256(session_secret)` provides domain separation. Using HKDF with a labeled `info` string ensures that the `session_id` output is cryptographically independent from any other value derived from `session_secret` (e.g., `sas_input`). This prevents cross-protocol attacks where an attacker tricks one derivation path into producing a value valid for another.
### Why 6-digit decimal SAS?
6 decimal digits provide ~20 bits of entropy (10^6 = ~2^20). An attacker who can race the legitimate target has a 1-in-1,000,000 chance of a matching SAS per attempt. The session timeout (120 seconds) and single-offer acceptance limit make brute force infeasible. Decimal was chosen over emoji (Matrix) for cross-client compatibility — emoji sets vary by platform and font, causing display inconsistencies. Decimal was chosen over 4-digit (Bluetooth) because 4 digits (1-in-10,000) is considered insufficient against targeted attacks.
### Why `session_secret` in the QR code instead of deriving it from the ephemeral keypair?
The `session_secret` is independent of the ephemeral keypair. This means that even if an attacker somehow learns the ephemeral private key (e.g., via a side-channel), they cannot compute the `session_id` or `sas_input` without also knowing `session_secret`. The QR code is a separate out-of-band channel; requiring knowledge of both the QR code AND the ECDH handshake provides defense-in-depth for session establishment (offer authentication and SAS derivation). Note: the payload encryption key is derived purely from ECDH and does not depend on `session_secret`, so this defense-in-depth applies to the pairing handshake, not to payload confidentiality directly.
### Why transcript binding (`transcript_hash`)?
The `transcript_hash` in `sas-confirm` commits the source to the exact session parameters: the `session_id`, both ephemeral public keys, and the `sas_input`. This gives the _target_ a cryptographic consistency check that detects session inconsistency or parameter tampering. (Cross-session replay is already prevented independently by `p`-tag binding and NIP-44 key binding — see §Replay Protection.) The transcript hash is **not** the MITM prevention mechanism — that role belongs to the user's visual SAS comparison on the _source_ device, which gates whether `sas-confirm` and the payload are sent at all.
### Why NIP-44 for event encryption instead of a custom scheme?
NIP-44 is the Nostr standard for authenticated encryption. Using it here means NIP-AB inherits NIP-44's security audit, test vectors, and broad implementation support. A custom scheme would require separate review and implementation work in every client.
### Audit
An independent security audit of this protocol is planned. Until an audit is completed, implementations in high-security contexts should treat this NIP as `draft` and conduct their own review.
## Formal Verification
A Tamarin model of the protocol lives at [NIP-AB.spthy](NIP-AB.spthy). The model focuses on the security-critical core of the protocol:
- QR distribution of `session_secret` and `source_ephemeral_pubkey`
- `offer` authentication via possession of the QR secret
- SAS comparison as an explicit user-mediated gate
- `sas-confirm` transcript binding
- encrypted `payload` delivery
- advisory `complete` acknowledgment
The model treats the relay and network as a full **Dolev-Yao attacker**: the adversary can intercept, reorder, replay, drop, and fabricate messages. It also includes explicit compromise rules for:
- QR-code exposure (`session_secret` leaks out-of-band)
- source-session compromise
- target-session compromise
Under those assumptions, the proved lemmas are:
**Core security invariants:**
- **`executable_core_flow`** *(executability)*: the happy-path protocol completes — both sides reach `complete` with the same session and payload.
- **`payload_requires_successful_sas_match`** *(SAS gate)*: an honest source can only send `payload` after a successful SAS match.
- **`payload_secrecy_without_endpoint_compromise`** *(payload secrecy)*: the payload remains unknown to the attacker unless one endpoint session is compromised. QR-code exposure alone does not break secrecy, because the SAS gate pins delivery to an honest target-role execution in the model. (This assumes correct SAS verification — the model treats SAS comparison as perfect; the ~20-bit collision bound is a separate computational argument, see §Design Rationale.)
- **`target_completion_agrees_on_source_payload`** *(target agreement)*: under no-compromise assumptions, if the target completes, then the source previously sent that exact payload in the same session.
- **`source_completion_implies_prior_target_completion_without_compromise`** *(source completion soundness)*: under the same no-compromise assumptions, if the source accepts `complete`, the target previously sent `complete` for the same session. (The model abstracts away `success:true/false` semantics — this proves the `complete` event is authentic, not that import succeeded.)
- **`injective_target_source_agreement`** *(injective agreement, target → source)*: each target completion corresponds to a unique prior source payload send with the same `(sid, pkS, pkT, payload)`, and that send is itself unique. This is a one-directional injective mapping; the reverse (every send leads to a completion) is a liveness property not provable under Dolev-Yao scheduling.
**MITM resistance:**
- **`sas_match_implies_genuine_target`**: every SAS match is bound to a `pkT` that an honest target-role instance in the model actually generated (i.e., from `Target_Scan_QR_And_Send_Offer` with a fresh ephemeral). A network adversary that substitutes the offer's ephemeral key with an attacker-chosen value cannot cause the SAS-match rule to fire. This proves resistance to network key-substitution, not physical-device authenticity — the latter relies on the user's physical verification of the SAS code and is outside the symbolic model's scope.
- **`payload_delivery_requires_genuine_target`** *(composition)*: no payload is ever sent under a `pkT` that lacks a prior honest target-role execution. Follows from the SAS gate combined with the genuine-target lemma.
**Dual consent and payload buffering:**
- **`target_decrypts_payload_only_after_dual_consent`**: the target never decrypts the payload without **both** transcript verification **and** an explicit user-approval step. The model proves a stronger abstraction than the spec requires: payload plaintext is not made available to protocol logic before both conditions are met. (The spec permits implementations to NIP-44-decrypt the event content early for message-type classification, but the model conservatively defers all decryption — this is strictly stronger. Early type-field decryption on the target is a local operation that does not emit network-observable messages or alter protocol flow; since the Dolev-Yao attacker already possesses the ciphertext, local decryption reveals nothing new to the adversary, and all proved properties (secrecy, agreement, MITM resistance) hold a fortiori for the spec's more permissive buffering model.)
- **`decryption_requires_prior_buffering`**: every decryption is preceded by buffering — the intended two-phase flow (buffer ciphertext, then decrypt after approval) is explicit in the proof surface.
- **`executable_payload_buffered_before_approval`** *(sanity)*: the payload **can** arrive and be buffered before the target user approves, proving the buffering path is reachable and the dual-consent gate is not vacuously enforced by message ordering alone.
**Reachability and anti-vacuousness:**
- **`executable_with_qr_leak`**, **`executable_with_source_compromise`**, **`executable_with_target_compromise`**: each compromise rule is reachable from protocol state (i.e., the compromise rules are not dead code), so the no-compromise guards in the secrecy and agreement lemmas are non-trivial.
- **`source_compromise_can_leak_payload`**, **`target_compromise_can_leak_payload`**: there exist traces where endpoint compromise (leakage of session-ephemeral private keys) leads to attacker knowledge of the payload, confirming that the no-compromise guards in the secrecy lemma are load-bearing.
The Tamarin model intentionally abstracts away details that are orthogonal to the cryptographic proof:
- exact NIP-01 event IDs / Schnorr signatures — relay anti-forgery relies on these but is not proved symbolically
- exact NIP-44 ciphertext framing, padding, version bytes, and nonce handling — modeled as ideal authenticated encryption (`senc`) over a DH-derived key
- HKDF-SHA256 — collapsed to tagged hashes (`h(< label, inputs >)`) preserving domain separation but not RFC 5869 internals
- ECDH — modeled as symbolic Diffie-Hellman, not exact secp256k1 x-coordinate extraction
- SAS comparison — modeled as perfect (requiring actual key agreement); the ~20-bit collision bound (1/10^6) is a separate computational argument (see §Design Rationale)
- timeout and abort branches
- duplicate-event bookkeeping
- `p`-tag validation and within-session replay protection — these are state-machine / implementation requirements, not Tamarin results
- version negotiation (`version` field in `offer`)
- `complete` success/failure semantics
- payload typing (`nsec` / `bunker` / `connect` / `custom`)
Those behaviors remain normative in this document and in the Rust implementation; they are simply not the focus of the symbolic proof.
Run the proof with:
```bash
tamarin-prover --prove crates/buzz-core/src/pairing/NIP-AB.spthy
```
## Cryptographic Primitives
### ECDH
`secp256k1_ecdh(priv, pub)` is scalar multiplication of point `pub` by scalar `priv`, as defined in [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). The result is the shared point `P`; this function returns the 32-byte x-coordinate of `P` using BIP-340's `bytes(P)` encoding. The result is **not hashed**.
⚠️ **Implementation warning**: many secp256k1 libraries (including some bindings to libsecp256k1) hash the ECDH output with SHA-256 by default. This NIP requires the **unhashed** x-coordinate. Verify your library's behavior before shipping.
Private keys MUST be validated as scalars in range `[1, secp256k1_order - 1]`. Public keys MUST be validated as valid, non-zero curve points per BIP-340.
### HKDF-SHA256
[RFC 5869](https://datatracker.ietf.org/doc/html/rfc5869) with SHA-256.
- **Extract**: `PRK = HMAC-SHA256(salt, IKM)`. When `salt` is specified as `""` (empty string), use a zero-length byte array (not the string literal).
- **Expand**: `OKM = HKDF-Expand(PRK, info, L)` where `info` is the UTF-8 encoding of the specified string and `L` is the output length in bytes.
### Operators and Notation
- `||` denotes byte array concatenation with no length prefixes or delimiters.
- `x[i:j]` where `x` is a byte array returns bytes `i` (inclusive) through `j` (exclusive).
- `be_u32(x)` interprets the first 4 bytes of `x` as a big-endian unsigned 32-bit integer.
### Constants
| Name | Value | Description |
|------|-------|-------------|
| `SESSION_TIMEOUT` | 120 seconds | Maximum time from QR display to session completion |
| `STEP_TIMEOUT` | 30 seconds | Maximum time to wait for each protocol step |
| `SAS_DIGITS` | 6 | Number of decimal digits in SAS code |
| `SAS_MODULUS` | 1,000,000 | `10^SAS_DIGITS` |
| `SESSION_SECRET_LEN` | 32 bytes | Length of session secret |
| `MAX_URI_LEN` | 2048 characters | Maximum total length of the `nostrpair://` URI |
| `MAX_PAYLOAD_LEN` | 65,400 bytes | Safe practical maximum for the `payload` field (65,535-byte NIP-44 limit minus JSON envelope overhead) |
## Test Vectors
```
session_secret (hex):
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2
source_ephemeral_privkey (hex):
7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5
source_ephemeral_pubkey (hex):
199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a
target_ephemeral_privkey (hex):
3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b
target_ephemeral_pubkey (hex):
89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4
session_id = HKDF-SHA256(IKM=session_secret, salt="", info="nostr-pair-session-id", L=32):
fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380
ecdh_shared = ECDH(source_priv, target_pub) x-coordinate:
9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408
sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32):
e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3
sas_code = be_u32(sas_input[0..4]) mod 1000000:
863346
transcript = session_id || source_pubkey || target_pubkey || sas_input (128 bytes)
transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret, info="nostr-pair-transcript-v1", L=32):
d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3
```
Implementations MUST validate against these vectors. They can be reproduced with `buzz-pair test-vectors`.
A future external vector file (`nip-ab.vectors.json`) with a sha256 checksum committed in this document is planned. When published, it will include categorized intermediate-value vectors for each derivation step and negative/invalid test cases. The sha256 checksum will be the canonical commitment; implementations MUST verify against the checksum before using the file.
Implementations MUST also test rejection of invalid inputs. Examples of what to test:
- `session_secret` with wrong length (< 32 or > 32 bytes) → MUST be rejected
- `session_secret` that is all zeros → MUST be rejected
- `offer` with `session_id` that does not match the derived value → MUST be silently discarded
- `sas-confirm` with a mismatched `transcript_hash` → MUST trigger `abort` with reason `"sas_mismatch"`
- NIP-44 ciphertext with version byte ≠ `0x02` → MUST be silently discarded
- `content` field outside the 13287472 character range → MUST be silently discarded
- decrypted plaintext JSON exceeding 65,535 bytes → MUST be silently discarded
- Duplicate event `id` within a session → MUST be silently discarded
## Implementation Notes
### Choosing a Pairing Relay
The _source_ encodes the relay URL in the QR code. Implementations MAY:
- Use the user's preferred relay from [NIP-65](65.md)
- Use a hardcoded default relay
- Allow the user to choose
The protocol is secure regardless of relay trustworthiness. For additional metadata privacy, a relay that supports [NIP-42](42.md) AUTH is preferred but not required.
### SAS Display
Implementations MUST display the SAS code as a zero-padded 6-digit decimal number (e.g., `047291`). Implementations MAY additionally display an emoji representation for improved usability, but the 6-digit decimal MUST always be shown as the canonical representation to ensure cross-client compatibility.
### Secure Storage
After importing a key, clients MUST store it in platform-secure storage:
- **iOS**: Keychain Services with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`
- **Android**: Android Keystore or EncryptedSharedPreferences
- **Desktop**: OS credential manager or encrypted keyring
### Error Handling
If _source_ receives an `offer` with an invalid `session_id`, it MUST silently ignore it and continue waiting for a valid offer (up to the session timeout).
If either device receives an event with an unexpected `type` for the current state, it MUST silently discard it (see §Event Validation, item 7 — out-of-order messages). Implementations MUST NOT send `abort` in response to an out-of-order message.
If either device does not receive the expected next message within a reasonable time (recommended: 30 seconds per step), it SHOULD send an `abort` with reason `"timeout"` and terminate the session.
### Concurrent Sessions
**Source**: A _source_ implementation MAY run multiple pairing sessions simultaneously. Each session MUST use a distinct ephemeral keypair and session secret, and therefore a distinct QR code. Sessions are fully independent — an event addressed to one session's ephemeral pubkey cannot affect another session. Implementations SHOULD limit the number of concurrent active sessions to a small number (recommended: 3) to prevent resource exhaustion.
**Target**: A _target_ implementation MAY scan multiple QR codes and run multiple pairing sessions simultaneously. Each session is independent. However, importing the same payload type (e.g., `nsec`) from two concurrent sessions is application-defined behavior; implementations SHOULD prompt the user to confirm each import individually.
**Session isolation**: Because each session uses independent ephemeral keypairs, there is no cryptographic interaction between concurrent sessions. A compromised or malicious session cannot affect the security of other sessions.
**UX recommendation**: Implementations SHOULD display each active session distinctly (e.g., by SAS code) so the user can match the correct QR code to the correct device.
## Multi-Relay Considerations
The QR URI format supports multiple `relay` parameters for redundancy. Multi-relay support is OPTIONAL — implementations that use a single relay are fully conformant. The guidance below is for implementations that choose to support multiple relays.
**Recommended relay count**: 13 relay URLs. More than 3 increases QR code size and connection overhead without proportional benefit.
**Source behavior**: _source_ SHOULD subscribe to **all** listed relays simultaneously. This ensures _target_ can reach _source_ regardless of which relay _target_ connects to first. Subscribing to all relays has no privacy cost since all events use ephemeral pubkeys.
**Target behavior**: _target_ SHOULD attempt to connect to listed relays in parallel and use the first relay that both (a) accepts the WebSocket connection and (b) successfully delivers the subscription (confirmed by receiving an `EOSE` or the first event). If a relay connection fails after the session is underway, _target_ MAY attempt the next relay in the list; however, _target_ MUST NOT construct a new `offer` event. If _target_ needs to reach _source_ via a different relay, _target_ SHOULD re-publish the **same signed `offer` event** (identical bytes, same event ID) to the new relay. This is safe because the event is already signed and addressed to `source_ephemeral_pubkey`; _source_ will deduplicate by event ID if it receives the offer on multiple relays.
**Cross-relay delivery**: Because _source_ subscribes to all listed relays, events published by _target_ to any listed relay will be received by _source_. The protocol is relay-agnostic: _source_ and _target_ do not need to be connected to the same relay simultaneously.
**Fallback**: If all listed relays fail, the session MUST be aborted. There is no relay discovery mechanism; the QR code is the authoritative relay list.
## Relation to Other NIPs
- [NIP-01](01.md): All pairing events are valid NIP-01 events.
- [NIP-44](44.md): Used for all encryption within pairing events.
- [NIP-46](46.md): This NIP can bootstrap a NIP-46 session via the `bunker` or `connect` payload types. NIP-46 provides ongoing remote signing; this NIP provides one-time secure transfer. They are complementary.
- [NIP-49](49.md): Recommended format for `nsec` payloads.
- [NIP-59](59.md): Gift Wrap uses ephemeral keys for metadata privacy; this NIP uses ephemeral keys for session isolation. Both demonstrate the pattern of throwaway Nostr identities for protocol-level operations.
+455
View File
@@ -0,0 +1,455 @@
theory NIP_AB
begin
builtins: diffie-hellman, hashing, symmetric-encryption
rule Source_Start:
[ Fr(~qr), Fr(~xs) ]
--[
SourceStarted(h(< 'session-id', ~qr >), 'g'^~xs)
]->
[
SrcWaiting(~qr, ~xs),
!QrVisible(~qr, 'g'^~xs),
!SourceSecrets(~qr, ~xs)
]
rule Leak_QR:
[ !QrVisible(qr, pkS) ]
--[
QrLeaked(h(< 'session-id', qr >), pkS)
]->
[ Out(< qr, pkS >) ]
rule Compromise_Source_Session:
[ !SourceSecrets(qr, xs) ]
--[
SourceCompromised(h(< 'session-id', qr >), 'g'^xs)
]->
[ Out(< qr, xs >) ]
rule Target_Scan_QR_And_Send_Offer:
[ !QrVisible(qr, pkS), Fr(~xt) ]
--[
TargetStarted(h(< 'session-id', qr >), pkS, 'g'^~xt)
]->
[
TgtOfferSent(qr, pkS, ~xt, 'g'^~xt),
!TargetSecrets(qr, pkS, ~xt),
Out(
<
'offer_evt',
'g'^~xt,
senc(
< 'offer', h(< 'session-id', qr >) >,
h(< 'pair-key', pkS^~xt >)
)
>
)
]
rule Compromise_Target_Session:
[ !TargetSecrets(qr, pkS, xt) ]
--[
TargetCompromised(h(< 'session-id', qr >), pkS, 'g'^xt)
]->
[ Out(< qr, xt >) ]
rule Source_Accepts_Offer:
[ SrcWaiting(qr, xs),
In(
<
'offer_evt',
pkT,
senc(
< 'offer', h(< 'session-id', qr >) >,
h(< 'pair-key', pkT^xs >)
)
>
)
]
--[
SourceAcceptedOffer(h(< 'session-id', qr >), 'g'^xs, pkT)
]->
[
SrcSasReady(qr, xs, pkT)
]
// SAS comparison is modeled as perfect: the rule requires both devices'
// state facts with matching cryptographic material, so it only fires when
// the ECDH shared secret (and therefore the SAS code) genuinely agrees.
// In reality SAS provides ~20 bits of entropy (1/10^6 collision); that
// computational bound is argued separately in §Design Rationale.
rule User_Compares_Matching_SAS:
[ SrcSasReady(qr, xs, pkT),
TgtOfferSent(qr, 'g'^xs, xt, pkT)
]
--[
SasMatched(
h(< 'session-id', qr >),
'g'^xs,
pkT,
h(< 'sas', pkT^xs, qr >)
)
]->
[
SrcUserConfirmed(qr, xs, pkT),
TgtAwaitingSasConfirm(qr, 'g'^xs, xt)
]
// Transcript hash matches spec §Step 3 (see also PR #346 clarifications):
// transcript_hash = HKDF(IKM = session_id || pkS || pkT || sas_input,
// salt = session_secret, info = "nostr-pair-transcript-v1")
// Symbolically we collapse HKDF to h(.) and rely on collision resistance;
// qr (session_secret) is already committed via session_id and sas_input, so
// we do not include it again here.
//
// Per §Step 3, the transcript hash is a detection mechanism for session
// inconsistency, not the MITM prevention gate (that role belongs to the
// user's SAS comparison, modeled by User_Compares_Matching_SAS above).
rule Source_Sends_SAS_Confirm:
[ SrcUserConfirmed(qr, xs, pkT) ]
--[
SourceSentSasConfirm(h(< 'session-id', qr >), 'g'^xs, pkT)
]->
[
SrcReadyPayload(qr, xs, pkT),
Out(
<
'sas_confirm_evt',
senc(
<
'sas-confirm',
h(
<
'transcript',
h(< 'session-id', qr >),
'g'^xs,
pkT,
h(< 'sas', pkT^xs, qr >)
>
)
>,
h(< 'pair-key', pkT^xs >)
)
>
)
]
rule Target_Receives_SAS_Confirm:
[ TgtAwaitingSasConfirm(qr, pkS, xt),
In(
<
'sas_confirm_evt',
senc(
<
'sas-confirm',
h(
<
'transcript',
h(< 'session-id', qr >),
pkS,
'g'^xt,
h(< 'sas', pkS^xt, qr >)
>
)
>,
h(< 'pair-key', pkS^xt >)
)
>
)
]
--[
TargetVerifiedTranscript(h(< 'session-id', qr >), pkS, 'g'^xt)
]->
[
TgtAwaitingUserApproval(qr, pkS, xt),
TgtCanBuffer(qr, pkS, xt)
]
// Target user approval: AwaitingConfirmation -> Transferring (spec §Step 3).
// This fires only after transcript verification (Target_Receives_SAS_Confirm).
rule Target_User_Approves_After_Transcript:
[ TgtAwaitingUserApproval(qr, pkS, xt) ]
--[
TargetUserApproved(h(< 'session-id', qr >), pkS, 'g'^xt)
]->
[
TgtTransferring(qr, pkS, xt)
]
rule Source_Sends_Payload:
[ SrcReadyPayload(qr, xs, pkT), Fr(~payload) ]
--[
SourceSentPayload(h(< 'session-id', qr >), 'g'^xs, pkT, ~payload),
PayloadMarkedSecret(h(< 'session-id', qr >), ~payload)
]->
[
SrcAwaitingComplete(qr, xs, pkT, ~payload),
Out(
<
'payload_evt',
senc(
< 'payload', ~payload >,
h(< 'pair-key', pkT^xs >)
)
>
)
]
// --- Payload buffering (spec §Event Validation, §Step 3-4) ---
//
// Per #346, the source sends payload immediately after sas-confirm without
// waiting for the target. The target may therefore receive the encrypted
// payload while still in AwaitingConfirmation (before user approval).
// The spec requires: buffer the ciphertext, do NOT decrypt or import until
// both transcript_hash is verified AND the user confirms SAS on the target.
//
// We model this as two rules:
// 1. Target_Buffers_Payload — receives ciphertext into a holding fact
// WITHOUT extracting the plaintext. The rule validates that the
// ciphertext is encrypted under the session's DH-derived key
// (h(< 'pair-key', pkS^xt >)), matching the spec's requirement that
// invalid events are silently discarded without advancing state.
// Only fires after transcript verification (linear TgtCanBuffer),
// matching the spec's state table where `payload` is valid only in
// AwaitingConfirmation or Transferring (both post-transcript-verify).
// The linear fact is consumed, so at most one payload can be buffered
// per session — matching the spec's single-payload semantics.
// 2. Target_Decrypts_Payload — pattern-matches senc() to extract plaintext.
// Requires both TgtTransferring (post-approval) and the buffered
// ciphertext. This is the dual-consent gate: decryption only happens
// after transcript verification + user approval.
rule Target_Buffers_Payload:
[ TgtCanBuffer(qr, pkS, xt),
In(< 'payload_evt', senc(msg, h(< 'pair-key', pkS^xt >)) >)
]
--[
TargetBufferedPayload(h(< 'session-id', qr >), pkS, 'g'^xt)
]->
[
TgtPayloadBuffer(qr, pkS, xt, senc(msg, h(< 'pair-key', pkS^xt >)))
]
// Target decrypts the payload only after entering Transferring state
// (transcript verified + user approved). This is the dual-consent gate:
// the senc() pattern match here is the symbolic decryption operation.
rule Target_Decrypts_Payload:
[ TgtTransferring(qr, pkS, xt),
TgtPayloadBuffer(qr, pkS, xt,
senc(
< 'payload', payload >,
h(< 'pair-key', pkS^xt >)
)
)
]
--[
TargetDecryptedPayload(h(< 'session-id', qr >), pkS, 'g'^xt, payload)
]->
[
TgtHasPayload(qr, pkS, xt, payload)
]
rule Target_Sends_Complete:
[ TgtHasPayload(qr, pkS, xt, payload) ]
--[
TargetCompleted(h(< 'session-id', qr >), pkS, 'g'^xt, payload)
]->
[
TgtDone(qr, pkS, xt, payload),
Out(
<
'complete_evt',
senc('complete', h(< 'pair-key', pkS^xt >))
>
)
]
rule Source_Receives_Complete:
[ SrcAwaitingComplete(qr, xs, pkT, payload),
In(
<
'complete_evt',
senc('complete', h(< 'pair-key', pkT^xs >))
>
)
]
--[
SourceCompleted(h(< 'session-id', qr >), 'g'^xs, pkT, payload)
]->
[
SrcDone(qr, xs, pkT, payload)
]
// ============================================================================
// Core security lemmas (invariants)
// ============================================================================
// Happy path: both sides complete with the same session and payload.
lemma executable_core_flow:
exists-trace
"Ex sid pkS pkT payload #i #j.
TargetCompleted(sid, pkS, pkT, payload) @ i
& SourceCompleted(sid, pkS, pkT, payload) @ j"
// SAS gate: source never sends the payload without a prior SAS match.
lemma payload_requires_successful_sas_match:
"All sid pkS pkT payload #i.
SourceSentPayload(sid, pkS, pkT, payload) @ i
==> (Ex sas #j.
SasMatched(sid, pkS, pkT, sas) @ j
& #j < #i)"
// Payload secrecy: without endpoint compromise, the payload is secret.
// Note: this holds even under QR-code leak. The SAS gate prevents a MITM
// from causing the source to send the payload under the attacker's key,
// because the SAS rule only fires when the source's accepted pkT matches
// a genuine fresh target ephemeral (see sas_match_implies_genuine_target).
lemma payload_secrecy_without_endpoint_compromise:
"All sid payload #i.
PayloadMarkedSecret(sid, payload) @ i
& not (Ex pkS #r. SourceCompromised(sid, pkS) @ r)
& not (Ex pkS pkT #r. TargetCompromised(sid, pkS, pkT) @ r)
==> not (Ex #j. K(payload) @ j)"
// Target-side agreement: if the target completes, the source genuinely
// sent that exact payload under this session.
lemma target_completion_agrees_on_source_payload:
"All sid pkS pkT payload #i.
TargetCompleted(sid, pkS, pkT, payload) @ i
& not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r)
& not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r)
==> (Ex #j.
SourceSentPayload(sid, pkS, pkT, payload) @ j
& #j < #i)"
// Source-side completion soundness: if the source sees `complete`, the
// target really completed this session.
lemma source_completion_implies_prior_target_completion_without_compromise:
"All sid pkS pkT payload #i.
SourceCompleted(sid, pkS, pkT, payload) @ i
& not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r)
& not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r)
==> (Ex #j.
TargetCompleted(sid, pkS, pkT, payload) @ j
& #j < #i)"
// Injective agreement (target → source): each target completion corresponds
// to a unique source payload send, and that send is itself unique. This is
// one-directional; the reverse (every send leads to a completion) is a
// liveness property not provable under Dolev-Yao scheduling.
lemma injective_target_source_agreement:
"All sid pkS pkT payload #i.
TargetCompleted(sid, pkS, pkT, payload) @ i
& not (Ex pkS2 #r. SourceCompromised(sid, pkS2) @ r)
& not (Ex pkS2 pkT2 #r. TargetCompromised(sid, pkS2, pkT2) @ r)
==> (Ex #j.
SourceSentPayload(sid, pkS, pkT, payload) @ j
& #j < #i
& not (Ex #i2.
TargetCompleted(sid, pkS, pkT, payload) @ i2
& not (#i2 = #i))
& not (Ex #j2.
SourceSentPayload(sid, pkS, pkT, payload) @ j2
& not (#j2 = #j)))"
// ============================================================================
// MITM-resistance test cases
// ============================================================================
// "MITM does not work": any SAS match pins the source's view of pkT to an
// actual target-generated ephemeral ('g'^~xt from Target_Scan_QR_And_Send_Offer).
// A network adversary who substitutes the offer's pkT with an attacker-chosen
// value can never make this lemma's conclusion hold, because the fresh ~xt
// in TargetStarted is outside attacker knowledge.
lemma sas_match_implies_genuine_target:
"All sid pkS pkT sas #i.
SasMatched(sid, pkS, pkT, sas) @ i
==> (Ex #j.
TargetStarted(sid, pkS, pkT) @ j
& #j < #i)"
// Composition: no payload is ever sent under a pkT the real target did not
// produce. Follows from payload_requires_successful_sas_match combined with
// sas_match_implies_genuine_target, and is the explicit no-MITM guarantee.
lemma payload_delivery_requires_genuine_target:
"All sid pkS pkT payload #i.
SourceSentPayload(sid, pkS, pkT, payload) @ i
==> (Ex #j.
TargetStarted(sid, pkS, pkT) @ j
& #j < #i)"
// Dual-consent gate (spec §Step 3, PR #346): the target never processes
// (decrypts/imports) a payload without BOTH transcript verification AND
// an explicit user-approval step. The payload may arrive and be buffered
// earlier (see Target_Buffers_Payload), but processing is gated.
lemma target_decrypts_payload_only_after_dual_consent:
"All sid pkS pkT payload #i.
TargetDecryptedPayload(sid, pkS, pkT, payload) @ i
==> (Ex #j #k.
TargetVerifiedTranscript(sid, pkS, pkT) @ j
& TargetUserApproved(sid, pkS, pkT) @ k
& #j < #i
& #k < #i)"
// ============================================================================
// Reachability / sanity test cases
// ============================================================================
//
// These exists-trace lemmas prove that the compromise model is meaningful
// (each compromise rule is actually reachable within a valid protocol run)
// and that compromise genuinely breaks payload confidentiality. Without
// these, a trivially unreachable compromise rule would make the no-compromise
// secrecy claims vacuous.
lemma executable_with_qr_leak:
exists-trace
"Ex sid pkS #r. QrLeaked(sid, pkS) @ r"
lemma executable_with_source_compromise:
exists-trace
"Ex sid pkS #r. SourceCompromised(sid, pkS) @ r"
lemma executable_with_target_compromise:
exists-trace
"Ex sid pkS pkT #r. TargetCompromised(sid, pkS, pkT) @ r"
// Buffer-then-decrypt sequencing: every decryption is preceded by buffering.
// Makes the intended two-phase flow explicit in the proof surface.
lemma decryption_requires_prior_buffering:
"All sid pkS pkT payload #i.
TargetDecryptedPayload(sid, pkS, pkT, payload) @ i
==> (Ex #j.
TargetBufferedPayload(sid, pkS, pkT) @ j
& #j < #i)"
// Sanity: the payload CAN arrive (be buffered) before the target user
// approves. This proves the buffering path is reachable and the dual-consent
// gate is not vacuously enforced by message ordering alone.
lemma executable_payload_buffered_before_approval:
exists-trace
"Ex sid pkS pkT #i #j.
TargetBufferedPayload(sid, pkS, pkT) @ i
& TargetUserApproved(sid, pkS, pkT) @ j
& #i < #j"
// Source-side compromise: an attacker who learns xs can decrypt the payload.
// Counter-example to a naive "secrecy always holds" claim; justifies the
// `not SourceCompromised` guard in payload_secrecy_without_endpoint_compromise.
lemma source_compromise_can_leak_payload:
exists-trace
"Ex sid pkS payload #i #j #k.
PayloadMarkedSecret(sid, payload) @ i
& SourceCompromised(sid, pkS) @ j
& K(payload) @ k"
// Target-side compromise: same story, from the target side.
lemma target_compromise_can_leak_payload:
exists-trace
"Ex sid pkS pkT payload #i #j #k.
PayloadMarkedSecret(sid, payload) @ i
& TargetCompromised(sid, pkS, pkT) @ j
& K(payload) @ k"
end
+413
View File
@@ -0,0 +1,413 @@
//! NIP-AB HKDF-SHA256 key derivation primitives.
//!
//! All functions are pure (no I/O, no side effects) and operate on fixed-size
//! `[u8; 32]` arrays. The underlying HKDF implementation is
//! [`nostr::util::hkdf`], which uses `bitcoin::hashes` internally.
//!
//! # Derivation overview
//!
//! ```text
//! session_secret (32 bytes, random)
//! │
//! ├─► derive_session_id → session_id (HKDF, salt=[], info="nostr-pair-session-id")
//! │
//! ├─► derive_sas(ecdh_shared, …)
//! │ ├─ sas_input (HKDF, salt=session_secret, info="nostr-pair-sas-v1")
//! │ └─ sas_code = be_u32(sas_input[0..4]) % 1_000_000
//! │
//! └─► derive_transcript_hash(session_id, src_pk, tgt_pk, sas_input, …)
//! └─ transcript_hash (HKDF, salt=session_secret,
//! info="nostr-pair-transcript-v1")
//! ```
use nostr::hashes::Hash as _;
use nostr::util::hkdf;
const INFO_SESSION_ID: &[u8] = b"nostr-pair-session-id";
const INFO_SAS: &[u8] = b"nostr-pair-sas-v1";
const INFO_TRANSCRIPT: &[u8] = b"nostr-pair-transcript-v1";
/// Run HKDF-SHA256(IKM=`ikm`, salt=`salt`, info=`info`) and return 32 bytes.
///
/// Uses `nostr::util::hkdf::{extract, expand}` directly so we don't pull in
/// an extra `hkdf` crate dependency.
fn hkdf32(salt: &[u8], ikm: &[u8], info: &[u8]) -> [u8; 32] {
let prk = hkdf::extract(salt, ikm);
let okm = hkdf::expand(&prk.to_byte_array(), info, 32);
// HKDF-Expand with L=32 and SHA-256 (HashLen=32) always produces exactly
// 32 bytes (one iteration, truncated to L). Copy into a fixed-size array
// without expect/unwrap.
let mut out = [0u8; 32];
out.copy_from_slice(&okm[..32]);
out
}
/// Derive the session ID from the session secret.
///
/// ```text
/// session_id = HKDF-SHA256(IKM=session_secret, salt=[], info="nostr-pair-session-id", L=32)
/// ```
///
/// The session ID is safe to share publicly (e.g., in the QR code or as a
/// Nostr event tag). It uniquely identifies the pairing session without
/// revealing the secret.
pub fn derive_session_id(session_secret: &[u8; 32]) -> [u8; 32] {
hkdf32(b"", session_secret, INFO_SESSION_ID)
}
/// Derive the Short Authentication String (SAS) code and the raw SAS input.
///
/// ```text
/// sas_input = HKDF-SHA256(IKM=ecdh_shared, salt=session_secret, info="nostr-pair-sas-v1", L=32)
/// sas_code = be_u32(sas_input[0..4]) mod 1_000_000
/// ```
///
/// Returns `(sas_code, sas_input)`. The caller needs `sas_input` to compute
/// the transcript hash — see [`derive_transcript_hash`].
///
/// `ecdh_shared` is the raw 32-byte x-coordinate from
/// `nostr::util::generate_shared_key(own_secret, other_pubkey)`.
pub fn derive_sas(ecdh_shared: &[u8; 32], session_secret: &[u8; 32]) -> (u32, [u8; 32]) {
let sas_input = hkdf32(session_secret, ecdh_shared, INFO_SAS);
let sas_code =
u32::from_be_bytes([sas_input[0], sas_input[1], sas_input[2], sas_input[3]]) % 1_000_000;
(sas_code, sas_input)
}
/// Derive the transcript hash that binds all session parameters together.
///
/// ```text
/// transcript = session_id ‖ source_pubkey ‖ target_pubkey ‖ sas_input (128 bytes)
/// transcript_hash = HKDF-SHA256(IKM=transcript, salt=session_secret,
/// info="nostr-pair-transcript-v1", L=32)
/// ```
///
/// Both parties must independently compute this value and compare it before
/// exchanging the actual payload. A mismatch means the session is compromised.
///
/// `sas_input` is the second return value of [`derive_sas`].
pub fn derive_transcript_hash(
session_id: &[u8; 32],
source_pubkey: &[u8; 32],
target_pubkey: &[u8; 32],
sas_input: &[u8; 32],
session_secret: &[u8; 32],
) -> [u8; 32] {
// Concatenate into a 128-byte transcript.
let mut transcript = [0u8; 128];
transcript[0..32].copy_from_slice(session_id);
transcript[32..64].copy_from_slice(source_pubkey);
transcript[64..96].copy_from_slice(target_pubkey);
transcript[96..128].copy_from_slice(sas_input);
hkdf32(session_secret, &transcript, INFO_TRANSCRIPT)
}
/// Format a SAS code as a zero-padded 6-digit string.
///
/// # Examples
/// ```
/// use buzz_core::pairing::crypto::format_sas;
/// assert_eq!(format_sas(291), "000291");
/// assert_eq!(format_sas(47291), "047291");
/// assert_eq!(format_sas(999999), "999999");
/// assert_eq!(format_sas(0), "000000");
/// ```
pub fn format_sas(code: u32) -> String {
format!("{code:06}")
}
/// Constant-time comparison of two 32-byte arrays.
///
/// Returns `true` iff all bytes are equal. Uses [`subtle::ConstantTimeEq`]
/// to guarantee the comparison is not optimized into a short-circuit by the
/// compiler, preventing timing side-channels on secret-derived values like
/// transcript hashes and session IDs.
pub fn ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool {
use subtle::ConstantTimeEq;
a.ct_eq(b).into()
}
#[cfg(test)]
mod tests {
use super::*;
/// session_secret = 0xa1b2c3d4…
fn session_secret() -> [u8; 32] {
hex_to_32("a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2")
}
/// source ephemeral private key bytes (used to derive pubkey for transcript test)
fn source_privkey_bytes() -> [u8; 32] {
hex_to_32("7f4c11a9c9d1e3b5a7f2e4d6c8b0a2f4e6d8c0b2a4f6e8d0c2b4a6f8e0d2c4b5")
}
/// target ephemeral private key bytes
fn target_privkey_bytes() -> [u8; 32] {
hex_to_32("3a5b7c9d1e3f5a7b9c1d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1a3b5c7d9e1f3a5b")
}
fn hex_to_32(s: &str) -> [u8; 32] {
let bytes = hex::decode(s).expect("valid hex");
bytes.try_into().expect("32 bytes")
}
fn bytes_to_hex(b: &[u8]) -> String {
hex::encode(b)
}
#[test]
fn session_id_is_deterministic() {
let secret = session_secret();
let id1 = derive_session_id(&secret);
let id2 = derive_session_id(&secret);
assert_eq!(id1, id2, "session_id must be deterministic");
}
#[test]
fn session_id_is_32_bytes() {
let id = derive_session_id(&session_secret());
assert_eq!(id.len(), 32);
}
#[test]
fn session_id_differs_from_secret() {
let secret = session_secret();
let id = derive_session_id(&secret);
assert_ne!(id, secret, "session_id must not equal the raw secret");
}
#[test]
fn session_id_test_vector() {
let id = derive_session_id(&session_secret());
assert_eq!(
bytes_to_hex(&id),
"fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380",
"session_id must match NIP-AB spec test vector"
);
}
#[test]
fn sas_code_is_six_digits() {
// Use a synthetic ECDH shared secret (just some fixed bytes).
let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
let (code, _) = derive_sas(&ecdh, &session_secret());
assert!(code < 1_000_000, "SAS code must be < 1_000_000, got {code}");
}
#[test]
fn sas_is_deterministic() {
let ecdh = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
let (code1, input1) = derive_sas(&ecdh, &session_secret());
let (code2, input2) = derive_sas(&ecdh, &session_secret());
assert_eq!(code1, code2);
assert_eq!(input1, input2);
}
#[test]
fn sas_changes_with_different_ecdh() {
let ecdh1 = hex_to_32("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
let ecdh2 = hex_to_32("ff02030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
let (code1, _) = derive_sas(&ecdh1, &session_secret());
let (code2, _) = derive_sas(&ecdh2, &session_secret());
assert_ne!(
code1, code2,
"different ECDH inputs must produce different SAS codes"
);
}
#[test]
fn sas_with_real_ecdh_keys() {
use nostr::{Keys, SecretKey};
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
let src_keys = Keys::new(src_sk);
let tgt_keys = Keys::new(tgt_sk);
// ECDH: source computes shared key with target's pubkey
let ecdh_from_src =
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
.unwrap();
// ECDH: target computes shared key with source's pubkey (must match)
let ecdh_from_tgt =
nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key())
.unwrap();
assert_eq!(ecdh_from_src, ecdh_from_tgt, "ECDH must be symmetric");
let (code, sas_input) = derive_sas(&ecdh_from_src, &session_secret());
println!("sas_code = {}", format_sas(code));
println!("sas_input = {}", bytes_to_hex(&sas_input));
assert!(code < 1_000_000);
}
#[test]
fn transcript_hash_is_deterministic() {
use nostr::{Keys, SecretKey};
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
let src_keys = Keys::new(src_sk);
let tgt_keys = Keys::new(tgt_sk);
let session_id = derive_session_id(&session_secret());
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
.unwrap();
let (_, sas_input) = derive_sas(&ecdh, &session_secret());
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
let h1 =
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
let h2 =
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
assert_eq!(h1, h2);
}
/// Full test vector suite — all values pinned against the NIP-AB spec.
#[test]
fn all_test_vectors() {
use nostr::{Keys, SecretKey};
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
let src_keys = Keys::new(src_sk);
let tgt_keys = Keys::new(tgt_sk);
// Pubkeys
assert_eq!(
bytes_to_hex(&src_keys.public_key().to_bytes()),
"199e64ca60662cb2d6e91d16cb065be51ad74a6ee5f8c5b0fdc53d246611ed9a"
);
assert_eq!(
bytes_to_hex(&tgt_keys.public_key().to_bytes()),
"89a9fa762105d0aee2b19678246fe7b823aabbc4f4bf691a1ce8a70fcd36d6e4"
);
// ECDH
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
.unwrap();
assert_eq!(
bytes_to_hex(&ecdh),
"9b4b6d6990713d89d6d9982e506ee1bbcde6f05c54d9d2978696e8a7274d4408"
);
// Session ID
let session_id = derive_session_id(&session_secret());
assert_eq!(
bytes_to_hex(&session_id),
"fb357d0f8e8d5a5ba3b2a91cb18c119e1567b07ffa38cdebb73e68df78f5a380"
);
// SAS
let (sas_code, sas_input) = derive_sas(&ecdh, &session_secret());
assert_eq!(
bytes_to_hex(&sas_input),
"e8b03a329f3a0ac37fe7fbe929171e14b72812be67e33c5d6e193543c41798d3"
);
assert_eq!(format_sas(sas_code), "863346");
// Transcript hash
let src_pk = src_keys.public_key().to_bytes();
let tgt_pk = tgt_keys.public_key().to_bytes();
let transcript_hash =
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
assert_eq!(
bytes_to_hex(&transcript_hash),
"d662818ff8911fc60a2d025f8b8b4756107104e85888dd202d28db5ca2cf28d3"
);
}
#[test]
fn transcript_hash_sensitive_to_pubkey_order() {
use nostr::{Keys, SecretKey};
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
let src_keys = Keys::new(src_sk);
let tgt_keys = Keys::new(tgt_sk);
let session_id = derive_session_id(&session_secret());
let ecdh = nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
.unwrap();
let (_, sas_input) = derive_sas(&ecdh, &session_secret());
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
let h_correct =
derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input, &session_secret());
// Swap source and target — must produce a different hash.
let h_swapped =
derive_transcript_hash(&session_id, &tgt_pk, &src_pk, &sas_input, &session_secret());
assert_ne!(
h_correct, h_swapped,
"transcript_hash must be sensitive to pubkey order"
);
}
#[test]
fn format_sas_zero_padding() {
assert_eq!(format_sas(0), "000000");
assert_eq!(format_sas(1), "000001");
assert_eq!(format_sas(291), "000291");
assert_eq!(format_sas(47291), "047291");
assert_eq!(format_sas(999999), "999999");
}
#[test]
fn format_sas_always_six_chars() {
for code in [0u32, 1, 99, 1000, 99999, 100000, 999999] {
let s = format_sas(code);
assert_eq!(s.len(), 6, "format_sas({code}) = {s:?} (expected 6 chars)");
assert!(s.chars().all(|c| c.is_ascii_digit()), "all digits: {s}");
}
}
#[test]
fn full_derivation_round_trip() {
use nostr::{Keys, SecretKey};
// Simulate both sides of the pairing independently deriving the same values.
let src_sk = SecretKey::from_slice(&source_privkey_bytes()).expect("valid key");
let tgt_sk = SecretKey::from_slice(&target_privkey_bytes()).expect("valid key");
let src_keys = Keys::new(src_sk);
let tgt_keys = Keys::new(tgt_sk);
let secret = session_secret();
// Both sides derive the same session_id.
let session_id = derive_session_id(&secret);
// Both sides compute ECDH (symmetric).
let ecdh_src =
nostr::util::generate_shared_key(src_keys.secret_key(), &tgt_keys.public_key())
.unwrap();
let ecdh_tgt =
nostr::util::generate_shared_key(tgt_keys.secret_key(), &src_keys.public_key())
.unwrap();
assert_eq!(ecdh_src, ecdh_tgt, "ECDH must be symmetric");
// Both sides derive the same SAS.
let (code_src, sas_input_src) = derive_sas(&ecdh_src, &secret);
let (code_tgt, sas_input_tgt) = derive_sas(&ecdh_tgt, &secret);
assert_eq!(code_src, code_tgt, "SAS codes must match");
assert_eq!(sas_input_src, sas_input_tgt, "sas_input must match");
// Both sides derive the same transcript hash (using the agreed pubkey ordering).
let src_pk: [u8; 32] = src_keys.public_key().to_bytes();
let tgt_pk: [u8; 32] = tgt_keys.public_key().to_bytes();
let th_src = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_src, &secret);
let th_tgt = derive_transcript_hash(&session_id, &src_pk, &tgt_pk, &sas_input_tgt, &secret);
assert_eq!(th_src, th_tgt, "transcript hashes must match");
println!(
"✅ Round-trip OK: sas={} transcript={}",
format_sas(code_src),
bytes_to_hex(&th_src)
);
}
}
+80
View File
@@ -0,0 +1,80 @@
//! NIP-AB device pairing — crypto primitives, message types, and error types.
//!
//! NIP-AB enables two Nostr devices to securely exchange a secret (e.g., an
//! `nsec` or a NIP-46 bunker connection string) over an untrusted relay, using:
//!
//! 1. **HKDF-SHA256** for all key derivation (session ID, SAS code, transcript hash).
//! 2. **ECDH** (via [`nostr::util::generate_shared_key`]) for the shared secret.
//! 3. **NIP-44 v2** for encrypting the message payloads.
//! 4. **Short Authentication String (SAS)** for out-of-band confirmation.
//!
//! # Module layout
//!
//! | Module | Contents |
//! |--------|----------|
//! | [`crypto`] | Pure HKDF derivation functions |
//! | [`types`] | Serde-serializable pairing message types |
//!
//! # Error handling
//!
//! All fallible operations in the pairing flow return [`PairingError`].
pub mod crypto;
pub mod qr;
pub mod session;
pub mod types;
pub use qr::QrPayload;
pub use session::{PairingSession, Role, SessionState};
pub use types::{AbortReason, PairingMessage, PayloadType};
use thiserror::Error;
/// Errors that can occur during a NIP-AB pairing session.
#[derive(Debug, Error)]
pub enum PairingError {
/// The scanned QR URI was not a valid NIP-AB pairing URI.
#[error("invalid QR URI: {0}")]
InvalidQr(String),
/// The session ID extracted from a message was not a valid 32-byte hex string.
#[error("invalid session ID")]
InvalidSessionId,
/// The SAS code shown on both devices did not match — session must be aborted.
#[error("SAS mismatch")]
SasMismatch,
/// The transcript hash received from the peer did not match the locally computed value.
#[error("transcript hash mismatch")]
TranscriptMismatch,
/// A message arrived out of sequence or with the wrong type for the current state.
#[error("unexpected message type: expected {expected}, got {got}")]
UnexpectedMessage {
/// The message type that was expected at this point in the protocol.
expected: String,
/// The message type that was actually received.
got: String,
},
/// The pairing session exceeded its time limit without completing.
#[error("session expired")]
SessionExpired,
/// NIP-44 encryption or decryption failed.
#[error("NIP-44 error: {0}")]
Nip44(#[from] nostr::nips::nip44::Error),
/// JSON serialization or deserialization failed.
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
/// A public key string could not be parsed.
#[error("invalid pubkey: {0}")]
InvalidPubkey(String),
/// Event signing or construction failed.
#[error("event signing failed: {0}")]
SigningError(String),
}
+588
View File
@@ -0,0 +1,588 @@
//! NIP-AB QR code URI encoding and decoding.
//!
//! The QR code encodes a `nostrpair://` URI that the scanning device uses to
//! bootstrap a pairing session. The URI carries:
//!
//! - The source device's ephemeral public key (hex, 64 chars)
//! - A 32-byte session secret shared between both devices (hex, 64 chars)
//! - One or more relay URLs where the pairing messages will be exchanged
//! - A protocol version (`v=1`)
//!
//! # URI format
//!
//! ```text
//! nostrpair://<source_pubkey_hex>?secret=<session_secret_hex>&relay=<url-encoded-relay>&v=1
//! ```
//!
//! Multiple relays are represented as repeated `relay=` parameters:
//!
//! ```text
//! nostrpair://abc123...?secret=def456...&relay=wss%3A%2F%2Frelay1.example.com&relay=wss%3A%2F%2Frelay2.example.com&v=1
//! ```
//!
//! All characters unsafe in a query-parameter value (`:`, `/`, `?`, `#`,
//! `&`, `=`, `%`, and space) are percent-encoded.
use nostr::PublicKey;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
use zeroize::Zeroize;
use super::PairingError;
/// Data encoded in the QR code displayed by the source device.
#[derive(Debug, Clone)]
pub struct QrPayload {
/// The source device's ephemeral public key.
pub source_pubkey: PublicKey,
/// 32-byte session secret shared between both devices.
///
/// This is generated fresh for each pairing session and never reused.
pub session_secret: [u8; 32],
/// One or more relay URLs where pairing messages will be exchanged.
pub relays: Vec<String>,
/// Protocol version. Always `1` for this implementation.
///
/// Encoded as `v=1` in the URI. Absent in legacy URIs; defaults to `1`
/// on decode for backward compatibility. Values > 1 are rejected.
pub version: u32,
}
/// Zero the session secret on drop using `zeroize` to prevent dead-store
/// elimination by the compiler (plain `fill(0)` can be optimized away).
impl Drop for QrPayload {
fn drop(&mut self) {
self.session_secret.zeroize();
}
}
/// Encode a [`QrPayload`] as a `nostrpair://` URI.
///
/// Relay URLs are percent-encoded (`:` → `%3A`, `/` → `%2F`) so they can
/// safely appear as query parameter values.
///
/// # Example
///
/// ```
/// use buzz_core::pairing::qr::{QrPayload, encode_qr};
/// use nostr::Keys;
///
/// let keys = Keys::generate();
/// let payload = QrPayload {
/// source_pubkey: keys.public_key(),
/// session_secret: [0u8; 32],
/// relays: vec!["wss://relay.example.com".to_string()],
/// version: 1,
/// };
/// let uri = encode_qr(&payload);
/// assert!(uri.starts_with("nostrpair://"));
/// ```
pub fn encode_qr(payload: &QrPayload) -> String {
let pubkey_hex = payload.source_pubkey.to_hex();
let secret_hex = hex::encode(payload.session_secret);
let mut uri = format!("nostrpair://{}?secret={}", pubkey_hex, secret_hex);
for relay in &payload.relays {
uri.push_str("&relay=");
uri.push_str(&url_encode(relay));
}
uri.push_str("&v=1");
uri
}
/// Decode a `nostrpair://` URI into a [`QrPayload`].
///
/// # Errors
///
/// Returns [`PairingError::InvalidQr`] if:
/// - The scheme is not `nostrpair`
/// - The public key is not a valid 64-char hex string
/// - The `secret` parameter is missing or not a valid 64-char hex string
/// - No `relay` parameters are present
pub fn decode_qr(uri: &str) -> Result<QrPayload, PairingError> {
// NIP-AB §QR Code Format: URI length MUST NOT exceed 2048 characters.
if uri.len() > 2048 {
return Err(PairingError::InvalidQr(format!(
"URI exceeds 2048-character limit ({} chars)",
uri.len()
)));
}
// Split scheme from the rest.
let rest = uri
.strip_prefix("nostrpair://")
.ok_or_else(|| PairingError::InvalidQr("URI must start with nostrpair://".into()))?;
// Split pubkey from query string.
let (pubkey_hex, query) = match rest.split_once('?') {
Some((pk, q)) => (pk, q),
None => {
return Err(PairingError::InvalidQr(
"missing query string (expected ?secret=…&relay=…)".into(),
))
}
};
// Validate pubkey: must be exactly 64 lowercase hex chars (NIP-AB §QR Code Format).
if pubkey_hex.len() != 64 || !pubkey_hex.chars().all(is_lowercase_hex) {
return Err(PairingError::InvalidQr(format!(
"pubkey must be 64 lowercase hex chars, got {:?}",
pubkey_hex
)));
}
let source_pubkey = PublicKey::from_hex(pubkey_hex)
.map_err(|e| PairingError::InvalidQr(format!("invalid pubkey: {e}")))?;
// Parse query parameters.
let mut secret_hex: Option<&str> = None;
let mut relays: Vec<String> = Vec::new();
let mut version: Option<u32> = None;
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
match key {
"secret" => secret_hex = Some(value),
"relay" => relays.push(url_decode(value)),
"v" => version = value.parse::<u32>().ok(),
_ => {} // ignore unknown params
}
}
}
// Default to version 1 if absent (backward compat); reject unsupported versions.
let version = version.unwrap_or(1);
if version != 1 {
return Err(PairingError::InvalidQr(format!(
"unsupported protocol version {version}, expected 1"
)));
}
// Validate secret: must be exactly 64 hex chars.
let secret_str = secret_hex
.ok_or_else(|| PairingError::InvalidQr("missing 'secret' query parameter".into()))?;
if secret_str.len() != 64 || !secret_str.chars().all(is_lowercase_hex) {
return Err(PairingError::InvalidQr(format!(
"secret must be 64 lowercase hex chars, got {:?}",
secret_str
)));
}
let secret_bytes = hex::decode(secret_str)
.map_err(|e| PairingError::InvalidQr(format!("invalid secret hex: {e}")))?;
let session_secret: [u8; 32] = secret_bytes
.try_into()
.map_err(|_| PairingError::InvalidQr("secret must be exactly 32 bytes".into()))?;
// NIP-AB §Test Vectors: all-zeros session_secret MUST be rejected.
if session_secret == [0u8; 32] {
return Err(PairingError::InvalidQr(
"session_secret must not be all zeros".into(),
));
}
// Must have at least one relay.
if relays.is_empty() {
return Err(PairingError::InvalidQr(
"at least one 'relay' query parameter is required".into(),
));
}
// Validate relay URLs — parse fully and require WebSocket scheme + host.
// Prefix-matching alone would accept malformed URLs that crash downstream.
for relay in &relays {
let parsed = url::Url::parse(relay)
.map_err(|e| PairingError::InvalidQr(format!("invalid relay URL {:?}: {e}", relay)))?;
match parsed.scheme() {
"wss" | "ws" => {}
other => {
return Err(PairingError::InvalidQr(format!(
"relay URL must use wss:// or ws:// scheme, got {:?}",
other
)));
}
}
if parsed.host().is_none() {
return Err(PairingError::InvalidQr(format!(
"relay URL has no host: {:?}",
relay
)));
}
}
Ok(QrPayload {
source_pubkey,
session_secret,
relays,
version,
})
}
/// Percent-encode a relay URL for use as a query parameter value.
///
/// Uses `percent-encoding` crate's `NON_ALPHANUMERIC` set, which encodes
/// everything except ASCII alphanumerics. This is a strict superset of the
/// characters unsafe in query-parameter values (`:`, `/`, `?`, `#`, `&`,
/// `=`, `%`, space) — safe by construction.
fn url_encode(s: &str) -> String {
utf8_percent_encode(s, NON_ALPHANUMERIC).to_string()
}
/// Percent-decode a query parameter value.
///
/// Falls back to lossy UTF-8 conversion for non-UTF-8 sequences (which
/// shouldn't appear in valid relay URLs, but we handle it safely).
fn url_decode(s: &str) -> String {
percent_decode_str(s).decode_utf8_lossy().into_owned()
}
/// NIP-AB §QR Code Format requires lowercase hex only (`0-9`, `a-f`).
fn is_lowercase_hex(c: char) -> bool {
c.is_ascii_digit() || ('a'..='f').contains(&c)
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::Keys;
fn make_payload(relays: Vec<String>) -> QrPayload {
let keys = Keys::generate();
QrPayload {
source_pubkey: keys.public_key(),
session_secret: [0xab; 32],
relays,
version: 1,
}
}
// 1. Round-trip encode/decode
#[test]
fn round_trip_single_relay() {
let original = make_payload(vec!["wss://relay.example.com".to_string()]);
let uri = encode_qr(&original);
let decoded = decode_qr(&uri).expect("decode should succeed");
assert_eq!(original.source_pubkey, decoded.source_pubkey);
assert_eq!(original.session_secret, decoded.session_secret);
assert_eq!(original.relays, decoded.relays);
}
// 7. Handle multiple relays
#[test]
fn round_trip_multiple_relays() {
let original = make_payload(vec![
"wss://relay1.example.com".to_string(),
"wss://relay2.example.com".to_string(),
"wss://relay3.example.com".to_string(),
]);
let uri = encode_qr(&original);
let decoded = decode_qr(&uri).expect("decode should succeed");
assert_eq!(decoded.relays.len(), 3);
assert_eq!(decoded.relays, original.relays);
}
// 8. Handle URL-encoded relay URLs
#[test]
fn url_encoding_round_trip() {
let relay = "wss://relay.example.com/path";
let encoded = url_encode(relay);
// NON_ALPHANUMERIC encodes dots too — stricter than necessary but safe.
assert_eq!(encoded, "wss%3A%2F%2Frelay%2Eexample%2Ecom%2Fpath");
let decoded = url_decode(&encoded);
assert_eq!(decoded, relay);
}
#[test]
fn round_trip_relay_with_path() {
let original = make_payload(vec!["wss://relay.example.com/nostr".to_string()]);
let uri = encode_qr(&original);
let decoded = decode_qr(&uri).expect("decode should succeed");
assert_eq!(decoded.relays[0], "wss://relay.example.com/nostr");
}
// 2. Reject missing scheme
#[test]
fn reject_missing_scheme() {
let err = decode_qr("https://relay.example.com").unwrap_err();
assert!(
matches!(err, PairingError::InvalidQr(_)),
"expected InvalidQr, got {err:?}"
);
}
#[test]
fn reject_wrong_scheme() {
let err = decode_qr("nostr://abc").unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
// 3. Reject missing secret
#[test]
fn reject_missing_secret() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!("nostrpair://{}?relay={}", pubkey, relay_encoded);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
// 4. Reject missing relay
#[test]
fn reject_missing_relay() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let secret = hex::encode([0xab; 32]);
let uri = format!("nostrpair://{}?secret={}", pubkey, secret);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
// 5. Reject invalid hex in pubkey
#[test]
fn reject_invalid_pubkey_hex() {
let bad_pubkey = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; // 64 chars, not hex
let secret = hex::encode([0xab; 32]);
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}",
bad_pubkey, secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
// 6. Reject invalid hex in secret
#[test]
fn reject_invalid_secret_hex() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let bad_secret = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"; // 64 chars, not hex
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}",
pubkey, bad_secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
#[test]
fn reject_short_pubkey() {
let secret = hex::encode([0xab; 32]);
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://abc123?secret={}&relay={}",
secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
#[test]
fn reject_short_secret() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://{}?secret=abc123&relay={}",
pubkey, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
#[test]
fn reject_missing_query_string() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let uri = format!("nostrpair://{}", pubkey);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
#[test]
fn reject_non_websocket_relay_scheme() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let secret = hex::encode([0xab; 32]);
// http:// is not a valid relay scheme
let relay_encoded = url_encode("https://evil.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}",
pubkey, secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
#[test]
fn accept_ws_and_wss_relay_schemes() {
let payload_wss = make_payload(vec!["wss://relay.example.com".to_string()]);
let uri_wss = encode_qr(&payload_wss);
assert!(decode_qr(&uri_wss).is_ok(), "wss:// should be accepted");
let payload_ws = make_payload(vec!["ws://relay.example.com".to_string()]);
let uri_ws = encode_qr(&payload_ws);
assert!(decode_qr(&uri_ws).is_ok(), "ws:// should be accepted");
}
#[test]
fn reject_relay_with_no_scheme() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let secret = hex::encode([0xab; 32]);
let relay_encoded = url_encode("relay.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}",
pubkey, secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(matches!(err, PairingError::InvalidQr(_)));
}
#[test]
fn uri_contains_scheme_and_pubkey() {
let payload = make_payload(vec!["wss://relay.example.com".to_string()]);
let uri = encode_qr(&payload);
assert!(uri.starts_with("nostrpair://"));
assert!(uri.contains(&payload.source_pubkey.to_hex()));
assert!(uri.contains("secret="));
assert!(uri.contains("relay="));
}
#[test]
fn url_decode_case_insensitive() {
// %3a and %2f (lowercase) should also decode
assert_eq!(
url_decode("wss%3a%2f%2frelay.example.com"),
"wss://relay.example.com"
);
}
#[test]
fn round_trip_relay_with_query_params() {
// Relay URL with query parameters containing &, =, and ?
let original = make_payload(vec![
"wss://relay.example.com/path?token=abc&flag=1".to_string()
]);
let uri = encode_qr(&original);
let decoded = decode_qr(&uri).expect("decode should succeed");
assert_eq!(
decoded.relays[0],
"wss://relay.example.com/path?token=abc&flag=1"
);
}
#[test]
fn round_trip_relay_with_percent_and_hash() {
let original = make_payload(vec!["wss://relay.example.com/path#frag%20ment".to_string()]);
let uri = encode_qr(&original);
let decoded = decode_qr(&uri).expect("decode should succeed");
assert_eq!(
decoded.relays[0],
"wss://relay.example.com/path#frag%20ment"
);
}
#[test]
fn url_encode_reserved_chars() {
let encoded = url_encode("wss://relay.com/path?a=1&b=2#frag");
assert!(!encoded.contains('&'), "& must be encoded");
assert!(!encoded.contains('='), "= must be encoded");
assert!(!encoded.contains('?'), "? must be encoded");
assert!(!encoded.contains('#'), "# must be encoded");
let decoded = url_decode(&encoded);
assert_eq!(decoded, "wss://relay.com/path?a=1&b=2#frag");
}
// Version field tests
#[test]
fn round_trip_with_version() {
let original = make_payload(vec!["wss://relay.example.com".to_string()]);
let uri = encode_qr(&original);
assert!(uri.contains("&v=1"), "URI must contain &v=1: {uri}");
let decoded = decode_qr(&uri).expect("decode should succeed");
assert_eq!(decoded.version, 1);
assert_eq!(original.source_pubkey, decoded.source_pubkey);
assert_eq!(original.session_secret, decoded.session_secret);
assert_eq!(original.relays, decoded.relays);
}
#[test]
fn reject_unsupported_version() {
let payload = make_payload(vec!["wss://relay.example.com".to_string()]);
// Build a URI with v=2 manually.
let uri = encode_qr(&payload).replace("&v=1", "&v=2");
let err = decode_qr(&uri).unwrap_err();
assert!(
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("unsupported protocol version 2")),
"expected unsupported version error, got {err:?}"
);
}
#[test]
fn default_version_when_absent() {
// Strip the &v=1 from a well-formed URI to simulate a legacy QR code.
let payload = make_payload(vec!["wss://relay.example.com".to_string()]);
let uri = encode_qr(&payload).replace("&v=1", "");
let decoded = decode_qr(&uri).expect("legacy URI without v= should decode as version 1");
assert_eq!(decoded.version, 1, "missing v= should default to version 1");
}
#[test]
fn reject_all_zeros_session_secret() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let zero_secret = "00".repeat(32); // 64 hex chars, all zeros
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}&v=1",
pubkey, zero_secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("all zeros")),
"expected all-zeros rejection, got {err:?}"
);
}
#[test]
fn reject_uppercase_hex_in_pubkey() {
let keys = Keys::generate();
// Force uppercase in the pubkey hex
let pubkey_upper = keys.public_key().to_hex().to_uppercase();
let secret = hex::encode([0xab; 32]);
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}&v=1",
pubkey_upper, secret, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")),
"expected lowercase rejection for pubkey, got {err:?}"
);
}
#[test]
fn reject_uppercase_hex_in_secret() {
let keys = Keys::generate();
let pubkey = keys.public_key().to_hex();
let secret_upper = hex::encode([0xab; 32]).to_uppercase();
let relay_encoded = url_encode("wss://relay.example.com");
let uri = format!(
"nostrpair://{}?secret={}&relay={}&v=1",
pubkey, secret_upper, relay_encoded
);
let err = decode_qr(&uri).unwrap_err();
assert!(
matches!(err, PairingError::InvalidQr(ref msg) if msg.contains("lowercase")),
"expected lowercase rejection for secret, got {err:?}"
);
}
}
File diff suppressed because it is too large Load Diff
+242
View File
@@ -0,0 +1,242 @@
//! NIP-AB pairing message types.
//!
//! All message types are serialized as JSON with a `"type"` discriminant field
//! (kebab-case). These are the plaintext payloads that get NIP-44 encrypted
//! before being placed in a [`crate::kind::KIND_PAIRING`] event.
use serde::{Deserialize, Serialize};
fn default_version() -> u32 {
1
}
/// The set of messages exchanged during a NIP-AB device-pairing session.
///
/// Serialized with `"type"` as the tag field (kebab-case). Example:
/// ```json
/// {"type":"offer","session_id":"a1b2c3..."}
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum PairingMessage {
/// Target → Source. Announces the session and proves possession of the QR secret.
Offer {
/// Hex-encoded 32-byte session ID derived via HKDF from the session secret.
session_id: String,
/// Protocol version. Always `1` for this implementation.
///
/// Defaults to `1` when absent (backward compat with pre-versioned implementations).
#[serde(default = "default_version")]
version: u32,
},
/// Either party → other. Confirms the Short Authentication String matches.
SasConfirm {
/// Hex-encoded 32-byte transcript hash, binding all session parameters.
transcript_hash: String,
},
/// Initiator → Responder (or vice-versa). Delivers the actual secret payload.
Payload {
/// Discriminates the payload format so the receiver knows how to handle it.
payload_type: PayloadType,
/// The payload content (format depends on `payload_type`).
payload: String,
},
/// Sent by either party to signal successful session completion.
Complete {
/// `true` if the session completed successfully, `false` on partial failure.
success: bool,
},
/// Sent by either party to abort the session early.
Abort {
/// Machine-readable reason for the abort.
reason: AbortReason,
},
}
/// Discriminates the content of a [`PairingMessage::Payload`] message.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PayloadType {
/// Raw `nsec` bech32 secret key.
Nsec,
/// NIP-46 bunker connection string.
Bunker,
/// NIP-46 `nostrconnect://` URI.
Connect,
/// Application-defined payload; interpretation is out-of-band.
Custom,
}
/// Machine-readable reason a pairing session was aborted.
///
/// The spec allows implementations to define additional reason strings.
/// Unknown reasons are deserialized as [`Unknown`](AbortReason::Unknown)
/// and SHOULD be treated as `protocol_error` per NIP-AB §Abort.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AbortReason {
/// The Short Authentication Strings shown to both users did not match.
SasMismatch,
/// The user explicitly denied the pairing request.
UserDenied,
/// The session exceeded its time limit without completing.
Timeout,
/// An unexpected or malformed message was received.
ProtocolError,
/// An unrecognized abort reason from a future or extended implementation.
/// Produced only by deserialization of unknown reason strings.
/// Callers MUST NOT use this variant for outbound aborts — use a
/// spec-defined reason instead. Treat as `ProtocolError` per NIP-AB §Abort.
#[serde(other)]
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offer_round_trip() {
let msg = PairingMessage::Offer {
session_id: "deadbeef".repeat(8),
version: 1,
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(
json.contains(r#""type":"offer""#),
"tag field present: {json}"
);
assert!(
json.contains(r#""version":1"#),
"version field present: {json}"
);
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
#[test]
fn offer_version_defaults_to_1_when_absent() {
// Simulate a legacy offer message without the version field.
let json = r#"{"type":"offer","session_id":"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}"#;
let msg: PairingMessage = serde_json::from_str(json).expect("deserialize");
assert_eq!(
msg,
PairingMessage::Offer {
session_id: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
.to_string(),
version: 1,
}
);
}
#[test]
fn sas_confirm_round_trip() {
let msg = PairingMessage::SasConfirm {
transcript_hash: "cafebabe".repeat(8),
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(
json.contains(r#""type":"sas-confirm""#),
"kebab-case tag: {json}"
);
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
#[test]
fn payload_round_trip() {
let msg = PairingMessage::Payload {
payload_type: PayloadType::Nsec,
payload: "nsec1abc".to_string(),
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(json.contains(r#""type":"payload""#));
assert!(json.contains(r#""payload_type":"nsec""#));
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
#[test]
fn abort_sas_mismatch_round_trip() {
let msg = PairingMessage::Abort {
reason: AbortReason::SasMismatch,
};
let json = serde_json::to_string(&msg).expect("serialize");
assert!(
json.contains(r#""reason":"sas_mismatch""#),
"snake_case: {json}"
);
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
#[test]
fn complete_round_trip() {
for success in [true, false] {
let msg = PairingMessage::Complete { success };
let json = serde_json::to_string(&msg).expect("serialize");
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
}
#[test]
fn all_abort_reasons_round_trip() {
let reasons = [
AbortReason::SasMismatch,
AbortReason::UserDenied,
AbortReason::Timeout,
AbortReason::ProtocolError,
];
for reason in reasons {
let msg = PairingMessage::Abort { reason };
let json = serde_json::to_string(&msg).expect("serialize");
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
}
#[test]
fn unknown_abort_reason_deserializes_to_unknown() {
// NIP-AB §Abort: "unknown reasons SHOULD be treated as protocol_error"
let json = r#"{"type":"abort","reason":"solar_flare"}"#;
let msg: PairingMessage = serde_json::from_str(json).expect("deserialize");
assert_eq!(
msg,
PairingMessage::Abort {
reason: AbortReason::Unknown
}
);
}
#[test]
fn unknown_abort_reason_is_not_protocol_error_variant() {
// Unknown is a distinct variant — callers should never construct it
// for outbound use, but if they do it serializes distinctly from
// ProtocolError so we can catch the mistake.
assert_ne!(AbortReason::Unknown, AbortReason::ProtocolError);
}
#[test]
fn all_payload_types_round_trip() {
let types = [
PayloadType::Nsec,
PayloadType::Bunker,
PayloadType::Connect,
PayloadType::Custom,
];
for payload_type in types {
let msg = PairingMessage::Payload {
payload_type,
payload: "data".to_string(),
};
let json = serde_json::to_string(&msg).expect("serialize");
let back: PairingMessage = serde_json::from_str(&json).expect("deserialize");
assert_eq!(msg, back);
}
}
}