canary-kit 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CANARY.md +1065 -0
  2. package/INTEGRATION.md +351 -0
  3. package/LICENSE +21 -0
  4. package/NIP-CANARY.md +624 -0
  5. package/README.md +187 -0
  6. package/SECURITY.md +92 -0
  7. package/dist/beacon.d.ts +104 -0
  8. package/dist/beacon.d.ts.map +1 -0
  9. package/dist/beacon.js +197 -0
  10. package/dist/beacon.js.map +1 -0
  11. package/dist/counter.d.ts +37 -0
  12. package/dist/counter.d.ts.map +1 -0
  13. package/dist/counter.js +62 -0
  14. package/dist/counter.js.map +1 -0
  15. package/dist/crypto.d.ts +111 -0
  16. package/dist/crypto.d.ts.map +1 -0
  17. package/dist/crypto.js +309 -0
  18. package/dist/crypto.js.map +1 -0
  19. package/dist/derive.d.ts +68 -0
  20. package/dist/derive.d.ts.map +1 -0
  21. package/dist/derive.js +85 -0
  22. package/dist/derive.js.map +1 -0
  23. package/dist/encoding.d.ts +56 -0
  24. package/dist/encoding.d.ts.map +1 -0
  25. package/dist/encoding.js +98 -0
  26. package/dist/encoding.js.map +1 -0
  27. package/dist/group.d.ts +185 -0
  28. package/dist/group.d.ts.map +1 -0
  29. package/dist/group.js +263 -0
  30. package/dist/group.js.map +1 -0
  31. package/dist/index.d.ts +10 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +12 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/nostr.d.ts +134 -0
  36. package/dist/nostr.d.ts.map +1 -0
  37. package/dist/nostr.js +175 -0
  38. package/dist/nostr.js.map +1 -0
  39. package/dist/presets.d.ts +26 -0
  40. package/dist/presets.d.ts.map +1 -0
  41. package/dist/presets.js +39 -0
  42. package/dist/presets.js.map +1 -0
  43. package/dist/session.d.ts +114 -0
  44. package/dist/session.d.ts.map +1 -0
  45. package/dist/session.js +173 -0
  46. package/dist/session.js.map +1 -0
  47. package/dist/sync-crypto.d.ts +66 -0
  48. package/dist/sync-crypto.d.ts.map +1 -0
  49. package/dist/sync-crypto.js +125 -0
  50. package/dist/sync-crypto.js.map +1 -0
  51. package/dist/sync.d.ts +191 -0
  52. package/dist/sync.d.ts.map +1 -0
  53. package/dist/sync.js +568 -0
  54. package/dist/sync.js.map +1 -0
  55. package/dist/token.d.ts +186 -0
  56. package/dist/token.d.ts.map +1 -0
  57. package/dist/token.js +344 -0
  58. package/dist/token.js.map +1 -0
  59. package/dist/verify.d.ts +45 -0
  60. package/dist/verify.d.ts.map +1 -0
  61. package/dist/verify.js +59 -0
  62. package/dist/verify.js.map +1 -0
  63. package/dist/wordlist.d.ts +28 -0
  64. package/dist/wordlist.d.ts.map +1 -0
  65. package/dist/wordlist.js +297 -0
  66. package/dist/wordlist.js.map +1 -0
  67. package/llms-full.txt +1461 -0
  68. package/llms.txt +180 -0
  69. package/package.json +144 -0
package/NIP-CANARY.md ADDED
@@ -0,0 +1,624 @@
1
+ NIP-CANARY
2
+ ==========
3
+
4
+ Nostr Binding for the CANARY Spoken Verification Protocol
5
+ ---------------------------------------------------------
6
+
7
+ `draft` `optional`
8
+
9
+ ## Abstract
10
+
11
+ This NIP defines a Nostr application layer for the [CANARY protocol](CANARY.md),
12
+ providing group management, seed distribution, and counter synchronisation over
13
+ Nostr relays. The core protocol (CANARY-DERIVE, CANARY-DURESS, CANARY-WORDLIST)
14
+ is defined in the transport-agnostic [CANARY specification](CANARY.md).
15
+
16
+ ## Nostr Canary Groups
17
+
18
+ This section defines a Nostr application layer built on the CANARY protocol, providing
19
+ group management, seed distribution, and counter synchronisation over Nostr relays.
20
+
21
+ Nostr canary groups use a specific instantiation of the CANARY protocol with time-based
22
+ counters and Nostr public keys as member identities.
23
+
24
+ ### Group Derivation Scheme
25
+
26
+ The Nostr group scheme is a specific instantiation of the universal CANARY protocol
27
+ (see [CANARY.md](CANARY.md)) with the following parameters:
28
+
29
+ | Parameter | Value |
30
+ |---------------|--------------------------------------------------------------------|
31
+ | `secret` | 32-byte group seed |
32
+ | `context` | `"canary:group"` |
33
+ | `counter` | `floor(unix_timestamp / rotation_interval)` plus any usage offset |
34
+ | `identity` | Member's Nostr public key (64-char lowercase hex) |
35
+ | `encoding` | Word encoding from CANARY-WORDLIST (`uint16_be mod 2048`) |
36
+
37
+ #### Verification Word
38
+
39
+ Derived using **CANARY-DERIVE** (see [CANARY.md §CANARY-DERIVE](CANARY.md)):
40
+
41
+ ```
42
+ token_bytes = HMAC-SHA256(seed, utf8("canary:group") || counter_be32)
43
+ index = uint16_be(token_bytes[0:2]) mod 2048
44
+ word = wordlist[index]
45
+ ```
46
+
47
+ Where `counter_be32` is a 4-byte big-endian unsigned integer.
48
+
49
+ #### Verification Phrase
50
+
51
+ For multi-word phrases (2 or 3 words), each word is derived from a consecutive 2-byte
52
+ slice of the same HMAC digest:
53
+
54
+ ```
55
+ token_bytes = HMAC-SHA256(seed, utf8("canary:group") || counter_be32)
56
+ word_1 = wordlist[uint16_be(token_bytes[0:2]) mod 2048]
57
+ word_2 = wordlist[uint16_be(token_bytes[2:4]) mod 2048]
58
+ word_3 = wordlist[uint16_be(token_bytes[4:6]) mod 2048]
59
+ ```
60
+
61
+ Different 2-byte slices MAY produce the same index; this is a valid output, not an error.
62
+
63
+ #### Duress Word
64
+
65
+ Derived using **CANARY-DURESS** (see [CANARY.md §CANARY-DURESS](CANARY.md)). Each
66
+ member has a distinct duress word. In finite wordlist spaces, two members may derive
67
+ the same duress word — this is handled by multi-match attribution. The member's Nostr
68
+ public key (64-char lowercase hex) is the identity parameter:
69
+
70
+ ```
71
+ duress_bytes = HMAC-SHA256(seed, utf8("canary:group:duress") || 0x00 || utf8(member_pubkey) || counter_be32)
72
+ index = uint16_be(duress_bytes[0:2]) mod 2048
73
+ word = wordlist[index]
74
+ ```
75
+
76
+ If the duress word collides with any verification word within the collision window
77
+ defined by CANARY-DURESS (±2 × maxTolerance counter values), the deterministic
78
+ multi-suffix retry algorithm applies (append suffix bytes 0x01..0xFF to the HMAC
79
+ data until distinct, error if exhausted). See [CANARY.md §Collision Avoidance](CANARY.md).
80
+
81
+ ### Counter Derivation
82
+
83
+ ```
84
+ counter = floor(unix_timestamp / rotation_interval) + usage_offset
85
+ ```
86
+
87
+ The `usage_offset` is the number of times the word has been burned within the current
88
+ time window. It MUST be included in Word Used events (kind 28802) so all members can
89
+ advance in step.
90
+
91
+ ### Verification Algorithm
92
+
93
+ When verifying a spoken response, implementations MUST follow the priority order
94
+ defined by CANARY-DURESS (see [CANARY.md §Verification Flow](CANARY.md)):
95
+
96
+ 1. **Normal token at exact counter:** If the input matches the current verification
97
+ word (or phrase) → identity confirmed.
98
+ 2. **All duress tokens across ±tolerance window:** Derive the duress word (or phrase)
99
+ for every member at every counter in the tolerance window. Collect all matches.
100
+ Per CANARY-DURESS, the verifier MUST check all members and collect all matches
101
+ (see [CANARY.md](CANARY.md)). If any matches → **DURESS DETECTED**. Act normally,
102
+ broadcast silent duress event.
103
+ 3. **Normal token at remaining tolerance window:** Check the verification word (or
104
+ phrase) at non-exact counters within the tolerance window. If it matches →
105
+ identity confirmed, member out of sync.
106
+ 4. **No match** → verification failed.
107
+
108
+ This ordering ensures that a duress token at the expected counter is never masked
109
+ by a normal token at an adjacent counter (fail-safe).
110
+
111
+ ### Burn-After-Use
112
+
113
+ When a word is used for verification:
114
+
115
+ 1. The verifying member broadcasts a Word Used event (kind 28802) with the new counter.
116
+ 2. All members advance their counter to `max(local_counter + 1, time_based_counter)`.
117
+ 3. Members who miss the event resynchronise at the next natural time rotation.
118
+
119
+ ## Event Kinds
120
+
121
+ This NIP defines six event kinds for Nostr transport. Kind numbers 38800–38801 are
122
+ replaceable events (NIP-16). Kind numbers 28800–28802 are ephemeral events (NIP-16).
123
+
124
+ ```
125
+ Kind 38800 Canary Group Replaceable
126
+ Kind 28800 Seed Distribution Ephemeral
127
+ Kind 38801 Member Update Replaceable
128
+ Kind 28801 Re-seed Ephemeral
129
+ Kind 28802 Word Used / Duress Alert Ephemeral
130
+ Kind 20800 Encrypted Location Beacon Ephemeral
131
+ ```
132
+
133
+ ### Kind 38800: Canary Group
134
+
135
+ Published by the group creator. The `d` tag value is the group identifier throughout the
136
+ group's lifetime.
137
+
138
+ ```json
139
+ {
140
+ "kind": 38800,
141
+ "content": "<NIP-44 encrypted group config>",
142
+ "tags": [
143
+ ["d", "<group-identifier>"],
144
+ ["name", "<human-readable group name>"],
145
+ ["p", "<member-1-pubkey>"],
146
+ ["p", "<member-2-pubkey>"],
147
+ ["p", "<member-3-pubkey>"],
148
+ ["rotation", "604800"],
149
+ ["words", "1"],
150
+ ["wordlist", "en-v1"],
151
+ ["expiration", "<unix timestamp>"]
152
+ ]
153
+ }
154
+ ```
155
+
156
+ | Tag | Required | Description |
157
+ |--------------|----------|--------------------------------------------------------------------|
158
+ | `d` | MUST | Unique group identifier; replaceable event address |
159
+ | `name` | SHOULD | Human-readable group name (unencrypted — visible to relays) |
160
+ | `p` | MUST | Member public keys, one tag per member |
161
+ | `rotation` | MUST | Rotation interval in seconds (e.g. `"604800"` for 7 days) |
162
+ | `words` | MUST | Number of words per verification phrase: `"1"`, `"2"`, or `"3"` |
163
+ | `wordlist` | MUST | Wordlist identifier (e.g. `"en-v1"`) |
164
+ | `expiration` | SHOULD | NIP-40 expiration timestamp — group auto-dissolves after this time |
165
+
166
+ The encrypted `content` MUST be a NIP-44 encrypted JSON object:
167
+
168
+ ```json
169
+ {
170
+ "description": "<creator-defined group description>",
171
+ "policies": {
172
+ "invite_by": "creator",
173
+ "reseed_by": "creator"
174
+ }
175
+ }
176
+ ```
177
+
178
+ ### Kind 28800: Seed Distribution
179
+
180
+ Delivers the group seed to a specific member, encrypted with NIP-44.
181
+
182
+ ```json
183
+ {
184
+ "kind": 28800,
185
+ "content": "<NIP-44 encrypted payload>",
186
+ "tags": [
187
+ ["p", "<recipient-pubkey>"],
188
+ ["e", "<group-event-id>"]
189
+ ]
190
+ }
191
+ ```
192
+
193
+ Encrypted payload:
194
+
195
+ ```json
196
+ {
197
+ "seed": "<256-bit hex-encoded group seed>",
198
+ "counter_offset": 0,
199
+ "group_d": "<group d-tag value>"
200
+ }
201
+ ```
202
+
203
+ The `counter_offset` allows re-seeding mid-window without waiting for the next natural
204
+ time rotation.
205
+
206
+ ### Kind 38801: Member Update
207
+
208
+ Published by the group creator to record a membership change.
209
+
210
+ ```json
211
+ {
212
+ "kind": 38801,
213
+ "content": "<NIP-44 encrypted reason>",
214
+ "tags": [
215
+ ["d", "<group-identifier>"],
216
+ ["action", "add"],
217
+ ["p", "<affected-member-pubkey>"],
218
+ ["reseed", "false"]
219
+ ]
220
+ }
221
+ ```
222
+
223
+ - `action` MUST be `"add"` or `"remove"`.
224
+ - When `action` is `"remove"`, `reseed` SHOULD be `"true"`. The creator SHOULD distribute
225
+ a new seed to all remaining members promptly. Implementations MAY defer reseeding for
226
+ low-risk presets (e.g. `family`) when the removed member is trusted not to be
227
+ adversarial, but MUST reseed if the removal is due to compromise or duress.
228
+ - When `action` is `"add"`, the creator distributes the current seed to the new member.
229
+
230
+ **Data model note:** Kind 38801 is a replaceable event keyed by group identifier. Only the
231
+ most recent member update per group is visible to relays. Clients MUST derive the canonical
232
+ member list from kind 38800's `p` tags, which are updated by the creator whenever membership
233
+ changes. Kind 38801 serves as a change notification (latest-wins by design), not as an
234
+ accumulative membership log.
235
+
236
+ ### Kind 28801: Re-seed
237
+
238
+ Signals that a re-seed is in progress. Individual seed deliveries follow as kind 28800
239
+ events.
240
+
241
+ ```json
242
+ {
243
+ "kind": 28801,
244
+ "content": "<NIP-44 encrypted reason>",
245
+ "tags": [
246
+ ["e", "<group-event-id>"],
247
+ ["reason", "member_removed"]
248
+ ]
249
+ }
250
+ ```
251
+
252
+ The `reason` tag MUST be one of: `"member_removed"`, `"compromise"`, `"scheduled"`,
253
+ `"duress"`.
254
+
255
+ ### Kind 28802: Word Used / Duress Alert
256
+
257
+ Signals that the current word was used for verification, or that a duress word was
258
+ detected. All members who receive this event MUST advance their local counter.
259
+
260
+ ```json
261
+ {
262
+ "kind": 28802,
263
+ "content": "<NIP-44 encrypted payload>",
264
+ "tags": [
265
+ ["e", "<group-event-id>"]
266
+ ]
267
+ }
268
+ ```
269
+
270
+ Encrypted payload:
271
+
272
+ ```json
273
+ {
274
+ "new_counter": 12346,
275
+ "used_by": "<pubkey of member who triggered the advancement>",
276
+ "duress": false
277
+ }
278
+ ```
279
+
280
+ When `duress` is `true`, clients MUST handle this silently — they MUST NOT display any
281
+ visible indication that could alert an attacker. Clients SHOULD initiate an automatic
282
+ re-seed (kind 28801) with `reason=duress`.
283
+
284
+ #### Counter Acceptance
285
+
286
+ Per the CANARY protocol counter acceptance rules (see [CANARY.md](CANARY.md)):
287
+
288
+ - Word Used events MUST be signed by a pubkey listed in the group's `p` tags
289
+ (kind 38800). Clients MUST reject events from non-members.
290
+ - `new_counter` in the encrypted payload MUST be greater than the client's current
291
+ counter. Clients MUST reject `new_counter <= local_counter` (replay protection).
292
+ - `new_counter` MUST NOT exceed `time_based_counter + 100`. Clients MUST reject
293
+ larger jumps to bound counter drift from compromised senders.
294
+ - Clients MUST deduplicate events by event ID.
295
+ - `used_by` in the encrypted payload MUST equal the event signer's pubkey. Clients MUST
296
+ reject events where `used_by` does not match the signer. If `used_by` is omitted,
297
+ the signer is assumed to be the user who triggered the advancement.
298
+
299
+ ### Kind 20800: Encrypted Location Beacon
300
+
301
+ Ephemeral event carrying AES-256-GCM encrypted location data. Used for periodic heartbeat
302
+ beacons and duress location alerts. Encrypted with a beacon key derived from the group seed
303
+ (not NIP-44).
304
+
305
+ ```json
306
+ {
307
+ "kind": 20800,
308
+ "content": "<AES-256-GCM encrypted payload>",
309
+ "tags": [
310
+ ["h", "<group-identifier>"]
311
+ ]
312
+ }
313
+ ```
314
+
315
+ The `h` tag identifies the group. The encrypted payload varies by type:
316
+
317
+ **Normal beacon payload:**
318
+
319
+ ```json
320
+ {
321
+ "geohash": "<geohash string>",
322
+ "precision": 6,
323
+ "timestamp": 1709510400
324
+ }
325
+ ```
326
+
327
+ **Duress alert payload:**
328
+
329
+ ```json
330
+ {
331
+ "type": "duress",
332
+ "member": "<pubkey of member under duress>",
333
+ "geohash": "<geohash string>",
334
+ "precision": 11,
335
+ "locationSource": "beacon",
336
+ "timestamp": 1709510400
337
+ }
338
+ ```
339
+
340
+ | Field | Required | Description |
341
+ |------------------|----------|----------------------------------------------------------------|
342
+ | `type` | MUST | `"duress"` — distinguishes from normal beacons |
343
+ | `member` | MUST | Pubkey of the member who is under duress |
344
+ | `geohash` | MUST | Location geohash (empty string if unavailable) |
345
+ | `precision` | MUST | Geohash precision (0 if unavailable, 11 for high-precision duress) |
346
+ | `locationSource` | MUST | `"beacon"`, `"verifier"`, or `"none"` |
347
+ | `timestamp` | MUST | Unix timestamp of the alert |
348
+
349
+ Duress alert beacons SHOULD use maximum geohash precision (11) to aid emergency response.
350
+ The `locationSource` indicates where the location came from: the member's own beacon, the
351
+ verifier's location, or unavailable.
352
+
353
+ Beacon key derivation: `HMAC-SHA256(key=seed, data=utf8("canary:beacon:key"))`.
354
+
355
+ ### Sync Envelope Encryption
356
+
357
+ Sync messages are protected by a symmetric envelope layer derived from the group seed.
358
+ This is distinct from the beacon key and from NIP-44: NIP-44 encrypts Nostr event
359
+ `content` fields point-to-point between two Nostr keys; envelope encryption wraps sync
360
+ messages group-wide and is transport-agnostic.
361
+
362
+ #### Group Key Derivation
363
+
364
+ The symmetric key for envelope encryption is derived as:
365
+
366
+ ```
367
+ group_key = HMAC-SHA256(key=hex_to_bytes(seed), data=utf8("canary:sync:key"))
368
+ ```
369
+
370
+ Implementations MUST use this derivation and MUST NOT reuse the beacon key
371
+ (`"canary:beacon:key"`) for sync envelopes.
372
+
373
+ #### Group Signing Key
374
+
375
+ Each participant derives a per-group signing identity by binding the group seed to their
376
+ personal private key:
377
+
378
+ ```
379
+ group_signing_key = HMAC-SHA256(key=hex_to_bytes(seed), data=utf8("canary:sync:sign:") || hex_to_bytes(personal_privkey))
380
+ ```
381
+
382
+ Binding the personal private key ensures that each participant's signing identity within
383
+ the group is unique, even across reseed events. Implementations MUST use the participant's
384
+ 32-byte private key (64-character lowercase hex) as the binding material.
385
+
386
+ #### Group Tag Hashing
387
+
388
+ To address a group on a relay without revealing the group identifier, implementations
389
+ derive a public tag by hashing the group identifier:
390
+
391
+ ```
392
+ group_tag = hex(SHA-256(utf8(group_id)))
393
+ ```
394
+
395
+ The resulting 64-character lowercase hex string MAY be published in relay filter tags.
396
+ Observers can query for group events without learning the group identifier from which the
397
+ tag was derived.
398
+
399
+ #### Envelope Format
400
+
401
+ Sync payloads MUST be encrypted using AES-256-GCM:
402
+
403
+ 1. Generate a random 12-byte IV.
404
+ 2. Encrypt the UTF-8 plaintext with `AES-256-GCM(key=group_key, iv=iv, plaintext)`.
405
+ 3. The Web Crypto API returns `ciphertext || auth_tag` concatenated.
406
+ 4. Encode as `base64(iv || ciphertext || auth_tag)`.
407
+
408
+ Decryption reverses this: base64-decode, split the leading 12 bytes as IV, pass the
409
+ remainder to AES-256-GCM. Implementations MUST reject any payload where authentication
410
+ fails.
411
+
412
+ #### Relationship to NIP-44
413
+
414
+ | Layer | Algorithm | Scope | Key source |
415
+ |--------------------|--------------|------------------------------------|-----------------------|
416
+ | Nostr event content | NIP-44 | Point-to-point between Nostr keys | Recipient's pubkey |
417
+ | Sync envelope | AES-256-GCM | Group-wide; transport-agnostic | `canary:sync:key` derivation |
418
+ | Location beacon | AES-256-GCM | Group-wide; ephemeral relay events | `canary:beacon:key` derivation |
419
+
420
+ NIP-44 MUST be used for all Nostr event `content` fields (kinds 38800, 28800, 38801,
421
+ 28801, 28802). Envelope encryption MUST be used for sync-layer payloads that are not
422
+ carried directly in a Nostr event `content` field.
423
+
424
+ ## Group Lifecycle
425
+
426
+ ### Creation
427
+
428
+ 1. Creator generates a cryptographically random 256-bit group seed.
429
+ 2. Creator publishes a Canary Group event (kind 38800) naming all initial members.
430
+ 3. Creator publishes Seed Distribution events (kind 28800) to each member, encrypted
431
+ with NIP-44.
432
+
433
+ ### Active Use
434
+
435
+ Members independently derive the current verification word from the shared seed and
436
+ counter. No network is required for derivation. When a word is used:
437
+
438
+ 1. The verifying member broadcasts a Word Used event (kind 28802).
439
+ 2. All members advance their counter.
440
+
441
+ ### Member Removal
442
+
443
+ 1. Creator publishes an updated Canary Group event (kind 38800) with the removed member's
444
+ `p` tag deleted. This is the canonical membership update — all clients derive the
445
+ member list from 38800's `p` tags.
446
+ 2. Creator publishes a Member Update event (kind 38801) with `action=remove`.
447
+ 3. Creator generates a new group seed.
448
+ 4. Creator publishes a Re-seed event (kind 28801) with `reason=member_removed`.
449
+ 5. Creator distributes the new seed to all remaining members (kind 28800).
450
+
451
+ ### Member Addition
452
+
453
+ 1. Creator publishes an updated Canary Group event (kind 38800) with the new member's
454
+ `p` tag added.
455
+ 2. Creator publishes a Member Update event (kind 38801) with `action=add`.
456
+ 3. Creator distributes the current seed to the new member (kind 28800). A re-seed is
457
+ NOT required.
458
+
459
+ ### Duress Detection
460
+
461
+ 1. Verifier detects a duress word (see Verification Algorithm).
462
+ 2. Verifier broadcasts a Word Used event (kind 28802) with `duress=true`.
463
+ 3. Clients handle silently. The verifier acts normally to avoid alerting the attacker.
464
+ 4. Creator initiates a re-seed (kind 28801) with `reason=duress`, excluding the
465
+ compromised member.
466
+
467
+ ### Dissolution
468
+
469
+ A group is dissolved when the creator removes all members, or when the NIP-40
470
+ `expiration` timestamp is reached. Clients MUST wipe the group seed from local storage
471
+ upon dissolution.
472
+
473
+ ### Implementation Note
474
+
475
+ The lifecycle above describes the protocol-level event flow. In the reference
476
+ implementation (`canary-kit`), state management and event construction are
477
+ separate concerns:
478
+
479
+ | Protocol operation | State function | Event builder(s) |
480
+ |----------------------------|--------------------|-------------------------------------|
481
+ | Create group + distribute | `createGroup()` | `buildGroupEvent()` + `buildSeedDistributionEvent()` × N |
482
+ | Add member | `addMember()` | `buildMemberUpdateEvent()` + `buildGroupEvent()` + `buildSeedDistributionEvent()` |
483
+ | Remove member + reseed | `removeMember()` | `buildMemberUpdateEvent()` + `buildGroupEvent()` + `buildReseedEvent()` + `buildSeedDistributionEvent()` |
484
+ | Advance counter | `advanceCounter()` | `buildWordUsedEvent()` |
485
+ | Full reseed | `reseed()` | `buildReseedEvent()` + `buildSeedDistributionEvent()` × N |
486
+
487
+ State functions (`src/group.ts`) are pure local transforms — they return new
488
+ state without side effects. Event builders (`canary-kit/nostr`) construct
489
+ unsigned Nostr events from that state. The application is responsible for
490
+ signing and publishing events to relays.
491
+
492
+ The protocol complexity exists to enable interoperable implementations. The SDK
493
+ complexity does not.
494
+
495
+ ## Transport Layers
496
+
497
+ Word derivation is entirely local. Network transport is used only for counter
498
+ synchronisation, seed distribution, and group management.
499
+
500
+ ### Nostr (Primary)
501
+
502
+ All event kinds are published to one or more Nostr relays. NIP-44 encryption throughout.
503
+ Relays see group membership metadata (public keys in `p` tags) but MUST NOT have access
504
+ to seeds, tokens, or reason text.
505
+
506
+ ### Meshtastic (Fallback)
507
+
508
+ When Nostr relays are unavailable, a canary group MAY operate over Meshtastic mesh
509
+ radio with a channel PSK derived as:
510
+
511
+ ```
512
+ channel_psk = HMAC-SHA256(key=group_seed, data="meshtastic-channel-key")
513
+ ```
514
+
515
+ | Message type | Format |
516
+ |-------------------|----------------------------------------------------------------|
517
+ | Seed distribution | Encrypted to channel PSK |
518
+ | Word Used signal | `{"u":<new_counter>}` |
519
+ | Duress alert | `{"d":"<first 8 hex chars of pubkey>"}` |
520
+ | Group management | NOT supported — use Nostr or in-person exchange |
521
+
522
+ ### In-Person (Last Resort)
523
+
524
+ For initial seed exchange or emergency re-seeding, an implementation MAY present the
525
+ encrypted seed payload as a QR code for scanning. The QR payload MUST be a NIP-44
526
+ encrypted Seed Distribution payload. This mechanism works fully offline.
527
+
528
+ ## Nostr Group Test Vectors
529
+
530
+ The following vectors define canonical outputs for the Nostr canary group derivation
531
+ scheme. Seeds and public keys are hex-encoded 32-byte values. Counters are unsigned
532
+ 64-bit integers. Words are from the `en-v1` wordlist.
533
+
534
+ **Inputs:**
535
+
536
+ ```
537
+ SEED_1 = 0000000000000000000000000000000000000000000000000000000000000001
538
+ SEED_2 = ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
539
+ PUBKEY_A = 0000000000000000000000000000000000000000000000000000000000000002
540
+ PUBKEY_B = 0000000000000000000000000000000000000000000000000000000000000003
541
+ ```
542
+
543
+ **Algorithm (CANARY-DERIVE and CANARY-DURESS with `context = "canary:group"`):**
544
+
545
+ ```
546
+ verification (CANARY-DERIVE):
547
+ token_bytes = HMAC-SHA256(seed, utf8("canary:group") || counter_be32)
548
+ index = uint16_be(token_bytes[0:2]) mod 2048
549
+ word = wordlist[index]
550
+
551
+ duress (CANARY-DURESS):
552
+ duress_data = utf8("canary:group:duress") || 0x00 || utf8(pubkey) || counter_be32
553
+ duress_bytes = HMAC-SHA256(seed, duress_data)
554
+ index = uint16_be(duress_bytes[0:2]) mod 2048
555
+ word = wordlist[index]
556
+
557
+ # Cross-counter collision avoidance per CANARY-DURESS §Collision Avoidance:
558
+ # forbidden set spans ±(2 × maxTolerance) counter values.
559
+ forbidden = { verification_word(c) for c in [max(0, counter - 2*tol),
560
+ ...,
561
+ counter + 2*tol] }
562
+ if word in forbidden:
563
+ for suffix in 0x01..0xFF:
564
+ duress_bytes = HMAC-SHA256(seed, duress_data || byte(suffix))
565
+ index = uint16_be(duress_bytes[0:2]) mod 2048
566
+ word = wordlist[index]
567
+ if word not in forbidden: break
568
+ ```
569
+
570
+ **Vector Table:**
571
+
572
+ | # | Function | Seed | Pubkey | Counter | Expected output |
573
+ |----|---------------------|--------|----------|---------|----------------------------------|
574
+ | 1 | verification word | SEED_1 | — | 0 | `garnet` |
575
+ | 2 | verification word | SEED_1 | — | 1 | `twice` |
576
+ | 3 | verification word | SEED_2 | — | 0 | `gossip` |
577
+ | 4 | verification word | SEED_1 | — | 100 | `treat` |
578
+ | 5 | verification phrase | SEED_1 | — | 0 | `["garnet", "inject"]` |
579
+ | 6 | verification phrase | SEED_1 | — | 0 | `["garnet", "inject", "garnet"]` |
580
+ | 7 | duress word | SEED_1 | PUBKEY_A | 0 | `theory` |
581
+ | 8 | duress word | SEED_1 | PUBKEY_B | 0 | `cedar` |
582
+
583
+ Notes:
584
+
585
+ - Vector 5 uses `words=2`. First word from `mac[0:2]`, second from `mac[2:4]`.
586
+ - Vector 6 uses `words=3`. Bytes 0–1 and 4–5 produce the same index; the repeated word
587
+ is a correct output.
588
+ - Vectors 7–8: Duress words are distinct from the verification word (`garnet`) — no
589
+ collision re-derivation needed.
590
+
591
+ **Round-Trip Verification:**
592
+
593
+ | # | Input word | Seed | Members | Counter | Expected status | Expected members |
594
+ |----|------------|--------|----------------------|---------|-----------------|------------------|
595
+ | 9 | `garnet` | SEED_1 | [PUBKEY_A, PUBKEY_B] | 0 | `verified` | — |
596
+ | 10 | `theory` | SEED_1 | [PUBKEY_A, PUBKEY_B] | 0 | `duress` | [PUBKEY_A] |
597
+
598
+ ## Dependencies
599
+
600
+ - **NIP-44**: Versioned encryption (for all Nostr event `content` fields and seed
601
+ distribution payloads)
602
+ - **NIP-40**: Expiration tags (for group auto-dissolution and ephemeral event expiry)
603
+
604
+ ## Appendix A: English Wordlist (en-v1)
605
+
606
+ The canonical `en-v1` wordlist is maintained in the reference implementation repository
607
+ and distributed with the `canary-kit` npm package. Implementations MUST use this exact
608
+ list without modification. The wordlist file contains exactly 2048 entries, one word per
609
+ line, ordered alphabetically, UTF-8 encoding, Unix line endings.
610
+
611
+ Reference: `https://github.com/TheCryptoDonkey/canary-kit` — `src/wordlists/en-v1.txt`
612
+
613
+ ## Reference Implementation
614
+
615
+ ### Nostr Group API
616
+
617
+ ```typescript
618
+ import {
619
+ createGroup, getCurrentWord, verifyWord,
620
+ deriveDuressWord, deriveVerificationWord,
621
+ } from 'canary-kit'
622
+ ```
623
+
624
+ Source: `https://github.com/TheCryptoDonkey/canary-kit`