@fiscalmindset/blindfold 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/assets/SKILL.md +205 -0
- package/assets/blindfold_proxy.wasm +0 -0
- package/bin/blindfold.ts +90 -0
- package/bin/cli-shared.ts +72 -0
- package/bin/cmd-auth.ts +193 -0
- package/bin/cmd-enclave.ts +373 -0
- package/bin/cmd-lifecycle.ts +151 -0
- package/bin/cmd-secrets.ts +136 -0
- package/bin/cmd-serve.ts +165 -0
- package/bin/cmd-tenant.ts +84 -0
- package/dist/cli.mjs +4317 -0
- package/dist/lib/index.mjs +2362 -0
- package/dist/lib/proxy.mjs +1078 -0
- package/dist/lib/register.mjs +756 -0
- package/dist/lib/wrap.mjs +81 -0
- package/package.json +48 -0
- package/postinstall.mjs +21 -0
- package/src/attest.ts +235 -0
- package/src/color.ts +31 -0
- package/src/compat.ts +217 -0
- package/src/constants.ts +24 -0
- package/src/dashboard.ts +785 -0
- package/src/env.ts +261 -0
- package/src/index.ts +11 -0
- package/src/init.ts +391 -0
- package/src/keychain.ts +155 -0
- package/src/log.ts +51 -0
- package/src/migrate.ts +135 -0
- package/src/prompt.ts +114 -0
- package/src/providers.ts +224 -0
- package/src/proxy.ts +454 -0
- package/src/register.ts +81 -0
- package/src/release.ts +82 -0
- package/src/sealed-ledger.ts +158 -0
- package/src/t3-client.ts +580 -0
- package/src/types.ts +50 -0
- package/src/usage-log.ts +149 -0
- package/src/versions.ts +64 -0
- package/src/wrap.ts +122 -0
package/src/t3-client.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin wrapper around @terminal3/t3n-sdk (v3.x).
|
|
3
|
+
*
|
|
4
|
+
* SECURITY MODEL — read carefully, the paths differ:
|
|
5
|
+
* - PROXY/FORWARD path: plaintext is substituted INSIDE the enclave. The
|
|
6
|
+
* proxy sends the sentinel string as Authorization; this module never sees
|
|
7
|
+
* the plaintext key for forwarded calls. This is the un-leakable path.
|
|
8
|
+
* - SEED path (registration): `seedSecret()` DOES handle the plaintext once,
|
|
9
|
+
* passing it as the `value` of a single `map-entry-set` call. It is dropped
|
|
10
|
+
* immediately and never logged.
|
|
11
|
+
* - RELEASE path: `releaseSecret()` RETURNS plaintext to the local process by
|
|
12
|
+
* design (broker use/export/rotate/rollback). Protection here rests on the
|
|
13
|
+
* tenant key (T3N_API_KEY) not being reachable by the agent — see SECURITY.md.
|
|
14
|
+
*
|
|
15
|
+
* The SDK is loaded lazily so MOCK mode works on machines that haven't
|
|
16
|
+
* installed it. REAL mode requires `@terminal3/t3n-sdk` (optionalDep).
|
|
17
|
+
*/
|
|
18
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import type { BlindfoldEnv, ForwardRequest, ForwardResponse } from "./types.ts";
|
|
22
|
+
import { CONTRACT_TAIL, CONTRACT_VERSION } from "./constants.ts";
|
|
23
|
+
import { assertRealReady, stateDir, withFileLockSync } from "./env.ts";
|
|
24
|
+
import { safeLog } from "./log.ts";
|
|
25
|
+
|
|
26
|
+
/** Deadline wrapper so a stalled T3 node can't hang an agent request forever. */
|
|
27
|
+
const T3_TIMEOUT_MS = Number(process.env.BLINDFOLD_T3_TIMEOUT_MS) || 30_000;
|
|
28
|
+
export class T3TimeoutError extends Error {}
|
|
29
|
+
function withDeadline<T>(promise: Promise<T>, label: string, ms = T3_TIMEOUT_MS): Promise<T> {
|
|
30
|
+
return new Promise<T>((resolve, reject) => {
|
|
31
|
+
const timer = setTimeout(() => reject(new T3TimeoutError(`T3 ${label} timed out after ${ms}ms`)), ms);
|
|
32
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
33
|
+
promise.then(
|
|
34
|
+
(v) => { clearTimeout(timer); resolve(v); },
|
|
35
|
+
(e) => { clearTimeout(timer); reject(e); },
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* ---- egress allowlist cache -------------------------------------------------
|
|
41
|
+
* T3 REPLACES the contract's egress allowlist on every agent-auth update, so a
|
|
42
|
+
* grant must send the FULL desired set each time. We remember previously-granted
|
|
43
|
+
* hosts per tenant in .blindfold/egress-hosts.json and union new hosts in, so
|
|
44
|
+
* `grant` becomes additive instead of clobbering earlier grants.
|
|
45
|
+
* ---------------------------------------------------------------------------- */
|
|
46
|
+
function egressCachePath(): string {
|
|
47
|
+
return process.env.BLINDFOLD_EGRESS_CACHE ?? path.join(stateDir(), "egress-hosts.json");
|
|
48
|
+
}
|
|
49
|
+
export function loadEgressHosts(did: string): string[] {
|
|
50
|
+
try {
|
|
51
|
+
const all = JSON.parse(fs.readFileSync(egressCachePath(), "utf8")) as Record<string, string[]>;
|
|
52
|
+
return Array.isArray(all[did]) ? all[did] : [];
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function saveEgressHosts(did: string, hosts: string[]): void {
|
|
58
|
+
const p = egressCachePath();
|
|
59
|
+
// Lock + re-read + union so two concurrent grants can't clobber each other's
|
|
60
|
+
// hosts (T3 replaces the whole allowlist, so a dropped host = lost egress).
|
|
61
|
+
withFileLockSync(p, () => {
|
|
62
|
+
let all: Record<string, string[]> = {};
|
|
63
|
+
try {
|
|
64
|
+
all = JSON.parse(fs.readFileSync(p, "utf8")) as Record<string, string[]>;
|
|
65
|
+
} catch {
|
|
66
|
+
/* fresh file */
|
|
67
|
+
}
|
|
68
|
+
const existing = Array.isArray(all[did]) ? all[did] : [];
|
|
69
|
+
all[did] = Array.from(new Set([...existing, ...hosts])).sort();
|
|
70
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
71
|
+
fs.writeFileSync(p, JSON.stringify(all, null, 2));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Loaded SDK module shape (the subset of @terminal3/t3n-sdk that Blindfold
|
|
76
|
+
* actually calls). Exported so init.ts shares the same typed boundary instead
|
|
77
|
+
* of casting the dynamic import to `any`. */
|
|
78
|
+
export interface T3Sdk {
|
|
79
|
+
setEnvironment: (env: "testnet" | "production") => void;
|
|
80
|
+
loadWasmComponent: () => Promise<unknown>;
|
|
81
|
+
eth_get_address: (privKey: string) => string;
|
|
82
|
+
metamask_sign: (address: string, _: undefined, privKey: string) => unknown;
|
|
83
|
+
createEthAuthInput: (address: string) => unknown;
|
|
84
|
+
/** Optional control-plane helper; present on newer SDKs, guarded at the call site. */
|
|
85
|
+
getScriptVersion?: (baseUrl: string, scriptPath: string) => Promise<unknown>;
|
|
86
|
+
T3nClient: new (cfg: unknown) => {
|
|
87
|
+
handshake: () => Promise<unknown>;
|
|
88
|
+
/** Returns the caller's DID (eth-derived, when authenticated with an eth key). */
|
|
89
|
+
authenticate: (input: unknown) => Promise<unknown>;
|
|
90
|
+
execute: (input: unknown) => Promise<unknown>;
|
|
91
|
+
/** Dispatch a one-time code to a contact (email/sms) on the authed session. */
|
|
92
|
+
otpRequest: (input: { emailChannel?: { emailAddress: string }; smsChannel?: { phoneNumber: string } }) =>
|
|
93
|
+
Promise<{ status?: string; isNewProfile?: boolean; expiresAtSec?: number }>;
|
|
94
|
+
/**
|
|
95
|
+
* Redeem the code and bind the contact to the authenticated DID. Returns
|
|
96
|
+
* `status: "otp_failed" | "otp_expired"` as DATA (no throw) on a bad code,
|
|
97
|
+
* and a `mergeSuggestion` when the contact already belongs to another DID.
|
|
98
|
+
*/
|
|
99
|
+
otpVerify: (input: { otpCode: string; request: { emailChannel?: { emailAddress: string }; smsChannel?: { phoneNumber: string } } }) =>
|
|
100
|
+
Promise<{ status?: string; did?: string; mergeSuggestion?: { existingDid: string; currentDid: string } }>;
|
|
101
|
+
/** Commit Level-1 profile; with `becomeDevTenant` self-admits + mints welcome credits. */
|
|
102
|
+
submitUserInput: (input: { profile: Record<string, unknown>; becomeDevTenant?: boolean }) =>
|
|
103
|
+
Promise<{ tenantAdmit?: { status?: string; grantedCredits?: string; reason?: string; detail?: string } }>;
|
|
104
|
+
};
|
|
105
|
+
TenantClient: new (cfg: unknown) => {
|
|
106
|
+
canonicalName: (tail: string) => string;
|
|
107
|
+
executeControl: (functionName: string, input: unknown) => Promise<unknown>;
|
|
108
|
+
tenant: { me: () => Promise<unknown> };
|
|
109
|
+
token: { getUsage: (opts?: unknown) => Promise<{ balance?: Record<string, unknown> }> };
|
|
110
|
+
maps: {
|
|
111
|
+
create: (input: unknown) => Promise<unknown>;
|
|
112
|
+
update: (name: string, input: unknown) => Promise<unknown>;
|
|
113
|
+
};
|
|
114
|
+
contracts: {
|
|
115
|
+
register: (input: { tail: string; version: string; wasm: Uint8Array }) => Promise<unknown>;
|
|
116
|
+
execute: (tail: string, input: { version: string; functionName: string; input?: unknown }) => Promise<unknown>;
|
|
117
|
+
enable: (tail: string) => Promise<unknown>;
|
|
118
|
+
disable: (tail: string) => Promise<unknown>;
|
|
119
|
+
unregister: (tail: string) => Promise<unknown>;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
NODE_URLS: { testnet: string; production: string };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let sdkCache: T3Sdk | null = null;
|
|
126
|
+
async function loadSdk(): Promise<T3Sdk> {
|
|
127
|
+
if (sdkCache) return sdkCache;
|
|
128
|
+
try {
|
|
129
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
130
|
+
const mod = (await import("@terminal3/t3n-sdk")) as unknown as T3Sdk;
|
|
131
|
+
sdkCache = mod;
|
|
132
|
+
return mod;
|
|
133
|
+
} catch {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"@terminal3/t3n-sdk not installed. Run `npm install @terminal3/t3n-sdk` " +
|
|
136
|
+
"for REAL T3 mode, or set BLINDFOLD_MOCK=1 to keep using mock mode.",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface T3ClientHandle {
|
|
142
|
+
close: () => Promise<void>;
|
|
143
|
+
seedSecret: (name: string, value: string) => Promise<void>;
|
|
144
|
+
invokeForward: (req: ForwardRequest) => Promise<ForwardResponse>;
|
|
145
|
+
registerContract: (wasm: Uint8Array) => Promise<{ contractId: string | number }>;
|
|
146
|
+
/**
|
|
147
|
+
* Release a sealed secret from the enclave by name.
|
|
148
|
+
* for short-lived use — drop the returned value from scope as soon as the call is done.
|
|
149
|
+
*/
|
|
150
|
+
releaseSecret: (name: string) => Promise<string>;
|
|
151
|
+
/**
|
|
152
|
+
* Linearizable read of the tenant behind the current key. Throws if the key
|
|
153
|
+
* has no provisioned tenant (the failure mode that surfaces as a bare 500).
|
|
154
|
+
*/
|
|
155
|
+
me: () => Promise<{ tenant: string; status?: string }>;
|
|
156
|
+
/**
|
|
157
|
+
* Authorize the blindfold-proxy contract's forward/release-to-tenant
|
|
158
|
+
* functions to make outbound calls to the given hosts (the egress grant the
|
|
159
|
+
* proxy + in-enclave http::call path require).
|
|
160
|
+
*/
|
|
161
|
+
grantEgress: (hosts: string[], opts?: { replace?: boolean }) => Promise<string[]>;
|
|
162
|
+
/**
|
|
163
|
+
* Authorize an arbitrary agent DID (a teammate) to call this tenant's
|
|
164
|
+
* contract functions for the given hosts. Empty hosts+functions revokes.
|
|
165
|
+
*/
|
|
166
|
+
setAgentGrant: (agentDid: string, hosts: string[], functions: string[]) => Promise<void>;
|
|
167
|
+
/**
|
|
168
|
+
* Confirm a sealed secret still exists in the enclave. Returns its byte
|
|
169
|
+
* length + a non-reversible fingerprint (never the value) so an audit can
|
|
170
|
+
* reconcile the local ledger against the enclave (the source of truth).
|
|
171
|
+
*/
|
|
172
|
+
verifySecret: (name: string) => Promise<{ present: boolean; length: number; fingerprint: string }>;
|
|
173
|
+
/**
|
|
174
|
+
* Read the tenant's token/credit balance (a session-authed read that costs no
|
|
175
|
+
* credit — works even when the account is exhausted). Powers `blindfold credit`.
|
|
176
|
+
*/
|
|
177
|
+
getBalance: () => Promise<CreditBalance>;
|
|
178
|
+
/** True if a real T3 round-trip happened during construction. */
|
|
179
|
+
isReal: boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Tenant credit balance (base units; 1 token = 1,000,000 base units). */
|
|
183
|
+
export interface CreditBalance {
|
|
184
|
+
available: number;
|
|
185
|
+
reserved: number;
|
|
186
|
+
creditExhausted: boolean;
|
|
187
|
+
storageDeposit?: number;
|
|
188
|
+
mock?: boolean;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* SELF-SERVE SIGNUP — testnet self-admit (becomeDevTenant) */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Thrown when the signup email already belongs to a Terminal 3 tenant. Signup
|
|
197
|
+
* mints a fresh DID, and T3 refuses to reparent an owned email onto it — so the
|
|
198
|
+
* user must either log in with that tenant's key or sign up with a new email.
|
|
199
|
+
*/
|
|
200
|
+
export class SignupEmailTakenError extends Error {
|
|
201
|
+
constructor(public email: string, public existingDid: string) {
|
|
202
|
+
super(`"${email}" is already registered to Terminal 3 tenant ${existingDid}.`);
|
|
203
|
+
this.name = "SignupEmailTakenError";
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export interface SignupOptions {
|
|
208
|
+
env: "testnet" | "production";
|
|
209
|
+
email: string;
|
|
210
|
+
/** Prompt callback: the SDK calls this between OTP-request and OTP-verify. */
|
|
211
|
+
getOtpCode: (contact: string, channel: "email" | "sms") => Promise<string> | string;
|
|
212
|
+
/** Extra profile fields to merge (first_name, campaign_code, …). */
|
|
213
|
+
profile?: Record<string, unknown>;
|
|
214
|
+
/**
|
|
215
|
+
* Called the instant the email is verified and the DID is real — BEFORE the
|
|
216
|
+
* self-admit commit that could still fail. Persist the key here so a failed
|
|
217
|
+
* admit never strands a live, email-bound tenant with an unrecoverable key.
|
|
218
|
+
*/
|
|
219
|
+
onKeyReady?: (key: string, did: string) => void | Promise<void>;
|
|
220
|
+
baseUrl?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export interface SignupResult {
|
|
224
|
+
/**
|
|
225
|
+
* The freshly-generated tenant private key (0x…, secp256k1). This is the
|
|
226
|
+
* ONLY secret this function produces; the caller must store it in the OS
|
|
227
|
+
* keychain and MUST NOT print or log it — same handling rule as a sealed key.
|
|
228
|
+
*/
|
|
229
|
+
key: string;
|
|
230
|
+
did: string;
|
|
231
|
+
address: string;
|
|
232
|
+
env: "testnet" | "production";
|
|
233
|
+
/** "admitted" | "already-admitted" | "refused" | "unknown". */
|
|
234
|
+
admitStatus: string;
|
|
235
|
+
grantedCredits?: string;
|
|
236
|
+
refusedReason?: string;
|
|
237
|
+
refusedDetail?: string;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Provision a Terminal 3 testnet tenant end-to-end, with no human in the loop
|
|
242
|
+
* on the T3 side:
|
|
243
|
+
* 1. generate a fresh secp256k1 key locally (the tenant's identity),
|
|
244
|
+
* 2. eth-authenticate with it (yields an eth-derived DID — required by
|
|
245
|
+
* becomeDevTenant),
|
|
246
|
+
* 3. prove the email via OTP and self-admit (`becomeDevTenant: true`), which
|
|
247
|
+
* mints operator-configured welcome credits.
|
|
248
|
+
*
|
|
249
|
+
* The generated key is returned so the caller can persist it to the keychain;
|
|
250
|
+
* it never leaves this process otherwise. Testnet-only (production refuses
|
|
251
|
+
* self-admit).
|
|
252
|
+
*/
|
|
253
|
+
export async function signupTenant(opts: SignupOptions): Promise<SignupResult> {
|
|
254
|
+
const sdk = await loadSdk();
|
|
255
|
+
sdk.setEnvironment(opts.env);
|
|
256
|
+
const baseUrl = opts.baseUrl || sdk.NODE_URLS[opts.env];
|
|
257
|
+
const wasmComponent = await sdk.loadWasmComponent();
|
|
258
|
+
|
|
259
|
+
// A fresh 32-byte random value is a valid secp256k1 private key with
|
|
260
|
+
// overwhelming probability (~1 - 2^-128). If the SDK ever rejects one as
|
|
261
|
+
// out-of-range, the caller can simply retry signup.
|
|
262
|
+
const key = "0x" + randomBytes(32).toString("hex");
|
|
263
|
+
const address = sdk.eth_get_address(key);
|
|
264
|
+
|
|
265
|
+
const t3n = new sdk.T3nClient({
|
|
266
|
+
baseUrl,
|
|
267
|
+
wasmComponent,
|
|
268
|
+
handlers: { EthSign: sdk.metamask_sign(address, undefined, key) },
|
|
269
|
+
});
|
|
270
|
+
await t3n.handshake();
|
|
271
|
+
const did = String(await t3n.authenticate(sdk.createEthAuthInput(address)));
|
|
272
|
+
|
|
273
|
+
// Run the OTP roundtrip explicitly (rather than the runOtpThenUserInput
|
|
274
|
+
// convenience) so we can surface the two failure modes it otherwise swallows:
|
|
275
|
+
// a wrong/expired code, and an email that already belongs to another tenant.
|
|
276
|
+
const channel = { emailChannel: { emailAddress: opts.email } };
|
|
277
|
+
await t3n.otpRequest(channel);
|
|
278
|
+
const code = String(await opts.getOtpCode(opts.email, "email")).trim();
|
|
279
|
+
const verify = await t3n.otpVerify({ otpCode: code, request: channel });
|
|
280
|
+
if (verify.mergeSuggestion) {
|
|
281
|
+
throw new SignupEmailTakenError(opts.email, verify.mergeSuggestion.existingDid);
|
|
282
|
+
}
|
|
283
|
+
if (verify.status === "otp_failed" || verify.status === "otp_expired") {
|
|
284
|
+
throw new Error(
|
|
285
|
+
verify.status === "otp_expired"
|
|
286
|
+
? "the code expired — run `blindfold signup` again to get a fresh one"
|
|
287
|
+
: "that code was incorrect — re-run `blindfold signup` and enter the emailed code",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The email is now bound to `did` and the generated key controls it — a real,
|
|
292
|
+
// recoverable credential whether or not the self-admit below succeeds. Hand it
|
|
293
|
+
// to the caller to persist NOW, so a failing admit can't burn the tenant.
|
|
294
|
+
if (opts.onKeyReady) await opts.onKeyReady(key, did);
|
|
295
|
+
|
|
296
|
+
const result = await t3n.submitUserInput({
|
|
297
|
+
profile: { email_address: opts.email, ...(opts.profile ?? {}) },
|
|
298
|
+
becomeDevTenant: true,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
const admit = result.tenantAdmit;
|
|
302
|
+
return {
|
|
303
|
+
key,
|
|
304
|
+
did,
|
|
305
|
+
address,
|
|
306
|
+
env: opts.env,
|
|
307
|
+
admitStatus: admit?.status ?? "unknown",
|
|
308
|
+
grantedCredits: admit?.grantedCredits,
|
|
309
|
+
refusedReason: admit?.reason,
|
|
310
|
+
refusedDetail: admit?.detail,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function openT3Client(env: BlindfoldEnv): Promise<T3ClientHandle> {
|
|
315
|
+
if (env.mock) return openMockClient();
|
|
316
|
+
assertRealReady(env);
|
|
317
|
+
return openRealClient(env);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/* ------------------------------------------------------------------ */
|
|
321
|
+
/* REAL — @terminal3/t3n-sdk v3.x */
|
|
322
|
+
/* ------------------------------------------------------------------ */
|
|
323
|
+
|
|
324
|
+
async function openRealClient(env: BlindfoldEnv): Promise<T3ClientHandle> {
|
|
325
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(env.t3nApiKey)) {
|
|
326
|
+
throw new Error("T3N_API_KEY must be a 0x-prefixed 32-byte hex (secp256k1 private key).");
|
|
327
|
+
}
|
|
328
|
+
if (!/^did:t3n:[0-9a-fA-F]+$/.test(env.did)) {
|
|
329
|
+
throw new Error('DID must look like "did:t3n:<hex>".');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const sdk = await loadSdk();
|
|
333
|
+
sdk.setEnvironment(env.t3Env);
|
|
334
|
+
// Prefer an explicit override (T3_BASE_URL) so the user can point at a
|
|
335
|
+
// healthy/leader node when the SDK's default node is an unhealthy follower.
|
|
336
|
+
const baseUrl = env.t3BaseUrl || sdk.NODE_URLS[env.t3Env];
|
|
337
|
+
|
|
338
|
+
const wasmComponent = await sdk.loadWasmComponent();
|
|
339
|
+
const address = sdk.eth_get_address(env.t3nApiKey);
|
|
340
|
+
|
|
341
|
+
const t3n = new sdk.T3nClient({
|
|
342
|
+
baseUrl,
|
|
343
|
+
wasmComponent,
|
|
344
|
+
handlers: { EthSign: sdk.metamask_sign(address, undefined, env.t3nApiKey) },
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await t3n.handshake();
|
|
348
|
+
await t3n.authenticate(sdk.createEthAuthInput(address));
|
|
349
|
+
|
|
350
|
+
const tenant = new sdk.TenantClient({
|
|
351
|
+
environment: env.t3Env,
|
|
352
|
+
baseUrl,
|
|
353
|
+
tenantDid: env.did,
|
|
354
|
+
t3n,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const seedSecret = async (name: string, value: string): Promise<void> => {
|
|
358
|
+
// ⚠️ The ONLY line in Blindfold that ever sees plaintext.
|
|
359
|
+
await tenant.executeControl("map-entry-set", {
|
|
360
|
+
map_name: tenant.canonicalName("secrets"),
|
|
361
|
+
key: name,
|
|
362
|
+
value,
|
|
363
|
+
});
|
|
364
|
+
safeLog("info", { msg: "seeded", name });
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const registerContract = async (wasm: Uint8Array): Promise<{ contractId: string | number }> => {
|
|
368
|
+
const r = (await tenant.contracts.register({
|
|
369
|
+
tail: CONTRACT_TAIL,
|
|
370
|
+
version: CONTRACT_VERSION,
|
|
371
|
+
wasm,
|
|
372
|
+
})) as Record<string, unknown>;
|
|
373
|
+
return {
|
|
374
|
+
contractId: (r.contract_id as string | number | undefined) ?? (r.contractId as string | number | undefined) ?? "(unknown)",
|
|
375
|
+
};
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const invokeForward = async (req: ForwardRequest): Promise<ForwardResponse> => {
|
|
379
|
+
// The contract's forward() returns { ok, code, body, length } — the
|
|
380
|
+
// canonical T3 http.response has a status code + payload but NO response
|
|
381
|
+
// headers. Adapt that to the proxy's ForwardResponse shape.
|
|
382
|
+
const raw = (await withDeadline(tenant.contracts.execute(CONTRACT_TAIL, {
|
|
383
|
+
version: CONTRACT_VERSION,
|
|
384
|
+
functionName: "forward",
|
|
385
|
+
input: req,
|
|
386
|
+
}), "forward")) as { ok?: boolean; code?: number; body?: string; status?: number; headers?: Array<[string, string]> };
|
|
387
|
+
// Tolerate both the new shape (code/body) and any legacy shape (status/headers).
|
|
388
|
+
if (typeof raw.status === "number" && Array.isArray(raw.headers)) {
|
|
389
|
+
return { status: raw.status, headers: raw.headers, body: raw.body ?? "" };
|
|
390
|
+
}
|
|
391
|
+
return {
|
|
392
|
+
status: raw.code ?? 502,
|
|
393
|
+
headers: [["content-type", "application/json"]],
|
|
394
|
+
body: raw.body ?? "",
|
|
395
|
+
};
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const decodeSecret = (v: unknown): string => {
|
|
399
|
+
if (typeof v === "string") return v;
|
|
400
|
+
if (Array.isArray(v)) return Buffer.from(v as number[]).toString("utf8");
|
|
401
|
+
if (v && typeof v === "object") {
|
|
402
|
+
const o = v as Record<string, unknown>;
|
|
403
|
+
if (typeof o.value === "string") return o.value;
|
|
404
|
+
if (Array.isArray(o.value)) return Buffer.from(o.value as number[]).toString("utf8");
|
|
405
|
+
const entry = o.entry as Record<string, unknown> | undefined;
|
|
406
|
+
if (entry && typeof entry.value === "string") return entry.value;
|
|
407
|
+
}
|
|
408
|
+
return "";
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
const releaseSecret = async (name: string): Promise<string> => {
|
|
412
|
+
// Preferred path: the contract's `release-to-tenant` (access gated by the
|
|
413
|
+
// contract's read-ACL on the secrets map). Requires the contract published
|
|
414
|
+
// on this tenant.
|
|
415
|
+
try {
|
|
416
|
+
const r = (await withDeadline(tenant.contracts.execute(CONTRACT_TAIL, {
|
|
417
|
+
version: CONTRACT_VERSION,
|
|
418
|
+
functionName: "release-to-tenant",
|
|
419
|
+
input: { secret_key: name },
|
|
420
|
+
}), "release-to-tenant")) as { ok?: boolean; value?: string };
|
|
421
|
+
if (r && r.ok && r.value) return r.value;
|
|
422
|
+
} catch (e) {
|
|
423
|
+
if (e instanceof T3TimeoutError) throw e; // don't mask a hang as "not published"
|
|
424
|
+
/* contract not published on this tenant — fall through to direct read */
|
|
425
|
+
}
|
|
426
|
+
// Fallback: the tenant owner reads its own secrets map directly. Same trust
|
|
427
|
+
// boundary (the holder of the tenant key can always release its secrets),
|
|
428
|
+
// but works without a published contract.
|
|
429
|
+
const direct = decodeSecret(
|
|
430
|
+
await withDeadline(tenant.executeControl("map-entry-get", {
|
|
431
|
+
map_name: tenant.canonicalName("secrets"),
|
|
432
|
+
key: name,
|
|
433
|
+
}), "map-entry-get"),
|
|
434
|
+
);
|
|
435
|
+
if (!direct) throw new Error(`secret "${name}" not found in the secrets map`);
|
|
436
|
+
return direct;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const me = async (): Promise<{ tenant: string; status?: string }> => {
|
|
440
|
+
const info = (await tenant.tenant.me()) as Record<string, unknown>;
|
|
441
|
+
return { tenant: String(info.tenant ?? ""), status: info.status as string | undefined };
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Shared agent-auth grant. Authorizes `agentDid` (self or a teammate) to call
|
|
445
|
+
// this tenant's contract functions for the given hosts. Empty functions+hosts
|
|
446
|
+
// means revoke (scripts: []).
|
|
447
|
+
const agentAuthUpdate = async (agentDid: string, hosts: string[], functions: string[]): Promise<void> => {
|
|
448
|
+
let ucv = "0.1.0";
|
|
449
|
+
if (typeof sdk.getScriptVersion === "function") {
|
|
450
|
+
try {
|
|
451
|
+
const v = await sdk.getScriptVersion(baseUrl, "tee:user/contracts");
|
|
452
|
+
if (typeof v === "string" && /^\d/.test(v)) ucv = v;
|
|
453
|
+
} catch {
|
|
454
|
+
/* fall back to 0.1.0 */
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const didHex = env.did.replace(/^did:t3n:/, "");
|
|
458
|
+
// T3 rejects an empty scripts array, so "revoke" is expressed as a script
|
|
459
|
+
// entry that authorizes the function with NO allowed hosts → it can't reach
|
|
460
|
+
// anything, which is an effective revocation.
|
|
461
|
+
const revoking = functions.length === 0 && hosts.length === 0;
|
|
462
|
+
const scripts = [{
|
|
463
|
+
scriptName: `z:${didHex}:${CONTRACT_TAIL}`,
|
|
464
|
+
versionReq: `>=${CONTRACT_VERSION}`,
|
|
465
|
+
functions: revoking ? ["forward"] : functions,
|
|
466
|
+
allowedHosts: revoking ? [] : hosts,
|
|
467
|
+
}];
|
|
468
|
+
await t3n.execute({
|
|
469
|
+
script_name: "tee:user/contracts",
|
|
470
|
+
script_version: ucv,
|
|
471
|
+
function_name: "agent-auth-update",
|
|
472
|
+
input: { agents: [{ agentDid, scripts }] },
|
|
473
|
+
});
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
const grantEgress = async (hosts: string[], opts?: { replace?: boolean }): Promise<string[]> => {
|
|
477
|
+
const prev = opts?.replace ? [] : loadEgressHosts(env.did);
|
|
478
|
+
const merged = Array.from(new Set([...prev, ...hosts])).sort();
|
|
479
|
+
await agentAuthUpdate(env.did, merged, ["forward", "release-to-tenant"]);
|
|
480
|
+
saveEgressHosts(env.did, merged);
|
|
481
|
+
return merged;
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const setAgentGrant = (agentDid: string, hosts: string[], functions: string[]): Promise<void> =>
|
|
485
|
+
agentAuthUpdate(agentDid, hosts, functions);
|
|
486
|
+
|
|
487
|
+
const verifySecret = async (name: string): Promise<{ present: boolean; length: number; fingerprint: string }> => {
|
|
488
|
+
try {
|
|
489
|
+
const value = await releaseSecret(name); // contract path, or control-plane fallback
|
|
490
|
+
return { present: true, length: value.length, fingerprint: createHash("sha256").update(value).digest("hex").slice(0, 8) };
|
|
491
|
+
} catch {
|
|
492
|
+
return { present: false, length: 0, fingerprint: "" };
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const getBalance = async (): Promise<CreditBalance> => {
|
|
497
|
+
const page = await withDeadline(tenant.token.getUsage(), "token.getUsage");
|
|
498
|
+
const b = (page.balance ?? {}) as Record<string, unknown>;
|
|
499
|
+
return {
|
|
500
|
+
available: Number(b.available ?? 0),
|
|
501
|
+
reserved: Number(b.reserved ?? 0),
|
|
502
|
+
creditExhausted: Boolean(b.credit_exhausted),
|
|
503
|
+
storageDeposit: b.storage_deposit != null ? Number(b.storage_deposit) : undefined,
|
|
504
|
+
};
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
close: async () => {
|
|
509
|
+
/* SDK has no close() */
|
|
510
|
+
},
|
|
511
|
+
seedSecret,
|
|
512
|
+
invokeForward,
|
|
513
|
+
registerContract,
|
|
514
|
+
releaseSecret,
|
|
515
|
+
me,
|
|
516
|
+
grantEgress,
|
|
517
|
+
setAgentGrant,
|
|
518
|
+
verifySecret,
|
|
519
|
+
getBalance,
|
|
520
|
+
isReal: true,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/* ------------------------------------------------------------------ */
|
|
525
|
+
/* MOCK */
|
|
526
|
+
/* ------------------------------------------------------------------ */
|
|
527
|
+
|
|
528
|
+
function openMockClient(): T3ClientHandle {
|
|
529
|
+
return {
|
|
530
|
+
close: async () => {},
|
|
531
|
+
async seedSecret(name, value) {
|
|
532
|
+
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
533
|
+
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
534
|
+
},
|
|
535
|
+
async registerContract(wasm) {
|
|
536
|
+
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
537
|
+
return { contractId: "mock-contract-1" };
|
|
538
|
+
},
|
|
539
|
+
async invokeForward(req) {
|
|
540
|
+
safeLog("info", {
|
|
541
|
+
msg: "mock-forward",
|
|
542
|
+
method: req.method,
|
|
543
|
+
url: schemeAndHost(req.url),
|
|
544
|
+
secret_key: req.secret_key,
|
|
545
|
+
});
|
|
546
|
+
const body = `{"mock":true,"note":"Blindfold mock mode — no real call made.","echo":{"url":${JSON.stringify(req.url)}}}`;
|
|
547
|
+
return { status: 200, headers: [["content-type", "application/json"]], body };
|
|
548
|
+
},
|
|
549
|
+
async releaseSecret(name) {
|
|
550
|
+
safeLog("info", { msg: "mock-release", name });
|
|
551
|
+
return `mock-released:${name}`;
|
|
552
|
+
},
|
|
553
|
+
async me() {
|
|
554
|
+
return { tenant: "did:t3n:mock", status: "active" };
|
|
555
|
+
},
|
|
556
|
+
async grantEgress(hosts) {
|
|
557
|
+
safeLog("info", { msg: "mock-grant-egress", hosts });
|
|
558
|
+
return hosts;
|
|
559
|
+
},
|
|
560
|
+
async setAgentGrant(agentDid, hosts, functions) {
|
|
561
|
+
safeLog("info", { msg: "mock-set-agent-grant", agentDid, hosts, functions });
|
|
562
|
+
},
|
|
563
|
+
async verifySecret(name) {
|
|
564
|
+
return { present: true, length: 0, fingerprint: `mock-${name}`.slice(0, 8) };
|
|
565
|
+
},
|
|
566
|
+
async getBalance() {
|
|
567
|
+
return { available: 1_000_000_000, reserved: 0, creditExhausted: false, mock: true };
|
|
568
|
+
},
|
|
569
|
+
isReal: false,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function schemeAndHost(url: string): string {
|
|
574
|
+
try {
|
|
575
|
+
const u = new URL(url);
|
|
576
|
+
return `${u.protocol}//${u.host}`;
|
|
577
|
+
} catch {
|
|
578
|
+
return "<bad-url>";
|
|
579
|
+
}
|
|
580
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface ForwardRequest {
|
|
2
|
+
method: string;
|
|
3
|
+
url: string;
|
|
4
|
+
headers: Array<[string, string]>;
|
|
5
|
+
body?: string;
|
|
6
|
+
/** Name of the secret in z:<tid>:secrets to substitute into headers. */
|
|
7
|
+
secret_key: string;
|
|
8
|
+
/**
|
|
9
|
+
* Provider auth scheme. Omitted → the enclave defaults to bearer (sentinel
|
|
10
|
+
* swap). For basic/sigv4 the enclave *computes* the Authorization from the
|
|
11
|
+
* sealed secret; the raw secret is never placed in a header on its own.
|
|
12
|
+
* Serialises 1:1 into the contract's tagged `AuthSpec`.
|
|
13
|
+
*/
|
|
14
|
+
auth?:
|
|
15
|
+
| { scheme: "bearer" }
|
|
16
|
+
| { scheme: "basic"; username: string }
|
|
17
|
+
| { scheme: "sigv4"; access_key_id: string; region: string; service: string; amz_date: string }
|
|
18
|
+
| { scheme: "webhook" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ForwardResponse {
|
|
22
|
+
status: number;
|
|
23
|
+
headers: Array<[string, string]>;
|
|
24
|
+
/** Response body bytes (transported as base64 in mock mode for safety). */
|
|
25
|
+
body: number[] | string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BlindfoldEnv {
|
|
29
|
+
t3nApiKey: string;
|
|
30
|
+
did: string;
|
|
31
|
+
port: number;
|
|
32
|
+
t3Env: "testnet" | "production";
|
|
33
|
+
/**
|
|
34
|
+
* Optional override for the T3 node URL (from T3_BASE_URL / BLINDFOLD_BASE_URL).
|
|
35
|
+
* Lets you point at a healthy/leader node when the SDK's default node is
|
|
36
|
+
* unhealthy. Empty string = use the SDK's built-in NODE_URLS[t3Env].
|
|
37
|
+
*/
|
|
38
|
+
t3BaseUrl: string;
|
|
39
|
+
/** When true, all T3 calls are simulated locally. */
|
|
40
|
+
mock: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RegisterOpts {
|
|
44
|
+
/** Logical name (the KV key inside z:<tid>:secrets). */
|
|
45
|
+
name: string;
|
|
46
|
+
/** Optional: env var to read the plaintext value from (scripting). */
|
|
47
|
+
fromEnv?: string;
|
|
48
|
+
/** Optional: explicit value (programmatic API). */
|
|
49
|
+
value?: string;
|
|
50
|
+
}
|