@tokono.ma/firkin 0.2.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
@@ -46,14 +46,14 @@ signs directly); delegates mint with [`invocation()`](#delegation--sessions).
46
46
  ## API
47
47
 
48
48
  ```ts
49
- // blobs — reads are authenticated automatically (for PUBLIC_READS=false)
50
- rb.upload(data, { codec?, hash?: "sha256" | "blake3" }) → { cid, deduplicated }
49
+ // blobs — reads are authenticated automatically (read tokens)
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,17 +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
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
70
82
 
71
83
  // account
72
- rb.revoke({ cids? , all? }) // kill delegations (account key only)
84
+ rb.revoke({ cids?, all? }, stepUpAssertion?) // kill grants (attested sessions need a fresh touch)
73
85
  rb.register(did, credential?) // admin: register a DID (+ passkey binding)
74
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
75
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? })
76
99
  ```
77
100
 
78
101
  `dagPull` follows dag-cbor **and dag-pb** links server-side, so a WNFS-shaped
@@ -82,6 +105,12 @@ which is what makes it composable with other IPLD tooling and with
82
105
  registry entries that point at the real root. `pushFile` is sugar over
83
106
  `dagPush` for plain chunk lists.
84
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
+
85
114
  Errors throw an `Error` with `status` (the HTTP code) and the server's
86
115
  message — branch on `status` (401 unauthorized, 403 forbidden/revoked,
87
116
  404 not found, 413 too large, 422 digest mismatch).
@@ -113,35 +142,77 @@ await accountClient.revoke({ cids: [cid] }); // or { all: true }
113
142
  ```
114
143
 
115
144
  The server enforces that the chain cannot widen rights and that revocations
116
- are signed by the account key (`revoke` from a plain delegated session is
117
- 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.
118
148
 
119
149
  ## Passkeys
120
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
+
121
156
  `WebCryptoSigner` / `WebCryptoP256Signer` hold **non-extractable** keys
122
157
  (structured-cloneable into IndexedDB: scripts can sign, never read).
123
- Passkey-native accounts — the credential key *is* the account — sign
124
- 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"]`.
125
162
 
126
163
  ```ts
127
- import { assertionToProof, delegationPayloadBytes } from "@tokono.ma/firkin";
128
-
129
- const payload = delegationPayloadBytes({
130
- issuerDid: passkeyDid, subject: passkeyDid, audience: session.did(),
131
- 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,
132
172
  });
133
173
  const assertion = await navigator.credentials.get({
134
- publicKey: { challenge: base64url(payload), rpId, userVerification: "required" },
174
+ publicKey: { challenge, rpId, userVerification: "required" },
135
175
  });
136
- const proof = assertionToProof(payload, {
176
+
177
+ const attestation = await Firkin.attest("http://localhost:8787", {
137
178
  authData: /* bytes */, clientDataJSON: /* exact JSON text */, signature: /* bytes */,
138
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 }));
139
183
  ```
140
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.
141
188
  `Firkin.whoami` resolves a discoverable-credential assertion back to its
142
189
  account DID on a fresh browser. Working example:
143
190
  [`examples/passkey/`](https://tangled.org/tokono.ma/firkin/tree/main/examples/passkey).
144
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
+
145
216
  ## Design notes
146
217
 
147
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
+ }