@askalf/dario 3.29.1 → 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.
- package/README.md +6 -34
- package/dist/proxy.d.ts +3 -5
- package/dist/proxy.js +16 -187
- package/package.json +2 -2
- package/dist/sealed-pool.d.ts +0 -202
- package/dist/sealed-pool.js +0 -416
package/README.md
CHANGED
|
@@ -11,10 +11,6 @@
|
|
|
11
11
|
<a href="https://www.npmjs.com/package/@askalf/dario"><img src="https://img.shields.io/npm/dm/@askalf/dario" alt="Downloads"></a>
|
|
12
12
|
</p>
|
|
13
13
|
|
|
14
|
-
<p align="center">
|
|
15
|
-
<sub><strong>v4 is not a version bump.</strong> The router was the prerequisite. What comes next uses it as a substrate. — <a href="https://github.com/askalf/dario/discussions/categories/announcements">watch this space</a></sub>
|
|
16
|
-
</p>
|
|
17
|
-
|
|
18
14
|
```bash
|
|
19
15
|
npm install -g @askalf/dario && dario proxy
|
|
20
16
|
```
|
|
@@ -26,7 +22,7 @@ One command, one local URL, every provider behind it. Point `ANTHROPIC_BASE_URL`
|
|
|
26
22
|
- `llama-3.3-70b`, `deepseek-v3`, anything else → **Groq**, **OpenRouter**, **local LiteLLM**, **vLLM**, **Ollama**, whichever OpenAI-compat backend you wired up
|
|
27
23
|
- Force a backend explicitly with a prefix: `openai:gpt-4o`, `groq:llama-3.3-70b`, `local:qwen-coder`, `claude:opus`
|
|
28
24
|
|
|
29
|
-
Switching providers is a **model-name change** in your tool. Not a reconfigure. Not new base URLs. Not new API keys. Not a new SDK import. **Zero runtime dependencies. ~
|
|
25
|
+
Switching providers is a **model-name change** in your tool. Not a reconfigure. Not new base URLs. Not new API keys. Not a new SDK import. **Zero runtime dependencies. ~10,750 lines of TypeScript across ~24 files. ~1,185 assertions across 32 test suites. [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) on every release. Nothing phones home, ever.**
|
|
30
26
|
|
|
31
27
|
---
|
|
32
28
|
|
|
@@ -98,11 +94,9 @@ Something broken? `dario doctor` prints a single aggregated health report — da
|
|
|
98
94
|
|
|
99
95
|
**You want dario itself addressable from inside Claude Code or any MCP client.** `dario subagent install` registers a first-party sub-agent under `~/.claude/agents/dario.md` so CC can delegate diagnostics and template-refresh in-session ([Claude Code sub-agent hook](#claude-code-sub-agent-hook-v326)). `dario mcp` turns dario itself into a read-only MCP server — Claude Desktop, Cursor, Zed, any MCP-aware editor can introspect dario's state (auth, pool, backends, template, fingerprint, runtime) without leaving the editor ([dario as MCP server](#dario-as-mcp-server-v327)).
|
|
100
96
|
|
|
101
|
-
**You want to share capacity with a trusted group without surveilling each other.** The **sealed-sender overflow protocol** uses RSA blind signatures (Chaum 1983, implemented from scratch over Node's `crypto`) so members of a trust group can lend unused Claude capacity to each other with cryptographic unlinkability. Dario ships the primitive; [mux](https://github.com/askalf/mux) is the dedicated product around it. See [Sealed-sender overflow](#sealed-sender-overflow-protocol).
|
|
102
|
-
|
|
103
97
|
**You want certainty that the proxy isn't trivially fingerprintable.** The "get ahead of Anthropic" release track (v3.22 – v3.28) closed six observable divergence axes between dario and real Claude Code: body field order (v3.22), TLS ClientHello (v3.23), inter-request timing (v3.24), stream-consumption shape (v3.25), sub-agent/MCP reach (v3.26/v3.27), and session-id lifecycle (v3.28). See [Fingerprint axes](#fingerprint-axes).
|
|
104
98
|
|
|
105
|
-
**You want to actually audit the thing.** ~
|
|
99
|
+
**You want to actually audit the thing.** ~10,750 lines of TypeScript across ~24 files. Zero runtime dependencies (`npm ls --production` confirms). Credentials at `~/.dario/` with `0600` permissions. `127.0.0.1`-only by default. Every release [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions. Nothing phones home. Small enough to read in a weekend.
|
|
106
100
|
|
|
107
101
|
---
|
|
108
102
|
|
|
@@ -246,26 +240,6 @@ Every request carries a `billingBucket` field (`subscription` / `subscription_fa
|
|
|
246
240
|
|
|
247
241
|
---
|
|
248
242
|
|
|
249
|
-
## Sealed-sender overflow protocol
|
|
250
|
-
|
|
251
|
-
Trust-group members can lend each other Claude capacity with **cryptographic unlinkability**: a lender can verify the borrower is a valid group member without learning *which* member, so no one in the pool can surveil another through borrow telemetry.
|
|
252
|
-
|
|
253
|
-
**The primitive.** RSA blind signatures (Chaum 1983), implemented from scratch on top of Node's `crypto` module using `RSA_NO_PADDING` for raw `m^e mod n` / `c^d mod n` primitives. Full-Domain Hash via MGF1-SHA256 (with counter retry) prevents multiplicative forgery. The flow: the group admin signs *blinded* tokens in a batch without seeing their real values; the member unblinds locally to obtain valid RSA-FDH signatures on random tokens the admin has never seen. When a member spends a token with a lender, the lender verifies the signature with the group public key — it proves "some member got this signed" without identifying who.
|
|
254
|
-
|
|
255
|
-
**What this is, and what it isn't.** This is **privacy between group members**, not anonymity from Anthropic. When a lender accepts a borrow, the actual upstream request still lands under the lender's attributable Claude account identity — Anthropic sees the lender as the originator, exactly as they would for any other request on that account. The cryptographic unlinkability protects group members from each other.
|
|
256
|
-
|
|
257
|
-
**What's in the release:**
|
|
258
|
-
|
|
259
|
-
- `src/sealed-pool.ts` — ~550 lines. `GroupAdmin` / `GroupMember` / `GroupLender` classes with quota/expiry enforcement, SHA-256-hashed double-spend set, JSON wire envelope (`{v:1, groupId, token, sig, request}`), and key export/import for distributing group credentials.
|
|
260
|
-
- `POST /v1/pool/borrow` endpoint on the proxy, gated on `~/.dario/group.json`. Positioned before `checkAuth` — the group signature *is* the authentication, so doubling it with a local API key would add nothing. Verified borrows delegate to `pool.select()` and forward upstream under the lender's account.
|
|
261
|
-
- 85 test assertions in `test/sealed-pool.mjs` covering raw RSA roundtrip, unlinkability, wrong-key / tampered-sig / wrong-group / double-spend rejection, key export/import, admin membership / quota / expiry enforcement, concurrent-borrow double-spend prevention, and end-to-end two-member unlinkability.
|
|
262
|
-
|
|
263
|
-
Full feature-parity with `/v1/messages` (streaming, inside-request 429 failover, reverse tool mapping) for borrowed requests is intentionally a follow-up — the current release ships the cryptographic primitive and a working minimal endpoint; full integration layers on top.
|
|
264
|
-
|
|
265
|
-
**Dedicated product.** The sealed-sender protocol has a dedicated product around it: [mux](https://github.com/askalf/mux). mux carries the group admin tooling (key generation, member roster, batch signing), the member workflow (prepare / finalize / status), the borrower CLI, and the lender daemon as a coherent surface. It uses dario as its backend — a mux lender runs a dario pool and fronts it with `/v1/pool/borrow`. Dario keeps the primitive here for anyone who wants to embed it without running the full mux flow; for peer-to-peer capacity sharing as a product, use mux.
|
|
266
|
-
|
|
267
|
-
---
|
|
268
|
-
|
|
269
243
|
## Shim mode
|
|
270
244
|
|
|
271
245
|
*Experimental, opt-in. The proxy is still the default — shim mode is a second transport, not a replacement.*
|
|
@@ -582,7 +556,6 @@ curl http://localhost:3456/health
|
|
|
582
556
|
|---|---|
|
|
583
557
|
| `POST /v1/messages` | Anthropic Messages API (Claude backend) |
|
|
584
558
|
| `POST /v1/chat/completions` | OpenAI-compatible Chat API (routes by model name) |
|
|
585
|
-
| `POST /v1/pool/borrow` | Sealed-sender borrow endpoint. Accepts group-signed tokens and forwards the request through the lender's pool. |
|
|
586
559
|
| `GET /v1/models` | Model list (Claude models — OpenAI models come from the OpenAI backend directly) |
|
|
587
560
|
| `GET /health` | Proxy health + OAuth status + request count |
|
|
588
561
|
| `GET /status` | Detailed Claude OAuth token status |
|
|
@@ -597,11 +570,11 @@ Dario handles your OAuth tokens and API keys locally. Here's why you can trust i
|
|
|
597
570
|
|
|
598
571
|
| Signal | Status |
|
|
599
572
|
|---|---|
|
|
600
|
-
| **Source code** | ~
|
|
573
|
+
| **Source code** | ~10,750 lines of TypeScript across ~24 files — small enough to audit in a weekend |
|
|
601
574
|
| **Dependencies** | 0 runtime dependencies. Verify: `npm ls --production` |
|
|
602
575
|
| **npm provenance** | Every release is [SLSA-attested](https://www.npmjs.com/package/@askalf/dario) via GitHub Actions with sigstore provenance attached to the transparency log |
|
|
603
576
|
| **Security scanning** | [CodeQL](https://github.com/askalf/dario/actions/workflows/codeql.yml) runs on every push and weekly |
|
|
604
|
-
| **Test footprint** | ~1,
|
|
577
|
+
| **Test footprint** | ~1,185 assertions across 32 test suites. Full `npm test` green on every release |
|
|
605
578
|
| **Credential handling** | Tokens and API keys never logged, redacted from errors, stored with `0600` permissions. MCP server (v3.27) redacts keys at the tool boundary too — not even a `sk-…` prefix leaks. |
|
|
606
579
|
| **OAuth flow** | PKCE (Proof Key for Code Exchange), no client secret. `--manual` flow for headless setups (v3.20). |
|
|
607
580
|
| **Network scope** | Binds to `127.0.0.1` by default. `--host` allows LAN/mesh with `DARIO_API_KEY` gating. Upstream traffic goes only to the configured backend target URLs over HTTPS |
|
|
@@ -718,7 +691,7 @@ The CHANGELOG documents every v3.22 – v3.28 "get ahead of Anthropic" release w
|
|
|
718
691
|
|
|
719
692
|
## Contributing
|
|
720
693
|
|
|
721
|
-
PRs welcome. The codebase is small TypeScript — ~
|
|
694
|
+
PRs welcome. The codebase is small TypeScript — ~10,750 lines across ~24 files:
|
|
722
695
|
|
|
723
696
|
| File | Purpose |
|
|
724
697
|
|---|---|
|
|
@@ -740,7 +713,6 @@ PRs welcome. The codebase is small TypeScript — ~11,300 lines across ~25 files
|
|
|
740
713
|
| `src/oauth.ts` | Single-account token storage, PKCE flow, auto-refresh, manual/headless flow (v3.20) |
|
|
741
714
|
| `src/accounts.ts` | Multi-account credential storage, independent OAuth lifecycle, refresh single-flight |
|
|
742
715
|
| `src/pool.ts` | Account pool, headroom-aware routing, session stickiness, failover target selection |
|
|
743
|
-
| `src/sealed-pool.ts` | Sealed-sender overflow protocol — RSA blind signatures for unlinkable group pooling |
|
|
744
716
|
| `src/analytics.ts` | Rolling request history, per-account / per-model stats, burn-rate, billing bucket classification |
|
|
745
717
|
| `src/openai-backend.ts` | OpenAI-compat backend credential storage and request forwarder |
|
|
746
718
|
| `src/shim/runtime.cjs` | Hand-written CJS payload loaded into child processes via `NODE_OPTIONS=--require`; patches `globalThis.fetch` for Anthropic messages requests only |
|
|
@@ -753,7 +725,7 @@ git clone https://github.com/askalf/dario
|
|
|
753
725
|
cd dario
|
|
754
726
|
npm install
|
|
755
727
|
npm run dev # runs with tsx, no build step
|
|
756
|
-
npm test # ~1,
|
|
728
|
+
npm test # ~1,185 assertions across 32 suites
|
|
757
729
|
npm run e2e # live proxy + OAuth (requires a working Claude backend)
|
|
758
730
|
```
|
|
759
731
|
|
package/dist/proxy.d.ts
CHANGED
|
@@ -24,11 +24,9 @@ interface ProxyOptions {
|
|
|
24
24
|
}
|
|
25
25
|
export declare function sanitizeError(err: unknown): string;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* gateway forwarding a verified sealed borrow. If neither is configured
|
|
30
|
-
* the request is allowed (loopback-only default). Exported for tests.
|
|
27
|
+
* API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
|
|
28
|
+
* If unset, requests are allowed (loopback-only default). Exported for tests.
|
|
31
29
|
*/
|
|
32
|
-
export declare function authenticateRequest(headers: IncomingMessage['headers'], apiKeyBuf: Buffer | null
|
|
30
|
+
export declare function authenticateRequest(headers: IncomingMessage['headers'], apiKeyBuf: Buffer | null): boolean;
|
|
33
31
|
export declare function startProxy(opts?: ProxyOptions): Promise<void>;
|
|
34
32
|
export {};
|
package/dist/proxy.js
CHANGED
|
@@ -11,7 +11,6 @@ import { describeTemplate, detectDrift, checkCCCompat } from './live-fingerprint
|
|
|
11
11
|
import { AccountPool, computeStickyKey, parseRateLimits } from './pool.js';
|
|
12
12
|
import { Analytics, billingBucketFromClaim } from './analytics.js';
|
|
13
13
|
import { loadAllAccounts, loadAccount, refreshAccountToken } from './accounts.js';
|
|
14
|
-
import { GroupLender, importGroupPublicKey, decodeBorrowEnvelope, parseBorrowToken, } from './sealed-pool.js';
|
|
15
14
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
16
15
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
17
16
|
const DEFAULT_PORT = 3456;
|
|
@@ -334,31 +333,18 @@ export function sanitizeError(err) {
|
|
|
334
333
|
.replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [REDACTED]');
|
|
335
334
|
}
|
|
336
335
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* gateway forwarding a verified sealed borrow. If neither is configured
|
|
340
|
-
* the request is allowed (loopback-only default). Exported for tests.
|
|
336
|
+
* API-key auth via DARIO_API_KEY (x-api-key or Authorization: Bearer).
|
|
337
|
+
* If unset, requests are allowed (loopback-only default). Exported for tests.
|
|
341
338
|
*/
|
|
342
|
-
export function authenticateRequest(headers, apiKeyBuf
|
|
343
|
-
if (!apiKeyBuf
|
|
339
|
+
export function authenticateRequest(headers, apiKeyBuf) {
|
|
340
|
+
if (!apiKeyBuf)
|
|
344
341
|
return true;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
return true;
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
if (apiKeyBuf) {
|
|
355
|
-
const provided = headers['x-api-key']
|
|
356
|
-
|| headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
357
|
-
if (provided) {
|
|
358
|
-
const providedBuf = Buffer.from(provided);
|
|
359
|
-
if (providedBuf.length === apiKeyBuf.length && timingSafeEqual(providedBuf, apiKeyBuf))
|
|
360
|
-
return true;
|
|
361
|
-
}
|
|
342
|
+
const provided = headers['x-api-key']
|
|
343
|
+
|| headers.authorization?.replace(/^Bearer\s+/i, '');
|
|
344
|
+
if (provided) {
|
|
345
|
+
const providedBuf = Buffer.from(provided);
|
|
346
|
+
if (providedBuf.length === apiKeyBuf.length && timingSafeEqual(providedBuf, apiKeyBuf))
|
|
347
|
+
return true;
|
|
362
348
|
}
|
|
363
349
|
return false;
|
|
364
350
|
}
|
|
@@ -440,30 +426,6 @@ export async function startProxy(opts = {}) {
|
|
|
440
426
|
const accountsList = await loadAllAccounts();
|
|
441
427
|
const pool = accountsList.length >= 2 ? new AccountPool() : null;
|
|
442
428
|
const analytics = pool ? new Analytics() : null;
|
|
443
|
-
// Sealed-sender overflow pool — activated when ~/.dario/group.json exists.
|
|
444
|
-
// Config format: { "groupId": "<name>", "publicKey": { n, e, modulusBytes } }
|
|
445
|
-
// where publicKey is the GroupAdmin's exported RSA public key. Lender runs
|
|
446
|
-
// in addition to normal pool mode — borrow requests go through a separate
|
|
447
|
-
// /v1/pool/borrow endpoint and are verified via the admin-signed token.
|
|
448
|
-
let groupLender = null;
|
|
449
|
-
try {
|
|
450
|
-
const groupConfigPath = join(homedir(), '.dario', 'group.json');
|
|
451
|
-
const rawGroup = readFileSync(groupConfigPath, 'utf-8');
|
|
452
|
-
const parsed = JSON.parse(rawGroup);
|
|
453
|
-
if (parsed?.groupId && parsed.publicKey?.n && parsed.publicKey?.e && parsed.publicKey?.modulusBytes) {
|
|
454
|
-
const pub = importGroupPublicKey(parsed.publicKey);
|
|
455
|
-
groupLender = new GroupLender(parsed.groupId, pub);
|
|
456
|
-
console.log(` Sealed-sender pool: group "${parsed.groupId}" loaded (${pub.modulusBytes * 8}-bit key)`);
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
catch (err) {
|
|
460
|
-
// Group config is optional — silent fallthrough if missing. Log parse
|
|
461
|
-
// errors explicitly so a broken config doesn't fail silently.
|
|
462
|
-
const e = err;
|
|
463
|
-
if (e.code && e.code !== 'ENOENT') {
|
|
464
|
-
console.warn(`[dario] group.json present but unusable: ${e.message}`);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
429
|
let status;
|
|
468
430
|
if (pool) {
|
|
469
431
|
for (const acc of accountsList) {
|
|
@@ -647,14 +609,6 @@ export async function startProxy(opts = {}) {
|
|
|
647
609
|
// Optional proxy authentication — pre-encode key buffer for performance
|
|
648
610
|
const apiKey = process.env.DARIO_API_KEY;
|
|
649
611
|
const apiKeyBuf = apiKey ? Buffer.from(apiKey) : null;
|
|
650
|
-
// Mux coord-secret — the shared secret mux uses when forwarding sealed
|
|
651
|
-
// borrow requests to this dario acting as a lender endpoint. A request
|
|
652
|
-
// carrying a matching X-Mux-Coord-Secret header is authenticated without
|
|
653
|
-
// needing DARIO_API_KEY. The sealed-sender envelope has already been
|
|
654
|
-
// verified upstream, so the coord secret is the one-hop auth between
|
|
655
|
-
// the mux gateway and this instance.
|
|
656
|
-
const mcs = process.env.MUX_COORD_SECRET;
|
|
657
|
-
const mcsBuf = mcs ? Buffer.from(mcs) : null;
|
|
658
612
|
// CORS origin defaults to the localhost URL the proxy is served at. Users
|
|
659
613
|
// binding to a non-loopback address (e.g. a Tailscale interface) can
|
|
660
614
|
// override via DARIO_CORS_ORIGIN — otherwise browser-based clients hitting
|
|
@@ -670,7 +624,7 @@ export async function startProxy(opts = {}) {
|
|
|
670
624
|
const CORS_HEADERS = {
|
|
671
625
|
'Access-Control-Allow-Origin': corsOrigin,
|
|
672
626
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
673
|
-
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta
|
|
627
|
+
'Access-Control-Allow-Headers': 'Content-Type, Authorization, x-api-key, anthropic-version, anthropic-beta',
|
|
674
628
|
'Access-Control-Max-Age': '86400',
|
|
675
629
|
...SECURITY_HEADERS,
|
|
676
630
|
};
|
|
@@ -680,7 +634,7 @@ export async function startProxy(opts = {}) {
|
|
|
680
634
|
const ERR_FORBIDDEN = JSON.stringify({ error: 'Forbidden', message: 'Path not allowed. Supported paths: POST /v1/messages, POST /v1/chat/completions, GET /v1/models' });
|
|
681
635
|
const ERR_METHOD = JSON.stringify({ error: 'Method not allowed' });
|
|
682
636
|
function checkAuth(req) {
|
|
683
|
-
return authenticateRequest(req.headers, apiKeyBuf
|
|
637
|
+
return authenticateRequest(req.headers, apiKeyBuf);
|
|
684
638
|
}
|
|
685
639
|
const server = createServer(async (req, res) => {
|
|
686
640
|
if (req.method === 'OPTIONS') {
|
|
@@ -702,121 +656,6 @@ export async function startProxy(opts = {}) {
|
|
|
702
656
|
}));
|
|
703
657
|
return;
|
|
704
658
|
}
|
|
705
|
-
// Sealed-sender borrow endpoint — runs BEFORE the API-key auth check
|
|
706
|
-
// because the admin-signed group token IS the authentication. Anyone
|
|
707
|
-
// who presents a valid unused token can borrow capacity from this
|
|
708
|
-
// instance's pool without also holding the local dario API key.
|
|
709
|
-
// See src/sealed-pool.ts for the protocol.
|
|
710
|
-
if (urlPath === '/v1/pool/borrow' && req.method === 'POST') {
|
|
711
|
-
if (!groupLender) {
|
|
712
|
-
res.writeHead(503, JSON_HEADERS);
|
|
713
|
-
res.end(JSON.stringify({ error: 'sealed-sender pool not configured on this instance' }));
|
|
714
|
-
return;
|
|
715
|
-
}
|
|
716
|
-
if (!pool) {
|
|
717
|
-
res.writeHead(503, JSON_HEADERS);
|
|
718
|
-
res.end(JSON.stringify({ error: 'pool mode required for sealed-sender borrows' }));
|
|
719
|
-
return;
|
|
720
|
-
}
|
|
721
|
-
// Read body with the same limits as normal /v1/messages.
|
|
722
|
-
const bChunks = [];
|
|
723
|
-
let bBytes = 0;
|
|
724
|
-
const bTimeout = setTimeout(() => { req.destroy(); }, BODY_READ_TIMEOUT_MS);
|
|
725
|
-
try {
|
|
726
|
-
for await (const chunk of req) {
|
|
727
|
-
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
728
|
-
bBytes += buf.length;
|
|
729
|
-
if (bBytes > MAX_BODY_BYTES) {
|
|
730
|
-
clearTimeout(bTimeout);
|
|
731
|
-
res.writeHead(413, JSON_HEADERS);
|
|
732
|
-
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
733
|
-
return;
|
|
734
|
-
}
|
|
735
|
-
bChunks.push(buf);
|
|
736
|
-
}
|
|
737
|
-
}
|
|
738
|
-
finally {
|
|
739
|
-
clearTimeout(bTimeout);
|
|
740
|
-
}
|
|
741
|
-
const envelope = decodeBorrowEnvelope(Buffer.concat(bChunks).toString('utf-8'));
|
|
742
|
-
if (!envelope) {
|
|
743
|
-
res.writeHead(400, JSON_HEADERS);
|
|
744
|
-
res.end(JSON.stringify({ error: 'malformed borrow envelope' }));
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
747
|
-
// Envelope shape guard — envelope.request is `unknown` on the wire.
|
|
748
|
-
// We stringify it and forward to Anthropic under the lender's identity,
|
|
749
|
-
// so a borrower could otherwise waste the lender's rate-limit slot with
|
|
750
|
-
// a body Anthropic will reject. Minimum: must be a plain object with
|
|
751
|
-
// `model` (string) and `messages` (array). Anthropic validates the rest.
|
|
752
|
-
const br = envelope.request;
|
|
753
|
-
if (!br || typeof br !== 'object' || Array.isArray(br) ||
|
|
754
|
-
typeof br.model !== 'string' ||
|
|
755
|
-
!Array.isArray(br.messages)) {
|
|
756
|
-
res.writeHead(400, JSON_HEADERS);
|
|
757
|
-
res.end(JSON.stringify({ error: 'envelope.request must be an Anthropic /v1/messages body' }));
|
|
758
|
-
return;
|
|
759
|
-
}
|
|
760
|
-
if (envelope.groupId !== groupLender.groupId) {
|
|
761
|
-
res.writeHead(403, JSON_HEADERS);
|
|
762
|
-
res.end(JSON.stringify({ error: 'unknown_group', expected: groupLender.groupId }));
|
|
763
|
-
return;
|
|
764
|
-
}
|
|
765
|
-
const borrowTok = parseBorrowToken(envelope);
|
|
766
|
-
if (!borrowTok) {
|
|
767
|
-
res.writeHead(400, JSON_HEADERS);
|
|
768
|
-
res.end(JSON.stringify({ error: 'malformed token' }));
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
|
-
const accept = groupLender.acceptBorrow(borrowTok.token, borrowTok.signature);
|
|
772
|
-
if (!accept.ok) {
|
|
773
|
-
res.writeHead(403, JSON_HEADERS);
|
|
774
|
-
res.end(JSON.stringify({ error: 'borrow_rejected', reason: accept.reason }));
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
// Token validated. Forward the embedded /v1/messages request to
|
|
778
|
-
// Anthropic using the lender's normal pool. This path is minimal:
|
|
779
|
-
// no streaming parser, no reverse tool mapping, no 429 failover.
|
|
780
|
-
// It's enough to demonstrate sealed-sender end-to-end; the full
|
|
781
|
-
// feature-parity wire-up with the main /v1/messages path is a
|
|
782
|
-
// separate change (requires threading a pre-read body through
|
|
783
|
-
// the existing handler).
|
|
784
|
-
const lenderAccount = pool.select();
|
|
785
|
-
if (!lenderAccount) {
|
|
786
|
-
res.writeHead(503, JSON_HEADERS);
|
|
787
|
-
res.end(JSON.stringify({ error: 'lender pool exhausted' }));
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
try {
|
|
791
|
-
const upstream = await fetch(`${ANTHROPIC_API}/v1/messages?beta=true`, {
|
|
792
|
-
method: 'POST',
|
|
793
|
-
headers: {
|
|
794
|
-
'Content-Type': 'application/json',
|
|
795
|
-
'authorization': `Bearer ${lenderAccount.accessToken}`,
|
|
796
|
-
'anthropic-version': '2023-06-01',
|
|
797
|
-
'anthropic-beta': 'claude-code-20250219',
|
|
798
|
-
},
|
|
799
|
-
body: JSON.stringify(envelope.request),
|
|
800
|
-
});
|
|
801
|
-
const snapshot = parseRateLimits(upstream.headers);
|
|
802
|
-
pool.updateRateLimits(lenderAccount.alias, snapshot);
|
|
803
|
-
const body = Buffer.from(await upstream.arrayBuffer());
|
|
804
|
-
res.writeHead(upstream.status, {
|
|
805
|
-
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
806
|
-
'Access-Control-Allow-Origin': corsOrigin,
|
|
807
|
-
...SECURITY_HEADERS,
|
|
808
|
-
});
|
|
809
|
-
res.end(body);
|
|
810
|
-
if (verbose) {
|
|
811
|
-
console.log(`[dario] borrow: group=${envelope.groupId} → ${lenderAccount.alias} (${upstream.status}, ${body.length}B)`);
|
|
812
|
-
}
|
|
813
|
-
}
|
|
814
|
-
catch (err) {
|
|
815
|
-
res.writeHead(502, JSON_HEADERS);
|
|
816
|
-
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
|
|
817
|
-
}
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
659
|
if (!checkAuth(req)) {
|
|
821
660
|
res.writeHead(401, JSON_HEADERS);
|
|
822
661
|
res.end(ERR_UNAUTH);
|
|
@@ -852,10 +691,6 @@ export async function startProxy(opts = {}) {
|
|
|
852
691
|
mode: 'pool',
|
|
853
692
|
...pool.status(),
|
|
854
693
|
stickyBindings: pool.stickyCount(),
|
|
855
|
-
sealedSender: groupLender ? {
|
|
856
|
-
groupId: groupLender.groupId,
|
|
857
|
-
seenTokens: groupLender.seenCount(),
|
|
858
|
-
} : null,
|
|
859
694
|
accounts,
|
|
860
695
|
}));
|
|
861
696
|
return;
|
|
@@ -1807,19 +1642,13 @@ export async function startProxy(opts = {}) {
|
|
|
1807
1642
|
if (!isLoopbackHost(host)) {
|
|
1808
1643
|
console.log('');
|
|
1809
1644
|
console.log(` ⚠ Bound to ${host} — reachable from other machines on the network.`);
|
|
1810
|
-
if (!apiKey
|
|
1645
|
+
if (!apiKey) {
|
|
1811
1646
|
console.log(' No auth configured. Any host that can reach this port can proxy');
|
|
1812
|
-
console.log(' requests through your OAuth subscription. Set DARIO_API_KEY
|
|
1813
|
-
console.log('
|
|
1814
|
-
console.log(' exposing dario beyond loopback.');
|
|
1647
|
+
console.log(' requests through your OAuth subscription. Set DARIO_API_KEY');
|
|
1648
|
+
console.log(' before exposing dario beyond loopback.');
|
|
1815
1649
|
}
|
|
1816
1650
|
else {
|
|
1817
|
-
|
|
1818
|
-
if (apiKey)
|
|
1819
|
-
lanes.push('x-api-key / Authorization (DARIO_API_KEY)');
|
|
1820
|
-
if (mcs)
|
|
1821
|
-
lanes.push('X-Mux-Coord-Secret (MUX_COORD_SECRET — mux lender mode)');
|
|
1822
|
-
console.log(` Auth required — accepted credentials: ${lanes.join(' or ')}.`);
|
|
1651
|
+
console.log(' Auth required — accepted credentials: x-api-key / Authorization (DARIO_API_KEY).');
|
|
1823
1652
|
}
|
|
1824
1653
|
}
|
|
1825
1654
|
console.log('');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/dario",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.30.0",
|
|
4
4
|
"description": "A local LLM router. One endpoint, every provider — Claude subscriptions, OpenAI, OpenRouter, Groq, local LiteLLM, any OpenAI-compat endpoint — your tools don't need to change.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"build": "tsc && cp src/cc-template-data.json dist/ && node -e \"require('fs').mkdirSync('dist/shim',{recursive:true})\" && cp src/shim/runtime.cjs dist/shim/",
|
|
24
|
-
"test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/
|
|
24
|
+
"test": "node test/issue-29-tool-translation.mjs && node test/hybrid-tools.mjs && node test/tool-schema-contract.mjs && node test/scrub-paths.mjs && node test/provider-prefix.mjs && node test/analytics-recording.mjs && node test/analytics-billing-bucket.mjs && node test/failover-429.mjs && node test/pool-sticky.mjs && node test/live-fingerprint.mjs && node test/shim-runtime.mjs && node test/shim-e2e.mjs && node test/proxy-header-order.mjs && node test/proxy-body-order.mjs && node test/runtime-fingerprint.mjs && node test/pacing.mjs && node test/stream-drain.mjs && node test/subagent.mjs && node test/mcp-protocol.mjs && node test/mcp-tools.mjs && node test/mcp-e2e.mjs && node test/session-rotation.mjs && node test/drift-detection.mjs && node test/cc-authorize-probe-classifier.mjs && node test/compat-range.mjs && node test/doctor-formatter.mjs && node test/atomic-write.mjs && node test/account-refresh-singleflight.mjs && node test/streaming-edge-cases.mjs && node test/client-detection.mjs && node test/manual-oauth-flow.mjs && node test/scrub-template.mjs",
|
|
25
25
|
"audit": "npm audit --production --audit-level=high",
|
|
26
26
|
"prepublishOnly": "npm run build",
|
|
27
27
|
"start": "node dist/cli.js",
|
package/dist/sealed-pool.d.ts
DELETED
|
@@ -1,202 +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 { type KeyObject } from 'node:crypto';
|
|
45
|
-
export interface RSAPublicKey {
|
|
46
|
-
n: bigint;
|
|
47
|
-
e: bigint;
|
|
48
|
-
modulusBytes: number;
|
|
49
|
-
keyObj: KeyObject;
|
|
50
|
-
}
|
|
51
|
-
export interface RSAPrivateKey extends RSAPublicKey {
|
|
52
|
-
keyObjPriv: KeyObject;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Blind a token: pick r ∈ [2, n), compute blinded = FDH(token) · r^e mod n.
|
|
56
|
-
* The admin sees only the blinded value (uniform over Z_n*) and learns
|
|
57
|
-
* nothing about the token.
|
|
58
|
-
*/
|
|
59
|
-
export declare function blindToken(tokenBytes: Buffer, pubKey: RSAPublicKey): {
|
|
60
|
-
blinded: bigint;
|
|
61
|
-
r: bigint;
|
|
62
|
-
};
|
|
63
|
-
/** Admin-side: sign a blinded value. No knowledge of the original token. */
|
|
64
|
-
export declare function signBlinded(blinded: bigint, privKey: RSAPrivateKey): bigint;
|
|
65
|
-
/**
|
|
66
|
-
* Member-side: given the admin's signature on the blinded value, remove
|
|
67
|
-
* the blinding factor to obtain a raw RSA-FDH signature over the original
|
|
68
|
-
* token that the admin never saw.
|
|
69
|
-
*
|
|
70
|
-
* Math: signed_blinded = (FDH(t) · r^e)^d = FDH(t)^d · r mod n.
|
|
71
|
-
* Multiplying by r^(-1) mod n yields FDH(t)^d = the raw signature.
|
|
72
|
-
*/
|
|
73
|
-
export declare function unblindSignature(blindedSignature: bigint, r: bigint, pubKey: RSAPublicKey): bigint;
|
|
74
|
-
/**
|
|
75
|
-
* Verify a (token, signature) pair against the admin's public key.
|
|
76
|
-
* True iff signature^e ≡ FDH(token) (mod n).
|
|
77
|
-
*/
|
|
78
|
-
export declare function verifyTokenSignature(tokenBytes: Buffer, signature: bigint, pubKey: RSAPublicKey): boolean;
|
|
79
|
-
export declare function generateGroupKey(bits?: number): RSAPrivateKey;
|
|
80
|
-
export interface ExportedGroupKey {
|
|
81
|
-
n: string;
|
|
82
|
-
e: string;
|
|
83
|
-
modulusBytes: number;
|
|
84
|
-
}
|
|
85
|
-
export declare function exportGroupPublicKey(key: RSAPublicKey): ExportedGroupKey;
|
|
86
|
-
export declare function importGroupPublicKey(exported: ExportedGroupKey): RSAPublicKey;
|
|
87
|
-
export interface MemberRecord {
|
|
88
|
-
pubkey: string;
|
|
89
|
-
expiresAt: number;
|
|
90
|
-
quotaPerBatch: number;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Admin holds the group private key and a roster of authorized members.
|
|
94
|
-
* Admin does NOT hold any of the tokens, by design — blind signing means
|
|
95
|
-
* the admin never sees what they signed. This is the key privacy property.
|
|
96
|
-
*
|
|
97
|
-
* The admin's responsibilities are purely social: decide who's in the
|
|
98
|
-
* group, set per-member quotas, rotate the group key when someone leaves.
|
|
99
|
-
*/
|
|
100
|
-
export declare class GroupAdmin {
|
|
101
|
-
readonly groupId: string;
|
|
102
|
-
readonly key: RSAPrivateKey;
|
|
103
|
-
readonly members: Map<string, MemberRecord>;
|
|
104
|
-
constructor(groupId: string, key: RSAPrivateKey, members: Map<string, MemberRecord>);
|
|
105
|
-
static create(groupId: string, bits?: number): GroupAdmin;
|
|
106
|
-
addMember(pubkey: string, quotaPerBatch?: number, validForDays?: number): void;
|
|
107
|
-
removeMember(pubkey: string): boolean;
|
|
108
|
-
/**
|
|
109
|
-
* Sign a batch of blinded tokens submitted by a member. The admin
|
|
110
|
-
* authenticates the request out-of-band (member identity auth happens
|
|
111
|
-
* at the HTTP layer via a member signing key — not modelled here).
|
|
112
|
-
*
|
|
113
|
-
* Throws on: unknown member, expired membership, batch-too-large.
|
|
114
|
-
*/
|
|
115
|
-
signBatch(memberPubkey: string, blinded: bigint[]): bigint[];
|
|
116
|
-
publicKey(): ExportedGroupKey;
|
|
117
|
-
}
|
|
118
|
-
export interface PreparedBatch {
|
|
119
|
-
blinded: bigint[];
|
|
120
|
-
state: Array<{
|
|
121
|
-
token: Buffer;
|
|
122
|
-
r: bigint;
|
|
123
|
-
}>;
|
|
124
|
-
}
|
|
125
|
-
export interface BorrowToken {
|
|
126
|
-
token: Buffer;
|
|
127
|
-
signature: bigint;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Member holds an identity pubkey (used by the admin for roster lookup)
|
|
131
|
-
* and a local stash of unused (token, signature) pairs. Tokens are single-
|
|
132
|
-
* use — consume one per borrow. Admin never saw any of these tokens.
|
|
133
|
-
*/
|
|
134
|
-
export declare class GroupMember {
|
|
135
|
-
readonly memberPubkey: string;
|
|
136
|
-
readonly groupPublicKey: RSAPublicKey;
|
|
137
|
-
private tokens;
|
|
138
|
-
constructor(memberPubkey: string, groupPublicKey: RSAPublicKey);
|
|
139
|
-
/**
|
|
140
|
-
* Step 1 of a token batch: generate random tokens, blind each, return
|
|
141
|
-
* the blinded values (to send to admin) plus the per-token state
|
|
142
|
-
* (kept locally for unblinding after admin responds).
|
|
143
|
-
*/
|
|
144
|
-
prepareBatch(count: number): PreparedBatch;
|
|
145
|
-
/**
|
|
146
|
-
* Step 2 of a token batch: unblind each admin-signed value, verify the
|
|
147
|
-
* resulting raw signature, and add the (token, signature) pair to the
|
|
148
|
-
* local stash. Any verification failure throws — we never store a token
|
|
149
|
-
* whose signature doesn't check out.
|
|
150
|
-
*/
|
|
151
|
-
finalizeBatch(signedBlinded: bigint[], state: Array<{
|
|
152
|
-
token: Buffer;
|
|
153
|
-
r: bigint;
|
|
154
|
-
}>): void;
|
|
155
|
-
consumeToken(): BorrowToken | null;
|
|
156
|
-
tokenCount(): number;
|
|
157
|
-
}
|
|
158
|
-
export type AcceptResult = {
|
|
159
|
-
ok: true;
|
|
160
|
-
} | {
|
|
161
|
-
ok: false;
|
|
162
|
-
reason: 'invalid_signature' | 'double_spend' | 'malformed';
|
|
163
|
-
};
|
|
164
|
-
/**
|
|
165
|
-
* Lender holds the group public key and a set of token hashes that have
|
|
166
|
-
* already been redeemed. Memory-only in v1; a persisted set would be a
|
|
167
|
-
* sqlite table keyed on token hash, or just a file of sha256 hex lines.
|
|
168
|
-
*
|
|
169
|
-
* The lender LEARNS NOTHING about which member borrowed. That's the
|
|
170
|
-
* whole point — blind signatures decouple "who signed this request"
|
|
171
|
-
* (the admin, uniformly) from "who holds the token" (one specific
|
|
172
|
-
* member who is anonymous to the lender).
|
|
173
|
-
*/
|
|
174
|
-
export declare class GroupLender {
|
|
175
|
-
readonly groupId: string;
|
|
176
|
-
readonly groupPublicKey: RSAPublicKey;
|
|
177
|
-
private seenTokens;
|
|
178
|
-
private maxSeenTokens;
|
|
179
|
-
constructor(groupId: string, groupPublicKey: RSAPublicKey, opts?: {
|
|
180
|
-
maxSeenTokens?: number;
|
|
181
|
-
});
|
|
182
|
-
acceptBorrow(token: Buffer, signature: bigint): AcceptResult;
|
|
183
|
-
seenCount(): number;
|
|
184
|
-
}
|
|
185
|
-
/**
|
|
186
|
-
* Envelope the member sends to a lender's /v1/pool/borrow endpoint. The
|
|
187
|
-
* `request` field carries an embedded Anthropic /v1/messages body that
|
|
188
|
-
* the lender will proxy to api.anthropic.com under its own identity.
|
|
189
|
-
*
|
|
190
|
-
* Version field lets us rotate crypto or protocol details without
|
|
191
|
-
* breaking older members in the same group.
|
|
192
|
-
*/
|
|
193
|
-
export interface BorrowEnvelope {
|
|
194
|
-
v: 1;
|
|
195
|
-
groupId: string;
|
|
196
|
-
token: string;
|
|
197
|
-
sig: string;
|
|
198
|
-
request: unknown;
|
|
199
|
-
}
|
|
200
|
-
export declare function encodeBorrowEnvelope(groupId: string, bt: BorrowToken, request: unknown): string;
|
|
201
|
-
export declare function decodeBorrowEnvelope(s: string): BorrowEnvelope | null;
|
|
202
|
-
export declare function parseBorrowToken(env: BorrowEnvelope): BorrowToken | null;
|
package/dist/sealed-pool.js
DELETED
|
@@ -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
|
-
}
|