run402 4.42.0 → 4.43.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/lib/command-manifest.mjs +6 -3
- package/lib/deploy-v2.mjs +27 -4
- package/lib/next-actions.mjs +5 -2
- package/lib/repos.mjs +466 -117
- package/lib/stats.mjs +57 -0
- package/package.json +1 -1
- package/sdk/dist/index.d.ts +10 -1
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +11 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/kernel.d.ts +31 -0
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +116 -3
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.d.ts +178 -5
- package/sdk/dist/namespaces/gitvault.crypto.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.crypto.js +266 -7
- package/sdk/dist/namespaces/gitvault.crypto.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +103 -1
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +175 -7
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.d.ts +184 -9
- package/sdk/dist/namespaces/gitvault.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.types.js +2 -1
- package/sdk/dist/namespaces/gitvault.types.js.map +1 -1
- package/sdk/dist/node/gitvault-apply.d.ts +8 -0
- package/sdk/dist/node/gitvault-apply.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-apply.js +1 -0
- package/sdk/dist/node/gitvault-apply.js.map +1 -1
- package/sdk/dist/node/gitvault-deploy.d.ts +13 -0
- package/sdk/dist/node/gitvault-deploy.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-deploy.js +28 -0
- package/sdk/dist/node/gitvault-deploy.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +54 -4
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +20 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +328 -4
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +560 -14
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.d.ts +40 -7
- package/sdk/dist/node/gitvault-snapshot.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-snapshot.js +67 -19
- package/sdk/dist/node/gitvault-snapshot.js.map +1 -1
|
@@ -37,7 +37,8 @@ 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, deriveDigestKey, deriveObjectKey, ekFingerprint, formatGitvaultTimestamp, fromBase64url, hexToBytes, jcs, keyedCommitment, newGitvaultId, objectsetContent, openBindingPreimage, openFrame, parseGitvaultStrict, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, 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, openFrame, openKeyEnvelope, parseGitvaultStrict, pinManifestLedgerId, randomBytes, sealFrame, sealKeyEnvelope, sha256Hex, signGitvaultObject, storedBytes, storedBytesSha256, toBase64url, verifyGitvaultObject, } from "../namespaces/gitvault.crypto.js";
|
|
41
|
+
import { GITVAULT_ZERO_SHA256_SENTINEL } from "../namespaces/gitvault.types.js";
|
|
41
42
|
import { crossProfileGitvaultHint } from "./gitvault-profile-scan.js";
|
|
42
43
|
import { GITVAULT_DEPLOY_REF, hardenedGit, hasObject, isAncestor } from "./gitvault-snapshot.js";
|
|
43
44
|
// ─── Constants (constants.json) ──────────────────────────────────────────────
|
|
@@ -340,11 +341,14 @@ export function assertNoTransition(head) {
|
|
|
340
341
|
fail("UPGRADE_REQUIRED", `head ${head.generation} carries an admitted "${head.transition.kind}" transition this client cannot validate; staying read-only at the materialized pin and refusing to publish past it`, "verifying head chain", { generation: head.generation, kind: head.transition.kind }, [{ action: "upgrade the client; the vault stays readable at the materialized pin" }]);
|
|
341
342
|
}
|
|
342
343
|
const HEX16 = "[0-9a-f]{16}";
|
|
343
|
-
|
|
344
|
+
const SHA256 = "[0-9a-f]{64}";
|
|
345
|
+
/** Ordered longest-suffix-first so `.ticket.json` is never eaten by `.enc`, and the rotation-scoped envelope pattern before the plain one so it never matches short. */
|
|
344
346
|
const PATH_PATTERNS = [
|
|
345
347
|
[new RegExp(`^head/(${HEX16})$`), (m) => ({ kind: "head", generation: m[1] })],
|
|
346
348
|
[new RegExp(`^admissions/(${HEX16})$`), (m) => ({ kind: "admission", generation: m[1] })],
|
|
349
|
+
[new RegExp(`^envelopes/(${HEX16})/(${SHA256})/(ek_[0-9a-f]{32})$`), (m) => ({ kind: "object", read: { object_kind: "key_envelope", epoch: m[1], rotation_id: m[2], recipient_fingerprint: m[3] } })],
|
|
347
350
|
[/^envelopes\/([0-9a-f]{16})\/(ek_[0-9a-f]{32})$/, (m) => ({ kind: "object", read: { object_kind: "key_envelope", epoch: m[1], recipient_fingerprint: m[2] } })],
|
|
351
|
+
[new RegExp(`^recipient-pins/(${HEX16})\\.json$`), (m) => ({ kind: "object", read: { object_kind: "recipient_pin_manifest", pin_manifest_version: m[1] } })],
|
|
348
352
|
[/^wal\/(wal_[0-9a-f]{32})\.pack\.enc$/, (m) => ({ kind: "object", read: { object_kind: "wal_pack", object_id: m[1] } })],
|
|
349
353
|
[/^refs\/(refs_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "ref_state", object_id: m[1] } })],
|
|
350
354
|
[/^retention\/(rr_[0-9a-f]{32})\.enc$/, (m) => ({ kind: "object", read: { object_kind: "retention_roots", object_id: m[1] } })],
|
|
@@ -378,9 +382,19 @@ export function gitvaultManifestEntry(object) {
|
|
|
378
382
|
}
|
|
379
383
|
return entry;
|
|
380
384
|
}
|
|
381
|
-
/**
|
|
385
|
+
/**
|
|
386
|
+
* The stable key both sides agree on, used to pair receipts back to
|
|
387
|
+
* requests — MIRRORS the gateway's `keyEnvelopeLedgerId`/`pinManifestLedgerId`
|
|
388
|
+
* (services/gitvault/epoch-rotation.ts) exactly; drift here breaks receipt
|
|
389
|
+
* pairing at upload finalize for a rotation-attempt envelope or a pin
|
|
390
|
+
* manifest.
|
|
391
|
+
*/
|
|
382
392
|
export function gitvaultLedgerId(read) {
|
|
383
|
-
|
|
393
|
+
if (read.object_kind === "key_envelope")
|
|
394
|
+
return keyEnvelopeLedgerId(read.epoch, read.recipient_fingerprint, read.rotation_id ?? null);
|
|
395
|
+
if (read.object_kind === "recipient_pin_manifest")
|
|
396
|
+
return pinManifestLedgerId(read.pin_manifest_version);
|
|
397
|
+
return String(read.object_id);
|
|
384
398
|
}
|
|
385
399
|
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
386
400
|
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
|
|
@@ -445,6 +459,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
445
459
|
return null;
|
|
446
460
|
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
|
|
447
461
|
return null;
|
|
462
|
+
// A known live gateway gap (confirmed 2026-08-27 against
|
|
463
|
+
// src_c78d2f710a8f49d22f9c66faf2a915cd): `POST …/object-reads`
|
|
464
|
+
// validates every null-idScalar (path-addressed) object_kind against
|
|
465
|
+
// key_envelope's `{epoch, recipient_fingerprint}` shape, never having
|
|
466
|
+
// been generalized for the SECOND path-addressed kind D197 shipped —
|
|
467
|
+
// `recipient_pin_manifest`'s real `{pin_manifest_version}` fields —
|
|
468
|
+
// so a genuine read always 400s `"epoch must be 16 hex"`. This is a
|
|
469
|
+
// read-back of a manifest neither uploaded nor cached by THIS
|
|
470
|
+
// keystore (a cache hit in `readPinManifestObject` never reaches
|
|
471
|
+
// here at all) — re-thrown with an attribution that names the gap
|
|
472
|
+
// instead of leaving a caller to read this as a client validation bug.
|
|
473
|
+
if (ref.read.object_kind === "recipient_pin_manifest" && isRun402Error(e) && e.code === "VALIDATION_FAILED") {
|
|
474
|
+
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 });
|
|
475
|
+
}
|
|
448
476
|
throw e;
|
|
449
477
|
}
|
|
450
478
|
const target = presigned.reads[0];
|
|
@@ -590,6 +618,20 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
590
618
|
findVaultByRepo: ({ org_slug, repo_name }) => client.request(`/gitvault/v1/vaults?repo=${encodeURIComponent(`${org_slug}/${repo_name}`)}`, { context: "resolving the gitvault by repo address" }),
|
|
591
619
|
listOrgEncryptionKeys: ({ org_id }) => client.request(`/orgs/v1/${encodeURIComponent(org_id)}/encryption-keys`, { context: "reading the org encryption-key directory" }),
|
|
592
620
|
listEnvelopeRecipients: ({ repo_id }) => client.request(`${base(repo_id)}/envelope-recipients`, { context: "reading the gitvault envelope recipients" }),
|
|
621
|
+
// ── epoch rotation (D193-D203, rev 42, §9.2) ──
|
|
622
|
+
async createRotationAttempt({ repo_id, descriptor }) {
|
|
623
|
+
const res = await client.request(`${base(repo_id)}/rotation-attempts`, {
|
|
624
|
+
method: "POST",
|
|
625
|
+
body: descriptor,
|
|
626
|
+
context: "creating a gitvault rotation attempt",
|
|
627
|
+
});
|
|
628
|
+
return { rotation_id: res.rotation_id, descriptor: res.descriptor, deduplicated: res.deduplicated ?? false };
|
|
629
|
+
},
|
|
630
|
+
confirmRecipient: ({ repo_id, principal_id, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/confirm`, { method: "POST", body: { new_fingerprint }, context: "confirming a gitvault recipient's first pin" }),
|
|
631
|
+
repinRecipient: ({ repo_id, principal_id, old_ek_fingerprint, new_fingerprint }) => client.request(`${base(repo_id)}/recipients/${encodeURIComponent(principal_id)}/repin`, { method: "POST", body: { old_ek_fingerprint, new_fingerprint }, context: "re-pinning a gitvault recipient" }),
|
|
632
|
+
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" }),
|
|
633
|
+
declareEpochSecretExposed: ({ repo_id }) => client.request(`${base(repo_id)}/epoch-secret-exposure`, { method: "POST", body: {}, context: "declaring a gitvault epoch secret exposed" }),
|
|
634
|
+
declareWriterAuthorityUnavailable: ({ repo_id }) => client.request(`${base(repo_id)}/writer-authority/declare-unavailable`, { method: "POST", body: {}, context: "declaring gitvault writer authority unavailable" }),
|
|
593
635
|
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`, {
|
|
594
636
|
method: "POST",
|
|
595
637
|
body: {
|
|
@@ -660,8 +702,18 @@ export const gitvaultPaths = {
|
|
|
660
702
|
cutoffTicket: (id) => `retention/${id}.ticket.json`,
|
|
661
703
|
/** `verifier-receipts/<vr>.json` — plaintext-structured, uploaded before a prune intent may reference it (§7.3). */
|
|
662
704
|
verifierReceipt: (id) => `verifier-receipts/${id}.json`,
|
|
663
|
-
/**
|
|
664
|
-
|
|
705
|
+
/**
|
|
706
|
+
* `envelopes/<epoch>/<recipient_fingerprint>` — mirrors
|
|
707
|
+
* `gitvault-creation-journal.ts`'s private `envelopePath` (the genesis
|
|
708
|
+
* creator's envelope); this is the same addressing for every OTHER
|
|
709
|
+
* recipient's `key_envelope`. `rotationId` present (D195, rev 42) widens
|
|
710
|
+
* this to `envelopes/<epoch>/<rotation_id>/<recipient_fingerprint>` — a
|
|
711
|
+
* rotation-attempt envelope's own path (protocol §1: `rotation_id`
|
|
712
|
+
* ABSENT, never explicit null, in the genesis/ADD-workaround case).
|
|
713
|
+
*/
|
|
714
|
+
envelope: (epoch, recipientFingerprint, rotationId) => rotationId ? `envelopes/${epoch}/${rotationId}/${recipientFingerprint}` : `envelopes/${epoch}/${recipientFingerprint}`,
|
|
715
|
+
/** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
|
|
716
|
+
pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
|
|
665
717
|
};
|
|
666
718
|
/** A transport-agnostic view of git ops the publication needs (the local repository). */
|
|
667
719
|
export class GitvaultVault {
|
|
@@ -988,8 +1040,17 @@ export class GitvaultVault {
|
|
|
988
1040
|
return { repo_id: this.repoId, org_id: repo.org_id, epoch, wrapped, already_covered: alreadyCovered, skipped };
|
|
989
1041
|
}
|
|
990
1042
|
// ── object building ──
|
|
991
|
-
|
|
992
|
-
|
|
1043
|
+
/**
|
|
1044
|
+
* `keyOverride` (D194, rev 42): a `rotate_epoch` head's OWN `ref_state`/
|
|
1045
|
+
* `retention_roots` must be sealed under the NEWLY-sampled `K_e` at the
|
|
1046
|
+
* NEW epoch, never under `this.kRepo()`/`this.epoch()` (the about-to-be-
|
|
1047
|
+
* superseded current key) — every other call site keeps calling `seal`
|
|
1048
|
+
* with no override, unaffected.
|
|
1049
|
+
*/
|
|
1050
|
+
seal(kind, objectId, plaintext, path, keyOverride) {
|
|
1051
|
+
const kRepo = keyOverride?.k_repo ?? this.kRepo();
|
|
1052
|
+
const epoch = keyOverride?.epoch ?? this.epoch();
|
|
1053
|
+
const sealed = sealFrame({ k_obj: deriveObjectKey(kRepo, this.repoId, epoch, kind, objectId), repo_id: this.repoId, object_kind: kind, object_id: objectId, epoch, plaintext });
|
|
993
1054
|
return { path, object_kind: kind, object_id: objectId, bytes: sealed.frame, sha256: sealed.ciphertext_sha256, size_bytes: sealed.size_bytes };
|
|
994
1055
|
}
|
|
995
1056
|
/**
|
|
@@ -1017,7 +1078,7 @@ export class GitvaultVault {
|
|
|
1017
1078
|
fail("GITVAULT_READ_ONLY", "the signing key is missing from identity.json; this principal is read-only", "signing gitvault object", undefined, [{ action: "stay read-only at the materialized pin" }]);
|
|
1018
1079
|
return kp;
|
|
1019
1080
|
}
|
|
1020
|
-
buildRefState(generation, refs, headTarget) {
|
|
1081
|
+
buildRefState(generation, refs, headTarget, keyOverride) {
|
|
1021
1082
|
if (headTarget.kind === "symref" && !BRANCH_REF_RE.test(headTarget.ref))
|
|
1022
1083
|
fail("REFNAME_UNSUPPORTED", `head_target symref must name a refs/heads/* branch: ${headTarget.ref}`, "building ref_state");
|
|
1023
1084
|
if (headTarget.kind === "detached" && !GITVAULT_OID40_RE.test(headTarget.oid))
|
|
@@ -1031,15 +1092,15 @@ export class GitvaultVault {
|
|
|
1031
1092
|
const plaintext = storedBytes(object);
|
|
1032
1093
|
if (plaintext.length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
|
|
1033
1094
|
fail("REF_STATE_LIMIT_EXCEEDED", "ref_state object exceeds 32 MiB", "building ref_state");
|
|
1034
|
-
return { object, upload: this.seal("ref_state", id, plaintext, gitvaultPaths.refState(id)) };
|
|
1095
|
+
return { object, upload: this.seal("ref_state", id, plaintext, gitvaultPaths.refState(id), keyOverride) };
|
|
1035
1096
|
}
|
|
1036
|
-
buildRetentionRoots(generation, roots, cutoff) {
|
|
1097
|
+
buildRetentionRoots(generation, roots, cutoff, keyOverride) {
|
|
1037
1098
|
const id = newGitvaultId("rr");
|
|
1038
1099
|
const object = signGitvaultObject({ format: GITVAULT_FORMAT, object_kind: "retention_roots", suite: GITVAULT_SUITE, repo_id: this.repoId, object_id: id, generation, cutoff, roots: [...roots].sort(compareRoots) }, this.signer());
|
|
1039
1100
|
const plaintext = storedBytes(object);
|
|
1040
1101
|
if (plaintext.length > GITVAULT_MAX_REF_STATE_OBJECT_BYTES)
|
|
1041
1102
|
fail("REF_STATE_LIMIT_EXCEEDED", "retention_roots object exceeds 32 MiB", "building retention_roots");
|
|
1042
|
-
return { object, upload: this.seal("retention_roots", id, plaintext, gitvaultPaths.retentionRoots(id)) };
|
|
1103
|
+
return { object, upload: this.seal("retention_roots", id, plaintext, gitvaultPaths.retentionRoots(id), keyOverride) };
|
|
1043
1104
|
}
|
|
1044
1105
|
/** Plaintext, independently non-thin packs covering `reachable(tips) ∖ reachable(base)`, split at the multi-object target. */
|
|
1045
1106
|
async buildPacks(tips, base) {
|
|
@@ -1518,8 +1579,31 @@ export class GitvaultVault {
|
|
|
1518
1579
|
}
|
|
1519
1580
|
return issued;
|
|
1520
1581
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1582
|
+
/**
|
|
1583
|
+
* `overrides.epoch` (D194, rev 42 fix): defaults to `this.epoch()` — the
|
|
1584
|
+
* LOCALLY KNOWN current epoch — rather than the fixed genesis constant.
|
|
1585
|
+
* This is load-bearing, not cosmetic: once ANY rotation has landed, every
|
|
1586
|
+
* ORDINARY (non-`rotate_epoch`) head this principal signs must still
|
|
1587
|
+
* claim the vault's CURRENT epoch (protocol §4.3's chain-link rule —
|
|
1588
|
+
* "every head's epoch equals its predecessor's UNLESS this head admits a
|
|
1589
|
+
* `rotate_epoch` transition"); hard-coding the generation-1 constant here
|
|
1590
|
+
* would make EVERY ordinary push after a vault's first rotation refuse
|
|
1591
|
+
* `CHAIN_BROKEN` forever. `rotateEpoch` passes `overrides.epoch = new_epoch`
|
|
1592
|
+
* explicitly for the ONE head that legitimately claims a DIFFERENT epoch
|
|
1593
|
+
* than `this.epoch()` currently reads (the local pointer only advances
|
|
1594
|
+
* AFTER a successful admit, via `keystore.recordEpochRotation`).
|
|
1595
|
+
*/
|
|
1596
|
+
signHead(fields, overrides) {
|
|
1597
|
+
const unsigned = {
|
|
1598
|
+
format: GITVAULT_FORMAT, object_kind: "head", suite: GITVAULT_SUITE, repo_id: this.repoId,
|
|
1599
|
+
generation: fields.generation, prev_sha256: fields.prev_sha256, epoch: overrides?.epoch ?? this.epoch(),
|
|
1600
|
+
wal_entries: fields.wal_entries, ref_state: fields.ref_state, retention_roots: fields.retention_roots,
|
|
1601
|
+
checkpoint: fields.checkpoint, checkpoint_purpose: fields.checkpoint_purpose, capture_binding: fields.capture_binding,
|
|
1602
|
+
repair: fields.repair, transition: overrides?.transition ?? null,
|
|
1603
|
+
...(overrides?.pin_manifest ? { pin_manifest: overrides.pin_manifest } : {}),
|
|
1604
|
+
writer_key_id: this.writerKeyId(), created_at: formatGitvaultTimestamp(this.now()),
|
|
1605
|
+
};
|
|
1606
|
+
return signGitvaultObject(unsigned, this.signer());
|
|
1523
1607
|
}
|
|
1524
1608
|
/** Admit a signed head; on success read it back from storage and compare BEFORE any pin advances. */
|
|
1525
1609
|
async admit(head) {
|
|
@@ -1536,6 +1620,468 @@ export class GitvaultVault {
|
|
|
1536
1620
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
|
|
1537
1621
|
return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
|
|
1538
1622
|
}
|
|
1623
|
+
// ── epoch rotation (D193-D203, rev 42, change gitvault-human-envelopes) ──
|
|
1624
|
+
/**
|
|
1625
|
+
* Walk the chain BACKWARD from the current tip to find the nearest
|
|
1626
|
+
* admitted `recipient_pin_manifest` receipt (D197: "a fresh client... reads
|
|
1627
|
+
* the LATEST admitted manifest, walking `prev_sha256` back to the nearest
|
|
1628
|
+
* head carrying one"). Heads are plaintext-structured/signed (never
|
|
1629
|
+
* encrypted) so this needs no key material — only signature verification
|
|
1630
|
+
* against the creator's own pubkey. Falls back to `vault_genesis.pin_manifest`
|
|
1631
|
+
* (D198 N-recipient genesis — this SDK does not BUILD one, but reads one
|
|
1632
|
+
* correctly for interop), then to the zero-value sentinel (no manifest has
|
|
1633
|
+
* ever been admitted — every principal starts `excluded_unconfirmed`).
|
|
1634
|
+
*
|
|
1635
|
+
* Cost is O(distance to the nearest pin-manifest-bearing head) — for a
|
|
1636
|
+
* vault that has never published one, that is every generation back to
|
|
1637
|
+
* genesis. There is no index that avoids this in protocol v0 (the same
|
|
1638
|
+
* cost class `verifyToNewest`'s own chain walk already has); a vault with
|
|
1639
|
+
* a long, pin-manifest-free history pays it once per rotation.
|
|
1640
|
+
*/
|
|
1641
|
+
async loadEffectivePinManifest(newestGeneration) {
|
|
1642
|
+
const { genesis } = await this.genesis();
|
|
1643
|
+
let gen = generationToBigInt(newestGeneration);
|
|
1644
|
+
while (gen >= 1n) {
|
|
1645
|
+
const generation = gen.toString(16).padStart(16, "0");
|
|
1646
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
|
|
1647
|
+
if (!bytes)
|
|
1648
|
+
fail("CHAIN_BROKEN", `head ${generation} is missing from storage while resolving the effective pin manifest`, "resolving the effective recipient pin manifest", { generation });
|
|
1649
|
+
const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1650
|
+
if (head.pin_manifest)
|
|
1651
|
+
return this.readPinManifestObject(head.pin_manifest);
|
|
1652
|
+
gen -= 1n;
|
|
1653
|
+
}
|
|
1654
|
+
if (genesis.pin_manifest)
|
|
1655
|
+
return this.readPinManifestObject(genesis.pin_manifest);
|
|
1656
|
+
return { pinManifestVersion: "0".repeat(16), pinManifestSha256: GITVAULT_ZERO_SHA256_SENTINEL, pinnedFingerprintOf: new Map() };
|
|
1657
|
+
}
|
|
1658
|
+
/**
|
|
1659
|
+
* Local-cache short-circuit (see {@link GitvaultRepoFile.known_pin_manifest}'s
|
|
1660
|
+
* own doc comment for why this is safe): a manifest THIS keystore itself
|
|
1661
|
+
* just built, signed, and admitted is resolved from the on-disk cache
|
|
1662
|
+
* instead of a network `object-reads` round trip — same return shape,
|
|
1663
|
+
* skipped only on an exact `(pin_manifest_version, stored_bytes_sha256)`
|
|
1664
|
+
* match against `receipt`. A miss (cache absent, or naming a DIFFERENT
|
|
1665
|
+
* manifest — e.g. one another principal/machine published) falls through
|
|
1666
|
+
* to the unchanged network path below.
|
|
1667
|
+
*
|
|
1668
|
+
* **Known gap this happens to route around, not fix:** the gateway's
|
|
1669
|
+
* `POST …/object-reads` route does not yet recognize `recipient_pin_manifest`
|
|
1670
|
+
* as a readable `object_kind` at all (`services/gitvault/reads.ts`
|
|
1671
|
+
* `validateReadRequest`'s null-`idScalar` branch is still hardcoded to
|
|
1672
|
+
* `key_envelope`'s `{epoch, recipient_fingerprint}` shape — the ONE other
|
|
1673
|
+
* path-addressed kind, `recipient_pin_manifest`, was never folded in when
|
|
1674
|
+
* D197 shipped it with its own `{pin_manifest_version}` `pathFields`).
|
|
1675
|
+
* Confirmed live 2026-08-27 against `src_c78d2f710a8f49d22f9c66faf2a915cd`:
|
|
1676
|
+
* every read attempt fails `400 VALIDATION_FAILED "objects[0]: epoch must
|
|
1677
|
+
* be 16 hex"`. This cache unblocks the SAME keystore re-reading its OWN
|
|
1678
|
+
* just-published manifest (this ceremony's actual need) but does nothing
|
|
1679
|
+
* for a genuinely fresh keystore/machine reading an EXISTING manifest for
|
|
1680
|
+
* the first time (§4.11's "fresh client... SEEDS its local pin file from
|
|
1681
|
+
* it" onboarding path) — that still needs the gateway fix.
|
|
1682
|
+
*/
|
|
1683
|
+
async readPinManifestObject(receipt) {
|
|
1684
|
+
const known = this.repoFile().known_pin_manifest;
|
|
1685
|
+
if (known && known.pin_manifest_version === receipt.pin_manifest_version && known.stored_bytes_sha256 === receipt.stored_bytes_sha256) {
|
|
1686
|
+
return { pinManifestVersion: known.pin_manifest_version, pinManifestSha256: known.stored_bytes_sha256, pinnedFingerprintOf: new Map(known.pins.map((p) => [p.principal_id, p.ek_fingerprint])) };
|
|
1687
|
+
}
|
|
1688
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.pinManifest(receipt.pin_manifest_version) });
|
|
1689
|
+
if (!bytes || sha256Hex(bytes) !== receipt.stored_bytes_sha256) {
|
|
1690
|
+
fail("GITVAULT_RECEIPT_MISMATCH", `recipient_pin_manifest ${receipt.pin_manifest_version} is absent or does not match its receipted hash`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
|
|
1691
|
+
}
|
|
1692
|
+
const manifest = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1693
|
+
const { genesis } = await this.genesis();
|
|
1694
|
+
if (!verifyGitvaultObject(manifest, genesis.creator_signing_pubkey)) {
|
|
1695
|
+
fail("GITVAULT_SIGNATURE_INVALID", `recipient_pin_manifest ${receipt.pin_manifest_version} signature does not verify`, "resolving the effective recipient pin manifest", { pin_manifest_version: receipt.pin_manifest_version });
|
|
1696
|
+
}
|
|
1697
|
+
const pinnedFingerprintOf = new Map(manifest.pins.map((p) => [p.principal_id, p.ek_fingerprint]));
|
|
1698
|
+
return { pinManifestVersion: receipt.pin_manifest_version, pinManifestSha256: receipt.stored_bytes_sha256, pinnedFingerprintOf };
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Build the successor `recipient_pin_manifest` object from the currently-
|
|
1702
|
+
* effective PREDECESSOR manifest plus a batch of receipted updates (D197
|
|
1703
|
+
* full-map conservation: `next_map = prior_map + receipt-authorized
|
|
1704
|
+
* additions/replacements`). Shared by {@link publishPinManifestUpdate} (a
|
|
1705
|
+
* single-entry ordinary-push publish) and {@link rotateEpoch}'s
|
|
1706
|
+
* `pending_confirmations` fold (a multi-entry publish riding the SAME
|
|
1707
|
+
* head as the rotation, D196). Validates EVERY update's receipt against
|
|
1708
|
+
* `prior` before building — a receipt issued against a stale predecessor
|
|
1709
|
+
* fails closed here (`VALIDATION_FAILED`), never silently overwritten,
|
|
1710
|
+
* matching the gateway's own admission-time field-by-field check.
|
|
1711
|
+
*
|
|
1712
|
+
* Does NOT admit anything — the caller uploads `.upload` and attaches the
|
|
1713
|
+
* returned `{object_kind, pin_manifest_version, stored_bytes_sha256,
|
|
1714
|
+
* size_bytes}` receipt shape to whichever head it is publishing on.
|
|
1715
|
+
*/
|
|
1716
|
+
buildPinManifestUpdate(prior, updates) {
|
|
1717
|
+
for (const u of updates) {
|
|
1718
|
+
if (u.receipt.base_pin_manifest_sha256 !== prior.pinManifestSha256) {
|
|
1719
|
+
fail("VALIDATION_FAILED", `the confirmation receipt for ${u.principal_id} was issued against a different predecessor manifest than the one currently effective — obtain a fresh /confirm or /repin`, "publishing a recipient_pin_manifest update", { principal_id: u.principal_id, receipt_base: u.receipt.base_pin_manifest_sha256, current_base: prior.pinManifestSha256 });
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const updateIds = new Set(updates.map((u) => u.principal_id));
|
|
1723
|
+
if (updateIds.size !== updates.length) {
|
|
1724
|
+
fail("VALIDATION_FAILED", "duplicate principal_id across pin-manifest updates in one publish", "publishing a recipient_pin_manifest update", { principal_ids: updates.map((u) => u.principal_id) });
|
|
1725
|
+
}
|
|
1726
|
+
const nextVersion = (generationToBigInt(prior.pinManifestVersion) + 1n).toString(16).padStart(16, "0");
|
|
1727
|
+
const pins = [];
|
|
1728
|
+
for (const [principalId, ekFp] of prior.pinnedFingerprintOf) {
|
|
1729
|
+
if (updateIds.has(principalId))
|
|
1730
|
+
continue; // replaced below
|
|
1731
|
+
pins.push({ principal_id: principalId, ek_fingerprint: ekFp, pinned_at: formatGitvaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: GITVAULT_ZERO_SHA256_SENTINEL });
|
|
1732
|
+
}
|
|
1733
|
+
for (const u of updates) {
|
|
1734
|
+
pins.push({ principal_id: u.principal_id, ek_fingerprint: u.ek_fingerprint, pinned_at: formatGitvaultTimestamp(this.now()), confirmed_by: "operator_confirmation", confirmation_receipt_sha256: storedBytesSha256(u.receipt) });
|
|
1735
|
+
}
|
|
1736
|
+
pins.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
|
|
1737
|
+
const unsignedManifest = { format: GITVAULT_FORMAT, object_kind: "recipient_pin_manifest", suite: GITVAULT_SUITE, repo_id: this.repoId, pin_manifest_version: nextVersion, base_pin_manifest_sha256: prior.pinManifestSha256, pins, writer_key_id: this.writerKeyId() };
|
|
1738
|
+
const manifest = signGitvaultObject(unsignedManifest, this.signer());
|
|
1739
|
+
const manifestBytes = storedBytes(manifest);
|
|
1740
|
+
const manifestSha = sha256Hex(manifestBytes);
|
|
1741
|
+
const upload = { path: gitvaultPaths.pinManifest(nextVersion), object_kind: "recipient_pin_manifest", object_id: null, bytes: manifestBytes, sha256: manifestSha, size_bytes: String(manifestBytes.length) };
|
|
1742
|
+
return { nextVersion, manifestSha, upload, pins: pins.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })) };
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Build a `recipient_pin_manifest` update (D197 full-map conservation) and
|
|
1746
|
+
* publish it via an ORDINARY head (`gitvault.writer`-sufficient — the
|
|
1747
|
+
* OWNER-GATED half of the ceremony already happened at `/confirm`/`/repin`,
|
|
1748
|
+
* which is what produced `receipt`; PUBLISHING the resulting entry is
|
|
1749
|
+
* ordinary-writer authority, same split as envelope-wrap authority
|
|
1750
|
+
* always had). Carries the SAME refs/roots forward unchanged, at the
|
|
1751
|
+
* CURRENT epoch (this is not a rotation — no new epoch, no new K_e).
|
|
1752
|
+
*
|
|
1753
|
+
* **This is an ORDINARY admission (`transition: null`) and is therefore
|
|
1754
|
+
* itself refused `EPOCH_ROTATION_REQUIRED` while a migration/revocation/
|
|
1755
|
+
* exposure condition is outstanding on this vault (D193) — the exact
|
|
1756
|
+
* deadlock the incident behind {@link GitvaultVault.rotateEpoch}'s
|
|
1757
|
+
* `pending_confirmations` parameter closes.** When this vault is in that
|
|
1758
|
+
* state, fold the receipt into `rotateEpoch({..., pending_confirmations:
|
|
1759
|
+
* [{principal_id, ek_fingerprint, receipt}]})` instead of calling this
|
|
1760
|
+
* method directly — that submission carries a `rotate_epoch` transition,
|
|
1761
|
+
* which IS the gate's own escape valve.
|
|
1762
|
+
*/
|
|
1763
|
+
async publishPinManifestUpdate(input) {
|
|
1764
|
+
let conflicts = 0;
|
|
1765
|
+
for (;;) {
|
|
1766
|
+
const base = await this.materialize();
|
|
1767
|
+
const prior = await this.loadEffectivePinManifest(base.generation);
|
|
1768
|
+
const { nextVersion, manifestSha, upload: manifestUpload, pins } = this.buildPinManifestUpdate(prior, [input]);
|
|
1769
|
+
await this.uploadAll([manifestUpload]);
|
|
1770
|
+
const generation = nextGeneration(base.generation);
|
|
1771
|
+
const refState = this.buildRefState(generation, base.refs, base.head_target);
|
|
1772
|
+
const rootsObj = this.buildRetentionRoots(generation, base.roots, null);
|
|
1773
|
+
await this.uploadAll([refState.upload, rootsObj.upload]);
|
|
1774
|
+
const head = this.signHead({
|
|
1775
|
+
generation, prev_sha256: base.head_sha256, wal_entries: [],
|
|
1776
|
+
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
|
|
1777
|
+
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
|
|
1778
|
+
checkpoint: null, checkpoint_purpose: null, capture_binding: null, repair: null,
|
|
1779
|
+
}, { pin_manifest: { object_kind: "recipient_pin_manifest", pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, size_bytes: String(manifestUpload.size_bytes) } });
|
|
1780
|
+
const admitted = await this.admit(head);
|
|
1781
|
+
if (admitted.outcome === "conflict") {
|
|
1782
|
+
conflicts += 1;
|
|
1783
|
+
if (conflicts > this.retries)
|
|
1784
|
+
fail("HEAD_CAS_CONFLICT", `the pin-manifest update lost ${conflicts} races at generation ${generation}; giving up`, "publishing a recipient_pin_manifest update", { generation, winner: admitted.winner });
|
|
1785
|
+
continue;
|
|
1786
|
+
}
|
|
1787
|
+
// Cache OUR OWN just-admitted manifest (see readPinManifestObject's
|
|
1788
|
+
// doc comment) so a LATER call on this vault — most importantly
|
|
1789
|
+
// rotateEpoch's own loadEffectivePinManifest, which MUST read the
|
|
1790
|
+
// predecessor manifest back to compute confirmed() (D196) — resolves
|
|
1791
|
+
// it locally instead of a network object-reads round trip.
|
|
1792
|
+
this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: nextVersion, stored_bytes_sha256: manifestSha, pins } });
|
|
1793
|
+
return { generation, head_sha256: admitted.head_sha256, head, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form: "wal", conflicts_retried: conflicts, refs: base.refs };
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
/**
|
|
1797
|
+
* Drive one epoch rotation to a committed head (D193-D203). This is the
|
|
1798
|
+
* producer's obligations in full: sample a FRESH `K_e` independent of
|
|
1799
|
+
* every prior epoch key this principal has locally held; compute the H
|
|
1800
|
+
* bijection from the live desired-recipient state + the effective pin
|
|
1801
|
+
* manifest; seal one `key_envelope` per included recipient from the SAME
|
|
1802
|
+
* `K_e`; submit the create-only `rotation_attempt_descriptor` BEFORE any
|
|
1803
|
+
* envelope upload; upload the envelopes; submit the `rotate_epoch` head;
|
|
1804
|
+
* on a CAS conflict, retry from a fresh `materialize()` (the SAME
|
|
1805
|
+
* conflict-retry shape `push()` uses); after commit, verify this
|
|
1806
|
+
* principal's OWN envelope (when it is itself a recipient) opens to
|
|
1807
|
+
* exactly the committed `K_e` and reproduces `epoch_key_commitment`
|
|
1808
|
+
* (D200's narrowed per-recipient self-check — never a global proof);
|
|
1809
|
+
* advance the local keystore's current epoch/key pointer AND retain the
|
|
1810
|
+
* prior key in `epoch_keys`.
|
|
1811
|
+
*
|
|
1812
|
+
* **`recipient_state_version`/`recipient_revocation_version` are REQUIRED
|
|
1813
|
+
* inputs, not discovered here.** The gateway exposes NO general read route
|
|
1814
|
+
* for `internal.gitvault_recipient_state_counters` (D194) — the ONLY
|
|
1815
|
+
* client-visible read of these two org-scoped counters today is the
|
|
1816
|
+
* response of `POST …/recipients/:principal_id/key-revocation`
|
|
1817
|
+
* ({@link GitvaultTransport.declareRecipientKeyRevoked}), which is why
|
|
1818
|
+
* {@link rotateEpochForKeyRevocation} (below) is the one fully
|
|
1819
|
+
* self-contained entry point. For `member_removed` / `elective_rekey` /
|
|
1820
|
+
* `epoch_secret_exposed`, a caller that does not already know the current
|
|
1821
|
+
* counter pair cannot discover it from any shipped gateway route — this
|
|
1822
|
+
* is a confirmed gap in the live gateway (verified against
|
|
1823
|
+
* `packages/gateway/src/services/gitvault/reads.ts:getVaultRecord` and
|
|
1824
|
+
* every `routes/gitvault*.ts` handler, not inferred), not a client
|
|
1825
|
+
* limitation this SDK can work around. Passing a stale/guessed pair fails
|
|
1826
|
+
* safely: the admission fence's D194 frozen-counter comparison refuses
|
|
1827
|
+
* `RECIPIENT_SET_MISMATCH` rather than silently canonizing against wrong
|
|
1828
|
+
* evidence.
|
|
1829
|
+
*
|
|
1830
|
+
* **`options.pending_confirmations` (the manifest-publish deadlock fix).**
|
|
1831
|
+
* `publishPinManifestUpdate` is an ORDINARY admission (`transition:
|
|
1832
|
+
* null`) and is therefore itself refused `EPOCH_ROTATION_REQUIRED` while
|
|
1833
|
+
* this vault has an urgent/migration condition outstanding — the exact
|
|
1834
|
+
* state a `rotateEpoch` call is being made to clear. Reproduced live in
|
|
1835
|
+
* production 2026-08-27 (a vault's first `epoch_secret_exposed` rekey:
|
|
1836
|
+
* `/confirm` minted a receipt server-side, but the ordinary push that
|
|
1837
|
+
* would publish it never admitted). Pass the pending receipted updates
|
|
1838
|
+
* here instead — they ride the SAME head as this rotation's `transition`,
|
|
1839
|
+
* which IS `EPOCH_ROTATION_REQUIRED`'s own escape valve, so the publish
|
|
1840
|
+
* is no longer blocked. **This does NOT include these principals in
|
|
1841
|
+
* THIS rotation's `envelopes[]`** — protocol-v0.md D196 is explicit: "a
|
|
1842
|
+
* manifest update riding the SAME head never self-authorizes its own
|
|
1843
|
+
* recipients"; `confirmed(h)` for THIS rotation still reads only the
|
|
1844
|
+
* PREDECESSOR manifest, unchanged. They land in
|
|
1845
|
+
* `excluded_unconfirmed_principal_ids` here (same as without folding) and
|
|
1846
|
+
* become eligible starting at the NEXT rotation, once this manifest is
|
|
1847
|
+
* the admitted predecessor. See {@link GitvaultRotationResult.pin_manifest_published}.
|
|
1848
|
+
*
|
|
1849
|
+
* **Honest residual — this does not rescue a vault with ZERO ever-
|
|
1850
|
+
* confirmed principals.** If `included` would be empty even with the
|
|
1851
|
+
* fold (no predecessor-confirmed principal exists at all — e.g. a
|
|
1852
|
+
* grandfathered pre-rev-42 vault whose bare genesis never published a
|
|
1853
|
+
* pin manifest and which was never bootstrapped before its
|
|
1854
|
+
* `migration_rotation_required` flag was set), the gateway refuses
|
|
1855
|
+
* `EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED` regardless of what rides
|
|
1856
|
+
* along on `pin_manifest` — D196's same-head exclusion makes THIS
|
|
1857
|
+
* impossible to route around from the client. That is a genuine,
|
|
1858
|
+
* currently-open protocol gap (not something this parameter can paper
|
|
1859
|
+
* over) and needs an operator-side decision, not a client workaround.
|
|
1860
|
+
*/
|
|
1861
|
+
async rotateEpoch(options) {
|
|
1862
|
+
let conflicts = 0;
|
|
1863
|
+
for (;;) {
|
|
1864
|
+
const base = await this.materialize();
|
|
1865
|
+
const repo = this.repoFile();
|
|
1866
|
+
const currentEpoch = base.head?.epoch ?? this.epoch();
|
|
1867
|
+
const newEpoch = nextEpoch(currentEpoch);
|
|
1868
|
+
// D195's producer obligation: a fresh K_e, checked against EVERY prior
|
|
1869
|
+
// epoch key this principal has ever locally held.
|
|
1870
|
+
const kE = randomBytes(32);
|
|
1871
|
+
const priorKnownKeys = Object.values(repo.epoch_keys ?? { [repo.epoch]: repo.k_repo_hex }).map(hexToBytes);
|
|
1872
|
+
checkFreshEpochKeyAgainstPriorKeys(kE, priorKnownKeys);
|
|
1873
|
+
// Live desired-recipient state (H) + the effective pin manifest.
|
|
1874
|
+
const envelopeRecipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
|
|
1875
|
+
const desired = envelopeRecipients.desired;
|
|
1876
|
+
if (!desired) {
|
|
1877
|
+
fail("GITVAULT_DESIRED_STATE_UNAVAILABLE", "the gateway did not report desired-recipient state (desired[]) for this vault — an epoch rotation cannot compute its H-partition without it", "computing the epoch-rotation H-partition");
|
|
1878
|
+
}
|
|
1879
|
+
const pinManifest = await this.loadEffectivePinManifest(base.generation);
|
|
1880
|
+
// D196's H-partition: included / excluded_keyless / excluded_unconfirmed.
|
|
1881
|
+
// A `pending_removal` desired-recipient row is EXCLUDED from H entirely
|
|
1882
|
+
// (the gateway's own H is `status='active'` only) — that omission is
|
|
1883
|
+
// the entire forward-revocation point for reason:"member_removed".
|
|
1884
|
+
const included = [];
|
|
1885
|
+
const excludedKeyless = [];
|
|
1886
|
+
const excludedUnconfirmed = [];
|
|
1887
|
+
const pinBlocked = [];
|
|
1888
|
+
for (const d of desired) {
|
|
1889
|
+
if (d.status !== "active")
|
|
1890
|
+
continue;
|
|
1891
|
+
if (!d.ek_fingerprint || !d.public_key) {
|
|
1892
|
+
excludedKeyless.push(d.principal_id);
|
|
1893
|
+
continue;
|
|
1894
|
+
}
|
|
1895
|
+
const pinned = pinManifest.pinnedFingerprintOf.get(d.principal_id);
|
|
1896
|
+
if (pinned === undefined) {
|
|
1897
|
+
excludedUnconfirmed.push(d.principal_id);
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
if (pinned !== d.ek_fingerprint) {
|
|
1901
|
+
// A live pin exists but disagrees with the directory's current
|
|
1902
|
+
// fingerprint (the principal re-enrolled a key since their last
|
|
1903
|
+
// confirmation). Neither "included" (D196 requires the envelope
|
|
1904
|
+
// fingerprint to equal the PINNED one) nor "excluded_unconfirmed"
|
|
1905
|
+
// (D196/gateway validation requires pinnedFingerprintOf to LACK
|
|
1906
|
+
// them entirely) is a legal bucket for this principal under the
|
|
1907
|
+
// protocol as specified — the owner must run `/repin` first.
|
|
1908
|
+
pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
|
|
1909
|
+
continue;
|
|
1910
|
+
}
|
|
1911
|
+
let pub;
|
|
1912
|
+
try {
|
|
1913
|
+
pub = fromBase64url(d.public_key, "public_key");
|
|
1914
|
+
}
|
|
1915
|
+
catch {
|
|
1916
|
+
pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
|
|
1917
|
+
continue;
|
|
1918
|
+
}
|
|
1919
|
+
if (ekFingerprint(pub) !== d.ek_fingerprint) {
|
|
1920
|
+
pinBlocked.push({ principal_id: d.principal_id, directory_fingerprint: d.ek_fingerprint, pinned_fingerprint: pinned });
|
|
1921
|
+
continue;
|
|
1922
|
+
}
|
|
1923
|
+
included.push({ principal_id: d.principal_id, ek_fingerprint: d.ek_fingerprint, public_key: pub });
|
|
1924
|
+
}
|
|
1925
|
+
if (pinBlocked.length > 0) {
|
|
1926
|
+
fail("GITVAULT_ROTATION_BLOCKED_PIN_MISMATCH", `${pinBlocked.length} principal(s) have a live pin that disagrees with their current directory key and cannot be safely included or excluded under the protocol's own H-partition rules — run the /repin ceremony for each, then retry: ${pinBlocked.map((p) => p.principal_id).join(", ")}`, "computing the epoch-rotation H-partition", { blocked: pinBlocked });
|
|
1927
|
+
}
|
|
1928
|
+
if (options.reason === "elective_rekey" && (excludedKeyless.length > 0 || excludedUnconfirmed.length > 0)) {
|
|
1929
|
+
fail("EPOCH_ROTATION_INCOMPLETE_ENROLLMENT", `reason:"elective_rekey" refuses a target set that is a proper subset of the vault's current actual coverage — ${excludedKeyless.length} keyless, ${excludedUnconfirmed.length} unconfirmed`, "computing the epoch-rotation H-partition", { excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed });
|
|
1930
|
+
}
|
|
1931
|
+
if (included.length === 0) {
|
|
1932
|
+
fail("EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED", "every eligible principal is excluded — this reason would leave the vault with no recipient able to decrypt the new epoch", "computing the epoch-rotation H-partition");
|
|
1933
|
+
}
|
|
1934
|
+
// Self-consistency (defense in depth — the gateway's own recomputation under its live lock is authoritative).
|
|
1935
|
+
const desiredIds = new Set(desired.filter((d) => d.status === "active").map((d) => d.principal_id));
|
|
1936
|
+
const keyedIds = new Set(desired.filter((d) => d.status === "active" && d.ek_fingerprint).map((d) => d.principal_id));
|
|
1937
|
+
const hCheck = checkHPartition({ desiredPrincipalIds: desiredIds, keyedPrincipalIds: keyedIds, pinnedFingerprintOf: pinManifest.pinnedFingerprintOf, included, excludedKeylessPrincipalIds: excludedKeyless, excludedUnconfirmedPrincipalIds: excludedUnconfirmed });
|
|
1938
|
+
if (!hCheck.ok)
|
|
1939
|
+
fail("VALIDATION_FAILED", `internal: the H-partition this producer built is not a valid bijection over H (${hCheck.detail}) — refusing to submit a rotation the gateway would refuse`, "computing the epoch-rotation H-partition");
|
|
1940
|
+
const targetPartitionDigest = computeTargetPartitionDigest({
|
|
1941
|
+
recipient_state_version: options.recipient_state_version,
|
|
1942
|
+
recipient_revocation_version: options.recipient_revocation_version,
|
|
1943
|
+
pin_manifest_version: pinManifest.pinManifestVersion,
|
|
1944
|
+
pin_manifest_sha256: pinManifest.pinManifestSha256,
|
|
1945
|
+
included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
|
|
1946
|
+
excluded_keyless_principal_ids: excludedKeyless,
|
|
1947
|
+
excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
1948
|
+
});
|
|
1949
|
+
// D195: build + sign the rotation_attempt_descriptor, derive rotation_id, submit the create-only CAS BEFORE any envelope upload.
|
|
1950
|
+
const clientIdempotencyKey = options.client_idempotency_key ?? newHex32();
|
|
1951
|
+
const descriptorFields = {
|
|
1952
|
+
format: GITVAULT_FORMAT, object_kind: "rotation_attempt_descriptor", suite: GITVAULT_SUITE,
|
|
1953
|
+
repo_id: this.repoId, base_head_sha256: base.head_sha256, new_epoch: newEpoch,
|
|
1954
|
+
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
|
|
1955
|
+
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
|
|
1956
|
+
client_idempotency_key: clientIdempotencyKey, writer_key_id: this.writerKeyId(),
|
|
1957
|
+
};
|
|
1958
|
+
const attemptCommitment = attemptKeyCommitment(kE, this.repoId, newEpoch, descriptorFields);
|
|
1959
|
+
const signedDescriptor = signGitvaultObject({ ...descriptorFields, attempt_key_commitment: attemptCommitment }, this.signer());
|
|
1960
|
+
const rotationId = computeRotationId(signedDescriptor);
|
|
1961
|
+
const attemptOut = await this.transport.createRotationAttempt({ repo_id: this.repoId, descriptor: signedDescriptor });
|
|
1962
|
+
if (attemptOut.rotation_id !== rotationId) {
|
|
1963
|
+
fail("ROTATION_ID_MISMATCH", "the gateway's own re-derived rotation_id disagrees with this client's derivation — refusing to proceed", "creating a rotation attempt", { client_derived: rotationId, gateway_derived: attemptOut.rotation_id });
|
|
1964
|
+
}
|
|
1965
|
+
// Seal one key_envelope per included recipient, all from the SAME kE (D200's global one-key property depends on this).
|
|
1966
|
+
const sealedReceipts = [];
|
|
1967
|
+
const envelopeUploads = [];
|
|
1968
|
+
const nowIso = formatGitvaultTimestamp(this.now());
|
|
1969
|
+
for (const p of included) {
|
|
1970
|
+
const sealed = await sealKeyEnvelope({ k_repo: kE, repo_id: this.repoId, epoch: newEpoch, recipient_public_key: p.public_key, signer: this.signingKeypair(), created_at: nowIso, rotation_id: rotationId });
|
|
1971
|
+
sealedReceipts.push({ principal_id: p.principal_id, envelope: sealed.receipt });
|
|
1972
|
+
envelopeUploads.push({ path: gitvaultPaths.envelope(newEpoch, p.ek_fingerprint, rotationId), object_kind: "key_envelope", object_id: null, bytes: sealed.stored_bytes, sha256: sealed.stored_bytes_sha256, size_bytes: sealed.size_bytes });
|
|
1973
|
+
}
|
|
1974
|
+
if (envelopeUploads.length > 0)
|
|
1975
|
+
await this.uploadAll(envelopeUploads);
|
|
1976
|
+
sealedReceipts.sort((a, b) => (a.principal_id < b.principal_id ? -1 : a.principal_id > b.principal_id ? 1 : 0));
|
|
1977
|
+
const epochKeyCommitmentValue = epochRotationKeyCommitment(kE, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
|
|
1978
|
+
const payload = {
|
|
1979
|
+
new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
|
|
1980
|
+
recipient_state_version: options.recipient_state_version, recipient_revocation_version: options.recipient_revocation_version,
|
|
1981
|
+
pin_manifest_sha256: pinManifest.pinManifestSha256, target_partition_digest: targetPartitionDigest,
|
|
1982
|
+
epoch_key_commitment: epochKeyCommitmentValue, excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
1983
|
+
recipient_authority_attestation: null, envelopes: sealedReceipts,
|
|
1984
|
+
};
|
|
1985
|
+
const payloadBytes = jcs(payload);
|
|
1986
|
+
const transition = { kind: "rotate_epoch", payload_format: "base64url-jcs", payload: toBase64url(payloadBytes), payload_sha256: sha256Hex(payloadBytes) };
|
|
1987
|
+
// The manifest-publish deadlock fix: fold receipted pending
|
|
1988
|
+
// confirmations into THIS SAME head's `pin_manifest` field (schema-
|
|
1989
|
+
// legal, §4.3 — `transition` and `pin_manifest` are independent
|
|
1990
|
+
// optional fields on one `head`). Built from `pinManifest`, the
|
|
1991
|
+
// PREDECESSOR already loaded above for the H-partition — NEVER used
|
|
1992
|
+
// to recompute `included`/`excludedUnconfirmed` above, which is what
|
|
1993
|
+
// keeps this conformant with D196 ("a manifest update riding the SAME
|
|
1994
|
+
// head never self-authorizes its own recipients"). This durably
|
|
1995
|
+
// publishes the receipts (unblocking every future ordinary admission
|
|
1996
|
+
// once this rotation clears the urgent/migration condition) without
|
|
1997
|
+
// claiming these principals are covered by THIS rotation's envelopes.
|
|
1998
|
+
const pendingConfirmations = options.pending_confirmations ?? [];
|
|
1999
|
+
const pinManifestFold = pendingConfirmations.length > 0
|
|
2000
|
+
? this.buildPinManifestUpdate(pinManifest, pendingConfirmations.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint, confirmed_by: "operator_confirmation", receipt: p.receipt })))
|
|
2001
|
+
: null;
|
|
2002
|
+
// The new epoch's ref_state/retention_roots — CARRIED FORWARD unchanged, sealed under kE.
|
|
2003
|
+
const generation = nextGeneration(base.generation);
|
|
2004
|
+
const refState = this.buildRefState(generation, base.refs, base.head_target, { k_repo: kE, epoch: newEpoch });
|
|
2005
|
+
const rootsObj = this.buildRetentionRoots(generation, base.roots, null, { k_repo: kE, epoch: newEpoch });
|
|
2006
|
+
const uploads = [refState.upload, rootsObj.upload];
|
|
2007
|
+
if (pinManifestFold)
|
|
2008
|
+
uploads.push(pinManifestFold.upload);
|
|
2009
|
+
await this.uploadAll(uploads);
|
|
2010
|
+
const head = this.signHead({
|
|
2011
|
+
generation, prev_sha256: base.head_sha256, wal_entries: [],
|
|
2012
|
+
ref_state: { object_id: refState.object.object_id, object_kind: "ref_state", ciphertext_sha256: refState.upload.sha256, size_bytes: refState.upload.size_bytes },
|
|
2013
|
+
retention_roots: { object_id: rootsObj.object.object_id, object_kind: "retention_roots", ciphertext_sha256: rootsObj.upload.sha256, size_bytes: rootsObj.upload.size_bytes },
|
|
2014
|
+
checkpoint: null, checkpoint_purpose: null, capture_binding: null, repair: null,
|
|
2015
|
+
}, {
|
|
2016
|
+
epoch: newEpoch, transition,
|
|
2017
|
+
...(pinManifestFold ? { pin_manifest: { object_kind: "recipient_pin_manifest", pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, size_bytes: pinManifestFold.upload.size_bytes } } : {}),
|
|
2018
|
+
});
|
|
2019
|
+
const admitted = await this.admit(head);
|
|
2020
|
+
if (admitted.outcome === "conflict") {
|
|
2021
|
+
conflicts += 1;
|
|
2022
|
+
if (conflicts > this.retries)
|
|
2023
|
+
fail("HEAD_CAS_CONFLICT", `the rotation lost ${conflicts} races at generation ${generation}; giving up`, "admitting a rotate_epoch head", { generation, winner: admitted.winner });
|
|
2024
|
+
continue;
|
|
2025
|
+
}
|
|
2026
|
+
// D200's post-commit self-check: verify THIS principal's own envelope
|
|
2027
|
+
// (when it is itself a recipient) opens to exactly the committed K_e
|
|
2028
|
+
// and reproduces epoch_key_commitment — a per-recipient proof only,
|
|
2029
|
+
// never a global set-coherence claim.
|
|
2030
|
+
let selfCheck = "not_a_recipient";
|
|
2031
|
+
const identity = this.keystore.readIdentity();
|
|
2032
|
+
const ownKeypair = identity ? this.keystore.encryptionKeypair(identity) : null;
|
|
2033
|
+
if (identity && ownKeypair) {
|
|
2034
|
+
const ownFingerprint = ekFingerprint(ownKeypair.public_key);
|
|
2035
|
+
const own = included.find((p) => p.ek_fingerprint === ownFingerprint);
|
|
2036
|
+
const ownPair = own ? sealedReceipts.find((r) => r.principal_id === own.principal_id) : undefined;
|
|
2037
|
+
if (own && ownPair) {
|
|
2038
|
+
const envelopeBytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.envelope(newEpoch, own.ek_fingerprint, rotationId) });
|
|
2039
|
+
if (!envelopeBytes || sha256Hex(envelopeBytes) !== ownPair.envelope.stored_bytes_sha256) {
|
|
2040
|
+
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 });
|
|
2041
|
+
}
|
|
2042
|
+
const envelopeObj = parseGitvaultStrict(new TextDecoder().decode(envelopeBytes));
|
|
2043
|
+
// Verification key: this producer's OWN signing key — only the
|
|
2044
|
+
// vault's single registered writer key can sign a rotate_epoch
|
|
2045
|
+
// head/descriptor at all (v0 single-writer model), so a rotation
|
|
2046
|
+
// this call itself drove was necessarily signed by `this.signingKeypair()`.
|
|
2047
|
+
const recoveredKe = await openKeyEnvelope({ envelope: envelopeObj, recipient: ownKeypair, signer_public_key: this.signingKeypair().public_key });
|
|
2048
|
+
if (bytesToHex(recoveredKe) !== bytesToHex(kE)) {
|
|
2049
|
+
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");
|
|
2050
|
+
}
|
|
2051
|
+
const recomputed = epochRotationKeyCommitment(recoveredKe, this.repoId, newEpoch, rotationId, included.map((p) => p.ek_fingerprint));
|
|
2052
|
+
if (recomputed !== epochKeyCommitmentValue) {
|
|
2053
|
+
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");
|
|
2054
|
+
}
|
|
2055
|
+
selfCheck = "passed";
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
// Advance the local pointer only after the self-check (when applicable) confirms this principal genuinely holds the committed K_e.
|
|
2059
|
+
this.keystore.recordEpochRotation(this.repoId, { new_epoch: newEpoch, new_k_repo_hex: bytesToHex(kE) });
|
|
2060
|
+
// Same local-cache short-circuit as publishPinManifestUpdate's own
|
|
2061
|
+
// success path (see readPinManifestObject's doc comment) — the fold
|
|
2062
|
+
// built and admitted its OWN recipient_pin_manifest on this SAME head.
|
|
2063
|
+
if (pinManifestFold) {
|
|
2064
|
+
this.keystore.updateRepo(this.repoId, { known_pin_manifest: { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, pins: pinManifestFold.pins } });
|
|
2065
|
+
}
|
|
2066
|
+
return {
|
|
2067
|
+
outcome: "admitted", generation, head_sha256: admitted.head_sha256, new_epoch: newEpoch, rotation_id: rotationId, reason: options.reason,
|
|
2068
|
+
included: included.map((p) => ({ principal_id: p.principal_id, ek_fingerprint: p.ek_fingerprint })),
|
|
2069
|
+
excluded_keyless_principal_ids: excludedKeyless, excluded_unconfirmed_principal_ids: excludedUnconfirmed,
|
|
2070
|
+
admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, self_check: selfCheck,
|
|
2071
|
+
pin_manifest_published: pinManifestFold ? { pin_manifest_version: pinManifestFold.nextVersion, stored_bytes_sha256: pinManifestFold.manifestSha, principal_ids: pendingConfirmations.map((p) => p.principal_id) } : null,
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* The ONE fully self-contained rotation entry point: declares
|
|
2077
|
+
* `reason:"recipient_key_revoked"` (owner + step-up) for `principal_id`,
|
|
2078
|
+
* takes the D194 counters straight off that call's OWN response, and
|
|
2079
|
+
* drives the rotation with them — no external counter source needed.
|
|
2080
|
+
*/
|
|
2081
|
+
async rotateEpochForKeyRevocation(principalId, options = {}) {
|
|
2082
|
+
const counters = await this.transport.declareRecipientKeyRevoked({ repo_id: this.repoId, principal_id: principalId });
|
|
2083
|
+
return this.rotateEpoch({ reason: "recipient_key_revoked", recipient_state_version: counters.recipient_state_version, recipient_revocation_version: counters.recipient_revocation_version, client_idempotency_key: options.client_idempotency_key });
|
|
2084
|
+
}
|
|
1539
2085
|
// ── repair (owner-only; mandatory fresh checkpoint; repair resource lane server-side) ──
|
|
1540
2086
|
/**
|
|
1541
2087
|
* Publish a repair head over `base_generation` (§4.3): superseded tips that
|