@tokono.ma/firkin 0.3.0 → 0.4.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 CHANGED
@@ -47,13 +47,13 @@ signs directly); delegates mint with [`invocation()`](#delegation--sessions).
47
47
 
48
48
  ```ts
49
49
  // blobs — reads are authenticated automatically (read tokens)
50
- rb.upload(data, { codec?, hash?: "sha256" | "blake3" }) → { cid, deduplicated }
50
+ rb.upload(data, { codec?, hash?: "sha256" | "blake3", tags? }) → { cid, deduplicated }
51
51
  rb.get(cid) → Uint8Array
52
52
  rb.getRange(cid, { start?, end?, suffix? }) → { data, start, end, total }
53
53
 
54
54
  // claims (multi-account sharing of one stored object)
55
55
  rb.publishDelegation(d) // store a delegation so tokens can reference it by CID
56
- rb.claim(cid) // attach an already-stored CID to your account
56
+ rb.claim(cid, { tags? }) // attach an already-stored CID to your account
57
57
  rb.delete(cid) // detach; bytes die with the last claimant
58
58
 
59
59
  // name registry — private name → CID pointers (names imply no claim)
@@ -62,18 +62,40 @@ rb.registryGet(name) // null when unset
62
62
  rb.registryList() // Record<name, cid>
63
63
  rb.registryDelete(name)
64
64
 
65
+ // tags — per-account label groups over blobs (paged)
66
+ rb.tagAdd("photos", [cidA, cidB])
67
+ rb.tagGet("photos") // one page: { name, cids, count, cursor, next }
68
+ rb.tagAll("photos") // every CID in the group (walks the pages)
69
+ rb.blobTags(cidA) // ["photos"] — reverse lookup
70
+ rb.tagList() // [{ name, count }]
71
+ rb.tagRemove("photos", cidA)
72
+ rb.tagDelete("photos") // drop the group
73
+ rb.tagDeleteBlobs("photos") // drop the group AND detach its blobs
74
+
65
75
  // CAR Mirror (batch sync)
66
76
  rb.pushFile(chunks) // ordered chunks → dag-cbor list root
67
77
  rb.pullFile(root) // inverse of pushFile
68
78
  rb.dagPush(root, blocks) // an existing DAG, as-is: [cid, bytes][] | Map
69
79
  rb.dagPull(root) // Map<cid, bytes> — only the root is needed
70
80
  rb.ingestCar(carBytes) // rootless CARv1: verify + claim each block (no DAG)
81
+ rb.verifiedRange(cid, { start, end }) // BDASL: bao proof verified against the CID
71
82
 
72
83
  // account
73
- rb.revoke({ cids? , all? }) // kill delegations (account key only)
84
+ rb.revoke({ cids?, all? }, stepUpAssertion?) // kill grants (attested sessions need a fresh touch)
74
85
  rb.register(did, credential?) // admin: register a DID (+ passkey binding)
75
86
  rb.listAccounts() // admin
87
+ Firkin.attest(base, assertion) // passkey attestation → session binding / recovery grant
88
+ Firkin.attestPlc(base, { did, ops, commit, record, proof }) // did:plc enrolment
89
+ plcSessionRecord({ firkin, session, commands, expireIn? }) // the repo record to write
90
+ collectPlcAttestation({ agent, did, record }) // write + gather + encode
91
+ appealRequest({ firkin, expireIn?, nonce?, note? }) // ask to be registered (repo record)
92
+ appealRkey(firkin) // the instance host, used as rkey
93
+ normalizeOrigin(origin) // canonical origin for the audience field
76
94
  Firkin.whoami(base, assertion) // passkey re-attachment → account DID
95
+ sessionTokenSource(session, { account }) // attested-session token source
96
+ attestationChallenge({ firkin, account, session, commands, expireIn? })
97
+ stepUpChallenge({ firkin, account, action, expireIn? })
98
+ recoveryChallenge({ firkin, account, recovery, expireIn? })
77
99
  ```
78
100
 
79
101
  `dagPull` follows dag-cbor **and dag-pb** links server-side, so a WNFS-shaped
@@ -83,6 +105,12 @@ which is what makes it composable with other IPLD tooling and with
83
105
  registry entries that point at the real root. `pushFile` is sugar over
84
106
  `dagPush` for plain chunk lists.
85
107
 
108
+ `appealRequest` and `plcSessionRecord` normalize their `firkin` audience with
109
+ `normalizeOrigin` (lowercase scheme + host + non-default port, no trailing
110
+ slash or path). Firkin compares that field to `ORIGIN` exactly, and so do the
111
+ ATProto indexers that can discover appeals — use the same helper when you
112
+ compare an incoming record against your own origin.
113
+
86
114
  Errors throw an `Error` with `status` (the HTTP code) and the server's
87
115
  message — branch on `status` (401 unauthorized, 403 forbidden/revoked,
88
116
  404 not found, 413 too large, 422 digest mismatch).
@@ -114,35 +142,77 @@ await accountClient.revoke({ cids: [cid] }); // or { all: true }
114
142
  ```
115
143
 
116
144
  The server enforces that the chain cannot widen rights and that revocations
117
- are signed by the account key (`revoke` from a plain delegated session is
118
- refused — a stolen session must not be able to revoke).
145
+ are authorized by the account itself: the account key signs directly, while an
146
+ attested session must present a fresh passkey step-up assertion — a stolen
147
+ session key must not be able to revoke.
119
148
 
120
149
  ## Passkeys
121
150
 
151
+ All four identity models — seed keys, regular passkeys, PRF passkeys and
152
+ `did:plc` — are compared, with code for each, in
153
+ [`docs/ACCOUNTS.md`](https://tangled.org/tokono.ma/firkin/blob/main/docs/ACCOUNTS.md).
154
+ The short version follows.
155
+
122
156
  `WebCryptoSigner` / `WebCryptoP256Signer` hold **non-extractable** keys
123
157
  (structured-cloneable into IndexedDB: scripts can sign, never read).
124
- Passkey-native accounts — the credential key *is* the account — sign
125
- delegations with a WebAuthn assertion:
158
+ Passkey-native accounts — the credential key *is* the account — authorise a
159
+ session with a WebAuthn **attestation**, verified once by the server. The
160
+ assertion is never a UCAN proof: requests are ordinary self-issued
161
+ invocations by the session key, carrying `meta["firkin/account"]`.
126
162
 
127
163
  ```ts
128
- import { assertionToProof, delegationPayloadBytes } from "@tokono.ma/firkin";
129
-
130
- const payload = delegationPayloadBytes({
131
- issuerDid: passkeyDid, subject: passkeyDid, audience: session.did(),
132
- command: "/firkin/blob", expireIn: 8 * 3600,
164
+ import { attestationChallenge, sessionTokenSource, Firkin } from "@tokono.ma/firkin";
165
+
166
+ const challenge = attestationChallenge({
167
+ firkin: "http://localhost:8787", // the API origin
168
+ account: passkeyDid,
169
+ session: session.did(),
170
+ commands: ["/firkin/blob"],
171
+ expireIn: 8 * 3600,
133
172
  });
134
173
  const assertion = await navigator.credentials.get({
135
- publicKey: { challenge: base64url(payload), rpId, userVerification: "required" },
174
+ publicKey: { challenge, rpId, userVerification: "required" },
136
175
  });
137
- const proof = assertionToProof(payload, {
176
+
177
+ const attestation = await Firkin.attest("http://localhost:8787", {
138
178
  authData: /* bytes */, clientDataJSON: /* exact JSON text */, signature: /* bytes */,
139
179
  });
180
+
181
+ // From here on the session signs directly. It can be revoked by CID.
182
+ const rb = new Firkin(base, sessionTokenSource(session, { account: passkeyDid }));
140
183
  ```
141
184
 
185
+ Revocation and recovery need a fresh touch: `stepUpChallenge(...)` beside an
186
+ ordinary session invocation (`rb.revoke(body, assertion)`), and
187
+ `recoveryChallenge(...)` to install a backup identity.
142
188
  `Firkin.whoami` resolves a discoverable-credential assertion back to its
143
189
  account DID on a fresh browser. Working example:
144
190
  [`examples/passkey/`](https://tangled.org/tokono.ma/firkin/tree/main/examples/passkey).
145
191
 
192
+ `did:plc` accounts use the same session model with a repo record as the proof.
193
+ One call does the write and the gather:
194
+
195
+ ```ts
196
+ import { collectPlcAttestation, plcSessionRecord } from "@tokono.ma/firkin";
197
+
198
+ const material = await collectPlcAttestation({
199
+ agent, // an @atproto/api Agent
200
+ did: account, // did:plc:…
201
+ record: plcSessionRecord({
202
+ firkin: origin, session: session.did(),
203
+ commands: ["/firkin/blob", "/firkin/account/revoke"],
204
+ expireIn: 30 * 24 * 3600,
205
+ }),
206
+ });
207
+ await Firkin.attestPlc(origin, material);
208
+ // then: new Firkin(origin, sessionTokenSource(session, { account }))
209
+ ```
210
+
211
+ `collectPlcAttestation` writes the record via your ATProto agent, reads the PLC
212
+ log, fetches the signed commit and the MST proof, and retries if the repo
213
+ advanced between fetches. Everything it produces is exactly what
214
+ `Firkin.attestPlc` posts; you can also assemble that material yourself.
215
+
146
216
  ## Design notes
147
217
 
148
218
  **Minting is a shim, on purpose.** `ucan.ts` only assembles the wire format;
package/dist/bao.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** BDASL verifies in 1 KiB chunks ("rounds to the nearest kilobyte"). */
2
+ export declare const BAO_CHUNK_SIZE = 1024;
3
+ /** Number of 1 KiB chunks (an empty blob still has one chunk). */
4
+ export declare function baoChunkCount(size: number): number;
5
+ /** The BLAKE3 root of arbitrary data, computed with the verified-streaming
6
+ * tree (exposed for tests; equals `blake3(data)`). */
7
+ export declare function blake3TreeRoot(data: Uint8Array): Uint8Array;
8
+ export interface BaoRangeInput {
9
+ /** The bao-encoded proof + chunk bytes. */
10
+ encoded: Uint8Array;
11
+ /** The CID's 32-byte digest — the BLAKE3 root to verify against. */
12
+ root: Uint8Array;
13
+ /** Total blob size in bytes (from the server, but bound by the proof). */
14
+ size: number;
15
+ /** First chunk to read (inclusive). */
16
+ chunkStart: number;
17
+ /** One past the last chunk to read (exclusive). */
18
+ chunkEnd: number;
19
+ }
20
+ /**
21
+ * Decode and verify a bao proof for a chunk range. Throws on any proof
22
+ * mismatch, truncation, or when the root does not match. Returns the
23
+ * chunk-aligned data starting at `chunkStart * 1024`.
24
+ */
25
+ export declare function decodeBaoRange(input: BaoRangeInput): Uint8Array;
package/dist/bao.js ADDED
@@ -0,0 +1,225 @@
1
+ // BDASL verified streaming (bao): verify a BLAKE3 hash-tree proof so a byte
2
+ // range can be trusted without trusting the server.
3
+ //
4
+ // Wire format (as produced by `bao-tree` / iroh, block size 1 KiB, pre-order):
5
+ // - an internal node intersecting the requested range contributes 64 bytes:
6
+ // `left_cv ‖ right_cv` (the two child chaining values),
7
+ // - a requested leaf chunk contributes its raw bytes (≤ 1024, the last chunk
8
+ // may be shorter).
9
+ // The verifier recomputes chunk CVs, folds them up, and checks that the
10
+ // ROOT-flagged root equals the CID's digest (which *is* the BLAKE3 root).
11
+ /** BDASL verifies in 1 KiB chunks ("rounds to the nearest kilobyte"). */
12
+ export const BAO_CHUNK_SIZE = 1024;
13
+ const IV = new Uint32Array([
14
+ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c,
15
+ 0x1f83d9ab, 0x5be0cd19,
16
+ ]);
17
+ const MSG_PERMUTATION = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8];
18
+ const CHUNK_START = 1;
19
+ const CHUNK_END = 2;
20
+ const PARENT = 4;
21
+ const ROOT = 8;
22
+ function rotr(value, bits) {
23
+ return ((value >>> bits) | (value << (32 - bits))) >>> 0;
24
+ }
25
+ function g(v, a, b, c, d, mx, my) {
26
+ v[a] = (v[a] + v[b] + mx) >>> 0;
27
+ v[d] = rotr(v[d] ^ v[a], 16);
28
+ v[c] = (v[c] + v[d]) >>> 0;
29
+ v[b] = rotr(v[b] ^ v[c], 12);
30
+ v[a] = (v[a] + v[b] + my) >>> 0;
31
+ v[d] = rotr(v[d] ^ v[a], 8);
32
+ v[c] = (v[c] + v[d]) >>> 0;
33
+ v[b] = rotr(v[b] ^ v[c], 7);
34
+ }
35
+ function round(v, m) {
36
+ g(v, 0, 4, 8, 12, m[0], m[1]);
37
+ g(v, 1, 5, 9, 13, m[2], m[3]);
38
+ g(v, 2, 6, 10, 14, m[4], m[5]);
39
+ g(v, 3, 7, 11, 15, m[6], m[7]);
40
+ g(v, 0, 5, 10, 15, m[8], m[9]);
41
+ g(v, 1, 6, 11, 12, m[10], m[11]);
42
+ g(v, 2, 7, 8, 13, m[12], m[13]);
43
+ g(v, 3, 4, 9, 14, m[14], m[15]);
44
+ }
45
+ /** BLAKE3 compression; returns the 8-word chaining value (32 bytes). */
46
+ function compress(cv, block, counter, blockLen, flags) {
47
+ const v = new Uint32Array(16);
48
+ v.set(cv.subarray(0, 8), 0);
49
+ v[8] = IV[0];
50
+ v[9] = IV[1];
51
+ v[10] = IV[2];
52
+ v[11] = IV[3];
53
+ v[12] = counter >>> 0;
54
+ v[13] = Math.floor(counter / 0x1_0000_0000) >>> 0;
55
+ v[14] = blockLen;
56
+ v[15] = flags;
57
+ const m = Uint32Array.from(block);
58
+ for (let r = 0; r < 7; r++) {
59
+ round(v, m);
60
+ if (r < 6) {
61
+ const permuted = new Uint32Array(16);
62
+ for (let i = 0; i < 16; i++)
63
+ permuted[i] = m[MSG_PERMUTATION[i]];
64
+ m.set(permuted);
65
+ }
66
+ }
67
+ const out = new Uint32Array(8);
68
+ for (let i = 0; i < 8; i++)
69
+ out[i] = (v[i] ^ v[i + 8]) >>> 0;
70
+ return out;
71
+ }
72
+ function wordsToBytes(words) {
73
+ const out = new Uint8Array(words.length * 4);
74
+ for (let i = 0; i < words.length; i++) {
75
+ const word = words[i];
76
+ out[i * 4] = word & 0xff;
77
+ out[i * 4 + 1] = (word >>> 8) & 0xff;
78
+ out[i * 4 + 2] = (word >>> 16) & 0xff;
79
+ out[i * 4 + 3] = (word >>> 24) & 0xff;
80
+ }
81
+ return out;
82
+ }
83
+ function bytesToWords(bytes) {
84
+ const words = new Uint32Array(bytes.length / 4);
85
+ for (let i = 0; i < words.length; i++) {
86
+ words[i] =
87
+ (bytes[i * 4] |
88
+ (bytes[i * 4 + 1] << 8) |
89
+ (bytes[i * 4 + 2] << 16) |
90
+ (bytes[i * 4 + 3] << 24)) >>>
91
+ 0;
92
+ }
93
+ return words;
94
+ }
95
+ /** The chaining value of one chunk (chunk counter = its index). */
96
+ function chunkCv(chunk, counter, root) {
97
+ let cv = Uint32Array.from(IV);
98
+ const blocks = Math.max(1, Math.ceil(chunk.length / 64));
99
+ for (let b = 0; b < blocks; b++) {
100
+ const start = b * 64;
101
+ const len = Math.min(64, chunk.length - start);
102
+ const block = new Uint32Array(16);
103
+ for (let i = 0; i < len; i++) {
104
+ block[i >> 2] = block[i >> 2] | (chunk[start + i] << ((i & 3) * 8));
105
+ }
106
+ let flags = 0;
107
+ if (b === 0)
108
+ flags |= CHUNK_START;
109
+ if (b === blocks - 1) {
110
+ flags |= CHUNK_END;
111
+ if (root)
112
+ flags |= ROOT;
113
+ }
114
+ cv = compress(cv, block, counter, len, flags);
115
+ }
116
+ return wordsToBytes(cv);
117
+ }
118
+ function parentCv(left, right, root) {
119
+ const block = new Uint32Array(16);
120
+ block.set(bytesToWords(left), 0);
121
+ block.set(bytesToWords(right), 8);
122
+ return wordsToBytes(compress(Uint32Array.from(IV), block, 0, 64, PARENT | (root ? ROOT : 0)));
123
+ }
124
+ /** Number of 1 KiB chunks (an empty blob still has one chunk). */
125
+ export function baoChunkCount(size) {
126
+ return Math.max(1, Math.ceil(size / BAO_CHUNK_SIZE));
127
+ }
128
+ /** Largest power of two strictly smaller than `n` (the BLAKE3 split rule). */
129
+ function splitLeft(n) {
130
+ let p = 1;
131
+ while (p * 2 < n)
132
+ p *= 2;
133
+ return p;
134
+ }
135
+ function chunkLen(size, chunk) {
136
+ return Math.min(BAO_CHUNK_SIZE, size - chunk * BAO_CHUNK_SIZE);
137
+ }
138
+ /** The BLAKE3 root of arbitrary data, computed with the verified-streaming
139
+ * tree (exposed for tests; equals `blake3(data)`). */
140
+ export function blake3TreeRoot(data) {
141
+ const size = data.length;
142
+ const visit = (start, len, root) => {
143
+ if (len === 1) {
144
+ const offset = start * BAO_CHUNK_SIZE;
145
+ return chunkCv(data.subarray(offset, offset + chunkLen(size, start)), start, root);
146
+ }
147
+ const left = splitLeft(len);
148
+ return parentCv(visit(start, left, false), visit(start + left, len - left, false), root);
149
+ };
150
+ return visit(0, baoChunkCount(size), true);
151
+ }
152
+ function equalBytes(a, b) {
153
+ if (a.length !== b.length)
154
+ return false;
155
+ for (let i = 0; i < a.length; i++)
156
+ if (a[i] !== b[i])
157
+ return false;
158
+ return true;
159
+ }
160
+ /**
161
+ * Decode and verify a bao proof for a chunk range. Throws on any proof
162
+ * mismatch, truncation, or when the root does not match. Returns the
163
+ * chunk-aligned data starting at `chunkStart * 1024`.
164
+ */
165
+ export function decodeBaoRange(input) {
166
+ const { encoded, root, size } = input;
167
+ const total = baoChunkCount(size);
168
+ const chunkStart = Math.max(0, Math.min(input.chunkStart, total - 1));
169
+ const chunkEnd = Math.max(chunkStart + 1, Math.min(input.chunkEnd, total));
170
+ if (root.length !== 32)
171
+ throw new Error("bao: root must be 32 bytes");
172
+ let pos = 0;
173
+ const read = (n) => {
174
+ if (pos + n > encoded.length)
175
+ throw new Error("bao: truncated proof");
176
+ const slice = encoded.subarray(pos, pos + n);
177
+ pos += n;
178
+ return slice;
179
+ };
180
+ const chunks = [];
181
+ const intersects = (start, len) => start < chunkEnd && start + len > chunkStart;
182
+ const visit = (start, len, isRoot) => {
183
+ if (len === 1) {
184
+ const data = read(chunkLen(size, start));
185
+ chunks.push(data);
186
+ return chunkCv(data, start, isRoot);
187
+ }
188
+ const pair = read(64);
189
+ const leftHash = pair.subarray(0, 32);
190
+ const rightHash = pair.subarray(32, 64);
191
+ const leftLen = splitLeft(len);
192
+ const leftSelected = intersects(start, leftLen);
193
+ const rightSelected = intersects(start + leftLen, len - leftLen);
194
+ let leftCv;
195
+ if (leftSelected) {
196
+ leftCv = visit(start, leftLen, false);
197
+ if (!equalBytes(leftCv, leftHash))
198
+ throw new Error("bao: left subtree mismatch");
199
+ }
200
+ else {
201
+ leftCv = leftHash;
202
+ }
203
+ let rightCv;
204
+ if (rightSelected) {
205
+ rightCv = visit(start + leftLen, len - leftLen, false);
206
+ if (!equalBytes(rightCv, rightHash))
207
+ throw new Error("bao: right subtree mismatch");
208
+ }
209
+ else {
210
+ rightCv = rightHash;
211
+ }
212
+ return parentCv(leftCv, rightCv, isRoot);
213
+ };
214
+ const computed = visit(0, total, true);
215
+ if (!equalBytes(computed, root)) {
216
+ throw new Error("bao: proof does not match the CID (root mismatch)");
217
+ }
218
+ const assembled = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0));
219
+ let at = 0;
220
+ for (const chunk of chunks) {
221
+ assembled.set(chunk, at);
222
+ at += chunk.length;
223
+ }
224
+ return assembled;
225
+ }