@fiscalmindset/blindfold 0.4.5 → 0.4.6
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/bin/blindfold.ts +1 -1
- package/bin/cmd-secrets.ts +41 -2
- package/dist/cli.mjs +99 -1
- package/dist/lib/index.mjs +17 -0
- package/dist/lib/proxy.mjs +17 -0
- package/dist/lib/register.mjs +17 -0
- package/package.json +1 -1
- package/src/help.ts +7 -0
- package/src/sealed-ledger.ts +34 -0
- package/src/t3-client.ts +27 -0
package/bin/blindfold.ts
CHANGED
|
@@ -18,7 +18,7 @@ type Handler = (cmd: string, argv: Argv, cmdArgs: string[]) => Promise<void>;
|
|
|
18
18
|
|
|
19
19
|
const ROUTES: Record<string, Handler> = {
|
|
20
20
|
signup: handleAuth, login: handleAuth, logout: handleAuth, whoami: handleAuth,
|
|
21
|
-
register: handleSecrets, use: handleSecrets, export: handleSecrets,
|
|
21
|
+
register: handleSecrets, use: handleSecrets, export: handleSecrets, delete: handleSecrets, remove: handleSecrets,
|
|
22
22
|
rotate: handleLifecycle, rollback: handleLifecycle, versions: handleLifecycle, migrate: handleLifecycle,
|
|
23
23
|
grant: handleTenant, share: handleTenant, revoke: handleTenant,
|
|
24
24
|
proxy: handleServe, attest: handleServe, dashboard: handleServe, stats: handleServe, "stats:clear": handleServe,
|
package/bin/cmd-secrets.ts
CHANGED
|
@@ -5,7 +5,8 @@ import { spawn } from "node:child_process";
|
|
|
5
5
|
import { randomBytes } from "node:crypto";
|
|
6
6
|
import { loadBlindfoldEnv, configPath, homeDir, stateDir } from "../src/env.ts";
|
|
7
7
|
import { keychainAvailable, keychainBackend, keychainSet, keychainDelete } from "../src/keychain.ts";
|
|
8
|
-
import { readSecretLine } from "../src/prompt.ts";
|
|
8
|
+
import { readSecretLine, readLine } from "../src/prompt.ts";
|
|
9
|
+
import { c, ok, warn } from "../src/color.ts";
|
|
9
10
|
import { registerSecret, registerContract } from "../src/register.ts";
|
|
10
11
|
import { startProxy } from "../src/proxy.ts";
|
|
11
12
|
import { attest, attestationGate, writePinnedRtmr3 } from "../src/attest.ts";
|
|
@@ -13,7 +14,7 @@ import { startDashboard } from "../src/dashboard.ts";
|
|
|
13
14
|
import { clearUsage, defaultLogPath, readUsage } from "../src/usage-log.ts";
|
|
14
15
|
import { runInit, runVerify } from "../src/init.ts";
|
|
15
16
|
import { runCompat } from "../src/compat.ts";
|
|
16
|
-
import { defaultSealedLogPath, readSealed, verifyLedgerChain } from "../src/sealed-ledger.ts";
|
|
17
|
+
import { defaultSealedLogPath, readSealed, removeSealedEntry, verifyLedgerChain } from "../src/sealed-ledger.ts";
|
|
17
18
|
import { type Argv, die, assetPath, fingerprint, resolveEnvVar } from "./cli-shared.ts";
|
|
18
19
|
|
|
19
20
|
export async function handleSecrets(cmd: string, argv: Argv, cmdArgs: string[]): Promise<void> {
|
|
@@ -49,6 +50,44 @@ export async function handleSecrets(cmd: string, argv: Argv, cmdArgs: string[]):
|
|
|
49
50
|
console.log(` proxy/SDK: set the key to "__BLINDFOLD__" + route via \`blindfold proxy\`, or \`release("${name}")\` in code`);
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
53
|
+
case "delete":
|
|
54
|
+
case "remove": {
|
|
55
|
+
// Delete a sealed secret: empty its value in the enclave (current tenant)
|
|
56
|
+
// AND remove it from the local ledger, re-chaining so `audit` stays valid.
|
|
57
|
+
const name = String(argv.flags.name ?? argv._[1] ?? "");
|
|
58
|
+
if (!name) die("usage: blindfold delete --name <secret> [--yes to skip the prompt]");
|
|
59
|
+
|
|
60
|
+
const inLedger = readSealed().some((e) => e.name === name);
|
|
61
|
+
if (!inLedger) console.error(warn(`⚠ "${name}" isn't in the local ledger — will still try to empty it in the enclave.`));
|
|
62
|
+
|
|
63
|
+
// Confirm (unless --yes/--force). Destructive.
|
|
64
|
+
const skip = Boolean(argv.flags.yes || argv.flags.force || argv.flags.y);
|
|
65
|
+
if (!skip) {
|
|
66
|
+
const ans = (await readLine(`Delete sealed secret ${c.bold(name)}? This empties it in the enclave and removes it from the ledger. Type "yes": `)).trim().toLowerCase();
|
|
67
|
+
if (ans !== "yes" && ans !== "y") { console.log("Aborted — nothing deleted."); return; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Enclave side (best-effort — a secret on a different/old tenant just won't be found).
|
|
71
|
+
const env = loadBlindfoldEnv();
|
|
72
|
+
let enclaveNote = "";
|
|
73
|
+
try {
|
|
74
|
+
const { openT3Client } = await import("../src/t3-client.ts");
|
|
75
|
+
const client = await openT3Client(env);
|
|
76
|
+
try {
|
|
77
|
+
const how = await client.deleteSecret(name);
|
|
78
|
+
enclaveNote = how === "deleted" ? "enclave entry deleted" : "enclave value emptied (key may remain)";
|
|
79
|
+
} finally { await client.close(); }
|
|
80
|
+
} catch (e) {
|
|
81
|
+
enclaveNote = `enclave not updated (${(e as Error).message.slice(0, 80)}) — ledger still cleaned`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const removed = removeSealedEntry(name);
|
|
85
|
+
console.log(ok(`✓ Deleted "${name}".`));
|
|
86
|
+
console.log(` ${c.gray("ledger:")} ${removed > 0 ? `removed ${removed} entr${removed === 1 ? "y" : "ies"} (re-chained; backup kept)` : "not present"}`);
|
|
87
|
+
console.log(` ${c.gray("enclave:")} ${enclaveNote}`);
|
|
88
|
+
if (inLedger) console.log(c.gray(` Verify: blindfold sealed · blindfold audit`));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
52
91
|
case "use": {
|
|
53
92
|
const name = String(argv.flags.name ?? "");
|
|
54
93
|
if (!name) {
|
package/dist/cli.mjs
CHANGED
|
@@ -600,6 +600,18 @@ async function openRealClient(env) {
|
|
|
600
600
|
});
|
|
601
601
|
safeLog("info", { msg: "seeded", name });
|
|
602
602
|
};
|
|
603
|
+
const deleteSecret = async (name) => {
|
|
604
|
+
const map_name = tenant.canonicalName("secrets");
|
|
605
|
+
try {
|
|
606
|
+
await tenant.executeControl("map-entry-delete", { map_name, key: name });
|
|
607
|
+
safeLog("info", { msg: "deleted", name });
|
|
608
|
+
return "deleted";
|
|
609
|
+
} catch {
|
|
610
|
+
await tenant.executeControl("map-entry-set", { map_name, key: name, value: "" });
|
|
611
|
+
safeLog("info", { msg: "emptied", name });
|
|
612
|
+
return "emptied";
|
|
613
|
+
}
|
|
614
|
+
};
|
|
603
615
|
const registerContract4 = async (wasm) => {
|
|
604
616
|
const r = await tenant.contracts.register({
|
|
605
617
|
tail: CONTRACT_TAIL,
|
|
@@ -715,6 +727,7 @@ async function openRealClient(env) {
|
|
|
715
727
|
close: async () => {
|
|
716
728
|
},
|
|
717
729
|
seedSecret,
|
|
730
|
+
deleteSecret,
|
|
718
731
|
invokeForward,
|
|
719
732
|
registerContract: registerContract4,
|
|
720
733
|
releaseSecret,
|
|
@@ -734,6 +747,10 @@ function openMockClient() {
|
|
|
734
747
|
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
735
748
|
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
736
749
|
},
|
|
750
|
+
async deleteSecret(name) {
|
|
751
|
+
safeLog("info", { msg: "mock-delete", name });
|
|
752
|
+
return "deleted";
|
|
753
|
+
},
|
|
737
754
|
async registerContract(wasm) {
|
|
738
755
|
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
739
756
|
return { contractId: "mock-contract-1" };
|
|
@@ -880,6 +897,38 @@ function recordSealed(entry) {
|
|
|
880
897
|
} catch {
|
|
881
898
|
}
|
|
882
899
|
}
|
|
900
|
+
function removeSealedEntry(name) {
|
|
901
|
+
const p = defaultSealedLogPath();
|
|
902
|
+
if (!fs5.existsSync(p)) return 0;
|
|
903
|
+
let removed = 0;
|
|
904
|
+
withFileLockSync(p, () => {
|
|
905
|
+
const all = fs5.readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => {
|
|
906
|
+
try {
|
|
907
|
+
return JSON.parse(l);
|
|
908
|
+
} catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}).filter((e) => e !== null);
|
|
912
|
+
const kept = all.filter((e) => e.name !== name);
|
|
913
|
+
removed = all.length - kept.length;
|
|
914
|
+
if (removed === 0) return;
|
|
915
|
+
try {
|
|
916
|
+
fs5.writeFileSync(`${p}.bak.${Date.now()}`, fs5.readFileSync(p), { mode: 384 });
|
|
917
|
+
} catch {
|
|
918
|
+
}
|
|
919
|
+
const key = ledgerKey();
|
|
920
|
+
let prev = "";
|
|
921
|
+
const rebuilt = kept.map((e) => {
|
|
922
|
+
const core = coreString(e);
|
|
923
|
+
const thisPrev = prev;
|
|
924
|
+
const { hash, alg } = chainHash(key, thisPrev, core);
|
|
925
|
+
prev = hash;
|
|
926
|
+
return { ...e, prev: thisPrev, hash, ...alg ? { alg } : {} };
|
|
927
|
+
});
|
|
928
|
+
fs5.writeFileSync(p, rebuilt.map((e) => JSON.stringify(e)).join("\n") + (rebuilt.length ? "\n" : ""));
|
|
929
|
+
});
|
|
930
|
+
return removed;
|
|
931
|
+
}
|
|
883
932
|
function readSealed() {
|
|
884
933
|
const p = defaultSealedLogPath();
|
|
885
934
|
if (!fs5.existsSync(p)) return [];
|
|
@@ -1589,6 +1638,16 @@ var COMMANDS = [
|
|
|
1589
1638
|
examples: ["blindfold rollback --name stripe_secret_key"]
|
|
1590
1639
|
},
|
|
1591
1640
|
{ name: "versions", group: "\u{1F511} Secrets", usage: "[--name <secret>]", summary: "List the snapshots available to roll back to (metadata only).", flags: [["--name <secret>", "Limit to one secret."]], examples: ["blindfold versions --name stripe_secret_key"] },
|
|
1641
|
+
{
|
|
1642
|
+
name: "delete",
|
|
1643
|
+
group: "\u{1F511} Secrets",
|
|
1644
|
+
usage: "--name <secret> [--yes]",
|
|
1645
|
+
aliases: ["remove"],
|
|
1646
|
+
summary: "Delete a sealed secret: empty its value in the enclave AND remove it from the local ledger (re-chained; backup kept). Use if you sealed the wrong thing.",
|
|
1647
|
+
flags: [["--name <secret>", "The sealed secret to delete (required)."], ["--yes", "Skip the confirmation prompt (destructive)."]],
|
|
1648
|
+
examples: ["blindfold delete --name old_api_key"],
|
|
1649
|
+
notes: "Re-seal correctly with `blindfold register` afterward. If the value was a real key, revoke it at the provider too \u2014 deleting here doesn't un-expose anything already leaked."
|
|
1650
|
+
},
|
|
1592
1651
|
{
|
|
1593
1652
|
name: "migrate",
|
|
1594
1653
|
group: "\u{1F511} Secrets",
|
|
@@ -1923,9 +1982,10 @@ async function handleAuth(cmd, argv, cmdArgs) {
|
|
|
1923
1982
|
|
|
1924
1983
|
// bin/cmd-secrets.ts
|
|
1925
1984
|
init_env();
|
|
1926
|
-
|
|
1985
|
+
init_prompt();
|
|
1927
1986
|
import fs8 from "node:fs";
|
|
1928
1987
|
import { spawn } from "node:child_process";
|
|
1988
|
+
init_register();
|
|
1929
1989
|
|
|
1930
1990
|
// src/attest.ts
|
|
1931
1991
|
init_env();
|
|
@@ -2054,6 +2114,7 @@ async function attestationGate(opts = {}) {
|
|
|
2054
2114
|
}
|
|
2055
2115
|
|
|
2056
2116
|
// bin/cmd-secrets.ts
|
|
2117
|
+
init_sealed_ledger();
|
|
2057
2118
|
async function handleSecrets(cmd, argv, cmdArgs) {
|
|
2058
2119
|
switch (cmd) {
|
|
2059
2120
|
case "register": {
|
|
@@ -2084,6 +2145,41 @@ async function handleSecrets(cmd, argv, cmdArgs) {
|
|
|
2084
2145
|
console.log(` proxy/SDK: set the key to "__BLINDFOLD__" + route via \`blindfold proxy\`, or \`release("${name}")\` in code`);
|
|
2085
2146
|
return;
|
|
2086
2147
|
}
|
|
2148
|
+
case "delete":
|
|
2149
|
+
case "remove": {
|
|
2150
|
+
const name = String(argv.flags.name ?? argv._[1] ?? "");
|
|
2151
|
+
if (!name) die("usage: blindfold delete --name <secret> [--yes to skip the prompt]");
|
|
2152
|
+
const inLedger = readSealed().some((e) => e.name === name);
|
|
2153
|
+
if (!inLedger) console.error(warn(`\u26A0 "${name}" isn't in the local ledger \u2014 will still try to empty it in the enclave.`));
|
|
2154
|
+
const skip = Boolean(argv.flags.yes || argv.flags.force || argv.flags.y);
|
|
2155
|
+
if (!skip) {
|
|
2156
|
+
const ans = (await readLine(`Delete sealed secret ${c.bold(name)}? This empties it in the enclave and removes it from the ledger. Type "yes": `)).trim().toLowerCase();
|
|
2157
|
+
if (ans !== "yes" && ans !== "y") {
|
|
2158
|
+
console.log("Aborted \u2014 nothing deleted.");
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
const env = loadBlindfoldEnv();
|
|
2163
|
+
let enclaveNote = "";
|
|
2164
|
+
try {
|
|
2165
|
+
const { openT3Client: openT3Client2 } = await Promise.resolve().then(() => (init_t3_client(), t3_client_exports));
|
|
2166
|
+
const client = await openT3Client2(env);
|
|
2167
|
+
try {
|
|
2168
|
+
const how = await client.deleteSecret(name);
|
|
2169
|
+
enclaveNote = how === "deleted" ? "enclave entry deleted" : "enclave value emptied (key may remain)";
|
|
2170
|
+
} finally {
|
|
2171
|
+
await client.close();
|
|
2172
|
+
}
|
|
2173
|
+
} catch (e) {
|
|
2174
|
+
enclaveNote = `enclave not updated (${e.message.slice(0, 80)}) \u2014 ledger still cleaned`;
|
|
2175
|
+
}
|
|
2176
|
+
const removed = removeSealedEntry(name);
|
|
2177
|
+
console.log(ok(`\u2713 Deleted "${name}".`));
|
|
2178
|
+
console.log(` ${c.gray("ledger:")} ${removed > 0 ? `removed ${removed} entr${removed === 1 ? "y" : "ies"} (re-chained; backup kept)` : "not present"}`);
|
|
2179
|
+
console.log(` ${c.gray("enclave:")} ${enclaveNote}`);
|
|
2180
|
+
if (inLedger) console.log(c.gray(` Verify: blindfold sealed \xB7 blindfold audit`));
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2087
2183
|
case "use": {
|
|
2088
2184
|
const name = String(argv.flags.name ?? "");
|
|
2089
2185
|
if (!name) {
|
|
@@ -4624,6 +4720,8 @@ var ROUTES = {
|
|
|
4624
4720
|
register: handleSecrets,
|
|
4625
4721
|
use: handleSecrets,
|
|
4626
4722
|
export: handleSecrets,
|
|
4723
|
+
delete: handleSecrets,
|
|
4724
|
+
remove: handleSecrets,
|
|
4627
4725
|
rotate: handleLifecycle,
|
|
4628
4726
|
rollback: handleLifecycle,
|
|
4629
4727
|
versions: handleLifecycle,
|
package/dist/lib/index.mjs
CHANGED
|
@@ -479,6 +479,18 @@ async function openRealClient(env) {
|
|
|
479
479
|
});
|
|
480
480
|
safeLog("info", { msg: "seeded", name });
|
|
481
481
|
};
|
|
482
|
+
const deleteSecret = async (name) => {
|
|
483
|
+
const map_name = tenant.canonicalName("secrets");
|
|
484
|
+
try {
|
|
485
|
+
await tenant.executeControl("map-entry-delete", { map_name, key: name });
|
|
486
|
+
safeLog("info", { msg: "deleted", name });
|
|
487
|
+
return "deleted";
|
|
488
|
+
} catch {
|
|
489
|
+
await tenant.executeControl("map-entry-set", { map_name, key: name, value: "" });
|
|
490
|
+
safeLog("info", { msg: "emptied", name });
|
|
491
|
+
return "emptied";
|
|
492
|
+
}
|
|
493
|
+
};
|
|
482
494
|
const registerContract2 = async (wasm) => {
|
|
483
495
|
const r = await tenant.contracts.register({
|
|
484
496
|
tail: CONTRACT_TAIL,
|
|
@@ -594,6 +606,7 @@ async function openRealClient(env) {
|
|
|
594
606
|
close: async () => {
|
|
595
607
|
},
|
|
596
608
|
seedSecret,
|
|
609
|
+
deleteSecret,
|
|
597
610
|
invokeForward,
|
|
598
611
|
registerContract: registerContract2,
|
|
599
612
|
releaseSecret,
|
|
@@ -613,6 +626,10 @@ function openMockClient() {
|
|
|
613
626
|
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
614
627
|
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
615
628
|
},
|
|
629
|
+
async deleteSecret(name) {
|
|
630
|
+
safeLog("info", { msg: "mock-delete", name });
|
|
631
|
+
return "deleted";
|
|
632
|
+
},
|
|
616
633
|
async registerContract(wasm) {
|
|
617
634
|
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
618
635
|
return { contractId: "mock-contract-1" };
|
package/dist/lib/proxy.mjs
CHANGED
|
@@ -387,6 +387,18 @@ async function openRealClient(env) {
|
|
|
387
387
|
});
|
|
388
388
|
safeLog("info", { msg: "seeded", name });
|
|
389
389
|
};
|
|
390
|
+
const deleteSecret = async (name) => {
|
|
391
|
+
const map_name = tenant.canonicalName("secrets");
|
|
392
|
+
try {
|
|
393
|
+
await tenant.executeControl("map-entry-delete", { map_name, key: name });
|
|
394
|
+
safeLog("info", { msg: "deleted", name });
|
|
395
|
+
return "deleted";
|
|
396
|
+
} catch {
|
|
397
|
+
await tenant.executeControl("map-entry-set", { map_name, key: name, value: "" });
|
|
398
|
+
safeLog("info", { msg: "emptied", name });
|
|
399
|
+
return "emptied";
|
|
400
|
+
}
|
|
401
|
+
};
|
|
390
402
|
const registerContract = async (wasm) => {
|
|
391
403
|
const r = await tenant.contracts.register({
|
|
392
404
|
tail: CONTRACT_TAIL,
|
|
@@ -502,6 +514,7 @@ async function openRealClient(env) {
|
|
|
502
514
|
close: async () => {
|
|
503
515
|
},
|
|
504
516
|
seedSecret,
|
|
517
|
+
deleteSecret,
|
|
505
518
|
invokeForward,
|
|
506
519
|
registerContract,
|
|
507
520
|
releaseSecret,
|
|
@@ -521,6 +534,10 @@ function openMockClient() {
|
|
|
521
534
|
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
522
535
|
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
523
536
|
},
|
|
537
|
+
async deleteSecret(name) {
|
|
538
|
+
safeLog("info", { msg: "mock-delete", name });
|
|
539
|
+
return "deleted";
|
|
540
|
+
},
|
|
524
541
|
async registerContract(wasm) {
|
|
525
542
|
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
526
543
|
return { contractId: "mock-contract-1" };
|
package/dist/lib/register.mjs
CHANGED
|
@@ -524,6 +524,18 @@ async function openRealClient(env) {
|
|
|
524
524
|
});
|
|
525
525
|
safeLog("info", { msg: "seeded", name });
|
|
526
526
|
};
|
|
527
|
+
const deleteSecret = async (name) => {
|
|
528
|
+
const map_name = tenant.canonicalName("secrets");
|
|
529
|
+
try {
|
|
530
|
+
await tenant.executeControl("map-entry-delete", { map_name, key: name });
|
|
531
|
+
safeLog("info", { msg: "deleted", name });
|
|
532
|
+
return "deleted";
|
|
533
|
+
} catch {
|
|
534
|
+
await tenant.executeControl("map-entry-set", { map_name, key: name, value: "" });
|
|
535
|
+
safeLog("info", { msg: "emptied", name });
|
|
536
|
+
return "emptied";
|
|
537
|
+
}
|
|
538
|
+
};
|
|
527
539
|
const registerContract2 = async (wasm) => {
|
|
528
540
|
const r = await tenant.contracts.register({
|
|
529
541
|
tail: CONTRACT_TAIL,
|
|
@@ -639,6 +651,7 @@ async function openRealClient(env) {
|
|
|
639
651
|
close: async () => {
|
|
640
652
|
},
|
|
641
653
|
seedSecret,
|
|
654
|
+
deleteSecret,
|
|
642
655
|
invokeForward,
|
|
643
656
|
registerContract: registerContract2,
|
|
644
657
|
releaseSecret,
|
|
@@ -658,6 +671,10 @@ function openMockClient() {
|
|
|
658
671
|
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
659
672
|
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
660
673
|
},
|
|
674
|
+
async deleteSecret(name) {
|
|
675
|
+
safeLog("info", { msg: "mock-delete", name });
|
|
676
|
+
return "deleted";
|
|
677
|
+
},
|
|
661
678
|
async registerContract(wasm) {
|
|
662
679
|
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
663
680
|
return { contractId: "mock-contract-1" };
|
package/package.json
CHANGED
package/src/help.ts
CHANGED
|
@@ -103,6 +103,13 @@ export const COMMANDS: CmdDef[] = [
|
|
|
103
103
|
examples: ["blindfold rollback --name stripe_secret_key"],
|
|
104
104
|
},
|
|
105
105
|
{ name: "versions", group: "🔑 Secrets", usage: "[--name <secret>]", summary: "List the snapshots available to roll back to (metadata only).", flags: [["--name <secret>", "Limit to one secret."]], examples: ["blindfold versions --name stripe_secret_key"] },
|
|
106
|
+
{
|
|
107
|
+
name: "delete", group: "🔑 Secrets", usage: "--name <secret> [--yes]", aliases: ["remove"],
|
|
108
|
+
summary: "Delete a sealed secret: empty its value in the enclave AND remove it from the local ledger (re-chained; backup kept). Use if you sealed the wrong thing.",
|
|
109
|
+
flags: [["--name <secret>", "The sealed secret to delete (required)."], ["--yes", "Skip the confirmation prompt (destructive)."]],
|
|
110
|
+
examples: ["blindfold delete --name old_api_key"],
|
|
111
|
+
notes: "Re-seal correctly with `blindfold register` afterward. If the value was a real key, revoke it at the provider too — deleting here doesn't un-expose anything already leaked.",
|
|
112
|
+
},
|
|
106
113
|
{
|
|
107
114
|
name: "migrate", group: "🔑 Secrets", usage: "[--dry-run] [--keep]",
|
|
108
115
|
summary: "Seal every secret in .env at once, then remove the plaintext lines (backup kept).",
|
package/src/sealed-ledger.ts
CHANGED
|
@@ -111,6 +111,40 @@ export function recordSealed(entry: SealedEntry): void {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Remove every entry with the given name from the ledger and RE-CHAIN what
|
|
116
|
+
* remains, so the tamper-evident hash-chain stays valid (an owner-initiated
|
|
117
|
+
* delete is legitimate, unlike a stealth edit). Backs the old ledger up first.
|
|
118
|
+
* Returns the number of entries removed.
|
|
119
|
+
*/
|
|
120
|
+
export function removeSealedEntry(name: string): number {
|
|
121
|
+
const p = defaultSealedLogPath();
|
|
122
|
+
if (!fs.existsSync(p)) return 0;
|
|
123
|
+
let removed = 0;
|
|
124
|
+
withFileLockSync(p, () => {
|
|
125
|
+
const all = fs.readFileSync(p, "utf8").split("\n").filter(Boolean)
|
|
126
|
+
.map((l) => { try { return JSON.parse(l) as SealedEntry; } catch { return null; } })
|
|
127
|
+
.filter((e): e is SealedEntry => e !== null);
|
|
128
|
+
const kept = all.filter((e) => e.name !== name);
|
|
129
|
+
removed = all.length - kept.length;
|
|
130
|
+
if (removed === 0) return;
|
|
131
|
+
// Backup the pre-delete ledger (0600) so nothing is ever silently lost.
|
|
132
|
+
try { fs.writeFileSync(`${p}.bak.${Date.now()}`, fs.readFileSync(p), { mode: 0o600 }); } catch { /* best effort */ }
|
|
133
|
+
// Re-chain the survivors into a fresh, valid HMAC chain.
|
|
134
|
+
const key = ledgerKey();
|
|
135
|
+
let prev = "";
|
|
136
|
+
const rebuilt = kept.map((e) => {
|
|
137
|
+
const core = coreString(e); // preserves original t/name/source/length/mode/tenant/map
|
|
138
|
+
const thisPrev = prev; // the PREVIOUS entry's hash (not this one's)
|
|
139
|
+
const { hash, alg } = chainHash(key, thisPrev, core);
|
|
140
|
+
prev = hash;
|
|
141
|
+
return { ...e, prev: thisPrev, hash, ...(alg ? { alg } : {}) };
|
|
142
|
+
});
|
|
143
|
+
fs.writeFileSync(p, rebuilt.map((e) => JSON.stringify(e)).join("\n") + (rebuilt.length ? "\n" : ""));
|
|
144
|
+
});
|
|
145
|
+
return removed;
|
|
146
|
+
}
|
|
147
|
+
|
|
114
148
|
export function readSealed(): SealedEntry[] {
|
|
115
149
|
const p = defaultSealedLogPath();
|
|
116
150
|
if (!fs.existsSync(p)) return [];
|
package/src/t3-client.ts
CHANGED
|
@@ -141,6 +141,12 @@ async function loadSdk(): Promise<T3Sdk> {
|
|
|
141
141
|
export interface T3ClientHandle {
|
|
142
142
|
close: () => Promise<void>;
|
|
143
143
|
seedSecret: (name: string, value: string) => Promise<void>;
|
|
144
|
+
/**
|
|
145
|
+
* Delete a sealed secret from the enclave's secrets map. Tries a true
|
|
146
|
+
* map-entry-delete; if the control plane lacks it, overwrites the value with
|
|
147
|
+
* empty so the plaintext is gone. Returns which happened.
|
|
148
|
+
*/
|
|
149
|
+
deleteSecret: (name: string) => Promise<"deleted" | "emptied">;
|
|
144
150
|
invokeForward: (req: ForwardRequest) => Promise<ForwardResponse>;
|
|
145
151
|
registerContract: (wasm: Uint8Array) => Promise<{ contractId: string | number }>;
|
|
146
152
|
/**
|
|
@@ -364,6 +370,22 @@ async function openRealClient(env: BlindfoldEnv): Promise<T3ClientHandle> {
|
|
|
364
370
|
safeLog("info", { msg: "seeded", name });
|
|
365
371
|
};
|
|
366
372
|
|
|
373
|
+
const deleteSecret = async (name: string): Promise<"deleted" | "emptied"> => {
|
|
374
|
+
const map_name = tenant.canonicalName("secrets");
|
|
375
|
+
try {
|
|
376
|
+
// Preferred: a true delete of the map entry (removes key + value).
|
|
377
|
+
await tenant.executeControl("map-entry-delete", { map_name, key: name });
|
|
378
|
+
safeLog("info", { msg: "deleted", name });
|
|
379
|
+
return "deleted";
|
|
380
|
+
} catch {
|
|
381
|
+
// Fallback: the control plane may not expose delete — overwrite the value
|
|
382
|
+
// with empty so the plaintext is gone (the key may remain).
|
|
383
|
+
await tenant.executeControl("map-entry-set", { map_name, key: name, value: "" });
|
|
384
|
+
safeLog("info", { msg: "emptied", name });
|
|
385
|
+
return "emptied";
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
367
389
|
const registerContract = async (wasm: Uint8Array): Promise<{ contractId: string | number }> => {
|
|
368
390
|
const r = (await tenant.contracts.register({
|
|
369
391
|
tail: CONTRACT_TAIL,
|
|
@@ -509,6 +531,7 @@ async function openRealClient(env: BlindfoldEnv): Promise<T3ClientHandle> {
|
|
|
509
531
|
/* SDK has no close() */
|
|
510
532
|
},
|
|
511
533
|
seedSecret,
|
|
534
|
+
deleteSecret,
|
|
512
535
|
invokeForward,
|
|
513
536
|
registerContract,
|
|
514
537
|
releaseSecret,
|
|
@@ -532,6 +555,10 @@ function openMockClient(): T3ClientHandle {
|
|
|
532
555
|
if (!value || value.length === 0) throw new Error(`secret ${name} is empty`);
|
|
533
556
|
safeLog("info", { msg: "mock-seed (value dropped, length only)", name, length: value.length });
|
|
534
557
|
},
|
|
558
|
+
async deleteSecret(name) {
|
|
559
|
+
safeLog("info", { msg: "mock-delete", name });
|
|
560
|
+
return "deleted";
|
|
561
|
+
},
|
|
535
562
|
async registerContract(wasm) {
|
|
536
563
|
safeLog("info", { msg: "mock-register-contract", wasmBytes: wasm.byteLength });
|
|
537
564
|
return { contractId: "mock-contract-1" };
|