run402 4.49.0 → 4.51.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 +1 -1
- package/lib/repos.mjs +29 -0
- package/package.json +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +104 -1
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +124 -0
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.d.ts +96 -2
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +175 -14
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +348 -47
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/index.d.ts +2 -2
- package/sdk/dist/node/index.d.ts.map +1 -1
- package/sdk/dist/node/index.js +1 -1
- package/sdk/dist/node/index.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, openEpochRotationForRecipient, openFrame,
|
|
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, 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";
|
|
@@ -63,6 +63,34 @@ const LIMIT_RE = /^([1-9]|[1-9][0-9]|[1-9][0-9][0-9]|1000)$/;
|
|
|
63
63
|
function fail(code, message, context, details, nextActions) {
|
|
64
64
|
throw new LocalError(message, context, { code, details, ...(nextActions ? { next_actions: nextActions } : {}) });
|
|
65
65
|
}
|
|
66
|
+
// ─── Reader-provenance strings (D209/D210) ───────────────────────────────────
|
|
67
|
+
/**
|
|
68
|
+
* Resolved once from this package's own `package.json` — mirrors
|
|
69
|
+
* `node/index.ts`'s `readSdkPackageVersion`, duplicated locally rather than
|
|
70
|
+
* imported to avoid a dependency edge from this file (imported by the
|
|
71
|
+
* cross-runtime `namespaces/gitvault.ts` via a dynamic `import()`, task
|
|
72
|
+
* 5.0's public crypto surface) back onto the Node CLI/SDK entry module.
|
|
73
|
+
*/
|
|
74
|
+
const GITVAULT_SDK_PACKAGE_VERSION = (() => {
|
|
75
|
+
try {
|
|
76
|
+
const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
|
|
77
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return "0.0.0";
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
/**
|
|
84
|
+
* `run402@<version>/<entrypoint>` — the audit-provenance string named on
|
|
85
|
+
* `self_open_attestation.reader_entrypoint` (D209) and
|
|
86
|
+
* `recipient_open_receipt.reader_entrypoint` (D210): "names the client
|
|
87
|
+
* implementation + entry point that produced the evidence." Never an
|
|
88
|
+
* authorization input; no wire grammar is promised for it (protocol-v0.md
|
|
89
|
+
* §4.14, `rotate_epoch_payload.json`'s own field `$comment`).
|
|
90
|
+
*/
|
|
91
|
+
export function gitvaultReaderEntrypoint(entrypoint) {
|
|
92
|
+
return `run402@${GITVAULT_SDK_PACKAGE_VERSION}/${entrypoint}`;
|
|
93
|
+
}
|
|
66
94
|
// ─── Generations ─────────────────────────────────────────────────────────────
|
|
67
95
|
export function generationToBigInt(generation) {
|
|
68
96
|
if (!GITVAULT_HEX16_RE.test(generation))
|
|
@@ -543,6 +571,37 @@ export function gitvaultManifestEntry(object) {
|
|
|
543
571
|
}
|
|
544
572
|
return entry;
|
|
545
573
|
}
|
|
574
|
+
// ─── Inline upload (gitvault-composite-state-read design D2) ─────────────────
|
|
575
|
+
/** Mirrors the gateway's `GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES` (`services/gitvault/upload-sessions.ts`). */
|
|
576
|
+
export const GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES = 262_144;
|
|
577
|
+
/** Mirrors the gateway's `GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES`. */
|
|
578
|
+
export const GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES = 1_048_576;
|
|
579
|
+
/**
|
|
580
|
+
* The client-side mirror of the gateway's `isInlineUploadRequest` + per-object/
|
|
581
|
+
* per-request cap check: every object must fit under the PER-OBJECT cap AND
|
|
582
|
+
* the batch's total under the PER-REQUEST cap, or the whole batch takes the
|
|
583
|
+
* presigned session+PUT+finalize shape — no per-object mixing, matching the
|
|
584
|
+
* server's `VALIDATION_FAILED` refusal on a mixed request. An empty batch is
|
|
585
|
+
* never "inline" (nothing to send either way; `upload()`'s own early return
|
|
586
|
+
* already short-circuits before this is consulted, and `putObject` always
|
|
587
|
+
* wraps exactly one object so it never hits this branch).
|
|
588
|
+
*
|
|
589
|
+
* Takes the narrowest shape that satisfies every call site (`GitvaultUploadObject[]`
|
|
590
|
+
* for `uploadObjects`, a single `{bytes}`-shaped array for `putObject`) so
|
|
591
|
+
* neither caller needs to fabricate unrelated fields just to ask the
|
|
592
|
+
* question.
|
|
593
|
+
*/
|
|
594
|
+
export function gitvaultInlineUploadEligible(objects) {
|
|
595
|
+
if (objects.length === 0)
|
|
596
|
+
return false;
|
|
597
|
+
let total = 0;
|
|
598
|
+
for (const o of objects) {
|
|
599
|
+
if (o.bytes.length > GITVAULT_INLINE_UPLOAD_MAX_OBJECT_BYTES)
|
|
600
|
+
return false;
|
|
601
|
+
total += o.bytes.length;
|
|
602
|
+
}
|
|
603
|
+
return total <= GITVAULT_INLINE_UPLOAD_MAX_REQUEST_BYTES;
|
|
604
|
+
}
|
|
546
605
|
/**
|
|
547
606
|
* The stable key both sides agree on, used to pair receipts back to
|
|
548
607
|
* requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
|
|
@@ -715,6 +774,42 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
715
774
|
return new Uint8Array(await r.arrayBuffer());
|
|
716
775
|
});
|
|
717
776
|
}
|
|
777
|
+
/** Resolve ONE `GET …/state` carrier arm to raw bytes — inline decode, or a plain GET on the presigned URL, `null` on a 404 (mirrors {@link getObjectBytes}'s absent reading; both arms indistinguishable after this). */
|
|
778
|
+
async function resolveVaultStateCarrier(carrier) {
|
|
779
|
+
if ("inline" in carrier)
|
|
780
|
+
return fromBase64url(carrier.inline, "carriers.inline");
|
|
781
|
+
const r = await client.fetch(carrier.presigned_url, { method: "GET" });
|
|
782
|
+
if (r.status === 404)
|
|
783
|
+
return null;
|
|
784
|
+
if (!r.ok)
|
|
785
|
+
fail("GITVAULT_OBJECT_READ_FAILED", `vault-state carrier GET failed (HTTP ${r.status})`, "reading the gitvault vault state", { status: r.status });
|
|
786
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* `GET …/state` (design D1): one JSON body carrying the vault record, the
|
|
790
|
+
* newest generation, its head's exact stored bytes, and both carriers —
|
|
791
|
+
* resolved to raw bytes here, verified nowhere here (see the interface's
|
|
792
|
+
* own doc comment on {@link GitvaultTransport.getState}).
|
|
793
|
+
*/
|
|
794
|
+
async function getVaultStateOut(repoId) {
|
|
795
|
+
const raw = await client.request(`${base(repoId)}/state`, { context: "reading the gitvault vault state" });
|
|
796
|
+
const head = raw.head ? { stored_bytes: fromBase64url(raw.head.stored_bytes, "head.stored_bytes"), stored_bytes_sha256: raw.head.stored_bytes_sha256 } : null;
|
|
797
|
+
const carriers = raw.carriers
|
|
798
|
+
? { ref_state: await resolveVaultStateCarrier(raw.carriers.ref_state), retention_roots: await resolveVaultStateCarrier(raw.carriers.retention_roots) }
|
|
799
|
+
: null;
|
|
800
|
+
return { vault: raw.vault, newest_generation: raw.newest_generation, head, carriers };
|
|
801
|
+
}
|
|
802
|
+
/** Pair a `finalize`-shaped response's receipts back onto `objects` by ledger id — shared by the inline and presigned upload paths so neither forks the other's receipt-compare logic. */
|
|
803
|
+
function receiptsFromFinalize(objects, entries, fin) {
|
|
804
|
+
const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
|
|
805
|
+
return objects.map((o, i) => {
|
|
806
|
+
const id = gitvaultLedgerId(entries[i]);
|
|
807
|
+
const r = receipts.get(id);
|
|
808
|
+
if (!r)
|
|
809
|
+
fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
|
|
810
|
+
return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
|
|
811
|
+
});
|
|
812
|
+
}
|
|
718
813
|
async function upload(repoId, objects, resourceBinding) {
|
|
719
814
|
if (objects.length === 0)
|
|
720
815
|
return [];
|
|
@@ -722,6 +817,19 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
722
817
|
// wire — the control plane derives the bucket key itself and refuses an
|
|
723
818
|
// entry carrying an unexpected member.
|
|
724
819
|
const entries = objects.map((o) => gitvaultManifestEntry(o));
|
|
820
|
+
if (gitvaultInlineUploadEligible(objects)) {
|
|
821
|
+
// gitvault-composite-state-read design D2: every object fits under the
|
|
822
|
+
// caps — one POST, bytes verified + written server-side, and the
|
|
823
|
+
// response IS the finalize response (no session, no PUTs, no separate
|
|
824
|
+
// finalize call). Same closed-key manifest as the presigned path, plus
|
|
825
|
+
// each entry's own bytes.
|
|
826
|
+
const fin = await client.request(`${base(repoId)}/upload-sessions`, {
|
|
827
|
+
method: "POST",
|
|
828
|
+
body: { objects: entries.map((entry, i) => ({ ...entry, bytes: b64u(objects[i].bytes) })), ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
|
|
829
|
+
context: "uploading gitvault objects inline",
|
|
830
|
+
});
|
|
831
|
+
return receiptsFromFinalize(objects, entries, fin);
|
|
832
|
+
}
|
|
725
833
|
const session = await client.request(`${base(repoId)}/upload-sessions`, {
|
|
726
834
|
method: "POST",
|
|
727
835
|
body: { objects: entries, ...(resourceBinding ? { resource_binding: resourceBinding } : {}) },
|
|
@@ -757,14 +865,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
757
865
|
fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
|
|
758
866
|
});
|
|
759
867
|
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
|
|
760
|
-
|
|
761
|
-
return objects.map((o, i) => {
|
|
762
|
-
const id = gitvaultLedgerId(entries[i]);
|
|
763
|
-
const r = receipts.get(id);
|
|
764
|
-
if (!r)
|
|
765
|
-
fail("GITVAULT_RECEIPT_MISSING", `finalize returned no receipt for ${id}`, "finalizing gitvault upload session", { object_id: id, path: o.path });
|
|
766
|
-
return { path: o.path, object_id: o.object_id, sha256: r.ciphertext_sha256 ?? r.stored_bytes_sha256 ?? "", size_bytes: r.size_bytes };
|
|
767
|
-
});
|
|
868
|
+
return receiptsFromFinalize(objects, entries, fin);
|
|
768
869
|
}
|
|
769
870
|
async function admit(repoId, generation, bytes, hash, extra = {}) {
|
|
770
871
|
try {
|
|
@@ -846,6 +947,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
846
947
|
return { cleared: r.advisory_cleared ?? r.cleared ?? false };
|
|
847
948
|
},
|
|
848
949
|
getVaultRecord: ({ repo_id }) => client.request(base(repo_id), { context: "reading the gitvault record" }),
|
|
950
|
+
getState: ({ repo_id }) => getVaultStateOut(repo_id),
|
|
849
951
|
findVaultByProject: ({ project_id }) => client.request(`/gitvault/v1/vaults?project_id=${encodeURIComponent(project_id)}`, { context: "resolving the project's gitvault" }),
|
|
850
952
|
findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/gitvault/v1/vaults?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the gitvault by repo address" }),
|
|
851
953
|
listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
|
|
@@ -864,6 +966,15 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
864
966
|
declareRecipientKeyRevoked: ({ repo_id, principal_id }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/key-revocation`, { method: "POST", body: {}, context: "declaring a gitvault recipient key revoked" }),
|
|
865
967
|
declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a gitvault epoch secret exposed" }),
|
|
866
968
|
declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring gitvault writer authority unavailable" }),
|
|
969
|
+
async submitOpenProof({ repo_id, principal_id, ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }) {
|
|
970
|
+
// requestWithResponse (not request) — the gateway's own status code is
|
|
971
|
+
// the ONLY signal distinguishing a fresh mint (201) from an idempotent
|
|
972
|
+
// replay of the tuple's already-committed winner (200); the receipt
|
|
973
|
+
// BODY is identical either way (routes/gitvault.ts:
|
|
974
|
+
// `res.status(inserted ? 201 : 200).json(receipt)`).
|
|
975
|
+
const res = await client.requestWithResponse(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/proof-of-open`, { method: "POST", body: { ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint }, context: "submitting a gitvault proof-of-open receipt" });
|
|
976
|
+
return { receipt: res.body, deduplicated: res.status === 200 };
|
|
977
|
+
},
|
|
867
978
|
acquireMaintenanceLease: ({ repo_id, base_head_sha256, current_checkpoint_hash, r1_size_bytes, r2_cap_size_bytes, p_before_c1_size_bytes, p_before_c2_size_bytes }) => client.request(`${base(repo_id)}/maintenance-leases`, {
|
|
868
979
|
method: "POST",
|
|
869
980
|
body: {
|
|
@@ -1131,6 +1242,130 @@ export class GitvaultVault {
|
|
|
1131
1242
|
return this.genesisCache;
|
|
1132
1243
|
}
|
|
1133
1244
|
// ── §6.3/6.4 discovery + verification ──
|
|
1245
|
+
/**
|
|
1246
|
+
* gitvault-composite-state-read design D1 — the pin-current fast path
|
|
1247
|
+
* `verifyToNewest` tries FIRST: one `GET …/state` in place of BOTH the
|
|
1248
|
+
* live "server still holds the pin" read {@link readHead} would otherwise
|
|
1249
|
+
* perform AND, when eligible, the `listHeads` walk that would follow it.
|
|
1250
|
+
*
|
|
1251
|
+
* `null` means ineligible — the caller falls straight through to the
|
|
1252
|
+
* UNCHANGED `readHead` + `listHeads` flow, so a `null` here never weakens
|
|
1253
|
+
* verification, it only declines the shortcut:
|
|
1254
|
+
* - the vault is genuinely more than one generation ahead of `pin`
|
|
1255
|
+
* (the listing-walk shape this change does not touch, per design D1:
|
|
1256
|
+
* "a client whose pin is >1 behind newest_generation falls back to
|
|
1257
|
+
* the existing paginated listing + per-head walk");
|
|
1258
|
+
* - OR (one-generation-ahead only) the D194 epoch-continuity check the
|
|
1259
|
+
* ONE new head needs `pin`'s own `.epoch` for, and this call declines
|
|
1260
|
+
* to fetch `pin`'s own head bytes over the network — the entire point
|
|
1261
|
+
* of the shortcut — so it needs a LOCAL source for that epoch: either
|
|
1262
|
+
* `pin` is genesis (a fixed, known epoch), or `pin`'s own head bytes
|
|
1263
|
+
* are already cache-warm (from an earlier call, or from `admit()`'s
|
|
1264
|
+
* own post-push cache write). A cold cache here is not a correctness
|
|
1265
|
+
* problem, only a missed optimization.
|
|
1266
|
+
*
|
|
1267
|
+
* On a non-`null` return, `entries` is exactly what ONE real `listHeads`
|
|
1268
|
+
* page's `heads[]` would have been for this pin (0 items when the pin is
|
|
1269
|
+
* already current, 1 when it is exactly one generation behind — chain
|
|
1270
|
+
* link, gaplessness, and signature all still verified by the UNCHANGED
|
|
1271
|
+
* per-entry loop body {@link verifyToNewest} feeds them through), and
|
|
1272
|
+
* `pinnedHead` is `lastHead`'s INITIAL value: the real, verified head at
|
|
1273
|
+
* `pin.generation` when nothing new needs walking (so it is also the
|
|
1274
|
+
* FINAL value — the loop never runs), or a throwaway placeholder when one
|
|
1275
|
+
* new head is coming (the loop overwrites `lastHead` before anything else
|
|
1276
|
+
* ever reads it again — see `verifyToNewest`'s own `prevEpoch` line,
|
|
1277
|
+
* which is the ONLY thing that reads `lastHead`'s pre-loop value).
|
|
1278
|
+
*
|
|
1279
|
+
* Every byte this method reads from `getState` is cache-WARMED (head +
|
|
1280
|
+
* both carriers, keyed exactly as {@link readCachedHeadBytes}/
|
|
1281
|
+
* {@link openCarrier} already key their own writes) but NEVER trusted
|
|
1282
|
+
* here — every reader downstream re-verifies a cache hit against the hash
|
|
1283
|
+
* it would check network bytes against before using it (this file's own
|
|
1284
|
+
* established cache discipline; see `GitvaultKeystore`'s class doc
|
|
1285
|
+
* comment). A wrong or absent byte this method wrote is therefore just a
|
|
1286
|
+
* cache MISS on the next read, never a verification bypass.
|
|
1287
|
+
*/
|
|
1288
|
+
async tryStateFastPath(pin) {
|
|
1289
|
+
const state = await this.transport.getState({ repo_id: this.repoId });
|
|
1290
|
+
// §6.4: the vault's newest generation may never fall below the
|
|
1291
|
+
// authenticated pin — checked here regardless of eligibility below, so
|
|
1292
|
+
// a regressed vault is caught exactly as loudly as it always was, even
|
|
1293
|
+
// when the walk that would otherwise discover it is about to be skipped.
|
|
1294
|
+
checkGenerationRegression(state.newest_generation ?? GITVAULT_GENESIS_GENERATION, pin.generation);
|
|
1295
|
+
// The gateway's own admission ledger advances `newest_generation` to the
|
|
1296
|
+
// GENESIS generation (0) the moment genesis itself is admitted — a vault
|
|
1297
|
+
// between "genesis admitted" and "first ordinary push" reports its own
|
|
1298
|
+
// generation as newest, `null` only for the narrower window before that
|
|
1299
|
+
// (kept here defensively; `state.head`/`state.carriers` are non-null
|
|
1300
|
+
// whenever `newest_generation` is non-null on the real route). EITHER
|
|
1301
|
+
// way, "no ORDINARY head yet" is the same case this file has always
|
|
1302
|
+
// treated specially: genesis is a DIFFERENT stored-object shape
|
|
1303
|
+
// (`vault_genesis` — no `ref_state`/`retention_roots`, {@link
|
|
1304
|
+
// GitvaultVault.genesis} owns verifying it via its OWN cache), so this
|
|
1305
|
+
// path must never parse `state.head` as a {@link GitvaultHead} when it
|
|
1306
|
+
// is actually genesis's bytes.
|
|
1307
|
+
const noOrdinaryHeadYet = state.newest_generation === null || state.newest_generation === GITVAULT_GENESIS_GENERATION;
|
|
1308
|
+
const pinBig = generationToBigInt(pin.generation);
|
|
1309
|
+
const newestBig = noOrdinaryHeadYet ? 0n : generationToBigInt(state.newest_generation);
|
|
1310
|
+
const diff = newestBig - pinBig; // ≥ 0n, guaranteed by the regression check above (which treats `null`/genesis identically to this)
|
|
1311
|
+
if (diff === 0n) {
|
|
1312
|
+
if (noOrdinaryHeadYet)
|
|
1313
|
+
return { entries: [], pinnedHead: null, prevEpoch: GITVAULT_GENESIS_EPOCH }; // matches today's `lastHead = null` for a genesis-only vault
|
|
1314
|
+
if (!state.head)
|
|
1315
|
+
fail("CHAIN_BROKEN", "the vault state reports an admitted generation but carries no head bytes", "verifying gitvault chain", { generation: state.newest_generation });
|
|
1316
|
+
const sha = sha256Hex(state.head.stored_bytes);
|
|
1317
|
+
if (sha !== pin.head_sha256)
|
|
1318
|
+
fail("CHAIN_BROKEN", `pinned head ${pin.generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation: pin.generation });
|
|
1319
|
+
this.keystore.writeCachedHead(this.repoId, pin.generation, sha, state.head.stored_bytes);
|
|
1320
|
+
const head = parseGitvaultStrict(new TextDecoder().decode(state.head.stored_bytes));
|
|
1321
|
+
if (state.carriers)
|
|
1322
|
+
this.warmStateCarrierCache(pin.generation, head, state.carriers);
|
|
1323
|
+
return { entries: [], pinnedHead: head, prevEpoch: head.epoch };
|
|
1324
|
+
}
|
|
1325
|
+
if (diff === 1n) {
|
|
1326
|
+
let prevEpoch;
|
|
1327
|
+
if (pin.generation === GITVAULT_GENESIS_GENERATION) {
|
|
1328
|
+
prevEpoch = GITVAULT_GENESIS_EPOCH;
|
|
1329
|
+
}
|
|
1330
|
+
else {
|
|
1331
|
+
const cached = this.keystore.readCachedHead(this.repoId, pin.generation);
|
|
1332
|
+
if (!cached || sha256Hex(cached.bytes) !== pin.head_sha256)
|
|
1333
|
+
return null; // cold cache — decline the shortcut, never weaken it
|
|
1334
|
+
prevEpoch = parseGitvaultStrict(new TextDecoder().decode(cached.bytes)).epoch;
|
|
1335
|
+
}
|
|
1336
|
+
if (!state.head)
|
|
1337
|
+
fail("CHAIN_BROKEN", "the vault state reports a newer generation but carries no head bytes", "verifying gitvault chain", { generation: state.newest_generation });
|
|
1338
|
+
const newestGeneration = state.newest_generation;
|
|
1339
|
+
const shaOfBytes = sha256Hex(state.head.stored_bytes);
|
|
1340
|
+
if (shaOfBytes !== state.head.stored_bytes_sha256)
|
|
1341
|
+
fail("CHAIN_BROKEN", `head ${newestGeneration}: stored bytes hash does not match its own declared hash`, "verifying head chain", { generation: newestGeneration });
|
|
1342
|
+
this.keystore.writeCachedHead(this.repoId, newestGeneration, shaOfBytes, state.head.stored_bytes);
|
|
1343
|
+
const head = parseGitvaultStrict(new TextDecoder().decode(state.head.stored_bytes));
|
|
1344
|
+
if (state.carriers)
|
|
1345
|
+
this.warmStateCarrierCache(newestGeneration, head, state.carriers);
|
|
1346
|
+
// `pinnedHead` is a throwaway — see this method's own doc comment: the
|
|
1347
|
+
// ONE loop iteration below overwrites `lastHead` before anything but
|
|
1348
|
+
// `prevEpoch` (already resolved above) ever reads it again.
|
|
1349
|
+
return { entries: [{ generation: newestGeneration, stored_bytes_sha256: shaOfBytes }], pinnedHead: null, prevEpoch };
|
|
1350
|
+
}
|
|
1351
|
+
return null; // more than one generation behind — the existing listHeads walk owns this
|
|
1352
|
+
}
|
|
1353
|
+
/**
|
|
1354
|
+
* Warm the D3 carrier cache from a `GET …/state` response's two carriers,
|
|
1355
|
+
* keyed by the SAME `(object_id, ciphertext_sha256)` the carrying head's
|
|
1356
|
+
* own receipts name — a BLIND write (see {@link tryStateFastPath}'s doc
|
|
1357
|
+
* comment: every reader re-verifies a cache hit before trusting it, so
|
|
1358
|
+
* this is safe by construction). Skips a `null` carrier (absent stored
|
|
1359
|
+
* bytes) entirely rather than caching an absence — the existing
|
|
1360
|
+
* `openCarrier`/`decodeCarrierFrame` machinery already has its own
|
|
1361
|
+
* "frame absent" handling (`CHAIN_UNUSABLE`) via a genuine cache miss.
|
|
1362
|
+
*/
|
|
1363
|
+
warmStateCarrierCache(generation, head, carriers) {
|
|
1364
|
+
if (carriers.ref_state)
|
|
1365
|
+
this.keystore.writeCachedCarrier(this.repoId, head.ref_state.object_id, generation, head.ref_state.ciphertext_sha256, carriers.ref_state);
|
|
1366
|
+
if (carriers.retention_roots)
|
|
1367
|
+
this.keystore.writeCachedCarrier(this.repoId, head.retention_roots.object_id, generation, head.retention_roots.ciphertext_sha256, carriers.retention_roots);
|
|
1368
|
+
}
|
|
1134
1369
|
/**
|
|
1135
1370
|
* List from the authenticated pin and verify every link upward. Persists
|
|
1136
1371
|
* the verified prefix after each page, so a `VERIFICATION_BUDGET_EXCEEDED`
|
|
@@ -1176,13 +1411,26 @@ export class GitvaultVault {
|
|
|
1176
1411
|
const writerKeyId = genesis.writer_key_id;
|
|
1177
1412
|
const repo = this.repoFile();
|
|
1178
1413
|
let pin = repo.verified_prefix ?? repo.head_pin ?? { generation: GITVAULT_GENESIS_GENERATION, head_sha256: genesisSha, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
1179
|
-
|
|
1414
|
+
// gitvault-composite-state-read design D1: try the pin-current fast path
|
|
1415
|
+
// FIRST — see {@link tryStateFastPath}'s own doc comment. `null` means
|
|
1416
|
+
// ineligible (genuinely more than one generation behind, or the pin's
|
|
1417
|
+
// own epoch could not be resolved without the network read this path
|
|
1418
|
+
// exists to avoid) — the caller falls straight through to the UNCHANGED
|
|
1419
|
+
// readHead + listHeads flow below, byte-identical to before this change.
|
|
1420
|
+
const fastPath = await this.tryStateFastPath(pin);
|
|
1421
|
+
let lastHead = fastPath ? fastPath.pinnedHead : pin.generation === GITVAULT_GENESIS_GENERATION ? null : await this.readHead(pin.generation, pin.head_sha256);
|
|
1180
1422
|
const anchor = pin.generation;
|
|
1181
|
-
let prevEpoch = lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH;
|
|
1423
|
+
let prevEpoch = fastPath ? fastPath.prevEpoch : (lastHead?.epoch ?? GITVAULT_GENESIS_EPOCH);
|
|
1182
1424
|
let progress = { after_generation: anchor, last_generation: anchor, delivered: 0 };
|
|
1183
1425
|
let request = { after_generation: anchor, limit: String(GITVAULT_MAX_HEADS_PER_LISTING_PAGE) };
|
|
1184
1426
|
let verified = 0;
|
|
1185
1427
|
const rotations = [];
|
|
1428
|
+
// Consumed by (at most) the FIRST for(;;) iteration below — a listHeads
|
|
1429
|
+
// page this call never had to ask the network for, because the state
|
|
1430
|
+
// read above already proved it (0 entries: pin already current; 1 entry:
|
|
1431
|
+
// exactly the ONE new head, chain-linked from `pin` the SAME way a real
|
|
1432
|
+
// listHeads page's entry would be).
|
|
1433
|
+
let syntheticEntries = fastPath ? fastPath.entries : null;
|
|
1186
1434
|
// ── decrypt-validation state (opt-in) ──
|
|
1187
1435
|
const identity = decryptValidate ? this.keystore.readIdentity() : null;
|
|
1188
1436
|
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
|
|
@@ -1270,7 +1518,14 @@ export class GitvaultVault {
|
|
|
1270
1518
|
}
|
|
1271
1519
|
};
|
|
1272
1520
|
for (;;) {
|
|
1273
|
-
|
|
1521
|
+
// The synthetic page (0 or 1 entries) is only ever valid for the FIRST
|
|
1522
|
+
// iteration — `has_more: false` guarantees `nextListingRequest` ends
|
|
1523
|
+
// the loop right after it is consumed, so clearing it here is purely
|
|
1524
|
+
// defensive (a real second iteration can never see it non-null).
|
|
1525
|
+
const page = syntheticEntries !== null
|
|
1526
|
+
? { format: GITVAULT_FORMAT, repo_id: this.repoId, after_generation: request.after_generation, heads: syntheticEntries, has_more: false, next_cursor: null, total: null }
|
|
1527
|
+
: await this.transport.listHeads({ repo_id: this.repoId, ...request });
|
|
1528
|
+
syntheticEntries = null;
|
|
1274
1529
|
progress = verifyHeadsListingPage(page, request, progress, this.repoId);
|
|
1275
1530
|
for (const entry of page.heads) {
|
|
1276
1531
|
if (verified >= this.budget) {
|
|
@@ -2552,6 +2807,11 @@ export class GitvaultVault {
|
|
|
2552
2807
|
const repo = this.repoFile();
|
|
2553
2808
|
const currentEpoch = base.head?.epoch ?? this.epoch();
|
|
2554
2809
|
const newEpoch = nextEpoch(currentEpoch);
|
|
2810
|
+
// Computed here (rather than where it was historically built, just
|
|
2811
|
+
// before the head is signed) because D209's self_open_attestation
|
|
2812
|
+
// needs to name THIS attempt's own admitted generation as
|
|
2813
|
+
// decryptable_to_generation before the head itself is built.
|
|
2814
|
+
const generation = nextGeneration(base.generation);
|
|
2555
2815
|
// D195's producer obligation: a fresh K_e, checked against EVERY prior
|
|
2556
2816
|
// epoch key this principal has ever locally held.
|
|
2557
2817
|
const kE = randomBytes(32);
|
|
@@ -2662,13 +2922,78 @@ export class GitvaultVault {
|
|
|
2662
2922
|
await this.uploadAll(envelopeUploads);
|
|
2663
2923
|
sealedReceipts.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
|
|
2664
2924
|
const epochKeyCommitmentValue = epochRotationKeyCommitment(kE, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
|
|
2665
|
-
const
|
|
2925
|
+
const payloadBase = {
|
|
2666
2926
|
new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
|
|
2667
2927
|
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
|
|
2668
2928
|
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
|
|
2669
2929
|
epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
2670
2930
|
recipient_authority_attestation: null, envelopes: sealedReceipts,
|
|
2671
2931
|
};
|
|
2932
|
+
// D209 (rev 44) — round-trip THIS principal's own new-epoch
|
|
2933
|
+
// key_envelope through the REAL reader entry point
|
|
2934
|
+
// (openEpochRotationForRecipient — the exact unit fsck/verifyToNewest
|
|
2935
|
+
// use to open a rotation) BEFORE submitting, and bake the result into
|
|
2936
|
+
// the payload as `self_open_attestation`. A rev-44 gateway refuses
|
|
2937
|
+
// EPOCH_ROTATION_SELF_OPEN_UNPROVEN on any rotate_epoch admission
|
|
2938
|
+
// that omits this or whose claim disagrees with its own
|
|
2939
|
+
// server-computed writer-in-envelopes biconditional — this call site
|
|
2940
|
+
// is the fix for the exact gap the 2026-08-28 drill found (production
|
|
2941
|
+
// clients could not READ a rotated vault because the reader had never
|
|
2942
|
+
// implemented rotation traversal, while the write side was green on
|
|
2943
|
+
// its own tests). This SUPERSEDES the old post-commit self-check
|
|
2944
|
+
// below (which called openKeyEnvelope directly, bypassing the
|
|
2945
|
+
// membership lookup, the envelope_path callback derivation, and the
|
|
2946
|
+
// reader's own error framing) — moving the round-trip BEFORE
|
|
2947
|
+
// admission means a genuine failure aborts this call before anything
|
|
2948
|
+
// is ever submitted to the gateway, rather than leaving a broken
|
|
2949
|
+
// rotation committed server-side that even its own writer cannot
|
|
2950
|
+
// read back. A round-trip failure THROWS
|
|
2951
|
+
// (openEpochRotationForRecipient's own error framing — e.g.
|
|
2952
|
+
// GITVAULT_EPOCH_NOT_OPENABLE / EPOCH_KEY_COMMITMENT_MISMATCH — never
|
|
2953
|
+
// a bare AEAD failure); this call never emits a false attestation.
|
|
2954
|
+
const identity = this.keystore.readIdentity();
|
|
2955
|
+
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
|
|
2956
|
+
const ownFingerprint = ownKeypair ? ekFingerprint(ownKeypair.public_key) : null;
|
|
2957
|
+
const ownIncluded = ownFingerprint ? included.find((p) => p.ek_fingerprint === ownFingerprint) : undefined;
|
|
2958
|
+
let selfOpenAttestation;
|
|
2959
|
+
let selfCheck = "not_a_recipient";
|
|
2960
|
+
if (ownFingerprint && ownKeypair && ownIncluded) {
|
|
2961
|
+
await openEpochRotationForRecipient({
|
|
2962
|
+
repo_id: this.repoId,
|
|
2963
|
+
payload: payloadBase,
|
|
2964
|
+
own_fingerprint: ownFingerprint,
|
|
2965
|
+
own_encryption_keypair: ownKeypair,
|
|
2966
|
+
writer_signing_public_key: this.signingKeypair().public_key,
|
|
2967
|
+
get_envelope_bytes: (path) => this.transport.getObject({ repo_id: this.repoId, path }),
|
|
2968
|
+
envelope_path: (epoch, fp, rid) => gitvaultPaths.envelope(epoch, fp, rid),
|
|
2969
|
+
});
|
|
2970
|
+
// openEpochRotationForRecipient already recomputes epoch_key_commitment
|
|
2971
|
+
// from the opened plaintext and compares it to payloadBase's own
|
|
2972
|
+
// (HMAC-derived from kE) — an HMAC collision across different keys
|
|
2973
|
+
// is cryptographically infeasible, so its own pass IS the proof
|
|
2974
|
+
// that the opened secret equals kE. No separate byte comparison
|
|
2975
|
+
// needed here (unlike the old post-commit check, which duplicated
|
|
2976
|
+
// this logic by hand instead of trusting the real entry point).
|
|
2977
|
+
selfOpenAttestation = {
|
|
2978
|
+
outcome: "opened",
|
|
2979
|
+
chain_verified_to_generation: base.generation,
|
|
2980
|
+
decryptable_to_generation: generation,
|
|
2981
|
+
opened_fingerprint: ownFingerprint,
|
|
2982
|
+
reader_entrypoint: gitvaultReaderEntrypoint("openEpochRotationForRecipient"),
|
|
2983
|
+
};
|
|
2984
|
+
selfCheck = "passed";
|
|
2985
|
+
}
|
|
2986
|
+
else {
|
|
2987
|
+
// The normal agent/CI-writer case (D209's "writer_not_recipient"
|
|
2988
|
+
// branch): the admitting principal has no included pair in
|
|
2989
|
+
// envelopes[] (no local encryption identity at all, or one whose
|
|
2990
|
+
// fingerprint is not among `included`), so no self round-trip is
|
|
2991
|
+
// possible. This is NOT a confidentiality gap — the writer sampled
|
|
2992
|
+
// kE itself — and D210's recipient proof-of-open receipts are the
|
|
2993
|
+
// closure for post-rotation readability on this branch.
|
|
2994
|
+
selfOpenAttestation = { outcome: "writer_not_recipient", chain_verified_to_generation: base.generation };
|
|
2995
|
+
}
|
|
2996
|
+
const payload = { ...payloadBase, self_open_attestation: selfOpenAttestation };
|
|
2672
2997
|
const payloadBytes = jcs(payload);
|
|
2673
2998
|
const transition = { kind: "rotate_epoch", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
|
|
2674
2999
|
// The manifest-publish deadlock fix: fold receipted pending
|
|
@@ -2687,7 +3012,9 @@ export class GitvaultVault {
|
|
|
2687
3012
|
? this.buildPinManifestUpdate(pinManifest, pendingConfirmations.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: p.receipt })))
|
|
2688
3013
|
: null;
|
|
2689
3014
|
// The new epoch's ref_state/retention_roots — CARRIED FORWARD unchanged, sealed under kE.
|
|
2690
|
-
|
|
3015
|
+
// (`generation` itself was computed earlier, at the top of this loop
|
|
3016
|
+
// — D209's self_open_attestation needed to name it as
|
|
3017
|
+
// decryptable_to_generation before the head was even built.)
|
|
2691
3018
|
const refState = this.buildRefState(generation, base.refs, base.head_target, { k_repo: kE, epoch: newEpoch });
|
|
2692
3019
|
const rootsObj = this.buildRetentionRoots(generation, base.roots, null, { k_repo: kE, epoch: newEpoch });
|
|
2693
3020
|
const uploads = [refState.upload, rootsObj.upload];
|
|
@@ -2710,39 +3037,13 @@ export class GitvaultVault {
|
|
|
2710
3037
|
fail("HEAD_CAS_CONFLICT", `the rotation lost ${conflicts} races at generation ${generation}; giving up`, "admitting a rotate_epoch head", { generation, winner: admitted.winner });
|
|
2711
3038
|
continue;
|
|
2712
3039
|
}
|
|
2713
|
-
//
|
|
2714
|
-
//
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
if (identity && ownKeypair) {
|
|
2721
|
-
const ownFingerprint = ekFingerprint(ownKeypair.public_key);
|
|
2722
|
-
const own = included.find((p) => p.ek_fingerprint === ownFingerprint);
|
|
2723
|
-
const ownPair = own ? sealedReceipts.find((r) => r.principal_id === own.principal_id) : undefined;
|
|
2724
|
-
if (own && ownPair) {
|
|
2725
|
-
const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.envelope(newEpoch, own.ek_fingerprint, rotationId) });
|
|
2726
|
-
if (!envelopeBytes || sha256Hex(envelopeBytes) !== ownPair.envelope.stored_bytes_sha256) {
|
|
2727
|
-
fail("GITVAULT_RECEIPT_MISMATCH", "this principal's own rotation-attempt envelope is absent or altered after commit", "verifying the committed epoch key", { rotation_id: rotationId });
|
|
2728
|
-
}
|
|
2729
|
-
const envelopeObj = parseGitvaultStrict(new TextDecoder().decode(envelopeBytes));
|
|
2730
|
-
// Verification key: this producer's OWN signing key — only the
|
|
2731
|
-
// vault's single registered writer key can sign a rotate_epoch
|
|
2732
|
-
// head/descriptor at all (v0 single-writer model), so a rotation
|
|
2733
|
-
// this call itself drove was necessarily signed by `this.signingKeypair()`.
|
|
2734
|
-
const recoveredKe = await openKeyEnvelope({ envelope: envelopeObj, recipient: ownKeypair, signer_public_key: this.signingKeypair().public_key });
|
|
2735
|
-
if (bytesToHex(recoveredKe) !== bytesToHex(kE)) {
|
|
2736
|
-
fail("GITVAULT_EPOCH_ROTATION_SELF_CHECK_FAILED", "this principal's own opened envelope does not recover the K_e it sealed — refusing to advance the local epoch pointer", "verifying the committed epoch key");
|
|
2737
|
-
}
|
|
2738
|
-
const recomputed = epochRotationKeyCommitment(recoveredKe, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
|
|
2739
|
-
if (recomputed !== epochKeyCommitmentValue) {
|
|
2740
|
-
fail("GITVAULT_EPOCH_ROTATION_SELF_CHECK_FAILED", "epoch_key_commitment does not reproduce from this principal's own opened K_e", "verifying the committed epoch key");
|
|
2741
|
-
}
|
|
2742
|
-
selfCheck = "passed";
|
|
2743
|
-
}
|
|
2744
|
-
}
|
|
2745
|
-
// Advance the local pointer only after the self-check (when applicable) confirms this principal genuinely holds the committed K_e.
|
|
3040
|
+
// The pre-submission D209 round-trip above already confirmed (when
|
|
3041
|
+
// applicable — `selfCheck === "passed"`) that this principal's own
|
|
3042
|
+
// opened envelope recovers exactly the K_e it sealed and reproduces
|
|
3043
|
+
// epoch_key_commitment, through the real reader entry point. A
|
|
3044
|
+
// genuine self-check failure would have thrown BEFORE this head was
|
|
3045
|
+
// ever built or submitted, so the local pointer advances
|
|
3046
|
+
// unconditionally here.
|
|
2746
3047
|
this.keystore.recordEpochRotation(this.repoId, { new_epoch: newEpoch, new_k_repo_hex: bytesToHex(kE) });
|
|
2747
3048
|
// Same local-cache short-circuit as publishPinManifestUpdate's own
|
|
2748
3049
|
// success path (see readPinManifestObject's doc comment) — the fold
|