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.
- package/CANARY.md +1065 -0
- package/INTEGRATION.md +351 -0
- package/LICENSE +21 -0
- package/NIP-CANARY.md +624 -0
- package/README.md +187 -0
- package/SECURITY.md +92 -0
- package/dist/beacon.d.ts +104 -0
- package/dist/beacon.d.ts.map +1 -0
- package/dist/beacon.js +197 -0
- package/dist/beacon.js.map +1 -0
- package/dist/counter.d.ts +37 -0
- package/dist/counter.d.ts.map +1 -0
- package/dist/counter.js +62 -0
- package/dist/counter.js.map +1 -0
- package/dist/crypto.d.ts +111 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +309 -0
- package/dist/crypto.js.map +1 -0
- package/dist/derive.d.ts +68 -0
- package/dist/derive.d.ts.map +1 -0
- package/dist/derive.js +85 -0
- package/dist/derive.js.map +1 -0
- package/dist/encoding.d.ts +56 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +98 -0
- package/dist/encoding.js.map +1 -0
- package/dist/group.d.ts +185 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +263 -0
- package/dist/group.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/nostr.d.ts +134 -0
- package/dist/nostr.d.ts.map +1 -0
- package/dist/nostr.js +175 -0
- package/dist/nostr.js.map +1 -0
- package/dist/presets.d.ts +26 -0
- package/dist/presets.d.ts.map +1 -0
- package/dist/presets.js +39 -0
- package/dist/presets.js.map +1 -0
- package/dist/session.d.ts +114 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +173 -0
- package/dist/session.js.map +1 -0
- package/dist/sync-crypto.d.ts +66 -0
- package/dist/sync-crypto.d.ts.map +1 -0
- package/dist/sync-crypto.js +125 -0
- package/dist/sync-crypto.js.map +1 -0
- package/dist/sync.d.ts +191 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +568 -0
- package/dist/sync.js.map +1 -0
- package/dist/token.d.ts +186 -0
- package/dist/token.d.ts.map +1 -0
- package/dist/token.js +344 -0
- package/dist/token.js.map +1 -0
- package/dist/verify.d.ts +45 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +59 -0
- package/dist/verify.js.map +1 -0
- package/dist/wordlist.d.ts +28 -0
- package/dist/wordlist.d.ts.map +1 -0
- package/dist/wordlist.js +297 -0
- package/dist/wordlist.js.map +1 -0
- package/llms-full.txt +1461 -0
- package/llms.txt +180 -0
- 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`
|