@weaveprotocol/core 0.1.2 → 0.2.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/README.md +244 -39
- package/dist/identity/contact-key.d.ts +42 -0
- package/dist/identity/contact-key.d.ts.map +1 -0
- package/dist/identity/contact-key.js +133 -0
- package/dist/identity/contact-key.js.map +1 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/network/mesh.d.ts +6 -0
- package/dist/network/mesh.d.ts.map +1 -1
- package/dist/network/mesh.js +11 -1
- package/dist/network/mesh.js.map +1 -1
- package/dist/network/multi-signaling.d.ts +12 -2
- package/dist/network/multi-signaling.d.ts.map +1 -1
- package/dist/network/multi-signaling.js +86 -14
- package/dist/network/multi-signaling.js.map +1 -1
- package/dist/network/peer-auth.d.ts +50 -16
- package/dist/network/peer-auth.d.ts.map +1 -1
- package/dist/network/peer-auth.js +52 -15
- package/dist/network/peer-auth.js.map +1 -1
- package/dist/node/carrier.d.ts +87 -0
- package/dist/node/carrier.d.ts.map +1 -1
- package/dist/node/carrier.js +180 -59
- package/dist/node/carrier.js.map +1 -1
- package/dist/node/copy.d.ts.map +1 -1
- package/dist/node/copy.js +6 -3
- package/dist/node/copy.js.map +1 -1
- package/dist/node/host.d.ts +117 -0
- package/dist/node/host.d.ts.map +1 -0
- package/dist/node/host.js +192 -0
- package/dist/node/host.js.map +1 -0
- package/dist/node/index.d.ts +6 -2
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -0
- package/dist/node/index.js.map +1 -1
- package/dist/node/node.d.ts.map +1 -1
- package/dist/node/node.js +640 -25
- package/dist/node/node.js.map +1 -1
- package/dist/node/space-runtime.d.ts +42 -25
- package/dist/node/space-runtime.d.ts.map +1 -1
- package/dist/node/space-runtime.js +734 -35
- package/dist/node/space-runtime.js.map +1 -1
- package/dist/node/types.d.ts +269 -3
- package/dist/node/types.d.ts.map +1 -1
- package/dist/privacy/space-encryption.d.ts +16 -0
- package/dist/privacy/space-encryption.d.ts.map +1 -1
- package/dist/privacy/space-encryption.js +40 -0
- package/dist/privacy/space-encryption.js.map +1 -1
- package/dist/query/engine.js +1 -1
- package/dist/query/engine.js.map +1 -1
- package/dist/query/types.d.ts +7 -0
- package/dist/query/types.d.ts.map +1 -1
- package/dist/query/types.js.map +1 -1
- package/dist/react/use-query.d.ts +1 -1
- package/dist/react/use-query.d.ts.map +1 -1
- package/dist/records/topics.d.ts +21 -0
- package/dist/records/topics.d.ts.map +1 -0
- package/dist/records/topics.js +86 -0
- package/dist/records/topics.js.map +1 -0
- package/dist/schema/collection-def.d.ts +7 -0
- package/dist/schema/collection-def.d.ts.map +1 -1
- package/dist/schema/collection-def.js +4 -0
- package/dist/schema/collection-def.js.map +1 -1
- package/dist/schema/expression.d.ts +2 -0
- package/dist/schema/expression.d.ts.map +1 -1
- package/dist/schema/expression.js +1 -0
- package/dist/schema/expression.js.map +1 -1
- package/dist/schemas/contacts.d.ts +77 -0
- package/dist/schemas/contacts.d.ts.map +1 -0
- package/dist/schemas/contacts.js +35 -0
- package/dist/schemas/contacts.js.map +1 -0
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +1 -0
- package/dist/schemas/index.js.map +1 -1
- package/dist/session/auth.d.ts.map +1 -1
- package/dist/session/auth.js +14 -3
- package/dist/session/auth.js.map +1 -1
- package/dist/session/connect.d.ts +25 -1
- package/dist/session/connect.d.ts.map +1 -1
- package/dist/session/connect.js +9 -2
- package/dist/session/connect.js.map +1 -1
- package/dist/session/hosting.d.ts +155 -0
- package/dist/session/hosting.d.ts.map +1 -0
- package/dist/session/hosting.js +160 -0
- package/dist/session/hosting.js.map +1 -0
- package/dist/space/account-registry.d.ts +6 -0
- package/dist/space/account-registry.d.ts.map +1 -1
- package/dist/space/account-registry.js +16 -4
- package/dist/space/account-registry.js.map +1 -1
- package/dist/space/notify.d.ts +88 -0
- package/dist/space/notify.d.ts.map +1 -0
- package/dist/space/notify.js +127 -0
- package/dist/space/notify.js.map +1 -0
- package/dist/space/pass.d.ts +6 -0
- package/dist/space/pass.d.ts.map +1 -1
- package/dist/space/pass.js +5 -2
- package/dist/space/pass.js.map +1 -1
- package/dist/space/roles.d.ts +71 -0
- package/dist/space/roles.d.ts.map +1 -1
- package/dist/space/roles.js +114 -0
- package/dist/space/roles.js.map +1 -1
- package/dist/space/space-access.d.ts +24 -0
- package/dist/space/space-access.d.ts.map +1 -1
- package/dist/space/space-access.js +24 -0
- package/dist/space/space-access.js.map +1 -1
- package/dist/space/space-manager.d.ts +35 -1
- package/dist/space/space-manager.d.ts.map +1 -1
- package/dist/space/space-manager.js +72 -25
- package/dist/space/space-manager.js.map +1 -1
- package/dist/storage/blob/memory.d.ts +9 -0
- package/dist/storage/blob/memory.d.ts.map +1 -0
- package/dist/storage/blob/memory.js +20 -0
- package/dist/storage/blob/memory.js.map +1 -0
- package/dist/storage/blob/s3.d.ts +16 -0
- package/dist/storage/blob/s3.d.ts.map +1 -0
- package/dist/storage/blob/s3.js +86 -0
- package/dist/storage/blob/s3.js.map +1 -0
- package/dist/storage/blob-store.d.ts +26 -0
- package/dist/storage/blob-store.d.ts.map +1 -0
- package/dist/storage/blob-store.js +2 -0
- package/dist/storage/blob-store.js.map +1 -0
- package/dist/storage/encrypted-adapter.d.ts +3 -3
- package/dist/storage/encrypted-adapter.js +3 -3
- package/dist/storage/folder-adapter.d.ts +8 -8
- package/dist/storage/folder-adapter.d.ts.map +1 -1
- package/dist/storage/folder-adapter.js +7 -7
- package/dist/storage/folder-reconcile.d.ts +12 -14
- package/dist/storage/folder-reconcile.d.ts.map +1 -1
- package/dist/storage/folder-reconcile.js +20 -14
- package/dist/storage/folder-reconcile.js.map +1 -1
- package/dist/storage/index.d.ts +0 -1
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +0 -1
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/indexeddb-adapter.d.ts.map +1 -1
- package/dist/storage/indexeddb-adapter.js +8 -9
- package/dist/storage/indexeddb-adapter.js.map +1 -1
- package/dist/storage/mirror.d.ts +68 -0
- package/dist/storage/mirror.d.ts.map +1 -0
- package/dist/storage/mirror.js +174 -0
- package/dist/storage/mirror.js.map +1 -0
- package/dist/storage/segment.d.ts +20 -0
- package/dist/storage/segment.d.ts.map +1 -0
- package/dist/storage/segment.js +27 -0
- package/dist/storage/segment.js.map +1 -0
- package/dist/storage/storage-provider.d.ts +36 -40
- package/dist/storage/storage-provider.d.ts.map +1 -1
- package/dist/storage/storage-provider.js +208 -117
- package/dist/storage/storage-provider.js.map +1 -1
- package/dist/sync/index.d.ts +1 -1
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +1 -1
- package/dist/sync/index.js.map +1 -1
- package/dist/sync/negentropy.d.ts +68 -0
- package/dist/sync/negentropy.d.ts.map +1 -0
- package/dist/sync/negentropy.js +379 -0
- package/dist/sync/negentropy.js.map +1 -0
- package/dist/sync/sync-engine.d.ts +45 -13
- package/dist/sync/sync-engine.d.ts.map +1 -1
- package/dist/sync/sync-engine.js +290 -207
- package/dist/sync/sync-engine.js.map +1 -1
- package/dist/sync/sync-messages.d.ts +42 -32
- package/dist/sync/sync-messages.d.ts.map +1 -1
- package/dist/sync/sync-messages.js +1 -1
- package/dist/sync/sync-messages.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/hash.d.ts +9 -0
- package/dist/utils/hash.d.ts.map +1 -1
- package/dist/utils/hash.js +33 -0
- package/dist/utils/hash.js.map +1 -1
- package/package.json +3 -2
- package/dist/storage/mst.d.ts +0 -121
- package/dist/storage/mst.d.ts.map +0 -1
- package/dist/storage/mst.js +0 -402
- package/dist/storage/mst.js.map +0 -1
- package/dist/sync/anti-entropy.d.ts +0 -49
- package/dist/sync/anti-entropy.d.ts.map +0 -1
- package/dist/sync/anti-entropy.js +0 -62
- package/dist/sync/anti-entropy.js.map +0 -1
package/README.md
CHANGED
|
@@ -11,11 +11,11 @@ devices, and every app is a view onto that data rather than its owner.
|
|
|
11
11
|
│ Applications │
|
|
12
12
|
├──────────────┬───────────┬────────────┬──────────┬───────────┤
|
|
13
13
|
│ Accounts │ Spaces │ Validation │ Privacy │ Sync │
|
|
14
|
-
│ seed, vault, │ roles, │ crypto → │ AES-GCM │
|
|
15
|
-
│ root signer, │ members × │ structural │ per │
|
|
16
|
-
│ UCAN, pairing│ pub/priv │ → UCAN │ space │
|
|
14
|
+
│ seed, vault, │ roles, │ crypto → │ AES-GCM │ Negentropy│
|
|
15
|
+
│ root signer, │ members × │ structural │ per │ per │
|
|
16
|
+
│ UCAN, pairing│ pub/priv │ → UCAN │ space │ collection│
|
|
17
17
|
├──────────────┴───────────┴────────────┴──────────┴───────────┤
|
|
18
|
-
│ Storage:
|
|
18
|
+
│ Storage: signed versions + plain index over an adapter │
|
|
19
19
|
│ IndexedDB (per origin) · data folder (shared by origins) │
|
|
20
20
|
├──────────────────────────────────────────────────────────────┤
|
|
21
21
|
│ Network: WebRTC data channels │
|
|
@@ -71,7 +71,7 @@ const ucan = await root.delegate({
|
|
|
71
71
|
const spaces = createSpaceManager(await createIndexedDBAdapter('my-app/registry'));
|
|
72
72
|
const { space } = await spaces.create({ name: 'Notes', visibility: 'public', creator: me.did });
|
|
73
73
|
|
|
74
|
-
// 4. A signed record, stored in that space's own
|
|
74
|
+
// 4. A signed record, stored in that space's own store
|
|
75
75
|
const storage = createStorageProvider(await createIndexedDBAdapter(`my-app/space/${space.id}`));
|
|
76
76
|
const signed = await createSigner(provider).sign(
|
|
77
77
|
createExpression({
|
|
@@ -222,6 +222,61 @@ await calls.answer(ringing.id); calls.setMuted(true); await calls.shareScreen(
|
|
|
222
222
|
|
|
223
223
|
The messages, and the reasoning, are at the top of `src/calls/calls.ts`.
|
|
224
224
|
|
|
225
|
+
### Contacts
|
|
226
|
+
|
|
227
|
+
A DID is a **name, not an address**: it's on everything you sign, so knowing
|
|
228
|
+
it must not be enough to reach you. What lets two people reach each other is a
|
|
229
|
+
space they share. So **a contact is a private space for two**: records you
|
|
230
|
+
write there wait for the other person, and live messages reach them when
|
|
231
|
+
they're online. There's no directory to look people up in, and no inbox
|
|
232
|
+
strangers can knock on.
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// In a space you share with Anna — the book club — ask her to add you.
|
|
236
|
+
const { space } = await node.contacts.ask(club.id, anna, { note: "it's Leif from book club" });
|
|
237
|
+
|
|
238
|
+
// On Anna's side:
|
|
239
|
+
const [request] = await node.contacts.requests(club.id); // { from, name, note, pairSpace, … }
|
|
240
|
+
await node.contacts.accept(club.id, request.key); // joins the space for two, adds Leif
|
|
241
|
+
|
|
242
|
+
await node.contacts.list(); // [{ did, name, space, note, blocked }]
|
|
243
|
+
await node.contacts.put({ did, name: 'Anna K' }); // what you call them — only you see it
|
|
244
|
+
await node.contacts.remove(anna); // off the list, and out of your space with her
|
|
245
|
+
await node.contacts.block(anna); // and her requests are hidden in every space
|
|
246
|
+
await node.contacts.others(anna); // anyone else in your space with her: [] unless someone was let in
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
- **The list** is one `std.contact` per person, in a **contacts space**
|
|
250
|
+
derived from the account key the way the account registry is: every device
|
|
251
|
+
of the account has it, nobody else can find it, and it's hidden from
|
|
252
|
+
`spaces.list`. `contacts.space()` gives its id. A carrier carries it, so a
|
|
253
|
+
restore brings the list back.
|
|
254
|
+
- **Asking** (`ask`) makes a private space for the two of you, puts them on
|
|
255
|
+
your list with it, and posts a `std.contact-request` in the shared space:
|
|
256
|
+
the new space's invite, **sealed with their contact key**. The other members
|
|
257
|
+
can see that you asked, not what. The seal is bound to the space it was
|
|
258
|
+
posted in and to who asked whom, so a request copied into another space, or
|
|
259
|
+
re-posted by someone else, doesn't open. Joining is the answer: there's no
|
|
260
|
+
reply record.
|
|
261
|
+
- **The contact key** is a P-256 key for encryption, derived from the seed
|
|
262
|
+
under its own label (`deriveContactKeyBytes`), so it's the same on every
|
|
263
|
+
device and comes back with the recovery code. Its public half goes on your
|
|
264
|
+
profile in every space you write in (`spaces.profiles(id)[n].contactKey`),
|
|
265
|
+
and only counts on a profile signed by your own account. An app without the
|
|
266
|
+
key never takes it off: a profile keeps the newest key it was given.
|
|
267
|
+
- **Conversation or group** is decided by how the space was made, not by how
|
|
268
|
+
many people are in it: a space is your conversation with Anna because your
|
|
269
|
+
list says so. A space made with "New space" and shared with one person is
|
|
270
|
+
never one. If a third account turns up in a space for two, `others` says so,
|
|
271
|
+
and an app can offer to start a new space with just the two of you, or a new
|
|
272
|
+
group with everyone.
|
|
273
|
+
|
|
274
|
+
**What an app gets.** A home gives the contacts to an app that asks with
|
|
275
|
+
`contacts: true`: the contacts space as one more space in its grant, and the
|
|
276
|
+
contact key, which opens requests sent to you. Asking someone and accepting
|
|
277
|
+
also make or join a space, so those need `scope: 'account'`, which includes
|
|
278
|
+
the contacts. Agents never get them.
|
|
279
|
+
|
|
225
280
|
## Signing in — the element, and React
|
|
226
281
|
|
|
227
282
|
Getting to a node takes a sign-in flow: where the data lives (a pod or this
|
|
@@ -362,12 +417,51 @@ the account's list on every device.
|
|
|
362
417
|
An app that is a view onto *everything* — like the example — asks for
|
|
363
418
|
`scope: 'account'`: a note for every space, plus the key the account's space
|
|
364
419
|
list is derived from, so it sees every space and can make and join them. It
|
|
420
|
+
gets the contacts too (see Contacts, above). It
|
|
365
421
|
still never holds the seed: it cannot sign in anywhere as the account, change
|
|
366
422
|
its password or passkeys, or keep access past the note's date.
|
|
367
423
|
|
|
368
424
|
The home side is `receiveConnectRequest()` and `auth.grant(…)`; see
|
|
369
425
|
[home/README.md](home/README.md).
|
|
370
426
|
|
|
427
|
+
### Apps hold what they use — keepers hold the rest
|
|
428
|
+
|
|
429
|
+
A space can name its **keepers**: nodes that hold every record of it, like a
|
|
430
|
+
host or the extension (`node.spaces.setKeepers`, for someone who manages the
|
|
431
|
+
space; the account's carriers are named by themselves in every space it
|
|
432
|
+
manages). Once it does, an app connected to the account home holds only the
|
|
433
|
+
collections it uses, besides the space's own (`sys.*`):
|
|
434
|
+
|
|
435
|
+
- a query says what it needs — its collection and every `include … from` —
|
|
436
|
+
and those start syncing from any peer that has them;
|
|
437
|
+
- `result.complete` is false until they have caught up with a node holding
|
|
438
|
+
the whole space: show "Loading…", not "Nothing here";
|
|
439
|
+
- the app's own writes stay **pending** until enough keepers say they have
|
|
440
|
+
them (`stored`): the space's `copies`, or 2, never more than it names.
|
|
441
|
+
Nothing pending is ever dropped;
|
|
442
|
+
- a collection no query has touched for `unusedAfterDays` (30) is dropped,
|
|
443
|
+
unless the app declared it (`cache.collections`). It syncs back when needed.
|
|
444
|
+
|
|
445
|
+
A collection can also name **topic fields** (`topics: ['channel', 'mentions']`).
|
|
446
|
+
Each record then carries, on its outside, a keyed hash of each of those values
|
|
447
|
+
(`tags`), so a keeper that can't read the record can still match "in
|
|
448
|
+
#design" or "mentions me" — learning which records share a topic, never which
|
|
449
|
+
topic. `node.collections.tag(space, collection, field, value)` gives the tag to
|
|
450
|
+
match; anyone who can read a record checks its tags and refuses a mismatch.
|
|
451
|
+
|
|
452
|
+
**"Notify me when…"** (`node.notifications`, and the home's section of that
|
|
453
|
+
name): new records in a collection, in every space or some, perhaps only with
|
|
454
|
+
a topic value ("mentions me"), perhaps only other people's. The account
|
|
455
|
+
registry keeps each one sealed; every carrier gets it with the value replaced
|
|
456
|
+
by each space's tag, so the Weave extension notices matching records as they
|
|
457
|
+
arrive and shows a notification — the space, your label, the time — without
|
|
458
|
+
being able to read them, or what you asked for.
|
|
459
|
+
|
|
460
|
+
A space that names no keeper is held whole, as before: then the app may be one
|
|
461
|
+
of its copies. How much to hold is each node's choice (`NodeConfig.cache`;
|
|
462
|
+
`startConnectedNode` turns it on unless given `cache: false`); only how
|
|
463
|
+
keepers confirm is protocol. `spaces.status(id)` shows `holds` and `pending`.
|
|
464
|
+
|
|
371
465
|
## Modules
|
|
372
466
|
|
|
373
467
|
### Identity (`@weaveprotocol/core/identity`)
|
|
@@ -484,16 +578,27 @@ Typed, signed data expressions using [Standard Schema](https://standardschema.de
|
|
|
484
578
|
|
|
485
579
|
### Storage (`@weaveprotocol/core/storage`)
|
|
486
580
|
|
|
487
|
-
Local-first storage
|
|
581
|
+
Local-first storage: signed versions, and plain entries saying which is current
|
|
582
|
+
and which versions each collection keeps — the set sync compares.
|
|
488
583
|
|
|
489
584
|
| Export | Description |
|
|
490
585
|
|--------|-------------|
|
|
491
|
-
| `createStorageProvider()` |
|
|
586
|
+
| `createStorageProvider()` | Expression storage: current, first and retained versions (`r/`, `g/`, `h/`), and every kept version by collection (`i/`) for sync; `fingerprint()` is equal on two stores keeping the same versions |
|
|
492
587
|
| `createIndexedDBAdapter()` | IndexedDB storage adapter, scoped to this origin |
|
|
493
588
|
| `createFolderAdapter()` | A user-picked directory, shared by every origin given access |
|
|
494
589
|
| `createEncryptedAdapter()` | Seals chosen keys (space records, space keys) at rest |
|
|
495
|
-
| `reconcileFolder()` |
|
|
496
|
-
| `
|
|
590
|
+
| `reconcileFolder()` | Places the files another writer added to a folder, and drops entries whose file is gone |
|
|
591
|
+
| `createMirror()` | Keeps a space in a dumb file store too, synced like a peer that never runs code |
|
|
592
|
+
| `createS3BlobStore()` / `createMemoryBlobStore()` | File stores a mirror can use: any S3-compatible bucket (R2, B2, MinIO, AWS), or memory |
|
|
593
|
+
|
|
594
|
+
**Mirrors.** A bucket or an app folder can hold a space: each writer (one store
|
|
595
|
+
on one device, with a random id) only ever adds immutable segments in its own
|
|
596
|
+
folder, named by a counter and the hash of their bytes — so nothing is written
|
|
597
|
+
twice and nothing needs a lock. A segment holds versions exactly as they travel,
|
|
598
|
+
private bodies still sealed, and everything read back passes the same gates as a
|
|
599
|
+
peer's records: the store can hide things, not forge them. What a writer knows
|
|
600
|
+
the store holds, it never uploads again; a writer compacts its own segments
|
|
601
|
+
into fewer. Merging is the protocol's own — a set of versions that only grows.
|
|
497
602
|
|
|
498
603
|
### Spaces
|
|
499
604
|
|
|
@@ -523,7 +628,7 @@ const { space, key } = await spaces.create({
|
|
|
523
628
|
});
|
|
524
629
|
```
|
|
525
630
|
|
|
526
|
-
Give each space its own storage and its own
|
|
631
|
+
Give each space its own storage and its own sync and a peer you share one list
|
|
527
632
|
with learns nothing about the others.
|
|
528
633
|
|
|
529
634
|
#### Who may write: roles, and a history every peer replays
|
|
@@ -543,8 +648,9 @@ and roles ranked below you, and give out roles up to your own rank. Two people
|
|
|
543
648
|
at the same rank can never remove each other, only themselves — so the creator
|
|
544
649
|
**hands over** by giving someone their role, then leaving, and the space goes on.
|
|
545
650
|
|
|
546
|
-
Roles, members, invites, revoked notes
|
|
547
|
-
(`sys.role`, `sys.member`, `sys.invite`,
|
|
651
|
+
Roles, members, invites, revoked notes, collection definitions and changes of a
|
|
652
|
+
private space's key are records (`sys.role`, `sys.member`, `sys.invite`,
|
|
653
|
+
`sys.revoke`, `sys.collection`, `sys.key`), and
|
|
548
654
|
every record written anywhere names the latest of them its writer knew, as
|
|
549
655
|
`seen`. That makes the **access history** a small graph, which every peer
|
|
550
656
|
replays the same way (`space/roles.ts`): a change comes after what it saw;
|
|
@@ -586,15 +692,35 @@ connect under someone else's name, and in a private space with the read key
|
|
|
586
692
|
too, checked against its public half. A stranger who learns a space's id, or a
|
|
587
693
|
relay that sees its room, gets no ciphertext. A peer-to-peer handshake also
|
|
588
694
|
signs both ends' DTLS fingerprints, so a relay that swapped in its own offer to
|
|
589
|
-
sit in the middle is caught.
|
|
590
|
-
|
|
695
|
+
sit in the middle is caught.
|
|
696
|
+
|
|
697
|
+
**Removing someone from a private space changes its key**, the way Keybase
|
|
698
|
+
changes a team's key. Whoever manages the space, seeing someone lose their
|
|
699
|
+
place (removed, left, or their role deleted), makes a new key by itself and
|
|
700
|
+
writes a `sys.key` record: the new key's id and public read key, and every
|
|
701
|
+
earlier key sealed under the new one. It is part of the access history, so two
|
|
702
|
+
new keys made apart resolve like any other change, and only `manage` may make
|
|
703
|
+
one. Each member's copy goes in a `sys.box`, sealed to their **member key**: a
|
|
704
|
+
key pair per account per space, derived from the account's vault key, whose
|
|
705
|
+
public half each member publishes in the clear (`sys.memberkey`). An account
|
|
706
|
+
home hands an app the member keys of exactly the spaces it grants.
|
|
707
|
+
|
|
708
|
+
From then on new records are sealed with the new key. Someone removed keeps what
|
|
709
|
+
they could already read, and nothing after it. Holding the current key opens
|
|
710
|
+
every earlier one, so a newcomer reads the space's past. A member who was away
|
|
711
|
+
when the key changed still holds only the older read key: they prove that, and
|
|
712
|
+
send their note sealed under the older key, and a peer holding it lets them in
|
|
713
|
+
if they are still a member. A host with no key takes the current read key only.
|
|
714
|
+
View-only links made before the change stop working; share a new one.
|
|
715
|
+
`node.spaces.changeKey(space)` does the same by hand, for a lost device.
|
|
591
716
|
|
|
592
717
|
A space's **id is the hash of what is fixed at creation**: creator, visibility,
|
|
593
718
|
starting roles and which one the creator holds, time, a random nonce and the
|
|
594
|
-
read key (the name is left out, so it can change). `join` refuses an
|
|
595
|
-
whose space does not hash to its id
|
|
596
|
-
|
|
597
|
-
|
|
719
|
+
first read key (the name is left out, so it can change). `join` refuses an
|
|
720
|
+
invite whose space does not hash to its id — so whoever passes an invite on
|
|
721
|
+
cannot change who started the space, or with which roles. The key an invite
|
|
722
|
+
carries may be a later one; its id is its hash, so it is either the key the
|
|
723
|
+
history names or one that opens nothing.
|
|
598
724
|
|
|
599
725
|
**Spaces describe themselves.** A space stores its collections' definitions —
|
|
600
726
|
name, title, description and a JSON Schema — as signed records in
|
|
@@ -774,23 +900,36 @@ tell a Weave peer from anyone else, so set coturn's own quotas (`user-quota`,
|
|
|
774
900
|
`total-quota`, `max-bps`). TURN only forwards encrypted packets; it can't
|
|
775
901
|
see or hear a call.
|
|
776
902
|
|
|
903
|
+
The relay's Docker image (`server/`, deployed to Fly) runs coturn beside it when
|
|
904
|
+
`TURN_SECRET` is set, already capped that way: `server/fly.toml` says how.
|
|
905
|
+
|
|
777
906
|
Only peers already in a room hear about a newcomer, so exactly one side creates
|
|
778
907
|
the offer and the two never collide.
|
|
779
908
|
|
|
780
909
|
### Sync (`@weaveprotocol/core/sync`)
|
|
781
910
|
|
|
782
|
-
|
|
911
|
+
Range-based set reconciliation (Negentropy, as Nostr's NIP-77), one collection at a time.
|
|
783
912
|
|
|
784
913
|
| Export | Description |
|
|
785
914
|
|--------|-------------|
|
|
786
|
-
| `createSyncEngine()` |
|
|
787
|
-
| `
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
915
|
+
| `createSyncEngine()` | Reconciles a space with its peers, with a heartbeat |
|
|
916
|
+
| `createReconciler()` / `ItemSet` / `fingerprintOf()` | Negentropy itself, wire-compatible with the reference implementation |
|
|
917
|
+
|
|
918
|
+
A peer says hello with a fingerprint of each collection it keeps — in essence
|
|
919
|
+
the sum of its version ids. Equal fingerprints mean the same versions, and that
|
|
920
|
+
collection is done. For each one that differs, the peer whose id sorts first
|
|
921
|
+
compares fingerprints of ever smaller ranges with the other until both know
|
|
922
|
+
exactly which versions each lacks; then it asks for what it lacks and sends what
|
|
923
|
+
the other does. Cost follows the difference, not the size: one new version
|
|
924
|
+
among 2,000 syncs in under 8 KB, all told. Nothing is stored for sync beyond
|
|
925
|
+
the versions' own index entries: the sets and sums live in memory, updated by
|
|
926
|
+
every change and read again when another writer (a tab, a folder) changed the
|
|
927
|
+
store.
|
|
928
|
+
|
|
929
|
+
Each hello also says what the peer holds — `all`, or some collections — and
|
|
930
|
+
two peers reconcile only the collections both hold. A version for a collection
|
|
931
|
+
a node doesn't hold is passed over; one it takes in, it tells the sender it has
|
|
932
|
+
(`stored`). One peer's messages are handled in the order they came.
|
|
794
933
|
|
|
795
934
|
The engine's `validate` hook is the seam where the validation engine sits.
|
|
796
935
|
Expressions a peer sends are only committed if it accepts them; the rest are
|
|
@@ -877,7 +1016,7 @@ A directory handle is the exception. Each origin asks for permission once, and b
|
|
|
877
1016
|
accounts.json name, DID and id of each account (readable without unlocking)
|
|
878
1017
|
accounts/<id>/account.json that account's seed, encrypted once per way of unlocking it
|
|
879
1018
|
accounts/<id>/stores/<namespace>/
|
|
880
|
-
kv/<key>
|
|
1019
|
+
kv/<key> index entries (current, first, retained, by collection), space records (sealed)
|
|
881
1020
|
expressions/<cid>.json one signed record per file
|
|
882
1021
|
```
|
|
883
1022
|
|
|
@@ -910,11 +1049,11 @@ const registry = createEncryptedAdapter(
|
|
|
910
1049
|
);
|
|
911
1050
|
```
|
|
912
1051
|
|
|
913
|
-
**Expressions are the truth; the
|
|
1052
|
+
**Expressions are the truth; the entries are an index over them.** That inversion is what lets several writers share one folder without taking a lock. Every expression file is named by its own content hash, so concurrent writers can only ever add files that agree; the one thing both can overwrite, which version a record's current entry names, is derived state either side can put right. `reconcileFolder()` does — it places every file that appeared, which corrects an entry a race left wrong — so call it on an interval, on window focus, or after a sync round, since the web has no filesystem change notification.
|
|
914
1053
|
|
|
915
1054
|
Two consequences worth having:
|
|
916
1055
|
|
|
917
|
-
- **The folder is the account.** Copy it to a USB stick and it is your whole identity. Put it in iCloud, Dropbox or Syncthing and several devices converge with no relay at all — the folder becomes a second transport alongside WebRTC, and both meet in the same
|
|
1056
|
+
- **The folder is the account.** Copy it to a USB stick and it is your whole identity. Put it in iCloud, Dropbox or Syncthing and several devices converge with no relay at all — the folder becomes a second transport alongside WebRTC, and both meet in the same merge: the set of versions only grows, and the version rule picks the same current one everywhere.
|
|
918
1057
|
- **It changes nothing about the mesh.** A folder-backed node is an ordinary peer that happens to be durable and readable by several origins — an availability role, never an authority one. Where there is no folder (Safari, Firefox, mobile) a node keeps an origin-scoped replica and gossips exactly as before.
|
|
919
1058
|
|
|
920
1059
|
### Locking the folder
|
|
@@ -939,7 +1078,7 @@ await accounts.write(account, withWrap(vault, wrap));
|
|
|
939
1078
|
|
|
940
1079
|
`deviceWrapsFor(vault, rpId)` says which wraps this origin can even attempt; the rest name keys it cannot reach. The gate is enforced in application code rather than by cryptography — see `src/identity/device-key.ts` for what that does and does not protect against. The recovery code needs no wrap, because it *is* the seed in printable form — it opens the folder anywhere, including on a phone or in a browser with no File System Access API, and it is shown once and stored nowhere.
|
|
941
1080
|
|
|
942
|
-
`createEncryptedAdapter` seals `space:`, `spacekey:`, `spaceinvite:` and `spacerole:` values under the vault key, which is what makes a private space genuinely unreadable to someone holding the folder. It is scoped deliberately narrowly: expressions and
|
|
1081
|
+
`createEncryptedAdapter` seals `space:`, `spacekey:`, `spaceinvite:` and `spacerole:` values under the vault key, which is what makes a private space genuinely unreadable to someone holding the folder. It is scoped deliberately narrowly: expressions and index entries pass through, so what stays legible is each record's author, timestamp and collection, plus anything in a space its owner made public. Sealing those too would mean an opaque blob store, which would cost the property that makes a folder worth having.
|
|
943
1082
|
|
|
944
1083
|
### Where the root key lives
|
|
945
1084
|
|
|
@@ -991,6 +1130,18 @@ space with is a peer in that one only.
|
|
|
991
1130
|
**Several relays**, so there is no single phone book — a peer announced by two
|
|
992
1131
|
of them is announced upward once, and replies go back the way they arrived.
|
|
993
1132
|
|
|
1133
|
+
**The space says where it meets**, like a Nostr relay list, but the space's
|
|
1134
|
+
own. `sys.relays` is part of the access history, so only someone who manages
|
|
1135
|
+
the space changes it (`node.spaces.setRelays`), and a new space names its
|
|
1136
|
+
creator's relays by itself. Invites carry the list, so a joiner whose app uses
|
|
1137
|
+
other relays reaches the space before anything has synced. Every member then
|
|
1138
|
+
joins the space's room on those relays as well as their own
|
|
1139
|
+
(`mesh.useRelays(room, relays)`); relays only one space names are joined for
|
|
1140
|
+
that space's room alone, and dropped once no open space names them. Moving a
|
|
1141
|
+
space to another relay, a self-hosted one say, is one change, and everyone
|
|
1142
|
+
follows. No DHT: browsers can't be DHT nodes, and would need a relay to reach
|
|
1143
|
+
one anyway.
|
|
1144
|
+
|
|
994
1145
|
**Peers introduce peers**, so a relay is only needed for the *first* connection.
|
|
995
1146
|
Once you are connected to someone, their data channel carries signalling for the
|
|
996
1147
|
peers you have not met: `__peers` says who I can see, `__signal` carries
|
|
@@ -1089,6 +1240,27 @@ it doubles as a relay), and `weave mcp` to hand the same operations to an agent.
|
|
|
1089
1240
|
It reads and writes the same data folder layout a browser does. See
|
|
1090
1241
|
[cli/README.md](cli/README.md).
|
|
1091
1242
|
|
|
1243
|
+
**Hosting.** `weave host` keeps many accounts' spaces online without being able
|
|
1244
|
+
to read them: it is the extension's carrier (`createCarrierNode`) with one carry
|
|
1245
|
+
space per paying account (`createHostNode`), each space held once however many
|
|
1246
|
+
members pay for it. A subscription is a key the account makes and keeps in its
|
|
1247
|
+
registry (`sys.hosting`), so every device signs as it; `node.hosting.use(url)`
|
|
1248
|
+
starts one, and whichever device notices it is paid hands the host the carry
|
|
1249
|
+
space. Every call to the host is signed over method, path, time and body.
|
|
1250
|
+
The home knows nothing about payment (BLOCK-23): a host describes itself at
|
|
1251
|
+
`/.well-known/weave-host` (like a Nostr relay's NIP-11 document), signs every
|
|
1252
|
+
status it gives — the home keeps the latest in the registry as the person's
|
|
1253
|
+
proof — and takes payments on its own pay page, which `node.hosting.payPage(url)`
|
|
1254
|
+
links to with a signature by the subscription key, valid for an hour at that
|
|
1255
|
+
host only. On the page: Stripe Checkout and the Customer Portal (card, Apple
|
|
1256
|
+
Pay, Google Pay), whose webhook moves a paid-until date taken from Stripe's own
|
|
1257
|
+
billing period; and USDC from a crypto wallet straight to the host's address,
|
|
1258
|
+
checked on the network by the host. Past the date: a grace period, then the host drops
|
|
1259
|
+
the spaces and deletes its copy. With a bucket (`WEAVE_S3_*`) the host's disk
|
|
1260
|
+
is only a cache — every carried space and the subscription list live in the
|
|
1261
|
+
bucket, sealed, and a new machine starts from it. The account home's Settings
|
|
1262
|
+
has **Keep my spaces online**.
|
|
1263
|
+
|
|
1092
1264
|
## Example app
|
|
1093
1265
|
|
|
1094
1266
|
`example/` is the Weave website — a landing page for developers at `/`, and
|
|
@@ -1101,11 +1273,44 @@ npm install && (cd example && npm install) && (cd home && npm install) # the C
|
|
|
1101
1273
|
npm run dev
|
|
1102
1274
|
```
|
|
1103
1275
|
|
|
1104
|
-
That starts
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1276
|
+
That starts everything, with coloured output per part, and Ctrl-C stops it all:
|
|
1277
|
+
|
|
1278
|
+
| Part | Where | What |
|
|
1279
|
+
|---|---|---|
|
|
1280
|
+
| app | http://localhost:5173 | The example app |
|
|
1281
|
+
| home | http://localhost:5174 | The account home it connects to |
|
|
1282
|
+
| node | port 8787 | An always-on node that is also the relay; a throwaway identity on first run (`cli/.env.dev`, data in `.weave-dev/`) |
|
|
1283
|
+
| host | http://localhost:8788 | `weave host`, what "Keep my spaces online" uses, with its pay page at `/pay`; settings in `cli/.env.host.dev` |
|
|
1284
|
+
| stripe | — | Only when you add a Stripe test key (below): forwards Stripe's webhooks to the host |
|
|
1285
|
+
|
|
1286
|
+
`example/.env.development` and `home/.env.development` point the app and home
|
|
1287
|
+
at the rest. Override any of them in a `.env.local`, and the host in
|
|
1288
|
+
`cli/.env.host.local`.
|
|
1289
|
+
|
|
1290
|
+
**Trying hosting and payments.** In the home: Settings, **Keep my spaces
|
|
1291
|
+
online**, **Keep online** (the dev host is filled in), then **Payment**, which
|
|
1292
|
+
opens the host's pay page in a new tab. What you can pay with there:
|
|
1293
|
+
|
|
1294
|
+
- **A browser wallet, on by default.** Payments go to Base Sepolia, a test
|
|
1295
|
+
network: test money only. In MetaMask (or any browser wallet), get test ETH
|
|
1296
|
+
for the fee from a Base Sepolia faucet (Coinbase's, or Alchemy's) and test
|
|
1297
|
+
USDC from faucet.circle.com (choose Base Sepolia). Pay, and the pay page
|
|
1298
|
+
says "Payment received"; back in the home's tab, the host shows "paid until"
|
|
1299
|
+
and takes your spaces. To see payments arrive, set your own address as
|
|
1300
|
+
`WEAVE_WALLET_ADDRESS` in `cli/.env.host.local`.
|
|
1301
|
+
- **A card, in Stripe's test mode.** In `cli/.env.host.local`, add
|
|
1302
|
+
`STRIPE_SECRET_KEY=sk_test_…` and the price ids of a monthly and a yearly
|
|
1303
|
+
recurring test price (`STRIPE_PRICE_MONTHLY`, `STRIPE_PRICE_YEARLY`, from the Stripe
|
|
1304
|
+
dashboard in test mode). With the Stripe CLI installed, `npm run dev`
|
|
1305
|
+
forwards Stripe's webhooks to the host by itself. Pay with card
|
|
1306
|
+
4242 4242 4242 4242, any future date, any CVC.
|
|
1307
|
+
- **Phone wallets, by QR code.** Add `WEAVE_WALLETCONNECT_PROJECT_ID` (free at
|
|
1308
|
+
dashboard.reown.com, with localhost allowed in the project) to
|
|
1309
|
+
`cli/.env.host.local`; `npm run dev` builds what the pay page needs. The
|
|
1310
|
+
wallet has to be on Base Sepolia too.
|
|
1311
|
+
|
|
1312
|
+
`npm test` covers the same paths without any of this: a fake network, fake
|
|
1313
|
+
Stripe calls, and the home's side against a real host.
|
|
1109
1314
|
|
|
1110
1315
|
The example never signs anyone in: "Connect with Weave" opens the home, where
|
|
1111
1316
|
you make an account or sign in, and allow the example your whole account. Its
|
|
@@ -1129,7 +1334,7 @@ between pods, pair a phone by QR code, and see which apps you connected. In the
|
|
|
1129
1334
|
example: make private or public spaces, just yours or with people you invite, and share one with a
|
|
1130
1335
|
friend via an invite link. Everyone in a space is shown by the name
|
|
1131
1336
|
they gave. Every record is signed by a delegated session key, stored in that
|
|
1132
|
-
space's
|
|
1337
|
+
space's own store, encrypted first if the space is private, and gossiped to peers over
|
|
1133
1338
|
WebRTC; a record says *verified* once its signature and its delegation chain
|
|
1134
1339
|
check out here, and *encrypted* when it arrived encrypted.
|
|
1135
1340
|
|
|
@@ -1218,8 +1423,8 @@ for the same private scalars, and pinned to recorded DIDs so an accidental
|
|
|
1218
1423
|
change cannot slip through; recovery codes; account vaults, wraps and account
|
|
1219
1424
|
stores; data folders with several writers; spaces, invites and
|
|
1220
1425
|
encrypt-then-sign; UCAN issuing, attenuation and chain validation; phone
|
|
1221
|
-
pairing; peer introductions;
|
|
1222
|
-
|
|
1426
|
+
pairing; peer introductions; Negentropy, checked against the reference
|
|
1427
|
+
implementation; the validation gates; and two peers reconciling, including the forged, stolen,
|
|
1223
1428
|
unauthorized and malformed expressions their gatekeepers reject. Above those:
|
|
1224
1429
|
the node API — versioned records, links, queries, collection definitions,
|
|
1225
1430
|
profiles, the account registry, moving and merging accounts — and the CLI.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface ContactKeyPair {
|
|
2
|
+
/** The public half: a compressed P-256 point, base64url. What goes on a profile. */
|
|
3
|
+
readonly publicKey: string;
|
|
4
|
+
/** The private half, for opening what was sealed to the public one. Not extractable. */
|
|
5
|
+
readonly privateKey: CryptoKey;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The contact key's private scalar, from the account seed — 32 bytes. This is
|
|
9
|
+
* what an account home hands an app it lets handle contacts.
|
|
10
|
+
*/
|
|
11
|
+
export declare function deriveContactKeyBytes(seed: Uint8Array): Promise<Uint8Array>;
|
|
12
|
+
/**
|
|
13
|
+
* An account's **member key** for one space: the same kind of key pair, derived
|
|
14
|
+
* from the account's vault key and the space id. When a private space's key
|
|
15
|
+
* changes, the new key is sealed to each member's member key (`sys.box`).
|
|
16
|
+
*
|
|
17
|
+
* One per space, not one per account, so an account home can hand an app the
|
|
18
|
+
* member keys for exactly the spaces it grants — and a new space key reaches
|
|
19
|
+
* that app without it holding anything that opens other spaces' keys.
|
|
20
|
+
* @param accountKey The vault key bytes (`deriveVaultKeyBytes(seed)`)
|
|
21
|
+
*/
|
|
22
|
+
export declare function deriveMemberKeyBytes(accountKey: Uint8Array, spaceId: string): Promise<Uint8Array>;
|
|
23
|
+
/** The public half, from the private scalar */
|
|
24
|
+
export declare function contactPublicKey(secret: Uint8Array): string;
|
|
25
|
+
/** Whether a string is a contact key's public half: a point on P-256 */
|
|
26
|
+
export declare function isContactPublicKey(value: unknown): value is string;
|
|
27
|
+
/** The key pair, from the private scalar (`deriveContactKeyBytes`) */
|
|
28
|
+
export declare function contactKeyPair(secret: Uint8Array): Promise<ContactKeyPair>;
|
|
29
|
+
/**
|
|
30
|
+
* Seals a value so only the holder of a contact key can open it.
|
|
31
|
+
* @param recipient The contact key's public half, as a profile carries it
|
|
32
|
+
* @param value Anything JSON can carry
|
|
33
|
+
* @param context What the sealed value belongs to; opening needs the same
|
|
34
|
+
* @returns base64url: the ephemeral public point, the IV, and the ciphertext
|
|
35
|
+
*/
|
|
36
|
+
export declare function sealFor(recipient: string, value: unknown, context: string): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* Opens what `sealFor` sealed.
|
|
39
|
+
* @returns The value, or null when it was not sealed for this key, or for this context, or was changed
|
|
40
|
+
*/
|
|
41
|
+
export declare function openSealed(privateKey: CryptoKey, sealed: string, context: string): Promise<unknown>;
|
|
42
|
+
//# sourceMappingURL=contact-key.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contact-key.d.ts","sourceRoot":"","sources":["../../src/identity/contact-key.ts"],"names":[],"mappings":"AAkCA,MAAM,WAAW,cAAc;IAC7B,oFAAoF;IACpF,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,wFAAwF;IACxF,QAAQ,CAAC,UAAU,EAAE,SAAS,CAAC;CAChC;AAYD;;;GAGG;AACH,wBAAsB,qBAAqB,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAEjF;AAED;;;;;;;;;GASG;AACH,wBAAsB,oBAAoB,CAAC,UAAU,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAEvG;AAED,+CAA+C;AAC/C,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAE3D;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAQlE;AAED,sEAAsE;AACtE,wBAAsB,cAAc,CAAC,MAAM,EAAE,UAAU,GAAG,OAAO,CAAC,cAAc,CAAC,CAiBhF;AASD;;;;;;GAMG;AACH,wBAAsB,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAqBjG;AAED;;;GAGG;AACH,wBAAsB,UAAU,CAAC,UAAU,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CA2BzG"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module identity/contact-key
|
|
3
|
+
* The contact key: what lets someone seal a message only you can open, while
|
|
4
|
+
* you are offline.
|
|
5
|
+
*
|
|
6
|
+
* It is a P-256 key pair for key agreement (ECDH), derived from the account's
|
|
7
|
+
* seed like the root key but under its own label — the same on every device,
|
|
8
|
+
* and back with the recovery code. It is not the root key: apps never hold the
|
|
9
|
+
* seed, and one key should not both sign and decrypt. It is not a session key
|
|
10
|
+
* either: those change every hour, and a request read next week would be
|
|
11
|
+
* locked to a key that is gone.
|
|
12
|
+
*
|
|
13
|
+
* The public half goes on the account's profile in every space it writes in
|
|
14
|
+
* (`sys.profile`). The private half stays with the account home, and goes to
|
|
15
|
+
* apps the person lets handle contacts.
|
|
16
|
+
*
|
|
17
|
+
* Sealing works like wrapping a space key (`privacy/key-distribution.ts`): a
|
|
18
|
+
* fresh key pair for each message, a secret shared with the recipient's
|
|
19
|
+
* public key, and AES-GCM. The `context` is bound in as additional data, so a
|
|
20
|
+
* sealed message moved anywhere its context no longer matches doesn't open.
|
|
21
|
+
*/
|
|
22
|
+
import { p256 } from '@noble/curves/nist.js';
|
|
23
|
+
import { base64UrlDecode, base64UrlEncode, utf8Decode, utf8Encode } from '../utils/encoding.js';
|
|
24
|
+
/** Domain separation for the contact key. Changing it changes every account's contact key. */
|
|
25
|
+
const CONTACT_KEY_INFO = 'weave/p256-contact-key/v1';
|
|
26
|
+
const SEAL_INFO = 'weave/contact-seal/v1';
|
|
27
|
+
const MEMBER_KEY_INFO = 'weave/p256-member-key/v1';
|
|
28
|
+
/** 48 bytes reduce to a P-256 scalar without bias, as for the root key (`crypto-p256.ts`) */
|
|
29
|
+
const P256_SEED_BYTES = 48;
|
|
30
|
+
const POINT_BYTES = 65;
|
|
31
|
+
const IV_BYTES = 12;
|
|
32
|
+
async function hkdf(ikm, info, length) {
|
|
33
|
+
const key = await globalThis.crypto.subtle.importKey('raw', ikm, 'HKDF', false, ['deriveBits']);
|
|
34
|
+
const bits = await globalThis.crypto.subtle.deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: utf8Encode(info) }, key, length * 8);
|
|
35
|
+
return new Uint8Array(bits);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The contact key's private scalar, from the account seed — 32 bytes. This is
|
|
39
|
+
* what an account home hands an app it lets handle contacts.
|
|
40
|
+
*/
|
|
41
|
+
export async function deriveContactKeyBytes(seed) {
|
|
42
|
+
return p256.utils.randomSecretKey(await hkdf(seed, CONTACT_KEY_INFO, P256_SEED_BYTES));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* An account's **member key** for one space: the same kind of key pair, derived
|
|
46
|
+
* from the account's vault key and the space id. When a private space's key
|
|
47
|
+
* changes, the new key is sealed to each member's member key (`sys.box`).
|
|
48
|
+
*
|
|
49
|
+
* One per space, not one per account, so an account home can hand an app the
|
|
50
|
+
* member keys for exactly the spaces it grants — and a new space key reaches
|
|
51
|
+
* that app without it holding anything that opens other spaces' keys.
|
|
52
|
+
* @param accountKey The vault key bytes (`deriveVaultKeyBytes(seed)`)
|
|
53
|
+
*/
|
|
54
|
+
export async function deriveMemberKeyBytes(accountKey, spaceId) {
|
|
55
|
+
return p256.utils.randomSecretKey(await hkdf(accountKey, `${MEMBER_KEY_INFO}|${spaceId}`, P256_SEED_BYTES));
|
|
56
|
+
}
|
|
57
|
+
/** The public half, from the private scalar */
|
|
58
|
+
export function contactPublicKey(secret) {
|
|
59
|
+
return base64UrlEncode(p256.getPublicKey(secret, true));
|
|
60
|
+
}
|
|
61
|
+
/** Whether a string is a contact key's public half: a point on P-256 */
|
|
62
|
+
export function isContactPublicKey(value) {
|
|
63
|
+
if (typeof value !== 'string' || value.length > 64)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
p256.Point.fromBytes(base64UrlDecode(value)).assertValidity();
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The key pair, from the private scalar (`deriveContactKeyBytes`) */
|
|
74
|
+
export async function contactKeyPair(secret) {
|
|
75
|
+
const point = p256.getPublicKey(secret, false);
|
|
76
|
+
const privateKey = await globalThis.crypto.subtle.importKey('jwk', {
|
|
77
|
+
kty: 'EC',
|
|
78
|
+
crv: 'P-256',
|
|
79
|
+
x: base64UrlEncode(point.subarray(1, 33)),
|
|
80
|
+
y: base64UrlEncode(point.subarray(33, 65)),
|
|
81
|
+
d: base64UrlEncode(secret),
|
|
82
|
+
ext: false,
|
|
83
|
+
}, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
|
|
84
|
+
return Object.freeze({ publicKey: contactPublicKey(secret), privateKey });
|
|
85
|
+
}
|
|
86
|
+
/** The AES key two sides agree on, bound to the ephemeral key it came from */
|
|
87
|
+
async function sealKey(shared, ephemeral, usage) {
|
|
88
|
+
const ikm = new Uint8Array([...new Uint8Array(shared), ...ephemeral]);
|
|
89
|
+
const material = await hkdf(ikm, SEAL_INFO, 32);
|
|
90
|
+
return globalThis.crypto.subtle.importKey('raw', material, { name: 'AES-GCM', length: 256 }, false, [usage]);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Seals a value so only the holder of a contact key can open it.
|
|
94
|
+
* @param recipient The contact key's public half, as a profile carries it
|
|
95
|
+
* @param value Anything JSON can carry
|
|
96
|
+
* @param context What the sealed value belongs to; opening needs the same
|
|
97
|
+
* @returns base64url: the ephemeral public point, the IV, and the ciphertext
|
|
98
|
+
*/
|
|
99
|
+
export async function sealFor(recipient, value, context) {
|
|
100
|
+
const recipientKey = await globalThis.crypto.subtle.importKey('raw', p256.Point.fromBytes(base64UrlDecode(recipient)).toBytes(false), { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
101
|
+
const ephemeral = await globalThis.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
|
102
|
+
const ephemeralPoint = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', ephemeral.publicKey));
|
|
103
|
+
const shared = await globalThis.crypto.subtle.deriveBits({ name: 'ECDH', public: recipientKey }, ephemeral.privateKey, 256);
|
|
104
|
+
const key = await sealKey(shared, ephemeralPoint, 'encrypt');
|
|
105
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_BYTES));
|
|
106
|
+
const ciphertext = new Uint8Array(await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv, additionalData: utf8Encode(context) }, key, utf8Encode(JSON.stringify(value))));
|
|
107
|
+
return base64UrlEncode(new Uint8Array([...ephemeralPoint, ...iv, ...ciphertext]));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Opens what `sealFor` sealed.
|
|
111
|
+
* @returns The value, or null when it was not sealed for this key, or for this context, or was changed
|
|
112
|
+
*/
|
|
113
|
+
export async function openSealed(privateKey, sealed, context) {
|
|
114
|
+
try {
|
|
115
|
+
const bytes = base64UrlDecode(sealed);
|
|
116
|
+
if (bytes.length <= POINT_BYTES + IV_BYTES)
|
|
117
|
+
return null;
|
|
118
|
+
const ephemeralPoint = bytes.subarray(0, POINT_BYTES);
|
|
119
|
+
const ephemeral = await globalThis.crypto.subtle.importKey('raw', ephemeralPoint, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
|
120
|
+
const shared = await globalThis.crypto.subtle.deriveBits({ name: 'ECDH', public: ephemeral }, privateKey, 256);
|
|
121
|
+
const key = await sealKey(shared, ephemeralPoint, 'decrypt');
|
|
122
|
+
const plain = await globalThis.crypto.subtle.decrypt({
|
|
123
|
+
name: 'AES-GCM',
|
|
124
|
+
iv: bytes.subarray(POINT_BYTES, POINT_BYTES + IV_BYTES),
|
|
125
|
+
additionalData: utf8Encode(context),
|
|
126
|
+
}, key, bytes.subarray(POINT_BYTES + IV_BYTES));
|
|
127
|
+
return JSON.parse(utf8Decode(new Uint8Array(plain)));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
//# sourceMappingURL=contact-key.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contact-key.js","sourceRoot":"","sources":["../../src/identity/contact-key.ts"],"names":[],"mappings":"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"}
|