@cero-base/core 0.2.0 → 0.4.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/LICENSE +201 -0
- package/README.md +180 -136
- package/package.json +123 -28
- package/src/blobs/index.js +297 -0
- package/src/database/CLAUDE.md +3 -0
- package/src/database/bootstrap.js +76 -0
- package/src/database/dispatch.js +156 -0
- package/src/database/index.js +572 -0
- package/src/identity/CLAUDE.md +3 -0
- package/src/identity/index.js +232 -0
- package/src/index.js +20 -1
- package/src/lib/CLAUDE.md +3 -0
- package/src/lib/constants.js +24 -4
- package/src/lib/errors.js +150 -0
- package/src/lib/schema.js +58 -440
- package/src/lib/spec/index.js +353 -0
- package/src/lib/spec/schema.json +284 -0
- package/src/lib/utils.js +54 -49
- package/src/network/discovery.js +80 -0
- package/src/network/index.js +231 -0
- package/src/pairing/index.js +482 -0
- package/src/pairing/invite.js +199 -0
- package/src/rpc/client.js +45 -0
- package/src/rpc/index.js +141 -0
- package/src/rpc/server.js +45 -0
- package/src/storage/index.js +261 -0
- package/types/blobs/index.d.ts +169 -0
- package/types/database/bootstrap.d.ts +17 -0
- package/types/database/dispatch.d.ts +8 -0
- package/types/database/index.d.ts +329 -0
- package/types/identity/index.d.ts +160 -0
- package/types/index.d.ts +11 -0
- package/types/lib/constants.d.ts +13 -0
- package/types/lib/errors.d.ts +110 -0
- package/types/lib/schema.d.ts +53 -0
- package/types/lib/spec/index.d.ts +95 -0
- package/types/lib/utils.d.ts +39 -0
- package/types/network/discovery.d.ts +44 -0
- package/types/network/index.d.ts +115 -0
- package/types/pairing/index.d.ts +194 -0
- package/types/pairing/invite.d.ts +157 -0
- package/types/rpc/client.d.ts +18 -0
- package/types/rpc/index.d.ts +67 -0
- package/types/rpc/server.d.ts +18 -0
- package/types/storage/index.d.ts +163 -0
- package/src/lib/base.js +0 -84
- package/src/lib/batch.js +0 -98
- package/src/lib/builder.js +0 -24
- package/src/lib/collection.js +0 -252
- package/src/lib/crypto.js +0 -6
- package/src/lib/index.js +0 -6
- package/src/lib/room.js +0 -145
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
import BlindPairing from 'blind-pairing'
|
|
2
|
+
import ReadyResource from 'ready-resource'
|
|
3
|
+
import safetyCatch from 'safety-catch'
|
|
4
|
+
import b4a from 'b4a'
|
|
5
|
+
import c from 'compact-encoding'
|
|
6
|
+
import { discoveryKey, keyPair, sign } from 'hypercore-crypto'
|
|
7
|
+
|
|
8
|
+
import { Invite } from './invite.js'
|
|
9
|
+
import { getEncoding } from '../lib/spec/index.js'
|
|
10
|
+
import { CeroError } from '../lib/errors.js'
|
|
11
|
+
|
|
12
|
+
const STATUS_OK = 0
|
|
13
|
+
const STATUS_DENIED = 1
|
|
14
|
+
|
|
15
|
+
// Wire envelope carried in BlindPairing's `additional.data` on the confirm response.
|
|
16
|
+
// We piggyback on the confirm path for *both* success and deny — blind-pairing's
|
|
17
|
+
// native deny() has no payload field, and its discoveryKey check pins the wire
|
|
18
|
+
// `key` to the invite, so the real resource key flows through this envelope.
|
|
19
|
+
const Response = getEncoding('@cero/confirm')
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} PairingOpts
|
|
23
|
+
* @property {import('../network/index.js').Network} network Network to host the BlindPairing member.
|
|
24
|
+
* @property {import('../identity/index.js').Identity} identity Identity used to sign invites and derive the topic.
|
|
25
|
+
* @property {Uint8Array} [topic] Optional override topic; defaults to `identity.publicKey`.
|
|
26
|
+
* @property {any} [inviteEncoding] compact-encoding type for the invite payload `data`.
|
|
27
|
+
* @property {any} [joinerEncoding] compact-encoding type for the joiner-supplied `userData`.
|
|
28
|
+
*
|
|
29
|
+
* @typedef {object} CreateInviteOpts
|
|
30
|
+
* @property {string} [role] Role tag bound into the invite.
|
|
31
|
+
* @property {number} [expiresIn] TTL in ms; `0`/omitted = no expiry.
|
|
32
|
+
* @property {any} [data] Arbitrary payload (encoded via `inviteEncoding` when set).
|
|
33
|
+
*
|
|
34
|
+
* @typedef {object} JoinOpts
|
|
35
|
+
* @property {any} [userData] Payload sent with the candidate request.
|
|
36
|
+
* @property {number} [timeout] Deadline for the handshake, in ms. Defaults to 30000.
|
|
37
|
+
*
|
|
38
|
+
* @typedef {object} ConfirmOpts
|
|
39
|
+
* @property {Uint8Array} [key] 32-byte resource key delivered to the joiner; required at runtime.
|
|
40
|
+
* @property {Uint8Array | null} [encryptionKey] Optional 32-byte symmetric key.
|
|
41
|
+
* @property {Uint8Array | null} [additional] Extra opaque bytes piggybacked on the response.
|
|
42
|
+
*
|
|
43
|
+
* @typedef {{ key: Uint8Array, encryptionKey: Uint8Array | null, additional: Uint8Array | null }} JoinResult
|
|
44
|
+
*
|
|
45
|
+
* @typedef {object} CandidateOpts
|
|
46
|
+
* @property {Pairing} pairing Owning Pairing instance.
|
|
47
|
+
* @property {any} req blind-pairing candidate request being answered.
|
|
48
|
+
* @property {Invite} invite Invite the candidate paired against.
|
|
49
|
+
* @property {Uint8Array} seed Per-invite seed used to sign the response.
|
|
50
|
+
* @property {any} userData Decoded joiner payload (raw bytes when no encoding).
|
|
51
|
+
* @property {() => void} onsettle Called once when the candidate is confirmed or denied.
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Blind-pairing membership wrapper. Hosts invites, dispatches incoming
|
|
56
|
+
* candidates to the application for accept/deny, and provides the joiner
|
|
57
|
+
* side of the handshake. All wire payloads are signed and routed through
|
|
58
|
+
* the supplied network.
|
|
59
|
+
*/
|
|
60
|
+
export class Pairing extends ReadyResource {
|
|
61
|
+
/** @param {PairingOpts} [opts] */
|
|
62
|
+
constructor({ network, identity, topic, inviteEncoding = null, joinerEncoding = null } = {}) {
|
|
63
|
+
super()
|
|
64
|
+
if (!network) throw CeroError.REQUIRED('network')
|
|
65
|
+
if (!identity) throw CeroError.REQUIRED('identity')
|
|
66
|
+
|
|
67
|
+
this.network = network
|
|
68
|
+
this.identity = identity
|
|
69
|
+
this.topic = topic || identity.publicKey
|
|
70
|
+
this.inviteEncoding = inviteEncoding
|
|
71
|
+
this.joinerEncoding = joinerEncoding
|
|
72
|
+
|
|
73
|
+
this._blind = null
|
|
74
|
+
this._member = null
|
|
75
|
+
this._invites = new Map() // inviteId.hex → record
|
|
76
|
+
this._candidates = new Set() // active joiner candidates
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async _open() {
|
|
80
|
+
await this.network.ready()
|
|
81
|
+
this._blind = new BlindPairing(this.network.swarm)
|
|
82
|
+
await this._blind.ready()
|
|
83
|
+
|
|
84
|
+
this._member = this._blind.addMember({
|
|
85
|
+
discoveryKey: discoveryKey(this.topic),
|
|
86
|
+
onadd: (req) => this._onCandidate(req).catch(safetyCatch)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async _close() {
|
|
91
|
+
for (const candidate of [...this._candidates]) candidate._fail(CeroError.CLOSED('Pairing'))
|
|
92
|
+
this._candidates.clear()
|
|
93
|
+
|
|
94
|
+
if (this._member) {
|
|
95
|
+
await this._member.close()
|
|
96
|
+
this._member = null
|
|
97
|
+
}
|
|
98
|
+
if (this._blind) {
|
|
99
|
+
await this._blind.close()
|
|
100
|
+
this._blind = null
|
|
101
|
+
}
|
|
102
|
+
this._invites.clear()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Mint a new pairing invite. Returns its canonical wire form (z32 string).
|
|
107
|
+
*
|
|
108
|
+
* @param {CreateInviteOpts} [opts]
|
|
109
|
+
* @returns {Promise<string>}
|
|
110
|
+
*/
|
|
111
|
+
async createInvite({ role = '', expiresIn = 0, data = null } = {}) {
|
|
112
|
+
if (this.closing || this.closed) throw CeroError.CLOSED('Pairing')
|
|
113
|
+
if (!this.opened) await this.ready()
|
|
114
|
+
|
|
115
|
+
// Rendezvous key matches the Member topic so candidates land on the right
|
|
116
|
+
// Pairing instance (per-handle keys avoid identity-wide collisions).
|
|
117
|
+
const blind = BlindPairing.createInvite(this.topic)
|
|
118
|
+
|
|
119
|
+
const invite = Invite.create({
|
|
120
|
+
secretKey: this.identity.secretKey,
|
|
121
|
+
publicKey: this.identity.publicKey,
|
|
122
|
+
role,
|
|
123
|
+
expiresIn,
|
|
124
|
+
data,
|
|
125
|
+
blindInvite: blind.invite,
|
|
126
|
+
encoding: this.inviteEncoding
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const id = b4a.toString(blind.id, 'hex')
|
|
130
|
+
this._invites.set(id, {
|
|
131
|
+
id: blind.id,
|
|
132
|
+
seed: blind.seed,
|
|
133
|
+
publicKey: blind.publicKey,
|
|
134
|
+
invite
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
return invite.toString()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Forget a previously-minted invite. New candidates carrying it will be
|
|
142
|
+
* dropped silently.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} inviteStr
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
revoke(inviteStr) {
|
|
148
|
+
for (const [id, record] of this._invites) {
|
|
149
|
+
if (record.invite.toString() === inviteStr) {
|
|
150
|
+
this._invites.delete(id)
|
|
151
|
+
return true
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Cheap structural test for a candidate invite string.
|
|
159
|
+
*
|
|
160
|
+
* @param {unknown} str
|
|
161
|
+
* @returns {boolean}
|
|
162
|
+
*/
|
|
163
|
+
static isInvite(str) {
|
|
164
|
+
return Invite.isInvite(str)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Joiner side — start a pairing handshake against `inviteStr` and resolve
|
|
169
|
+
* with the host's response when the host confirms.
|
|
170
|
+
*
|
|
171
|
+
* @param {string} inviteStr
|
|
172
|
+
* @param {JoinOpts} [opts]
|
|
173
|
+
* @returns {Promise<JoinResult>}
|
|
174
|
+
*/
|
|
175
|
+
async join(inviteStr, { userData = null, timeout = 30000 } = {}) {
|
|
176
|
+
if (this.closing || this.closed) throw CeroError.CLOSED('Pairing')
|
|
177
|
+
if (!this.opened) await this.ready()
|
|
178
|
+
|
|
179
|
+
let invite
|
|
180
|
+
try {
|
|
181
|
+
invite = Invite.parse(inviteStr, { encoding: this.inviteEncoding })
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err instanceof CeroError) throw err
|
|
184
|
+
throw CeroError.INVALID_INVITE(err.message)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (invite.expired) throw CeroError.EXPIRED()
|
|
188
|
+
|
|
189
|
+
const encodedUserData = encodeUserData(userData, this.joinerEncoding)
|
|
190
|
+
const candidate = new JoinerCandidate(this, invite, encodedUserData, timeout)
|
|
191
|
+
return candidate.start()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether the underlying blind-pairing layer is suspended.
|
|
196
|
+
*
|
|
197
|
+
* @returns {boolean}
|
|
198
|
+
*/
|
|
199
|
+
get suspended() {
|
|
200
|
+
return this._blind?.suspended === true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pause blind-pairing — keeps state, drops sockets. Idempotent.
|
|
205
|
+
*
|
|
206
|
+
* @returns {Promise<void>}
|
|
207
|
+
*/
|
|
208
|
+
async suspend() {
|
|
209
|
+
if (!this._blind || this.closing || this.closed) return
|
|
210
|
+
if (this._blind.suspended) return
|
|
211
|
+
try {
|
|
212
|
+
await this._blind.suspend()
|
|
213
|
+
} catch (err) {
|
|
214
|
+
safetyCatch(err)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Resume a suspended blind-pairing layer. Idempotent.
|
|
220
|
+
*
|
|
221
|
+
* @returns {Promise<void>}
|
|
222
|
+
*/
|
|
223
|
+
async resume() {
|
|
224
|
+
if (!this._blind || this.closing || this.closed) return
|
|
225
|
+
if (!this._blind.suspended) return
|
|
226
|
+
try {
|
|
227
|
+
await this._blind.resume()
|
|
228
|
+
} catch (err) {
|
|
229
|
+
safetyCatch(err)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async _onCandidate(req) {
|
|
234
|
+
const id = b4a.toString(req.inviteId, 'hex')
|
|
235
|
+
const record = this._invites.get(id)
|
|
236
|
+
if (!record) return
|
|
237
|
+
|
|
238
|
+
try {
|
|
239
|
+
req.open(record.publicKey)
|
|
240
|
+
} catch (err) {
|
|
241
|
+
safetyCatch(err)
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const userData = decodeUserData(req.userData, this.joinerEncoding)
|
|
246
|
+
|
|
247
|
+
const candidate = new Candidate({
|
|
248
|
+
pairing: this,
|
|
249
|
+
req,
|
|
250
|
+
invite: record.invite,
|
|
251
|
+
seed: record.seed,
|
|
252
|
+
userData,
|
|
253
|
+
onsettle: () => {
|
|
254
|
+
this._invites.delete(id)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
this.emit('candidate', candidate)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Internal — single incoming candidate awaiting host accept/deny. Created by
|
|
264
|
+
* `Pairing` and emitted on the `'candidate'` event so the host can decide.
|
|
265
|
+
*
|
|
266
|
+
* @property {boolean} _settled Whether confirm/deny has already run.
|
|
267
|
+
*/
|
|
268
|
+
class Candidate {
|
|
269
|
+
/** @param {CandidateOpts} opts */
|
|
270
|
+
constructor({ pairing, req, invite, seed, userData, onsettle }) {
|
|
271
|
+
this.pairing = pairing
|
|
272
|
+
this._req = req
|
|
273
|
+
this._seed = seed
|
|
274
|
+
this._settled = false
|
|
275
|
+
this._onsettle = onsettle
|
|
276
|
+
|
|
277
|
+
this.invite = invite
|
|
278
|
+
this.userData = userData
|
|
279
|
+
this.publicKey = req.publicKey
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Accept the candidate and reveal the resource key. Idempotent.
|
|
284
|
+
*
|
|
285
|
+
* @param {ConfirmOpts} opts
|
|
286
|
+
* @returns {Promise<void>}
|
|
287
|
+
*/
|
|
288
|
+
async confirm({ key, encryptionKey = null, additional = null } = {}) {
|
|
289
|
+
if (!key || key.length !== 32) throw CeroError.INVALID('key must be a 32-byte buffer')
|
|
290
|
+
if (encryptionKey !== null && encryptionKey !== undefined && encryptionKey.length !== 32) {
|
|
291
|
+
throw CeroError.INVALID('encryptionKey must be a 32-byte buffer')
|
|
292
|
+
}
|
|
293
|
+
if (
|
|
294
|
+
additional !== null &&
|
|
295
|
+
additional !== undefined &&
|
|
296
|
+
!b4a.isBuffer(additional) &&
|
|
297
|
+
!(additional instanceof Uint8Array)
|
|
298
|
+
) {
|
|
299
|
+
throw CeroError.INVALID('additional must be a buffer')
|
|
300
|
+
}
|
|
301
|
+
if (this._settled) return
|
|
302
|
+
this._settled = true
|
|
303
|
+
this._onsettle()
|
|
304
|
+
|
|
305
|
+
const data = c.encode(Response, {
|
|
306
|
+
status: STATUS_OK,
|
|
307
|
+
reason: '',
|
|
308
|
+
key,
|
|
309
|
+
encryptionKey: encryptionKey || null,
|
|
310
|
+
extra: additional || null
|
|
311
|
+
})
|
|
312
|
+
const signature = sign(data, keyPair(this._seed).secretKey)
|
|
313
|
+
|
|
314
|
+
this._req.confirm({
|
|
315
|
+
key: this.pairing.topic,
|
|
316
|
+
encryptionKey: undefined,
|
|
317
|
+
additional: { data, signature }
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Reject the candidate with an optional reason. Idempotent.
|
|
323
|
+
*
|
|
324
|
+
* @param {string} [reason]
|
|
325
|
+
* @returns {Promise<void>}
|
|
326
|
+
*/
|
|
327
|
+
async deny(reason = '') {
|
|
328
|
+
if (this._settled) return
|
|
329
|
+
this._settled = true
|
|
330
|
+
this._onsettle()
|
|
331
|
+
|
|
332
|
+
const data = c.encode(Response, {
|
|
333
|
+
status: STATUS_DENIED,
|
|
334
|
+
reason: String(reason || ''),
|
|
335
|
+
key: null,
|
|
336
|
+
encryptionKey: null,
|
|
337
|
+
extra: null
|
|
338
|
+
})
|
|
339
|
+
const signature = sign(data, keyPair(this._seed).secretKey)
|
|
340
|
+
|
|
341
|
+
this._req.confirm({
|
|
342
|
+
key: this.pairing.topic,
|
|
343
|
+
encryptionKey: undefined,
|
|
344
|
+
additional: { data, signature }
|
|
345
|
+
})
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Internal — joiner-side handshake state machine. One-shot: created by
|
|
351
|
+
* `Pairing.join()`, settled exactly once via `_done` or `_fail`.
|
|
352
|
+
*
|
|
353
|
+
* @property {any} _candidate blind-pairing addCandidate handle (null until started).
|
|
354
|
+
* @property {ReturnType<typeof setTimeout> | null} _timer Pending timeout, if any.
|
|
355
|
+
* @property {((result: JoinResult) => void) | null} _resolve Promise resolver, set in `start`.
|
|
356
|
+
* @property {((err: Error) => void) | null} _reject Promise rejecter, set in `start`.
|
|
357
|
+
* @property {boolean} _settled Whether the handshake has already settled.
|
|
358
|
+
*/
|
|
359
|
+
class JoinerCandidate {
|
|
360
|
+
/**
|
|
361
|
+
* @param {Pairing} pairing
|
|
362
|
+
* @param {Invite} invite
|
|
363
|
+
* @param {Uint8Array | null} encodedUserData
|
|
364
|
+
* @param {number} timeout
|
|
365
|
+
*/
|
|
366
|
+
constructor(pairing, invite, encodedUserData, timeout) {
|
|
367
|
+
this.pairing = pairing
|
|
368
|
+
this.invite = invite
|
|
369
|
+
this.userData = encodedUserData
|
|
370
|
+
this.timeout = timeout
|
|
371
|
+
this._candidate = null
|
|
372
|
+
this._timer = null
|
|
373
|
+
this._resolve = null
|
|
374
|
+
this._reject = null
|
|
375
|
+
this._settled = false
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Kick off the handshake; returns a promise that settles with the host response.
|
|
380
|
+
*
|
|
381
|
+
* @returns {Promise<JoinResult>}
|
|
382
|
+
*/
|
|
383
|
+
start() {
|
|
384
|
+
this.pairing._candidates.add(this)
|
|
385
|
+
|
|
386
|
+
return new Promise((resolve, reject) => {
|
|
387
|
+
this._resolve = resolve
|
|
388
|
+
this._reject = reject
|
|
389
|
+
|
|
390
|
+
if (this.timeout > 0) {
|
|
391
|
+
this._timer = setTimeout(() => this._fail(CeroError.TIMEOUT()), this.timeout)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
try {
|
|
395
|
+
this._candidate = this.pairing._blind.addCandidate({
|
|
396
|
+
invite: this.invite.blindInvite,
|
|
397
|
+
userData: this.userData,
|
|
398
|
+
onadd: (result) => this._done(result)
|
|
399
|
+
})
|
|
400
|
+
this._candidate.request.on('rejected', (err) => this._fail(fromBlindError(err)))
|
|
401
|
+
} catch (err) {
|
|
402
|
+
this._fail(err instanceof CeroError ? err : CeroError.NETWORK_ERROR(err.message))
|
|
403
|
+
}
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
_done(result) {
|
|
408
|
+
if (this._settled) return
|
|
409
|
+
|
|
410
|
+
let envelope = null
|
|
411
|
+
const buf = result?.data ?? null
|
|
412
|
+
if (buf) {
|
|
413
|
+
try {
|
|
414
|
+
envelope = c.decode(Response, buf)
|
|
415
|
+
} catch {
|
|
416
|
+
envelope = null
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (envelope && envelope.status === STATUS_DENIED) {
|
|
421
|
+
this._fail(CeroError.DENIED(envelope.reason || null))
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (!envelope) {
|
|
426
|
+
this._fail(CeroError.NETWORK_ERROR('malformed pairing response'))
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this._settled = true
|
|
431
|
+
this.pairing._candidates.delete(this)
|
|
432
|
+
clearTimeout(this._timer)
|
|
433
|
+
|
|
434
|
+
this._resolve({
|
|
435
|
+
key: envelope.key,
|
|
436
|
+
encryptionKey: envelope.encryptionKey,
|
|
437
|
+
additional: envelope.extra
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
this._candidate.close().catch(safetyCatch)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
_fail(err) {
|
|
444
|
+
if (this._settled) return
|
|
445
|
+
this._settled = true
|
|
446
|
+
this.pairing._candidates.delete(this)
|
|
447
|
+
clearTimeout(this._timer)
|
|
448
|
+
if (this._candidate) this._candidate.close().catch(safetyCatch)
|
|
449
|
+
this._reject(err)
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function encodeUserData(userData, encoding) {
|
|
454
|
+
if (userData === null || userData === undefined) return null
|
|
455
|
+
if (encoding) return c.encode(encoding, userData)
|
|
456
|
+
if (b4a.isBuffer(userData) || userData instanceof Uint8Array) return userData
|
|
457
|
+
throw CeroError.INVALID('userData must be a buffer when no joinerEncoding is provided')
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function decodeUserData(buf, encoding) {
|
|
461
|
+
if (!buf || buf.length === 0) return null
|
|
462
|
+
if (!encoding) return buf
|
|
463
|
+
try {
|
|
464
|
+
return c.decode(encoding, buf)
|
|
465
|
+
} catch {
|
|
466
|
+
return buf
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function fromBlindError(err) {
|
|
471
|
+
if (!err || !err.code) return CeroError.DENIED(err?.message || null)
|
|
472
|
+
switch (err.code) {
|
|
473
|
+
case 'INVITE_EXPIRED':
|
|
474
|
+
return CeroError.EXPIRED(err.message)
|
|
475
|
+
case 'INVITE_USED':
|
|
476
|
+
return CeroError.DENIED('used', err.message)
|
|
477
|
+
case 'PAIRING_REJECTED':
|
|
478
|
+
return CeroError.DENIED(null, err.message)
|
|
479
|
+
default:
|
|
480
|
+
return CeroError.DENIED(err.message)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import BlindPairing from 'blind-pairing'
|
|
2
|
+
import sodium from 'sodium-universal'
|
|
3
|
+
import b4a from 'b4a'
|
|
4
|
+
import z32 from 'z32'
|
|
5
|
+
import c from 'compact-encoding'
|
|
6
|
+
|
|
7
|
+
import { getEncoding } from '../lib/spec/index.js'
|
|
8
|
+
import { CeroError } from '../lib/errors.js'
|
|
9
|
+
|
|
10
|
+
const VERSION = 1
|
|
11
|
+
const Envelope = getEncoding('@cero/invite')
|
|
12
|
+
const SignBody = getEncoding('@cero/invite-body')
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} InviteFields
|
|
16
|
+
* @property {Uint8Array} publicKey Signer's long-lived public key.
|
|
17
|
+
* @property {string} role Optional role tag baked into the signed body.
|
|
18
|
+
* @property {number} expires Absolute expiry timestamp; `0` means never.
|
|
19
|
+
* @property {Uint8Array | null} data Encoded payload (raw or via `encoding`).
|
|
20
|
+
* @property {Uint8Array} blindInvite Raw blind-pairing invite handed to candidates.
|
|
21
|
+
* @property {Uint8Array} [sig] Ed25519 signature over the canonical body.
|
|
22
|
+
* @property {string | null} [_str] Cached z32 string form.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {object} CreateInviteOpts
|
|
25
|
+
* @property {Uint8Array} secretKey 64-byte Ed25519 secret key used to sign.
|
|
26
|
+
* @property {Uint8Array} publicKey 32-byte Ed25519 public key matching `secretKey`.
|
|
27
|
+
* @property {string} role Optional role tag for the joiner.
|
|
28
|
+
* @property {number} expiresIn TTL in ms from now; `0` means never expires.
|
|
29
|
+
* @property {any} data Caller payload; encoded via `encoding` when provided.
|
|
30
|
+
* @property {Uint8Array} blindInvite Raw blind-pairing invite to wrap.
|
|
31
|
+
* @property {any} [encoding] compact-encoding type used to encode `data`.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} ParseInviteOpts
|
|
34
|
+
* @property {any} [encoding] compact-encoding type used to decode the embedded payload.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Signed, expirable pairing invite. Wraps a blind-pairing invite with a
|
|
39
|
+
* role, optional payload and an Ed25519 signature so the host can be
|
|
40
|
+
* authenticated by the joiner before any handshake happens.
|
|
41
|
+
*
|
|
42
|
+
* @property {any} [_rawData] Decoded payload kept alongside the encoded `data` for convenience.
|
|
43
|
+
* @property {any} _blind Lazily-decoded blind-pairing invite view (cache).
|
|
44
|
+
*/
|
|
45
|
+
export class Invite {
|
|
46
|
+
/** @param {InviteFields} fields */
|
|
47
|
+
constructor(fields) {
|
|
48
|
+
this.version = VERSION
|
|
49
|
+
this.publicKey = fields.publicKey
|
|
50
|
+
this.role = fields.role
|
|
51
|
+
this.expires = fields.expires
|
|
52
|
+
this.data = fields.data
|
|
53
|
+
this.blindInvite = fields.blindInvite
|
|
54
|
+
this.sig = fields.sig
|
|
55
|
+
this._str = fields._str || null
|
|
56
|
+
|
|
57
|
+
// decoded blind-pairing invite info (cached)
|
|
58
|
+
this._blind = null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the invite is past its TTL (always `false` when `expires === 0`).
|
|
63
|
+
*
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
get expired() {
|
|
67
|
+
return this.expires > 0 && Date.now() > this.expires
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Lazily-decoded view of the underlying blind-pairing invite.
|
|
72
|
+
*
|
|
73
|
+
* @returns {any}
|
|
74
|
+
*/
|
|
75
|
+
get blind() {
|
|
76
|
+
if (!this._blind) this._blind = BlindPairing.decodeInvite(this.blindInvite)
|
|
77
|
+
return this._blind
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Verify the embedded signature against the encoded body.
|
|
82
|
+
*
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
verify() {
|
|
86
|
+
const body = c.encode(SignBody, this)
|
|
87
|
+
return sodium.crypto_sign_verify_detached(this.sig, body, this.publicKey)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Serialise to the canonical z32 wire form (cached).
|
|
92
|
+
*
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
toString() {
|
|
96
|
+
if (this._str) return this._str
|
|
97
|
+
const buf = c.encode(Envelope, this)
|
|
98
|
+
this._str = z32.encode(buf)
|
|
99
|
+
return this._str
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Build and sign a new invite envelope wrapping a blind-pairing invite.
|
|
104
|
+
*
|
|
105
|
+
* @param {CreateInviteOpts} opts
|
|
106
|
+
* @returns {Invite}
|
|
107
|
+
*/
|
|
108
|
+
static create({ secretKey, publicKey, role, expiresIn, data, blindInvite, encoding }) {
|
|
109
|
+
if (!secretKey || secretKey.length !== 64)
|
|
110
|
+
throw CeroError.INVALID('secretKey must be a 64-byte buffer')
|
|
111
|
+
if (!publicKey || publicKey.length !== 32)
|
|
112
|
+
throw CeroError.INVALID('publicKey must be a 32-byte buffer')
|
|
113
|
+
if (!b4a.isBuffer(blindInvite) && !(blindInvite instanceof Uint8Array)) {
|
|
114
|
+
throw CeroError.INVALID('blindInvite must be a buffer')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const expires = expiresIn ? Date.now() + expiresIn : 0
|
|
118
|
+
const encoded =
|
|
119
|
+
data === undefined || data === null ? null : encoding ? c.encode(encoding, data) : data
|
|
120
|
+
|
|
121
|
+
const fields = {
|
|
122
|
+
version: VERSION,
|
|
123
|
+
publicKey,
|
|
124
|
+
role: role || '',
|
|
125
|
+
expires,
|
|
126
|
+
data: encoded,
|
|
127
|
+
blindInvite
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const body = c.encode(SignBody, fields)
|
|
131
|
+
const sig = b4a.alloc(64)
|
|
132
|
+
sodium.crypto_sign_detached(sig, body, secretKey)
|
|
133
|
+
fields.sig = sig
|
|
134
|
+
|
|
135
|
+
const inv = new Invite(fields)
|
|
136
|
+
inv._rawData = data // remember decoded form for convenience
|
|
137
|
+
return inv
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Parse a z32 invite string, verify its signature and (optionally) decode
|
|
142
|
+
* its payload.
|
|
143
|
+
*
|
|
144
|
+
* @param {string} str
|
|
145
|
+
* @param {ParseInviteOpts} [opts]
|
|
146
|
+
* @returns {Invite}
|
|
147
|
+
*/
|
|
148
|
+
static parse(str, { encoding } = {}) {
|
|
149
|
+
if (typeof str !== 'string') throw CeroError.INVALID_INVITE('invite must be a string')
|
|
150
|
+
|
|
151
|
+
let buf
|
|
152
|
+
try {
|
|
153
|
+
buf = z32.decode(str)
|
|
154
|
+
} catch {
|
|
155
|
+
throw CeroError.INVALID_INVITE('invite is not valid z32')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let fields
|
|
159
|
+
try {
|
|
160
|
+
fields = c.decode(Envelope, buf)
|
|
161
|
+
} catch {
|
|
162
|
+
throw CeroError.INVALID_INVITE('invite envelope failed to decode')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (fields.version !== VERSION) throw CeroError.INVALID_INVITE('unknown invite version')
|
|
166
|
+
|
|
167
|
+
const inv = new Invite({ ...fields, _str: str })
|
|
168
|
+
if (!inv.verify()) throw CeroError.INVALID_INVITE('invite signature is invalid')
|
|
169
|
+
|
|
170
|
+
if (encoding && inv.data) {
|
|
171
|
+
try {
|
|
172
|
+
inv._rawData = c.decode(encoding, inv.data)
|
|
173
|
+
} catch {
|
|
174
|
+
throw CeroError.INVALID_INVITE('invite data failed to decode')
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return inv
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Cheap structural test — does `str` decode as an invite envelope? Does not
|
|
183
|
+
* verify the signature.
|
|
184
|
+
*
|
|
185
|
+
* @param {unknown} str
|
|
186
|
+
* @returns {boolean}
|
|
187
|
+
*/
|
|
188
|
+
static isInvite(str) {
|
|
189
|
+
if (typeof str !== 'string') return false
|
|
190
|
+
try {
|
|
191
|
+
const buf = z32.decode(str)
|
|
192
|
+
const fields = c.decode(Envelope, buf)
|
|
193
|
+
if (fields.blindInvite) BlindPairing.decodeInvite(fields.blindInvite)
|
|
194
|
+
return true
|
|
195
|
+
} catch {
|
|
196
|
+
return false
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|