run402 4.45.1 → 4.47.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gitvault-surface.json +74 -0
- package/lib/gitvault-capabilities.mjs +40 -0
- package/lib/repos.mjs +34 -0
- package/package.json +3 -2
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +46 -1
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +78 -0
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +44 -8
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +38 -13
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +111 -18
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +300 -48
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-recover.d.ts +41 -1
- package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-recover.js +168 -25
- package/sdk/dist/node/gitvault-recover.js.map +1 -1
|
@@ -37,7 +37,7 @@ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
|
|
|
37
37
|
import { tmpdir } from "node:os";
|
|
38
38
|
import { join } from "node:path";
|
|
39
39
|
import { LocalError, isRun402Error } from "../errors.js";
|
|
40
|
-
import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openFrame, openKeyEnvelope, parseGitvaultStrict, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
|
|
40
|
+
import { GITVAULT_FORMAT, GITVAULT_GENESIS_EPOCH, GITVAULT_GENESIS_GENERATION, GITVAULT_HEX16_RE, GITVAULT_OID40_RE, GITVAULT_SUITE, attemptKeyCommitment, bytesToHex, checkFreshEpochKeyAgainstPriorKeys, checkHPartition, computeRotationId, computeTargetPartitionDigest, deriveDigestKey, deriveObjectKey, ekFingerprint, epochRotationKeyCommitment, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyEnvelopeLedgerId, keyedCommitment, newGitvaultId, newHex32, nextEpoch, objectsetContent, openBindingPreimage, openEpochRotationForRecipient, openFrame, openKeyEnvelope, parseGitvaultStrict, parseRotateEpochPayload, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
|
|
41
41
|
import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
|
|
42
42
|
import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
|
|
43
43
|
import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
|
|
@@ -452,8 +452,18 @@ export function checkChainLink(input) {
|
|
|
452
452
|
fail("CHAIN_BROKEN", `head generation ${h.generation} ≠ expected ${input.expected_generation} (generation must equal newest+1)`, "verifying head chain", { got: h.generation, expected: input.expected_generation });
|
|
453
453
|
if (h.prev_sha256 !== input.prev_sha256)
|
|
454
454
|
fail("CHAIN_BROKEN", `head ${h.generation}: prev_sha256 does not name the predecessor's stored bytes`, "verifying head chain", { prev_sha256: h.prev_sha256, expected: input.prev_sha256 });
|
|
455
|
-
|
|
456
|
-
|
|
455
|
+
// D194, rev 42 (relaxed by D193): epoch continuity — a head's `epoch`
|
|
456
|
+
// equals its predecessor's UNLESS THIS head admits a `rotate_epoch`
|
|
457
|
+
// transition, in which case it equals `nextEpoch(predecessor.epoch)`
|
|
458
|
+
// exactly (increment-by-one, no skip). Pure and keyless: this checks only
|
|
459
|
+
// the head's own signed `epoch` FIELD against the predecessor's, never
|
|
460
|
+
// opens any envelope — decrypting under the resulting epoch is a separate,
|
|
461
|
+
// keyed step (`GitvaultVault`'s rotation-envelope open, `openEpochRotationForRecipient`).
|
|
462
|
+
const isRotation = h.transition !== null && h.transition.kind === "rotate_epoch";
|
|
463
|
+
const permittedEpoch = isRotation ? nextEpoch(input.prev_epoch) : input.prev_epoch;
|
|
464
|
+
if (h.epoch !== permittedEpoch) {
|
|
465
|
+
fail("CHAIN_BROKEN", `head ${h.generation}: epoch ${h.epoch} does not match the expected ${permittedEpoch} (${isRotation ? "post-rotation increment of the predecessor's epoch" : "predecessor continuity — only an admitted rotate_epoch transition may advance the epoch"})`, "verifying head chain", { generation: h.generation, got: h.epoch, expected: permittedEpoch, prev_epoch: input.prev_epoch, is_rotation: isRotation });
|
|
466
|
+
}
|
|
457
467
|
if (h.writer_key_id !== input.writer_key_id)
|
|
458
468
|
fail("CHAIN_BROKEN", `head ${h.generation}: writer_key_id ${h.writer_key_id} is not the registered writer`, "verifying head chain");
|
|
459
469
|
if (!verifyGitvaultObject(h, input.writer_public_key))
|
|
@@ -469,12 +479,23 @@ export function checkChainLink(input) {
|
|
|
469
479
|
}
|
|
470
480
|
/**
|
|
471
481
|
* The transition fail-closed rule: a V0 client that encounters an ADMITTED
|
|
472
|
-
*
|
|
473
|
-
* no publish past it, `UPGRADE_REQUIRED`. Unknown kinds
|
|
482
|
+
* transition kind it cannot validate stops advancing — read-only at the
|
|
483
|
+
* materialized pin, no publish past it, `UPGRADE_REQUIRED`. Unknown kinds
|
|
484
|
+
* are a parse reject.
|
|
485
|
+
*
|
|
486
|
+
* `rotate_epoch` is EXEMPT from this rule as of rev 42 (D193: "epoch
|
|
487
|
+
* rotation is ACTIVATED") — `checkChainLink`'s own D194 epoch-continuity
|
|
488
|
+
* check already validates its structural admissibility, and
|
|
489
|
+
* {@link parseRotateEpochPayload} / the caller's own envelope-open step
|
|
490
|
+
* (`GitvaultVault.verifyToNewest`) handle it fully. The other three kinds
|
|
491
|
+
* (`add_envelope`, `add_writer_key`, `transfer_binding`) remain genuinely
|
|
492
|
+
* unactivated and stay fail-closed exactly as before.
|
|
474
493
|
*/
|
|
475
494
|
export function assertNoTransition(head) {
|
|
476
495
|
if (head.transition === null)
|
|
477
496
|
return;
|
|
497
|
+
if (head.transition.kind === "rotate_epoch")
|
|
498
|
+
return;
|
|
478
499
|
const kinds = ["add_envelope", "rotate_epoch", "add_writer_key", "transfer_binding"];
|
|
479
500
|
if (!kinds.includes(head.transition.kind))
|
|
480
501
|
fail("CHAIN_BROKEN", `head ${head.generation}: unknown transition kind ${String(head.transition.kind)} (closed enum)`, "verifying head chain");
|
|
@@ -599,19 +620,18 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
599
620
|
return null;
|
|
600
621
|
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
|
|
601
622
|
return null;
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
//
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
throw new LocalError(`the gateway's object-reads route does not yet accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch is still hardcoded to key_envelope's epoch/recipient_fingerprint shape) — this vault's pin manifest at ${path} cannot be read back over the network by a keystore that did not itself just publish it`, "reading gitvault object", { code: "GITVAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
|
|
623
|
+
// The gateway accepts `{object_kind: "recipient_pin_manifest",
|
|
624
|
+
// pin_manifest_version}` reads (fixed 2026-08-28; before that its
|
|
625
|
+
// null-idScalar validation was hardcoded to key_envelope's
|
|
626
|
+
// `{epoch, recipient_fingerprint}` shape and 400'd every such read
|
|
627
|
+
// with "epoch must be 16 hex"). A VALIDATION_FAILED here therefore
|
|
628
|
+
// indicates a genuinely malformed request and propagates as-is —
|
|
629
|
+
// EXCEPT the pre-fix epoch-shape complaint, which a fixed gateway can
|
|
630
|
+
// never emit for this kind: that signature can only mean an unfixed
|
|
631
|
+
// (older/staging) gateway behind RUN402_API_BASE, so name it rather
|
|
632
|
+
// than letting it read as a client validation bug.
|
|
633
|
+
if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED" && /epoch must be 16 hex/.test(e.message ?? "")) {
|
|
634
|
+
throw new LocalError(`this gateway predates the 2026-08-28 fix that made object-reads accept recipient_pin_manifest reads by pin_manifest_version (its null-idScalar branch rejected them with key_envelope's epoch-shape complaint) — this vault's pin manifest at ${path} cannot be read back over the network from it by a keystore that did not itself just publish the manifest`, "reading gitvault object", { code: "GITVAULT_PIN_MANIFEST_READ_UNSUPPORTED", details: { path, pin_manifest_version: ref.read.pin_manifest_version }, cause: e });
|
|
615
635
|
}
|
|
616
636
|
throw e;
|
|
617
637
|
}
|
|
@@ -936,9 +956,33 @@ export class GitvaultVault {
|
|
|
936
956
|
* retry restarts from the ORIGINAL pin rather than resuming — the honest
|
|
937
957
|
* consequence of asking for a no-write audit and then walking off the end
|
|
938
958
|
* of one call's budget.
|
|
959
|
+
*
|
|
960
|
+
* The chain walk itself (`checkChainLink`, `assertNoTransition`, collecting
|
|
961
|
+
* `rotations[]`) is ALWAYS keyless — an admitted `rotate_epoch` transition
|
|
962
|
+
* never stops it (D193, rev 42), so `generation`/`head` here are the
|
|
963
|
+
* genuinely chain-verified newest, independent of whether this principal
|
|
964
|
+
* can decrypt anything past a rotation it cannot open.
|
|
965
|
+
*
|
|
966
|
+
* `options.decryptValidate` (default `false`, Part C — `repos fsck`'s
|
|
967
|
+
* decrypt-validation pass): additionally opens every `rotate_epoch`
|
|
968
|
+
* envelope needed and decrypts each walked generation's OWN
|
|
969
|
+
* `ref_state`/`retention_roots` as it goes — the "main object" restoration
|
|
970
|
+
* needs per generation — persisting newly-opened epoch keys via
|
|
971
|
+
* `keystore.recordEpochRotation` exactly like an ordinary rotation
|
|
972
|
+
* producer/consumer would, and counting each decrypt attempt as an EXTRA
|
|
973
|
+
* unit against the same `this.budget` (decryption is the expensive step).
|
|
974
|
+
* `options.strict` (default `true`) throws immediately, fail-closed, on the
|
|
975
|
+
* first `GITVAULT_EPOCH_NOT_OPENABLE` / AEAD failure it hits — the ordinary
|
|
976
|
+
* `materialize()` read path's behavior. `strict: false` (fsck's tolerant
|
|
977
|
+
* mode) instead records `decrypt.failure` and stops attempting further
|
|
978
|
+
* decrypts while the pure chain walk keeps going — this is exactly what
|
|
979
|
+
* makes `chain_verified_to` (this call's `generation`) and `decryptable_to`
|
|
980
|
+
* (`decrypt.decryptable_to_generation`) able to differ honestly.
|
|
939
981
|
*/
|
|
940
982
|
async verifyToNewest(options = {}) {
|
|
941
983
|
const persist = options.persist ?? true;
|
|
984
|
+
const decryptValidate = options.decryptValidate ?? false;
|
|
985
|
+
const strict = options.strict ?? true;
|
|
942
986
|
const { genesis, sha256: genesisSha } = await this.genesis();
|
|
943
987
|
const writerKey = genesis.creator_signing_pubkey;
|
|
944
988
|
const writerKeyId = genesis.writer_key_id;
|
|
@@ -946,9 +990,95 @@ export class GitvaultVault {
|
|
|
946
990
|
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
947
991
|
let lastHead = pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
|
|
948
992
|
const anchor = pin.generation;
|
|
993
|
+
let prevEpoch = lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH;
|
|
949
994
|
let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
|
|
950
995
|
let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
|
|
951
996
|
let verified = 0;
|
|
997
|
+
const rotations = [];
|
|
998
|
+
// ── decrypt-validation state (opt-in) ──
|
|
999
|
+
const identity = decryptValidate ? this.keystore.readIdentity() : null;
|
|
1000
|
+
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
|
|
1001
|
+
const epochKeys = { ...(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }) };
|
|
1002
|
+
let decryptPin = repo.materialized_pin ?? null;
|
|
1003
|
+
let decryptedRefState = null;
|
|
1004
|
+
let decryptedRoots = null;
|
|
1005
|
+
let decryptFailure = null;
|
|
1006
|
+
let decryptFailureError = null;
|
|
1007
|
+
const persistMaterializedIfAny = () => {
|
|
1008
|
+
if (persist && decryptValidate && decryptPin)
|
|
1009
|
+
this.keystore.updateRepo(this.repoId, { materialized_pin: decryptPin });
|
|
1010
|
+
};
|
|
1011
|
+
/**
|
|
1012
|
+
* Open (if needed) `headPin`'s own rotation envelope and decrypt its
|
|
1013
|
+
* `ref_state`/`retention_roots`. Shared by the per-head inline call below
|
|
1014
|
+
* AND the post-loop catch-up call: a call with NOTHING new to walk (this
|
|
1015
|
+
* repo's chain-verified pin is already at the newest generation) never
|
|
1016
|
+
* enters the loop body at all, so the newest head's own decrypt still
|
|
1017
|
+
* needs to run once, using whatever `epoch_keys` the keystore already
|
|
1018
|
+
* persisted from an EARLIER call's rotation-envelope open.
|
|
1019
|
+
*
|
|
1020
|
+
* NEVER throws — a decrypt failure is recorded on the enclosing
|
|
1021
|
+
* `decryptFailure`/`decryptFailureError` and this returns `false`. This
|
|
1022
|
+
* is deliberate: chain verification (this call's OWN `generation`/
|
|
1023
|
+
* `head_pin`, "highest_authenticated") is independent of decrypt
|
|
1024
|
+
* capability and must walk the FULL chain regardless — exactly the
|
|
1025
|
+
* existing "authenticated-but-undecryptable ⇒ CHAIN_UNUSABLE, read-only
|
|
1026
|
+
* at the materialized pin" split this file's own header already
|
|
1027
|
+
* documents. `strict` is enforced ONCE, at the very end of
|
|
1028
|
+
* `verifyToNewest`, after the complete chain walk has run.
|
|
1029
|
+
*/
|
|
1030
|
+
const tryDecrypt = async (head, headPin, pendingRotations) => {
|
|
1031
|
+
verified += 1; // decryption is the expensive step — counted separately against the same budget
|
|
1032
|
+
// Tracks whichever rotation is currently being opened — the failure
|
|
1033
|
+
// report below names THAT rotation's own generation/epoch/rotation_id,
|
|
1034
|
+
// never `head`'s (which, in the backward catch-up call, can be a much
|
|
1035
|
+
// LATER, ordinary-push generation entirely unrelated to which epoch
|
|
1036
|
+
// actually failed to open).
|
|
1037
|
+
let current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
|
|
1038
|
+
try {
|
|
1039
|
+
for (const rot of pendingRotations) {
|
|
1040
|
+
if (epochKeys[rot.epoch])
|
|
1041
|
+
continue;
|
|
1042
|
+
current = { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id };
|
|
1043
|
+
if (!identity || !ownKeypair) {
|
|
1044
|
+
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no local gitvault identity — cannot open epoch ${rot.epoch} (rotation ${rot.payload.rotation_id})`, "opening an epoch-rotation key envelope", { epoch: rot.epoch, rotation_id: rot.payload.rotation_id });
|
|
1045
|
+
}
|
|
1046
|
+
const ownFingerprint = ekFingerprint(ownKeypair.public_key);
|
|
1047
|
+
const kE = await openEpochRotationForRecipient({
|
|
1048
|
+
repo_id: this.repoId,
|
|
1049
|
+
payload: rot.payload,
|
|
1050
|
+
own_fingerprint: ownFingerprint,
|
|
1051
|
+
own_encryption_keypair: ownKeypair,
|
|
1052
|
+
writer_signing_public_key: writerKey,
|
|
1053
|
+
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
|
|
1054
|
+
envelope_path: (epoch, fp, rotationId) => gitvaultPaths.envelope(epoch, fp, rotationId),
|
|
1055
|
+
});
|
|
1056
|
+
epochKeys[rot.epoch] = bytesToHex(kE);
|
|
1057
|
+
if (persist)
|
|
1058
|
+
this.keystore.recordEpochRotation(this.repoId, { new_epoch: rot.epoch, new_k_repo_hex: bytesToHex(kE) });
|
|
1059
|
+
}
|
|
1060
|
+
current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
|
|
1061
|
+
const kRepoHex = epochKeys[head.epoch];
|
|
1062
|
+
if (!kRepoHex) {
|
|
1063
|
+
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${head.epoch} at generation ${head.generation}`, "materializing gitvault head", { epoch: head.epoch, generation: head.generation });
|
|
1064
|
+
}
|
|
1065
|
+
const kRepo = hexToBytes(kRepoHex);
|
|
1066
|
+
const refState = await this.openCarrier("ref_state", head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
|
|
1067
|
+
const roots = await this.openCarrier("retention_roots", head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
|
|
1068
|
+
if (refState.generation !== head.generation || roots.generation !== head.generation)
|
|
1069
|
+
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
|
|
1070
|
+
decryptedRefState = refState;
|
|
1071
|
+
decryptedRoots = roots;
|
|
1072
|
+
decryptPin = { ...headPin };
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
catch (e) {
|
|
1076
|
+
const code = isRun402Error(e) && e.code ? e.code : "GITVAULT_EPOCH_NOT_OPENABLE";
|
|
1077
|
+
decryptFailure = { generation: current.generation, epoch: current.epoch, rotation_id: current.rotation_id, code, message: e instanceof Error ? e.message : String(e) };
|
|
1078
|
+
decryptFailureError = e;
|
|
1079
|
+
return false;
|
|
1080
|
+
}
|
|
1081
|
+
};
|
|
952
1082
|
for (;;) {
|
|
953
1083
|
const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
|
|
954
1084
|
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
|
|
@@ -956,6 +1086,7 @@ export class GitvaultVault {
|
|
|
956
1086
|
if (verified >= this.budget) {
|
|
957
1087
|
if (persist)
|
|
958
1088
|
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
|
|
1089
|
+
persistMaterializedIfAny();
|
|
959
1090
|
fail("VERIFICATION_BUDGET_EXCEEDED", persist
|
|
960
1091
|
? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
|
|
961
1092
|
: `${verified} heads verified this call in no-write mode; nothing was persisted — a retry restarts from the original pin, not generation ${pin.generation}`, "verifying gitvault chain", { verified_through: pin.generation, persisted: persist }, [{ action: persist ? "resume verification from the persisted verified prefix" : "re-run without --no-write to persist and resume incrementally, or re-run this same audit call again from the start" }]);
|
|
@@ -964,7 +1095,7 @@ export class GitvaultVault {
|
|
|
964
1095
|
if (!bytes)
|
|
965
1096
|
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
|
|
966
1097
|
const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
967
|
-
checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: writerKey, writer_key_id: writerKeyId });
|
|
1098
|
+
checkChainLink({ head, stored_bytes: bytes, listed_sha256: entry.stored_bytes_sha256, expected_generation: nextGeneration(pin.generation), prev_sha256: pin.head_sha256, repo_id: this.repoId, writer_public_key: writerKey, writer_key_id: writerKeyId, prev_epoch: prevEpoch });
|
|
968
1099
|
try {
|
|
969
1100
|
assertNoTransition(head);
|
|
970
1101
|
}
|
|
@@ -972,23 +1103,116 @@ export class GitvaultVault {
|
|
|
972
1103
|
// fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
|
|
973
1104
|
if (persist)
|
|
974
1105
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
1106
|
+
persistMaterializedIfAny();
|
|
975
1107
|
throw e;
|
|
976
1108
|
}
|
|
1109
|
+
const isRotation = head.transition !== null && head.transition.kind === "rotate_epoch";
|
|
1110
|
+
let rotationPayload = null;
|
|
1111
|
+
if (isRotation) {
|
|
1112
|
+
// Pure/keyless (Part A's structural half — D202's join predicate
|
|
1113
|
+
// needs only this): parses + self-checks the payload, but never
|
|
1114
|
+
// opens an envelope. Collected regardless of decryptValidate so a
|
|
1115
|
+
// later `materialize()` call over an already-chain-verified prefix
|
|
1116
|
+
// can still resolve the epoch keys it needs.
|
|
1117
|
+
rotationPayload = parseRotateEpochPayload(head);
|
|
1118
|
+
rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload });
|
|
1119
|
+
}
|
|
1120
|
+
prevEpoch = head.epoch;
|
|
977
1121
|
pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
978
1122
|
lastHead = head;
|
|
979
1123
|
verified += 1;
|
|
1124
|
+
// The chain walk ALWAYS continues below regardless of decrypt
|
|
1125
|
+
// outcome (`tryDecrypt` never throws) — `!decryptFailure` just stops
|
|
1126
|
+
// WASTING further decrypt attempts once one has failed, since every
|
|
1127
|
+
// later generation shares the same unopenable epoch until (if ever)
|
|
1128
|
+
// a LATER rotation this principal CAN open supersedes it.
|
|
1129
|
+
if (decryptValidate && !decryptFailure)
|
|
1130
|
+
await tryDecrypt(head, pin, rotationPayload ? [{ generation: head.generation, epoch: head.epoch, payload: rotationPayload }] : []);
|
|
980
1131
|
}
|
|
981
1132
|
// verified prefix persists per page (resumable) — skipped entirely in no-write mode
|
|
982
1133
|
if (persist)
|
|
983
1134
|
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
|
|
1135
|
+
persistMaterializedIfAny();
|
|
984
1136
|
const next = nextListingRequest(request, page);
|
|
985
1137
|
if (!next)
|
|
986
1138
|
break;
|
|
987
1139
|
request = next;
|
|
988
1140
|
}
|
|
1141
|
+
// Catch-up: a call with NOTHING new to walk (this repo's chain-verified
|
|
1142
|
+
// pin was already at `pin`/`lastHead` — e.g. an EARLIER, decrypt-blind
|
|
1143
|
+
// `verifyToNewest({})` call already advanced `head_pin` to the newest,
|
|
1144
|
+
// and THIS is the first decrypt-validating call) never entered the loop
|
|
1145
|
+
// body above at all, so any `rotate_epoch` transitions between the
|
|
1146
|
+
// newest and the highest epoch this call's `epochKeys` seed already
|
|
1147
|
+
// covers were NEVER collected — walk BACKWARD via `prev_sha256` (the
|
|
1148
|
+
// same re-read `chainFrom` uses) to find every one of them, stopping the
|
|
1149
|
+
// INSTANT a generation's own epoch is already a known key (nothing
|
|
1150
|
+
// earlier can matter: every generation before it decrypts under a key
|
|
1151
|
+
// already held). Never a silent gap — this is exactly the mechanism
|
|
1152
|
+
// that makes a vault readable end to end even when chain verification
|
|
1153
|
+
// and decrypt-validation happened in genuinely SEPARATE calls.
|
|
1154
|
+
if (decryptValidate && !decryptFailure && lastHead && !decryptedRefState) {
|
|
1155
|
+
const backwardRotations = [];
|
|
1156
|
+
let cur = lastHead;
|
|
1157
|
+
let curPin = pin;
|
|
1158
|
+
while (cur && !epochKeys[cur.epoch]) {
|
|
1159
|
+
if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
|
|
1160
|
+
const payload = parseRotateEpochPayload(cur);
|
|
1161
|
+
backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload });
|
|
1162
|
+
if (!rotations.some((r) => r.generation === cur.generation))
|
|
1163
|
+
rotations.push({ generation: cur.generation, epoch: cur.epoch, payload });
|
|
1164
|
+
}
|
|
1165
|
+
if (cur.generation === "0000000000000001") {
|
|
1166
|
+
cur = null;
|
|
1167
|
+
break;
|
|
1168
|
+
}
|
|
1169
|
+
const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
|
|
1170
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(prevGen) });
|
|
1171
|
+
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
1172
|
+
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing gitvault head", { generation: prevGen });
|
|
1173
|
+
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1174
|
+
curPin = { generation: prevGen, head_sha256: bytes ? sha256Hex(bytes) : curPin.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
1175
|
+
}
|
|
1176
|
+
rotations.sort((a, b) => (a.generation < b.generation ? -1 : a.generation > b.generation ? 1 : 0));
|
|
1177
|
+
// `cur` (non-null, non-lastHead) is the BOUNDARY generation whose own
|
|
1178
|
+
// epoch this call already holds a key for — establish `decryptPin`
|
|
1179
|
+
// THERE first (this always succeeds: its epoch is, by the loop's own
|
|
1180
|
+
// stop condition, already in `epochKeys`), so `refs`/`head_target`
|
|
1181
|
+
// reflect a REAL decrypted generation even when every pending rotation
|
|
1182
|
+
// toward `lastHead` then fails. Without this, a keystore that has
|
|
1183
|
+
// never once materialized (no `materialized_pin` yet) reports
|
|
1184
|
+
// `decryptable_to_generation: genesis` on an epoch-open failure, even
|
|
1185
|
+
// though generations well before the failing rotation were genuinely
|
|
1186
|
+
// decryptable all along.
|
|
1187
|
+
if (cur && cur.generation !== lastHead.generation)
|
|
1188
|
+
await tryDecrypt(cur, curPin, []);
|
|
1189
|
+
if (!decryptFailure)
|
|
1190
|
+
await tryDecrypt(lastHead, pin, backwardRotations);
|
|
1191
|
+
}
|
|
989
1192
|
if (persist)
|
|
990
1193
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
991
|
-
|
|
1194
|
+
persistMaterializedIfAny();
|
|
1195
|
+
// `strict` (materialize()'s ordinary, fail-closed read path) is enforced
|
|
1196
|
+
// HERE, once, after the full keyless chain walk has already run to
|
|
1197
|
+
// completion — never mid-walk (see `tryDecrypt`'s own doc comment).
|
|
1198
|
+
if (strict && decryptValidate && decryptFailure)
|
|
1199
|
+
throw decryptFailureError;
|
|
1200
|
+
return {
|
|
1201
|
+
generation: pin.generation,
|
|
1202
|
+
head_sha256: pin.head_sha256,
|
|
1203
|
+
head: lastHead,
|
|
1204
|
+
genesis,
|
|
1205
|
+
rotations,
|
|
1206
|
+
decrypt: decryptValidate
|
|
1207
|
+
? {
|
|
1208
|
+
decryptable_to_generation: decryptPin?.generation ?? GITVAULT_GENESIS_GENERATION,
|
|
1209
|
+
ref_state: decryptedRefState,
|
|
1210
|
+
retention_roots: decryptedRoots,
|
|
1211
|
+
epoch_keys_hex: epochKeys,
|
|
1212
|
+
failure: decryptFailure,
|
|
1213
|
+
}
|
|
1214
|
+
: null,
|
|
1215
|
+
};
|
|
992
1216
|
}
|
|
993
1217
|
async readHead(generation, expectedSha256) {
|
|
994
1218
|
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
|
|
@@ -1003,14 +1227,24 @@ export class GitvaultVault {
|
|
|
1003
1227
|
fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation });
|
|
1004
1228
|
return parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1005
1229
|
}
|
|
1006
|
-
/**
|
|
1007
|
-
|
|
1230
|
+
/**
|
|
1231
|
+
* Decrypt one encrypted carrier object by its receipt; any failure is
|
|
1232
|
+
* `CHAIN_UNUSABLE`. `keyOverride` supplies the exact `(epoch, k_repo)` this
|
|
1233
|
+
* carrier was sealed under — REQUIRED for any generation that is not
|
|
1234
|
+
* necessarily under `this.epoch()`/`this.kRepo()` (this principal's
|
|
1235
|
+
* CURRENT pointer), which is exactly the case across an epoch rotation;
|
|
1236
|
+
* omitted call sites (checkpoint/prune paths untouched by this fold) keep
|
|
1237
|
+
* the prior CURRENT-pointer behavior unchanged.
|
|
1238
|
+
*/
|
|
1239
|
+
async openCarrier(kind, receipt, path, writerKey, keyOverride) {
|
|
1240
|
+
const epoch = keyOverride?.epoch ?? this.epoch();
|
|
1241
|
+
const kRepo = keyOverride?.k_repo ?? this.kRepo();
|
|
1008
1242
|
const frame = await this.transport.getObject({ repo_id: this.repoId, path });
|
|
1009
1243
|
if (!frame)
|
|
1010
1244
|
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
|
|
1011
1245
|
let plaintext;
|
|
1012
1246
|
try {
|
|
1013
|
-
plaintext = openFrame({ k_obj: deriveObjectKey(
|
|
1247
|
+
plaintext = openFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, receipt.object_id), repo_id: this.repoId, object_kind: kind, object_id: receipt.object_id, epoch, frame, expected_ciphertext_sha256: receipt.ciphertext_sha256 });
|
|
1014
1248
|
}
|
|
1015
1249
|
catch (e) {
|
|
1016
1250
|
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} cannot be opened: ${e.message}`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
|
|
@@ -1027,23 +1261,32 @@ export class GitvaultVault {
|
|
|
1027
1261
|
* {@link verifyToNewest} and gates this method's OWN `materialized_pin`
|
|
1028
1262
|
* write the same way — `repos fsck --no-write` computes and returns the
|
|
1029
1263
|
* real ref map and generation without moving either local pin.
|
|
1264
|
+
*
|
|
1265
|
+
* Runs `verifyToNewest({..., decryptValidate: true, strict: true})`
|
|
1266
|
+
* internally (Part A: an ordinary read across an admitted `rotate_epoch`
|
|
1267
|
+
* transition now opens the rotation's own envelope and decrypts under the
|
|
1268
|
+
* NEW epoch, chaining through multiple sequential rotations; a keystore
|
|
1269
|
+
* with no envelope for a new epoch fails CLOSED with
|
|
1270
|
+
* `GITVAULT_EPOCH_NOT_OPENABLE`, never a bare `GITVAULT_AEAD_AUTH_FAILURE`)
|
|
1271
|
+
* — `strict: true` means this call throws exactly where the OLD
|
|
1272
|
+
* (pre-fix) `materialize()` silently produced a wrong `k_obj` instead.
|
|
1030
1273
|
*/
|
|
1031
1274
|
async materialize(options = {}) {
|
|
1032
1275
|
const persist = options.persist ?? true;
|
|
1033
|
-
const state = await this.verifyToNewest({ persist });
|
|
1034
|
-
const writerKey = state.genesis.creator_signing_pubkey;
|
|
1276
|
+
const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true });
|
|
1035
1277
|
if (!state.head) {
|
|
1036
1278
|
if (persist)
|
|
1037
1279
|
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
1038
|
-
return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" } };
|
|
1280
|
+
return { ...state, ref_state: null, retention_roots: null, refs: {}, roots: [], head_target: { kind: "symref", ref: "refs/heads/main" }, epoch_keys_hex: state.decrypt?.epoch_keys_hex ?? {} };
|
|
1039
1281
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1282
|
+
// `strict: true` guarantees `state.decrypt` is non-null with no failure
|
|
1283
|
+
// and `decryptable_to_generation === state.generation` — it throws
|
|
1284
|
+
// otherwise, so these are never null/mismatched here.
|
|
1285
|
+
const refState = state.decrypt.ref_state;
|
|
1286
|
+
const roots = state.decrypt.retention_roots;
|
|
1042
1287
|
if (refState.generation !== state.generation || roots.generation !== state.generation)
|
|
1043
1288
|
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
|
|
1044
|
-
|
|
1045
|
-
this.keystore.updateRepo(this.repoId, { materialized_pin: { generation: state.generation, head_sha256: state.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) } });
|
|
1046
|
-
return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target };
|
|
1289
|
+
return { ...state, ref_state: refState, retention_roots: roots, refs: { ...refState.refs }, roots: roots.roots.map((r) => ({ ...r })), head_target: refState.head_target, epoch_keys_hex: state.decrypt.epoch_keys_hex };
|
|
1047
1290
|
}
|
|
1048
1291
|
// ── envelope recipients (gitvault-human-envelopes task 4.1, the ADD-path workaround) ──
|
|
1049
1292
|
/**
|
|
@@ -1805,20 +2048,15 @@ export class GitvaultVault {
|
|
|
1805
2048
|
* manifest — e.g. one another principal/machine published) falls through
|
|
1806
2049
|
* to the unchanged network path below.
|
|
1807
2050
|
*
|
|
1808
|
-
*
|
|
1809
|
-
* `POST …/object-reads`
|
|
1810
|
-
*
|
|
1811
|
-
* `
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1817
|
-
* be 16 hex"`. This cache unblocks the SAME keystore re-reading its OWN
|
|
1818
|
-
* just-published manifest (this ceremony's actual need) but does nothing
|
|
1819
|
-
* for a genuinely fresh keystore/machine reading an EXISTING manifest for
|
|
1820
|
-
* the first time (§4.11's "fresh client... SEEDS its local pin file from
|
|
1821
|
-
* it" onboarding path) — that still needs the gateway fix.
|
|
2051
|
+
* History: this cache originally also routed around a gateway gap —
|
|
2052
|
+
* `POST …/object-reads` rejected every `recipient_pin_manifest` read
|
|
2053
|
+
* (its null-`idScalar` validation was hardcoded to `key_envelope`'s
|
|
2054
|
+
* `{epoch, recipient_fingerprint}` shape, never generalized when D197
|
|
2055
|
+
* shipped the second path-addressed kind), so the network fallback below
|
|
2056
|
+
* always 400'd. That gateway bug is fixed (deployed and live-verified
|
|
2057
|
+
* 2026-08-28): the network path works for any keystore, including
|
|
2058
|
+
* §4.11's fresh-client "SEEDS its local pin file from it" onboarding.
|
|
2059
|
+
* The cache stays purely as the round-trip saver described above.
|
|
1822
2060
|
*/
|
|
1823
2061
|
async readPinManifestObject(receipt) {
|
|
1824
2062
|
const known = this.repoFile().known_pin_manifest;
|
|
@@ -2346,6 +2584,20 @@ export class GitvaultVault {
|
|
|
2346
2584
|
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
|
|
2347
2585
|
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
2348
2586
|
}
|
|
2587
|
+
// Every object below is decrypted under ITS OWN carrying head's `epoch`
|
|
2588
|
+
// (D194) — a covered span crossing a rotation mixes epochs, so this
|
|
2589
|
+
// NEVER falls back to `this.kRepo()`/`this.epoch()` (this principal's
|
|
2590
|
+
// CURRENT pointer, which is the NEWEST epoch, not necessarily every
|
|
2591
|
+
// historical one a restore spans). `newest.epoch_keys_hex` is the full
|
|
2592
|
+
// map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
|
|
2593
|
+
// fail-closed if any needed epoch could not be opened), so every lookup
|
|
2594
|
+
// below is guaranteed present.
|
|
2595
|
+
const kRepoForEpoch = (epoch) => {
|
|
2596
|
+
const hex = newest.epoch_keys_hex[epoch];
|
|
2597
|
+
if (!hex)
|
|
2598
|
+
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
|
|
2599
|
+
return hexToBytes(hex);
|
|
2600
|
+
};
|
|
2349
2601
|
const first = heads[0];
|
|
2350
2602
|
if (first.checkpoint) {
|
|
2351
2603
|
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id) });
|
|
@@ -2354,13 +2606,13 @@ export class GitvaultVault {
|
|
|
2354
2606
|
const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
|
|
2355
2607
|
if (!verifyGitvaultObject(claimSet, writerKey))
|
|
2356
2608
|
fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
|
|
2357
|
-
const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey);
|
|
2609
|
+
const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey, { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
|
|
2358
2610
|
checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
|
|
2359
2611
|
for (const p of manifest.packs) {
|
|
2360
2612
|
const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.checkpointPack(p.object_id) });
|
|
2361
2613
|
if (!frame)
|
|
2362
2614
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
|
|
2363
|
-
const plain = openFrame({ k_obj: deriveObjectKey(
|
|
2615
|
+
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(first.epoch), this.repoId, first.epoch, "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: first.epoch, frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
|
|
2364
2616
|
if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
|
|
2365
2617
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring gitvault objects");
|
|
2366
2618
|
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
@@ -2371,7 +2623,7 @@ export class GitvaultVault {
|
|
|
2371
2623
|
const frame = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.wal(w.object_id) });
|
|
2372
2624
|
if (!frame)
|
|
2373
2625
|
fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
|
|
2374
|
-
const plain = openFrame({ k_obj: deriveObjectKey(
|
|
2626
|
+
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(h.epoch), this.repoId, h.epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch: h.epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
|
|
2375
2627
|
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
2376
2628
|
}
|
|
2377
2629
|
}
|