autho 3.0.3 → 3.0.5

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.
Files changed (2) hide show
  1. package/dist/autho.js +1433 -285
  2. package/package.json +1 -1
package/dist/autho.js CHANGED
@@ -590,7 +590,7 @@ var package_default;
590
590
  var init_package = __esm(() => {
591
591
  package_default = {
592
592
  name: "autho",
593
- version: "3.0.3",
593
+ version: "3.0.5",
594
594
  description: "Local-first secret manager for humans and coding agents, rebuilt on Bun",
595
595
  type: "module",
596
596
  bin: {
@@ -632,6 +632,19 @@ var init_package = __esm(() => {
632
632
  import { Database } from "bun:sqlite";
633
633
  import { chmodSync, existsSync, mkdirSync } from "fs";
634
634
  import { dirname } from "path";
635
+ function protectedTable(tableName) {
636
+ if (!Object.hasOwn(PROTECTED_COLUMNS, tableName)) {
637
+ throw new Error(`Not a sealed table: ${tableName}`);
638
+ }
639
+ return tableName;
640
+ }
641
+ function protectedRowWhere(table, rowId) {
642
+ if (table === "secret_links") {
643
+ const [primaryId, linkedId] = rowId.split(">");
644
+ return { sql: "primary_id = ?1 AND linked_id = ?2", values: [primaryId ?? "", linkedId ?? ""] };
645
+ }
646
+ return { sql: `${table === "meta" ? "key" : "id"} = ?1`, values: [rowId] };
647
+ }
635
648
  function parseJson(value) {
636
649
  return JSON.parse(value);
637
650
  }
@@ -660,6 +673,12 @@ class AuthoDatabase {
660
673
  transaction(fn) {
661
674
  return this.db.transaction(fn)();
662
675
  }
676
+ immediateTransaction(fn) {
677
+ return this.db.transaction(fn).immediate();
678
+ }
679
+ get inTransaction() {
680
+ return this.db.inTransaction;
681
+ }
663
682
  close() {
664
683
  this.hardenStorageFiles();
665
684
  this.db.close();
@@ -711,7 +730,30 @@ class AuthoDatabase {
711
730
  metadata TEXT NOT NULL,
712
731
  created_at TEXT NOT NULL
713
732
  );
733
+
734
+ CREATE TABLE IF NOT EXISTS secret_links (
735
+ primary_id TEXT NOT NULL,
736
+ linked_id TEXT NOT NULL,
737
+ created_at TEXT NOT NULL,
738
+ PRIMARY KEY (primary_id, linked_id)
739
+ );
740
+ CREATE INDEX IF NOT EXISTS secret_links_linked ON secret_links (linked_id);
741
+
742
+ CREATE TABLE IF NOT EXISTS seal_rows (
743
+ table_name TEXT NOT NULL,
744
+ row_id TEXT NOT NULL,
745
+ mac TEXT NOT NULL,
746
+ PRIMARY KEY (table_name, row_id)
747
+ );
714
748
  `);
749
+ const leaseColumns = this.db.query("PRAGMA table_info(leases)").all();
750
+ if (!leaseColumns.some((column) => column.name === "include_links")) {
751
+ this.db.exec("ALTER TABLE leases ADD COLUMN include_links INTEGER NOT NULL DEFAULT 1");
752
+ }
753
+ const sealRowColumns = this.db.query("PRAGMA table_info(seal_rows)").all();
754
+ if (!sealRowColumns.some((column) => column.name === "row_json")) {
755
+ this.db.exec("ALTER TABLE seal_rows ADD COLUMN row_json TEXT");
756
+ }
715
757
  }
716
758
  getVaultConfig() {
717
759
  const row = this.db.query("SELECT value FROM meta WHERE key = ?1").get("vault.config");
@@ -797,11 +839,32 @@ class AuthoDatabase {
797
839
  this.db.query(`UPDATE secrets SET ${parts.join(", ")} WHERE id = ?${idx}`).run(...values);
798
840
  }
799
841
  deleteSecret(id) {
842
+ this.db.query("DELETE FROM secret_links WHERE primary_id = ?1 OR linked_id = ?1").run(id);
800
843
  this.db.query("DELETE FROM secrets WHERE id = ?1").run(id);
801
844
  }
845
+ insertLink(link) {
846
+ this.db.query("INSERT OR IGNORE INTO secret_links (primary_id, linked_id, created_at) VALUES (?1, ?2, ?3)").run(link.primaryId, link.linkedId, link.createdAt);
847
+ }
848
+ deleteLink(primaryId, linkedId) {
849
+ return this.db.query("DELETE FROM secret_links WHERE primary_id = ?1 AND linked_id = ?2").run(primaryId, linkedId).changes > 0;
850
+ }
851
+ hasLink(primaryId, linkedId) {
852
+ return this.db.query("SELECT 1 FROM secret_links WHERE primary_id = ?1 AND linked_id = ?2").get(primaryId, linkedId) !== null;
853
+ }
854
+ listLinksFrom(id) {
855
+ return this.db.query(`SELECT primary_id AS primaryId, linked_id AS linkedId, created_at AS createdAt
856
+ FROM secret_links WHERE primary_id = ?1 ORDER BY created_at ASC`).all(id);
857
+ }
858
+ listLinksTo(id) {
859
+ return this.db.query(`SELECT primary_id AS primaryId, linked_id AS linkedId, created_at AS createdAt
860
+ FROM secret_links WHERE linked_id = ?1 ORDER BY created_at ASC`).all(id);
861
+ }
862
+ listLinks() {
863
+ return this.db.query("SELECT primary_id AS primaryId, linked_id AS linkedId, created_at AS createdAt FROM secret_links").all();
864
+ }
802
865
  insertLease(lease) {
803
- this.db.query(`INSERT INTO leases (id, name, secret_refs, expires_at, revoked_at, created_at)
804
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)`).run(lease.id, lease.name, lease.secretRefs, lease.expiresAt, lease.revokedAt, lease.createdAt);
866
+ this.db.query(`INSERT INTO leases (id, name, secret_refs, expires_at, revoked_at, created_at, include_links)
867
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`).run(lease.id, lease.name, lease.secretRefs, lease.expiresAt, lease.revokedAt, lease.createdAt, lease.includeLinks);
805
868
  }
806
869
  findLease(id) {
807
870
  const row = this.db.query(`SELECT
@@ -810,7 +873,8 @@ class AuthoDatabase {
810
873
  secret_refs AS secretRefs,
811
874
  expires_at AS expiresAt,
812
875
  revoked_at AS revokedAt,
813
- created_at AS createdAt
876
+ created_at AS createdAt,
877
+ include_links AS includeLinks
814
878
  FROM leases
815
879
  WHERE id = ?1
816
880
  LIMIT 1`).get(id);
@@ -819,6 +883,55 @@ class AuthoDatabase {
819
883
  revokeLease(id, revokedAt) {
820
884
  this.db.query("UPDATE leases SET revoked_at = ?2 WHERE id = ?1").run(id, revokedAt);
821
885
  }
886
+ revokeActiveLeases(nowIso) {
887
+ return this.db.query("UPDATE leases SET revoked_at = ?1 WHERE revoked_at IS NULL AND expires_at > ?1").run(nowIso).changes;
888
+ }
889
+ listProtectedRows() {
890
+ const select = (table) => `SELECT ${PROTECTED_COLUMNS[table].join(", ")} FROM ${table}`;
891
+ const secrets = this.db.query(select("secrets")).all();
892
+ const links = this.db.query(select("secret_links")).all();
893
+ const leases = this.db.query(select("leases")).all();
894
+ const meta = this.db.query(`${select("meta")} WHERE key != ?1`).all(SEAL_META_KEY);
895
+ return [
896
+ ...secrets.map((row) => ({ row, rowId: String(row.id), tableName: "secrets" })),
897
+ ...links.map((row) => ({ row, rowId: `${row.primary_id}>${row.linked_id}`, tableName: "secret_links" })),
898
+ ...leases.map((row) => ({ row, rowId: String(row.id), tableName: "leases" })),
899
+ ...meta.map((row) => ({ row, rowId: String(row.key), tableName: "meta" }))
900
+ ];
901
+ }
902
+ getSeal() {
903
+ const row = this.db.query("SELECT value FROM meta WHERE key = ?1").get(SEAL_META_KEY);
904
+ return row?.value ?? null;
905
+ }
906
+ setSeal(seal, rows) {
907
+ this.db.query("INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)").run(SEAL_META_KEY, seal);
908
+ this.db.exec("DELETE FROM seal_rows");
909
+ const insert = this.db.query("INSERT INTO seal_rows (table_name, row_id, mac, row_json) VALUES (?1, ?2, ?3, ?4)");
910
+ for (const row of rows) {
911
+ insert.run(row.tableName, row.rowId, row.mac, row.snapshot);
912
+ }
913
+ }
914
+ listSealRows() {
915
+ return this.db.query("SELECT table_name AS tableName, row_id AS rowId, mac, row_json AS snapshot FROM seal_rows").all();
916
+ }
917
+ deleteProtectedRow(tableName, rowId) {
918
+ const table = protectedTable(tableName);
919
+ if (table === "meta" && rowId === SEAL_META_KEY) {
920
+ throw new Error("The seal is not a protected row");
921
+ }
922
+ const where = protectedRowWhere(table, rowId);
923
+ this.db.query(`DELETE FROM ${table} WHERE ${where.sql}`).run(...where.values);
924
+ }
925
+ insertProtectedRow(tableName, row) {
926
+ const table = protectedTable(tableName);
927
+ const columns = PROTECTED_COLUMNS[table];
928
+ if (table === "meta" && row.key === SEAL_META_KEY) {
929
+ throw new Error("The seal is not a protected row");
930
+ }
931
+ const placeholders = columns.map((_, index) => `?${index + 1}`).join(", ");
932
+ const values = columns.map((column) => row[column] ?? null);
933
+ this.db.query(`INSERT INTO ${table} (${columns.join(", ")}) VALUES (${placeholders})`).run(...values);
934
+ }
822
935
  insertAudit(event) {
823
936
  this.db.query(`INSERT INTO audit_events (id, event_type, subject_type, subject_ref, message, metadata, created_at)
824
937
  VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`).run(event.id, event.eventType, event.subjectType, event.subjectRef, event.message, event.metadata, event.createdAt);
@@ -837,7 +950,15 @@ class AuthoDatabase {
837
950
  LIMIT ?1`).all(limit);
838
951
  }
839
952
  }
840
- var init_src = () => {};
953
+ var PROTECTED_COLUMNS, SEAL_META_KEY = "vault.seal";
954
+ var init_src = __esm(() => {
955
+ PROTECTED_COLUMNS = {
956
+ leases: ["id", "name", "secret_refs", "expires_at", "revoked_at", "created_at", "include_links"],
957
+ meta: ["key", "value"],
958
+ secret_links: ["primary_id", "linked_id", "created_at"],
959
+ secrets: ["id", "name", "type", "payload", "wrapped_key", "created_at", "updated_at"]
960
+ };
961
+ });
841
962
 
842
963
  // packages/crypto/src/index.ts
843
964
  import {
@@ -889,7 +1010,10 @@ function decryptWithKey(blob, key, aad) {
889
1010
  decipher.final()
890
1011
  ]);
891
1012
  }
892
- function createVaultConfig(password, rootKey = randomBytes2(32)) {
1013
+ function wrapRootKey(rootKey, passwordKey, sealed) {
1014
+ return encryptWithKey(rootKey, passwordKey, sealed ? SEALED_ROOT_AAD : ROOT_AAD);
1015
+ }
1016
+ function createVaultConfig(password, rootKey = randomBytes2(32), options) {
893
1017
  const kdf = {
894
1018
  ...DEFAULT_KDF,
895
1019
  salt: randomBytes2(16).toString("base64")
@@ -900,14 +1024,20 @@ function createVaultConfig(password, rootKey = randomBytes2(32)) {
900
1024
  createdAt: new Date().toISOString(),
901
1025
  kdf,
902
1026
  version: 1,
903
- wrappedRootKey: encryptWithKey(rootKey, key, "autho:vault-root")
1027
+ wrappedRootKey: wrapRootKey(rootKey, key, options?.sealed ?? false)
904
1028
  },
905
1029
  rootKey
906
1030
  };
907
1031
  }
1032
+ function unwrapRootKey(config, passwordKey) {
1033
+ try {
1034
+ return { rootKey: decryptWithKey(config.wrappedRootKey, passwordKey, SEALED_ROOT_AAD), sealed: true };
1035
+ } catch {
1036
+ return { rootKey: decryptWithKey(config.wrappedRootKey, passwordKey, ROOT_AAD), sealed: false };
1037
+ }
1038
+ }
908
1039
  function unlockRootKey(password, config) {
909
- const key = deriveKeyFromPassword(password, config.kdf);
910
- return decryptWithKey(config.wrappedRootKey, key, "autho:vault-root");
1040
+ return unwrapRootKey(config, deriveKeyFromPassword(password, config.kdf)).rootKey;
911
1041
  }
912
1042
  function decodeBase32(input) {
913
1043
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
@@ -981,7 +1111,7 @@ function verifyTotpCode(secret, code, opts) {
981
1111
  }
982
1112
  return false;
983
1113
  }
984
- var DEFAULT_KDF;
1114
+ var DEFAULT_KDF, ROOT_AAD = "autho:vault-root", SEALED_ROOT_AAD = "autho:vault-root:sealed";
985
1115
  var init_src2 = __esm(() => {
986
1116
  DEFAULT_KDF = {
987
1117
  keyLength: 32,
@@ -1209,6 +1339,182 @@ var init_artifacts = __esm(() => {
1209
1339
  init_paths();
1210
1340
  });
1211
1341
 
1342
+ // packages/core/src/seal.ts
1343
+ import { createHmac as createHmac2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1344
+ function sealKey(rootKey) {
1345
+ return deriveKeyFromSecret(rootKey, Buffer.from(`${SEAL_INFO}:v1`, "utf8"), SEAL_INFO);
1346
+ }
1347
+ function canonical(row) {
1348
+ return JSON.stringify(Object.keys(row).sort().map((key) => [key, row[key] ?? null]));
1349
+ }
1350
+ function computeRowMac(key, row) {
1351
+ return createHmac2("sha256", key).update(`${ROW_MAC_VERSION}\x00${row.tableName}\x00${row.rowId}\x00${canonical(row.row)}`).digest("base64");
1352
+ }
1353
+ function rowMac(key, row) {
1354
+ return { mac: computeRowMac(key, row), rowId: row.rowId, snapshot: JSON.stringify(row.row), tableName: row.tableName };
1355
+ }
1356
+ function rowKey(row) {
1357
+ return `${row.tableName}\x00${row.rowId}`;
1358
+ }
1359
+ function rootMac(key, seal, rows) {
1360
+ const lines = rows.map((row) => `${rowKey(row)}\x00${row.mac}`).sort();
1361
+ const header = seal.version === 1 ? "autho-seal:v1" : `autho-seal:v2
1362
+ ${seal.vaultId}
1363
+ ${seal.counter}`;
1364
+ return createHmac2("sha256", key).update(`${header}
1365
+ ${lines.join(`
1366
+ `)}`).digest("base64");
1367
+ }
1368
+ function validSeal(seal) {
1369
+ if (seal.version === 1)
1370
+ return true;
1371
+ return seal.version === 2 && Number.isSafeInteger(seal.counter) && typeof seal.vaultId === "string" && seal.vaultId !== "";
1372
+ }
1373
+ function sameMac(a, b) {
1374
+ const left = Buffer.from(a, "utf8");
1375
+ const right = Buffer.from(b, "utf8");
1376
+ return left.length === right.length && timingSafeEqual2(left, right);
1377
+ }
1378
+ function readSeal(db) {
1379
+ const raw = db.getSeal();
1380
+ if (raw === null)
1381
+ return null;
1382
+ try {
1383
+ return JSON.parse(raw);
1384
+ } catch {
1385
+ return { mac: "", sealedAt: "", version: -1 };
1386
+ }
1387
+ }
1388
+ function diffRows(stored, current) {
1389
+ const before = new Map(stored.map((row) => [rowKey(row), row]));
1390
+ const after = new Map(current.map((row) => [rowKey(row), row]));
1391
+ const changes = [];
1392
+ for (const [id, row] of after) {
1393
+ const old = before.get(id);
1394
+ if (!old)
1395
+ changes.push({ change: "added", rowId: row.rowId, table: row.tableName });
1396
+ else if (!sameMac(old.mac, row.mac))
1397
+ changes.push({ change: "modified", rowId: row.rowId, table: row.tableName });
1398
+ }
1399
+ for (const [id, row] of before) {
1400
+ if (!after.has(id))
1401
+ changes.push({ change: "removed", rowId: row.rowId, table: row.tableName });
1402
+ }
1403
+ return changes;
1404
+ }
1405
+ function verifySeal(db, key, expectSealed, record) {
1406
+ const stored = readSeal(db);
1407
+ if (!stored) {
1408
+ if (!expectSealed && !record)
1409
+ return { state: "unsealed" };
1410
+ return { changes: [], diffTrusted: false, reason: "the seal was removed", state: "tampered" };
1411
+ }
1412
+ if (!validSeal(stored)) {
1413
+ return { changes: [], diffTrusted: false, reason: "unknown seal format", state: "tampered" };
1414
+ }
1415
+ const current = db.listProtectedRows().map((row) => rowMac(key, row));
1416
+ if (sameMac(rootMac(key, stored, current), stored.mac)) {
1417
+ return rolledBack(stored, record) ? ROLLED_BACK : { state: "intact" };
1418
+ }
1419
+ const storedRows = db.listSealRows();
1420
+ const diffTrusted = sameMac(rootMac(key, stored, storedRows), stored.mac);
1421
+ return {
1422
+ changes: diffRows(storedRows, current),
1423
+ diffTrusted,
1424
+ reason: "rows changed without the vault key",
1425
+ state: "tampered"
1426
+ };
1427
+ }
1428
+ function rolledBack(stored, record) {
1429
+ if (!record)
1430
+ return false;
1431
+ if (stored.vaultId !== undefined && stored.vaultId !== record.vaultId)
1432
+ return false;
1433
+ return (stored.counter ?? 0) < record.counter;
1434
+ }
1435
+ function currentSealRecord(db) {
1436
+ const stored = readSeal(db);
1437
+ if (!stored || stored.version !== 2 || !validSeal(stored))
1438
+ return null;
1439
+ return { counter: stored.counter, vaultId: stored.vaultId };
1440
+ }
1441
+ function writeSeal(db, key, options) {
1442
+ const previous = readSeal(db);
1443
+ const previousCounter = Number.isSafeInteger(previous?.counter) ? previous.counter : 0;
1444
+ const vaultId = typeof previous?.vaultId === "string" && previous.vaultId !== "" ? previous.vaultId : randomId();
1445
+ const counter = Math.max(previousCounter + 1, options?.minCounter ?? 0);
1446
+ const rows = db.listProtectedRows().map((row) => rowMac(key, row));
1447
+ const header = { counter, vaultId, version: SEAL_VERSION };
1448
+ const seal = { ...header, mac: rootMac(key, header, rows), sealedAt: new Date().toISOString() };
1449
+ db.setSeal(JSON.stringify(seal), rows);
1450
+ return { counter, vaultId };
1451
+ }
1452
+ function sealedSnapshot(key, sealRow) {
1453
+ if (!sealRow.snapshot)
1454
+ return null;
1455
+ let row;
1456
+ try {
1457
+ row = JSON.parse(sealRow.snapshot);
1458
+ } catch {
1459
+ return null;
1460
+ }
1461
+ if (typeof row !== "object" || row === null || Array.isArray(row))
1462
+ return null;
1463
+ const record = row;
1464
+ const mac = computeRowMac(key, { row: record, rowId: sealRow.rowId, tableName: sealRow.tableName });
1465
+ return sameMac(mac, sealRow.mac) ? record : null;
1466
+ }
1467
+ function unlockWithPassword(db, password, beforeUnwrap, record) {
1468
+ const config = db.getVaultConfig();
1469
+ if (!config) {
1470
+ throw new Error("Vault is not initialized");
1471
+ }
1472
+ const passwordKey = deriveKeyFromPassword(password, config.kdf);
1473
+ beforeUnwrap?.(passwordKey);
1474
+ const { rootKey, sealed } = unwrapRootKey(config, passwordKey);
1475
+ if (sealed) {
1476
+ return { rootKey, sealBound: true };
1477
+ }
1478
+ if (record) {
1479
+ return { rootKey, sealBound: false };
1480
+ }
1481
+ const sealedNow = db.immediateTransaction(() => {
1482
+ if (db.getSeal() !== null)
1483
+ return false;
1484
+ db.setVaultConfig({ ...config, wrappedRootKey: wrapRootKey(rootKey, passwordKey, true) });
1485
+ writeSeal(db, sealKey(rootKey));
1486
+ db.insertAudit({
1487
+ createdAt: new Date().toISOString(),
1488
+ eventType: "vault.sealed.initial",
1489
+ id: randomId(),
1490
+ message: "Vault sealed for the first time",
1491
+ metadata: JSON.stringify({}),
1492
+ subjectRef: null,
1493
+ subjectType: "vault"
1494
+ });
1495
+ return true;
1496
+ });
1497
+ return { rootKey, sealBound: sealedNow };
1498
+ }
1499
+ var ROW_MAC_VERSION = 1, SEAL_VERSION = 2, SEAL_INFO = "autho:vault-seal", VaultTamperedError, ROLLED_BACK;
1500
+ var init_seal = __esm(() => {
1501
+ init_src2();
1502
+ VaultTamperedError = class VaultTamperedError extends Error {
1503
+ seal;
1504
+ constructor(seal) {
1505
+ super(`Vault was modified outside autho (${seal.reason}). Run \`autho vault verify\` to see what changed, then \`autho vault reseal\`.`);
1506
+ this.seal = seal;
1507
+ this.name = "VaultTamperedError";
1508
+ }
1509
+ };
1510
+ ROLLED_BACK = {
1511
+ changes: [],
1512
+ diffTrusted: false,
1513
+ reason: "the vault file was replaced with an older copy",
1514
+ state: "tampered"
1515
+ };
1516
+ });
1517
+
1212
1518
  // packages/core/src/settings.ts
1213
1519
  function preferenceSpec(key) {
1214
1520
  return PREFERENCES.find((spec) => spec.key === key);
@@ -1309,7 +1615,7 @@ var init_settings = __esm(() => {
1309
1615
 
1310
1616
  // packages/core/src/index.ts
1311
1617
  import { spawnSync as spawnSync2 } from "child_process";
1312
- import { createHmac as createHmac2, randomBytes as randomBytes4 } from "crypto";
1618
+ import { createHmac as createHmac3, randomBytes as randomBytes4 } from "crypto";
1313
1619
  import {
1314
1620
  existsSync as existsSync3,
1315
1621
  readFileSync as readFileSync3
@@ -1365,7 +1671,7 @@ function generateTotp(secret, options, now = Date.now()) {
1365
1671
  message[index] = cursor & 255;
1366
1672
  cursor >>= 8;
1367
1673
  }
1368
- const hash = createHmac2(algorithm, Buffer.from(key)).update(message).digest();
1674
+ const hash = createHmac3(algorithm, Buffer.from(key)).update(message).digest();
1369
1675
  const offset = hash[hash.length - 1] & 15;
1370
1676
  const binary = (hash[offset] & 127) << 24 | (hash[offset + 1] & 255) << 16 | (hash[offset + 2] & 255) << 8 | hash[offset + 3] & 255;
1371
1677
  const mod = 10 ** digits;
@@ -1391,6 +1697,7 @@ function parseLease(row) {
1391
1697
  createdAt: row.createdAt,
1392
1698
  expiresAt: row.expiresAt,
1393
1699
  id: row.id,
1700
+ includeLinks: row.includeLinks !== 0,
1394
1701
  name: row.name,
1395
1702
  revokedAt: row.revokedAt,
1396
1703
  secretRefs: JSON.parse(row.secretRefs)
@@ -1515,17 +1822,21 @@ class VaultService {
1515
1822
  if (db.getVaultConfig()) {
1516
1823
  throw new Error(`Vault already initialized at ${vaultPath}`);
1517
1824
  }
1518
- const { config } = createVaultConfig(password);
1519
- db.setVaultConfig(config);
1520
- db.insertAudit({
1521
- createdAt: new Date().toISOString(),
1522
- eventType: "vault.initialized",
1523
- id: randomId(),
1524
- message: "Vault initialized",
1525
- metadata: JSON.stringify({ version: config.version }),
1526
- subjectRef: null,
1527
- subjectType: "vault"
1825
+ const { config, rootKey } = createVaultConfig(password, undefined, { sealed: true });
1826
+ db.immediateTransaction(() => {
1827
+ db.setVaultConfig(config);
1828
+ writeSeal(db, sealKey(rootKey));
1829
+ db.insertAudit({
1830
+ createdAt: new Date().toISOString(),
1831
+ eventType: "vault.initialized",
1832
+ id: randomId(),
1833
+ message: "Vault initialized",
1834
+ metadata: JSON.stringify({ version: config.version }),
1835
+ subjectRef: null,
1836
+ subjectType: "vault"
1837
+ });
1528
1838
  });
1839
+ rootKey.fill(0);
1529
1840
  return { vaultPath };
1530
1841
  } finally {
1531
1842
  db.close();
@@ -1593,7 +1904,7 @@ class VaultService {
1593
1904
  subjectRef: null,
1594
1905
  subjectType: "vault"
1595
1906
  });
1596
- return new VaultSession(db, rootKey);
1907
+ return new VaultSession(db, rootKey, { sealRecord: creds.sealRecord });
1597
1908
  } catch (error) {
1598
1909
  db.close();
1599
1910
  throw new Error("Invalid recovery token", { cause: error });
@@ -1617,22 +1928,15 @@ class VaultService {
1617
1928
  subjectRef: passkey.id,
1618
1929
  subjectType: "vault"
1619
1930
  });
1620
- return new VaultSession(db, rootKey);
1931
+ return new VaultSession(db, rootKey, { sealRecord: creds.sealRecord });
1621
1932
  } catch (error) {
1622
1933
  db.close();
1623
1934
  throw new Error("Passkey did not unlock the vault", { cause: error });
1624
1935
  }
1625
1936
  }
1626
1937
  try {
1627
- const passwordKEK = deriveKeyFromPassword(creds.password, config.kdf);
1628
- if (vaultAuth?.totp) {
1629
- const totpSecret = decryptWithKey(vaultAuth.totp.encryptedSecret, passwordKEK, "autho:vault-totp").toString("utf8");
1630
- if (!creds.totp || !verifyTotpCode(totpSecret, creds.totp)) {
1631
- throw new Error("Invalid or missing TOTP code");
1632
- }
1633
- }
1634
- const rootKey = decryptWithKey(config.wrappedRootKey, passwordKEK, "autho:vault-root");
1635
- return new VaultSession(db, rootKey);
1938
+ const { rootKey, sealBound } = unlockWithPassword(db, creds.password, (passwordKEK) => checkVaultTotp(vaultAuth, passwordKEK, creds.totp), creds.sealRecord);
1939
+ return new VaultSession(db, rootKey, { sealBound, sealRecord: creds.sealRecord });
1636
1940
  } catch (error) {
1637
1941
  db.close();
1638
1942
  if (error instanceof Error && error.message === "Invalid or missing TOTP code") {
@@ -1657,9 +1961,25 @@ class VaultService {
1657
1961
  static authorize(vaultPath, credentials) {
1658
1962
  const session = VaultService.unlock(vaultPath, credentials);
1659
1963
  const rootKey = session.getRootKey();
1964
+ const sealBound = session.sealBound;
1660
1965
  session.close();
1661
1966
  const viaPassword = !credentials.passkey && !credentials.recovery;
1662
- return new VaultAdmin(vaultPath, rootKey, viaPassword ? credentials.password : null);
1967
+ return new VaultAdmin(vaultPath, rootKey, viaPassword ? credentials.password : null, sealBound, credentials.sealRecord);
1968
+ }
1969
+ static unlockRootKey(vaultPath, credentials) {
1970
+ const db = new AuthoDatabase(vaultPath);
1971
+ try {
1972
+ if (!db.getVaultConfig()) {
1973
+ throw new Error(`Vault is not initialized at ${vaultPath}`);
1974
+ }
1975
+ const vaultAuth = db.getVaultAuth();
1976
+ return unlockWithPassword(db, credentials.password, (passwordKEK) => checkVaultTotp(vaultAuth, passwordKEK, credentials.totp), credentials.sealRecord);
1977
+ } finally {
1978
+ db.close();
1979
+ }
1980
+ }
1981
+ static reseal(vaultPath, credentials, mode) {
1982
+ return VaultService.withAdmin(vaultPath, credentials, (admin) => admin.reseal(mode));
1663
1983
  }
1664
1984
  static withAdmin(vaultPath, credentials, fn) {
1665
1985
  const admin = VaultService.authorize(vaultPath, credentials);
@@ -1695,6 +2015,14 @@ class VaultService {
1695
2015
  return VaultService.withAdmin(vaultPath, credentials, (admin) => admin.removePasskey(ref));
1696
2016
  }
1697
2017
  }
2018
+ function checkVaultTotp(vaultAuth, passwordKEK, totp) {
2019
+ if (!vaultAuth?.totp)
2020
+ return;
2021
+ const totpSecret = decryptWithKey(vaultAuth.totp.encryptedSecret, passwordKEK, "autho:vault-totp").toString("utf8");
2022
+ if (!totp || !verifyTotpCode(totpSecret, totp)) {
2023
+ throw new Error("Invalid or missing TOTP code");
2024
+ }
2025
+ }
1698
2026
  function assertTotpCode(secret, code) {
1699
2027
  if (!verifyTotpCode(secret, code)) {
1700
2028
  throw new Error("Invalid TOTP code \u2014 make sure your authenticator app is showing the right code");
@@ -1705,11 +2033,18 @@ class VaultAdmin {
1705
2033
  vaultPath;
1706
2034
  rootKey;
1707
2035
  password;
2036
+ sealBound;
2037
+ record;
1708
2038
  closed = false;
1709
- constructor(vaultPath, rootKey, password) {
2039
+ constructor(vaultPath, rootKey, password, sealBound = false, record = null) {
1710
2040
  this.vaultPath = vaultPath;
1711
2041
  this.rootKey = rootKey;
1712
2042
  this.password = password;
2043
+ this.sealBound = sealBound;
2044
+ this.record = record;
2045
+ }
2046
+ sealRecord() {
2047
+ return this.record;
1713
2048
  }
1714
2049
  get hasPassword() {
1715
2050
  return this.password !== null;
@@ -1752,9 +2087,9 @@ class VaultAdmin {
1752
2087
  if (newPassword.length < MIN_PASSWORD_LENGTH) {
1753
2088
  throw new Error(`The new password must be at least ${MIN_PASSWORD_LENGTH} characters`);
1754
2089
  }
1755
- this.write((db) => {
2090
+ this.write((db, sealed) => {
1756
2091
  const auth = db.getVaultAuth();
1757
- const { config } = createVaultConfig(newPassword, this.liveRootKey());
2092
+ const { config } = createVaultConfig(newPassword, this.liveRootKey(), { sealed });
1758
2093
  if (auth?.totp) {
1759
2094
  const oldPassword = this.requirePassword("Two-factor codes are on: authorize with the current master password to change it");
1760
2095
  const oldKEK = deriveKeyFromPassword(oldPassword, db.getVaultConfig().kdf);
@@ -1857,16 +2192,116 @@ class VaultAdmin {
1857
2192
  }
1858
2193
  return this.password;
1859
2194
  }
2195
+ reseal(mode) {
2196
+ const rootKey = this.liveRootKey();
2197
+ const key = sealKey(rootKey);
2198
+ const db = new AuthoDatabase(this.vaultPath);
2199
+ try {
2200
+ return db.immediateTransaction(() => {
2201
+ const seal = verifySeal(db, key, true, this.record);
2202
+ const result = {
2203
+ changes: seal.state === "tampered" ? seal.changes : [],
2204
+ diffTrusted: seal.state === "tampered" ? seal.diffTrusted : true,
2205
+ kept: [],
2206
+ mode,
2207
+ reason: seal.state === "tampered" ? seal.reason : seal.state === "unsealed" ? "unsealed" : null,
2208
+ rebound: false,
2209
+ revokedLeases: 0,
2210
+ sealRecord: null,
2211
+ undone: [],
2212
+ wasIntact: seal.state === "intact"
2213
+ };
2214
+ if (seal.state === "intact")
2215
+ return result;
2216
+ if (mode === "quarantine") {
2217
+ Object.assign(result, quarantineChanges(db, key, result.changes, result.diffTrusted));
2218
+ result.revokedLeases = db.revokeActiveLeases(new Date().toISOString());
2219
+ } else {
2220
+ result.kept = result.changes;
2221
+ }
2222
+ if (this.password !== null) {
2223
+ const config = db.getVaultConfig();
2224
+ const passwordKey = deriveKeyFromPassword(this.password, config.kdf);
2225
+ if (!unwrapRootKey(config, passwordKey).sealed) {
2226
+ db.setVaultConfig({ ...config, wrappedRootKey: wrapRootKey(rootKey, passwordKey, true) });
2227
+ result.rebound = true;
2228
+ }
2229
+ }
2230
+ result.sealRecord = writeSeal(db, key, { minCounter: this.record ? this.record.counter + 1 : 0 });
2231
+ this.record = result.sealRecord;
2232
+ audit(db, `vault.reseal.${mode}`, mode === "accept" ? "Vault re-sealed as is" : "Vault re-sealed after quarantine", {
2233
+ changes: result.changes.slice(0, 200),
2234
+ diffTrusted: result.diffTrusted,
2235
+ kept: result.kept.length,
2236
+ reason: result.reason,
2237
+ revokedLeases: result.revokedLeases,
2238
+ undone: result.undone.length
2239
+ });
2240
+ return result;
2241
+ });
2242
+ } finally {
2243
+ key.fill(0);
2244
+ db.close();
2245
+ }
2246
+ }
1860
2247
  write(fn) {
1861
- this.liveRootKey();
2248
+ const key = sealKey(this.liveRootKey());
1862
2249
  const db = new AuthoDatabase(this.vaultPath);
1863
2250
  try {
1864
- return db.transaction(() => fn(db));
2251
+ return db.immediateTransaction(() => {
2252
+ const seal = verifySeal(db, key, this.sealBound, this.record);
2253
+ if (seal.state === "tampered")
2254
+ throw new VaultTamperedError(seal);
2255
+ const result = fn(db, seal.state === "intact");
2256
+ if (seal.state === "intact")
2257
+ this.record = writeSeal(db, key);
2258
+ return result;
2259
+ });
1865
2260
  } finally {
2261
+ key.fill(0);
1866
2262
  db.close();
1867
2263
  }
1868
2264
  }
1869
2265
  }
2266
+ function quarantineChanges(db, key, changes, diffTrusted) {
2267
+ const sealed = new Map(db.listSealRows().map((row) => [`${row.tableName}\x00${row.rowId}`, row]));
2268
+ const current = new Map(db.listProtectedRows().map((row) => [`${row.tableName}\x00${row.rowId}`, row.row]));
2269
+ const undone = [];
2270
+ const kept = [];
2271
+ const restores = [];
2272
+ const deletes = [];
2273
+ for (const change of changes) {
2274
+ const sealedRow = sealed.get(`${change.table}\x00${change.rowId}`);
2275
+ const snapshot = diffTrusted && change.change !== "added" && sealedRow ? sealedSnapshot(key, sealedRow) : null;
2276
+ if (snapshot) {
2277
+ if (change.change === "modified")
2278
+ deletes.push(change);
2279
+ restores.push({ change, row: snapshot });
2280
+ } else if (change.change === "added" && diffTrusted) {
2281
+ deletes.push(change);
2282
+ undone.push(change);
2283
+ } else if (change.change !== "removed" && ACCESS_TABLES.has(change.table)) {
2284
+ deletes.push(change);
2285
+ undone.push(change);
2286
+ } else {
2287
+ kept.push(change);
2288
+ }
2289
+ }
2290
+ for (const change of deletes)
2291
+ db.deleteProtectedRow(change.table, change.rowId);
2292
+ for (const { change, row } of restores) {
2293
+ try {
2294
+ db.insertProtectedRow(change.table, row);
2295
+ undone.push(change);
2296
+ } catch {
2297
+ const modified = change.change === "modified" ? current.get(`${change.table}\x00${change.rowId}`) : undefined;
2298
+ if (modified)
2299
+ db.insertProtectedRow(change.table, modified);
2300
+ kept.push(change);
2301
+ }
2302
+ }
2303
+ return { kept, undone };
2304
+ }
1870
2305
  function audit(db, eventType, message, metadata = {}, subjectRef = null) {
1871
2306
  db.insertAudit({
1872
2307
  createdAt: new Date().toISOString(),
@@ -1903,16 +2338,54 @@ function recoveryFileContent(vaultPath, token) {
1903
2338
  class VaultSession {
1904
2339
  db;
1905
2340
  rootKey;
1906
- constructor(db, rootKey) {
2341
+ sealKey;
2342
+ sealBound;
2343
+ sealRecord;
2344
+ constructor(db, rootKey, options = {}) {
1907
2345
  this.db = db;
1908
2346
  this.rootKey = rootKey;
2347
+ this.sealKey = sealKey(rootKey);
2348
+ this.sealBound = options.sealBound ?? false;
2349
+ this.sealRecord = options.sealRecord ?? null;
2350
+ }
2351
+ sealCheckpoint() {
2352
+ return this.verifySeal().state === "intact" ? currentSealRecord(this.db) : null;
1909
2353
  }
1910
2354
  getRootKey() {
1911
2355
  return Buffer.from(this.rootKey);
1912
2356
  }
1913
2357
  close() {
2358
+ this.sealKey.fill(0);
1914
2359
  this.db.close();
1915
2360
  }
2361
+ verifySeal() {
2362
+ return verifySeal(this.db, this.sealKey, this.sealBound, this.sealRecord);
2363
+ }
2364
+ sealedWrite(fn, options) {
2365
+ if (this.db.inTransaction) {
2366
+ return fn();
2367
+ }
2368
+ return this.db.immediateTransaction(() => {
2369
+ const seal = this.verifySeal();
2370
+ if (seal.state === "tampered" && !options?.allowTampered) {
2371
+ throw new VaultTamperedError(seal);
2372
+ }
2373
+ const result = fn();
2374
+ if (seal.state === "intact") {
2375
+ writeSeal(this.db, this.sealKey);
2376
+ }
2377
+ return result;
2378
+ });
2379
+ }
2380
+ requireIntactSeal() {
2381
+ const seal = this.verifySeal();
2382
+ if (seal.state === "tampered") {
2383
+ throw new VaultTamperedError(seal);
2384
+ }
2385
+ if (seal.state === "unsealed") {
2386
+ throw new Error("This vault isn't sealed yet: unlock it once with the master password, then retry");
2387
+ }
2388
+ }
1916
2389
  status(vaultPath, projectFile, projectMappings = []) {
1917
2390
  return {
1918
2391
  activeLeaseCount: this.db.countActiveLeases(new Date().toISOString()),
@@ -1966,26 +2439,40 @@ class VaultSession {
1966
2439
  }
1967
2440
  return parseLease(row);
1968
2441
  }
1969
- assertLeaseAllows(leaseId, secretRef) {
1970
- if (!leaseId) {
1971
- return;
1972
- }
1973
- const lease = this.getLeaseOrThrow(leaseId);
2442
+ getActiveLeaseOrThrow(id) {
2443
+ const lease = this.getLeaseOrThrow(id);
1974
2444
  if (lease.revokedAt) {
1975
2445
  throw new Error(`Lease revoked: ${lease.id}`);
1976
2446
  }
1977
2447
  if (Date.parse(lease.expiresAt) <= Date.now()) {
1978
2448
  throw new Error(`Lease expired: ${lease.id}`);
1979
2449
  }
1980
- const secret = this.getSecretOrThrow(secretRef);
1981
- if (!lease.secretRefs.includes(secret.id) && !lease.secretRefs.includes(secret.name)) {
1982
- throw new Error(`Lease ${lease.id} does not allow secret ${secretRef}`);
2450
+ return lease;
2451
+ }
2452
+ linkedSecret(id) {
2453
+ const row = this.db.findSecret(id);
2454
+ return row ? { id: row.id, name: row.name, type: normalizeSecretType(row.type) } : null;
2455
+ }
2456
+ leaseAccess(lease, secret) {
2457
+ if (lease.secretRefs.includes(secret.id) || lease.secretRefs.includes(secret.name)) {
2458
+ return { via: null };
2459
+ }
2460
+ if (lease.includeLinks) {
2461
+ for (const link of this.db.listLinksTo(secret.id)) {
2462
+ if (lease.secretRefs.includes(link.primaryId)) {
2463
+ return { via: this.linkedSecret(link.primaryId) };
2464
+ }
2465
+ }
1983
2466
  }
2467
+ throw new Error(`Lease ${lease.id} does not allow secret ${secret.name}`);
1984
2468
  }
1985
2469
  addSecret(input) {
1986
2470
  requireValue(input.name, "--name");
1987
2471
  requireValue(input.value, "--value");
1988
2472
  const type = normalizeSecretType(input.type);
2473
+ return this.sealedWrite(() => this.insertNewSecret(input, type));
2474
+ }
2475
+ insertNewSecret(input, type) {
1989
2476
  if (this.db.findSecret(input.name)) {
1990
2477
  throw new Error(`Secret already exists: ${input.name}`);
1991
2478
  }
@@ -2020,6 +2507,9 @@ class VaultSession {
2020
2507
  };
2021
2508
  }
2022
2509
  updateSecret(ref, updates) {
2510
+ return this.sealedWrite(() => this.applySecretUpdate(ref, updates));
2511
+ }
2512
+ applySecretUpdate(ref, updates) {
2023
2513
  const existing = this.getSecretOrThrow(ref);
2024
2514
  if (updates.name && updates.name !== existing.name) {
2025
2515
  const conflict = this.db.findSecret(updates.name);
@@ -2059,6 +2549,9 @@ class VaultSession {
2059
2549
  }
2060
2550
  importLegacyFile(filePath, options) {
2061
2551
  const raw = JSON.parse(readFileSync3(filePath, "utf8"));
2552
+ return this.sealedWrite(() => this.importLegacyEntries(raw, options));
2553
+ }
2554
+ importLegacyEntries(raw, options) {
2062
2555
  let imported = 0;
2063
2556
  let skipped = 0;
2064
2557
  for (const entry of raw) {
@@ -2092,6 +2585,10 @@ class VaultSession {
2092
2585
  }));
2093
2586
  }
2094
2587
  listSecretIndex() {
2588
+ const linkCounts = new Map;
2589
+ for (const link of this.db.listLinks()) {
2590
+ linkCounts.set(link.primaryId, (linkCounts.get(link.primaryId) ?? 0) + 1);
2591
+ }
2095
2592
  const entries = this.db.listSecrets().map((row) => {
2096
2593
  const secret = this.unwrapSecret(row);
2097
2594
  const { url, description } = secret.metadata;
@@ -2099,6 +2596,7 @@ class VaultSession {
2099
2596
  createdAt: secret.createdAt,
2100
2597
  description: typeof description === "string" && description ? description : null,
2101
2598
  id: secret.id,
2599
+ linkCount: linkCounts.get(secret.id) ?? 0,
2102
2600
  name: secret.name,
2103
2601
  type: secret.type,
2104
2602
  updatedAt: secret.updatedAt,
@@ -2117,33 +2615,128 @@ class VaultSession {
2117
2615
  });
2118
2616
  return secret;
2119
2617
  }
2120
- removeSecret(ref) {
2121
- const secret = this.getSecretOrThrow(ref);
2122
- this.db.deleteSecret(secret.id);
2123
- this.audit("secret.deleted", "secret", secret.id, "Secret deleted", {
2124
- type: secret.type
2618
+ removeSecret(ref, options) {
2619
+ return this.sealedWrite(() => {
2620
+ const secret = this.getSecretOrThrow(ref);
2621
+ const removedLinked = [];
2622
+ const keptLinked = [];
2623
+ if (options?.withLinked) {
2624
+ for (const link of this.db.listLinksFrom(secret.id)) {
2625
+ const linked = this.linkedSecret(link.linkedId);
2626
+ if (!linked)
2627
+ continue;
2628
+ const otherPrimaries = this.db.listLinksTo(linked.id).filter((l) => l.primaryId !== secret.id);
2629
+ (otherPrimaries.length > 0 ? keptLinked : removedLinked).push(linked);
2630
+ }
2631
+ }
2632
+ for (const target of [{ id: secret.id, type: secret.type }, ...removedLinked]) {
2633
+ this.db.deleteSecret(target.id);
2634
+ this.audit("secret.deleted", "secret", target.id, "Secret deleted", {
2635
+ type: target.type,
2636
+ ...target.id === secret.id ? {} : { withPrimary: secret.id }
2637
+ });
2638
+ }
2639
+ return { id: secret.id, keptLinked, name: secret.name, removedLinked };
2640
+ });
2641
+ }
2642
+ linkSecrets(primaryRef, linkedRef) {
2643
+ return this.sealedWrite(() => {
2644
+ const primary = this.requireLinkedSecret(primaryRef);
2645
+ const linked = this.requireLinkedSecret(linkedRef);
2646
+ if (primary.id === linked.id) {
2647
+ throw new Error("A secret can't be linked to itself");
2648
+ }
2649
+ if (this.db.hasLink(primary.id, linked.id)) {
2650
+ return { created: false, linked, primary };
2651
+ }
2652
+ this.db.insertLink({ createdAt: new Date().toISOString(), linkedId: linked.id, primaryId: primary.id });
2653
+ this.audit("secret.linked", "secret", primary.id, `Linked ${linked.name} under ${primary.name}`, {
2654
+ linkedId: linked.id,
2655
+ primaryId: primary.id
2656
+ });
2657
+ return { created: true, linked, primary };
2658
+ });
2659
+ }
2660
+ unlinkSecrets(primaryRef, linkedRef) {
2661
+ return this.sealedWrite(() => {
2662
+ const primary = this.requireLinkedSecret(primaryRef);
2663
+ const linked = this.requireLinkedSecret(linkedRef);
2664
+ if (!this.db.deleteLink(primary.id, linked.id)) {
2665
+ throw new Error(`${linked.name} is not linked under ${primary.name}`);
2666
+ }
2667
+ this.audit("secret.unlinked", "secret", primary.id, `Unlinked ${linked.name} from ${primary.name}`, {
2668
+ linkedId: linked.id,
2669
+ primaryId: primary.id
2670
+ });
2671
+ return { linked, primary };
2125
2672
  });
2126
- return { id: secret.id, name: secret.name };
2127
2673
  }
2128
- generateOtp(ref) {
2674
+ getLinks(ref) {
2675
+ const secret = this.requireLinkedSecret(ref);
2676
+ const resolve = (ids) => ids.map((id) => this.linkedSecret(id)).filter((entry) => entry !== null);
2677
+ return {
2678
+ linkedFrom: resolve(this.db.listLinksTo(secret.id).map((link) => link.primaryId)),
2679
+ links: resolve(this.db.listLinksFrom(secret.id).map((link) => link.linkedId))
2680
+ };
2681
+ }
2682
+ requireLinkedSecret(ref) {
2683
+ const row = this.db.findSecret(ref);
2684
+ if (!row) {
2685
+ throw new Error(`Secret not found: ${ref}`);
2686
+ }
2687
+ return { id: row.id, name: row.name, type: normalizeSecretType(row.type) };
2688
+ }
2689
+ generateOtp(ref, options) {
2129
2690
  const secret = this.getSecretOrThrow(ref);
2130
2691
  if (secret.type !== "otp") {
2131
2692
  throw new Error(`Secret is not an OTP secret: ${ref}`);
2132
2693
  }
2133
- const result = generateTotp(secret.value, {
2134
- algorithm: secret.metadata.algorithm,
2135
- digits: secret.metadata.digits
2136
- });
2137
- this.audit("otp.generated", "secret", secret.id, "OTP code generated", {
2138
- expiresAt: result.expiresAt
2139
- });
2694
+ const result = this.otpCode(secret);
2695
+ if (options?.audit ?? true) {
2696
+ this.audit("otp.generated", "secret", secret.id, "OTP code generated", {
2697
+ expiresAt: result.expiresAt
2698
+ });
2699
+ }
2140
2700
  return {
2141
2701
  code: result.code,
2142
2702
  expiresAt: result.expiresAt,
2143
2703
  secret: secret.name
2144
2704
  };
2145
2705
  }
2706
+ otpCode(secret) {
2707
+ return generateTotp(secret.value, {
2708
+ algorithm: secret.metadata.algorithm,
2709
+ digits: secret.metadata.digits
2710
+ });
2711
+ }
2712
+ showLease(id) {
2713
+ const lease = this.getLeaseOrThrow(id);
2714
+ const coverage = [];
2715
+ const seen = new Set;
2716
+ for (const ref of lease.secretRefs) {
2717
+ const direct = this.linkedSecret(ref);
2718
+ if (!direct || seen.has(direct.id))
2719
+ continue;
2720
+ seen.add(direct.id);
2721
+ coverage.push({ ...direct, via: null });
2722
+ }
2723
+ if (lease.includeLinks) {
2724
+ for (const primary of [...coverage]) {
2725
+ for (const link of this.db.listLinksFrom(primary.id)) {
2726
+ const linked = this.linkedSecret(link.linkedId);
2727
+ if (!linked || seen.has(linked.id))
2728
+ continue;
2729
+ seen.add(linked.id);
2730
+ coverage.push({ ...linked, via: { id: primary.id, name: primary.name, type: primary.type } });
2731
+ }
2732
+ }
2733
+ }
2734
+ return { coverage, lease };
2735
+ }
2146
2736
  createLease(input) {
2737
+ return this.sealedWrite(() => this.insertLease(input));
2738
+ }
2739
+ insertLease(input) {
2147
2740
  if (input.secretRefs.length === 0) {
2148
2741
  throw new Error("Lease requires at least one --secret");
2149
2742
  }
@@ -2157,6 +2750,7 @@ class VaultSession {
2157
2750
  createdAt: now,
2158
2751
  expiresAt,
2159
2752
  id: randomId(),
2753
+ includeLinks: !input.noLinks,
2160
2754
  name: input.name || "session",
2161
2755
  revokedAt: null,
2162
2756
  secretRefs: resolved.map((secret) => secret.id)
@@ -2165,39 +2759,55 @@ class VaultSession {
2165
2759
  createdAt: lease.createdAt,
2166
2760
  expiresAt: lease.expiresAt,
2167
2761
  id: lease.id,
2762
+ includeLinks: lease.includeLinks ? 1 : 0,
2168
2763
  name: lease.name,
2169
2764
  revokedAt: lease.revokedAt,
2170
2765
  secretRefs: JSON.stringify(lease.secretRefs)
2171
2766
  });
2172
2767
  this.audit("lease.created", "lease", lease.id, "Lease created", {
2173
2768
  expiresAt,
2769
+ includeLinks: lease.includeLinks,
2174
2770
  secretCount: lease.secretRefs.length
2175
2771
  });
2176
2772
  return lease;
2177
2773
  }
2178
2774
  revokeLease(id) {
2179
- const lease = this.getLeaseOrThrow(id);
2180
- const revokedAt = new Date().toISOString();
2181
- this.db.revokeLease(id, revokedAt);
2182
- this.audit("lease.revoked", "lease", id, "Lease revoked", {
2183
- id
2184
- });
2185
- return {
2186
- ...lease,
2187
- revokedAt
2188
- };
2775
+ return this.sealedWrite(() => {
2776
+ const lease = this.getLeaseOrThrow(id);
2777
+ const revokedAt = new Date().toISOString();
2778
+ this.db.revokeLease(id, revokedAt);
2779
+ this.audit("lease.revoked", "lease", id, "Lease revoked", {
2780
+ id
2781
+ });
2782
+ return {
2783
+ ...lease,
2784
+ revokedAt
2785
+ };
2786
+ }, { allowTampered: true });
2189
2787
  }
2190
2788
  buildEnv(mappings, leaseId) {
2191
2789
  if (mappings.length === 0) {
2192
2790
  throw new Error("Provide at least one env mapping");
2193
2791
  }
2194
- const output = {};
2195
- for (const mapping of mappings) {
2196
- this.assertLeaseAllows(leaseId, mapping.secretRef);
2197
- const secret = this.getSecretOrThrow(mapping.secretRef);
2198
- output[mapping.envName] = secret.value;
2199
- }
2200
- return output;
2792
+ return this.db.immediateTransaction(() => {
2793
+ this.requireIntactSeal();
2794
+ const lease = leaseId ? this.getActiveLeaseOrThrow(leaseId) : null;
2795
+ const output = {};
2796
+ for (const mapping of mappings) {
2797
+ const secret = this.getSecretOrThrow(mapping.secretRef);
2798
+ if (lease) {
2799
+ const { via } = this.leaseAccess(lease, secret);
2800
+ if (via) {
2801
+ this.audit("lease.link_access", "secret", secret.id, `Secret read via link from ${via.name}`, {
2802
+ leaseId: lease.id,
2803
+ primaryId: via.id
2804
+ });
2805
+ }
2806
+ }
2807
+ output[mapping.envName] = secret.type === "otp" ? this.otpCode(secret).code : secret.value;
2808
+ }
2809
+ return output;
2810
+ });
2201
2811
  }
2202
2812
  renderEnv(mappings, leaseId) {
2203
2813
  const env = this.buildEnv(mappings, leaseId);
@@ -2312,21 +2922,24 @@ class VaultSession {
2312
2922
  return this.db.listAudit(limit).map(toAuditEvent);
2313
2923
  }
2314
2924
  }
2315
- var PASSKEY_INFO = "autho:vault-passkey", MIN_PASSWORD_LENGTH = 8;
2925
+ var PASSKEY_INFO = "autho:vault-passkey", MIN_PASSWORD_LENGTH = 8, ACCESS_TABLES;
2316
2926
  var init_src3 = __esm(() => {
2317
2927
  init_src2();
2318
2928
  init_artifacts();
2319
2929
  init_src();
2930
+ init_seal();
2320
2931
  init_paths();
2932
+ init_seal();
2321
2933
  init_paths();
2322
2934
  init_settings();
2935
+ ACCESS_TABLES = new Set(["leases", "secret_links"]);
2323
2936
  });
2324
2937
 
2325
2938
  // packages/core/src/os-secrets.ts
2326
- import { createHash as createHash3, randomBytes as randomBytes5, scryptSync as scryptSync2, timingSafeEqual as timingSafeEqual3 } from "crypto";
2939
+ import { createHash as createHash2, randomBytes as randomBytes5, scryptSync as scryptSync2, timingSafeEqual as timingSafeEqual3 } from "crypto";
2327
2940
  import { resolve as resolve3 } from "path";
2328
2941
  function vaultPasswordName(vaultPath) {
2329
- return createHash3("sha256").update(resolve3(vaultPath)).digest("hex");
2942
+ return createHash2("sha256").update(resolve3(vaultPath)).digest("hex");
2330
2943
  }
2331
2944
  function osSecretsDisabled() {
2332
2945
  return process.env.AUTHO_DISABLE_OS_SECRETS === "1";
@@ -2373,6 +2986,41 @@ async function deleteVaultPassword(vaultPath) {
2373
2986
  return false;
2374
2987
  }
2375
2988
  }
2989
+ async function loadSealRecord(vaultPath) {
2990
+ if (osSecretsDisabled()) {
2991
+ return null;
2992
+ }
2993
+ try {
2994
+ const raw = await Bun.secrets.get({ name: vaultPasswordName(vaultPath), service: SEAL_SERVICE });
2995
+ if (!raw)
2996
+ return null;
2997
+ const parsed = JSON.parse(raw);
2998
+ if (!Number.isSafeInteger(parsed.counter) || typeof parsed.vaultId !== "string")
2999
+ return null;
3000
+ return { counter: parsed.counter, vaultId: parsed.vaultId };
3001
+ } catch {
3002
+ return null;
3003
+ }
3004
+ }
3005
+ async function saveSealRecord(vaultPath, record) {
3006
+ if (!record || osSecretsDisabled()) {
3007
+ return;
3008
+ }
3009
+ try {
3010
+ const stored = await loadSealRecord(vaultPath);
3011
+ if (stored && stored.vaultId === record.vaultId && stored.counter >= record.counter)
3012
+ return;
3013
+ await Bun.secrets.set({ name: vaultPasswordName(vaultPath), service: SEAL_SERVICE, value: JSON.stringify(record) });
3014
+ } catch {}
3015
+ }
3016
+ async function closeSessionAndRecordSeal(vaultPath, session) {
3017
+ let record = null;
3018
+ try {
3019
+ record = session.sealCheckpoint();
3020
+ } catch {}
3021
+ session.close();
3022
+ await saveSealRecord(vaultPath, record);
3023
+ }
2376
3024
  async function setOsSecret(name, value) {
2377
3025
  if (osSecretsDisabled()) {
2378
3026
  return false;
@@ -2459,7 +3107,7 @@ async function deletePin(vaultPath) {
2459
3107
  return false;
2460
3108
  }
2461
3109
  }
2462
- var VAULT_PASSWORD_SERVICE = "autho.vault", USER_SECRETS_SERVICE = "autho", PIN_SERVICE = "autho.pin";
3110
+ var VAULT_PASSWORD_SERVICE = "autho.vault", USER_SECRETS_SERVICE = "autho", PIN_SERVICE = "autho.pin", SEAL_SERVICE = "autho.seal";
2463
3111
  var init_os_secrets = () => {};
2464
3112
 
2465
3113
  // apps/cli/src/tui/theme.ts
@@ -2568,7 +3216,7 @@ function Chip({ label, color = theme.accent }) {
2568
3216
  ]
2569
3217
  });
2570
3218
  }
2571
- function StatusBar({ hints, toast }) {
3219
+ function StatusBar({ hints, pinned = [], toast }) {
2572
3220
  const { width } = useTerminalDimensions();
2573
3221
  if (toast) {
2574
3222
  const color = toast.kind === "error" ? theme.danger : toast.kind === "info" ? theme.info : theme.success;
@@ -2590,31 +3238,62 @@ function StatusBar({ hints, toast }) {
2590
3238
  })
2591
3239
  });
2592
3240
  }
2593
- return /* @__PURE__ */ jsx("box", {
3241
+ return /* @__PURE__ */ jsxs("box", {
2594
3242
  width: "100%",
2595
3243
  height: 1,
2596
3244
  backgroundColor: theme.panel,
2597
3245
  paddingX: 1,
2598
3246
  flexDirection: "row",
2599
- gap: 2,
3247
+ justifyContent: "space-between",
2600
3248
  overflow: "hidden",
2601
- children: fitHints(hints, width - 2).map(([key, label]) => /* @__PURE__ */ jsxs("text", {
2602
- flexShrink: 0,
2603
- children: [
2604
- /* @__PURE__ */ jsx(Chip, {
2605
- label: key
2606
- }),
2607
- /* @__PURE__ */ jsxs("span", {
2608
- fg: theme.muted,
3249
+ children: [
3250
+ /* @__PURE__ */ jsx("box", {
3251
+ flexDirection: "row",
3252
+ gap: 2,
3253
+ flexShrink: 1,
3254
+ overflow: "hidden",
3255
+ children: fitHints(hints, width - 2 - hintsWidth(pinned) - (pinned.length ? 3 : 0)).map(([key, label]) => /* @__PURE__ */ jsxs("text", {
3256
+ flexShrink: 0,
2609
3257
  children: [
2610
- " ",
2611
- label
3258
+ /* @__PURE__ */ jsx(Chip, {
3259
+ label: key
3260
+ }),
3261
+ /* @__PURE__ */ jsxs("span", {
3262
+ fg: theme.muted,
3263
+ children: [
3264
+ " ",
3265
+ label
3266
+ ]
3267
+ })
2612
3268
  ]
2613
- })
2614
- ]
2615
- }, `${key}-${label}`))
3269
+ }, `${key}-${label}`))
3270
+ }),
3271
+ pinned.length ? /* @__PURE__ */ jsx("box", {
3272
+ flexDirection: "row",
3273
+ gap: 2,
3274
+ flexShrink: 0,
3275
+ children: pinned.map(([key, label]) => /* @__PURE__ */ jsxs("text", {
3276
+ flexShrink: 0,
3277
+ children: [
3278
+ /* @__PURE__ */ jsx(Chip, {
3279
+ label: key
3280
+ }),
3281
+ /* @__PURE__ */ jsxs("span", {
3282
+ fg: theme.muted,
3283
+ children: [
3284
+ " ",
3285
+ label
3286
+ ]
3287
+ })
3288
+ ]
3289
+ }, `${key}-${label}`))
3290
+ }) : null
3291
+ ]
2616
3292
  });
2617
3293
  }
3294
+ function hintsWidth(hints) {
3295
+ return hints.reduce((sum, [key, label], i) => sum + key.length + 2 + 1 + label.length + (i ? 2 : 0), 0);
3296
+ }
2618
3297
  function fitHints(hints, width) {
2619
3298
  const shown = [];
2620
3299
  let used = 0;
@@ -2627,7 +3306,7 @@ function fitHints(hints, width) {
2627
3306
  }
2628
3307
  return shown;
2629
3308
  }
2630
- function Button({ label, hotkey, onPress, color = theme.accent }) {
3309
+ function Button({ label, hotkey, onPress, color = theme.accent, focused = false }) {
2631
3310
  return /* @__PURE__ */ jsx("box", {
2632
3311
  onMouseDown: (event) => {
2633
3312
  event.preventDefault();
@@ -2635,6 +3314,7 @@ function Button({ label, hotkey, onPress, color = theme.accent }) {
2635
3314
  },
2636
3315
  height: 1,
2637
3316
  flexShrink: 0,
3317
+ backgroundColor: focused ? theme.selection : undefined,
2638
3318
  children: /* @__PURE__ */ jsxs("text", {
2639
3319
  children: [
2640
3320
  hotkey ? /* @__PURE__ */ jsx("span", {
@@ -2648,7 +3328,14 @@ function Button({ label, hotkey, onPress, color = theme.accent }) {
2648
3328
  ]
2649
3329
  })
2650
3330
  }) : null,
2651
- /* @__PURE__ */ jsxs("span", {
3331
+ focused ? /* @__PURE__ */ jsxs("strong", {
3332
+ fg: color,
3333
+ children: [
3334
+ " ",
3335
+ label,
3336
+ " \u2039"
3337
+ ]
3338
+ }) : /* @__PURE__ */ jsxs("span", {
2652
3339
  fg: color,
2653
3340
  children: [
2654
3341
  " ",
@@ -3418,7 +4105,7 @@ var init_palette = __esm(() => {
3418
4105
  // apps/cli/src/tui/settings.tsx
3419
4106
  import { existsSync as existsSync5, writeFileSync as writeFileSync2 } from "fs";
3420
4107
  import { homedir as homedir2 } from "os";
3421
- import { dirname as dirname3, join as join3, resolve as resolve4 } from "path";
4108
+ import { dirname as dirname3, join as join3, resolve as resolve5 } from "path";
3422
4109
  import { useKeyboard as useKeyboard4, useTerminalDimensions as useTerminalDimensions3 } from "@opentui/react";
3423
4110
  import { useCallback, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef5, useState as useState4 } from "react";
3424
4111
  import { jsx as jsx4, jsxs as jsxs4, Fragment } from "@opentui/react/jsx-runtime";
@@ -3426,6 +4113,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3426
4113
  const app = useApp();
3427
4114
  const { width, height } = useTerminalDimensions3();
3428
4115
  const [section, setSection] = useState4("Storage" /* Storage */);
4116
+ const [focus, setFocus] = useState4("nav");
3429
4117
  const [rowIndex, setRowIndex] = useState4(0);
3430
4118
  const [dialog, setDialog] = useState4(null);
3431
4119
  const [error, setError] = useState4("");
@@ -3445,11 +4133,21 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3445
4133
  useEffect4(() => {
3446
4134
  refreshStatus();
3447
4135
  }, [refreshStatus]);
4136
+ const sealRecord = useRef5(null);
4137
+ useEffect4(() => {
4138
+ loadSealRecord(vaultPath).then((record) => {
4139
+ sealRecord.current = record;
4140
+ });
4141
+ }, [vaultPath]);
3448
4142
  useEffect4(() => () => {
3449
4143
  passkeyAbort.current?.abort();
3450
- admin.current?.close();
4144
+ const current = admin.current;
3451
4145
  admin.current = null;
3452
- }, []);
4146
+ if (current) {
4147
+ current.close();
4148
+ saveSealRecord(vaultPath, current.sealRecord());
4149
+ }
4150
+ }, [vaultPath]);
3453
4151
  const writeFields = (next) => {
3454
4152
  fieldsRef.current = next;
3455
4153
  setFields(next);
@@ -3653,7 +4351,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3653
4351
  return;
3654
4352
  }
3655
4353
  try {
3656
- admin.current = VaultService.authorize(vaultPath, { password: first, totp: status?.totp ? second : undefined });
4354
+ admin.current = VaultService.authorize(vaultPath, { password: first, sealRecord: sealRecord.current, totp: status?.totp ? second : undefined });
3657
4355
  } catch (e) {
3658
4356
  setError(message(e));
3659
4357
  writeFields(["", ""]);
@@ -3674,7 +4372,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3674
4372
  return;
3675
4373
  case "vaultDir": {
3676
4374
  const config = { ...loadConfig() };
3677
- const target = first.trim() ? resolve4(first.trim()).replace(/\\/g, "/") : null;
4375
+ const target = first.trim() ? resolve5(first.trim()).replace(/\\/g, "/") : null;
3678
4376
  if (target)
3679
4377
  config.vaultDir = target;
3680
4378
  else
@@ -3742,7 +4440,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3742
4440
  registerPasskey(first.trim());
3743
4441
  return;
3744
4442
  case "recoveryPath": {
3745
- const target = resolve4(first.trim() || join3(homedir2(), "autho-recovery.txt"));
4443
+ const target = resolve5(first.trim() || join3(homedir2(), "autho-recovery.txt"));
3746
4444
  if (existsSync5(target)) {
3747
4445
  setError("That file already exists \u2014 pick another name");
3748
4446
  return;
@@ -3795,6 +4493,12 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3795
4493
  setSection(next);
3796
4494
  setRowIndex(0);
3797
4495
  };
4496
+ const pickSection = (next) => {
4497
+ close();
4498
+ setSection(next);
4499
+ setRowIndex(0);
4500
+ setFocus("nav");
4501
+ };
3798
4502
  const moveRow = (delta) => setRowIndex(Math.min(rows.length - 1, Math.max(0, current + delta)));
3799
4503
  useKeyboard4((key) => {
3800
4504
  if (!app.active || key.ctrl || key.meta)
@@ -3819,6 +4523,44 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3819
4523
  }
3820
4524
  return;
3821
4525
  }
4526
+ if (key.name === "q") {
4527
+ onBack();
4528
+ return;
4529
+ }
4530
+ if (key.name === "tab") {
4531
+ key.preventDefault();
4532
+ setFocus(focus === "nav" ? "rows" : "nav");
4533
+ return;
4534
+ }
4535
+ if (focus === "nav") {
4536
+ switch (key.name) {
4537
+ case "up":
4538
+ case "k":
4539
+ switchSection(-1);
4540
+ return;
4541
+ case "down":
4542
+ case "j":
4543
+ switchSection(1);
4544
+ return;
4545
+ case "home":
4546
+ pickSection(SECTIONS[0]);
4547
+ return;
4548
+ case "end":
4549
+ pickSection(SECTIONS[SECTIONS.length - 1]);
4550
+ return;
4551
+ case "right":
4552
+ case "l":
4553
+ case "space":
4554
+ setFocus("rows");
4555
+ return;
4556
+ case "escape":
4557
+ onBack();
4558
+ return;
4559
+ }
4560
+ if (isEnter(key))
4561
+ setFocus("rows");
4562
+ return;
4563
+ }
3822
4564
  switch (key.name) {
3823
4565
  case "up":
3824
4566
  case "k":
@@ -3828,21 +4570,16 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3828
4570
  case "j":
3829
4571
  moveRow(1);
3830
4572
  return;
3831
- case "left":
3832
- case "h":
3833
- switchSection(-1);
3834
- return;
3835
- case "right":
3836
- case "l":
3837
- switchSection(1);
4573
+ case "home":
4574
+ setRowIndex(0);
3838
4575
  return;
3839
- case "tab":
3840
- key.preventDefault();
3841
- switchSection(key.shift ? -1 : 1);
4576
+ case "end":
4577
+ setRowIndex(rows.length - 1);
3842
4578
  return;
4579
+ case "left":
4580
+ case "h":
3843
4581
  case "escape":
3844
- case "q":
3845
- onBack();
4582
+ setFocus("nav");
3846
4583
  return;
3847
4584
  case "space":
3848
4585
  row?.run?.();
@@ -3852,14 +4589,10 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3852
4589
  row?.run?.();
3853
4590
  });
3854
4591
  const commands = useMemo3(() => [
3855
- ...SECTIONS.map((s) => ({ id: `settings:${s}`, title: `Settings \u2014 ${s}`, group: "Settings", run: () => {
3856
- close();
3857
- setSection(s);
3858
- setRowIndex(0);
3859
- } })),
4592
+ ...SECTIONS.map((s) => ({ id: `settings:${s}`, title: `Settings \u2014 ${s}`, group: "Settings", run: () => pickSection(s) })),
3860
4593
  { id: "settings:back", title: "Back to the vault", hint: "esc", group: "Settings", run: onBack }
3861
4594
  ], [onBack]);
3862
- const copyTarget = row?.copy ? { label: row.label, read: () => row.copy ?? null, secret: false } : null;
4595
+ const copyTarget = focus === "rows" && row?.copy ? { label: row.label, read: () => row.copy ?? null, secret: false } : null;
3863
4596
  useScreenContext(commands, copyTarget);
3864
4597
  useEffect4(() => {
3865
4598
  if (dialog?.kind === "confirm")
@@ -3868,13 +4601,16 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3868
4601
  setHints([["esc", "cancel"]]);
3869
4602
  else if (dialog)
3870
4603
  setHints([["enter", "save"], ...fieldCount(dialog, status) > 1 ? [["tab", "field"]] : [], ["esc", "cancel"]]);
4604
+ else if (focus === "nav")
4605
+ setHints([["\u2191\u2193", "section"], ["\u2192/enter", "open"], ["esc", "back to vault"]]);
3871
4606
  else
3872
- setHints([["\u2191\u2193", "move"], ["\u2190\u2192/tab", "section"], ...row?.verb ? [["enter", row.verb]] : [], ...row?.copy && row.verb !== "copy" ? [["ctrl+c", "copy"]] : [], ["esc", "back"]]);
3873
- }, [dialog, status, row?.verb, row?.copy, setHints]);
4607
+ setHints([["\u2191\u2193", "move"], ...row?.verb ? [["enter", row.verb]] : [], ...row?.copy && row.verb !== "copy" ? [["ctrl+c", "copy"]] : [], ["\u2190/esc", "sections"]]);
4608
+ }, [dialog, status, focus, row?.verb, row?.copy, setHints]);
3874
4609
  const listHeight = Math.max(3, height - 12);
3875
4610
  const offset = Math.max(0, Math.min(current - Math.floor(listHeight / 2), rows.length - listHeight));
3876
4611
  const visible = rows.slice(offset, offset + listHeight);
3877
4612
  const inputsFocused = app.active && dialog !== null;
4613
+ const rowsFocused = focus === "rows" || dialog !== null;
3878
4614
  const valueWidth = Math.max(12, width - 64);
3879
4615
  return /* @__PURE__ */ jsxs4("box", {
3880
4616
  flexDirection: "row",
@@ -3895,14 +4631,10 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3895
4631
  height: 1,
3896
4632
  paddingX: 1,
3897
4633
  backgroundColor: s === section ? theme.selection : undefined,
3898
- onMouseDown: () => {
3899
- close();
3900
- setSection(s);
3901
- setRowIndex(0);
3902
- },
4634
+ onMouseDown: () => pickSection(s),
3903
4635
  children: /* @__PURE__ */ jsx4("text", {
3904
4636
  children: s === section ? /* @__PURE__ */ jsxs4("strong", {
3905
- fg: theme.accent,
4637
+ fg: rowsFocused ? theme.muted : theme.accent,
3906
4638
  children: [
3907
4639
  "\u203A ",
3908
4640
  s
@@ -3932,7 +4664,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3932
4664
  children: admin.current ? "Authorized for this visit \u2014 changes apply right away." : "Changes ask for your master password once per visit."
3933
4665
  }) : null,
3934
4666
  visible.map((r, i) => {
3935
- const on = offset + i === current;
4667
+ const on = rowsFocused && offset + i === current;
3936
4668
  return /* @__PURE__ */ jsxs4("box", {
3937
4669
  flexDirection: "row",
3938
4670
  height: 1,
@@ -3940,6 +4672,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3940
4672
  onMouseDown: () => {
3941
4673
  if (dialog)
3942
4674
  return;
4675
+ setFocus("rows");
3943
4676
  if (on)
3944
4677
  r.run?.();
3945
4678
  else
@@ -3977,7 +4710,7 @@ function SettingsScreen({ vaultPath, setHints, onBack, passkeyRunner = runPasske
3977
4710
  ]
3978
4711
  }, r.id);
3979
4712
  }),
3980
- row?.help && !dialog ? /* @__PURE__ */ jsx4("text", {
4713
+ focus === "rows" && row?.help && !dialog ? /* @__PURE__ */ jsx4("text", {
3981
4714
  marginTop: 1,
3982
4715
  fg: theme.dim,
3983
4716
  wrapMode: "word",
@@ -4242,10 +4975,11 @@ function UnlockScreen({ vaultPath, onUnlock, setHints, notice, passkeyRunner = r
4242
4975
  const passkeys = useMemo4(() => VaultService.listPasskeys(vaultPath), [vaultPath]);
4243
4976
  const passkeyAbort = useRef6(null);
4244
4977
  const stepBeforePasskey = useRef6("password");
4978
+ const sealRecord = useRef6(null);
4245
4979
  const finish = useCallback2((creds) => {
4246
4980
  setStep("unlocking");
4247
4981
  try {
4248
- onUnlock(VaultService.unlock(vaultPath, creds));
4982
+ onUnlock(VaultService.unlock(vaultPath, { ...creds, sealRecord: sealRecord.current }));
4249
4983
  } catch (e) {
4250
4984
  setError(e instanceof Error ? e.message : String(e));
4251
4985
  setPassword("");
@@ -4257,13 +4991,14 @@ function UnlockScreen({ vaultPath, onUnlock, setHints, notice, passkeyRunner = r
4257
4991
  useEffect5(() => {
4258
4992
  let cancelled = false;
4259
4993
  (async () => {
4994
+ sealRecord.current = await loadSealRecord(vaultPath);
4260
4995
  const recoveryFile = process.env.AUTHO_RECOVERY_FILE;
4261
4996
  if (recoveryFile) {
4262
4997
  try {
4263
4998
  const token = readRecoveryToken(recoveryFile);
4264
4999
  if (token) {
4265
5000
  try {
4266
- const session = VaultService.unlock(vaultPath, { password: "", recovery: token });
5001
+ const session = VaultService.unlock(vaultPath, { password: "", recovery: token, sealRecord: sealRecord.current });
4267
5002
  if (!cancelled)
4268
5003
  onUnlock(session);
4269
5004
  return;
@@ -4580,7 +5315,9 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4580
5315
  const wide = width >= WIDE_MIN_COLUMNS;
4581
5316
  const [entries, setEntries] = useState6([]);
4582
5317
  const [query, setQuery] = useState6("");
4583
- const [searchFocused, setSearchFocused] = useState6(false);
5318
+ const [pane, setPane] = useState6("list");
5319
+ const [actionIndex, setActionIndex] = useState6(0);
5320
+ const searchFocused = pane === "search";
4584
5321
  const [selectedId, setSelectedId] = useState6(initialSelectedId ?? null);
4585
5322
  const [detailOpen, setDetailOpen] = useState6(false);
4586
5323
  const [revealed, setRevealed] = useState6(null);
@@ -4607,6 +5344,7 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4607
5344
  setRevealed((r) => r && r.id !== selected?.id ? null : r);
4608
5345
  setOtp((o) => o && o.id !== selected?.id ? null : o);
4609
5346
  setConfirmDelete(false);
5347
+ setActionIndex(0);
4610
5348
  }, [selected?.id]);
4611
5349
  const now = useNow(1000, Boolean(revealed || otp));
4612
5350
  useEffect6(() => {
@@ -4622,9 +5360,9 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4622
5360
  return null;
4623
5361
  }
4624
5362
  }, [session, app]);
4625
- const generateOtp = useCallback3((entry) => {
5363
+ const generateOtp = useCallback3((entry, options) => {
4626
5364
  try {
4627
- const result = session.generateOtp(entry.id);
5365
+ const result = session.generateOtp(entry.id, options);
4628
5366
  const next = { id: entry.id, code: result.code, expiresAt: Date.parse(result.expiresAt) };
4629
5367
  setOtp(next);
4630
5368
  return next;
@@ -4635,7 +5373,7 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4635
5373
  }, [session, app]);
4636
5374
  useEffect6(() => {
4637
5375
  if (otp && selected && otp.id === selected.id && now >= otp.expiresAt) {
4638
- generateOtp(selected);
5376
+ generateOtp(selected, { audit: false });
4639
5377
  }
4640
5378
  }, [now, otp, selected, generateOtp]);
4641
5379
  const copyPrimary = useCallback3((entry) => {
@@ -4686,6 +5424,7 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4686
5424
  app.toast(`Deleted "${entry.name}"`);
4687
5425
  setConfirmDelete(false);
4688
5426
  setDetailOpen(false);
5427
+ setPane("list");
4689
5428
  reload();
4690
5429
  } catch (e) {
4691
5430
  app.toast(e instanceof Error ? e.message : String(e), "error");
@@ -4707,6 +5446,32 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4707
5446
  };
4708
5447
  const showDetail = wide || detailOpen;
4709
5448
  const listHeight = Math.max(3, height - 6);
5449
+ const actions = selected ? [
5450
+ selected.type === "otp" ? { id: "copy", hotkey: "o", label: "copy code", color: theme.success, run: () => copyPrimary(selected) } : { id: "copy", hotkey: "c", label: `copy ${valueName(selected.type).toLowerCase()}`, color: theme.success, run: () => copyPrimary(selected) },
5451
+ ...selected.type !== "otp" ? [{ id: "reveal", hotkey: "s", label: revealed?.id === selected.id ? "hide" : "reveal", color: theme.revealed, run: () => toggleReveal(selected) }] : [],
5452
+ ...selected.url && selected.type !== "otp" ? [{ id: "open", hotkey: "o", label: "open", color: theme.info, run: () => open(selected) }] : [],
5453
+ { id: "edit", hotkey: "e", label: "edit", color: theme.accent, run: () => onEdit(selected.id) },
5454
+ { id: "delete", hotkey: "d", label: "delete", color: theme.danger, run: () => setConfirmDelete(true) }
5455
+ ] : [];
5456
+ const currentAction = Math.min(actionIndex, Math.max(0, actions.length - 1));
5457
+ const focusPane = (next) => {
5458
+ if (next === "actions") {
5459
+ if (!selected)
5460
+ return;
5461
+ setDetailOpen(true);
5462
+ setActionIndex(0);
5463
+ }
5464
+ if (next !== "actions" && !wide)
5465
+ setDetailOpen(false);
5466
+ setPane(next);
5467
+ };
5468
+ const cyclePane = (delta) => {
5469
+ let index = PANES.indexOf(pane);
5470
+ do
5471
+ index = (index + delta + PANES.length) % PANES.length;
5472
+ while (PANES[index] === "actions" && !selected);
5473
+ focusPane(PANES[index]);
5474
+ };
4710
5475
  useKeyboard6((key) => {
4711
5476
  if (!app.active || key.ctrl || key.meta)
4712
5477
  return;
@@ -4718,13 +5483,18 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4718
5483
  setConfirmDelete(false);
4719
5484
  return;
4720
5485
  }
5486
+ if (key.name === "tab") {
5487
+ key.preventDefault();
5488
+ cyclePane(key.shift ? -1 : 1);
5489
+ return;
5490
+ }
4721
5491
  if (searchFocused) {
4722
5492
  if (key.name === "escape") {
4723
5493
  setQuery("");
4724
- setSearchFocused(false);
4725
- } else if (isEnter(key) || key.name === "down" || key.name === "tab") {
5494
+ setPane("list");
5495
+ } else if (isEnter(key) || key.name === "down") {
4726
5496
  key.preventDefault();
4727
- setSearchFocused(false);
5497
+ setPane("list");
4728
5498
  }
4729
5499
  return;
4730
5500
  }
@@ -4754,7 +5524,7 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4754
5524
  return;
4755
5525
  case "/":
4756
5526
  key.preventDefault();
4757
- setSearchFocused(true);
5527
+ focusPane("search");
4758
5528
  return;
4759
5529
  case "n":
4760
5530
  onCreate();
@@ -4773,30 +5543,52 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4773
5543
  app.toast("Reloaded", "info");
4774
5544
  return;
4775
5545
  }
4776
- if (!selected)
4777
- return;
4778
- if (isEnter(key)) {
4779
- if (!showDetail)
4780
- setDetailOpen(true);
4781
- else
4782
- copyPrimary(selected);
5546
+ if (key.name === "escape") {
5547
+ if (pane === "actions")
5548
+ focusPane("list");
5549
+ else if (revealed)
5550
+ setRevealed(null);
5551
+ else if (query)
5552
+ setQuery("");
4783
5553
  return;
4784
5554
  }
4785
- switch (key.name) {
4786
- case "right":
4787
- case "l":
4788
- setDetailOpen(true);
5555
+ if (!selected)
5556
+ return;
5557
+ if (pane === "actions") {
5558
+ switch (key.name) {
5559
+ case "left":
5560
+ case "h":
5561
+ if (currentAction === 0)
5562
+ focusPane("list");
5563
+ else
5564
+ setActionIndex((i) => Math.max(0, Math.min(i, actions.length - 1) - 1));
5565
+ return;
5566
+ case "right":
5567
+ case "l":
5568
+ setActionIndex((i) => Math.min(actions.length - 1, i + 1));
5569
+ return;
5570
+ case "space":
5571
+ actions[currentAction]?.run();
5572
+ return;
5573
+ }
5574
+ if (isEnter(key)) {
5575
+ actions[currentAction]?.run();
4789
5576
  return;
4790
- case "left":
4791
- case "h":
4792
- case "escape":
4793
- if (detailOpen && !wide)
4794
- setDetailOpen(false);
4795
- else if (revealed)
4796
- setRevealed(null);
4797
- else if (query)
4798
- setQuery("");
5577
+ }
5578
+ } else {
5579
+ if (key.name === "right" || key.name === "l") {
5580
+ focusPane("actions");
4799
5581
  return;
5582
+ }
5583
+ if (isEnter(key)) {
5584
+ if (showDetail)
5585
+ copyPrimary(selected);
5586
+ else
5587
+ focusPane("actions");
5588
+ return;
5589
+ }
5590
+ }
5591
+ switch (key.name) {
4800
5592
  case "c":
4801
5593
  copyPrimary(selected);
4802
5594
  return;
@@ -4828,7 +5620,7 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4828
5620
  const commands = useMemo5(() => {
4829
5621
  const list = [
4830
5622
  { id: "new", title: "New secret", hint: "n", group: "Vault", run: onCreate },
4831
- { id: "search", title: "Search secrets", hint: "/", group: "Vault", run: () => setSearchFocused(true) },
5623
+ { id: "search", title: "Search secrets", hint: "/", group: "Vault", run: () => setPane("search") },
4832
5624
  { id: "reload", title: "Reload list", hint: "r", group: "Vault", run: reload }
4833
5625
  ];
4834
5626
  if (selected) {
@@ -4848,6 +5640,7 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4848
5640
  setQuery("");
4849
5641
  setSelectedId(entry.id);
4850
5642
  setDetailOpen(true);
5643
+ setPane("actions");
4851
5644
  }
4852
5645
  });
4853
5646
  }
@@ -4864,12 +5657,14 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4864
5657
  hints = [["y", "delete"], ["n/esc", "cancel"]];
4865
5658
  else if (searchFocused)
4866
5659
  hints = [["type", "filter"], ["enter/\u2193", "results"], ["esc", "clear"]];
5660
+ else if (pane === "actions")
5661
+ hints = [["\u2190\u2192", "choose"], ["enter", "run"], ["\u2191\u2193", "secret"], ["esc", "back"]];
4867
5662
  else if (!showDetail)
4868
- hints = [["\u2191\u2193", "move"], ["enter", "open"], ["c", "copy"], ["u", "user"], ["/", "search"], ["n", "new"], ["ctrl+k", "commands"], [",", "settings"], ["q", "quit"]];
5663
+ hints = [["\u2191\u2193", "move"], ["enter", "open"], ["c", "copy"], ["/", "search"], ["n", "new"], ["q", "quit"]];
4869
5664
  else
4870
- hints = [["enter/c", "copy"], ["u", "user"], ["s", "reveal"], ...selected?.type === "otp" ? [["o", "code"]] : [], ["e", "edit"], ["d", "delete"], ["/", "search"], ["ctrl+k", "commands"], wide ? ["q", "quit"] : ["esc", "back"]];
5665
+ hints = [["\u2191\u2193", "move"], ["enter", "copy"], ["\u2192/tab", "actions"], ["/", "search"], ["n", "new"], ["q", "quit"]];
4871
5666
  setHints(hints);
4872
- }, [confirmDelete, searchFocused, showDetail, wide, selected?.type, setHints]);
5667
+ }, [confirmDelete, searchFocused, pane, showDetail, setHints]);
4873
5668
  const listWidth = wide ? Math.max(32, Math.floor(width * 0.38)) : width;
4874
5669
  const offset = Math.max(0, Math.min(selectedIndex - Math.floor(listHeight / 2), filtered.length - listHeight));
4875
5670
  const visible = filtered.slice(offset, offset + listHeight);
@@ -4902,8 +5697,12 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4902
5697
  backgroundColor: isSelected ? theme.selection : undefined,
4903
5698
  onMouseDown: () => {
4904
5699
  setSelectedId(entry.id);
4905
- if (!wide)
5700
+ if (wide)
5701
+ setPane("list");
5702
+ else {
4906
5703
  setDetailOpen(true);
5704
+ setPane("actions");
5705
+ }
4907
5706
  },
4908
5707
  children: /* @__PURE__ */ jsxs6("text", {
4909
5708
  children: [
@@ -4936,11 +5735,8 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4936
5735
  otp: otp?.id === selected.id ? otp : null,
4937
5736
  now,
4938
5737
  confirmDelete,
4939
- onCopy: () => copyPrimary(selected),
4940
- onOtp: () => copyPrimary(selected),
4941
- onOpen: () => open(selected),
4942
- onEdit: () => onEdit(selected.id),
4943
- onDelete: () => setConfirmDelete(true)
5738
+ actions,
5739
+ focusedAction: pane === "actions" ? currentAction : null
4944
5740
  }) : /* @__PURE__ */ jsx6("box", {
4945
5741
  flexGrow: 1,
4946
5742
  border: true,
@@ -4987,9 +5783,12 @@ function VaultScreen({ session, initialSelectedId, onCreate, onEdit, onSettings,
4987
5783
  ]
4988
5784
  });
4989
5785
  }
4990
- function Detail({ entry, revealed, otp, now, confirmDelete, onCopy, onOtp, onOpen, onEdit, onDelete }) {
5786
+ function valueName(type) {
5787
+ return type === "note" ? "Note" : type === "otp" ? "Secret key" : "Password";
5788
+ }
5789
+ function Detail({ entry, revealed, otp, now, confirmDelete, actions, focusedAction }) {
4991
5790
  const badge = typeBadge(entry.type);
4992
- const valueLabel = entry.type === "note" ? "Note" : entry.type === "otp" ? "Secret key" : "Password";
5791
+ const valueLabel = valueName(entry.type);
4993
5792
  const revealSeconds = revealed ? Math.max(0, Math.ceil((revealed.until - now) / 1000)) : 0;
4994
5793
  const otpSeconds = otp ? Math.max(0, Math.ceil((otp.expiresAt - now) / 1000)) : 0;
4995
5794
  const otpBar = 10;
@@ -5119,51 +5918,28 @@ function Detail({ entry, revealed, otp, now, confirmDelete, onCopy, onOtp, onOpe
5119
5918
  })
5120
5919
  ]
5121
5920
  })
5122
- }) : /* @__PURE__ */ jsxs6("box", {
5921
+ }) : /* @__PURE__ */ jsx6("box", {
5123
5922
  flexDirection: "row",
5124
5923
  gap: 1,
5125
5924
  flexWrap: "wrap",
5126
- children: [
5127
- entry.type === "otp" ? /* @__PURE__ */ jsx6(Button, {
5128
- hotkey: "o",
5129
- label: "copy code",
5130
- onPress: onOtp,
5131
- color: theme.success
5132
- }) : /* @__PURE__ */ jsx6(Button, {
5133
- hotkey: "c",
5134
- label: `copy ${valueLabel.toLowerCase()}`,
5135
- onPress: onCopy,
5136
- color: theme.success
5137
- }),
5138
- entry.url && entry.type !== "otp" ? /* @__PURE__ */ jsx6(Button, {
5139
- hotkey: "o",
5140
- label: "open",
5141
- onPress: onOpen,
5142
- color: theme.info
5143
- }) : null,
5144
- /* @__PURE__ */ jsx6(Button, {
5145
- hotkey: "e",
5146
- label: "edit",
5147
- onPress: onEdit,
5148
- color: theme.accent
5149
- }),
5150
- /* @__PURE__ */ jsx6(Button, {
5151
- hotkey: "d",
5152
- label: "delete",
5153
- onPress: onDelete,
5154
- color: theme.danger
5155
- })
5156
- ]
5925
+ children: actions.map((action, i) => /* @__PURE__ */ jsx6(Button, {
5926
+ hotkey: action.hotkey,
5927
+ label: action.label,
5928
+ onPress: action.run,
5929
+ color: action.color,
5930
+ focused: focusedAction === i
5931
+ }, action.id))
5157
5932
  })
5158
5933
  ]
5159
5934
  });
5160
5935
  }
5161
- var WIDE_MIN_COLUMNS = 96;
5936
+ var PANES, WIDE_MIN_COLUMNS = 96;
5162
5937
  var init_vault = __esm(() => {
5163
5938
  init_components();
5164
5939
  init_context();
5165
5940
  init_theme();
5166
5941
  init_util();
5942
+ PANES = ["search", "list", "actions"];
5167
5943
  });
5168
5944
 
5169
5945
  // apps/cli/src/tui.tsx
@@ -5213,11 +5989,11 @@ function App({ vaultPath, clipboard, revealMs: initialRevealMs, clipboardClearMs
5213
5989
  const current = sessionRef.current;
5214
5990
  if (!current)
5215
5991
  return;
5216
- current.close();
5992
+ closeSessionAndRecordSeal(vaultPath, current);
5217
5993
  setSession(null);
5218
5994
  setPaletteOpen(false);
5219
5995
  setRoute({ name: "unlock", notice });
5220
- }, []);
5996
+ }, [vaultPath]);
5221
5997
  const quit = useCallback4(() => {
5222
5998
  if (quitting.current)
5223
5999
  return;
@@ -5322,6 +6098,9 @@ function App({ vaultPath, clipboard, revealMs: initialRevealMs, clipboardClearMs
5322
6098
  ] : [],
5323
6099
  { id: "quit", title: "Quit", hint: "ctrl+c \xD72", group: "App", run: quit }
5324
6100
  ];
6101
+ const goVault = useCallback4(() => setRoute({ name: "vault" }), []);
6102
+ const goSettings = useCallback4(() => setRoute({ name: "settings" }), []);
6103
+ const globalHints = !session || route.name === "unlock" ? [] : route.name === "vault" ? [["ctrl+k", "commands"], [",", "settings"]] : [["ctrl+k", "commands"]];
5325
6104
  const title = route.name === "unlock" ? "Locked" : route.name === "form" ? route.editing ? "Edit secret" : "New secret" : route.name === "settings" ? "Settings" : "Vault";
5326
6105
  const vaultName = basename3(vaultPath);
5327
6106
  let screen;
@@ -5349,7 +6128,7 @@ function App({ vaultPath, clipboard, revealMs: initialRevealMs, clipboardClearMs
5349
6128
  screen = /* @__PURE__ */ jsx7(SettingsScreen, {
5350
6129
  vaultPath,
5351
6130
  setHints,
5352
- onBack: () => setRoute({ name: "vault" }),
6131
+ onBack: goVault,
5353
6132
  passkeyRunner
5354
6133
  });
5355
6134
  } else {
@@ -5358,7 +6137,7 @@ function App({ vaultPath, clipboard, revealMs: initialRevealMs, clipboardClearMs
5358
6137
  initialSelectedId: route.focusId,
5359
6138
  onCreate: () => setRoute({ name: "form", editing: null }),
5360
6139
  onEdit,
5361
- onSettings: () => setRoute({ name: "settings" }),
6140
+ onSettings: goSettings,
5362
6141
  setHints,
5363
6142
  revealMs
5364
6143
  }, route.focusId ?? "vault");
@@ -5400,6 +6179,7 @@ function App({ vaultPath, clipboard, revealMs: initialRevealMs, clipboardClearMs
5400
6179
  }),
5401
6180
  /* @__PURE__ */ jsx7(StatusBar, {
5402
6181
  hints,
6182
+ pinned: globalHints,
5403
6183
  toast: toastState
5404
6184
  }),
5405
6185
  paletteOpen ? /* @__PURE__ */ jsx7(CommandPalette, {
@@ -5426,9 +6206,8 @@ async function runTui(vaultPath) {
5426
6206
  const sessionSink = { current: null };
5427
6207
  renderer.once("destroy", () => {
5428
6208
  clipboard.handOffPendingClear();
5429
- try {
5430
- sessionSink.current?.close();
5431
- } catch {}
6209
+ if (sessionSink.current)
6210
+ closeSessionAndRecordSeal(vault, sessionSink.current).catch(() => {});
5432
6211
  sessionSink.current = null;
5433
6212
  });
5434
6213
  createRoot(renderer).render(/* @__PURE__ */ jsx7(App, {
@@ -5443,6 +6222,7 @@ async function runTui(vaultPath) {
5443
6222
  }
5444
6223
  var init_tui = __esm(() => {
5445
6224
  init_src3();
6225
+ init_os_secrets();
5446
6226
  init_clipboard();
5447
6227
  init_components();
5448
6228
  init_context();
@@ -5458,10 +6238,9 @@ var init_tui = __esm(() => {
5458
6238
  init_clipboard();
5459
6239
  init_passkey();
5460
6240
  init_package();
5461
- import { spawn as spawn3 } from "child_process";
5462
6241
  import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "fs";
5463
6242
  import { createInterface as createInterface2 } from "readline/promises";
5464
- import { basename as basename4, isAbsolute, resolve as resolve5 } from "path";
6243
+ import { basename as basename4, isAbsolute, resolve as resolve6 } from "path";
5465
6244
 
5466
6245
  // apps/cli/src/password.ts
5467
6246
  import { createInterface } from "readline/promises";
@@ -5544,13 +6323,14 @@ async function readPasswordMasked(prompt = "Master password: ") {
5544
6323
  init_src();
5545
6324
  init_src2();
5546
6325
  init_src3();
6326
+ init_os_secrets();
5547
6327
  init_paths();
5548
6328
  init_paths();
5549
- import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
6329
+ import { createHash as createHash3, timingSafeEqual as timingSafeEqual4 } from "crypto";
5550
6330
  import { existsSync as existsSync4, readFileSync as readFileSync4, rmSync } from "fs";
5551
6331
  var DAEMON_TOKEN_SERVICE = "autho.daemon";
5552
6332
  function daemonTokenName(statePath) {
5553
- return createHash2("sha256").update(statePath).digest("hex");
6333
+ return createHash3("sha256").update(statePath).digest("hex");
5554
6334
  }
5555
6335
  async function storeDaemonToken(statePath, token) {
5556
6336
  const tokenName = daemonTokenName(statePath);
@@ -5641,20 +6421,11 @@ async function deleteDaemonState(statePath) {
5641
6421
  rmSync(statePath, { force: true });
5642
6422
  }
5643
6423
  }
5644
- function openSessionFromRootKey(rootKey, vaultPath) {
5645
- return new VaultSession(new AuthoDatabase(vaultPath), rootKey);
5646
- }
5647
- function unlockVaultRootKey(vaultPath, password) {
5648
- const db = new AuthoDatabase(vaultPath);
5649
- try {
5650
- const config = db.getVaultConfig();
5651
- if (!config) {
5652
- throw new Error(`Vault is not initialized at ${vaultPath}`);
5653
- }
5654
- return unlockRootKey(password, config);
5655
- } finally {
5656
- db.close();
5657
- }
6424
+ async function openSessionFromRootKey(session) {
6425
+ return new VaultSession(new AuthoDatabase(session.vaultPath), session.rootKey, {
6426
+ sealBound: session.sealBound,
6427
+ sealRecord: await loadSealRecord(session.vaultPath)
6428
+ });
5658
6429
  }
5659
6430
  async function readJson(request) {
5660
6431
  return await request.json();
@@ -5698,7 +6469,7 @@ async function startDaemonServer(options) {
5698
6469
  };
5699
6470
  const auth = (request) => {
5700
6471
  const provided = getBearerToken(request);
5701
- if (!provided || provided.length !== token.length || !timingSafeEqual2(Buffer.from(provided), Buffer.from(token))) {
6472
+ if (!provided || provided.length !== token.length || !timingSafeEqual4(Buffer.from(provided), Buffer.from(token))) {
5702
6473
  return unauthorized();
5703
6474
  }
5704
6475
  return null;
@@ -5748,13 +6519,18 @@ async function startDaemonServer(options) {
5748
6519
  if (ttlSeconds <= 0 || ttlSeconds > 86400) {
5749
6520
  return json({ error: "Unlock ttl must be between 1 and 86400 seconds" }, 400);
5750
6521
  }
5751
- const rootKey = unlockVaultRootKey(options.vaultPath, body.password);
6522
+ const { rootKey, sealBound } = VaultService.unlockRootKey(options.vaultPath, {
6523
+ password: body.password,
6524
+ sealRecord: await loadSealRecord(options.vaultPath),
6525
+ totp: body.totp
6526
+ });
5752
6527
  const sessionId = randomId();
5753
6528
  const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
5754
6529
  sessions.set(sessionId, {
5755
6530
  expiresAt,
5756
6531
  id: sessionId,
5757
6532
  rootKey,
6533
+ sealBound,
5758
6534
  vaultPath: options.vaultPath
5759
6535
  });
5760
6536
  return json({
@@ -5773,11 +6549,11 @@ async function startDaemonServer(options) {
5773
6549
  if (!session) {
5774
6550
  return json({ error: `Unknown daemon session: ${body.sessionId}` }, 404);
5775
6551
  }
5776
- const vaultSession = openSessionFromRootKey(session.rootKey, session.vaultPath);
6552
+ const vaultSession = await openSessionFromRootKey(session);
5777
6553
  try {
5778
6554
  return json(vaultSession.renderEnv(body.mappings, body.leaseId));
5779
6555
  } finally {
5780
- vaultSession.close();
6556
+ await closeSessionAndRecordSeal(session.vaultPath, vaultSession);
5781
6557
  }
5782
6558
  }
5783
6559
  if (request.method === "POST" && url.pathname === "/exec") {
@@ -5786,11 +6562,11 @@ async function startDaemonServer(options) {
5786
6562
  if (!session) {
5787
6563
  return json({ error: `Unknown daemon session: ${body.sessionId}` }, 404);
5788
6564
  }
5789
- const vaultSession = openSessionFromRootKey(session.rootKey, session.vaultPath);
6565
+ const vaultSession = await openSessionFromRootKey(session);
5790
6566
  try {
5791
6567
  return json(vaultSession.runExec(body));
5792
6568
  } finally {
5793
- vaultSession.close();
6569
+ await closeSessionAndRecordSeal(session.vaultPath, vaultSession);
5794
6570
  }
5795
6571
  }
5796
6572
  if (request.method === "POST" && url.pathname === "/shutdown") {
@@ -5861,6 +6637,7 @@ async function daemonUnlock(options) {
5861
6637
  }
5862
6638
  return daemonRequest(state, "/unlock", {
5863
6639
  password: options.password,
6640
+ totp: options.totp,
5864
6641
  ttlSeconds: options.ttlSeconds
5865
6642
  });
5866
6643
  }
@@ -5905,6 +6682,321 @@ async function daemonStop(options) {
5905
6682
  // apps/cli/src/index.ts
5906
6683
  init_src3();
5907
6684
  init_os_secrets();
6685
+
6686
+ // apps/web/src/server.ts
6687
+ init_src();
6688
+ init_src3();
6689
+ init_os_secrets();
6690
+ import { randomBytes as randomBytes6 } from "crypto";
6691
+ import { resolve as resolve4 } from "path";
6692
+ function json2(data, status = 200, headers) {
6693
+ return new Response(JSON.stringify(data, null, 2), {
6694
+ headers: {
6695
+ "content-type": "application/json; charset=utf-8",
6696
+ ...headers ?? {}
6697
+ },
6698
+ status
6699
+ });
6700
+ }
6701
+ function html(body) {
6702
+ return new Response(body, {
6703
+ headers: {
6704
+ "content-type": "text/html; charset=utf-8"
6705
+ }
6706
+ });
6707
+ }
6708
+ function parseCookies(request) {
6709
+ const cookieHeader = request.headers.get("cookie") ?? "";
6710
+ return Object.fromEntries(cookieHeader.split(";").map((part) => part.trim()).filter(Boolean).map((part) => {
6711
+ const index = part.indexOf("=");
6712
+ return [part.slice(0, index), decodeURIComponent(part.slice(index + 1))];
6713
+ }));
6714
+ }
6715
+ async function openSession(vaultPath, state) {
6716
+ return new VaultSession(new AuthoDatabase(vaultPath), state.rootKey, {
6717
+ sealBound: state.sealBound,
6718
+ sealRecord: await loadSealRecord(vaultPath)
6719
+ });
6720
+ }
6721
+ function requireSession(request, sessions) {
6722
+ const cookies = parseCookies(request);
6723
+ const sessionId = cookies.autho_session;
6724
+ if (!sessionId) {
6725
+ throw new Error("Missing web session");
6726
+ }
6727
+ const session = sessions.get(sessionId);
6728
+ if (!session) {
6729
+ throw new Error("Unknown web session");
6730
+ }
6731
+ if (Date.parse(session.expiresAt) <= Date.now()) {
6732
+ sessions.delete(sessionId);
6733
+ throw new Error("Expired web session");
6734
+ }
6735
+ return { ...session, id: sessionId };
6736
+ }
6737
+ async function readJson2(request) {
6738
+ return await request.json();
6739
+ }
6740
+ function page2() {
6741
+ return `<!doctype html>
6742
+ <html lang="en">
6743
+ <head>
6744
+ <meta charset="utf-8" />
6745
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6746
+ <title>Autho</title>
6747
+ <style>
6748
+ :root { color-scheme: light; font-family: ui-sans-serif, system-ui, sans-serif; }
6749
+ body { margin: 0; background: #f5f1e8; color: #1f2937; }
6750
+ main { max-width: 760px; margin: 0 auto; padding: 32px 20px 64px; }
6751
+ section { background: #fffdf8; border: 1px solid #d6cbb8; border-radius: 16px; padding: 20px; margin-bottom: 16px; box-shadow: 0 8px 30px rgba(83,58,18,0.08); }
6752
+ h1 { margin-top: 0; font-size: 2rem; }
6753
+ input, select, button { font: inherit; padding: 10px 12px; border-radius: 10px; border: 1px solid #bca889; }
6754
+ button { background: #1f4d3a; color: white; border: none; cursor: pointer; }
6755
+ form { display: flex; gap: 8px; flex-wrap: wrap; }
6756
+ pre { background: #1f2937; color: #f9fafb; padding: 12px; border-radius: 12px; overflow: auto; }
6757
+ ul { padding-left: 18px; }
6758
+ </style>
6759
+ </head>
6760
+ <body>
6761
+ <main>
6762
+ <section>
6763
+ <h1>Autho Local Web</h1>
6764
+ <p>Local-only Bun web UI for vault unlock and secret browsing.</p>
6765
+ <form id="unlock-form">
6766
+ <input id="password" type="password" placeholder="Master password" required />
6767
+ <input id="totp" inputmode="numeric" autocomplete="one-time-code" placeholder="Authenticator code (if enabled)" />
6768
+ <button type="submit">Unlock</button>
6769
+ </form>
6770
+ </section>
6771
+ <section>
6772
+ <h2>Secrets</h2>
6773
+ <button id="refresh">Refresh</button>
6774
+ <ul id="secret-list"></ul>
6775
+ <pre id="output"></pre>
6776
+ </section>
6777
+ <section id="linked" hidden>
6778
+ <h2>Linked to <span id="linked-name"></span></h2>
6779
+ <ul id="linked-list"></ul>
6780
+ <p id="linked-from"></p>
6781
+ <form id="link-form">
6782
+ <input id="link-ref" placeholder="Secret name or id to link" required />
6783
+ <button type="submit">Link</button>
6784
+ </form>
6785
+ </section>
6786
+ </main>
6787
+ <script>
6788
+ const output = document.getElementById('output');
6789
+ const secretList = document.getElementById('secret-list');
6790
+ async function refresh() {
6791
+ const response = await fetch('/api/secrets');
6792
+ if (!response.ok) {
6793
+ output.textContent = await response.text();
6794
+ return;
6795
+ }
6796
+ const data = await response.json();
6797
+ secretList.innerHTML = '';
6798
+ data.data.forEach((secret) => {
6799
+ const item = document.createElement('li');
6800
+ const open = document.createElement('a');
6801
+ open.href = '#';
6802
+ open.textContent = secret.name + " (" + secret.type + ")" + (secret.linkCount ? " \xB7 " + secret.linkCount + " linked" : "");
6803
+ open.addEventListener('click', (event) => { event.preventDefault(); showSecret(secret.id); });
6804
+ item.appendChild(open);
6805
+ secretList.appendChild(item);
6806
+ });
6807
+ output.textContent = JSON.stringify(data, null, 2);
6808
+ }
6809
+ let current = null;
6810
+ async function showSecret(ref) {
6811
+ const response = await fetch('/api/secrets/' + encodeURIComponent(ref));
6812
+ if (!response.ok) {
6813
+ output.textContent = await response.text();
6814
+ return;
6815
+ }
6816
+ const secret = (await response.json()).data;
6817
+ current = secret;
6818
+ const { links, linkedFrom, ...rest } = secret;
6819
+ output.textContent = JSON.stringify(rest, null, 2);
6820
+ document.getElementById('linked').hidden = false;
6821
+ document.getElementById('linked-name').textContent = secret.name;
6822
+ const list = document.getElementById('linked-list');
6823
+ list.innerHTML = '';
6824
+ if (!links.length) {
6825
+ list.innerHTML = '<li>No linked secrets</li>';
6826
+ }
6827
+ links.forEach((linked) => {
6828
+ const item = document.createElement('li');
6829
+ const open = document.createElement('a');
6830
+ open.href = '#';
6831
+ open.textContent = linked.name + " (" + linked.type + ") ";
6832
+ open.addEventListener('click', (event) => { event.preventDefault(); showSecret(linked.id); });
6833
+ const unlink = document.createElement('button');
6834
+ unlink.textContent = 'Unlink';
6835
+ unlink.addEventListener('click', () => changeLink('DELETE', linked.id));
6836
+ item.append(open, unlink);
6837
+ list.appendChild(item);
6838
+ });
6839
+ document.getElementById('linked-from').textContent = linkedFrom.length
6840
+ ? 'Linked from: ' + linkedFrom.map((primary) => primary.name).join(', ')
6841
+ : '';
6842
+ }
6843
+ async function changeLink(method, secret) {
6844
+ const response = await fetch('/api/links', {
6845
+ method,
6846
+ headers: { 'content-type': 'application/json' },
6847
+ body: JSON.stringify({ primary: current.id, secret })
6848
+ });
6849
+ if (!response.ok) {
6850
+ output.textContent = await response.text();
6851
+ return;
6852
+ }
6853
+ await showSecret(current.id);
6854
+ await refresh();
6855
+ }
6856
+ document.getElementById('link-form').addEventListener('submit', async (event) => {
6857
+ event.preventDefault();
6858
+ const input = document.getElementById('link-ref');
6859
+ await changeLink('POST', input.value);
6860
+ input.value = '';
6861
+ });
6862
+ document.getElementById('unlock-form').addEventListener('submit', async (event) => {
6863
+ event.preventDefault();
6864
+ const response = await fetch('/api/session/unlock', {
6865
+ method: 'POST',
6866
+ headers: { 'content-type': 'application/json' },
6867
+ body: JSON.stringify({
6868
+ password: document.getElementById('password').value,
6869
+ totp: document.getElementById('totp').value || undefined
6870
+ })
6871
+ });
6872
+ output.textContent = await response.text();
6873
+ if (response.ok) {
6874
+ await refresh();
6875
+ }
6876
+ });
6877
+ document.getElementById('refresh').addEventListener('click', refresh);
6878
+ </script>
6879
+ </body>
6880
+ </html>`;
6881
+ }
6882
+ async function startWebServer(options = {}) {
6883
+ const vaultPath = resolve4(options.vaultPath ?? defaultVaultPath());
6884
+ const host = options.host ?? "127.0.0.1";
6885
+ const port = options.port ?? 8061;
6886
+ const sessions = new Map;
6887
+ const cleanup = () => {
6888
+ for (const [id, session] of sessions.entries()) {
6889
+ if (Date.parse(session.expiresAt) <= Date.now()) {
6890
+ sessions.delete(id);
6891
+ }
6892
+ }
6893
+ };
6894
+ const server = Bun.serve({
6895
+ fetch: async (request) => {
6896
+ cleanup();
6897
+ const url = new URL(request.url);
6898
+ try {
6899
+ if (request.method === "GET" && url.pathname === "/health") {
6900
+ return json2({ ok: true, running: true, vaultPath });
6901
+ }
6902
+ if (request.method === "GET" && url.pathname === "/") {
6903
+ return html(page2());
6904
+ }
6905
+ if (request.method === "POST" && url.pathname === "/api/session/unlock") {
6906
+ const body = await readJson2(request);
6907
+ const ttlSeconds = body.ttlSeconds ?? 3600;
6908
+ if (ttlSeconds <= 0 || ttlSeconds > 86400) {
6909
+ return json2({ error: "Session TTL must be between 1 and 86400 seconds" }, 400);
6910
+ }
6911
+ const { rootKey, sealBound } = VaultService.unlockRootKey(vaultPath, {
6912
+ password: body.password,
6913
+ sealRecord: await loadSealRecord(vaultPath),
6914
+ totp: body.totp
6915
+ });
6916
+ const sessionId = randomBytes6(16).toString("hex");
6917
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
6918
+ sessions.set(sessionId, { expiresAt, rootKey, sealBound });
6919
+ return json2({ expiresAt, sessionId }, 200, { "set-cookie": `autho_session=${sessionId}; HttpOnly; SameSite=Strict; Path=/; Secure` });
6920
+ }
6921
+ if (request.method === "POST" && url.pathname === "/api/session/lock") {
6922
+ const cookies = parseCookies(request);
6923
+ if (cookies.autho_session) {
6924
+ sessions.delete(cookies.autho_session);
6925
+ }
6926
+ return json2({ locked: true }, 200, { "set-cookie": "autho_session=; Max-Age=0; Path=/; Secure" });
6927
+ }
6928
+ if (url.pathname.startsWith("/api/")) {
6929
+ const webSession = requireSession(request, sessions);
6930
+ const session = await openSession(vaultPath, webSession);
6931
+ try {
6932
+ if (request.method === "GET" && url.pathname === "/api/status") {
6933
+ return json2({
6934
+ initialized: true,
6935
+ secretCount: session.listSecrets().length,
6936
+ unlocked: true,
6937
+ vaultPath
6938
+ });
6939
+ }
6940
+ if (request.method === "GET" && url.pathname === "/api/secrets") {
6941
+ return json2({ data: session.listSecretIndex() });
6942
+ }
6943
+ if (request.method === "GET" && url.pathname.startsWith("/api/secrets/")) {
6944
+ const ref = decodeURIComponent(url.pathname.slice("/api/secrets/".length));
6945
+ const { linkedFrom, links } = session.getLinks(ref);
6946
+ return json2({ data: { ...session.getSecret(ref), linkedFrom, links } });
6947
+ }
6948
+ if ((request.method === "POST" || request.method === "DELETE") && url.pathname === "/api/links") {
6949
+ const body = await readJson2(request);
6950
+ return json2({
6951
+ data: request.method === "POST" ? session.linkSecrets(body.primary, body.secret) : session.unlinkSecrets(body.primary, body.secret)
6952
+ });
6953
+ }
6954
+ if (request.method === "POST" && url.pathname === "/api/secrets") {
6955
+ const body = await readJson2(request);
6956
+ return json2({
6957
+ data: session.addSecret({
6958
+ metadata: Object.fromEntries(Object.entries({
6959
+ algorithm: body.algorithm,
6960
+ description: body.description,
6961
+ digits: body.digits,
6962
+ url: body.url
6963
+ }).filter(([, value]) => value !== undefined)),
6964
+ name: body.name,
6965
+ type: body.type,
6966
+ username: body.username,
6967
+ value: body.value
6968
+ })
6969
+ }, 201);
6970
+ }
6971
+ if (request.method === "DELETE" && url.pathname.startsWith("/api/secrets/")) {
6972
+ const ref = decodeURIComponent(url.pathname.slice("/api/secrets/".length));
6973
+ return json2({ data: session.removeSecret(ref, { withLinked: url.searchParams.get("withLinked") === "1" }) });
6974
+ }
6975
+ if (request.method === "GET" && url.pathname.startsWith("/api/otp/")) {
6976
+ const ref = decodeURIComponent(url.pathname.slice("/api/otp/".length));
6977
+ return json2({ data: session.generateOtp(ref) });
6978
+ }
6979
+ } finally {
6980
+ await closeSessionAndRecordSeal(vaultPath, session);
6981
+ }
6982
+ }
6983
+ } catch (error) {
6984
+ const message = error instanceof Error ? error.message : String(error);
6985
+ const status = /session/i.test(message) ? 401 : 400;
6986
+ return json2({ error: message }, status);
6987
+ }
6988
+ return json2({ error: "Not found" }, 404);
6989
+ },
6990
+ hostname: host,
6991
+ port
6992
+ });
6993
+ process.on("SIGINT", () => process.exit(0));
6994
+ process.on("SIGTERM", () => process.exit(0));
6995
+ console.log(`Autho web server listening on http://${host}:${server.port}`);
6996
+ await new Promise(() => {});
6997
+ }
6998
+
6999
+ // apps/cli/src/index.ts
5908
7000
  function parseArgs(argv) {
5909
7001
  const dashDashIndex = argv.indexOf("--");
5910
7002
  const main = dashDashIndex === -1 ? argv : argv.slice(0, dashDashIndex);
@@ -5989,8 +7081,16 @@ function output(value, jsonMode = false) {
5989
7081
  }
5990
7082
  console.log(value);
5991
7083
  }
7084
+ function printSealChanges(changes, diffTrusted) {
7085
+ if (!diffTrusted && changes.length > 0) {
7086
+ console.log("The stored row signatures were edited too, so the list below may be incomplete.");
7087
+ }
7088
+ for (const change of changes) {
7089
+ console.log(` ${change.change.padEnd(8)} ${change.table} ${change.rowId}`);
7090
+ }
7091
+ }
5992
7092
  function absolutePath(path) {
5993
- return resolve5(path);
7093
+ return resolve6(path);
5994
7094
  }
5995
7095
  function buildSecretMetadata(args) {
5996
7096
  return Object.fromEntries(Object.entries({
@@ -6046,7 +7146,7 @@ async function runPromptMode(vaultPath, initialPassword) {
6046
7146
  const prompt = await createPromptAdapter();
6047
7147
  try {
6048
7148
  const password = await askPassword(prompt, initialPassword);
6049
- const session = VaultService.unlock(vaultPath, password);
7149
+ const session = VaultService.unlock(vaultPath, { password, sealRecord: await loadSealRecord(vaultPath) });
6050
7150
  try {
6051
7151
  const action = (await prompt.ask("Action [create/read/list/delete/otp/exit]: ")).toLowerCase();
6052
7152
  if (action === "exit" /* Exit */) {
@@ -6096,7 +7196,7 @@ async function runPromptMode(vaultPath, initialPassword) {
6096
7196
  }
6097
7197
  throw new Error(`Unknown prompt action: ${action}`);
6098
7198
  } finally {
6099
- session.close();
7199
+ await closeSessionAndRecordSeal(vaultPath, session);
6100
7200
  }
6101
7201
  } finally {
6102
7202
  prompt.close();
@@ -6119,8 +7219,9 @@ async function passkeyCredentials(vaultPath) {
6119
7219
  return { password: "", passkey: { credentialId: result.credentialId, prfOutput: result.prfOutput } };
6120
7220
  }
6121
7221
  async function resolveUnlockCredentials(vaultPath, args, existingPassword) {
7222
+ const sealRecord = await loadSealRecord(vaultPath);
6122
7223
  if (wantsPasskey(args)) {
6123
- return passkeyCredentials(vaultPath);
7224
+ return { ...await passkeyCredentials(vaultPath), sealRecord };
6124
7225
  }
6125
7226
  let password = existingPassword ?? getString(args, "password") ?? process.env.AUTHO_MASTER_PASSWORD;
6126
7227
  if (!password) {
@@ -6129,7 +7230,7 @@ async function resolveUnlockCredentials(vaultPath, args, existingPassword) {
6129
7230
  if (!password && process.stdin.isTTY) {
6130
7231
  password = await readPasswordMasked("Master password: ");
6131
7232
  }
6132
- const creds = { password: required(password, "--password") };
7233
+ const creds = { password: required(password, "--password"), sealRecord };
6133
7234
  if (await hasPinSet(vaultPath)) {
6134
7235
  const pin = process.env.AUTHO_PIN ?? getString(args, "pin") ?? (process.stdin.isTTY ? await readPasswordMasked("PIN: ") : undefined);
6135
7236
  if (!pin)
@@ -6244,34 +7345,10 @@ async function runInitWizard(vaultPath, password, existingCreds) {
6244
7345
  console.log("Setup complete. Run \x1B[1mautho init\x1B[0m anytime to change these settings.");
6245
7346
  }
6246
7347
  async function runWebServer(vaultPath, args) {
6247
- const commandArgs = [
6248
- "run",
6249
- absolutePath("./apps/web/src/index.ts"),
6250
- "serve",
6251
- "--vault",
6252
- absolutePath(vaultPath)
6253
- ];
6254
- const host = getString(args, "host");
6255
- const port = getString(args, "port") ?? String(numberPreference("webPort"));
6256
- if (host) {
6257
- commandArgs.push("--host", host);
6258
- }
6259
- if (port) {
6260
- commandArgs.push("--port", port);
6261
- }
6262
- await new Promise((resolvePromise, rejectPromise) => {
6263
- const child = spawn3(process.execPath, commandArgs, {
6264
- cwd: process.cwd(),
6265
- stdio: "inherit"
6266
- });
6267
- child.on("exit", (code) => {
6268
- if (code === 0) {
6269
- resolvePromise();
6270
- return;
6271
- }
6272
- rejectPromise(new Error(`Web server exited with code ${code ?? 1}`));
6273
- });
6274
- child.on("error", rejectPromise);
7348
+ await startWebServer({
7349
+ host: getString(args, "host"),
7350
+ port: Number(getString(args, "port") ?? numberPreference("webPort")),
7351
+ vaultPath: absolutePath(vaultPath)
6275
7352
  });
6276
7353
  }
6277
7354
  function help() {
@@ -6302,12 +7379,17 @@ function help() {
6302
7379
  " import legacy --file <path> [--skip-existing] [--vault <path>] [--json]",
6303
7380
  " secrets add --name <name> --type <password|note|otp> --value <value> [--username <v>] [--url <v>] [--description <v>] [--digits <v>] [--algorithm <v>] [--vault <path>]",
6304
7381
  " secrets list [--vault <path>] [--json]",
6305
- " secrets get --ref <name-or-id> [--vault <path>] [--json]",
6306
- " secrets rm --ref <name-or-id> [--vault <path>] [--json]",
7382
+ " secrets get --ref <name-or-id> [--links] [--vault <path>] [--json]",
7383
+ " secrets rm --ref <name-or-id> [--with-linked] [--vault <path>] [--json]",
7384
+ " link <primary> <secret> [--vault <path>] [--json] Show <secret> on <primary>'s page",
7385
+ " unlink <primary> <secret> [--vault <path>] [--json]",
6307
7386
  " secrets edit --ref <name-or-id> [--new-name <v>] [--value <v>] [--username <v>] [--url <v>] [--description <v>] [--vault <path>] [--json]",
6308
7387
  " otp code --ref <name-or-id> [--vault <path>] [--json]",
6309
- " lease create --secret <ref> [--secret <ref>] [--ttl <seconds>] [--name <v>] [--vault <path>] [--json]",
7388
+ " lease create --secret <ref> [--secret <ref>] [--ttl <seconds>] [--name <v>] [--no-links] [--vault <path>] [--json]",
7389
+ " lease show --lease <id> [--vault <path>] [--json]",
6310
7390
  " lease revoke --lease <id> [--vault <path>] [--json]",
7391
+ " vault verify [--vault <path>] [--json]",
7392
+ " vault reseal [--accept] [--vault <path>] [--json]",
6311
7393
  " env render --map <ENV=ref> [--project-file <path>] [--lease <id>] [--vault <path>] [--json]",
6312
7394
  " env sync --map <ENV=ref> [--project-file <path>] [--lease <id>] [--ttl <seconds>] [--output <path>] [--force] [--vault <path>] [--json]",
6313
7395
  " exec --map <ENV=ref> [--project-file <path>] [--lease <id>] [--vault <path>] -- <cmd>",
@@ -6358,6 +7440,9 @@ function help() {
6358
7440
  " Config keys: vaultDir, revealSeconds, clipboardClearSeconds, idleLockSeconds, passwordLength,",
6359
7441
  " passwordSymbols, mouse, defaultLeaseTtl, daemonTtlSeconds, webPort (see `autho config show`).",
6360
7442
  " lease create uses the defaultLeaseTtl setting when --ttl is omitted (0 = --ttl required).",
7443
+ " A lease on a secret also covers the secrets linked to it, read live (--no-links to opt out).",
7444
+ " env and exec inject an OTP secret's current code, never its seed.",
7445
+ " The vault is sealed: edits made outside autho stop env, exec and leases until `autho vault reseal`.",
6361
7446
  " In the TUI, press , for Settings. `autho --version` prints the installed version.",
6362
7447
  " The default vault path is ~/.autho/vault.db (override with config or AUTHO_HOME).",
6363
7448
  " The default project file is <vaultDir>/project.json.",
@@ -6419,7 +7504,10 @@ async function main() {
6419
7504
  "audit" /* Audit */,
6420
7505
  "import" /* Import */,
6421
7506
  "recovery" /* Recovery */,
6422
- "unlock" /* Unlock */
7507
+ "unlock" /* Unlock */,
7508
+ "link" /* Link */,
7509
+ "unlink" /* Unlink */,
7510
+ "vault" /* Vault */
6423
7511
  ].includes(scope ?? "");
6424
7512
  const daemonNeedsPassword = scope === "daemon" /* Daemon */ && action === "unlock" /* Unlock */;
6425
7513
  if (needsPassword || daemonNeedsPassword) {
@@ -6437,7 +7525,7 @@ async function main() {
6437
7525
  console.log(" Tip: Use a cloud-synced folder (e.g. Google Drive) to share the vault across machines.");
6438
7526
  const customDir = (await readLine(` Path (Enter to keep default): `)).trim();
6439
7527
  if (customDir && customDir !== currentDir) {
6440
- const resolved = resolve5(customDir).replace(/\\/g, "/");
7528
+ const resolved = resolve6(customDir).replace(/\\/g, "/");
6441
7529
  saveConfig({ ...config, vaultDir: resolved });
6442
7530
  effectiveVaultPath = resolved + "/vault.db";
6443
7531
  console.log(` \x1B[32m\u2713\x1B[0m Vault directory set to ${resolved}`);
@@ -6505,7 +7593,7 @@ async function main() {
6505
7593
  if (key === "autoLock") {
6506
7594
  updated.autoLock = value === "true";
6507
7595
  } else if (key === "vaultDir") {
6508
- updated.vaultDir = isAbsolute(value) ? value : resolve5(value);
7596
+ updated.vaultDir = isAbsolute(value) ? value : resolve6(value);
6509
7597
  } else if (key === "editor") {
6510
7598
  updated.editor = value;
6511
7599
  } else if (key === "autoLockTimeout") {
@@ -6595,9 +7683,13 @@ async function main() {
6595
7683
  return;
6596
7684
  }
6597
7685
  if (scope === "daemon" /* Daemon */ && action === "unlock" /* Unlock */) {
7686
+ const creds = await resolveUnlockCredentials(vaultPath, args, password);
7687
+ if (creds.passkey || creds.recovery)
7688
+ throw new Error("daemon unlock needs the master password");
6598
7689
  output(await daemonUnlock({
6599
- password: required(password, "--password"),
7690
+ password: creds.password,
6600
7691
  statePath,
7692
+ totp: creds.totp,
6601
7693
  ttlSeconds: getString(args, "ttl") ? Number(getString(args, "ttl")) : numberPreference("daemonTtlSeconds")
6602
7694
  }), jsonMode);
6603
7695
  return;
@@ -6683,6 +7775,7 @@ async function main() {
6683
7775
  info = admin.addPasskey({ ...result, label, prfSalt });
6684
7776
  } finally {
6685
7777
  admin.close();
7778
+ await saveSealRecord(vaultPath, admin.sealRecord());
6686
7779
  }
6687
7780
  if (jsonMode) {
6688
7781
  output({ added: true, id: info.id, label: info.label }, true);
@@ -6701,6 +7794,31 @@ async function main() {
6701
7794
  if (scope === "passkey" /* Passkey */) {
6702
7795
  throw new Error(`Unknown passkey action: ${action}. Use add, list or remove.`);
6703
7796
  }
7797
+ if (scope === "vault" /* Vault */ && action === "reseal" /* Reseal */) {
7798
+ const mode = getBoolean(args, "accept") ? "accept" : "quarantine";
7799
+ const result = VaultService.reseal(vaultPath, await resolveUnlockCredentials(vaultPath, args, password), mode);
7800
+ await saveSealRecord(vaultPath, result.sealRecord);
7801
+ if (jsonMode) {
7802
+ output(result, true);
7803
+ } else if (result.wasIntact) {
7804
+ console.log("The vault seal is intact; nothing to do.");
7805
+ } else {
7806
+ if (result.reason)
7807
+ console.log(`Seal problem: ${result.reason}.`);
7808
+ printSealChanges(result.changes, result.diffTrusted);
7809
+ if (mode === "quarantine") {
7810
+ console.log(`Revoked ${result.revokedLeases} active lease(s).`);
7811
+ if (result.undone.length)
7812
+ console.log(`Undid ${result.undone.length} change(s).`);
7813
+ if (result.kept.length) {
7814
+ console.log(`Couldn't undo ${result.kept.length} change(s); check these by hand:`);
7815
+ printSealChanges(result.kept, true);
7816
+ }
7817
+ }
7818
+ console.log(mode === "accept" ? "Vault re-sealed as it is now." : "Vault re-sealed.");
7819
+ }
7820
+ return;
7821
+ }
6704
7822
  if (scope === "password" /* Password */ && action === "change" /* Change */) {
6705
7823
  const admin = VaultService.authorize(vaultPath, await resolveUnlockCredentials(vaultPath, args, password));
6706
7824
  try {
@@ -6723,6 +7841,7 @@ async function main() {
6723
7841
  }
6724
7842
  } finally {
6725
7843
  admin.close();
7844
+ await saveSealRecord(vaultPath, admin.sealRecord());
6726
7845
  }
6727
7846
  return;
6728
7847
  }
@@ -6770,13 +7889,41 @@ async function main() {
6770
7889
  return;
6771
7890
  }
6772
7891
  if (scope === "secrets" /* Secrets */ && action === "get" /* Get */) {
6773
- const ref = getString(args, "ref") ?? getString(args, "name") ?? getString(args, "id");
6774
- output(session.getSecret(required(ref, "--ref")), jsonMode);
7892
+ const ref = required(getString(args, "ref") ?? getString(args, "name") ?? getString(args, "id"), "--ref");
7893
+ const secret = session.getSecret(ref);
7894
+ output(getBoolean(args, "links") ? { ...secret, ...session.getLinks(secret.id) } : secret, jsonMode);
6775
7895
  return;
6776
7896
  }
6777
7897
  if (scope === "secrets" /* Secrets */ && action === "rm" /* Rm */) {
6778
7898
  const ref = getString(args, "ref") ?? getString(args, "name") ?? getString(args, "id");
6779
- output(session.removeSecret(required(ref, "--ref")), jsonMode);
7899
+ output(session.removeSecret(required(ref, "--ref"), { withLinked: getBoolean(args, "with-linked") }), jsonMode);
7900
+ return;
7901
+ }
7902
+ if (scope === "link" /* Link */ || scope === "unlink" /* Unlink */) {
7903
+ const primary = required(action ?? getString(args, "primary"), "<primary>");
7904
+ const linked = required(subaction ?? getString(args, "secret"), "<secret>");
7905
+ output(scope === "link" /* Link */ ? session.linkSecrets(primary, linked) : session.unlinkSecrets(primary, linked), jsonMode);
7906
+ return;
7907
+ }
7908
+ if (scope === "vault" /* Vault */ && action === "verify" /* Verify */) {
7909
+ const seal = session.verifySeal();
7910
+ if (jsonMode) {
7911
+ output(seal, true);
7912
+ } else if (seal.state === "intact") {
7913
+ console.log("Vault seal is intact.");
7914
+ } else if (seal.state === "unsealed") {
7915
+ console.log("Vault isn't sealed yet. Unlock it with the master password to seal it.");
7916
+ } else {
7917
+ console.log(`Vault was modified outside autho: ${seal.reason}.`);
7918
+ printSealChanges(seal.changes, seal.diffTrusted);
7919
+ console.log("Run `autho vault reseal` to undo the changes and revoke leases, or `autho vault reseal --accept` if you made the change.");
7920
+ }
7921
+ if (seal.state === "tampered")
7922
+ process.exitCode = 2;
7923
+ return;
7924
+ }
7925
+ if (scope === "lease" /* Lease */ && action === "show" /* Show */) {
7926
+ output(session.showLease(required(getString(args, "lease"), "--lease")), jsonMode);
6780
7927
  return;
6781
7928
  }
6782
7929
  if (scope === "secrets" /* Secrets */ && action === "edit" /* Edit */) {
@@ -6804,6 +7951,7 @@ async function main() {
6804
7951
  if (scope === "lease" /* Lease */ && action === "create" /* Create */) {
6805
7952
  output(session.createLease({
6806
7953
  name: getString(args, "name") ?? "session",
7954
+ noLinks: getBoolean(args, "no-links"),
6807
7955
  secretRefs: getStrings(args, "secret"),
6808
7956
  ttlSeconds: leaseTtl(args)
6809
7957
  }), jsonMode);
@@ -6868,7 +8016,7 @@ async function main() {
6868
8016
  }
6869
8017
  throw new Error(`Unknown command: ${[scope, action, subaction].filter(Boolean).join(" ")}`);
6870
8018
  } finally {
6871
- session.close();
8019
+ await closeSessionAndRecordSeal(vaultPath, session);
6872
8020
  }
6873
8021
  }
6874
8022
  main().catch((error) => {