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/dist/token.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { type TokenEncoding } from './encoding.js';
|
|
2
|
+
/**
|
|
3
|
+
* Maximum allowed tolerance/maxTolerance value.
|
|
4
|
+
* Prevents pathological iteration: at MAX_TOLERANCE=10, the collision
|
|
5
|
+
* avoidance window is ±20 counters (41 iterations) — well within reason.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MAX_TOLERANCE = 10;
|
|
8
|
+
/**
|
|
9
|
+
* CANARY-DERIVE: Derive raw token bytes from a shared secret, context, and counter.
|
|
10
|
+
*
|
|
11
|
+
* When `identity` is omitted, derives a group-wide token:
|
|
12
|
+
* HMAC-SHA256(secret, utf8(context) || counter_be32)
|
|
13
|
+
*
|
|
14
|
+
* When `identity` is provided, derives a per-member token:
|
|
15
|
+
* HMAC-SHA256(secret, utf8(context) || 0x00 || utf8(identity) || counter_be32)
|
|
16
|
+
*
|
|
17
|
+
* The null-byte separator prevents concatenation ambiguity between context and identity.
|
|
18
|
+
*
|
|
19
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
20
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
21
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
22
|
+
* @param identity - Optional member pubkey for per-member tokens.
|
|
23
|
+
* @returns Raw 32-byte HMAC-SHA256 digest.
|
|
24
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
25
|
+
* @throws {Error} If identity is provided but empty.
|
|
26
|
+
*/
|
|
27
|
+
export declare function deriveTokenBytes(secret: Uint8Array | string, context: string, counter: number, identity?: string): Uint8Array;
|
|
28
|
+
/**
|
|
29
|
+
* CANARY-DERIVE: Derive an encoded token string.
|
|
30
|
+
*
|
|
31
|
+
* When `identity` is provided, produces a per-member token unique to that member.
|
|
32
|
+
* When omitted, produces the group-wide token (backwards-compatible).
|
|
33
|
+
*
|
|
34
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
35
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
36
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
37
|
+
* @param encoding - Output encoding format (default: single word from en-v1 wordlist).
|
|
38
|
+
* @param identity - Optional member pubkey for per-member tokens.
|
|
39
|
+
* @returns Encoded token string (word, PIN, or hex depending on encoding).
|
|
40
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* deriveToken(seedHex, 'canary:group', 42) // "falcon"
|
|
45
|
+
* deriveToken(seedHex, 'canary:group', 42, { format: 'pin', digits: 6 }) // "083721"
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export declare function deriveToken(secret: Uint8Array | string, context: string, counter: number, encoding?: TokenEncoding, identity?: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* CANARY-DURESS: Derive raw duress token bytes for a specific identity.
|
|
51
|
+
*
|
|
52
|
+
* Algorithm: HMAC-SHA256(secret, utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32)
|
|
53
|
+
*
|
|
54
|
+
* The null-byte separator between the context suffix and identity prevents concatenation
|
|
55
|
+
* ambiguity (e.g. context="x:duress" + identity="" vs context="x" + identity=":duress").
|
|
56
|
+
*
|
|
57
|
+
* NOTE: Returns raw bytes without collision avoidance. Use deriveDuressToken()
|
|
58
|
+
* for encoded output with guaranteed non-collision against the normal token.
|
|
59
|
+
*
|
|
60
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
61
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
62
|
+
* @param identity - Member pubkey or identifier (must be non-empty).
|
|
63
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
64
|
+
* @returns Raw 32-byte HMAC-SHA256 digest.
|
|
65
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
66
|
+
*/
|
|
67
|
+
export declare function deriveDuressTokenBytes(secret: Uint8Array | string, context: string, identity: string, counter: number): Uint8Array;
|
|
68
|
+
/**
|
|
69
|
+
* CANARY-DURESS: Derive an encoded duress token with collision avoidance.
|
|
70
|
+
*
|
|
71
|
+
* If the duress token collides with any normal verification token within
|
|
72
|
+
* ±(2 × maxTolerance) counter values (at the encoding level), re-derives with
|
|
73
|
+
* incrementing suffix bytes (0x01, 0x02, ..., 0xFF) until distinct.
|
|
74
|
+
* The 2× factor accounts for worst-case counter drift: the deriver and verifier
|
|
75
|
+
* may each be off by maxTolerance in opposite directions.
|
|
76
|
+
* If all 255 suffixes collide (astronomically unlikely), throws an error
|
|
77
|
+
* rather than failing open.
|
|
78
|
+
*
|
|
79
|
+
* **maxTolerance is required** — it must match the tolerance used by verifiers.
|
|
80
|
+
* Using an insufficient value allows duress tokens to collide with normal tokens
|
|
81
|
+
* at distant counters, causing silent alarm suppression.
|
|
82
|
+
*
|
|
83
|
+
* **identities** — when provided, the forbidden set also includes per-member
|
|
84
|
+
* normal tokens for all identities across the collision avoidance window.
|
|
85
|
+
* Without this, a duress token for identity A could collide with the normal
|
|
86
|
+
* per-member token for identity B, causing false duress detection.
|
|
87
|
+
*
|
|
88
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
89
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
90
|
+
* @param identity - Member pubkey or identifier for the duress signaller.
|
|
91
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
92
|
+
* @param encoding - Output encoding format (default: single word from en-v1 wordlist).
|
|
93
|
+
* @param maxTolerance - Counter tolerance window used by verifiers; must match their value.
|
|
94
|
+
* @param identities - All group member pubkeys for cross-member collision avoidance.
|
|
95
|
+
* @returns Encoded duress token string guaranteed distinct from all normal tokens in the window.
|
|
96
|
+
* @throws {RangeError} If maxTolerance is negative, exceeds MAX_TOLERANCE, or is not an integer.
|
|
97
|
+
* @throws {Error} If collision avoidance fails after 255 retries.
|
|
98
|
+
*/
|
|
99
|
+
export declare function deriveDuressToken(secret: Uint8Array | string, context: string, identity: string, counter: number, encoding: TokenEncoding | undefined, maxTolerance: number, identities?: string[]): string;
|
|
100
|
+
/** Result of verifying a token. */
|
|
101
|
+
export interface TokenVerifyResult {
|
|
102
|
+
/** 'valid' = matches normal token, 'duress' = matches a duress token, 'invalid' = no match. */
|
|
103
|
+
status: 'valid' | 'duress' | 'invalid';
|
|
104
|
+
/** Identities of duress signallers (only when status = 'duress'). */
|
|
105
|
+
identities?: string[];
|
|
106
|
+
}
|
|
107
|
+
/** Options for token verification. */
|
|
108
|
+
export interface VerifyOptions {
|
|
109
|
+
/** Output encoding to use for comparison (default: single word). */
|
|
110
|
+
encoding?: TokenEncoding;
|
|
111
|
+
/** Counter tolerance window: accept tokens within ±tolerance counter values (default: 0). */
|
|
112
|
+
tolerance?: number;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* CANARY-DURESS: Verify a spoken/entered token against a group.
|
|
116
|
+
*
|
|
117
|
+
* Checks in priority order (exact-counter-first):
|
|
118
|
+
* 1. Normal verification token at exact counter → 'valid'
|
|
119
|
+
* 2. ALL identities' duress tokens (within tolerance window) → 'duress' with all matches
|
|
120
|
+
* 3. Normal verification token at remaining tolerance window → 'valid'
|
|
121
|
+
* 4. No match → 'invalid'
|
|
122
|
+
*
|
|
123
|
+
* Exact-counter normal is checked first because same-counter collision avoidance
|
|
124
|
+
* guarantees no ambiguity. Duress across the full window is checked next so that
|
|
125
|
+
* duress at the exact counter is never masked by normal at an adjacent counter.
|
|
126
|
+
*
|
|
127
|
+
* Per CANARY-DURESS: the verifier MUST check all identities and collect all matches.
|
|
128
|
+
* The verifier MUST NOT short-circuit after the first duress match.
|
|
129
|
+
*
|
|
130
|
+
* @param secret - Shared secret (hex string or Uint8Array).
|
|
131
|
+
* @param context - Context string for domain separation.
|
|
132
|
+
* @param counter - Current time-based counter.
|
|
133
|
+
* @param input - The spoken/entered token to verify.
|
|
134
|
+
* @param identities - Array of member pubkeys (max 100).
|
|
135
|
+
* @param options - Optional encoding and tolerance settings.
|
|
136
|
+
* @returns `{ status: 'valid' | 'duress' | 'invalid', identities?: string[] }`.
|
|
137
|
+
* @throws {RangeError} If tolerance exceeds MAX_TOLERANCE or identities exceeds 100.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```ts
|
|
141
|
+
* const result = verifyToken(seed, 'canary:group', counter, 'falcon', [alice, bob])
|
|
142
|
+
* if (result.status === 'duress') alert(`Duress from: ${result.identities}`)
|
|
143
|
+
* ```
|
|
144
|
+
*/
|
|
145
|
+
export declare function verifyToken(secret: Uint8Array | string, context: string, counter: number, input: string, identities: string[], options?: VerifyOptions): TokenVerifyResult;
|
|
146
|
+
/**
|
|
147
|
+
* CANARY-DURESS: Derive a liveness heartbeat token for dead man's switch.
|
|
148
|
+
*
|
|
149
|
+
* Algorithm: HMAC-SHA256(secret, utf8(context + ":alive") || 0x00 || utf8(identity) || counter_be32)
|
|
150
|
+
*
|
|
151
|
+
* The null-byte separator between the context suffix and identity prevents concatenation
|
|
152
|
+
* ambiguity.
|
|
153
|
+
*
|
|
154
|
+
* The liveness token proves both identity and knowledge of the secret.
|
|
155
|
+
* If heartbeats stop arriving, the implementation triggers its DMS response.
|
|
156
|
+
*
|
|
157
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
158
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
159
|
+
* @param identity - Member pubkey or identifier of the heartbeat sender.
|
|
160
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
161
|
+
* @returns Raw 32-byte HMAC-SHA256 digest for the liveness heartbeat.
|
|
162
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
163
|
+
*/
|
|
164
|
+
export declare function deriveLivenessToken(secret: Uint8Array | string, context: string, identity: string, counter: number): Uint8Array;
|
|
165
|
+
/** A pair of directional tokens keyed by role name. */
|
|
166
|
+
export interface DirectionalPair {
|
|
167
|
+
[role: string]: string;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Derive a directional pair: two distinct tokens from the same secret,
|
|
171
|
+
* one per role. Each token uses context = `${namespace}\0${role}`.
|
|
172
|
+
*
|
|
173
|
+
* Neither token can be derived from the other without the shared secret.
|
|
174
|
+
* This prevents the "echo problem" where the second speaker could parrot
|
|
175
|
+
* the first.
|
|
176
|
+
*
|
|
177
|
+
* @param secret - Shared secret (hex string or Uint8Array).
|
|
178
|
+
* @param namespace - Namespace prefix (e.g. `'aviva'`, `'dispatch'`).
|
|
179
|
+
* @param roles - Exactly two role names (e.g. `['caller', 'agent']`).
|
|
180
|
+
* @param counter - Current counter value.
|
|
181
|
+
* @param encoding - Output encoding format (default: single word).
|
|
182
|
+
* @returns Object keyed by role name, each value is the role's token string.
|
|
183
|
+
* @throws {Error} If roles does not contain exactly 2 entries, or roles are identical.
|
|
184
|
+
*/
|
|
185
|
+
export declare function deriveDirectionalPair(secret: Uint8Array | string, namespace: string, roles: [string, string], counter: number, encoding?: TokenEncoding): DirectionalPair;
|
|
186
|
+
//# sourceMappingURL=token.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.d.ts","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"AACA,OAAO,EAAe,KAAK,aAAa,EAAoB,MAAM,eAAe,CAAA;AAEjF;;;;GAIG;AACH,eAAO,MAAM,aAAa,KAAK,CAAA;AAmC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,QAAQ,CAAC,EAAE,MAAM,GAChB,UAAU,CASZ;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,QAAQ,GAAE,aAAgC,EAC1C,QAAQ,CAAC,EAAE,MAAM,GAChB,MAAM,CAGR;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GACd,UAAU,CAIZ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,aAAa,YAAmB,EAC1C,YAAY,EAAE,MAAM,EACpB,UAAU,CAAC,EAAE,MAAM,EAAE,GACpB,MAAM,CA2CR;AAED,mCAAmC;AACnC,MAAM,WAAW,iBAAiB;IAChC,+FAA+F;IAC/F,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAA;IACtC,qEAAqE;IACrE,UAAU,CAAC,EAAE,MAAM,EAAE,CAAA;CACtB;AAED,sCAAsC;AACtC,MAAM,WAAW,aAAa;IAC5B,oEAAoE;IACpE,QAAQ,CAAC,EAAE,aAAa,CAAA;IACxB,6FAA6F;IAC7F,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,WAAW,CACzB,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,EAAE,EACpB,OAAO,CAAC,EAAE,aAAa,GACtB,iBAAiB,CAqEnB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,GACd,UAAU,CAIZ;AAED,uDAAuD;AACvD,MAAM,WAAW,eAAe;IAC9B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAA;CACvB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,UAAU,GAAG,MAAM,EAC3B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EACvB,OAAO,EAAE,MAAM,EACf,QAAQ,GAAE,aAAgC,GACzC,eAAe,CAsBjB"}
|
package/dist/token.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { hmacSha256, hexToBytes, concatBytes, timingSafeStringEqual } from './crypto.js';
|
|
2
|
+
import { encodeToken, DEFAULT_ENCODING } from './encoding.js';
|
|
3
|
+
/**
|
|
4
|
+
* Maximum allowed tolerance/maxTolerance value.
|
|
5
|
+
* Prevents pathological iteration: at MAX_TOLERANCE=10, the collision
|
|
6
|
+
* avoidance window is ±20 counters (41 iterations) — well within reason.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_TOLERANCE = 10;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
function utf8(str) {
|
|
11
|
+
return encoder.encode(str);
|
|
12
|
+
}
|
|
13
|
+
function counterBe32(counter) {
|
|
14
|
+
if (!Number.isInteger(counter) || counter < 0 || counter > 0xFFFFFFFF) {
|
|
15
|
+
throw new RangeError(`Counter must be an integer 0–${0xFFFFFFFF}, got ${counter}`);
|
|
16
|
+
}
|
|
17
|
+
const buf = new Uint8Array(4);
|
|
18
|
+
const view = new DataView(buf.buffer);
|
|
19
|
+
view.setUint32(0, counter, false);
|
|
20
|
+
return buf;
|
|
21
|
+
}
|
|
22
|
+
// 128-bit (16-byte) minimum for the universal token API. The group layer
|
|
23
|
+
// enforces 256-bit (32-byte) seeds per CANARY.md, but the universal API
|
|
24
|
+
// supports contexts beyond group seeds where 128-bit keys may be appropriate
|
|
25
|
+
// (e.g. per-session or per-task handoff secrets).
|
|
26
|
+
const MIN_SECRET_BYTES = 16;
|
|
27
|
+
/** Maximum identities in verifyToken to bound O(n²·t²) computational cost. */
|
|
28
|
+
const MAX_MEMBERS = 100;
|
|
29
|
+
function normaliseSecret(secret) {
|
|
30
|
+
const key = typeof secret === 'string' ? hexToBytes(secret) : secret;
|
|
31
|
+
if (key.length < MIN_SECRET_BYTES) {
|
|
32
|
+
throw new RangeError(`Secret must be at least ${MIN_SECRET_BYTES} bytes, got ${key.length}`);
|
|
33
|
+
}
|
|
34
|
+
return key;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* CANARY-DERIVE: Derive raw token bytes from a shared secret, context, and counter.
|
|
38
|
+
*
|
|
39
|
+
* When `identity` is omitted, derives a group-wide token:
|
|
40
|
+
* HMAC-SHA256(secret, utf8(context) || counter_be32)
|
|
41
|
+
*
|
|
42
|
+
* When `identity` is provided, derives a per-member token:
|
|
43
|
+
* HMAC-SHA256(secret, utf8(context) || 0x00 || utf8(identity) || counter_be32)
|
|
44
|
+
*
|
|
45
|
+
* The null-byte separator prevents concatenation ambiguity between context and identity.
|
|
46
|
+
*
|
|
47
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
48
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
49
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
50
|
+
* @param identity - Optional member pubkey for per-member tokens.
|
|
51
|
+
* @returns Raw 32-byte HMAC-SHA256 digest.
|
|
52
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
53
|
+
* @throws {Error} If identity is provided but empty.
|
|
54
|
+
*/
|
|
55
|
+
export function deriveTokenBytes(secret, context, counter, identity) {
|
|
56
|
+
if (identity !== undefined && identity === '') {
|
|
57
|
+
throw new Error('identity must be non-empty when provided');
|
|
58
|
+
}
|
|
59
|
+
const key = normaliseSecret(secret);
|
|
60
|
+
const data = identity
|
|
61
|
+
? concatBytes(utf8(context), new Uint8Array([0x00]), utf8(identity), counterBe32(counter))
|
|
62
|
+
: concatBytes(utf8(context), counterBe32(counter));
|
|
63
|
+
return hmacSha256(key, data);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* CANARY-DERIVE: Derive an encoded token string.
|
|
67
|
+
*
|
|
68
|
+
* When `identity` is provided, produces a per-member token unique to that member.
|
|
69
|
+
* When omitted, produces the group-wide token (backwards-compatible).
|
|
70
|
+
*
|
|
71
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
72
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
73
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
74
|
+
* @param encoding - Output encoding format (default: single word from en-v1 wordlist).
|
|
75
|
+
* @param identity - Optional member pubkey for per-member tokens.
|
|
76
|
+
* @returns Encoded token string (word, PIN, or hex depending on encoding).
|
|
77
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* deriveToken(seedHex, 'canary:group', 42) // "falcon"
|
|
82
|
+
* deriveToken(seedHex, 'canary:group', 42, { format: 'pin', digits: 6 }) // "083721"
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function deriveToken(secret, context, counter, encoding = DEFAULT_ENCODING, identity) {
|
|
86
|
+
const bytes = deriveTokenBytes(secret, context, counter, identity);
|
|
87
|
+
return encodeToken(bytes, encoding);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* CANARY-DURESS: Derive raw duress token bytes for a specific identity.
|
|
91
|
+
*
|
|
92
|
+
* Algorithm: HMAC-SHA256(secret, utf8(context + ":duress") || 0x00 || utf8(identity) || counter_be32)
|
|
93
|
+
*
|
|
94
|
+
* The null-byte separator between the context suffix and identity prevents concatenation
|
|
95
|
+
* ambiguity (e.g. context="x:duress" + identity="" vs context="x" + identity=":duress").
|
|
96
|
+
*
|
|
97
|
+
* NOTE: Returns raw bytes without collision avoidance. Use deriveDuressToken()
|
|
98
|
+
* for encoded output with guaranteed non-collision against the normal token.
|
|
99
|
+
*
|
|
100
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
101
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
102
|
+
* @param identity - Member pubkey or identifier (must be non-empty).
|
|
103
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
104
|
+
* @returns Raw 32-byte HMAC-SHA256 digest.
|
|
105
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
106
|
+
*/
|
|
107
|
+
export function deriveDuressTokenBytes(secret, context, identity, counter) {
|
|
108
|
+
const key = normaliseSecret(secret);
|
|
109
|
+
const data = concatBytes(utf8(context + ':duress'), new Uint8Array([0x00]), utf8(identity), counterBe32(counter));
|
|
110
|
+
return hmacSha256(key, data);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* CANARY-DURESS: Derive an encoded duress token with collision avoidance.
|
|
114
|
+
*
|
|
115
|
+
* If the duress token collides with any normal verification token within
|
|
116
|
+
* ±(2 × maxTolerance) counter values (at the encoding level), re-derives with
|
|
117
|
+
* incrementing suffix bytes (0x01, 0x02, ..., 0xFF) until distinct.
|
|
118
|
+
* The 2× factor accounts for worst-case counter drift: the deriver and verifier
|
|
119
|
+
* may each be off by maxTolerance in opposite directions.
|
|
120
|
+
* If all 255 suffixes collide (astronomically unlikely), throws an error
|
|
121
|
+
* rather than failing open.
|
|
122
|
+
*
|
|
123
|
+
* **maxTolerance is required** — it must match the tolerance used by verifiers.
|
|
124
|
+
* Using an insufficient value allows duress tokens to collide with normal tokens
|
|
125
|
+
* at distant counters, causing silent alarm suppression.
|
|
126
|
+
*
|
|
127
|
+
* **identities** — when provided, the forbidden set also includes per-member
|
|
128
|
+
* normal tokens for all identities across the collision avoidance window.
|
|
129
|
+
* Without this, a duress token for identity A could collide with the normal
|
|
130
|
+
* per-member token for identity B, causing false duress detection.
|
|
131
|
+
*
|
|
132
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
133
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
134
|
+
* @param identity - Member pubkey or identifier for the duress signaller.
|
|
135
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
136
|
+
* @param encoding - Output encoding format (default: single word from en-v1 wordlist).
|
|
137
|
+
* @param maxTolerance - Counter tolerance window used by verifiers; must match their value.
|
|
138
|
+
* @param identities - All group member pubkeys for cross-member collision avoidance.
|
|
139
|
+
* @returns Encoded duress token string guaranteed distinct from all normal tokens in the window.
|
|
140
|
+
* @throws {RangeError} If maxTolerance is negative, exceeds MAX_TOLERANCE, or is not an integer.
|
|
141
|
+
* @throws {Error} If collision avoidance fails after 255 retries.
|
|
142
|
+
*/
|
|
143
|
+
export function deriveDuressToken(secret, context, identity, counter, encoding = DEFAULT_ENCODING, maxTolerance, identities) {
|
|
144
|
+
if (!Number.isInteger(maxTolerance) || maxTolerance < 0) {
|
|
145
|
+
throw new RangeError('maxTolerance must be a non-negative integer');
|
|
146
|
+
}
|
|
147
|
+
if (maxTolerance > MAX_TOLERANCE) {
|
|
148
|
+
throw new RangeError(`maxTolerance must be <= ${MAX_TOLERANCE}, got ${maxTolerance}`);
|
|
149
|
+
}
|
|
150
|
+
// Collect normal tokens within ±(2 × maxTolerance) for cross-counter collision avoidance.
|
|
151
|
+
// The 2× window accounts for worst-case counter drift between deriver and verifier.
|
|
152
|
+
const forbidden = new Set();
|
|
153
|
+
const window = 2 * maxTolerance;
|
|
154
|
+
const lo = Math.max(0, counter - window);
|
|
155
|
+
const hi = Math.min(0xFFFFFFFF, counter + window);
|
|
156
|
+
for (let c = lo; c <= hi; c++) {
|
|
157
|
+
// Group-wide (anonymous) token
|
|
158
|
+
forbidden.add(deriveToken(secret, context, c, encoding));
|
|
159
|
+
// Per-member tokens for all known identities
|
|
160
|
+
if (identities) {
|
|
161
|
+
for (const id of identities) {
|
|
162
|
+
forbidden.add(deriveToken(secret, context, c, encoding, id));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const key = normaliseSecret(secret);
|
|
167
|
+
const baseData = concatBytes(utf8(context + ':duress'), new Uint8Array([0x00]), utf8(identity), counterBe32(counter));
|
|
168
|
+
let bytes = hmacSha256(key, baseData);
|
|
169
|
+
let token = encodeToken(bytes, encoding);
|
|
170
|
+
// Collision avoidance: deterministic multi-suffix retry
|
|
171
|
+
let suffix = 1;
|
|
172
|
+
while (forbidden.has(token) && suffix <= 255) {
|
|
173
|
+
bytes = hmacSha256(key, concatBytes(baseData, new Uint8Array([suffix])));
|
|
174
|
+
token = encodeToken(bytes, encoding);
|
|
175
|
+
suffix++;
|
|
176
|
+
}
|
|
177
|
+
if (forbidden.has(token)) {
|
|
178
|
+
throw new Error('Duress token collision unresolvable after 255 retries');
|
|
179
|
+
}
|
|
180
|
+
return token;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* CANARY-DURESS: Verify a spoken/entered token against a group.
|
|
184
|
+
*
|
|
185
|
+
* Checks in priority order (exact-counter-first):
|
|
186
|
+
* 1. Normal verification token at exact counter → 'valid'
|
|
187
|
+
* 2. ALL identities' duress tokens (within tolerance window) → 'duress' with all matches
|
|
188
|
+
* 3. Normal verification token at remaining tolerance window → 'valid'
|
|
189
|
+
* 4. No match → 'invalid'
|
|
190
|
+
*
|
|
191
|
+
* Exact-counter normal is checked first because same-counter collision avoidance
|
|
192
|
+
* guarantees no ambiguity. Duress across the full window is checked next so that
|
|
193
|
+
* duress at the exact counter is never masked by normal at an adjacent counter.
|
|
194
|
+
*
|
|
195
|
+
* Per CANARY-DURESS: the verifier MUST check all identities and collect all matches.
|
|
196
|
+
* The verifier MUST NOT short-circuit after the first duress match.
|
|
197
|
+
*
|
|
198
|
+
* @param secret - Shared secret (hex string or Uint8Array).
|
|
199
|
+
* @param context - Context string for domain separation.
|
|
200
|
+
* @param counter - Current time-based counter.
|
|
201
|
+
* @param input - The spoken/entered token to verify.
|
|
202
|
+
* @param identities - Array of member pubkeys (max 100).
|
|
203
|
+
* @param options - Optional encoding and tolerance settings.
|
|
204
|
+
* @returns `{ status: 'valid' | 'duress' | 'invalid', identities?: string[] }`.
|
|
205
|
+
* @throws {RangeError} If tolerance exceeds MAX_TOLERANCE or identities exceeds 100.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* ```ts
|
|
209
|
+
* const result = verifyToken(seed, 'canary:group', counter, 'falcon', [alice, bob])
|
|
210
|
+
* if (result.status === 'duress') alert(`Duress from: ${result.identities}`)
|
|
211
|
+
* ```
|
|
212
|
+
*/
|
|
213
|
+
export function verifyToken(secret, context, counter, input, identities, options) {
|
|
214
|
+
const encoding = options?.encoding ?? DEFAULT_ENCODING;
|
|
215
|
+
const tolerance = options?.tolerance ?? 0;
|
|
216
|
+
if (!Number.isInteger(tolerance) || tolerance < 0) {
|
|
217
|
+
throw new RangeError('Tolerance must be a non-negative integer');
|
|
218
|
+
}
|
|
219
|
+
if (tolerance > MAX_TOLERANCE) {
|
|
220
|
+
throw new RangeError(`Tolerance must be <= ${MAX_TOLERANCE}, got ${tolerance}`);
|
|
221
|
+
}
|
|
222
|
+
if (identities.length > MAX_MEMBERS) {
|
|
223
|
+
throw new RangeError(`identities array must not exceed ${MAX_MEMBERS} entries, got ${identities.length}`);
|
|
224
|
+
}
|
|
225
|
+
const normalised = input.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
226
|
+
// All branches are computed regardless of which matches first to reduce
|
|
227
|
+
// timing side-channels. Note: deriveDuressToken has variable cost due to
|
|
228
|
+
// its collision-avoidance retry loop, so timing protection is partial.
|
|
229
|
+
// For high-assurance use, pair with rate limiting.
|
|
230
|
+
const lo = Math.max(0, counter - tolerance);
|
|
231
|
+
const hi = Math.min(0xFFFFFFFF, counter + tolerance);
|
|
232
|
+
// 1. Check per-member tokens at exact counter (each member has a unique word)
|
|
233
|
+
let exactMember = null;
|
|
234
|
+
for (const identity of identities) {
|
|
235
|
+
if (timingSafeStringEqual(normalised, deriveToken(secret, context, counter, encoding, identity))) {
|
|
236
|
+
exactMember = identity;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
// 2. Check duress tokens for ALL identities across entire tolerance window
|
|
240
|
+
const duressMatches = [];
|
|
241
|
+
for (const identity of identities) {
|
|
242
|
+
let found = false;
|
|
243
|
+
for (let c = lo; c <= hi; c++) {
|
|
244
|
+
if (timingSafeStringEqual(normalised, deriveDuressToken(secret, context, identity, c, encoding, tolerance, identities))) {
|
|
245
|
+
found = true;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (found)
|
|
249
|
+
duressMatches.push(identity);
|
|
250
|
+
}
|
|
251
|
+
// 3. Check per-member tokens at remaining tolerance window (non-exact counters)
|
|
252
|
+
let toleranceMember = null;
|
|
253
|
+
for (const identity of identities) {
|
|
254
|
+
for (let c = lo; c <= hi; c++) {
|
|
255
|
+
if (c === counter)
|
|
256
|
+
continue; // already checked in step 1
|
|
257
|
+
if (timingSafeStringEqual(normalised, deriveToken(secret, context, c, encoding, identity))) {
|
|
258
|
+
toleranceMember = identity;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
// 4. Also check group-wide token (backwards compat for anonymous verification)
|
|
263
|
+
let groupMatch = false;
|
|
264
|
+
for (let c = lo; c <= hi; c++) {
|
|
265
|
+
if (timingSafeStringEqual(normalised, deriveToken(secret, context, c, encoding))) {
|
|
266
|
+
groupMatch = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
// Priority: duress always wins unless there's an exact-counter exact-member match
|
|
270
|
+
// (collision avoidance guarantees no ambiguity at the exact counter).
|
|
271
|
+
// An exact per-member match that also matches duress → duress wins (safety-first).
|
|
272
|
+
if (duressMatches.length > 0)
|
|
273
|
+
return { status: 'duress', identities: duressMatches };
|
|
274
|
+
if (exactMember)
|
|
275
|
+
return { status: 'valid', identities: [exactMember] };
|
|
276
|
+
if (toleranceMember)
|
|
277
|
+
return { status: 'valid', identities: [toleranceMember] };
|
|
278
|
+
if (groupMatch)
|
|
279
|
+
return { status: 'valid' };
|
|
280
|
+
return { status: 'invalid' };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* CANARY-DURESS: Derive a liveness heartbeat token for dead man's switch.
|
|
284
|
+
*
|
|
285
|
+
* Algorithm: HMAC-SHA256(secret, utf8(context + ":alive") || 0x00 || utf8(identity) || counter_be32)
|
|
286
|
+
*
|
|
287
|
+
* The null-byte separator between the context suffix and identity prevents concatenation
|
|
288
|
+
* ambiguity.
|
|
289
|
+
*
|
|
290
|
+
* The liveness token proves both identity and knowledge of the secret.
|
|
291
|
+
* If heartbeats stop arriving, the implementation triggers its DMS response.
|
|
292
|
+
*
|
|
293
|
+
* @param secret - Shared secret (hex string or Uint8Array, minimum 16 bytes).
|
|
294
|
+
* @param context - Context string for domain separation (e.g. `'canary:group'`).
|
|
295
|
+
* @param identity - Member pubkey or identifier of the heartbeat sender.
|
|
296
|
+
* @param counter - Time-based or usage counter (uint32).
|
|
297
|
+
* @returns Raw 32-byte HMAC-SHA256 digest for the liveness heartbeat.
|
|
298
|
+
* @throws {RangeError} If secret is too short or counter is out of range.
|
|
299
|
+
*/
|
|
300
|
+
export function deriveLivenessToken(secret, context, identity, counter) {
|
|
301
|
+
const key = normaliseSecret(secret);
|
|
302
|
+
const data = concatBytes(utf8(context + ':alive'), new Uint8Array([0x00]), utf8(identity), counterBe32(counter));
|
|
303
|
+
return hmacSha256(key, data);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Derive a directional pair: two distinct tokens from the same secret,
|
|
307
|
+
* one per role. Each token uses context = `${namespace}\0${role}`.
|
|
308
|
+
*
|
|
309
|
+
* Neither token can be derived from the other without the shared secret.
|
|
310
|
+
* This prevents the "echo problem" where the second speaker could parrot
|
|
311
|
+
* the first.
|
|
312
|
+
*
|
|
313
|
+
* @param secret - Shared secret (hex string or Uint8Array).
|
|
314
|
+
* @param namespace - Namespace prefix (e.g. `'aviva'`, `'dispatch'`).
|
|
315
|
+
* @param roles - Exactly two role names (e.g. `['caller', 'agent']`).
|
|
316
|
+
* @param counter - Current counter value.
|
|
317
|
+
* @param encoding - Output encoding format (default: single word).
|
|
318
|
+
* @returns Object keyed by role name, each value is the role's token string.
|
|
319
|
+
* @throws {Error} If roles does not contain exactly 2 entries, or roles are identical.
|
|
320
|
+
*/
|
|
321
|
+
export function deriveDirectionalPair(secret, namespace, roles, counter, encoding = DEFAULT_ENCODING) {
|
|
322
|
+
if (!namespace) {
|
|
323
|
+
throw new Error('namespace must be a non-empty string');
|
|
324
|
+
}
|
|
325
|
+
if (namespace.includes('\0')) {
|
|
326
|
+
throw new Error('namespace must not contain null bytes');
|
|
327
|
+
}
|
|
328
|
+
if (!roles[0] || !roles[1]) {
|
|
329
|
+
throw new Error('Both roles must be non-empty strings');
|
|
330
|
+
}
|
|
331
|
+
if (roles[0].includes('\0') || roles[1].includes('\0')) {
|
|
332
|
+
throw new Error('Roles must not contain null bytes');
|
|
333
|
+
}
|
|
334
|
+
if (roles[0] === roles[1]) {
|
|
335
|
+
throw new Error(`Roles must be distinct, got ["${roles[0]}", "${roles[1]}"]`);
|
|
336
|
+
}
|
|
337
|
+
// Use null-byte separator to prevent concatenation ambiguity
|
|
338
|
+
// (e.g. namespace "a:b" + role "c" vs namespace "a" + role "b:c")
|
|
339
|
+
return {
|
|
340
|
+
[roles[0]]: deriveToken(secret, `${namespace}\0${roles[0]}`, counter, encoding),
|
|
341
|
+
[roles[1]]: deriveToken(secret, `${namespace}\0${roles[1]}`, counter, encoding),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
//# sourceMappingURL=token.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token.js","sourceRoot":"","sources":["../src/token.ts"],"names":[],"mappings":"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"}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group-level word verification — thin wrapper around the universal token API.
|
|
3
|
+
*
|
|
4
|
+
* Maps verifyToken results to group-specific statuses:
|
|
5
|
+
* - 'verified' = exact counter match
|
|
6
|
+
* - 'duress' = duress token detected (with member pubkeys)
|
|
7
|
+
* - 'stale' = valid token from an adjacent counter (out of sync)
|
|
8
|
+
* - 'failed' = no match
|
|
9
|
+
*/
|
|
10
|
+
export type VerifyStatus = 'verified' | 'duress' | 'stale' | 'failed';
|
|
11
|
+
export interface VerifyResult {
|
|
12
|
+
/** The outcome of the verification check. */
|
|
13
|
+
status: VerifyStatus;
|
|
14
|
+
/** Pubkeys of members whose duress word matched (only when status = 'duress'). */
|
|
15
|
+
members?: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Verify a spoken word against the group's current state.
|
|
19
|
+
*
|
|
20
|
+
* Checks in order:
|
|
21
|
+
* 1. Current verification word → verified
|
|
22
|
+
* 2. ALL members' duress words within tolerance window → duress (with all matching members)
|
|
23
|
+
* 3. Adjacent verification word within tolerance → stale (out of sync)
|
|
24
|
+
* 4. None matched → failed
|
|
25
|
+
*
|
|
26
|
+
* Per CANARY-DURESS: the verifier MUST check all identities and collect all matches.
|
|
27
|
+
* The verifier MUST NOT short-circuit after the first duress match.
|
|
28
|
+
*
|
|
29
|
+
* @param spokenWord - The word or phrase spoken/entered by the user (case-insensitive, trimmed).
|
|
30
|
+
* @param seedHex - Group seed as a hex string.
|
|
31
|
+
* @param memberPubkeys - Array of member pubkeys for per-member and duress checking.
|
|
32
|
+
* @param counter - Current effective counter for the group.
|
|
33
|
+
* @param wordCount - Number of words in the token (1, 2, or 3; default: 1).
|
|
34
|
+
* @param tolerance - Counter tolerance window: accept tokens within ±tolerance (default: 1).
|
|
35
|
+
* @returns `{ status: 'verified' | 'duress' | 'stale' | 'failed', members?: string[] }`.
|
|
36
|
+
* @throws {RangeError} If tolerance exceeds MAX_TOLERANCE or memberPubkeys exceeds 100.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```ts
|
|
40
|
+
* const result = verifyWord('falcon', seedHex, [alicePubkey, bobPubkey], counter)
|
|
41
|
+
* if (result.status === 'duress') alert(`Duress from: ${result.members}`)
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function verifyWord(spokenWord: string, seedHex: string, memberPubkeys: string[], counter: number, wordCount?: 1 | 2 | 3, tolerance?: number): VerifyResult;
|
|
45
|
+
//# sourceMappingURL=verify.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify.d.ts","sourceRoot":"","sources":["../src/verify.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,MAAM,MAAM,YAAY,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,QAAQ,CAAA;AAErE,MAAM,WAAW,YAAY;IAC3B,6CAA6C;IAC7C,MAAM,EAAE,YAAY,CAAA;IACpB,kFAAkF;IAClF,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,UAAU,CACxB,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,EACf,aAAa,EAAE,MAAM,EAAE,EACvB,OAAO,EAAE,MAAM,EACf,SAAS,GAAE,CAAC,GAAG,CAAC,GAAG,CAAK,EACxB,SAAS,GAAE,MAAU,GACpB,YAAY,CAkBd"}
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group-level word verification — thin wrapper around the universal token API.
|
|
3
|
+
*
|
|
4
|
+
* Maps verifyToken results to group-specific statuses:
|
|
5
|
+
* - 'verified' = exact counter match
|
|
6
|
+
* - 'duress' = duress token detected (with member pubkeys)
|
|
7
|
+
* - 'stale' = valid token from an adjacent counter (out of sync)
|
|
8
|
+
* - 'failed' = no match
|
|
9
|
+
*/
|
|
10
|
+
import { verifyToken, deriveToken } from './token.js';
|
|
11
|
+
import { GROUP_CONTEXT } from './derive.js';
|
|
12
|
+
import { timingSafeStringEqual } from './crypto.js';
|
|
13
|
+
/**
|
|
14
|
+
* Verify a spoken word against the group's current state.
|
|
15
|
+
*
|
|
16
|
+
* Checks in order:
|
|
17
|
+
* 1. Current verification word → verified
|
|
18
|
+
* 2. ALL members' duress words within tolerance window → duress (with all matching members)
|
|
19
|
+
* 3. Adjacent verification word within tolerance → stale (out of sync)
|
|
20
|
+
* 4. None matched → failed
|
|
21
|
+
*
|
|
22
|
+
* Per CANARY-DURESS: the verifier MUST check all identities and collect all matches.
|
|
23
|
+
* The verifier MUST NOT short-circuit after the first duress match.
|
|
24
|
+
*
|
|
25
|
+
* @param spokenWord - The word or phrase spoken/entered by the user (case-insensitive, trimmed).
|
|
26
|
+
* @param seedHex - Group seed as a hex string.
|
|
27
|
+
* @param memberPubkeys - Array of member pubkeys for per-member and duress checking.
|
|
28
|
+
* @param counter - Current effective counter for the group.
|
|
29
|
+
* @param wordCount - Number of words in the token (1, 2, or 3; default: 1).
|
|
30
|
+
* @param tolerance - Counter tolerance window: accept tokens within ±tolerance (default: 1).
|
|
31
|
+
* @returns `{ status: 'verified' | 'duress' | 'stale' | 'failed', members?: string[] }`.
|
|
32
|
+
* @throws {RangeError} If tolerance exceeds MAX_TOLERANCE or memberPubkeys exceeds 100.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* const result = verifyWord('falcon', seedHex, [alicePubkey, bobPubkey], counter)
|
|
37
|
+
* if (result.status === 'duress') alert(`Duress from: ${result.members}`)
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
export function verifyWord(spokenWord, seedHex, memberPubkeys, counter, wordCount = 1, tolerance = 1) {
|
|
41
|
+
const encodingOpt = wordCount === 1
|
|
42
|
+
? undefined
|
|
43
|
+
: { format: 'words', count: wordCount };
|
|
44
|
+
const result = verifyToken(seedHex, GROUP_CONTEXT, counter, spokenWord, memberPubkeys, {
|
|
45
|
+
encoding: encodingOpt,
|
|
46
|
+
tolerance,
|
|
47
|
+
});
|
|
48
|
+
if (result.status === 'invalid')
|
|
49
|
+
return { status: 'failed' };
|
|
50
|
+
if (result.status === 'duress')
|
|
51
|
+
return { status: 'duress', members: result.identities };
|
|
52
|
+
// result.status === 'valid' — distinguish exact from stale
|
|
53
|
+
const normalised = spokenWord.toLowerCase().trim().replace(/\s+/g, ' ');
|
|
54
|
+
const exact = deriveToken(seedHex, GROUP_CONTEXT, counter, encodingOpt);
|
|
55
|
+
if (timingSafeStringEqual(normalised, exact))
|
|
56
|
+
return { status: 'verified' };
|
|
57
|
+
return { status: 'stale' };
|
|
58
|
+
}
|
|
59
|
+
//# sourceMappingURL=verify.js.map
|