@askalf/dario 3.12.0 → 3.13.1
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 +44 -4
- package/dist/cc-template.js +25 -10
- package/dist/live-fingerprint.d.ts +82 -0
- package/dist/live-fingerprint.js +94 -0
- package/dist/pool.d.ts +48 -0
- package/dist/pool.js +99 -1
- package/dist/proxy.js +168 -2
- package/dist/sealed-pool.d.ts +202 -0
- package/dist/sealed-pool.js +416 -0
- package/dist/shim/runtime.cjs +146 -20
- package/package.json +2 -2
package/dist/proxy.js
CHANGED
|
@@ -7,9 +7,10 @@ import { homedir } from 'node:os';
|
|
|
7
7
|
import { arch, platform } from 'node:process';
|
|
8
8
|
import { getAccessToken, getStatus } from './oauth.js';
|
|
9
9
|
import { buildCCRequest, reverseMapResponse, createStreamingReverseMapper } from './cc-template.js';
|
|
10
|
-
import { AccountPool, parseRateLimits } from './pool.js';
|
|
10
|
+
import { AccountPool, computeStickyKey, parseRateLimits } from './pool.js';
|
|
11
11
|
import { Analytics, billingBucketFromClaim } from './analytics.js';
|
|
12
12
|
import { loadAllAccounts, loadAccount, refreshAccountToken } from './accounts.js';
|
|
13
|
+
import { GroupLender, importGroupPublicKey, decodeBorrowEnvelope, parseBorrowToken, } from './sealed-pool.js';
|
|
13
14
|
import { getOpenAIBackend, isOpenAIModel, forwardToOpenAI } from './openai-backend.js';
|
|
14
15
|
const ANTHROPIC_API = 'https://api.anthropic.com';
|
|
15
16
|
const DEFAULT_PORT = 3456;
|
|
@@ -367,6 +368,30 @@ export async function startProxy(opts = {}) {
|
|
|
367
368
|
const accountsList = await loadAllAccounts();
|
|
368
369
|
const pool = accountsList.length >= 2 ? new AccountPool() : null;
|
|
369
370
|
const analytics = pool ? new Analytics() : null;
|
|
371
|
+
// Sealed-sender overflow pool — activated when ~/.dario/group.json exists.
|
|
372
|
+
// Config format: { "groupId": "<name>", "publicKey": { n, e, modulusBytes } }
|
|
373
|
+
// where publicKey is the GroupAdmin's exported RSA public key. Lender runs
|
|
374
|
+
// in addition to normal pool mode — borrow requests go through a separate
|
|
375
|
+
// /v1/pool/borrow endpoint and are verified via the admin-signed token.
|
|
376
|
+
let groupLender = null;
|
|
377
|
+
try {
|
|
378
|
+
const groupConfigPath = join(homedir(), '.dario', 'group.json');
|
|
379
|
+
const rawGroup = readFileSync(groupConfigPath, 'utf-8');
|
|
380
|
+
const parsed = JSON.parse(rawGroup);
|
|
381
|
+
if (parsed?.groupId && parsed.publicKey?.n && parsed.publicKey?.e && parsed.publicKey?.modulusBytes) {
|
|
382
|
+
const pub = importGroupPublicKey(parsed.publicKey);
|
|
383
|
+
groupLender = new GroupLender(parsed.groupId, pub);
|
|
384
|
+
console.log(` Sealed-sender pool: group "${parsed.groupId}" loaded (${pub.modulusBytes * 8}-bit key)`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
// Group config is optional — silent fallthrough if missing. Log parse
|
|
389
|
+
// errors explicitly so a broken config doesn't fail silently.
|
|
390
|
+
const e = err;
|
|
391
|
+
if (e.code && e.code !== 'ENOENT') {
|
|
392
|
+
console.warn(`[dario] group.json present but unusable: ${e.message}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
370
395
|
let status;
|
|
371
396
|
if (pool) {
|
|
372
397
|
for (const acc of accountsList) {
|
|
@@ -524,6 +549,108 @@ export async function startProxy(opts = {}) {
|
|
|
524
549
|
}));
|
|
525
550
|
return;
|
|
526
551
|
}
|
|
552
|
+
// Sealed-sender borrow endpoint — runs BEFORE the API-key auth check
|
|
553
|
+
// because the admin-signed group token IS the authentication. Anyone
|
|
554
|
+
// who presents a valid unused token can borrow capacity from this
|
|
555
|
+
// instance's pool without also holding the local dario API key.
|
|
556
|
+
// See src/sealed-pool.ts for the protocol.
|
|
557
|
+
if (urlPath === '/v1/pool/borrow' && req.method === 'POST') {
|
|
558
|
+
if (!groupLender) {
|
|
559
|
+
res.writeHead(503, JSON_HEADERS);
|
|
560
|
+
res.end(JSON.stringify({ error: 'sealed-sender pool not configured on this instance' }));
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (!pool) {
|
|
564
|
+
res.writeHead(503, JSON_HEADERS);
|
|
565
|
+
res.end(JSON.stringify({ error: 'pool mode required for sealed-sender borrows' }));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
// Read body with the same limits as normal /v1/messages.
|
|
569
|
+
const bChunks = [];
|
|
570
|
+
let bBytes = 0;
|
|
571
|
+
const bTimeout = setTimeout(() => { req.destroy(); }, BODY_READ_TIMEOUT_MS);
|
|
572
|
+
try {
|
|
573
|
+
for await (const chunk of req) {
|
|
574
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
|
|
575
|
+
bBytes += buf.length;
|
|
576
|
+
if (bBytes > MAX_BODY_BYTES) {
|
|
577
|
+
clearTimeout(bTimeout);
|
|
578
|
+
res.writeHead(413, JSON_HEADERS);
|
|
579
|
+
res.end(JSON.stringify({ error: 'Request body too large' }));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
bChunks.push(buf);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
clearTimeout(bTimeout);
|
|
587
|
+
}
|
|
588
|
+
const envelope = decodeBorrowEnvelope(Buffer.concat(bChunks).toString('utf-8'));
|
|
589
|
+
if (!envelope) {
|
|
590
|
+
res.writeHead(400, JSON_HEADERS);
|
|
591
|
+
res.end(JSON.stringify({ error: 'malformed borrow envelope' }));
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
if (envelope.groupId !== groupLender.groupId) {
|
|
595
|
+
res.writeHead(403, JSON_HEADERS);
|
|
596
|
+
res.end(JSON.stringify({ error: 'unknown_group', expected: groupLender.groupId }));
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const borrowTok = parseBorrowToken(envelope);
|
|
600
|
+
if (!borrowTok) {
|
|
601
|
+
res.writeHead(400, JSON_HEADERS);
|
|
602
|
+
res.end(JSON.stringify({ error: 'malformed token' }));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const accept = groupLender.acceptBorrow(borrowTok.token, borrowTok.signature);
|
|
606
|
+
if (!accept.ok) {
|
|
607
|
+
res.writeHead(403, JSON_HEADERS);
|
|
608
|
+
res.end(JSON.stringify({ error: 'borrow_rejected', reason: accept.reason }));
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
// Token validated. Forward the embedded /v1/messages request to
|
|
612
|
+
// Anthropic using the lender's normal pool. This path is minimal:
|
|
613
|
+
// no streaming parser, no reverse tool mapping, no 429 failover.
|
|
614
|
+
// It's enough to demonstrate sealed-sender end-to-end; the full
|
|
615
|
+
// feature-parity wire-up with the main /v1/messages path is a
|
|
616
|
+
// separate change (requires threading a pre-read body through
|
|
617
|
+
// the existing handler).
|
|
618
|
+
const lenderAccount = pool.select();
|
|
619
|
+
if (!lenderAccount) {
|
|
620
|
+
res.writeHead(503, JSON_HEADERS);
|
|
621
|
+
res.end(JSON.stringify({ error: 'lender pool exhausted' }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
try {
|
|
625
|
+
const upstream = await fetch(`${ANTHROPIC_API}/v1/messages?beta=true`, {
|
|
626
|
+
method: 'POST',
|
|
627
|
+
headers: {
|
|
628
|
+
'Content-Type': 'application/json',
|
|
629
|
+
'authorization': `Bearer ${lenderAccount.accessToken}`,
|
|
630
|
+
'anthropic-version': '2023-06-01',
|
|
631
|
+
'anthropic-beta': 'claude-code-20250219',
|
|
632
|
+
},
|
|
633
|
+
body: JSON.stringify(envelope.request),
|
|
634
|
+
});
|
|
635
|
+
const snapshot = parseRateLimits(upstream.headers);
|
|
636
|
+
pool.updateRateLimits(lenderAccount.alias, snapshot);
|
|
637
|
+
const body = Buffer.from(await upstream.arrayBuffer());
|
|
638
|
+
res.writeHead(upstream.status, {
|
|
639
|
+
'content-type': upstream.headers.get('content-type') ?? 'application/json',
|
|
640
|
+
'Access-Control-Allow-Origin': corsOrigin,
|
|
641
|
+
...SECURITY_HEADERS,
|
|
642
|
+
});
|
|
643
|
+
res.end(body);
|
|
644
|
+
if (verbose) {
|
|
645
|
+
console.log(`[dario] borrow: group=${envelope.groupId} → ${lenderAccount.alias} (${upstream.status}, ${body.length}B)`);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
res.writeHead(502, JSON_HEADERS);
|
|
650
|
+
res.end(JSON.stringify({ error: 'upstream_error', message: err.message }));
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
527
654
|
if (!checkAuth(req)) {
|
|
528
655
|
res.writeHead(401, JSON_HEADERS);
|
|
529
656
|
res.end(ERR_UNAUTH);
|
|
@@ -555,7 +682,16 @@ export async function startProxy(opts = {}) {
|
|
|
555
682
|
expiresInMs: Math.max(0, a.expiresAt - Date.now()),
|
|
556
683
|
}));
|
|
557
684
|
res.writeHead(200, JSON_HEADERS);
|
|
558
|
-
res.end(JSON.stringify({
|
|
685
|
+
res.end(JSON.stringify({
|
|
686
|
+
mode: 'pool',
|
|
687
|
+
...pool.status(),
|
|
688
|
+
stickyBindings: pool.stickyCount(),
|
|
689
|
+
sealedSender: groupLender ? {
|
|
690
|
+
groupId: groupLender.groupId,
|
|
691
|
+
seenTokens: groupLender.seenCount(),
|
|
692
|
+
} : null,
|
|
693
|
+
accounts,
|
|
694
|
+
}));
|
|
559
695
|
return;
|
|
560
696
|
}
|
|
561
697
|
// Analytics endpoint — request history + burn-rate summary (pool mode only).
|
|
@@ -696,6 +832,16 @@ export async function startProxy(opts = {}) {
|
|
|
696
832
|
let finalBody = body.length > 0 ? body : undefined;
|
|
697
833
|
let ccToolMap = null;
|
|
698
834
|
let requestModel = '';
|
|
835
|
+
// Session stickiness key — hash of the first user message in this
|
|
836
|
+
// conversation. Populated inside the template-replay block below
|
|
837
|
+
// after the first user message is extracted for the build tag, then
|
|
838
|
+
// used to rebind the sticky slot on in-request 429 failover and on
|
|
839
|
+
// the eventual request bookkeeping. Null when body isn't JSON, when
|
|
840
|
+
// there's no user message, or when we're in passthrough mode (the
|
|
841
|
+
// fingerprint work doesn't run, so there's no point biasing account
|
|
842
|
+
// selection toward one we already paid cache cost on — passthrough
|
|
843
|
+
// users aren't doing template replay anyway).
|
|
844
|
+
let stickyKey = null;
|
|
699
845
|
// Request context for hybrid-mode field injection (#33). Built once
|
|
700
846
|
// per request from incoming headers so the reverse mapper can fill
|
|
701
847
|
// client-declared fields like `sessionId` that CC's schema doesn't
|
|
@@ -730,6 +876,24 @@ export async function startProxy(opts = {}) {
|
|
|
730
876
|
const fullVersion = `${cliVersion}.${buildTag}`;
|
|
731
877
|
const billingTag = `x-anthropic-billing-header: cc_version=${fullVersion}; cc_entrypoint=cli; cch=${cch};`;
|
|
732
878
|
const CACHE_1H = { type: 'ephemeral', ttl: '1h' };
|
|
879
|
+
// Session stickiness: rebind the pre-selected pool account to
|
|
880
|
+
// whatever the sticky-key resolver picks. If this is a new
|
|
881
|
+
// conversation the key binds to the current best account
|
|
882
|
+
// (no-op swap in most cases). If this is a follow-up turn of
|
|
883
|
+
// an existing conversation the key resolves to the account
|
|
884
|
+
// that already has the Anthropic prompt cache warmed for it.
|
|
885
|
+
// Rotating off mid-session costs cache-create on every turn.
|
|
886
|
+
stickyKey = computeStickyKey(userMsg);
|
|
887
|
+
if (pool && stickyKey) {
|
|
888
|
+
const preferred = pool.selectSticky(stickyKey);
|
|
889
|
+
if (preferred && preferred.alias !== poolAccount?.alias) {
|
|
890
|
+
poolAccount = preferred;
|
|
891
|
+
accessToken = preferred.accessToken;
|
|
892
|
+
if (verbose) {
|
|
893
|
+
console.log(`[dario] #${requestCount} sticky: bind ${stickyKey} → ${preferred.alias}`);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
733
897
|
const bodyIdentity = poolAccount
|
|
734
898
|
? poolAccount.identity
|
|
735
899
|
: { deviceId: identity.deviceId, accountUuid: identity.accountUuid, sessionId: SESSION_ID };
|
|
@@ -907,6 +1071,7 @@ export async function startProxy(opts = {}) {
|
|
|
907
1071
|
accessToken = nextAccount.accessToken;
|
|
908
1072
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
909
1073
|
headers['x-claude-code-session-id'] = nextAccount.identity.sessionId;
|
|
1074
|
+
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
910
1075
|
peekedBody = null;
|
|
911
1076
|
continue dispatchLoop;
|
|
912
1077
|
}
|
|
@@ -965,6 +1130,7 @@ export async function startProxy(opts = {}) {
|
|
|
965
1130
|
accessToken = nextAccount.accessToken;
|
|
966
1131
|
headers['Authorization'] = `Bearer ${accessToken}`;
|
|
967
1132
|
headers['x-claude-code-session-id'] = nextAccount.identity.sessionId;
|
|
1133
|
+
pool.rebindSticky(stickyKey, nextAccount.alias);
|
|
968
1134
|
continue dispatchLoop;
|
|
969
1135
|
}
|
|
970
1136
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
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;
|