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/CANARY.md
ADDED
|
@@ -0,0 +1,1065 @@
|
|
|
1
|
+
CANARY Protocol
|
|
2
|
+
===============
|
|
3
|
+
|
|
4
|
+
Coercion-Resistant Spoken Verification Protocol
|
|
5
|
+
------------------------------------------------
|
|
6
|
+
|
|
7
|
+
`version 1.0` `draft`
|
|
8
|
+
|
|
9
|
+
> **Compatibility policy:** Protocol v1 has `draft` status. No backward
|
|
10
|
+
> compatibility is guaranteed until `stable` designation. Breaking changes
|
|
11
|
+
> increment the `protocolVersion` field; implementations MUST reject messages
|
|
12
|
+
> with an unrecognised version.
|
|
13
|
+
|
|
14
|
+
## Abstract
|
|
15
|
+
|
|
16
|
+
CANARY is a protocol for coercion-resistant spoken verification. It combines
|
|
17
|
+
deterministic token derivation (extending the HMAC-counter pattern from
|
|
18
|
+
HOTP/TOTP) with duress signalling and human-spoken output — three properties
|
|
19
|
+
that exist independently in prior standards but have never been combined in a
|
|
20
|
+
single protocol.
|
|
21
|
+
|
|
22
|
+
The protocol is transport-agnostic — no Nostr, no cryptocurrency, no network
|
|
23
|
+
assumptions. Implementations can operate over Nostr, Signal, Matrix, SMS,
|
|
24
|
+
Meshtastic, radio, or in-person QR codes.
|
|
25
|
+
|
|
26
|
+
The protocol is defined in three layers:
|
|
27
|
+
|
|
28
|
+
1. **CANARY-DERIVE** — Deterministic token derivation from a shared secret, context string,
|
|
29
|
+
and counter
|
|
30
|
+
2. **CANARY-DURESS** — Coercion-resistant alternate tokens and dead man's switch (liveness
|
|
31
|
+
monitoring)
|
|
32
|
+
3. **CANARY-WORDLIST** — Spoken-word encoding optimised for voice clarity
|
|
33
|
+
|
|
34
|
+
## Motivation
|
|
35
|
+
|
|
36
|
+
AI voice cloning now requires as little as three seconds of audio. A thirty-second clip
|
|
37
|
+
and thirty minutes of work can produce a convincing clone. The security advice is already
|
|
38
|
+
widespread — "agree on a family safe word" — but common implementations are dangerously
|
|
39
|
+
naive:
|
|
40
|
+
|
|
41
|
+
- Static words that never rotate (one compromise burns the word forever)
|
|
42
|
+
- Human-chosen words with low entropy (predictable and guessable)
|
|
43
|
+
- No duress signalling (if a member is forced to reveal the word, there is no silent alarm)
|
|
44
|
+
- No protocol (just "remember a word" — no tooling, no synchronisation, no offline support)
|
|
45
|
+
|
|
46
|
+
Existing standards each solve part of the problem but not all of it:
|
|
47
|
+
|
|
48
|
+
| Standard | Rotating tokens | Coercion resistance | Human-spoken output |
|
|
49
|
+
|----------------|:---------------:|:-------------------:|:-------------------:|
|
|
50
|
+
| TOTP (RFC 6238)| Yes | No | No |
|
|
51
|
+
| HOTP (RFC 4226)| Yes | No | No |
|
|
52
|
+
| SAS (ZRTP) | No | No | Yes |
|
|
53
|
+
| BIP-39 | No | No | Yes |
|
|
54
|
+
| **CANARY** | **Yes** | **Yes** | **Yes** |
|
|
55
|
+
|
|
56
|
+
CANARY solves all of these by combining well-understood cryptographic primitives
|
|
57
|
+
(HMAC-SHA256, counters, wordlists) into a single protocol with coercion resistance as a
|
|
58
|
+
first-class property.
|
|
59
|
+
|
|
60
|
+
### Relationship to HOTP/TOTP
|
|
61
|
+
|
|
62
|
+
CANARY's derivation follows the HMAC-counter pattern established by HOTP
|
|
63
|
+
(RFC 4226) and TOTP (RFC 6238). The core operation — `HMAC(secret, counter)`,
|
|
64
|
+
truncated and encoded — is structurally identical. CANARY extends this pattern
|
|
65
|
+
with context-string domain separation, enabling multiple derivation channels
|
|
66
|
+
(verification, duress, liveness) from a single shared secret without requiring
|
|
67
|
+
separate keys or counters.
|
|
68
|
+
|
|
69
|
+
| Property | HOTP/TOTP | CANARY |
|
|
70
|
+
|---------------------|---------------------|-------------------------------------|
|
|
71
|
+
| Hash function | SHA-1 (SHA-256 opt) | SHA-256 (mandatory) |
|
|
72
|
+
| Counter encoding | 8-byte BE integer | Context string + 4-byte BE integer |
|
|
73
|
+
| Output encoding | 6–8 digit code | Words, PIN, or hex |
|
|
74
|
+
| Derivation channels | 1 per (secret, ctr) | Multiple via context strings |
|
|
75
|
+
| Coercion resistance | None | Duress + liveness channels |
|
|
76
|
+
| Target verifier | Machine | Human (spoken) |
|
|
77
|
+
|
|
78
|
+
## Terminology
|
|
79
|
+
|
|
80
|
+
| Term | Definition |
|
|
81
|
+
|--------------------|------------------------------------------------------------------------------------------------|
|
|
82
|
+
| Secret | A 256-bit (32-byte) shared key known to all verifying parties |
|
|
83
|
+
| Context | A UTF-8 string identifying the derivation purpose (e.g. `"canary:verify"`, `"dispatch:handoff"`) |
|
|
84
|
+
| Counter | An unsigned integer determining the current token; scheme is application-defined |
|
|
85
|
+
| Identity | A UTF-8 string identifying a specific person (pubkey, username, employee ID, etc.) |
|
|
86
|
+
| Verification token | The current group token, derived from secret + context + counter |
|
|
87
|
+
| Duress token | A person's coercion token, derived from secret + context + identity + counter |
|
|
88
|
+
| Liveness token | A heartbeat token proving "I am alive and hold the secret" |
|
|
89
|
+
| Encoding | Output format: words, PIN, or hex |
|
|
90
|
+
| Wordlist | A curated list of exactly 2048 words optimised for spoken clarity |
|
|
91
|
+
| Burn-after-use | Advancing the counter early after a token is used for verification |
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## CANARY-DERIVE
|
|
96
|
+
|
|
97
|
+
Core deterministic token derivation. The universal primitive that all other layers build on.
|
|
98
|
+
|
|
99
|
+
### Algorithm
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
token_bytes = HMAC-SHA256(secret, utf8(context) || counter_be32)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Where:
|
|
106
|
+
|
|
107
|
+
- `secret` — 256-bit (32-byte) shared key
|
|
108
|
+
- `context` — UTF-8 encoded string identifying the derivation purpose
|
|
109
|
+
- `counter_be32` — 4-byte big-endian unsigned integer
|
|
110
|
+
- Output: 32 bytes (256 bits), truncated and encoded per the output format
|
|
111
|
+
|
|
112
|
+
The `context` string ensures the same secret can derive different tokens for different
|
|
113
|
+
purposes without collision. For example, `"canary:verify"` and `"dispatch:handoff"` produce
|
|
114
|
+
entirely independent token sequences from the same secret.
|
|
115
|
+
|
|
116
|
+
### Counter Schemes
|
|
117
|
+
|
|
118
|
+
The protocol does not mandate a specific counter scheme. Implementations choose one or
|
|
119
|
+
more based on their use case:
|
|
120
|
+
|
|
121
|
+
| Scheme | Counter value | Use case |
|
|
122
|
+
|-----------------|-------------------------------------|-----------------------------------------|
|
|
123
|
+
| **Time-based** | `floor(unix_time / period)` | Canary groups (7d), short-lived (30s) |
|
|
124
|
+
| **Sequence** | Monotonic integer | Burn-after-use, one-time tokens |
|
|
125
|
+
| **Event-based** | Deterministic from event ID | Platforms (hash of task ID) |
|
|
126
|
+
|
|
127
|
+
### Tolerance Window
|
|
128
|
+
|
|
129
|
+
Verifiers SHOULD accept tokens within `±tolerance` counter values to handle clock skew
|
|
130
|
+
and latency. The tolerance value is application-defined (e.g. short-lived tokens: ±1 epoch,
|
|
131
|
+
canary-kit groups: 0).
|
|
132
|
+
|
|
133
|
+
Recommended tolerance values by use case:
|
|
134
|
+
|
|
135
|
+
| Use case | Tolerance | Rationale |
|
|
136
|
+
|-------------------|-----------|----------------------------------------------|
|
|
137
|
+
| Casual (family) | ±1 | Forgiving; handles minor sync drift |
|
|
138
|
+
| High-security | 0 | Strict; requires exact counter match |
|
|
139
|
+
| TOTP-equivalent | ±1 | Standard TOTP practice (RFC 6238 §5.2) |
|
|
140
|
+
|
|
141
|
+
The tolerance used by verifiers MUST be communicated to duress token derivers, because the
|
|
142
|
+
duress collision avoidance window (see §Collision Avoidance) is computed from this value.
|
|
143
|
+
|
|
144
|
+
### Counter Acceptance
|
|
145
|
+
|
|
146
|
+
Implementations that receive counter advancement signals (burn-after-use notifications,
|
|
147
|
+
re-sync messages) MUST enforce the following rules:
|
|
148
|
+
|
|
149
|
+
1. **Authorised source:** Counter advancement signals MUST originate from a party known
|
|
150
|
+
to hold the shared secret. The mechanism for verifying this is transport-defined
|
|
151
|
+
(e.g. cryptographic signature from a known group member).
|
|
152
|
+
|
|
153
|
+
2. **Monotonic advancement:** Implementations MUST reject counter updates where
|
|
154
|
+
`new_counter <= local_counter`. This provides replay protection and prevents
|
|
155
|
+
counter rollback.
|
|
156
|
+
|
|
157
|
+
3. **Bounded jumps:** Implementations MUST reject counter updates where
|
|
158
|
+
`new_counter > time_based_counter + max_offset`. The default `max_offset` is
|
|
159
|
+
100. This bounds the damage from a compromised sender attempting to desynchronise
|
|
160
|
+
the group by jumping the counter far ahead.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## CANARY-SYNC: Transport-Agnostic Synchronisation
|
|
165
|
+
|
|
166
|
+
CANARY-SYNC is the protocol layer for propagating group state mutations and telemetry
|
|
167
|
+
across any transport without depending on Nostr or any specific relay infrastructure.
|
|
168
|
+
It operates over any channel capable of delivering authenticated, ordered or unordered
|
|
169
|
+
messages — WebSocket, Bluetooth LE, Meshtastic, Matrix, or a direct TCP connection.
|
|
170
|
+
|
|
171
|
+
### Protocol Version
|
|
172
|
+
|
|
173
|
+
All CANARY-SYNC messages carry a `protocolVersion` field. The current version is **2**.
|
|
174
|
+
Receivers MUST reject any message whose `protocolVersion` does not exactly equal `2`.
|
|
175
|
+
Version negotiation is not supported — a version mismatch is a hard error. Senders MUST
|
|
176
|
+
inject `protocolVersion: 2` into every message before encoding.
|
|
177
|
+
|
|
178
|
+
### Message Types
|
|
179
|
+
|
|
180
|
+
CANARY-SYNC defines eight message types grouped by function:
|
|
181
|
+
|
|
182
|
+
#### State Mutations
|
|
183
|
+
|
|
184
|
+
These messages change persistent group state and require epoch/opId replay protection.
|
|
185
|
+
Only admins may send state-mutation messages (except `counter-advance` and self-leave —
|
|
186
|
+
see §Privileged Actions).
|
|
187
|
+
|
|
188
|
+
| Type | Required Fields | Description |
|
|
189
|
+
|------|----------------|-------------|
|
|
190
|
+
| `member-join` | `pubkey`, `displayName?`, `timestamp`, `epoch`, `opId` | Add a member to the group. Admin-only unless the sender is adding themselves (self-join proves possession of the group key via envelope decryption). |
|
|
191
|
+
| `member-leave` | `pubkey`, `timestamp`, `epoch`, `opId` | Remove a member from the group. Admin-only unless `pubkey` equals `sender` (self-leave). |
|
|
192
|
+
| `counter-advance` | `counter`, `usageOffset`, `timestamp` | Advance the group counter (burn-after-use). Any current member may send; subject to monotonicity and bounded-jump constraints (see §Bounded Counter Advance). No `epoch`/`opId` required. |
|
|
193
|
+
| `reseed` | `seed`, `counter`, `timestamp`, `epoch`, `opId`, `admins[]`, `members[]` | Replace seed, counter, members, and admins atomically. Admin-only. `epoch` MUST equal `current_epoch + 1`. Clears all replay state. |
|
|
194
|
+
|
|
195
|
+
#### Telemetry (Fire-and-Forget)
|
|
196
|
+
|
|
197
|
+
These messages convey real-time signals that do not persist in group state. They are
|
|
198
|
+
subject to freshness constraints but not epoch-based replay protection.
|
|
199
|
+
|
|
200
|
+
| Type | Required Fields | Description |
|
|
201
|
+
|------|----------------|-------------|
|
|
202
|
+
| `beacon` | `lat`, `lon`, `accuracy`, `timestamp`, `opId` | Share the sender's current location. `lat` MUST be in `[−90, 90]`; `lon` in `[−180, 180]`; `accuracy` in `[0, 20_000_000]` metres. |
|
|
203
|
+
| `duress-alert` | `lat`, `lon`, `timestamp`, `opId`, `subject?` | Emergency location alert. Same coordinate constraints as `beacon`. See §Passive Duress. |
|
|
204
|
+
| `liveness-checkin` | `pubkey`, `timestamp`, `opId` | Heartbeat proving presence. Receiver MUST verify that `pubkey` equals `sender`. See §Passive Duress. |
|
|
205
|
+
|
|
206
|
+
#### Recovery
|
|
207
|
+
|
|
208
|
+
| Type | Required Fields | Description |
|
|
209
|
+
|------|----------------|-------------|
|
|
210
|
+
| `state-snapshot` | `seed`, `counter`, `usageOffset`, `members[]`, `admins[]`, `epoch`, `opId`, `timestamp`, `prevEpochSeed?` | Admin-issued full state snapshot for catch-up. Subject to same-epoch anti-rollback constraints; higher-epoch snapshots are deliberately disabled (see §State Snapshot Recovery). |
|
|
211
|
+
|
|
212
|
+
### Field Constraints
|
|
213
|
+
|
|
214
|
+
- `pubkey`, `seed`, and `prevEpochSeed` fields MUST be 64-character lowercase hex strings
|
|
215
|
+
(32 bytes). `seed` in `reseed` is a `Uint8Array` in-process; it is hex-encoded for wire
|
|
216
|
+
transport and decoded back to bytes on receipt.
|
|
217
|
+
- `opId` MUST be a non-empty string of at most 128 characters.
|
|
218
|
+
- `epoch` and `counter` MUST be non-negative integers.
|
|
219
|
+
- `admins` MUST be a subset of `members` (all admins are members).
|
|
220
|
+
- `timestamp` MUST be a non-negative integer (Unix seconds).
|
|
221
|
+
|
|
222
|
+
### Privileged Actions
|
|
223
|
+
|
|
224
|
+
Certain message types require the sender to be an admin of the group at the time of
|
|
225
|
+
processing. The following rules govern privilege:
|
|
226
|
+
|
|
227
|
+
- `reseed` and `state-snapshot` are always privileged.
|
|
228
|
+
- `member-join` is privileged unless `msg.pubkey === sender` (self-join). Self-join is
|
|
229
|
+
permitted because successful decryption of the group envelope proves the sender holds
|
|
230
|
+
a valid admin-issued group key.
|
|
231
|
+
- `member-leave` is privileged unless `msg.pubkey === sender` (self-leave).
|
|
232
|
+
- `counter-advance` is not privileged; any current group member MAY send it.
|
|
233
|
+
- Fire-and-forget messages (`beacon`, `duress-alert`, `liveness-checkin`) are not privileged.
|
|
234
|
+
|
|
235
|
+
Implementations MUST fail closed: a privileged message with no identified sender MUST be
|
|
236
|
+
silently dropped without modifying group state.
|
|
237
|
+
|
|
238
|
+
### Epoch-Based Replay Protection
|
|
239
|
+
|
|
240
|
+
Each `reseed` increments the epoch counter by exactly one. Epoch numbers MUST be
|
|
241
|
+
monotonically increasing (Invariant I1).
|
|
242
|
+
|
|
243
|
+
Every state-mutating message that is subject to replay protection carries an `opId`. The
|
|
244
|
+
group state maintains a `consumedOps` list of opIds processed within the current epoch. A
|
|
245
|
+
message whose `opId` is already in `consumedOps` MUST be silently dropped.
|
|
246
|
+
|
|
247
|
+
On `reseed`, the new epoch begins and `consumedOps` is reset — the `reseed` message's own
|
|
248
|
+
`opId` is the sole initial entry (Invariant I4).
|
|
249
|
+
|
|
250
|
+
To bound memory growth, `consumedOps` is capped at 1000 entries per epoch. When this
|
|
251
|
+
limit is exceeded, the oldest entries are evicted and a `consumedOpsFloor` timestamp is
|
|
252
|
+
recorded. Any subsequent message with `timestamp ≤ consumedOpsFloor` MUST be dropped,
|
|
253
|
+
preventing replay of evicted operations.
|
|
254
|
+
|
|
255
|
+
Epoch matching rules for privileged operations:
|
|
256
|
+
|
|
257
|
+
| Message type | Epoch constraint |
|
|
258
|
+
|---|---|
|
|
259
|
+
| `reseed` | `msg.epoch` MUST equal `state.epoch + 1` |
|
|
260
|
+
| All other privileged ops | `msg.epoch` MUST equal `state.epoch` |
|
|
261
|
+
| `state-snapshot` (same-epoch) | `msg.epoch` MUST equal `state.epoch` |
|
|
262
|
+
| `state-snapshot` (higher-epoch) | Rejected — see §State Snapshot Recovery |
|
|
263
|
+
|
|
264
|
+
Messages with `msg.epoch < state.epoch` (stale epoch) MUST be silently dropped (I6).
|
|
265
|
+
|
|
266
|
+
### Fire-and-Forget Freshness
|
|
267
|
+
|
|
268
|
+
Telemetry messages (`beacon`, `duress-alert`, `liveness-checkin`) MUST pass a freshness
|
|
269
|
+
gate before being accepted:
|
|
270
|
+
|
|
271
|
+
- Messages older than **300 seconds** (`FIRE_AND_FORGET_FRESHNESS_SEC`) are silently dropped.
|
|
272
|
+
- Messages timestamped more than **60 seconds** (`MAX_FUTURE_SKEW_SEC`) in the future are
|
|
273
|
+
silently dropped.
|
|
274
|
+
|
|
275
|
+
These messages do not modify group state. Implementations SHOULD route them to
|
|
276
|
+
application-layer handlers (e.g. alert the group on `duress-alert`, update a presence
|
|
277
|
+
indicator on `liveness-checkin`). See §Passive Duress.
|
|
278
|
+
|
|
279
|
+
### Bounded Counter Advance
|
|
280
|
+
|
|
281
|
+
The `counter-advance` message is not gated by admin privilege but is bounded to limit the
|
|
282
|
+
damage from a compromised or malicious group member.
|
|
283
|
+
|
|
284
|
+
On receipt of a `counter-advance` message, receivers MUST enforce:
|
|
285
|
+
|
|
286
|
+
1. **Member check:** `sender` MUST be a current group member. Non-members MUST be rejected.
|
|
287
|
+
2. **Monotonicity:** `msg.counter + msg.usageOffset` MUST be strictly greater than
|
|
288
|
+
`state.counter + state.usageOffset`. Counter rollback MUST be rejected.
|
|
289
|
+
3. **Bounded jump:** `msg.counter + msg.usageOffset` MUST NOT exceed
|
|
290
|
+
`floor(now / rotationInterval) + 100`. The offset of **100** (`MAX_COUNTER_ADVANCE_OFFSET`)
|
|
291
|
+
is a hard cap. Messages that would advance the counter beyond this bound MUST be dropped.
|
|
292
|
+
|
|
293
|
+
The bound cross-references the **Bounded jumps** rule in §Counter Acceptance: both use a
|
|
294
|
+
`max_offset` of 100 relative to the current time-based counter.
|
|
295
|
+
|
|
296
|
+
### State Invariants
|
|
297
|
+
|
|
298
|
+
The following invariants MUST be preserved by all conforming implementations:
|
|
299
|
+
|
|
300
|
+
| # | Invariant | Description |
|
|
301
|
+
|---|-----------|-------------|
|
|
302
|
+
| **I1** | Epoch monotonicity | `state.epoch` only increases. No message may decrease it. |
|
|
303
|
+
| **I2** | opId uniqueness per epoch | Within a given epoch, each `opId` is processed at most once. Duplicate `opId` values MUST be dropped. |
|
|
304
|
+
| **I3** | Admin-only mutations | Only admins may execute privileged actions (reseed, state-snapshot, adding/removing other members). |
|
|
305
|
+
| **I4** | Reseed atomicity | A `reseed` atomically replaces `{seed, counter, usageOffset, members, admins, epoch}` and resets `consumedOps` to `[reseed.opId]`. No partial application is permitted. |
|
|
306
|
+
| **I5** | Same-epoch snapshot anti-rollback | A same-epoch `state-snapshot` MUST be rejected unless: seed matches, incoming effective counter ≥ local effective counter, incoming members ⊇ local members, and incoming admins ⊇ local admins. |
|
|
307
|
+
| **I6** | Stale-epoch rejection | Any privileged message with `msg.epoch < state.epoch` MUST be silently dropped. |
|
|
308
|
+
|
|
309
|
+
### State Snapshot Recovery
|
|
310
|
+
|
|
311
|
+
`state-snapshot` is an admin-issued message that allows group members who missed
|
|
312
|
+
intermediate transitions to resynchronise their local state.
|
|
313
|
+
|
|
314
|
+
#### Same-Epoch Recovery
|
|
315
|
+
|
|
316
|
+
When `msg.epoch === state.epoch`, the snapshot is accepted if and only if all of the
|
|
317
|
+
following hold:
|
|
318
|
+
|
|
319
|
+
1. `msg.seed === state.seed` — the seed has not changed within this epoch (no silent reseed).
|
|
320
|
+
2. `msg.counter + msg.usageOffset >= state.counter + state.usageOffset` — counter does not
|
|
321
|
+
regress.
|
|
322
|
+
3. `msg.members` is a superset of `state.members` — member removals within an epoch require
|
|
323
|
+
a reseed.
|
|
324
|
+
4. `msg.admins` is a superset of `state.admins` — admin demotions require a reseed.
|
|
325
|
+
|
|
326
|
+
On acceptance, the receiver advances to the snapshot's counter, members, and admins, and
|
|
327
|
+
appends the `opId` to `consumedOps`.
|
|
328
|
+
|
|
329
|
+
#### Higher-Epoch Recovery — Deliberately Disabled
|
|
330
|
+
|
|
331
|
+
Higher-epoch snapshots (`msg.epoch > state.epoch`) are **rejected**. A group member who
|
|
332
|
+
misses one or more `reseed` messages cannot recover via snapshot and MUST be re-invited
|
|
333
|
+
by an admin.
|
|
334
|
+
|
|
335
|
+
This restriction eliminates the stale-admin hijack attack surface: a removed admin whose
|
|
336
|
+
local state is stale could otherwise fabricate a higher-epoch snapshot and push a forged
|
|
337
|
+
group state to members who missed the reseed. By rejecting higher-epoch snapshots entirely,
|
|
338
|
+
the attack surface is reduced to same-epoch fabrication, which is mitigated by the
|
|
339
|
+
sender-must-be-in-snapshot-admins self-consistency check at the transport layer.
|
|
340
|
+
|
|
341
|
+
**Known limitation:** Full mitigation of the stale-admin fabrication attack requires either
|
|
342
|
+
quorum-based recovery (multiple admins must co-sign a snapshot) or a verifiable reseed
|
|
343
|
+
chain (signed epoch transitions stored on a relay). Both are deferred to a future protocol
|
|
344
|
+
version.
|
|
345
|
+
|
|
346
|
+
### Deterministic Serialisation
|
|
347
|
+
|
|
348
|
+
All CANARY-SYNC messages MUST be serialised deterministically for signing. The canonical
|
|
349
|
+
form is produced by `canonicaliseSyncMessage`:
|
|
350
|
+
|
|
351
|
+
1. Binary fields (`seed` in `reseed`) are hex-encoded before serialisation.
|
|
352
|
+
2. All object keys are sorted recursively (depth-first).
|
|
353
|
+
3. Arrays are serialised in their original order (element order is significant).
|
|
354
|
+
4. No whitespace is emitted.
|
|
355
|
+
5. `undefined` values are omitted.
|
|
356
|
+
|
|
357
|
+
This canonical form is the byte string over which inner signatures are computed (H2). The
|
|
358
|
+
`protocolVersion` field MUST be present in the canonical form — the sender is responsible
|
|
359
|
+
for injecting `protocolVersion: 2` before both encoding and signing, ensuring that the
|
|
360
|
+
canonical bytes always reflect the actual wire value.
|
|
361
|
+
|
|
362
|
+
Wire encoding uses `JSON.stringify` with the `protocolVersion` field added. Binary fields
|
|
363
|
+
(`seed` in `reseed`) are hex-encoded for safe JSON round-tripping via `bytesToHex`.
|
|
364
|
+
|
|
365
|
+
### Group Key Derivation and Envelope Encryption
|
|
366
|
+
|
|
367
|
+
CANARY-SYNC messages are transmitted inside encrypted envelopes keyed to the group seed.
|
|
368
|
+
|
|
369
|
+
**Group key derivation:**
|
|
370
|
+
|
|
371
|
+
```
|
|
372
|
+
group_key = HMAC-SHA256(hex_to_bytes(seed), utf8("canary:sync:key"))
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
**Envelope encryption:** AES-256-GCM with a random 12-byte nonce. The wire format is:
|
|
376
|
+
|
|
377
|
+
```
|
|
378
|
+
base64(IV || ciphertext || auth_tag)
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
where `IV` is 12 bytes, `ciphertext` is the UTF-8 encoded JSON message, and `auth_tag` is
|
|
382
|
+
the 16-byte GCM authentication tag appended by the Web Crypto API. Decryption MUST throw
|
|
383
|
+
on authentication failure.
|
|
384
|
+
|
|
385
|
+
**Per-participant signing key derivation:**
|
|
386
|
+
|
|
387
|
+
```
|
|
388
|
+
signing_key = HMAC-SHA256(hex_to_bytes(seed), utf8("canary:sync:sign:") || hex_to_bytes(personal_privkey))
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Binding the personal private key ensures each participant's signing identity is unique
|
|
392
|
+
within the group, even across reseed events. A reseed invalidates all prior signing keys
|
|
393
|
+
derived from the old seed.
|
|
394
|
+
|
|
395
|
+
**Group tag hashing:** To avoid correlating events to a known group name, transport-layer
|
|
396
|
+
routing tags use a privacy-preserving hash:
|
|
397
|
+
|
|
398
|
+
```
|
|
399
|
+
group_tag = hex(SHA256(utf8(group_id)))
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### Cross-References
|
|
403
|
+
|
|
404
|
+
- **§Counter Acceptance** — defines the bounded-jump rule that `counter-advance` enforces.
|
|
405
|
+
- **§Passive Duress** — defines liveness monitoring; `liveness-checkin` and `duress-alert`
|
|
406
|
+
are the CANARY-SYNC wire representations of liveness heartbeats and active duress signals.
|
|
407
|
+
- **§Seed Storage** — the group seed from which `group_key` and `signing_key` are derived
|
|
408
|
+
MUST be wiped from storage on group dissolution.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
### Output Encodings
|
|
413
|
+
|
|
414
|
+
The encoding is a presentation layer, not part of the derivation. The same `token_bytes`
|
|
415
|
+
can be rendered in multiple formats:
|
|
416
|
+
|
|
417
|
+
| Format | Encoding | Example |
|
|
418
|
+
|-----------|----------------------------------------------------------|-------------------------------|
|
|
419
|
+
| **Words** | `uint16_be(bytes[i*2..i*2+2]) mod 2048` → wordlist | `net`, `throw drafter category` |
|
|
420
|
+
| **PIN** | First N bytes as big-endian integer, mod 10^digits | `2796` |
|
|
421
|
+
| **Hex** | Lowercase hex pairs, truncated to length | `c51524053f1f27a4` |
|
|
422
|
+
|
|
423
|
+
#### Word Encoding
|
|
424
|
+
|
|
425
|
+
Each word consumes 2 bytes (16 bits) from the token, reduced modulo the wordlist size
|
|
426
|
+
(2048 = 11 effective bits per word):
|
|
427
|
+
|
|
428
|
+
```
|
|
429
|
+
for i in 0..word_count:
|
|
430
|
+
index = uint16_be(token_bytes[i*2 .. i*2+2]) mod 2048
|
|
431
|
+
words[i] = wordlist[index]
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
A single HMAC-SHA256 output (32 bytes) provides enough material for up to 16 words.
|
|
435
|
+
|
|
436
|
+
#### PIN Encoding
|
|
437
|
+
|
|
438
|
+
The first `ceil(digits × 0.415)` bytes are interpreted as a big-endian integer, reduced
|
|
439
|
+
modulo `10^digits`, zero-padded to the requested length:
|
|
440
|
+
|
|
441
|
+
```
|
|
442
|
+
value = big_endian_int(token_bytes[0..N])
|
|
443
|
+
pin = (value mod 10^digits), zero-padded to `digits` characters
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
### Token Length and Security
|
|
447
|
+
|
|
448
|
+
More output = more security, harder to speak:
|
|
449
|
+
|
|
450
|
+
| Format | Bits | Possibilities | Use case |
|
|
451
|
+
|--------------|-------|---------------|----------------------------------|
|
|
452
|
+
| 1 word | ~11 | ~2,048 | Casual verification, voice call |
|
|
453
|
+
| 2–3 words | ~22–33| ~4M–8B | Group identity, high-security |
|
|
454
|
+
| 4-digit PIN | ~13.3 | 10,000 | Quick handoff (dispatch) |
|
|
455
|
+
| 6-digit code | ~19.9 | 1,000,000 | TOTP-equivalent |
|
|
456
|
+
|
|
457
|
+
### Directional Pair Pattern
|
|
458
|
+
|
|
459
|
+
A single shared token has an echo problem: the second party to speak could parrot
|
|
460
|
+
the first. CANARY solves this with **directional context strings** — each side
|
|
461
|
+
derives a different token from the same secret.
|
|
462
|
+
|
|
463
|
+
A directional pair uses a namespace and two role identifiers to construct two
|
|
464
|
+
context strings:
|
|
465
|
+
|
|
466
|
+
```
|
|
467
|
+
context_a = namespace + ":" + role_a
|
|
468
|
+
context_b = namespace + ":" + role_b
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Each party derives BOTH tokens. Party A speaks their token; Party B verifies it
|
|
472
|
+
against context_a. Then Party B speaks their token; Party A verifies against
|
|
473
|
+
context_b. Neither token can be derived from the other without the shared secret.
|
|
474
|
+
|
|
475
|
+
#### Example — Insurance Phone Call
|
|
476
|
+
|
|
477
|
+
```
|
|
478
|
+
caller_word = HMAC-SHA256(secret, utf8("aviva\0caller") || counter_be32)
|
|
479
|
+
agent_word = HMAC-SHA256(secret, utf8("aviva\0agent") || counter_be32)
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
> **Note:** The null byte (`\0`) separator between namespace and role prevents
|
|
483
|
+
> concatenation ambiguity (e.g. namespace `a:b` + role `c` vs namespace `a` +
|
|
484
|
+
> role `b:c`).
|
|
485
|
+
|
|
486
|
+
1. Agent: "What's your verification word?"
|
|
487
|
+
2. Caller speaks their word — Agent verifies against `aviva\0caller` context ✓
|
|
488
|
+
3. Caller: "And what's mine?"
|
|
489
|
+
4. Agent speaks their word — Caller verifies against `aviva\0agent` context ✓
|
|
490
|
+
|
|
491
|
+
An eavesdropper hearing "bid" cannot derive "choose". Both parties have
|
|
492
|
+
independently proved knowledge of the shared secret.
|
|
493
|
+
|
|
494
|
+
#### Duress in Directional Pairs
|
|
495
|
+
|
|
496
|
+
Each party's duress token is derived from their OWN directional context:
|
|
497
|
+
|
|
498
|
+
```
|
|
499
|
+
caller_duress = HMAC-SHA256(secret, utf8("aviva\0caller:duress") || 0x00 || utf8(caller_id) || counter_be32)
|
|
500
|
+
agent_duress = HMAC-SHA256(secret, utf8("aviva\0agent:duress") || 0x00 || utf8(agent_id) || counter_be32)
|
|
501
|
+
```
|
|
502
|
+
|
|
503
|
+
If the caller speaks their duress word instead of their verification word, the
|
|
504
|
+
agent's system detects it and triggers the appropriate response — without the
|
|
505
|
+
caller ever revealing they are under coercion.
|
|
506
|
+
|
|
507
|
+
#### Convention
|
|
508
|
+
|
|
509
|
+
Implementations MUST use a null-byte separator (`namespace\0role`) for directional
|
|
510
|
+
context strings. The namespace identifies the application or domain. The roles
|
|
511
|
+
identify the two parties. Example namespaces:
|
|
512
|
+
|
|
513
|
+
| Namespace | Roles | Use case |
|
|
514
|
+
|-------------|------------------------------|--------------------------------|
|
|
515
|
+
| `dispatch` | `requester`, `provider` | Task handoff verification |
|
|
516
|
+
| `aviva` | `caller`, `agent` | Insurance phone verification |
|
|
517
|
+
| `barclays` | `customer`, `agent` | Banking phone verification |
|
|
518
|
+
| `id` | `subject`, `verifier` | Identity verification |
|
|
519
|
+
|
|
520
|
+
### Session Abstraction
|
|
521
|
+
|
|
522
|
+
Implementations MAY provide a **Session** object that wraps a directional pair with
|
|
523
|
+
role awareness and lifecycle methods. A Session encapsulates the shared secret, the
|
|
524
|
+
namespace, the two roles, and the caller's own role, and exposes the following
|
|
525
|
+
interface:
|
|
526
|
+
|
|
527
|
+
| Method | Description |
|
|
528
|
+
|--------|-------------|
|
|
529
|
+
| `counter(nowSec?)` | Return the current counter — time-derived (floor(t / rotationSeconds)) or fixed |
|
|
530
|
+
| `myToken(nowSec?)` | Derive the token this party speaks to prove their own identity |
|
|
531
|
+
| `theirToken(nowSec?)` | Derive the token expected from the other party |
|
|
532
|
+
| `verify(spoken, nowSec?)` | Verify a word spoken by the other party; returns a result indicating pass, duress, or fail |
|
|
533
|
+
| `pair(nowSec?)` | Return both tokens keyed by role name |
|
|
534
|
+
|
|
535
|
+
Sessions are constructed from a `SessionConfig` specifying the secret, namespace,
|
|
536
|
+
roles tuple, own role, and optional parameters (rotationSeconds, tolerance, encoding,
|
|
537
|
+
preset, fixed counter). Implementations SHOULD validate that the two roles are
|
|
538
|
+
distinct and that `myRole` is one of the configured roles.
|
|
539
|
+
|
|
540
|
+
#### Fixed-Counter Mode
|
|
541
|
+
|
|
542
|
+
When `rotationSeconds` is set to `0`, the Session operates in **fixed-counter mode**.
|
|
543
|
+
The counter is supplied explicitly at construction time rather than derived from the
|
|
544
|
+
clock. This is appropriate for single-use tokens tied to a specific event identifier
|
|
545
|
+
(e.g. a task ID or booking reference) rather than to a time window.
|
|
546
|
+
|
|
547
|
+
Implementations MUST require an explicit `counter` value when `rotationSeconds=0`.
|
|
548
|
+
Implementations MUST reject a `counter` value when `rotationSeconds>0`, since the
|
|
549
|
+
counter is derived deterministically from the current time in that case.
|
|
550
|
+
|
|
551
|
+
#### Session Presets
|
|
552
|
+
|
|
553
|
+
Implementations MAY provide named presets that configure a Session for a specific
|
|
554
|
+
two-party use case:
|
|
555
|
+
|
|
556
|
+
| Preset | Words | Rotation | Tolerance | Use case |
|
|
557
|
+
|--------|-------|----------|-----------|----------|
|
|
558
|
+
| `call` | 1 | 30 seconds | 1 | Phone verification for insurance, banking, and call centres |
|
|
559
|
+
| `handoff` | 1 | single-use (fixed counter) | 0 | Physical handoff for rideshare, delivery, and task completion |
|
|
560
|
+
|
|
561
|
+
The `call` preset uses a 30-second rotation window with a tolerance of ±1 counter,
|
|
562
|
+
giving a 90-second acceptance window to accommodate clock skew between caller and
|
|
563
|
+
agent.
|
|
564
|
+
|
|
565
|
+
The `handoff` preset uses fixed-counter mode (`rotationSeconds=0`), with a tolerance
|
|
566
|
+
of 0. The counter MUST be set to a value agreed out-of-band (e.g. the event ID
|
|
567
|
+
converted to a 32-bit integer). This ensures the token is single-use and bound to
|
|
568
|
+
a specific event, not to a time window.
|
|
569
|
+
|
|
570
|
+
#### Deterministic Seed Derivation
|
|
571
|
+
|
|
572
|
+
When multiple sessions share a master secret but MUST produce independent, isolated
|
|
573
|
+
token streams, implementations SHOULD derive per-session seeds deterministically
|
|
574
|
+
using HMAC:
|
|
575
|
+
|
|
576
|
+
```
|
|
577
|
+
sessionSeed = HMAC-SHA256(masterKey, utf8(component_0) || 0x00 || utf8(component_1) || ...)
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
Null-byte separators between components prevent concatenation ambiguity (the byte
|
|
581
|
+
sequence `"ab" || 0x00 || "c"` is distinct from `"a" || 0x00 || "bc"`). Components
|
|
582
|
+
SHOULD be chosen to uniquely identify the session context (e.g. namespace, task ID,
|
|
583
|
+
participant identifiers).
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## CANARY-DURESS
|
|
588
|
+
|
|
589
|
+
Coercion resistance and liveness monitoring. The differentiator from existing standards.
|
|
590
|
+
|
|
591
|
+
### Active Duress — Token Derivation
|
|
592
|
+
|
|
593
|
+
Each identity has a distinct duress token, derived independently from the verification
|
|
594
|
+
token. In finite output spaces (e.g. 2048-word wordlist), two identities may derive
|
|
595
|
+
the same duress token — this is expected and handled by multi-match attribution (see
|
|
596
|
+
Verification Flow).
|
|
597
|
+
|
|
598
|
+
```
|
|
599
|
+
duress_bytes = HMAC-SHA256(secret, utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32)
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
Where:
|
|
603
|
+
|
|
604
|
+
- `context + ":duress"` — the verification context with `":duress"` appended
|
|
605
|
+
- `0x00` — a null-byte separator preventing concatenation ambiguity between the context
|
|
606
|
+
suffix and the identity string
|
|
607
|
+
- `identity` — UTF-8 encoded identifier (pubkey, username, employee ID)
|
|
608
|
+
- Same `counter` as the verification token
|
|
609
|
+
|
|
610
|
+
**Key property:** The duress token is computationally independent from the verification
|
|
611
|
+
token. An attacker who knows one cannot derive the other. But a verifier with the shared
|
|
612
|
+
secret can check for both.
|
|
613
|
+
|
|
614
|
+
### Collision Avoidance
|
|
615
|
+
|
|
616
|
+
If the duress token, after encoding, is identical to any verification token within the
|
|
617
|
+
collision window, the implementation MUST re-derive by appending incrementing suffix bytes
|
|
618
|
+
to the HMAC data.
|
|
619
|
+
|
|
620
|
+
#### Collision Window
|
|
621
|
+
|
|
622
|
+
The collision window MUST be `±(2 × maxTolerance)` counter values centred on the deriver's
|
|
623
|
+
counter. The 2× factor accounts for worst-case counter drift: the deriver and the verifier
|
|
624
|
+
may each be off by `maxTolerance` in opposite directions, so the verifier could check
|
|
625
|
+
normal tokens anywhere in a `±(2 × maxTolerance)` range from the deriver's perspective.
|
|
626
|
+
|
|
627
|
+
The `maxTolerance` used when deriving the duress token MUST equal the `tolerance` used by
|
|
628
|
+
the verifier. Using an insufficient value allows duress tokens to collide with normal tokens
|
|
629
|
+
at distant counters, causing silent alarm suppression.
|
|
630
|
+
|
|
631
|
+
Implementations SHOULD enforce a practical upper bound on tolerance (the reference
|
|
632
|
+
implementation uses `MAX_TOLERANCE = 10`).
|
|
633
|
+
|
|
634
|
+
```
|
|
635
|
+
collision_window = [max(0, counter - 2 * maxTolerance),
|
|
636
|
+
min(MAX_UINT32, counter + 2 * maxTolerance)]
|
|
637
|
+
|
|
638
|
+
forbidden_tokens = { encode(derive(secret, context, c)) for c in collision_window }
|
|
639
|
+
|
|
640
|
+
duress_data = utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32
|
|
641
|
+
duress_bytes = HMAC-SHA256(secret, duress_data)
|
|
642
|
+
duress_token = encode(duress_bytes, encoding)
|
|
643
|
+
|
|
644
|
+
suffix = 1
|
|
645
|
+
while duress_token in forbidden_tokens and suffix <= 255:
|
|
646
|
+
duress_bytes = HMAC-SHA256(secret, duress_data || byte(suffix))
|
|
647
|
+
duress_token = encode(duress_bytes, encoding)
|
|
648
|
+
suffix += 1
|
|
649
|
+
|
|
650
|
+
if duress_token in forbidden_tokens:
|
|
651
|
+
ERROR: duress collision unresolvable
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Collision avoidance operates at the encoding level, not the byte level, because different
|
|
655
|
+
byte arrays can encode to the same output via modulo reduction. Implementations MUST
|
|
656
|
+
apply this check whenever computing a duress token. If all 255 suffix retries produce
|
|
657
|
+
a collision (probability effectively zero for any practical encoding), the implementation
|
|
658
|
+
MUST raise an error. Implementations MUST NOT return a duress token that matches any
|
|
659
|
+
verification token in the collision window — this would cause a duress signal to be
|
|
660
|
+
classified as valid, suppressing the silent alarm.
|
|
661
|
+
|
|
662
|
+
### Verification Flow
|
|
663
|
+
|
|
664
|
+
```
|
|
665
|
+
verify(secret, context, counter, input, identities[], tolerance?) ->
|
|
666
|
+
{ status: 'valid' } |
|
|
667
|
+
{ status: 'duress', identities: string[] } |
|
|
668
|
+
{ status: 'invalid' }
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
The verification algorithm uses exact-counter-first priority:
|
|
672
|
+
|
|
673
|
+
1. Derive the verification token at the **exact** counter. If the input matches → `valid`.
|
|
674
|
+
Same-counter collision avoidance guarantees no ambiguity at this step.
|
|
675
|
+
|
|
676
|
+
2. For each identity, for each counter in the tolerance window
|
|
677
|
+
`[counter - tolerance, ..., counter + tolerance]`:
|
|
678
|
+
derive the duress token (using `maxTolerance = tolerance`).
|
|
679
|
+
Collect all matching identities. If any match →
|
|
680
|
+
`duress` with all matching identities.
|
|
681
|
+
|
|
682
|
+
3. For each counter in the tolerance window **excluding the exact counter**:
|
|
683
|
+
derive the verification token. If the input matches → `valid`.
|
|
684
|
+
|
|
685
|
+
4. No match → `invalid`.
|
|
686
|
+
|
|
687
|
+
This ordering ensures that a duress token at the expected counter is never masked by a
|
|
688
|
+
normal token at an adjacent counter (fail-safe).
|
|
689
|
+
|
|
690
|
+
#### Multi-Match Attribution
|
|
691
|
+
|
|
692
|
+
When checking duress tokens, the verifier MUST check all identities and collect all
|
|
693
|
+
matches. If exactly one identity matches, the result is `duress` with that identity.
|
|
694
|
+
If multiple identities match, the result is `duress` with all matching identities.
|
|
695
|
+
The verifier MUST NOT short-circuit after the first duress match.
|
|
696
|
+
|
|
697
|
+
#### Duress Collision Probability
|
|
698
|
+
|
|
699
|
+
In finite output spaces, distinct identities may derive identical duress tokens at the
|
|
700
|
+
same counter (birthday problem). This is a known limitation, not a protocol flaw.
|
|
701
|
+
|
|
702
|
+
| Members | 1 word (~11 bits) | 2 words (~22 bits) | 3 words (~33 bits) |
|
|
703
|
+
|---------|-------------------|--------------------|--------------------|
|
|
704
|
+
| 5 | ~0.5% | ~0.00012% | negligible |
|
|
705
|
+
| 10 | ~2.2% | ~0.00048% | negligible |
|
|
706
|
+
| 20 | ~9.3% | ~0.0019% | negligible |
|
|
707
|
+
| 50 | ~45% | ~0.012% | negligible |
|
|
708
|
+
|
|
709
|
+
Groups of 10 or more members SHOULD use 2+ words for reliable attribution. PIN
|
|
710
|
+
encoding with 4 digits (10,000 outputs) has similar collision properties to 1-word
|
|
711
|
+
encoding (2,048 outputs).
|
|
712
|
+
|
|
713
|
+
### Deniability Properties
|
|
714
|
+
|
|
715
|
+
The duress token is indistinguishable from a wrong answer to any party that does not hold
|
|
716
|
+
the shared secret:
|
|
717
|
+
|
|
718
|
+
- An attacker hears a plausible word from the wordlist (or a plausible PIN).
|
|
719
|
+
- The attacker cannot verify whether it is the verification token or the duress token
|
|
720
|
+
without the shared secret.
|
|
721
|
+
- If the attacker demands the "real" token, the member can assert that the duress token
|
|
722
|
+
is the real token. The attacker cannot refute this.
|
|
723
|
+
|
|
724
|
+
### Threat-Profile Presets
|
|
725
|
+
|
|
726
|
+
Implementations MAY provide named threat-profile presets that select a word count and
|
|
727
|
+
rotation interval appropriate for a given risk level and operational context. These are
|
|
728
|
+
recommendations, not requirements — implementations MAY define additional presets or
|
|
729
|
+
allow operators to configure custom profiles.
|
|
730
|
+
|
|
731
|
+
| Preset | Words | Rotation | Tolerance | Use case |
|
|
732
|
+
|--------|-------|----------|-----------|----------|
|
|
733
|
+
| `family` | 1 | 7 days | 1 | Casual family and friend groups |
|
|
734
|
+
| `field-ops` | 2 | 24 hours | 1 | High-security field operations |
|
|
735
|
+
| `enterprise` | 2 | 48 hours | 1 | Corporate and institutional use |
|
|
736
|
+
| `event` | 1 | 4 hours | 1 | Temporary event-based groups |
|
|
737
|
+
|
|
738
|
+
The `family` preset prioritises usability: a single word with a weekly rotation is easy
|
|
739
|
+
to remember and sufficient for live voice calls where the attacker has at most one
|
|
740
|
+
attempt. It is NOT suitable for text-based or asynchronous verification where an
|
|
741
|
+
attacker can brute-force all 2,048 words offline.
|
|
742
|
+
|
|
743
|
+
The `field-ops` preset prioritises security: two-word phrases (~22 bits of entropy)
|
|
744
|
+
with daily rotation are recommended for journalism, activism, and operational contexts
|
|
745
|
+
where the threat model includes motivated, resourced adversaries.
|
|
746
|
+
|
|
747
|
+
The `enterprise` preset extends the rotation window to 48 hours, balancing security
|
|
748
|
+
with operational convenience for larger teams where frequent re-verification is
|
|
749
|
+
impractical.
|
|
750
|
+
|
|
751
|
+
The `event` preset uses a 4-hour rotation aligned with typical conference or festival
|
|
752
|
+
session schedules. It is intended for ephemeral groups formed at a specific event.
|
|
753
|
+
|
|
754
|
+
### Limitations
|
|
755
|
+
|
|
756
|
+
- If the attacker has compromised the member's device AND obtained the shared secret, they
|
|
757
|
+
can derive both the verification token and the duress token. At this point, the device is
|
|
758
|
+
the weakest link, not the protocol.
|
|
759
|
+
- If the attacker knows the CANARY protocol exists and demands the member display the
|
|
760
|
+
application, the member would expose the verification token. Implementations SHOULD NOT
|
|
761
|
+
display the duress token in the default UI. The duress token SHOULD be accessible only
|
|
762
|
+
through a non-obvious secondary gesture.
|
|
763
|
+
|
|
764
|
+
### Implementation UX Requirements
|
|
765
|
+
|
|
766
|
+
Implementations that display verification tokens to users MUST follow these
|
|
767
|
+
requirements to preserve duress deniability:
|
|
768
|
+
|
|
769
|
+
1. Implementations MUST NOT display the duress token alongside the verification
|
|
770
|
+
token in any default UI view.
|
|
771
|
+
2. Implementations SHOULD require a non-obvious secondary gesture (e.g. long-press,
|
|
772
|
+
hidden menu, specific swipe pattern) to access duress-related functionality.
|
|
773
|
+
3. When a duress token is detected during verification, implementations MUST NOT
|
|
774
|
+
display any visible indication that could alert an attacker observing the
|
|
775
|
+
verifier's screen. The response SHOULD appear identical to a failed verification
|
|
776
|
+
from the attacker's perspective.
|
|
777
|
+
4. Implementations SHOULD NOT label any UI element with the word "duress" or
|
|
778
|
+
"coercion" in the default interface.
|
|
779
|
+
|
|
780
|
+
### Passive Duress — Dead Man's Switch
|
|
781
|
+
|
|
782
|
+
The protocol defines a liveness token for heartbeat-based absence detection:
|
|
783
|
+
|
|
784
|
+
```
|
|
785
|
+
liveness_bytes = HMAC-SHA256(secret, utf8(context + ":alive") || 0x00 || utf8(identity) || counter_be32)
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
The liveness token proves both identity and knowledge of the secret — not just a ping.
|
|
789
|
+
|
|
790
|
+
Liveness monitoring parameters are application-defined:
|
|
791
|
+
|
|
792
|
+
| Parameter | Description | Example |
|
|
793
|
+
|----------------------|-------------------------------------------------|-------------|
|
|
794
|
+
| `heartbeat_interval` | Expected time between liveness proofs | 300s, 30s |
|
|
795
|
+
| `grace_period` | Time after missed heartbeat before DMS triggers | 2× interval |
|
|
796
|
+
|
|
797
|
+
**DMS trigger actions** are implementation-specific:
|
|
798
|
+
|
|
799
|
+
| Implementation | Action on DMS trigger |
|
|
800
|
+
|----------------|--------------------------------------------------------------------|
|
|
801
|
+
| Canary-kit | Derive liveness token; monitoring and alerting are application-layer |
|
|
802
|
+
| Key management | Lock signing keys, broadcast revocation |
|
|
803
|
+
| Dispatch | Escalate to dispatch, freeze escrow |
|
|
804
|
+
| Banking | Freeze account, alert fraud team |
|
|
805
|
+
|
|
806
|
+
The CANARY protocol defines liveness token derivation. The monitoring loop
|
|
807
|
+
(tracking heartbeat intervals, detecting missed check-ins, triggering alerts)
|
|
808
|
+
is application-defined. Libraries SHOULD provide the `deriveLivenessToken()`
|
|
809
|
+
primitive. Applications SHOULD implement heartbeat tracking with configurable
|
|
810
|
+
`heartbeat_interval` and `grace_period`.
|
|
811
|
+
|
|
812
|
+
---
|
|
813
|
+
|
|
814
|
+
## CANARY-WORDLIST
|
|
815
|
+
|
|
816
|
+
Spoken-word encoding optimised for voice clarity.
|
|
817
|
+
|
|
818
|
+
### Encoding Scheme
|
|
819
|
+
|
|
820
|
+
- 2048 words = 11 bits per word
|
|
821
|
+
- Token bytes split into 2-byte chunks, each mapped to a word index via
|
|
822
|
+
`uint16_be mod 2048`
|
|
823
|
+
- 1 word = ~11 bits, 2 words = ~22 bits, 3 words = ~33 bits
|
|
824
|
+
|
|
825
|
+
### Phonetic Clarity Criteria
|
|
826
|
+
|
|
827
|
+
Wordlists MUST satisfy:
|
|
828
|
+
|
|
829
|
+
1. **No homophones** — words must sound distinct (`write` vs `right` excluded)
|
|
830
|
+
2. **Distinct first syllable** — listeners can identify the word early
|
|
831
|
+
3. **Cross-accent pronounceable** — works across major accent families
|
|
832
|
+
4. **No offensive words** — culturally appropriate across contexts
|
|
833
|
+
5. **Single-word only** — no compounds, no hyphens
|
|
834
|
+
6. **4–8 letters preferred** — short enough to speak, long enough to be distinct
|
|
835
|
+
|
|
836
|
+
### Wordlist Requirements
|
|
837
|
+
|
|
838
|
+
Words MUST be:
|
|
839
|
+
|
|
840
|
+
- Between 3 and 8 characters in length (inclusive)
|
|
841
|
+
- Unambiguous when spoken aloud
|
|
842
|
+
- Phonetically distinct from every other word in the list
|
|
843
|
+
- Free of offensive meanings in major languages
|
|
844
|
+
- Easy to pronounce across common English accents
|
|
845
|
+
|
|
846
|
+
Words MUST NOT be:
|
|
847
|
+
|
|
848
|
+
- Homophones of other words in the list (e.g. there/their, right/write)
|
|
849
|
+
- Within 2 phonetic edits of another list word (e.g. cat/bat, pen/ten)
|
|
850
|
+
- Confusable over degraded audio channels (e.g. ship/chip, thin/fin)
|
|
851
|
+
- Emotionally charged words that could cause alarm if overheard (e.g. bomb, kill, death)
|
|
852
|
+
|
|
853
|
+
### Format
|
|
854
|
+
|
|
855
|
+
The wordlist is a plain text file, one word per line, with exactly 2048 entries. Lines are
|
|
856
|
+
numbered 0 through 2047. The word at line N is `wordlist[N]`.
|
|
857
|
+
|
|
858
|
+
### Reference Wordlist
|
|
859
|
+
|
|
860
|
+
`en-v1` — 2048-word English wordlist curated from BIP-39 with additional phonetic
|
|
861
|
+
filtering. Maintained in the `canary-kit` reference implementation.
|
|
862
|
+
|
|
863
|
+
The canonical English wordlist (`en-v1`) begins from the BIP-39 English wordlist (2048
|
|
864
|
+
words) with the following modifications:
|
|
865
|
+
|
|
866
|
+
1. Remove all words that fail the spoken-clarity requirements above.
|
|
867
|
+
2. Replace removed words with words from a supplementary spoken-word corpus.
|
|
868
|
+
3. Validate the complete list against phonetic distance metrics.
|
|
869
|
+
|
|
870
|
+
The full `en-v1` wordlist is defined in Appendix A of [NIP-CANARY.md](NIP-CANARY.md). Any
|
|
871
|
+
implementation claiming compliance with this protocol MUST use the exact wordlist defined
|
|
872
|
+
there.
|
|
873
|
+
|
|
874
|
+
### Why Not BIP-39 Directly?
|
|
875
|
+
|
|
876
|
+
CANARY's `en-v1` wordlist is **derived from** BIP-39 English — it starts with the same
|
|
877
|
+
2048 words, then filters for spoken-word clarity. The two lists optimise for different
|
|
878
|
+
threat models:
|
|
879
|
+
|
|
880
|
+
| Concern | BIP-39 | CANARY `en-v1` |
|
|
881
|
+
|---------|--------|----------------|
|
|
882
|
+
| **Primary channel** | Written (paper backup) | Spoken (voice call, radio) |
|
|
883
|
+
| **Identification** | First 4 characters unique | First syllable distinct |
|
|
884
|
+
| **Homophones** | Allowed (`write`/`right`) | Removed |
|
|
885
|
+
| **Audio confusion** | Not considered | Filtered (`ship`/`chip`, `thin`/`fin`) |
|
|
886
|
+
| **Emotional charge** | Allowed (`bomb`, `kill`) | Removed (avoids alarm if overheard) |
|
|
887
|
+
|
|
888
|
+
BIP-39 words are designed to be **written down and read back** — uniqueness from the first
|
|
889
|
+
4 characters is enough. CANARY words are designed to be **spoken aloud over a degraded
|
|
890
|
+
channel** — a phone call, a radio, a noisy conference room. The filtering removes ~15% of
|
|
891
|
+
BIP-39 words and replaces them with concrete, unambiguous alternatives.
|
|
892
|
+
|
|
893
|
+
**Custom wordlists are supported.** The `wordlist` field in group configuration accepts any
|
|
894
|
+
wordlist identifier. Implementations MAY offer BIP-39 as an alternative for teams that
|
|
895
|
+
prefer familiarity over spoken clarity. The protocol does not mandate `en-v1` — it mandates
|
|
896
|
+
that all group members use the **same** list.
|
|
897
|
+
|
|
898
|
+
### Internationalisation
|
|
899
|
+
|
|
900
|
+
Other languages can propose wordlists following the same criteria. Each list is identified
|
|
901
|
+
by a locale tag (`es-v1`, `fr-v1`, `ja-v1`). The protocol does not mandate a specific
|
|
902
|
+
language.
|
|
903
|
+
|
|
904
|
+
---
|
|
905
|
+
|
|
906
|
+
## Security Considerations
|
|
907
|
+
|
|
908
|
+
### Threat Model
|
|
909
|
+
|
|
910
|
+
| Threat | TOTP | CANARY | Mitigation |
|
|
911
|
+
|----------------------------------|:----:|:-------:|----------------------------------------------------------|
|
|
912
|
+
| Impersonation | Yes | Yes | Verification token challenge |
|
|
913
|
+
| AI voice/video clone | No | Yes | Shared secret — clone does not know the current token |
|
|
914
|
+
| Coercion (forced auth) | No | **Yes** | Duress token alerts silently; attacker cannot distinguish|
|
|
915
|
+
| Replay attack | Yes | Yes | Counter rotation / burn-after-use |
|
|
916
|
+
| Absence detection | No | **Yes** | Liveness token / dead man's switch |
|
|
917
|
+
| Transport interception | — | Yes | End-to-end encryption at the transport layer |
|
|
918
|
+
| Device compromise | — | Yes | Re-seed immediately; exclude compromised member |
|
|
919
|
+
| Wordlist brute force (live call) | — | Yes | 11 bits per word; attacker gets one attempt |
|
|
920
|
+
|
|
921
|
+
### Entropy Analysis
|
|
922
|
+
|
|
923
|
+
- Single word: ~11 bits (1 in 2048). Adequate for real-time verification where the
|
|
924
|
+
attacker has one attempt.
|
|
925
|
+
- Two words: ~22 bits (1 in ~4,194,304). Recommended for higher-security groups.
|
|
926
|
+
- Three words: ~33 bits (1 in ~8,589,934,592). Maximum word configuration.
|
|
927
|
+
- 4-digit PIN: ~13.3 bits (1 in 10,000). Quick handoff scenarios.
|
|
928
|
+
- 6-digit code: ~19.9 bits (1 in 1,000,000). TOTP-equivalent.
|
|
929
|
+
|
|
930
|
+
### Seed Storage
|
|
931
|
+
|
|
932
|
+
The shared secret MUST be stored securely on member devices:
|
|
933
|
+
|
|
934
|
+
- Encrypted at rest using the platform keychain or secure enclave where available
|
|
935
|
+
- Never exported in plaintext after initial receipt
|
|
936
|
+
- Wiped from storage on group dissolution
|
|
937
|
+
- Protected by device authentication (PIN or biometric) before displaying any derived
|
|
938
|
+
token
|
|
939
|
+
|
|
940
|
+
### Shared Secret Trade-off
|
|
941
|
+
|
|
942
|
+
CANARY uses a symmetric shared secret because offline derivation requires it.
|
|
943
|
+
Any member can verify any other member without contacting a server, a key server,
|
|
944
|
+
or any online authority. This property — the ability to verify identity with no
|
|
945
|
+
network — is a deliberate design choice for scenarios where connectivity is
|
|
946
|
+
unreliable, adversarial, or unavailable.
|
|
947
|
+
|
|
948
|
+
The trade-off: a single compromised member or device can derive all verification
|
|
949
|
+
and duress tokens for the group until the secret is rotated. The blast radius of
|
|
950
|
+
a compromise is the entire group.
|
|
951
|
+
|
|
952
|
+
**Why not per-member secrets?** Per-member derived sub-secrets would require a
|
|
953
|
+
verifier to know or fetch the speaker's sub-secret before verification. This
|
|
954
|
+
introduces an online dependency or pre-distribution of all members' sub-secrets,
|
|
955
|
+
negating the simplicity of a single shared key.
|
|
956
|
+
|
|
957
|
+
**Mitigation strategy:** Detect compromise quickly and reseed immediately.
|
|
958
|
+
|
|
959
|
+
- Re-seed on member removal (`reason=member_removed`) — SHOULD for trusted
|
|
960
|
+
departures, MUST for adversarial or unknown circumstances
|
|
961
|
+
- Re-seed on suspected compromise (`reason=compromise`)
|
|
962
|
+
- Re-seed on duress detection (`reason=duress`)
|
|
963
|
+
- Periodic scheduled re-seed even without known compromise — intervals should
|
|
964
|
+
match the group's risk profile (see Threat-Profile Presets in the reference
|
|
965
|
+
implementation)
|
|
966
|
+
|
|
967
|
+
The protocol provides the machinery for fast recovery. The operational discipline
|
|
968
|
+
of regular reseeds is where real-world resilience comes from.
|
|
969
|
+
|
|
970
|
+
### Timing Safety
|
|
971
|
+
|
|
972
|
+
Verification functions MUST NOT leak information through execution time.
|
|
973
|
+
Implementations MUST use constant-time comparison for all token matching
|
|
974
|
+
(e.g. XOR-and-accumulate, not early-exit string comparison). All verification
|
|
975
|
+
branches (exact match, duress check, tolerance window) MUST be computed
|
|
976
|
+
regardless of which branch matches — the result is determined after all
|
|
977
|
+
comparisons complete, not by short-circuiting on the first match.
|
|
978
|
+
|
|
979
|
+
This prevents an attacker from distinguishing verification tokens from duress
|
|
980
|
+
tokens by measuring response time.
|
|
981
|
+
|
|
982
|
+
---
|
|
983
|
+
|
|
984
|
+
## Test Vectors
|
|
985
|
+
|
|
986
|
+
### CANARY Protocol Vectors
|
|
987
|
+
|
|
988
|
+
The following vectors define canonical expected outputs for the universal CANARY protocol.
|
|
989
|
+
Any implementation claiming conformance MUST produce identical results.
|
|
990
|
+
|
|
991
|
+
**Inputs:**
|
|
992
|
+
|
|
993
|
+
```
|
|
994
|
+
SECRET = 0000000000000000000000000000000000000000000000000000000000000001
|
|
995
|
+
CONTEXT = canary:verify
|
|
996
|
+
IDENTITY = alice
|
|
997
|
+
```
|
|
998
|
+
|
|
999
|
+
**Algorithm:**
|
|
1000
|
+
|
|
1001
|
+
```
|
|
1002
|
+
CANARY-DERIVE:
|
|
1003
|
+
token_bytes = HMAC-SHA256(hex_to_bytes(SECRET), utf8(CONTEXT) || counter_be32)
|
|
1004
|
+
|
|
1005
|
+
CANARY-DURESS:
|
|
1006
|
+
duress_bytes = HMAC-SHA256(hex_to_bytes(SECRET), utf8(CONTEXT + ":duress") || 0x00 || utf8(IDENTITY) || counter_be32)
|
|
1007
|
+
|
|
1008
|
+
Liveness:
|
|
1009
|
+
liveness_bytes = HMAC-SHA256(hex_to_bytes(SECRET), utf8(CONTEXT + ":alive") || 0x00 || utf8(IDENTITY) || counter_be32)
|
|
1010
|
+
```
|
|
1011
|
+
|
|
1012
|
+
**Vector Table:**
|
|
1013
|
+
|
|
1014
|
+
| # | Function | Context | Identity | Counter | Encoding | Expected output |
|
|
1015
|
+
|----|--------------------|------------------|------------|---------|-------------|--------------------------------------------------------------------|
|
|
1016
|
+
| 1 | deriveTokenBytes | `canary:verify` | — | 0 | raw hex | `c51524053f1f27a4c871c63069f285ce5ac5b69a40d6caa5af9b6945dd9556d1` |
|
|
1017
|
+
| 2 | deriveToken | `canary:verify` | — | 0 | 1 word | `net` |
|
|
1018
|
+
| 3 | deriveToken | `canary:verify` | — | 1 | 1 word | `famous` |
|
|
1019
|
+
| 4 | deriveToken | `dispatch:handoff` | — | 0 | 4-digit PIN | `2818` |
|
|
1020
|
+
| 5 | deriveToken | `id:verify` | — | 0 | 3 words | `decrease mistake require` |
|
|
1021
|
+
| 6 | deriveDuressToken | `canary:verify` | `alice` | 0 | 1 word | `airport` |
|
|
1022
|
+
| 7 | deriveDuressToken | `dispatch:handoff` | `rider123` | 0 | 4-digit PIN | `0973` |
|
|
1023
|
+
| 8 | verifyToken | `canary:verify` | `alice` | 0 | input: `net` | `{ status: 'valid' }` |
|
|
1024
|
+
| 9 | verifyToken | `canary:verify` | `alice` | 0 | input: `airport`| `{ status: 'duress', identities: ['alice'] }` |
|
|
1025
|
+
| 10 | deriveLivenessToken| `canary:verify` | `alice` | 0 | raw hex | `b38a10676ea8d4e716ad606e0b2ae7d9678e47ff44b0920a68ed6cb02e9bb858` |
|
|
1026
|
+
| 11 | deriveToken | `aviva:caller` | — | 0 | 1 word | `bid` |
|
|
1027
|
+
| 12 | deriveToken | `aviva:agent` | — | 0 | 1 word | `choose` |
|
|
1028
|
+
|
|
1029
|
+
Notes:
|
|
1030
|
+
|
|
1031
|
+
- Vector 5 uses a different context string, demonstrating that the same secret derives
|
|
1032
|
+
independent tokens per context.
|
|
1033
|
+
- Vector 6: `airport` is distinct from `net` — no collision re-derivation needed.
|
|
1034
|
+
- Vector 7: `0973` is distinct from `2818` — no collision re-derivation needed.
|
|
1035
|
+
- Vectors 8–9: Round-trip verification confirms correct classification of normal tokens
|
|
1036
|
+
as `valid` and duress tokens as `duress` with the correct identity.
|
|
1037
|
+
- Vectors 11–12: Directional pair — same secret, different context strings produce
|
|
1038
|
+
different tokens (`bid` vs `choose`). An eavesdropper hearing one cannot derive the other.
|
|
1039
|
+
|
|
1040
|
+
---
|
|
1041
|
+
|
|
1042
|
+
## Reference Implementation
|
|
1043
|
+
|
|
1044
|
+
TypeScript: `canary-kit` (npm)
|
|
1045
|
+
|
|
1046
|
+
```
|
|
1047
|
+
npm install canary-kit
|
|
1048
|
+
```
|
|
1049
|
+
|
|
1050
|
+
### CANARY Protocol API
|
|
1051
|
+
|
|
1052
|
+
```typescript
|
|
1053
|
+
import {
|
|
1054
|
+
deriveToken, deriveTokenBytes,
|
|
1055
|
+
deriveDuressToken, deriveDuressTokenBytes,
|
|
1056
|
+
verifyToken,
|
|
1057
|
+
deriveLivenessToken,
|
|
1058
|
+
} from 'canary-kit/token'
|
|
1059
|
+
|
|
1060
|
+
import {
|
|
1061
|
+
encodeAsWords, encodeAsPin, encodeAsHex,
|
|
1062
|
+
} from 'canary-kit/encoding'
|
|
1063
|
+
```
|
|
1064
|
+
|
|
1065
|
+
Source: `https://github.com/TheCryptoDonkey/canary-kit`
|