canary-kit 2.6.2 → 2.7.1
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/API.md +306 -0
- package/COOKBOOK.md +763 -0
- package/INTEGRATION.md +329 -3
- package/README.md +25 -9
- package/SECURITY.md +2 -1
- package/dist/beacon.d.ts +2 -2
- package/dist/beacon.d.ts.map +1 -1
- package/llms-full.txt +170 -106
- package/llms.txt +24 -18
- package/package.json +7 -1
package/llms-full.txt
CHANGED
|
@@ -38,9 +38,9 @@ import { deriveToken, deriveDuressToken, verifyToken, deriveLivenessToken, deriv
|
|
|
38
38
|
import { encodeAsWords, encodeAsPin, encodeAsHex, encodeToken } from 'canary-kit/encoding'
|
|
39
39
|
import { createSession, generateSeed, deriveSeed, SESSION_PRESETS } from 'canary-kit/session'
|
|
40
40
|
import { WORDLIST, WORDLIST_SIZE, getWord, indexOf } from 'canary-kit/wordlist'
|
|
41
|
-
import { KINDS,
|
|
41
|
+
import { KINDS, buildGroupStateEvent, buildStoredSignalEvent, buildSignalEvent, buildRumourEvent, hashGroupId } from 'canary-kit/nostr'
|
|
42
42
|
import { deriveBeaconKey, encryptBeacon, decryptBeacon, buildDuressAlert, encryptDuressAlert, decryptDuressAlert } from 'canary-kit/beacon'
|
|
43
|
-
import { applySyncMessage, decodeSyncMessage, encodeSyncMessage, deriveGroupKey,
|
|
43
|
+
import { applySyncMessage, applySyncMessageWithResult, decodeSyncMessage, encodeSyncMessage, deriveGroupKey, deriveGroupIdentity, hashGroupTag, encryptEnvelope, decryptEnvelope, PROTOCOL_VERSION, type SyncApplyResult, type SyncTransport, type EventSigner } from 'canary-kit/sync'
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
---
|
|
@@ -107,8 +107,10 @@ interface GroupConfig {
|
|
|
107
107
|
rotationInterval?: number // Seconds; overrides preset value
|
|
108
108
|
wordCount?: 1 | 2 | 3 // Words per challenge; overrides preset value
|
|
109
109
|
wordlist?: string // Wordlist identifier (default: 'en-v1')
|
|
110
|
+
tolerance?: number // Counter tolerance for verification (default: 1)
|
|
110
111
|
beaconInterval?: number // Beacon broadcast interval in seconds (default: 300)
|
|
111
112
|
beaconPrecision?: number // Geohash precision for normal beacons 1–11 (default: 6)
|
|
113
|
+
creator?: string // Pubkey of group creator — only the creator is admin at bootstrap. Must be in members. Without creator, admins is empty and all privileged sync ops are silently rejected.
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
/** Serialisable persistent state for a canary group. */
|
|
@@ -116,14 +118,19 @@ interface GroupState {
|
|
|
116
118
|
name: string
|
|
117
119
|
seed: string // 64-char hex (256-bit shared secret)
|
|
118
120
|
members: string[] // Nostr pubkeys (64-char hex)
|
|
121
|
+
admins: string[] // Pubkeys with admin privileges (reseed, add/remove others). Set via GroupConfig.creator.
|
|
119
122
|
rotationInterval: number // Seconds
|
|
120
123
|
wordCount: 1 | 2 | 3
|
|
121
124
|
wordlist: string // e.g. 'en-v1'
|
|
122
125
|
counter: number // Time-based counter at last sync
|
|
123
126
|
usageOffset: number // Burn-after-use offset on top of counter
|
|
127
|
+
tolerance: number // Counter tolerance for verification
|
|
124
128
|
createdAt: number // Unix timestamp
|
|
125
129
|
beaconInterval: number // Seconds
|
|
126
130
|
beaconPrecision: number // 1–11
|
|
131
|
+
epoch: number // Monotonic epoch — increments on reseed. Used for replay protection.
|
|
132
|
+
consumedOps: string[] // Consumed operation IDs within current epoch. Cleared on epoch bump.
|
|
133
|
+
consumedOpsFloor?: number // Timestamp floor: reject messages at or below this value (replay protection after consumedOps eviction)
|
|
127
134
|
}
|
|
128
135
|
|
|
129
136
|
// ── Group Presets ─────────────────────────────────────────────────────────────
|
|
@@ -177,7 +184,7 @@ interface Session {
|
|
|
177
184
|
|
|
178
185
|
// ── Beacon ────────────────────────────────────────────────────────────────────
|
|
179
186
|
|
|
180
|
-
/** Decrypted content of
|
|
187
|
+
/** Decrypted content of an encrypted location beacon (kind 20078 signal). */
|
|
181
188
|
interface BeaconPayload {
|
|
182
189
|
geohash: string
|
|
183
190
|
precision: number
|
|
@@ -429,16 +436,41 @@ const updated = addMember(state, '<charlie-pubkey>')
|
|
|
429
436
|
|
|
430
437
|
### removeMember(state, pubkey)
|
|
431
438
|
|
|
432
|
-
Remove a member from the group
|
|
439
|
+
Remove a member from the group's member list. Does NOT reseed — the removed member still possesses the old seed. Use `removeMemberAndReseed()` instead unless you have a specific reason not to. Returns new state — does not mutate.
|
|
433
440
|
|
|
434
441
|
```typescript
|
|
435
442
|
removeMember(state: GroupState, pubkey: string): GroupState
|
|
436
443
|
|
|
437
444
|
const updated = removeMember(state, '<alice-pubkey>')
|
|
445
|
+
// updated.seed === state.seed (NOT reseeded — old seed still valid)
|
|
446
|
+
// updated.members does not include alice
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
### removeMemberAndReseed(state, pubkey)
|
|
450
|
+
|
|
451
|
+
Remove a member and immediately reseed, atomically invalidating the old seed. This is the recommended way to remove members — ensures forward secrecy by preventing the removed member from deriving future tokens or decrypting future beacons. Returns new state — does not mutate.
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
removeMemberAndReseed(state: GroupState, pubkey: string): GroupState
|
|
455
|
+
|
|
456
|
+
const updated = removeMemberAndReseed(state, '<alice-pubkey>')
|
|
438
457
|
// updated.seed !== state.seed (reseeded)
|
|
439
458
|
// updated.members does not include alice
|
|
440
459
|
```
|
|
441
460
|
|
|
461
|
+
### dissolveGroup(state)
|
|
462
|
+
|
|
463
|
+
Dissolve a group, zeroing the seed and clearing all members. Preserves name and timestamps for audit trail. Callers MUST also delete the persisted record (IndexedDB, localStorage, backend) after calling this. Returns new state — does not mutate.
|
|
464
|
+
|
|
465
|
+
```typescript
|
|
466
|
+
dissolveGroup(state: GroupState): GroupState
|
|
467
|
+
|
|
468
|
+
const dissolved = dissolveGroup(state)
|
|
469
|
+
// dissolved.seed === '0'.repeat(64)
|
|
470
|
+
// dissolved.members === []
|
|
471
|
+
// dissolved.admins === []
|
|
472
|
+
```
|
|
473
|
+
|
|
442
474
|
### syncCounter(state, nowSec?)
|
|
443
475
|
|
|
444
476
|
Refresh the counter to the current time window. Call after loading persisted state. Returns new state — does not mutate.
|
|
@@ -1007,117 +1039,126 @@ WORDLIST[2047] // 'zoo'
|
|
|
1007
1039
|
|
|
1008
1040
|
## canary-kit/nostr
|
|
1009
1041
|
|
|
1010
|
-
Nostr event builders for
|
|
1042
|
+
Nostr event builders for SSG (Simple Shared Secret) groups. Uses standard Nostr kinds — no custom event kinds. All builders return unsigned events (`UnsignedEvent`) — sign with your own Nostr library (e.g. `nostr-tools`).
|
|
1043
|
+
|
|
1044
|
+
Three event kinds are used:
|
|
1045
|
+
- **kind 30078** (parameterised replaceable) — group state and stored signals. The d-tag uses the `ssg/` namespace prefix.
|
|
1046
|
+
- **kind 20078** (ephemeral) — real-time signals between group members.
|
|
1047
|
+
- **kind 14** (rumour / unsigned inner event) — NIP-17 gift-wrapped DMs carrying seed distribution, reseed, and other private payloads.
|
|
1011
1048
|
|
|
1012
1049
|
### KINDS
|
|
1013
1050
|
|
|
1014
1051
|
```typescript
|
|
1015
1052
|
const KINDS = {
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
reseed: 28_801, // ephemeral — new seed broadcast
|
|
1020
|
-
wordUsed: 28_802, // ephemeral — burn-after-use notification
|
|
1021
|
-
beacon: 20_800, // ephemeral — encrypted location beacon
|
|
1053
|
+
groupState: 30_078, // parameterised replaceable — group state and stored signals
|
|
1054
|
+
signal: 20_078, // ephemeral — real-time signals
|
|
1055
|
+
giftWrap: 1_059, // NIP-17 gift wrap (consumer wraps kind 14 rumours)
|
|
1022
1056
|
} as const
|
|
1023
1057
|
```
|
|
1024
1058
|
|
|
1025
|
-
###
|
|
1059
|
+
### buildGroupStateEvent(params)
|
|
1026
1060
|
|
|
1027
|
-
Build
|
|
1061
|
+
Build an unsigned kind 30078 group state event. The d-tag uses plaintext `ssg/<groupId>` since content is NIP-44 encrypted. Includes NIP-32 labels for the SSG namespace and p-tags for each member.
|
|
1028
1062
|
|
|
1029
1063
|
```typescript
|
|
1030
|
-
|
|
1064
|
+
buildGroupStateEvent(params: GroupStateEventParams): UnsignedEvent
|
|
1031
1065
|
|
|
1032
|
-
|
|
1066
|
+
interface GroupStateEventParams {
|
|
1067
|
+
groupId: string
|
|
1068
|
+
members: string[] // hex pubkeys
|
|
1069
|
+
encryptedContent: string // NIP-44 encrypted group config
|
|
1070
|
+
rotationInterval?: number // seconds between word rotations
|
|
1071
|
+
tolerance?: number // counter tolerance window
|
|
1072
|
+
expiration?: number // NIP-40 expiration (unix seconds)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
buildGroupStateEvent({
|
|
1033
1076
|
groupId: 'alpine-team-1',
|
|
1034
|
-
name: 'Alpine Team',
|
|
1035
1077
|
members: ['<alice-pubkey>', '<bob-pubkey>'],
|
|
1078
|
+
encryptedContent: '<NIP-44 encrypted group config>',
|
|
1036
1079
|
rotationInterval: 86_400,
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
encryptedContent: '<NIP-44 encrypted group metadata>',
|
|
1040
|
-
expiration: Math.floor(Date.now() / 1000) + 30 * 86_400, // 30 days
|
|
1080
|
+
tolerance: 1,
|
|
1081
|
+
expiration: Math.floor(Date.now() / 1000) + 30 * 86_400,
|
|
1041
1082
|
})
|
|
1042
|
-
// { kind:
|
|
1083
|
+
// { kind: 30078, content: '...', tags: [['d','ssg/alpine-team-1'],['p','<alice>'],['p','<bob>'],['L','ssg'],['l','group','ssg'],['rotation','86400'],['tolerance','1'],['expiration','...']], created_at: ... }
|
|
1043
1084
|
```
|
|
1044
1085
|
|
|
1045
|
-
###
|
|
1086
|
+
### buildStoredSignalEvent(params)
|
|
1046
1087
|
|
|
1047
|
-
Build
|
|
1088
|
+
Build an unsigned kind 30078 stored signal event. The d-tag uses a SHA-256 hash of the group ID (for privacy) scoped by signal type: `ssg/<SHA256(groupId)>:<signalType>`. Includes a 7-day NIP-40 expiration tag.
|
|
1048
1089
|
|
|
1049
1090
|
```typescript
|
|
1050
|
-
|
|
1091
|
+
buildStoredSignalEvent(params: StoredSignalEventParams): UnsignedEvent
|
|
1051
1092
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
encryptedContent:
|
|
1056
|
-
}
|
|
1057
|
-
// { kind: 28800, content: '...', tags: [['p','<alice>'],['e','<group-id>']], created_at: ... }
|
|
1058
|
-
```
|
|
1059
|
-
|
|
1060
|
-
### buildMemberUpdateEvent(params)
|
|
1061
|
-
|
|
1062
|
-
Build a kind 38801 member update event.
|
|
1063
|
-
|
|
1064
|
-
```typescript
|
|
1065
|
-
buildMemberUpdateEvent(params: MemberUpdateParams): UnsignedEvent
|
|
1093
|
+
interface StoredSignalEventParams {
|
|
1094
|
+
groupId: string
|
|
1095
|
+
signalType: string // e.g. 'counter-advance', 'word-used'
|
|
1096
|
+
encryptedContent: string
|
|
1097
|
+
}
|
|
1066
1098
|
|
|
1067
|
-
|
|
1099
|
+
buildStoredSignalEvent({
|
|
1068
1100
|
groupId: 'alpine-team-1',
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
reseed: true,
|
|
1072
|
-
encryptedContent: '<encrypted update content>',
|
|
1101
|
+
signalType: 'counter-advance',
|
|
1102
|
+
encryptedContent: '<encrypted signal payload>',
|
|
1073
1103
|
})
|
|
1074
|
-
// { kind:
|
|
1104
|
+
// { kind: 30078, content: '...', tags: [['d','ssg/<hash>:counter-advance'],['expiration','...']], created_at: ... }
|
|
1075
1105
|
```
|
|
1076
1106
|
|
|
1077
|
-
###
|
|
1107
|
+
### buildSignalEvent(params)
|
|
1078
1108
|
|
|
1079
|
-
Build
|
|
1109
|
+
Build an unsigned kind 20078 ephemeral signal event. The d-tag uses a SHA-256 hash of the group ID (for privacy). A t-tag carries the signal type. No expiration tag — ephemeral events are not stored by relays.
|
|
1080
1110
|
|
|
1081
1111
|
```typescript
|
|
1082
|
-
|
|
1112
|
+
buildSignalEvent(params: SignalEventParams): UnsignedEvent
|
|
1113
|
+
|
|
1114
|
+
interface SignalEventParams {
|
|
1115
|
+
groupId: string
|
|
1116
|
+
signalType: string // e.g. 'beacon', 'word-used', 'counter-advance'
|
|
1117
|
+
encryptedContent: string
|
|
1118
|
+
}
|
|
1083
1119
|
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
encryptedContent: '<
|
|
1120
|
+
buildSignalEvent({
|
|
1121
|
+
groupId: 'alpine-team-1',
|
|
1122
|
+
signalType: 'beacon',
|
|
1123
|
+
encryptedContent: '<AES-256-GCM encrypted beacon payload>',
|
|
1088
1124
|
})
|
|
1089
|
-
// { kind:
|
|
1125
|
+
// { kind: 20078, content: '...', tags: [['d','ssg/<hash>'],['t','beacon']], created_at: ... }
|
|
1090
1126
|
```
|
|
1091
1127
|
|
|
1092
|
-
|
|
1128
|
+
### buildRumourEvent(params)
|
|
1093
1129
|
|
|
1094
|
-
|
|
1130
|
+
Build an unsigned kind 14 rumour event for NIP-17 gift wrapping. The consumer must set the `pubkey` field before computing the event ID, then seal (NIP-44 encrypt + kind 13) and gift-wrap (kind 1059) the rumour.
|
|
1095
1131
|
|
|
1096
|
-
|
|
1132
|
+
Used for seed distribution, reseed notifications, and member updates — anything that must be sent privately to a specific member.
|
|
1097
1133
|
|
|
1098
1134
|
```typescript
|
|
1099
|
-
|
|
1135
|
+
buildRumourEvent(params: RumourEventParams): UnsignedEvent
|
|
1136
|
+
|
|
1137
|
+
interface RumourEventParams {
|
|
1138
|
+
recipientPubkey: string // 64-char hex pubkey
|
|
1139
|
+
subject: string // e.g. 'ssg:seed-distribution', 'ssg:reseed', 'ssg:member-update'
|
|
1140
|
+
encryptedContent: string // NIP-44 encrypted payload
|
|
1141
|
+
groupEventId?: string // optional reference to the group state event
|
|
1142
|
+
}
|
|
1100
1143
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1144
|
+
buildRumourEvent({
|
|
1145
|
+
recipientPubkey: '<alice-pubkey>',
|
|
1146
|
+
subject: 'ssg:seed-distribution',
|
|
1147
|
+
encryptedContent: '<NIP-44 encrypted seed>',
|
|
1148
|
+
groupEventId: '<group-state-event-id>',
|
|
1104
1149
|
})
|
|
1105
|
-
// { kind:
|
|
1150
|
+
// { kind: 14, content: '...', tags: [['p','<alice>'],['subject','ssg:seed-distribution'],['e','<group-id>']], created_at: ... }
|
|
1106
1151
|
```
|
|
1107
1152
|
|
|
1108
|
-
###
|
|
1153
|
+
### hashGroupId(groupId)
|
|
1109
1154
|
|
|
1110
|
-
|
|
1155
|
+
SHA-256 hash a group ID for use in d-tags where privacy matters.
|
|
1111
1156
|
|
|
1112
1157
|
```typescript
|
|
1113
|
-
|
|
1158
|
+
hashGroupId(groupId: string): string
|
|
1114
1159
|
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
encryptedContent: '<AES-256-GCM encrypted beacon payload>',
|
|
1118
|
-
expiration: Math.floor(Date.now() / 1000) + 3600, // 1 hour
|
|
1119
|
-
})
|
|
1120
|
-
// { kind: 20800, ... tags: [['h','alpine-team-1'],['expiration','...']], ... }
|
|
1160
|
+
hashGroupId('alpine-team-1')
|
|
1161
|
+
// '3a7f...' (64-char hex)
|
|
1121
1162
|
```
|
|
1122
1163
|
|
|
1123
1164
|
---
|
|
@@ -1150,26 +1191,53 @@ type SyncMessage =
|
|
|
1150
1191
|
| { type: 'liveness-checkin'; pubkey: string; timestamp: number; opId: string }
|
|
1151
1192
|
| { type: 'state-snapshot'; seed: string; counter: number; usageOffset: number; members: string[]; admins: string[]; epoch: number; opId: string; timestamp: number; prevEpochSeed?: string }
|
|
1152
1193
|
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1194
|
+
/** Result of applying a sync message, indicating whether it was accepted. */
|
|
1195
|
+
interface SyncApplyResult {
|
|
1196
|
+
state: GroupState // The resulting group state (unchanged if rejected)
|
|
1197
|
+
applied: boolean // Whether the message was applied
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Minimal interface any sync transport must implement. */
|
|
1201
|
+
interface SyncTransport {
|
|
1202
|
+
send(groupId: string, message: SyncMessage, recipients?: string[]): Promise<void>
|
|
1203
|
+
subscribe(groupId: string, onMessage: (msg: SyncMessage, sender: string) => void): () => void
|
|
1204
|
+
disconnect(): void
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/** Abstracts event signing and NIP-44 encryption for transports that need it. */
|
|
1208
|
+
interface EventSigner {
|
|
1209
|
+
pubkey: string
|
|
1210
|
+
sign(event: unknown): Promise<unknown>
|
|
1211
|
+
encrypt(plaintext: string, recipientPubkey: string): Promise<string>
|
|
1212
|
+
decrypt(ciphertext: string, senderPubkey: string): Promise<string>
|
|
1157
1213
|
}
|
|
1158
1214
|
```
|
|
1159
1215
|
|
|
1160
1216
|
### Functions
|
|
1161
1217
|
|
|
1162
1218
|
```typescript
|
|
1163
|
-
decodeSyncMessage(json: string): SyncMessage
|
|
1164
|
-
encodeSyncMessage(msg: SyncMessage): string
|
|
1165
|
-
applySyncMessage(state: GroupState, msg: SyncMessage, sender?: string):
|
|
1219
|
+
decodeSyncMessage(json: string): SyncMessage // Parse and validate a JSON sync message. Throws on invalid input or wrong protocolVersion.
|
|
1220
|
+
encodeSyncMessage(msg: SyncMessage): string // Serialise a sync message to JSON. Injects protocolVersion automatically.
|
|
1221
|
+
applySyncMessage(state: GroupState, msg: SyncMessage, nowSec?: number, sender?: string): GroupState // Apply a sync message. Returns new state, or SAME REFERENCE if rejected (silent rejection).
|
|
1222
|
+
applySyncMessageWithResult(state: GroupState, msg: SyncMessage, nowSec?: number, sender?: string): SyncApplyResult // Same as applySyncMessage but returns { state, applied } for observability.
|
|
1166
1223
|
```
|
|
1167
1224
|
|
|
1225
|
+
**IMPORTANT — silent rejection:** `applySyncMessage` returns the unchanged GroupState reference when a message is rejected. Common causes of silent rejection:
|
|
1226
|
+
- Privileged action without sender, or sender not in `group.admins` (I1)
|
|
1227
|
+
- Replayed opId already in `consumedOps` (I2)
|
|
1228
|
+
- Epoch mismatch: non-reseed ops must match local epoch (I3), reseed must be local epoch + 1 (I4)
|
|
1229
|
+
- Stale epoch for any privileged op (I6)
|
|
1230
|
+
- `counter-advance` without sender, or sender not in `group.members`
|
|
1231
|
+
- `counter-advance` that would regress the effective counter (monotonic)
|
|
1232
|
+
- Fire-and-forget messages older than 5 minutes or >60s in the future
|
|
1233
|
+
|
|
1234
|
+
Use `applySyncMessageWithResult` when you need to log or alert on rejected messages.
|
|
1235
|
+
|
|
1168
1236
|
### Envelope Encryption
|
|
1169
1237
|
|
|
1170
1238
|
```typescript
|
|
1171
1239
|
deriveGroupKey(seed: string): Uint8Array // Derive AES-256-GCM group key from seed
|
|
1172
|
-
|
|
1240
|
+
deriveGroupIdentity(seed: string): Uint8Array // Derive HMAC signing key from seed
|
|
1173
1241
|
hashGroupTag(groupId: string): string // Privacy-preserving group tag hash
|
|
1174
1242
|
encryptEnvelope(key: Uint8Array, plaintext: string): Promise<string> // AES-256-GCM encrypt
|
|
1175
1243
|
decryptEnvelope(key: Uint8Array, ciphertext: string): Promise<string> // AES-256-GCM decrypt
|
|
@@ -1223,16 +1291,15 @@ Used with `createSession({ preset: '...' })`.
|
|
|
1223
1291
|
|
|
1224
1292
|
## Nostr Event Kinds
|
|
1225
1293
|
|
|
1226
|
-
|
|
1294
|
+
Uses standard Nostr kinds — no custom event kinds.
|
|
1295
|
+
|
|
1296
|
+
| Kind | Type | KINDS key | Description |
|
|
1227
1297
|
|---|---|---|---|
|
|
1228
|
-
|
|
|
1229
|
-
|
|
|
1230
|
-
|
|
|
1231
|
-
| 28801 | Ephemeral | `reseed` | New seed broadcast after compromise or member removal |
|
|
1232
|
-
| 28802 | Ephemeral | `wordUsed` | Burn-after-use notification — advance counter for all members |
|
|
1233
|
-
| 20800 | Ephemeral | `beacon` | AES-256-GCM encrypted location beacon |
|
|
1298
|
+
| 30078 | Parameterised replaceable | `groupState` | Group state (d-tag `ssg/<groupId>`) and stored signals (d-tag `ssg/<hash>:<signalType>`) |
|
|
1299
|
+
| 20078 | Ephemeral | `signal` | Real-time signals between group members (beacon, word-used, counter-advance) |
|
|
1300
|
+
| 14 → 1059 | NIP-17 gift wrap | `giftWrap` | Seed distribution, reseed, member updates (kind 14 rumour sealed + wrapped) |
|
|
1234
1301
|
|
|
1235
|
-
|
|
1302
|
+
Kind 30078 events use the `ssg/` d-tag namespace prefix. Kind 20078 events hash the group ID in the d-tag for privacy. Kind 14 rumours are sealed (kind 13) and gift-wrapped (kind 1059) by the consumer — the library builds the unsigned rumour only.
|
|
1236
1303
|
|
|
1237
1304
|
---
|
|
1238
1305
|
|
|
@@ -1343,22 +1410,22 @@ state = advanceCounter(state) // next call will use a different word
|
|
|
1343
1410
|
state = addMember(state, '<diana-pubkey>')
|
|
1344
1411
|
// Distribute new seed to diana via encrypted Nostr DM
|
|
1345
1412
|
|
|
1346
|
-
// Remove a compromised member
|
|
1347
|
-
state =
|
|
1413
|
+
// Remove a compromised member and reseed atomically
|
|
1414
|
+
state = removeMemberAndReseed(state, '<charlie-pubkey>')
|
|
1348
1415
|
// Distribute new state.seed to remaining members
|
|
1349
1416
|
```
|
|
1350
1417
|
|
|
1351
1418
|
### Nostr Group Integration
|
|
1352
1419
|
|
|
1353
|
-
Publish group state and encrypted seeds to Nostr relays so all members can synchronise.
|
|
1420
|
+
Publish group state and encrypted seeds to Nostr relays so all members can synchronise. Uses standard Nostr kinds (30078, 20078, NIP-17 gift wrap) — no custom event kinds.
|
|
1354
1421
|
|
|
1355
1422
|
```typescript
|
|
1356
|
-
import { createGroup,
|
|
1423
|
+
import { createGroup, deriveBeaconKey, encryptBeacon } from 'canary-kit'
|
|
1357
1424
|
import {
|
|
1358
1425
|
KINDS,
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1426
|
+
buildGroupStateEvent,
|
|
1427
|
+
buildSignalEvent,
|
|
1428
|
+
buildRumourEvent,
|
|
1362
1429
|
} from 'canary-kit/nostr'
|
|
1363
1430
|
|
|
1364
1431
|
// 1. Create the group
|
|
@@ -1368,37 +1435,34 @@ let state = createGroup({
|
|
|
1368
1435
|
preset: 'field-ops',
|
|
1369
1436
|
})
|
|
1370
1437
|
|
|
1371
|
-
// 2. Publish a kind
|
|
1372
|
-
const groupEvent =
|
|
1438
|
+
// 2. Publish a kind 30078 group state event (encrypted content via NIP-44)
|
|
1439
|
+
const groupEvent = buildGroupStateEvent({
|
|
1373
1440
|
groupId: 'alpine-team-1',
|
|
1374
|
-
name: state.name,
|
|
1375
1441
|
members: state.members,
|
|
1376
|
-
rotationInterval: state.rotationInterval,
|
|
1377
|
-
wordCount: state.wordCount,
|
|
1378
|
-
wordlist: state.wordlist,
|
|
1379
1442
|
encryptedContent: '<NIP-44 encrypted group config>',
|
|
1380
|
-
|
|
1443
|
+
rotationInterval: state.rotationInterval,
|
|
1381
1444
|
})
|
|
1382
1445
|
// sign and publish groupEvent to relays
|
|
1383
1446
|
|
|
1384
|
-
// 3. Send seed to each member via
|
|
1447
|
+
// 3. Send seed to each member via NIP-17 gift wrap (kind 14 rumour → seal → wrap)
|
|
1385
1448
|
for (const memberPubkey of state.members) {
|
|
1386
|
-
const
|
|
1449
|
+
const rumour = buildRumourEvent({
|
|
1387
1450
|
recipientPubkey: memberPubkey,
|
|
1388
|
-
|
|
1451
|
+
subject: 'ssg:seed-distribution',
|
|
1389
1452
|
encryptedContent: '<NIP-44 encrypted seed for this member>',
|
|
1453
|
+
groupEventId: '<signed-group-event-id>',
|
|
1390
1454
|
})
|
|
1391
|
-
//
|
|
1455
|
+
// set pubkey, compute id, then seal (kind 13) and gift-wrap (kind 1059)
|
|
1392
1456
|
}
|
|
1393
1457
|
|
|
1394
|
-
// 4. Publish periodic location beacons
|
|
1458
|
+
// 4. Publish periodic location beacons as kind 20078 ephemeral signals
|
|
1395
1459
|
const beaconKey = deriveBeaconKey(state.seed)
|
|
1396
1460
|
const encryptedBeacon = await encryptBeacon(beaconKey, 'gcpvjb', 6)
|
|
1397
1461
|
|
|
1398
|
-
const beaconEvent =
|
|
1462
|
+
const beaconEvent = buildSignalEvent({
|
|
1399
1463
|
groupId: 'alpine-team-1',
|
|
1464
|
+
signalType: 'beacon',
|
|
1400
1465
|
encryptedContent: encryptedBeacon,
|
|
1401
|
-
expiration: Math.floor(Date.now() / 1000) + state.beaconInterval * 2,
|
|
1402
1466
|
})
|
|
1403
1467
|
// sign and publish beaconEvent
|
|
1404
1468
|
|
package/llms.txt
CHANGED
|
@@ -25,14 +25,16 @@ Core derivation:
|
|
|
25
25
|
- verifyWord(spokenWord, seedHex, memberPubkeys, counter, wordCount?) → VerifyResult
|
|
26
26
|
|
|
27
27
|
Group lifecycle:
|
|
28
|
-
- createGroup(config) → GroupState
|
|
28
|
+
- createGroup(config) → GroupState (config.creator sets initial admin; without creator, admins is empty)
|
|
29
29
|
- getCurrentWord(state) → string
|
|
30
30
|
- getCurrentDuressWord(state, memberPubkey) → string
|
|
31
31
|
- advanceCounter(state) → GroupState
|
|
32
32
|
- reseed(state) → GroupState
|
|
33
33
|
- addMember(state, pubkey) → GroupState
|
|
34
|
-
- removeMember(state, pubkey) → GroupState
|
|
35
|
-
-
|
|
34
|
+
- removeMember(state, pubkey) → GroupState (does NOT reseed)
|
|
35
|
+
- removeMemberAndReseed(state, pubkey) → GroupState (recommended: removes + reseeds atomically)
|
|
36
|
+
- dissolveGroup(state) → GroupState (zeroes seed, clears members)
|
|
37
|
+
- syncCounter(state, nowSec?) → GroupState (refreshes counter to current time window, monotonic)
|
|
36
38
|
|
|
37
39
|
Counter:
|
|
38
40
|
- getCounter(timestampSec, rotationIntervalSec?) → number
|
|
@@ -100,19 +102,18 @@ Session methods:
|
|
|
100
102
|
|
|
101
103
|
### canary-kit/nostr
|
|
102
104
|
|
|
103
|
-
Unsigned Nostr event builders for
|
|
105
|
+
Unsigned Nostr event builders for SSG (Simple Shared Secret) groups. Uses standard Nostr kinds — no custom event kinds. Sign events with your preferred Nostr library.
|
|
104
106
|
|
|
105
|
-
- KINDS — {
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
110
|
-
-
|
|
111
|
-
- buildBeaconEvent(params) → UnsignedEvent
|
|
107
|
+
- KINDS — { groupState: 30078, signal: 20078, giftWrap: 1059 }
|
|
108
|
+
- buildGroupStateEvent(params) → UnsignedEvent — kind 30078, d-tag `ssg/<groupId>`, p-tags for members, NIP-32 labels
|
|
109
|
+
- buildStoredSignalEvent(params) → UnsignedEvent — kind 30078, d-tag `ssg/<SHA256(groupId)>:<signalType>`, 7-day expiration
|
|
110
|
+
- buildSignalEvent(params) → UnsignedEvent — kind 20078 ephemeral, d-tag `ssg/<SHA256(groupId)>`, t-tag for signal type
|
|
111
|
+
- buildRumourEvent(params) → UnsignedEvent — kind 14 rumour for NIP-17 gift wrapping (seed distribution, reseed, member updates)
|
|
112
|
+
- hashGroupId(groupId) → string — SHA-256 hash a group ID for privacy-preserving d-tags
|
|
112
113
|
|
|
113
114
|
### canary-kit/beacon
|
|
114
115
|
|
|
115
|
-
AES-256-GCM encrypted location beacons and duress alerts
|
|
116
|
+
AES-256-GCM encrypted location beacons and duress alerts. Beacons are published as kind 20078 ephemeral signal events.
|
|
116
117
|
|
|
117
118
|
- deriveBeaconKey(seedHex) → Uint8Array
|
|
118
119
|
- encryptBeacon(key, geohash, precision) → Promise\<string\>
|
|
@@ -125,18 +126,23 @@ Types: BeaconPayload ({ geohash, precision, timestamp }), DuressAlert ({ type, m
|
|
|
125
126
|
|
|
126
127
|
### canary-kit/sync
|
|
127
128
|
|
|
128
|
-
Transport-agnostic group state synchronisation. Authority model with 6 invariants, replay protection, epoch boundaries.
|
|
129
|
+
Transport-agnostic group state synchronisation. Authority model with 6 invariants, replay protection, epoch boundaries. See COOKBOOK.md for complete workflow examples.
|
|
129
130
|
|
|
130
|
-
- decodeSyncMessage(json) → SyncMessage (validates and parses)
|
|
131
|
-
- encodeSyncMessage(msg) → string (serialises to JSON)
|
|
132
|
-
- applySyncMessage(state, msg, sender?) →
|
|
131
|
+
- decodeSyncMessage(json) → SyncMessage (validates and parses; throws on invalid input)
|
|
132
|
+
- encodeSyncMessage(msg) → string (serialises to JSON; injects protocolVersion)
|
|
133
|
+
- applySyncMessage(state, msg, nowSec?, sender?) → GroupState (apply a sync message; returns same reference if rejected)
|
|
134
|
+
- applySyncMessageWithResult(state, msg, nowSec?, sender?) → SyncApplyResult ({ state, applied }) (same as above but indicates acceptance)
|
|
133
135
|
- deriveGroupKey(seed) → Uint8Array (AES-256-GCM group key)
|
|
134
|
-
-
|
|
136
|
+
- deriveGroupIdentity(seed) → Uint8Array (group identity key)
|
|
135
137
|
- hashGroupTag(groupId) → string (privacy-preserving group tag)
|
|
136
138
|
- encryptEnvelope(key, plaintext) → Promise<string> (AES-256-GCM)
|
|
137
139
|
- decryptEnvelope(key, ciphertext) → Promise<string> (AES-256-GCM)
|
|
138
140
|
|
|
139
|
-
|
|
141
|
+
IMPORTANT: applySyncMessage silently returns unchanged state when rejected. Privileged actions (member-join of others, reseed, state-snapshot) require sender in group.admins. counter-advance requires sender in group.members. Omitting sender causes silent rejection. Set creator in createGroup() to populate admins.
|
|
142
|
+
|
|
143
|
+
Message types: member-join, member-leave, counter-advance, reseed, beacon, duress-alert, duress-clear, liveness-checkin, state-snapshot
|
|
144
|
+
|
|
145
|
+
Types: SyncApplyResult ({ state: GroupState, applied: boolean }), SyncTransport (interface for pluggable transports), EventSigner (interface for Nostr signing)
|
|
140
146
|
|
|
141
147
|
## Quick Examples
|
|
142
148
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canary-kit",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"description": "Deepfake-proof identity verification. Open protocol, minimal dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -46,7 +46,9 @@
|
|
|
46
46
|
"files": [
|
|
47
47
|
"dist/",
|
|
48
48
|
"LICENSE",
|
|
49
|
+
"API.md",
|
|
49
50
|
"CANARY.md",
|
|
51
|
+
"COOKBOOK.md",
|
|
50
52
|
"NIP-CANARY.md",
|
|
51
53
|
"INTEGRATION.md",
|
|
52
54
|
"SECURITY.md",
|
|
@@ -143,5 +145,9 @@
|
|
|
143
145
|
"@scure/bip39": "^2.0.1",
|
|
144
146
|
"nsec-tree": "^1.4.0",
|
|
145
147
|
"spoken-token": "^2.0.2"
|
|
148
|
+
},
|
|
149
|
+
"funding": {
|
|
150
|
+
"type": "lightning",
|
|
151
|
+
"url": "lightning:thedonkey@strike.me"
|
|
146
152
|
}
|
|
147
153
|
}
|