run402 4.46.0 → 4.48.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/git-remote-run402.mjs +84 -13
- package/gitvault-surface.json +1 -1
- package/lib/repos.mjs +29 -0
- package/package.json +1 -1
- 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 +62 -14
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +61 -19
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +18 -5
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +57 -9
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +42 -0
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +130 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +234 -19
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +705 -74
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-recover.d.ts +32 -2
- package/sdk/dist/node/gitvault-recover.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-recover.js +158 -24
- 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");
|
|
@@ -538,6 +559,33 @@ export function gitvaultLedgerId(read) {
|
|
|
538
559
|
}
|
|
539
560
|
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
540
561
|
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
|
|
562
|
+
/**
|
|
563
|
+
* Independent reads/PUTs within one gitvault step run at this concurrency
|
|
564
|
+
* (design D2) — "browser-era origin etiquette", well within what S3/the
|
|
565
|
+
* gateway tolerate, and it keeps in-flight memory bounded by ~6×frame size.
|
|
566
|
+
* Chain-ordered steps (the head-chain walk, admission, readback) never call
|
|
567
|
+
* this — they stay strictly sequential.
|
|
568
|
+
*/
|
|
569
|
+
export const GITVAULT_TRANSPORT_CONCURRENCY = 6;
|
|
570
|
+
/**
|
|
571
|
+
* Run `fn` over `items` with at most `limit` in flight at once, preserving
|
|
572
|
+
* result order regardless of completion order. A plain worker-pool: `limit`
|
|
573
|
+
* workers each pull the next unclaimed index until the queue is empty.
|
|
574
|
+
*/
|
|
575
|
+
export async function mapBounded(items, limit, fn) {
|
|
576
|
+
const results = new Array(items.length);
|
|
577
|
+
let next = 0;
|
|
578
|
+
async function worker() {
|
|
579
|
+
for (;;) {
|
|
580
|
+
const i = next++;
|
|
581
|
+
if (i >= items.length)
|
|
582
|
+
return;
|
|
583
|
+
results[i] = await fn(items[i], i);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await Promise.all(Array.from({ length: Math.max(0, Math.min(limit, items.length)) }, () => worker()));
|
|
587
|
+
return results;
|
|
588
|
+
}
|
|
541
589
|
/**
|
|
542
590
|
* The `fetch`-backed transport over the SDK kernel. Presigned PUTs carry
|
|
543
591
|
* `If-None-Match: *` (create-only — the bucket policy demands it) and the
|
|
@@ -599,19 +647,18 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
599
647
|
return null;
|
|
600
648
|
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
|
|
601
649
|
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 });
|
|
650
|
+
// The gateway accepts `{object_kind: "recipient_pin_manifest",
|
|
651
|
+
// pin_manifest_version}` reads (fixed 2026-08-28; before that its
|
|
652
|
+
// null-idScalar validation was hardcoded to key_envelope's
|
|
653
|
+
// `{epoch, recipient_fingerprint}` shape and 400'd every such read
|
|
654
|
+
// with "epoch must be 16 hex"). A VALIDATION_FAILED here therefore
|
|
655
|
+
// indicates a genuinely malformed request and propagates as-is —
|
|
656
|
+
// EXCEPT the pre-fix epoch-shape complaint, which a fixed gateway can
|
|
657
|
+
// never emit for this kind: that signature can only mean an unfixed
|
|
658
|
+
// (older/staging) gateway behind RUN402_API_BASE, so name it rather
|
|
659
|
+
// than letting it read as a client validation bug.
|
|
660
|
+
if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED" && /epoch must be 16 hex/.test(e.message ?? "")) {
|
|
661
|
+
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
662
|
}
|
|
616
663
|
throw e;
|
|
617
664
|
}
|
|
@@ -625,6 +672,49 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
625
672
|
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
|
|
626
673
|
return new Uint8Array(await r.arrayBuffer());
|
|
627
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* Presign + fetch N independent objects (gitvault-client-round-trips
|
|
677
|
+
* design D2): ONE `object-reads` POST naming every path, then the GETs
|
|
678
|
+
* with bounded concurrency ({@link GITVAULT_TRANSPORT_CONCURRENCY}).
|
|
679
|
+
* Every path must be `object-reads`-addressed (a "carrier": ref_state,
|
|
680
|
+
* retention_roots, a WAL/checkpoint pack, …) — a generation-addressed
|
|
681
|
+
* head/admission path has no place in a batch built for materialize/
|
|
682
|
+
* restore's carrier and pack reads, so it fails closed rather than
|
|
683
|
+
* silently costing an extra round trip through `getGenerationBytes`.
|
|
684
|
+
*/
|
|
685
|
+
async function getObjectsBytes(repoId, paths) {
|
|
686
|
+
if (paths.length === 0)
|
|
687
|
+
return [];
|
|
688
|
+
const refs = paths.map((path) => {
|
|
689
|
+
const ref = gitvaultWireRefForPath(path);
|
|
690
|
+
if (!ref || ref.kind !== "object")
|
|
691
|
+
fail("GITVAULT_OBJECT_READ_FAILED", `${path} is not a batch-readable carrier object`, "reading gitvault objects", { path });
|
|
692
|
+
return ref.read;
|
|
693
|
+
});
|
|
694
|
+
let presigned;
|
|
695
|
+
try {
|
|
696
|
+
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: refs }, context: "resolving gitvault objects" });
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
if (isRun402Error(e) && e.status === 404)
|
|
700
|
+
return paths.map(() => null);
|
|
701
|
+
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
|
|
702
|
+
return paths.map(() => null);
|
|
703
|
+
throw e;
|
|
704
|
+
}
|
|
705
|
+
const byLedgerId = new Map(presigned.reads.map((r) => [gitvaultLedgerId(r), r]));
|
|
706
|
+
const targets = refs.map((r) => byLedgerId.get(gitvaultLedgerId(r)) ?? null);
|
|
707
|
+
return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
|
|
708
|
+
if (!target)
|
|
709
|
+
return null;
|
|
710
|
+
const r = await client.fetch(target.url, { method: "GET" });
|
|
711
|
+
if (r.status === 404)
|
|
712
|
+
return null;
|
|
713
|
+
if (!r.ok)
|
|
714
|
+
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
|
|
715
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
716
|
+
});
|
|
717
|
+
}
|
|
628
718
|
async function upload(repoId, objects, resourceBinding) {
|
|
629
719
|
if (objects.length === 0)
|
|
630
720
|
return [];
|
|
@@ -638,8 +728,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
638
728
|
context: "opening gitvault upload session",
|
|
639
729
|
});
|
|
640
730
|
const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
|
|
641
|
-
|
|
642
|
-
|
|
731
|
+
// Independent create-only PUTs within one session (design D2) — bounded
|
|
732
|
+
// concurrency, same limit as the batched object reads below.
|
|
733
|
+
await mapBounded(objects, GITVAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
|
|
643
734
|
const id = gitvaultLedgerId(entries[i]);
|
|
644
735
|
const target = issued.get(id);
|
|
645
736
|
if (!target)
|
|
@@ -660,11 +751,11 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
660
751
|
const existing = await getObjectBytes(repoId, o.path);
|
|
661
752
|
if (!existing || sha256Hex(existing) !== o.sha256)
|
|
662
753
|
fail("GITVAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading gitvault objects", { path: o.path });
|
|
663
|
-
|
|
754
|
+
return;
|
|
664
755
|
}
|
|
665
756
|
if (!r.ok)
|
|
666
757
|
fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
|
|
667
|
-
}
|
|
758
|
+
});
|
|
668
759
|
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
|
|
669
760
|
const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
|
|
670
761
|
return objects.map((o, i) => {
|
|
@@ -693,7 +784,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
693
784
|
throw e;
|
|
694
785
|
}
|
|
695
786
|
}
|
|
696
|
-
|
|
787
|
+
const transport = {
|
|
697
788
|
// ── creation (5.3) ──
|
|
698
789
|
async allocate(request) {
|
|
699
790
|
// The route wraps the signed allocation object under `allocation` and
|
|
@@ -707,6 +798,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
707
798
|
return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
|
|
708
799
|
},
|
|
709
800
|
getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
|
|
801
|
+
getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
|
|
710
802
|
async admitGenesis(request) {
|
|
711
803
|
try {
|
|
712
804
|
const r = await admit(request.repo_id, GITVAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
|
|
@@ -828,6 +920,80 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
828
920
|
}
|
|
829
921
|
},
|
|
830
922
|
};
|
|
923
|
+
return process.env.RUN402_GITVAULT_TRACE === "1" ? traceGitvaultTransport(transport) : transport;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* `RUN402_GITVAULT_TRACE=1` (design D7) — one stderr line per real
|
|
927
|
+
* transport operation (op kind, a path/object-count shape when the request
|
|
928
|
+
* carries one, byte count when the result carries one, duration) plus a
|
|
929
|
+
* session summary at process exit (total ops, total time spent in this
|
|
930
|
+
* transport, wall-clock since the transport was created). Debug-only:
|
|
931
|
+
* stderr only — never stdout, so it can never contaminate the
|
|
932
|
+
* `git-remote-run402` protocol stream, the same discipline the helper's
|
|
933
|
+
* own `note()` follows — and not a canonical surface: it is NOT what the
|
|
934
|
+
* client-surface spec's counted budgets measure (that is
|
|
935
|
+
* `GitvaultOpCounter`, a test-only instrument over the SAME operation
|
|
936
|
+
* shapes). The client-side env var carries no gateway env-registry policy.
|
|
937
|
+
*/
|
|
938
|
+
function traceGitvaultTransport(inner) {
|
|
939
|
+
let opCount = 0;
|
|
940
|
+
let totalMs = 0;
|
|
941
|
+
const sessionStart = Date.now();
|
|
942
|
+
process.on("exit", () => {
|
|
943
|
+
if (opCount === 0)
|
|
944
|
+
return;
|
|
945
|
+
process.stderr.write(`gitvault-trace: session summary — ${opCount} op(s), ${totalMs.toFixed(1)}ms in transport, ${Date.now() - sessionStart}ms wall-clock\n`);
|
|
946
|
+
});
|
|
947
|
+
const describeRequest = (arg) => {
|
|
948
|
+
if (!arg || typeof arg !== "object")
|
|
949
|
+
return "";
|
|
950
|
+
const a = arg;
|
|
951
|
+
if (typeof a.path === "string")
|
|
952
|
+
return ` path=${a.path}`;
|
|
953
|
+
if (Array.isArray(a.paths))
|
|
954
|
+
return ` paths=${a.paths.length}`;
|
|
955
|
+
if (Array.isArray(a.objects))
|
|
956
|
+
return ` objects=${a.objects.length}`;
|
|
957
|
+
if (typeof a.generation === "string")
|
|
958
|
+
return ` gen=${a.generation}`;
|
|
959
|
+
if (typeof a.repo_id === "string")
|
|
960
|
+
return ` repo=${a.repo_id}`;
|
|
961
|
+
return "";
|
|
962
|
+
};
|
|
963
|
+
const describeResult = (result) => {
|
|
964
|
+
if (result instanceof Uint8Array)
|
|
965
|
+
return ` bytes=${result.length}`;
|
|
966
|
+
if (Array.isArray(result)) {
|
|
967
|
+
const total = result.reduce((sum, v) => sum + (v instanceof Uint8Array ? v.length : 0), 0);
|
|
968
|
+
return total > 0 ? ` bytes=${total}` : "";
|
|
969
|
+
}
|
|
970
|
+
return "";
|
|
971
|
+
};
|
|
972
|
+
return new Proxy(inner, {
|
|
973
|
+
get(target, prop, receiver) {
|
|
974
|
+
const value = Reflect.get(target, prop, receiver);
|
|
975
|
+
if (typeof value !== "function" || typeof prop !== "string")
|
|
976
|
+
return value;
|
|
977
|
+
return async (...args) => {
|
|
978
|
+
const start = Date.now();
|
|
979
|
+
try {
|
|
980
|
+
const result = await Reflect.apply(value, target, args);
|
|
981
|
+
const elapsed = Date.now() - start;
|
|
982
|
+
opCount += 1;
|
|
983
|
+
totalMs += elapsed;
|
|
984
|
+
process.stderr.write(`gitvault-trace: ${prop}${describeRequest(args[0])}${describeResult(result)} ${elapsed}ms\n`);
|
|
985
|
+
return result;
|
|
986
|
+
}
|
|
987
|
+
catch (e) {
|
|
988
|
+
const elapsed = Date.now() - start;
|
|
989
|
+
opCount += 1;
|
|
990
|
+
totalMs += elapsed;
|
|
991
|
+
process.stderr.write(`gitvault-trace: ${prop}${describeRequest(args[0])} FAILED ${elapsed}ms\n`);
|
|
992
|
+
throw e;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
},
|
|
996
|
+
});
|
|
831
997
|
}
|
|
832
998
|
// ─── Storage paths (§3) ──────────────────────────────────────────────────────
|
|
833
999
|
export const gitvaultPaths = {
|
|
@@ -855,6 +1021,39 @@ export const gitvaultPaths = {
|
|
|
855
1021
|
/** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
|
|
856
1022
|
pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
|
|
857
1023
|
};
|
|
1024
|
+
// ─── Incremental restore marker (gitvault-client-round-trips design D5) ──────
|
|
1025
|
+
//
|
|
1026
|
+
// `restored_through` — the newest generation whose WAL packs this TARGET
|
|
1027
|
+
// DIRECTORY has fully applied — is local git state, same store as the
|
|
1028
|
+
// id-pin (`git config --local`), scoped to the directory `restoreObjectsInto`
|
|
1029
|
+
// actually wrote into (never `process.cwd()`, and never scoped to `repo_id`:
|
|
1030
|
+
// a re-pointed remote's marker simply fails its `head_sha256` comparison and
|
|
1031
|
+
// falls back to wholesale, safely, since that comparison is a content hash).
|
|
1032
|
+
const GITVAULT_RESTORE_MARKER_GENERATION_KEY = "r402.restoredThrough";
|
|
1033
|
+
const GITVAULT_RESTORE_MARKER_SHA256_KEY = "r402.restoredThroughSha256";
|
|
1034
|
+
async function readLocalGitConfigValue(dir, key) {
|
|
1035
|
+
try {
|
|
1036
|
+
const out = (await hardenedGit(dir, ["config", "--local", "--get", key])).text().trim();
|
|
1037
|
+
return out.length > 0 ? out : null;
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
// Absent key or not a repository at all — either way, "nothing marked" is the correct read.
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/** Read this target directory's `restored_through` marker, or `null` when nothing was ever restored into it. */
|
|
1045
|
+
export async function readGitvaultRestoreMarker(targetRepoDir) {
|
|
1046
|
+
const generation = await readLocalGitConfigValue(targetRepoDir, GITVAULT_RESTORE_MARKER_GENERATION_KEY);
|
|
1047
|
+
const head_sha256 = await readLocalGitConfigValue(targetRepoDir, GITVAULT_RESTORE_MARKER_SHA256_KEY);
|
|
1048
|
+
if (!generation || !head_sha256)
|
|
1049
|
+
return null;
|
|
1050
|
+
return { generation, head_sha256 };
|
|
1051
|
+
}
|
|
1052
|
+
/** Advance the marker — called only after a restore's coverage verification succeeds. */
|
|
1053
|
+
async function writeGitvaultRestoreMarker(targetRepoDir, generation, headSha256) {
|
|
1054
|
+
await hardenedGit(targetRepoDir, ["config", "--local", GITVAULT_RESTORE_MARKER_GENERATION_KEY, generation]);
|
|
1055
|
+
await hardenedGit(targetRepoDir, ["config", "--local", GITVAULT_RESTORE_MARKER_SHA256_KEY, headSha256]);
|
|
1056
|
+
}
|
|
858
1057
|
/** A transport-agnostic view of git ops the publication needs (the local repository). */
|
|
859
1058
|
export class GitvaultVault {
|
|
860
1059
|
keystore;
|
|
@@ -912,7 +1111,16 @@ export class GitvaultVault {
|
|
|
912
1111
|
if (this.genesisCache)
|
|
913
1112
|
return this.genesisCache;
|
|
914
1113
|
const repo = this.repoFile();
|
|
915
|
-
|
|
1114
|
+
// Design D3: genesis is one small, immutable object per vault — cached
|
|
1115
|
+
// forever beside the keystore's per-repo state, re-verified against the
|
|
1116
|
+
// pinned `genesis_sha256` on every use exactly like a network read.
|
|
1117
|
+
const cached = this.keystore.readCachedGenesis(this.repoId);
|
|
1118
|
+
let bytes = cached && sha256Hex(cached.bytes) === repo.genesis_sha256 ? cached.bytes : null;
|
|
1119
|
+
if (!bytes) {
|
|
1120
|
+
bytes = await this.transport.getGenesis({ repo_id: this.repoId });
|
|
1121
|
+
if (bytes && sha256Hex(bytes) === repo.genesis_sha256)
|
|
1122
|
+
this.keystore.writeCachedGenesis(this.repoId, repo.genesis_sha256, bytes);
|
|
1123
|
+
}
|
|
916
1124
|
if (!bytes)
|
|
917
1125
|
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading gitvault genesis", { repo_id: this.repoId });
|
|
918
1126
|
const sha256 = sha256Hex(bytes);
|
|
@@ -936,9 +1144,33 @@ export class GitvaultVault {
|
|
|
936
1144
|
* retry restarts from the ORIGINAL pin rather than resuming — the honest
|
|
937
1145
|
* consequence of asking for a no-write audit and then walking off the end
|
|
938
1146
|
* of one call's budget.
|
|
1147
|
+
*
|
|
1148
|
+
* The chain walk itself (`checkChainLink`, `assertNoTransition`, collecting
|
|
1149
|
+
* `rotations[]`) is ALWAYS keyless — an admitted `rotate_epoch` transition
|
|
1150
|
+
* never stops it (D193, rev 42), so `generation`/`head` here are the
|
|
1151
|
+
* genuinely chain-verified newest, independent of whether this principal
|
|
1152
|
+
* can decrypt anything past a rotation it cannot open.
|
|
1153
|
+
*
|
|
1154
|
+
* `options.decryptValidate` (default `false`, Part C — `repos fsck`'s
|
|
1155
|
+
* decrypt-validation pass): additionally opens every `rotate_epoch`
|
|
1156
|
+
* envelope needed and decrypts each walked generation's OWN
|
|
1157
|
+
* `ref_state`/`retention_roots` as it goes — the "main object" restoration
|
|
1158
|
+
* needs per generation — persisting newly-opened epoch keys via
|
|
1159
|
+
* `keystore.recordEpochRotation` exactly like an ordinary rotation
|
|
1160
|
+
* producer/consumer would, and counting each decrypt attempt as an EXTRA
|
|
1161
|
+
* unit against the same `this.budget` (decryption is the expensive step).
|
|
1162
|
+
* `options.strict` (default `true`) throws immediately, fail-closed, on the
|
|
1163
|
+
* first `GITVAULT_EPOCH_NOT_OPENABLE` / AEAD failure it hits — the ordinary
|
|
1164
|
+
* `materialize()` read path's behavior. `strict: false` (fsck's tolerant
|
|
1165
|
+
* mode) instead records `decrypt.failure` and stops attempting further
|
|
1166
|
+
* decrypts while the pure chain walk keeps going — this is exactly what
|
|
1167
|
+
* makes `chain_verified_to` (this call's `generation`) and `decryptable_to`
|
|
1168
|
+
* (`decrypt.decryptable_to_generation`) able to differ honestly.
|
|
939
1169
|
*/
|
|
940
1170
|
async verifyToNewest(options = {}) {
|
|
941
1171
|
const persist = options.persist ?? true;
|
|
1172
|
+
const decryptValidate = options.decryptValidate ?? false;
|
|
1173
|
+
const strict = options.strict ?? true;
|
|
942
1174
|
const { genesis, sha256: genesisSha } = await this.genesis();
|
|
943
1175
|
const writerKey = genesis.creator_signing_pubkey;
|
|
944
1176
|
const writerKeyId = genesis.writer_key_id;
|
|
@@ -946,9 +1178,97 @@ export class GitvaultVault {
|
|
|
946
1178
|
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
947
1179
|
let lastHead = pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
|
|
948
1180
|
const anchor = pin.generation;
|
|
1181
|
+
let prevEpoch = lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH;
|
|
949
1182
|
let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
|
|
950
1183
|
let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
|
|
951
1184
|
let verified = 0;
|
|
1185
|
+
const rotations = [];
|
|
1186
|
+
// ── decrypt-validation state (opt-in) ──
|
|
1187
|
+
const identity = decryptValidate ? this.keystore.readIdentity() : null;
|
|
1188
|
+
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
|
|
1189
|
+
const epochKeys = { ...(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }) };
|
|
1190
|
+
let decryptPin = repo.materialized_pin ?? null;
|
|
1191
|
+
let decryptedRefState = null;
|
|
1192
|
+
let decryptedRoots = null;
|
|
1193
|
+
let decryptFailure = null;
|
|
1194
|
+
let decryptFailureError = null;
|
|
1195
|
+
const persistMaterializedIfAny = () => {
|
|
1196
|
+
if (persist && decryptValidate && decryptPin)
|
|
1197
|
+
this.keystore.updateRepo(this.repoId, { materialized_pin: decryptPin });
|
|
1198
|
+
};
|
|
1199
|
+
/**
|
|
1200
|
+
* Open (if needed) `headPin`'s own rotation envelope and decrypt its
|
|
1201
|
+
* `ref_state`/`retention_roots`. Shared by the per-head inline call below
|
|
1202
|
+
* AND the post-loop catch-up call: a call with NOTHING new to walk (this
|
|
1203
|
+
* repo's chain-verified pin is already at the newest generation) never
|
|
1204
|
+
* enters the loop body at all, so the newest head's own decrypt still
|
|
1205
|
+
* needs to run once, using whatever `epoch_keys` the keystore already
|
|
1206
|
+
* persisted from an EARLIER call's rotation-envelope open.
|
|
1207
|
+
*
|
|
1208
|
+
* NEVER throws — a decrypt failure is recorded on the enclosing
|
|
1209
|
+
* `decryptFailure`/`decryptFailureError` and this returns `false`. This
|
|
1210
|
+
* is deliberate: chain verification (this call's OWN `generation`/
|
|
1211
|
+
* `head_pin`, "highest_authenticated") is independent of decrypt
|
|
1212
|
+
* capability and must walk the FULL chain regardless — exactly the
|
|
1213
|
+
* existing "authenticated-but-undecryptable ⇒ CHAIN_UNUSABLE, read-only
|
|
1214
|
+
* at the materialized pin" split this file's own header already
|
|
1215
|
+
* documents. `strict` is enforced ONCE, at the very end of
|
|
1216
|
+
* `verifyToNewest`, after the complete chain walk has run.
|
|
1217
|
+
*/
|
|
1218
|
+
const tryDecrypt = async (head, headPin, pendingRotations) => {
|
|
1219
|
+
verified += 1; // decryption is the expensive step — counted separately against the same budget
|
|
1220
|
+
// Tracks whichever rotation is currently being opened — the failure
|
|
1221
|
+
// report below names THAT rotation's own generation/epoch/rotation_id,
|
|
1222
|
+
// never `head`'s (which, in the backward catch-up call, can be a much
|
|
1223
|
+
// LATER, ordinary-push generation entirely unrelated to which epoch
|
|
1224
|
+
// actually failed to open).
|
|
1225
|
+
let current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
|
|
1226
|
+
try {
|
|
1227
|
+
for (const rot of pendingRotations) {
|
|
1228
|
+
if (epochKeys[rot.epoch])
|
|
1229
|
+
continue;
|
|
1230
|
+
current = { generation: rot.generation, epoch: rot.epoch, rotation_id: rot.payload.rotation_id };
|
|
1231
|
+
if (!identity || !ownKeypair) {
|
|
1232
|
+
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 });
|
|
1233
|
+
}
|
|
1234
|
+
const ownFingerprint = ekFingerprint(ownKeypair.public_key);
|
|
1235
|
+
const kE = await openEpochRotationForRecipient({
|
|
1236
|
+
repo_id: this.repoId,
|
|
1237
|
+
payload: rot.payload,
|
|
1238
|
+
own_fingerprint: ownFingerprint,
|
|
1239
|
+
own_encryption_keypair: ownKeypair,
|
|
1240
|
+
writer_signing_public_key: writerKey,
|
|
1241
|
+
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
|
|
1242
|
+
envelope_path: (epoch, fp, rotationId) => gitvaultPaths.envelope(epoch, fp, rotationId),
|
|
1243
|
+
});
|
|
1244
|
+
epochKeys[rot.epoch] = bytesToHex(kE);
|
|
1245
|
+
if (persist)
|
|
1246
|
+
this.keystore.recordEpochRotation(this.repoId, { new_epoch: rot.epoch, new_k_repo_hex: bytesToHex(kE) });
|
|
1247
|
+
}
|
|
1248
|
+
current = { generation: head.generation, epoch: head.epoch, rotation_id: null };
|
|
1249
|
+
const kRepoHex = epochKeys[head.epoch];
|
|
1250
|
+
if (!kRepoHex) {
|
|
1251
|
+
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 });
|
|
1252
|
+
}
|
|
1253
|
+
const kRepo = hexToBytes(kRepoHex);
|
|
1254
|
+
// Design D2: ref_state + retention_roots both ride `head`, so one
|
|
1255
|
+
// batched presign (or a cache hit) serves both instead of two
|
|
1256
|
+
// independent presign-then-GET round trips.
|
|
1257
|
+
const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
|
|
1258
|
+
if (refState.generation !== head.generation || roots.generation !== head.generation)
|
|
1259
|
+
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
|
|
1260
|
+
decryptedRefState = refState;
|
|
1261
|
+
decryptedRoots = roots;
|
|
1262
|
+
decryptPin = { ...headPin };
|
|
1263
|
+
return true;
|
|
1264
|
+
}
|
|
1265
|
+
catch (e) {
|
|
1266
|
+
const code = isRun402Error(e) && e.code ? e.code : "GITVAULT_EPOCH_NOT_OPENABLE";
|
|
1267
|
+
decryptFailure = { generation: current.generation, epoch: current.epoch, rotation_id: current.rotation_id, code, message: e instanceof Error ? e.message : String(e) };
|
|
1268
|
+
decryptFailureError = e;
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
952
1272
|
for (;;) {
|
|
953
1273
|
const page = await this.transport.listHeads({ repo_id: this.repoId, ...request });
|
|
954
1274
|
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
|
|
@@ -956,15 +1276,16 @@ export class GitvaultVault {
|
|
|
956
1276
|
if (verified >= this.budget) {
|
|
957
1277
|
if (persist)
|
|
958
1278
|
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
|
|
1279
|
+
persistMaterializedIfAny();
|
|
959
1280
|
fail("VERIFICATION_BUDGET_EXCEEDED", persist
|
|
960
1281
|
? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
|
|
961
1282
|
: `${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" }]);
|
|
962
1283
|
}
|
|
963
|
-
const bytes = await this.
|
|
1284
|
+
const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
|
|
964
1285
|
if (!bytes)
|
|
965
1286
|
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
|
|
966
1287
|
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 });
|
|
1288
|
+
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
1289
|
try {
|
|
969
1290
|
assertNoTransition(head);
|
|
970
1291
|
}
|
|
@@ -972,24 +1293,156 @@ export class GitvaultVault {
|
|
|
972
1293
|
// fail closed: pin stays BELOW the transition head; the verified prefix is cleared (this is the final state, not a budget pause)
|
|
973
1294
|
if (persist)
|
|
974
1295
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
1296
|
+
persistMaterializedIfAny();
|
|
975
1297
|
throw e;
|
|
976
1298
|
}
|
|
1299
|
+
const isRotation = head.transition !== null && head.transition.kind === "rotate_epoch";
|
|
1300
|
+
let rotationPayload = null;
|
|
1301
|
+
if (isRotation) {
|
|
1302
|
+
// Pure/keyless (Part A's structural half — D202's join predicate
|
|
1303
|
+
// needs only this): parses + self-checks the payload, but never
|
|
1304
|
+
// opens an envelope. Collected regardless of decryptValidate so a
|
|
1305
|
+
// later `materialize()` call over an already-chain-verified prefix
|
|
1306
|
+
// can still resolve the epoch keys it needs.
|
|
1307
|
+
rotationPayload = parseRotateEpochPayload(head);
|
|
1308
|
+
rotations.push({ generation: head.generation, epoch: head.epoch, payload: rotationPayload });
|
|
1309
|
+
}
|
|
1310
|
+
prevEpoch = head.epoch;
|
|
977
1311
|
pin = { generation: head.generation, head_sha256: entry.stored_bytes_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
978
1312
|
lastHead = head;
|
|
979
1313
|
verified += 1;
|
|
1314
|
+
// The chain walk ALWAYS continues below regardless of decrypt
|
|
1315
|
+
// outcome (`tryDecrypt` never throws) — `!decryptFailure` just stops
|
|
1316
|
+
// WASTING further decrypt attempts once one has failed, since every
|
|
1317
|
+
// later generation shares the same unopenable epoch until (if ever)
|
|
1318
|
+
// a LATER rotation this principal CAN open supersedes it.
|
|
1319
|
+
if (decryptValidate && !decryptFailure)
|
|
1320
|
+
await tryDecrypt(head, pin, rotationPayload ? [{ generation: head.generation, epoch: head.epoch, payload: rotationPayload }] : []);
|
|
980
1321
|
}
|
|
981
1322
|
// verified prefix persists per page (resumable) — skipped entirely in no-write mode
|
|
982
1323
|
if (persist)
|
|
983
1324
|
this.keystore.updateRepo(this.repoId, { verified_prefix: pin });
|
|
1325
|
+
persistMaterializedIfAny();
|
|
984
1326
|
const next = nextListingRequest(request, page);
|
|
985
1327
|
if (!next)
|
|
986
1328
|
break;
|
|
987
1329
|
request = next;
|
|
988
1330
|
}
|
|
1331
|
+
// Catch-up: a call with NOTHING new to walk (this repo's chain-verified
|
|
1332
|
+
// pin was already at `pin`/`lastHead` — e.g. an EARLIER, decrypt-blind
|
|
1333
|
+
// `verifyToNewest({})` call already advanced `head_pin` to the newest,
|
|
1334
|
+
// and THIS is the first decrypt-validating call) never entered the loop
|
|
1335
|
+
// body above at all, so any `rotate_epoch` transitions between the
|
|
1336
|
+
// newest and the highest epoch this call's `epochKeys` seed already
|
|
1337
|
+
// covers were NEVER collected — walk BACKWARD via `prev_sha256` (the
|
|
1338
|
+
// same re-read `chainFrom` uses) to find every one of them, stopping the
|
|
1339
|
+
// INSTANT a generation's own epoch is already a known key (nothing
|
|
1340
|
+
// earlier can matter: every generation before it decrypts under a key
|
|
1341
|
+
// already held). Never a silent gap — this is exactly the mechanism
|
|
1342
|
+
// that makes a vault readable end to end even when chain verification
|
|
1343
|
+
// and decrypt-validation happened in genuinely SEPARATE calls.
|
|
1344
|
+
if (decryptValidate && !decryptFailure && lastHead && !decryptedRefState) {
|
|
1345
|
+
const backwardRotations = [];
|
|
1346
|
+
let cur = lastHead;
|
|
1347
|
+
let curPin = pin;
|
|
1348
|
+
while (cur && !epochKeys[cur.epoch]) {
|
|
1349
|
+
if (cur.transition !== null && cur.transition.kind === "rotate_epoch") {
|
|
1350
|
+
const payload = parseRotateEpochPayload(cur);
|
|
1351
|
+
backwardRotations.unshift({ generation: cur.generation, epoch: cur.epoch, payload });
|
|
1352
|
+
if (!rotations.some((r) => r.generation === cur.generation))
|
|
1353
|
+
rotations.push({ generation: cur.generation, epoch: cur.epoch, payload });
|
|
1354
|
+
}
|
|
1355
|
+
if (cur.generation === "0000000000000001") {
|
|
1356
|
+
cur = null;
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
|
|
1360
|
+
// Design D3: this downward walk is rooted in `lastHead`, itself just
|
|
1361
|
+
// chain-verified above (or trusted from an earlier call's own
|
|
1362
|
+
// verification) — the same "freshness already established" argument
|
|
1363
|
+
// {@link readCachedHeadBytes} documents, so a cache hit is safe here too.
|
|
1364
|
+
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
|
|
1365
|
+
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
1366
|
+
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing gitvault head", { generation: prevGen });
|
|
1367
|
+
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1368
|
+
curPin = { generation: prevGen, head_sha256: bytes ? sha256Hex(bytes) : curPin.head_sha256, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
1369
|
+
}
|
|
1370
|
+
rotations.sort((a, b) => (a.generation < b.generation ? -1 : a.generation > b.generation ? 1 : 0));
|
|
1371
|
+
// `cur` (non-null, non-lastHead) is the BOUNDARY generation whose own
|
|
1372
|
+
// epoch this call already holds a key for — establish `decryptPin`
|
|
1373
|
+
// THERE first (this always succeeds: its epoch is, by the loop's own
|
|
1374
|
+
// stop condition, already in `epochKeys`), so `refs`/`head_target`
|
|
1375
|
+
// reflect a REAL decrypted generation even when every pending rotation
|
|
1376
|
+
// toward `lastHead` then fails. Without this, a keystore that has
|
|
1377
|
+
// never once materialized (no `materialized_pin` yet) reports
|
|
1378
|
+
// `decryptable_to_generation: genesis` on an epoch-open failure, even
|
|
1379
|
+
// though generations well before the failing rotation were genuinely
|
|
1380
|
+
// decryptable all along.
|
|
1381
|
+
if (cur && cur.generation !== lastHead.generation)
|
|
1382
|
+
await tryDecrypt(cur, curPin, []);
|
|
1383
|
+
if (!decryptFailure)
|
|
1384
|
+
await tryDecrypt(lastHead, pin, backwardRotations);
|
|
1385
|
+
}
|
|
989
1386
|
if (persist)
|
|
990
1387
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, verified_prefix: null });
|
|
991
|
-
|
|
1388
|
+
persistMaterializedIfAny();
|
|
1389
|
+
// `strict` (materialize()'s ordinary, fail-closed read path) is enforced
|
|
1390
|
+
// HERE, once, after the full keyless chain walk has already run to
|
|
1391
|
+
// completion — never mid-walk (see `tryDecrypt`'s own doc comment).
|
|
1392
|
+
if (strict && decryptValidate && decryptFailure)
|
|
1393
|
+
throw decryptFailureError;
|
|
1394
|
+
return {
|
|
1395
|
+
generation: pin.generation,
|
|
1396
|
+
head_sha256: pin.head_sha256,
|
|
1397
|
+
head: lastHead,
|
|
1398
|
+
genesis,
|
|
1399
|
+
rotations,
|
|
1400
|
+
decrypt: decryptValidate
|
|
1401
|
+
? {
|
|
1402
|
+
decryptable_to_generation: decryptPin?.generation ?? GITVAULT_GENESIS_GENERATION,
|
|
1403
|
+
ref_state: decryptedRefState,
|
|
1404
|
+
retention_roots: decryptedRoots,
|
|
1405
|
+
epoch_keys_hex: epochKeys,
|
|
1406
|
+
failure: decryptFailure,
|
|
1407
|
+
}
|
|
1408
|
+
: null,
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Read one NEWLY-LISTED head's raw bytes, trying the local cache first
|
|
1413
|
+
* (design D3), re-verified against `expectedSha256` — the SAME check
|
|
1414
|
+
* network bytes get. Safe to cache-serve ONLY because a caller here
|
|
1415
|
+
* always supplies a hash a FRESH `listHeads` call just reported as
|
|
1416
|
+
* current — the cache never substitutes for that freshness check, it just
|
|
1417
|
+
* avoids re-downloading bytes a live listing already vouched for. A cache
|
|
1418
|
+
* miss or a hash mismatch (never trusted, always falls through) fetches
|
|
1419
|
+
* from the network and, on a match, refreshes the cache entry. Returns
|
|
1420
|
+
* whatever the network returned on a final miss too (including `null`) —
|
|
1421
|
+
* callers keep their own existing absent/mismatch handling unchanged.
|
|
1422
|
+
*
|
|
1423
|
+
* Deliberately NOT used by {@link readHead}: that call verifies the
|
|
1424
|
+
* PINNED generation is STILL held by the server, with no fresh listing
|
|
1425
|
+
* involved — its entire purpose is detecting server-side loss/rollback,
|
|
1426
|
+
* which a cache read can never observe. That call always goes live.
|
|
1427
|
+
*/
|
|
1428
|
+
async readCachedHeadBytes(generation, expectedSha256) {
|
|
1429
|
+
const cached = this.keystore.readCachedHead(this.repoId, generation);
|
|
1430
|
+
if (cached && sha256Hex(cached.bytes) === expectedSha256)
|
|
1431
|
+
return cached.bytes;
|
|
1432
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
|
|
1433
|
+
if (bytes && sha256Hex(bytes) === expectedSha256)
|
|
1434
|
+
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
|
|
1435
|
+
return bytes;
|
|
992
1436
|
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Confirm the server STILL holds the pinned generation, unchanged —
|
|
1439
|
+
* ALWAYS a live network read (see {@link readCachedHeadBytes}'s doc
|
|
1440
|
+
* comment for why this specific check is not cacheable: it exists to
|
|
1441
|
+
* detect server-side loss, which a cache can never observe). A
|
|
1442
|
+
* successful read still WARMS the cache afterward — later reads of this
|
|
1443
|
+
* SAME generation via the chain walk or restore benefit from it; only
|
|
1444
|
+
* THIS call's own read is exempt.
|
|
1445
|
+
*/
|
|
993
1446
|
async readHead(generation, expectedSha256) {
|
|
994
1447
|
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
|
|
995
1448
|
// Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
|
|
@@ -1001,16 +1454,30 @@ export class GitvaultVault {
|
|
|
1001
1454
|
}
|
|
1002
1455
|
if (sha256Hex(bytes) !== expectedSha256)
|
|
1003
1456
|
fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation });
|
|
1457
|
+
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
|
|
1004
1458
|
return parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1005
1459
|
}
|
|
1006
|
-
/**
|
|
1007
|
-
|
|
1008
|
-
|
|
1460
|
+
/**
|
|
1461
|
+
* Decrypt + verify one carrier's already-fetched ciphertext frame; any
|
|
1462
|
+
* failure is `CHAIN_UNUSABLE`. Split out of {@link openCarrier} so a
|
|
1463
|
+
* caller that fetched the frame itself (a cache hit, or one leg of a
|
|
1464
|
+
* batched read) can reuse the same decode + identity/signature checks.
|
|
1465
|
+
*
|
|
1466
|
+
* `keyOverride` supplies the exact `(epoch, k_repo)` this carrier was
|
|
1467
|
+
* sealed under — REQUIRED for any generation that is not necessarily
|
|
1468
|
+
* under `this.epoch()`/`this.kRepo()` (this principal's CURRENT
|
|
1469
|
+
* pointer), which is exactly the case across an epoch rotation; omitted
|
|
1470
|
+
* call sites (checkpoint/prune paths untouched by this fold) keep the
|
|
1471
|
+
* prior CURRENT-pointer behavior unchanged.
|
|
1472
|
+
*/
|
|
1473
|
+
decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride) {
|
|
1474
|
+
const epoch = keyOverride?.epoch ?? this.epoch();
|
|
1475
|
+
const kRepo = keyOverride?.k_repo ?? this.kRepo();
|
|
1009
1476
|
if (!frame)
|
|
1010
1477
|
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
1478
|
let plaintext;
|
|
1012
1479
|
try {
|
|
1013
|
-
plaintext = openFrame({ k_obj: deriveObjectKey(
|
|
1480
|
+
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
1481
|
}
|
|
1015
1482
|
catch (e) {
|
|
1016
1483
|
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" }]);
|
|
@@ -1021,29 +1488,94 @@ export class GitvaultVault {
|
|
|
1021
1488
|
}
|
|
1022
1489
|
return object;
|
|
1023
1490
|
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Fetch (network) + decrypt one carrier object by its receipt; any
|
|
1493
|
+
* failure is `CHAIN_UNUSABLE`. Design D3: `ref_state`/`retention_roots`
|
|
1494
|
+
* ciphertext is cached beside the keystore's per-repo state, re-verified
|
|
1495
|
+
* against `receipt.ciphertext_sha256` on every use — a hit skips the
|
|
1496
|
+
* network read entirely. `checkpoint_manifest` is never cached (outside
|
|
1497
|
+
* D3's table). The cache write derives its generation tag from the
|
|
1498
|
+
* DECODED object's own `generation` field, so no caller needs to thread
|
|
1499
|
+
* one through by hand.
|
|
1500
|
+
*/
|
|
1501
|
+
async openCarrier(kind, receipt, path, writerKey, keyOverride) {
|
|
1502
|
+
const cacheable = kind === "ref_state" || kind === "retention_roots";
|
|
1503
|
+
const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
|
|
1504
|
+
const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
|
|
1505
|
+
const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path });
|
|
1506
|
+
const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
|
|
1507
|
+
if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
|
|
1508
|
+
this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
|
|
1509
|
+
}
|
|
1510
|
+
return object;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* `materialize`'s ref_state + retention_roots read, batched (design D2):
|
|
1514
|
+
* cache-check both first, then ONE `getObjects` call (one presigned batch
|
|
1515
|
+
* + concurrent GETs) for whichever missed, instead of two independent
|
|
1516
|
+
* presign-then-GET round trips. Falls through to zero network calls when
|
|
1517
|
+
* both are cache-warm. `keyOverride` is the epoch/k_repo the CARRYING
|
|
1518
|
+
* HEAD sealed both carriers under (D194) — both always share one head, so
|
|
1519
|
+
* one override serves both, unlike the WAL/checkpoint pack loops in
|
|
1520
|
+
* {@link restoreObjectsInto} which span many heads and many epochs.
|
|
1521
|
+
*/
|
|
1522
|
+
async openMaterializeCarriers(refStateReceipt, refStatePath, rootsReceipt, rootsPath, writerKey, keyOverride) {
|
|
1523
|
+
const cachedRefState = this.keystore.readCachedCarrier(this.repoId, refStateReceipt.object_id);
|
|
1524
|
+
const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
|
|
1525
|
+
const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
|
|
1526
|
+
const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
|
|
1527
|
+
const missingPaths = [];
|
|
1528
|
+
if (!refStateHit)
|
|
1529
|
+
missingPaths.push(refStatePath);
|
|
1530
|
+
if (!rootsHit)
|
|
1531
|
+
missingPaths.push(rootsPath);
|
|
1532
|
+
const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths }) : [];
|
|
1533
|
+
let next = 0;
|
|
1534
|
+
const refStateFrame = refStateHit ? cachedRefState.bytes : (fetched[next++] ?? null);
|
|
1535
|
+
const rootsFrame = rootsHit ? cachedRoots.bytes : (fetched[next++] ?? null);
|
|
1536
|
+
const refState = this.decodeCarrierFrame("ref_state", refStateReceipt, refStateFrame, writerKey, keyOverride);
|
|
1537
|
+
const roots = this.decodeCarrierFrame("retention_roots", rootsReceipt, rootsFrame, writerKey, keyOverride);
|
|
1538
|
+
if (!refStateHit && refStateFrame)
|
|
1539
|
+
this.keystore.writeCachedCarrier(this.repoId, refStateReceipt.object_id, refState.generation, refStateReceipt.ciphertext_sha256, refStateFrame);
|
|
1540
|
+
if (!rootsHit && rootsFrame)
|
|
1541
|
+
this.keystore.writeCachedCarrier(this.repoId, rootsReceipt.object_id, roots.generation, rootsReceipt.ciphertext_sha256, rootsFrame);
|
|
1542
|
+
return { refState, roots };
|
|
1543
|
+
}
|
|
1024
1544
|
/**
|
|
1025
1545
|
* Verify to newest, then decrypt + apply its carriers — advancing the
|
|
1026
1546
|
* materialized pin. `options.persist` (default `true`) is forwarded to
|
|
1027
1547
|
* {@link verifyToNewest} and gates this method's OWN `materialized_pin`
|
|
1028
1548
|
* write the same way — `repos fsck --no-write` computes and returns the
|
|
1029
1549
|
* real ref map and generation without moving either local pin.
|
|
1550
|
+
*
|
|
1551
|
+
* Runs `verifyToNewest({..., decryptValidate: true, strict: true})`
|
|
1552
|
+
* internally (Part A: an ordinary read across an admitted `rotate_epoch`
|
|
1553
|
+
* transition now opens the rotation's own envelope and decrypts under the
|
|
1554
|
+
* NEW epoch, chaining through multiple sequential rotations; a keystore
|
|
1555
|
+
* with no envelope for a new epoch fails CLOSED with
|
|
1556
|
+
* `GITVAULT_EPOCH_NOT_OPENABLE`, never a bare `GITVAULT_AEAD_AUTH_FAILURE`)
|
|
1557
|
+
* — `strict: true` means this call throws exactly where the OLD
|
|
1558
|
+
* (pre-fix) `materialize()` silently produced a wrong `k_obj` instead.
|
|
1030
1559
|
*/
|
|
1031
1560
|
async materialize(options = {}) {
|
|
1032
1561
|
const persist = options.persist ?? true;
|
|
1033
|
-
const state = await this.verifyToNewest({ persist });
|
|
1034
|
-
const writerKey = state.genesis.creator_signing_pubkey;
|
|
1562
|
+
const state = await this.verifyToNewest({ persist, decryptValidate: true, strict: true });
|
|
1035
1563
|
if (!state.head) {
|
|
1036
1564
|
if (persist)
|
|
1037
1565
|
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" } };
|
|
1566
|
+
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
1567
|
}
|
|
1040
|
-
|
|
1041
|
-
|
|
1568
|
+
// `strict: true` guarantees `state.decrypt` is non-null with no failure
|
|
1569
|
+
// and `decryptable_to_generation === state.generation` — it throws
|
|
1570
|
+
// otherwise, so these are never null/mismatched here. `verifyToNewest`'s
|
|
1571
|
+
// own `tryDecrypt` is what actually fetches these now (design D2/D3's
|
|
1572
|
+
// batching + caching moved there with it — see its doc comment) —
|
|
1573
|
+
// `openMaterializeCarriers` no longer has a caller from here.
|
|
1574
|
+
const refState = state.decrypt.ref_state;
|
|
1575
|
+
const roots = state.decrypt.retention_roots;
|
|
1042
1576
|
if (refState.generation !== state.generation || roots.generation !== state.generation)
|
|
1043
1577
|
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 };
|
|
1578
|
+
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
1579
|
}
|
|
1048
1580
|
// ── envelope recipients (gitvault-human-envelopes task 4.1, the ADD-path workaround) ──
|
|
1049
1581
|
/**
|
|
@@ -1595,11 +2127,18 @@ export class GitvaultVault {
|
|
|
1595
2127
|
return { outcome: "conflict", generation, winner: admitted.winner };
|
|
1596
2128
|
return { outcome: "admitted", generation, head, head_sha256: admitted.head_sha256, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form, refs: input.refs };
|
|
1597
2129
|
}
|
|
1598
|
-
/**
|
|
2130
|
+
/**
|
|
2131
|
+
* The complete push: verify → materialize → evaluate → pack → upload → head → admit (409: re-apply to the winner, retry) → read back → advance pins.
|
|
2132
|
+
*
|
|
2133
|
+
* `options.base` (design D1), when supplied, is used VERBATIM for the
|
|
2134
|
+
* first attempt instead of a fresh {@link materialize} call — a conflict
|
|
2135
|
+
* retry always re-materializes from storage, exactly as when no base is
|
|
2136
|
+
* supplied.
|
|
2137
|
+
*/
|
|
1599
2138
|
async push(options) {
|
|
1600
2139
|
let conflicts = 0;
|
|
2140
|
+
let base = options.base ?? (await this.materialize());
|
|
1601
2141
|
for (;;) {
|
|
1602
|
-
const base = await this.materialize();
|
|
1603
2142
|
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
|
|
1604
2143
|
const published = await this.publishGeneration({
|
|
1605
2144
|
base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
|
|
@@ -1609,7 +2148,8 @@ export class GitvaultVault {
|
|
|
1609
2148
|
conflicts += 1;
|
|
1610
2149
|
if (conflicts > this.retries)
|
|
1611
2150
|
fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing gitvault head", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
|
|
1612
|
-
|
|
2151
|
+
base = await this.materialize(); // re-verify from storage (the winner) before re-applying the transaction
|
|
2152
|
+
continue;
|
|
1613
2153
|
}
|
|
1614
2154
|
// `push()` never sets `dry_run`, so this outcome is unreachable here —
|
|
1615
2155
|
// narrows `published` to `"admitted"` for the return below.
|
|
@@ -1639,7 +2179,11 @@ export class GitvaultVault {
|
|
|
1639
2179
|
* that case instead of calling this method).
|
|
1640
2180
|
*/
|
|
1641
2181
|
async planPush(options) {
|
|
1642
|
-
|
|
2182
|
+
// Design D1: a caller-supplied base reports against the CALLER's own
|
|
2183
|
+
// observed snapshot instead of materializing a fresh one — same
|
|
2184
|
+
// verbatim-first-attempt contract as `push`, minus the retry loop
|
|
2185
|
+
// (a dry run never re-materializes; there is nothing to retry against).
|
|
2186
|
+
const base = options.base ?? (await this.materialize());
|
|
1643
2187
|
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
|
|
1644
2188
|
const headTarget = options.head_target ?? base.head_target;
|
|
1645
2189
|
const published = await this.publishGeneration({
|
|
@@ -1752,10 +2296,18 @@ export class GitvaultVault {
|
|
|
1752
2296
|
const result = await this.transport.admitHead({ repo_id: this.repoId, generation: head.generation, stored_bytes: bytes, stored_bytes_sha256: hash });
|
|
1753
2297
|
if (result.outcome === "conflict")
|
|
1754
2298
|
return result;
|
|
2299
|
+
// Post-admit readback is a spec-mandated verification obligation, never
|
|
2300
|
+
// a cache lookup — it exists to prove the SERVER stored what was sent,
|
|
2301
|
+
// which only a genuine network read can answer.
|
|
1755
2302
|
const back = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(head.generation) });
|
|
1756
2303
|
if (!back || sha256Hex(back) !== hash) {
|
|
1757
2304
|
fail("GITVAULT_HEAD_READBACK_MISMATCH", `the admitted head at generation ${head.generation} read back ${back ? sha256Hex(back) : "absent"} ≠ ${hash}; the push is NOT reported as landed and no pin advances`, "reading back admitted head", { generation: head.generation, expected: hash, observed: back ? sha256Hex(back) : null });
|
|
1758
2305
|
}
|
|
2306
|
+
// Design D3: the readback just network-confirmed these exact bytes —
|
|
2307
|
+
// warm the cache with them so the NEXT operation (this session's own
|
|
2308
|
+
// `materialize` retry, or a later invocation) can skip re-fetching this
|
|
2309
|
+
// generation's head entirely.
|
|
2310
|
+
this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
|
|
1759
2311
|
const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
1760
2312
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
|
|
1761
2313
|
return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
|
|
@@ -1805,20 +2357,15 @@ export class GitvaultVault {
|
|
|
1805
2357
|
* manifest — e.g. one another principal/machine published) falls through
|
|
1806
2358
|
* to the unchanged network path below.
|
|
1807
2359
|
*
|
|
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.
|
|
2360
|
+
* History: this cache originally also routed around a gateway gap —
|
|
2361
|
+
* `POST …/object-reads` rejected every `recipient_pin_manifest` read
|
|
2362
|
+
* (its null-`idScalar` validation was hardcoded to `key_envelope`'s
|
|
2363
|
+
* `{epoch, recipient_fingerprint}` shape, never generalized when D197
|
|
2364
|
+
* shipped the second path-addressed kind), so the network fallback below
|
|
2365
|
+
* always 400'd. That gateway bug is fixed (deployed and live-verified
|
|
2366
|
+
* 2026-08-28): the network path works for any keystore, including
|
|
2367
|
+
* §4.11's fresh-client "SEEDS its local pin file from it" onboarding.
|
|
2368
|
+
* The cache stays purely as the round-trip saver described above.
|
|
1822
2369
|
*/
|
|
1823
2370
|
async readPinManifestObject(receipt) {
|
|
1824
2371
|
const known = this.repoFile().known_pin_manifest;
|
|
@@ -2323,6 +2870,20 @@ export class GitvaultVault {
|
|
|
2323
2870
|
* Pull the newest checkpoint (if any) and every later WAL pack into
|
|
2324
2871
|
* `targetRepoDir` (an initialized repository), then verify every canonical
|
|
2325
2872
|
* ref + the HEAD target resolves. Returns the materialized refs.
|
|
2873
|
+
*
|
|
2874
|
+
* Design D5 (gitvault-client-round-trips): incremental above the local
|
|
2875
|
+
* `restored_through` marker (this target directory's own local git
|
|
2876
|
+
* config, read/written by {@link readGitvaultRestoreMarker}/{@link
|
|
2877
|
+
* writeGitvaultRestoreMarker}) — replaying only the WAL packs of
|
|
2878
|
+
* generations above it — WHEN the walk from newest back to the marker is
|
|
2879
|
+
* plain WAL the entire way. The moment that walk crosses a
|
|
2880
|
+
* checkpoint-bearing, repair, or transition head (checked BEFORE
|
|
2881
|
+
* including a head, so the marker-boundary and wholesale-boundary checks
|
|
2882
|
+
* share one pass), this falls back to the ORIGINAL wholesale walk below —
|
|
2883
|
+
* re-fetching any head already visited during the aborted attempt is a
|
|
2884
|
+
* cache hit (design D3), never a second network round trip. Coverage
|
|
2885
|
+
* verification and retained-refs reconciliation run UNCHANGED on both
|
|
2886
|
+
* paths; the marker only advances after they both succeed.
|
|
2326
2887
|
*/
|
|
2327
2888
|
async restoreObjectsInto(targetRepoDir) {
|
|
2328
2889
|
const newest = await this.materialize();
|
|
@@ -2331,21 +2892,69 @@ export class GitvaultVault {
|
|
|
2331
2892
|
return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2332
2893
|
}
|
|
2333
2894
|
const writerKey = newest.genesis.creator_signing_pubkey;
|
|
2334
|
-
|
|
2895
|
+
const marker = await readGitvaultRestoreMarker(targetRepoDir);
|
|
2896
|
+
// Already up to date locally: no pack fetch, not even a walk — only the
|
|
2897
|
+
// (entirely local) coverage + retained-refs bookkeeping below runs.
|
|
2898
|
+
if (marker && marker.generation === newest.generation && marker.head_sha256 === newest.head_sha256) {
|
|
2899
|
+
for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
|
|
2900
|
+
if (!(await hasObject(targetRepoDir, t)))
|
|
2901
|
+
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring gitvault objects", { oid: t });
|
|
2902
|
+
}
|
|
2903
|
+
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
2904
|
+
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2905
|
+
}
|
|
2906
|
+
// Walk back from newest, trying for an INCREMENTAL boundary at the
|
|
2907
|
+
// marker (a pure local prev_sha256 comparison — no fetch needed to
|
|
2908
|
+
// CONFIRM the boundary itself); abandon incremental the moment a
|
|
2909
|
+
// non-plain-WAL head would have to be included, and restart as the
|
|
2910
|
+
// ORIGINAL wholesale walk (stopping at the newest checkpoint, or
|
|
2911
|
+
// genesis) — every head visited in the aborted attempt is a D3 cache
|
|
2912
|
+
// hit on this restart, so abandoning costs no extra round trip.
|
|
2335
2913
|
const heads = [];
|
|
2336
2914
|
let cur = newest.head;
|
|
2915
|
+
let incremental = marker !== null;
|
|
2337
2916
|
while (cur) {
|
|
2917
|
+
// `cur` disqualifies incremental (checked BEFORE including it): abort
|
|
2918
|
+
// and restart as the wholesale walk. Re-visiting heads already fetched
|
|
2919
|
+
// this attempt costs nothing (D3 cache hits).
|
|
2920
|
+
if (incremental && (cur.checkpoint !== null || cur.transition !== null)) {
|
|
2921
|
+
incremental = false;
|
|
2922
|
+
heads.length = 0;
|
|
2923
|
+
cur = newest.head;
|
|
2924
|
+
continue;
|
|
2925
|
+
}
|
|
2338
2926
|
heads.unshift(cur);
|
|
2339
|
-
if (cur.checkpoint)
|
|
2340
|
-
break;
|
|
2927
|
+
if (!incremental && cur.checkpoint)
|
|
2928
|
+
break; // wholesale stop: the newest checkpoint-bearing head
|
|
2341
2929
|
if (cur.generation === "0000000000000001")
|
|
2342
|
-
break;
|
|
2930
|
+
break; // genesis — a hard stop either way
|
|
2931
|
+
// Before fetching `cur`'s predecessor, check whether the marker
|
|
2932
|
+
// already names it — a pure LOCAL comparison against bytes this
|
|
2933
|
+
// client already applied last time, no network round trip.
|
|
2343
2934
|
const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
|
|
2344
|
-
|
|
2935
|
+
if (incremental && cur.prev_sha256 === marker.head_sha256 && prevGen === marker.generation) {
|
|
2936
|
+
cur = null; // the predecessor is the marker's own head — already applied; `heads` already holds everything above it
|
|
2937
|
+
break;
|
|
2938
|
+
}
|
|
2939
|
+
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
|
|
2345
2940
|
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
2346
2941
|
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
|
|
2347
2942
|
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
2348
2943
|
}
|
|
2944
|
+
// Every object below is decrypted under ITS OWN carrying head's `epoch`
|
|
2945
|
+
// (D194) — a covered span crossing a rotation mixes epochs, so this
|
|
2946
|
+
// NEVER falls back to `this.kRepo()`/`this.epoch()` (this principal's
|
|
2947
|
+
// CURRENT pointer, which is the NEWEST epoch, not necessarily every
|
|
2948
|
+
// historical one a restore spans). `newest.epoch_keys_hex` is the full
|
|
2949
|
+
// map `materialize()` just resolved (throwing `GITVAULT_EPOCH_NOT_OPENABLE`
|
|
2950
|
+
// fail-closed if any needed epoch could not be opened), so every lookup
|
|
2951
|
+
// below is guaranteed present.
|
|
2952
|
+
const kRepoForEpoch = (epoch) => {
|
|
2953
|
+
const hex = newest.epoch_keys_hex[epoch];
|
|
2954
|
+
if (!hex)
|
|
2955
|
+
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${epoch} while restoring objects`, "restoring gitvault objects", { epoch });
|
|
2956
|
+
return hexToBytes(hex);
|
|
2957
|
+
};
|
|
2349
2958
|
const first = heads[0];
|
|
2350
2959
|
if (first.checkpoint) {
|
|
2351
2960
|
const claimBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.claimSet(first.checkpoint.claim_set.object_id) });
|
|
@@ -2354,26 +2963,42 @@ export class GitvaultVault {
|
|
|
2354
2963
|
const claimSet = parseGitvaultStrict(new TextDecoder().decode(claimBytes));
|
|
2355
2964
|
if (!verifyGitvaultObject(claimSet, writerKey))
|
|
2356
2965
|
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);
|
|
2966
|
+
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
2967
|
checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
|
|
2359
|
-
|
|
2360
|
-
|
|
2968
|
+
// Design D2: every checkpoint pack is independent — one batched
|
|
2969
|
+
// presign for all of them, THEN applied via index-pack strictly in
|
|
2970
|
+
// manifest order (the fetch is concurrent; the local git write is
|
|
2971
|
+
// not, and does not need to be).
|
|
2972
|
+
const frames = await this.transport.getObjects({ repo_id: this.repoId, paths: manifest.packs.map((p) => gitvaultPaths.checkpointPack(p.object_id)) });
|
|
2973
|
+
for (let i = 0; i < manifest.packs.length; i++) {
|
|
2974
|
+
const p = manifest.packs[i];
|
|
2975
|
+
const frame = frames[i] ?? null;
|
|
2361
2976
|
if (!frame)
|
|
2362
2977
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
|
|
2363
|
-
const plain = openFrame({ k_obj: deriveObjectKey(
|
|
2978
|
+
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
2979
|
if (sha256Hex(plain) !== p.plaintext_sha256 || String(plain.length) !== p.plaintext_size_bytes)
|
|
2365
2980
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} plaintext mismatch`, "restoring gitvault objects");
|
|
2366
2981
|
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
2367
2982
|
}
|
|
2368
2983
|
}
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2984
|
+
// Design D2: every WAL pack across every head in this restore's range is
|
|
2985
|
+
// independent — one batched presign for the whole set (this is the
|
|
2986
|
+
// "restore pack set" the design's own D2 prose names alongside
|
|
2987
|
+
// materialize's carriers), fetched concurrently, then applied via
|
|
2988
|
+
// index-pack in the SAME chain order the wholesale path always used
|
|
2989
|
+
// (git's pack application is sequential; the network fetch need not be).
|
|
2990
|
+
// Each entry decrypts under its OWN carrying head's epoch (D194) — the
|
|
2991
|
+
// flattened list keeps that pairing so a rotation-spanning restore never
|
|
2992
|
+
// reuses one head's epoch for another's pack.
|
|
2993
|
+
const walEntries = heads.flatMap((h) => h.wal_entries.map((w) => ({ w, epoch: h.epoch })));
|
|
2994
|
+
const walFrames = await this.transport.getObjects({ repo_id: this.repoId, paths: walEntries.map(({ w }) => gitvaultPaths.wal(w.object_id)) });
|
|
2995
|
+
for (let i = 0; i < walEntries.length; i++) {
|
|
2996
|
+
const { w, epoch } = walEntries[i];
|
|
2997
|
+
const frame = walFrames[i] ?? null;
|
|
2998
|
+
if (!frame)
|
|
2999
|
+
fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
|
|
3000
|
+
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(epoch), this.repoId, epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
|
|
3001
|
+
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
2377
3002
|
}
|
|
2378
3003
|
// the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
|
|
2379
3004
|
for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
|
|
@@ -2385,6 +3010,12 @@ export class GitvaultVault {
|
|
|
2385
3010
|
// ref so `git fsck` is silent. Runs AFTER coverage verification so a
|
|
2386
3011
|
// reconcile never references an object the restore itself failed to land.
|
|
2387
3012
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
3013
|
+
// Design D5: the marker advances ONLY here — after coverage verification
|
|
3014
|
+
// and the retained-refs reconcile both succeeded. An interrupted restore
|
|
3015
|
+
// (a crash or throw anywhere above) leaves the marker unadvanced, so the
|
|
3016
|
+
// next fetch simply repeats this same range; applying an already-applied
|
|
3017
|
+
// WAL pack again is the existing, already-safe wholesale behavior.
|
|
3018
|
+
await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
|
|
2388
3019
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2389
3020
|
}
|
|
2390
3021
|
}
|