@askalf/dario 3.29.0 → 3.30.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.
@@ -1,416 +0,0 @@
1
- /**
2
- * Sealed-sender overflow pool — RSA blind signatures for unlinkable capacity
3
- * sharing inside a trust group.
4
- *
5
- * The problem this solves: in a federated pool where several friends lend
6
- * each other account capacity, a naive design leaks who is borrowing what.
7
- * If member A sends "I'm borrowing from your pool" to member B's dario
8
- * instance, B learns exactly which of their friends is running which
9
- * workload — and over time B can build a pretty detailed surveillance log
10
- * of everyone else's agent sessions. That's the opposite of what a private
11
- * friend pool should provide.
12
- *
13
- * The solution is Chaum's 1983 blind signature construction. A trusted
14
- * admin (one member of the group, selected by social consensus) issues
15
- * signed borrow tokens to each member. The admin never sees the token
16
- * values — they sign blinded values, and the blinding is unlinkable at
17
- * the cryptographic level. When a member sends a token to a lender, the
18
- * lender can verify "this was signed by the group admin" without learning
19
- * WHICH member holds that token. The lender sees a valid group credential
20
- * and nothing more.
21
- *
22
- * From Anthropic's perspective nothing changes: the request still hits
23
- * their API under the lender's identity, fully attributable to a real
24
- * paying Max subscriber. The privacy property is entirely INSIDE the
25
- * trust group — no member can surveil another member's usage through
26
- * the pool layer.
27
- *
28
- * What this is NOT: this is not anonymity from Anthropic, not onion
29
- * routing, not credential laundering. It is a privacy layer on top of
30
- * a legitimate friends-pool arrangement. Members opt in, the admin is
31
- * known, membership is revocable by rotating the group key. It's the
32
- * same trust model as a family Netflix account, with unlinkability as
33
- * a feature for the pool's internal telemetry.
34
- *
35
- * Implementation notes:
36
- * - RSA-2048 with FDH (full-domain hash) padding via MGF1-SHA256.
37
- * - Node's crypto.publicEncrypt / privateDecrypt with RSA_NO_PADDING
38
- * for raw RSA operations. All modular arithmetic happens in BigInt.
39
- * - Tokens are 32 random bytes each, single-use (lender tracks SHA-256
40
- * hashes of seen tokens to prevent double-spend).
41
- * - Admin does not need to be online for members to use tokens. Admin
42
- * only runs when issuing a new batch (typically once per day/week).
43
- */
44
- import { generateKeyPairSync, publicEncrypt, privateDecrypt, constants, createPublicKey, randomBytes, createHash, } from 'node:crypto';
45
- // ======================================================================
46
- // BigInt / buffer helpers
47
- // ======================================================================
48
- function bigintToBytes(n, len) {
49
- let hex = n.toString(16);
50
- if (hex.length % 2)
51
- hex = '0' + hex;
52
- const buf = Buffer.from(hex, 'hex');
53
- if (buf.length > len)
54
- throw new Error('value too large for buffer');
55
- if (buf.length === len)
56
- return buf;
57
- const padded = Buffer.alloc(len);
58
- buf.copy(padded, len - buf.length);
59
- return padded;
60
- }
61
- function bytesToBigint(buf) {
62
- if (buf.length === 0)
63
- return 0n;
64
- return BigInt('0x' + buf.toString('hex'));
65
- }
66
- function egcd(a, b) {
67
- let [oldR, r] = [a, b];
68
- let [oldS, s] = [1n, 0n];
69
- let [oldT, t] = [0n, 1n];
70
- while (r !== 0n) {
71
- const q = oldR / r;
72
- [oldR, r] = [r, oldR - q * r];
73
- [oldS, s] = [s, oldS - q * s];
74
- [oldT, t] = [t, oldT - q * t];
75
- }
76
- return [oldR, oldS, oldT];
77
- }
78
- function modInverse(a, n) {
79
- const norm = ((a % n) + n) % n;
80
- const [g, x] = egcd(norm, n);
81
- if (g !== 1n)
82
- throw new Error('no modular inverse');
83
- return ((x % n) + n) % n;
84
- }
85
- // ======================================================================
86
- // Full-domain hash (FDH) for RSA blind signatures
87
- // ======================================================================
88
- /**
89
- * Map a message to a uniformly-distributed integer in [1, n). Standard
90
- * FDH construction via MGF1-SHA256 with a counter-based retry loop to
91
- * handle the edge case where the candidate is ≥ n.
92
- *
93
- * Why FDH matters: without it, RSA signatures are vulnerable to
94
- * multiplicative forgery attacks (signatures can be combined to forge
95
- * signatures on products of messages). FDH destroys the algebraic
96
- * structure of the message so no such combination exists.
97
- */
98
- function fdh(message, modulus, modulusBytes) {
99
- for (let counter = 0; counter < 1000; counter++) {
100
- const out = Buffer.alloc(modulusBytes);
101
- let written = 0;
102
- let i = 0;
103
- while (written < modulusBytes) {
104
- const hash = createHash('sha256');
105
- hash.update(message);
106
- const counterBuf = Buffer.alloc(4);
107
- counterBuf.writeUInt32BE(counter, 0);
108
- hash.update(counterBuf);
109
- const iBuf = Buffer.alloc(4);
110
- iBuf.writeUInt32BE(i, 0);
111
- hash.update(iBuf);
112
- const digest = hash.digest();
113
- const take = Math.min(digest.length, modulusBytes - written);
114
- digest.copy(out, written, 0, take);
115
- written += take;
116
- i++;
117
- }
118
- // Clear the top bit to reduce the chance of value ≥ n on the first try.
119
- out[0] &= 0x7f;
120
- const candidate = bytesToBigint(out);
121
- if (candidate > 0n && candidate < modulus)
122
- return candidate;
123
- }
124
- throw new Error('FDH: exhausted counter without finding candidate');
125
- }
126
- function rawPublicOp(key, value) {
127
- const input = bigintToBytes(value, key.modulusBytes);
128
- const output = publicEncrypt({ key: key.keyObj, padding: constants.RSA_NO_PADDING }, input);
129
- return bytesToBigint(output);
130
- }
131
- function rawPrivateOp(key, value) {
132
- const input = bigintToBytes(value, key.modulusBytes);
133
- const output = privateDecrypt({ key: key.keyObjPriv, padding: constants.RSA_NO_PADDING }, input);
134
- return bytesToBigint(output);
135
- }
136
- // ======================================================================
137
- // Blind signature protocol
138
- // ======================================================================
139
- /**
140
- * Blind a token: pick r ∈ [2, n), compute blinded = FDH(token) · r^e mod n.
141
- * The admin sees only the blinded value (uniform over Z_n*) and learns
142
- * nothing about the token.
143
- */
144
- export function blindToken(tokenBytes, pubKey) {
145
- const m = fdh(tokenBytes, pubKey.n, pubKey.modulusBytes);
146
- let r = 0n;
147
- for (let attempts = 0; attempts < 32; attempts++) {
148
- const rBytes = randomBytes(pubKey.modulusBytes);
149
- rBytes[0] &= 0x7f;
150
- const candidate = bytesToBigint(rBytes);
151
- if (candidate > 1n && candidate < pubKey.n) {
152
- r = candidate;
153
- break;
154
- }
155
- }
156
- if (r === 0n)
157
- throw new Error('blindToken: failed to sample r');
158
- const rE = rawPublicOp(pubKey, r);
159
- const blinded = (m * rE) % pubKey.n;
160
- return { blinded, r };
161
- }
162
- /** Admin-side: sign a blinded value. No knowledge of the original token. */
163
- export function signBlinded(blinded, privKey) {
164
- return rawPrivateOp(privKey, blinded);
165
- }
166
- /**
167
- * Member-side: given the admin's signature on the blinded value, remove
168
- * the blinding factor to obtain a raw RSA-FDH signature over the original
169
- * token that the admin never saw.
170
- *
171
- * Math: signed_blinded = (FDH(t) · r^e)^d = FDH(t)^d · r mod n.
172
- * Multiplying by r^(-1) mod n yields FDH(t)^d = the raw signature.
173
- */
174
- export function unblindSignature(blindedSignature, r, pubKey) {
175
- const rInv = modInverse(r, pubKey.n);
176
- return (blindedSignature * rInv) % pubKey.n;
177
- }
178
- /**
179
- * Verify a (token, signature) pair against the admin's public key.
180
- * True iff signature^e ≡ FDH(token) (mod n).
181
- */
182
- export function verifyTokenSignature(tokenBytes, signature, pubKey) {
183
- if (signature <= 0n || signature >= pubKey.n)
184
- return false;
185
- const expected = fdh(tokenBytes, pubKey.n, pubKey.modulusBytes);
186
- const actual = rawPublicOp(pubKey, signature);
187
- return expected === actual;
188
- }
189
- // ======================================================================
190
- // Key generation and export/import
191
- // ======================================================================
192
- export function generateGroupKey(bits = 2048) {
193
- const { publicKey, privateKey } = generateKeyPairSync('rsa', {
194
- modulusLength: bits,
195
- publicExponent: 65537,
196
- });
197
- const jwk = publicKey.export({ format: 'jwk' });
198
- const n = bytesToBigint(Buffer.from(jwk.n, 'base64url'));
199
- const e = bytesToBigint(Buffer.from(jwk.e, 'base64url'));
200
- const modulusBytes = Math.ceil(bits / 8);
201
- return {
202
- n, e, modulusBytes,
203
- keyObj: publicKey,
204
- keyObjPriv: privateKey,
205
- };
206
- }
207
- export function exportGroupPublicKey(key) {
208
- return {
209
- n: key.n.toString(16),
210
- e: key.e.toString(16),
211
- modulusBytes: key.modulusBytes,
212
- };
213
- }
214
- export function importGroupPublicKey(exported) {
215
- const n = BigInt('0x' + exported.n);
216
- const e = BigInt('0x' + exported.e);
217
- const nBytes = bigintToBytes(n, exported.modulusBytes);
218
- let eHex = e.toString(16);
219
- if (eHex.length % 2)
220
- eHex = '0' + eHex;
221
- const eBytes = Buffer.from(eHex, 'hex');
222
- const keyObj = createPublicKey({
223
- key: {
224
- kty: 'RSA',
225
- n: nBytes.toString('base64url'),
226
- e: eBytes.toString('base64url'),
227
- },
228
- format: 'jwk',
229
- });
230
- return { n, e, modulusBytes: exported.modulusBytes, keyObj };
231
- }
232
- /**
233
- * Admin holds the group private key and a roster of authorized members.
234
- * Admin does NOT hold any of the tokens, by design — blind signing means
235
- * the admin never sees what they signed. This is the key privacy property.
236
- *
237
- * The admin's responsibilities are purely social: decide who's in the
238
- * group, set per-member quotas, rotate the group key when someone leaves.
239
- */
240
- export class GroupAdmin {
241
- groupId;
242
- key;
243
- members;
244
- constructor(groupId, key, members) {
245
- this.groupId = groupId;
246
- this.key = key;
247
- this.members = members;
248
- }
249
- static create(groupId, bits = 2048) {
250
- return new GroupAdmin(groupId, generateGroupKey(bits), new Map());
251
- }
252
- addMember(pubkey, quotaPerBatch = 100, validForDays = 365) {
253
- this.members.set(pubkey, {
254
- pubkey,
255
- expiresAt: Date.now() + validForDays * 86400_000,
256
- quotaPerBatch,
257
- });
258
- }
259
- removeMember(pubkey) {
260
- return this.members.delete(pubkey);
261
- }
262
- /**
263
- * Sign a batch of blinded tokens submitted by a member. The admin
264
- * authenticates the request out-of-band (member identity auth happens
265
- * at the HTTP layer via a member signing key — not modelled here).
266
- *
267
- * Throws on: unknown member, expired membership, batch-too-large.
268
- */
269
- signBatch(memberPubkey, blinded) {
270
- const member = this.members.get(memberPubkey);
271
- if (!member)
272
- throw new Error(`unknown member: ${memberPubkey.slice(0, 16)}...`);
273
- if (member.expiresAt < Date.now())
274
- throw new Error('member membership expired');
275
- if (blinded.length > member.quotaPerBatch) {
276
- throw new Error(`batch size ${blinded.length} exceeds quota ${member.quotaPerBatch}`);
277
- }
278
- return blinded.map((b) => signBlinded(b, this.key));
279
- }
280
- publicKey() {
281
- return exportGroupPublicKey(this.key);
282
- }
283
- }
284
- /**
285
- * Member holds an identity pubkey (used by the admin for roster lookup)
286
- * and a local stash of unused (token, signature) pairs. Tokens are single-
287
- * use — consume one per borrow. Admin never saw any of these tokens.
288
- */
289
- export class GroupMember {
290
- memberPubkey;
291
- groupPublicKey;
292
- tokens = [];
293
- constructor(memberPubkey, groupPublicKey) {
294
- this.memberPubkey = memberPubkey;
295
- this.groupPublicKey = groupPublicKey;
296
- }
297
- /**
298
- * Step 1 of a token batch: generate random tokens, blind each, return
299
- * the blinded values (to send to admin) plus the per-token state
300
- * (kept locally for unblinding after admin responds).
301
- */
302
- prepareBatch(count) {
303
- const blinded = [];
304
- const state = [];
305
- for (let i = 0; i < count; i++) {
306
- const token = randomBytes(32);
307
- const { blinded: b, r } = blindToken(token, this.groupPublicKey);
308
- blinded.push(b);
309
- state.push({ token, r });
310
- }
311
- return { blinded, state };
312
- }
313
- /**
314
- * Step 2 of a token batch: unblind each admin-signed value, verify the
315
- * resulting raw signature, and add the (token, signature) pair to the
316
- * local stash. Any verification failure throws — we never store a token
317
- * whose signature doesn't check out.
318
- */
319
- finalizeBatch(signedBlinded, state) {
320
- if (signedBlinded.length !== state.length) {
321
- throw new Error('finalizeBatch: length mismatch');
322
- }
323
- const toStore = [];
324
- for (let i = 0; i < signedBlinded.length; i++) {
325
- const signature = unblindSignature(signedBlinded[i], state[i].r, this.groupPublicKey);
326
- if (!verifyTokenSignature(state[i].token, signature, this.groupPublicKey)) {
327
- throw new Error(`finalizeBatch: signature ${i} failed verification`);
328
- }
329
- toStore.push({ token: state[i].token, signature });
330
- }
331
- this.tokens.push(...toStore);
332
- }
333
- consumeToken() {
334
- return this.tokens.shift() ?? null;
335
- }
336
- tokenCount() {
337
- return this.tokens.length;
338
- }
339
- }
340
- /**
341
- * Lender holds the group public key and a set of token hashes that have
342
- * already been redeemed. Memory-only in v1; a persisted set would be a
343
- * sqlite table keyed on token hash, or just a file of sha256 hex lines.
344
- *
345
- * The lender LEARNS NOTHING about which member borrowed. That's the
346
- * whole point — blind signatures decouple "who signed this request"
347
- * (the admin, uniformly) from "who holds the token" (one specific
348
- * member who is anonymous to the lender).
349
- */
350
- export class GroupLender {
351
- groupId;
352
- groupPublicKey;
353
- seenTokens = new Set();
354
- maxSeenTokens;
355
- constructor(groupId, groupPublicKey, opts = {}) {
356
- this.groupId = groupId;
357
- this.groupPublicKey = groupPublicKey;
358
- this.maxSeenTokens = opts.maxSeenTokens ?? 100_000;
359
- }
360
- acceptBorrow(token, signature) {
361
- if (!verifyTokenSignature(token, signature, this.groupPublicKey)) {
362
- return { ok: false, reason: 'invalid_signature' };
363
- }
364
- const hash = createHash('sha256').update(token).digest('hex');
365
- if (this.seenTokens.has(hash)) {
366
- return { ok: false, reason: 'double_spend' };
367
- }
368
- this.seenTokens.add(hash);
369
- if (this.seenTokens.size > this.maxSeenTokens) {
370
- const oldest = this.seenTokens.values().next().value;
371
- if (oldest)
372
- this.seenTokens.delete(oldest);
373
- }
374
- return { ok: true };
375
- }
376
- seenCount() {
377
- return this.seenTokens.size;
378
- }
379
- }
380
- export function encodeBorrowEnvelope(groupId, bt, request) {
381
- const env = {
382
- v: 1,
383
- groupId,
384
- token: bt.token.toString('base64url'),
385
- sig: bt.signature.toString(16),
386
- request,
387
- };
388
- return JSON.stringify(env);
389
- }
390
- export function decodeBorrowEnvelope(s) {
391
- try {
392
- const obj = JSON.parse(s);
393
- if (obj?.v !== 1 ||
394
- typeof obj.groupId !== 'string' ||
395
- typeof obj.token !== 'string' ||
396
- typeof obj.sig !== 'string' ||
397
- obj.request === undefined) {
398
- return null;
399
- }
400
- return obj;
401
- }
402
- catch {
403
- return null;
404
- }
405
- }
406
- export function parseBorrowToken(env) {
407
- try {
408
- return {
409
- token: Buffer.from(env.token, 'base64url'),
410
- signature: BigInt('0x' + env.sig),
411
- };
412
- }
413
- catch {
414
- return null;
415
- }
416
- }