run402 4.105.6 → 4.106.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.
@@ -2967,97 +2967,127 @@ export class Vault {
2967
2967
  skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "pinned_key_mismatch", details: { pinned_fingerprint: pinned, directory_fingerprint: entry.ek_fingerprint } });
2968
2968
  continue;
2969
2969
  }
2970
- if (typeof entry.public_key !== "string" || entry.public_key.length === 0) {
2971
- skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "missing_public_key" });
2970
+ const wrapOutcome = await this.#wrapEnvelopeRecipient(entry, epoch, signer);
2971
+ if (wrapOutcome.outcome === "skipped") {
2972
+ skipped.push(wrapOutcome.skipped);
2972
2973
  continue;
2973
2974
  }
2974
- let recipientPublicKey;
2975
- try {
2976
- recipientPublicKey = fromBase64url(entry.public_key, "public_key");
2977
- }
2978
- catch {
2979
- skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "invalid_public_key" });
2980
- continue;
2981
- }
2982
- // Defense in depth: a directory row whose printed fingerprint does not
2983
- // derive from its own public_key is corrupt data, not a valid
2984
- // recipient — fail loud here rather than let `sealKeyEnvelope` (which
2985
- // derives the fingerprint itself and never trusts a caller-supplied
2986
- // one) silently seal under a fingerprint that disagrees with the one
2987
- // the directory printed.
2988
- const derived = ekFingerprint(recipientPublicKey);
2989
- if (derived !== entry.ek_fingerprint) {
2990
- skipped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "invalid_public_key", details: { derived_fingerprint: derived } });
2991
- continue;
2992
- }
2993
- const sealed = await sealKeyEnvelope({
2994
- k_repo: this.kRepo(),
2975
+ if (wrapOutcome.outcome === "wrapped")
2976
+ wrapped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint });
2977
+ else
2978
+ alreadyCovered.push(entry.ek_fingerprint);
2979
+ pins[entry.principal_id] = entry.ek_fingerprint;
2980
+ pinsChanged = true;
2981
+ }
2982
+ if (pinsChanged)
2983
+ this.keystore.updateRepo(this.repoId, { envelope_recipient_pins: pins });
2984
+ return { repo_id: this.repoId, org_id: repo.org_id, epoch, wrapped, already_covered: alreadyCovered, skipped };
2985
+ }
2986
+ /**
2987
+ * Seal and store ONE recipient's `key_envelope` for the current epoch:
2988
+ * the per-recipient step of {@link reconcileEnvelopeRecipients}, also used
2989
+ * by {@link wrapEnvelopeForRecipient}. A recipient whose public key is
2990
+ * missing or does not derive its printed fingerprint is skipped, never
2991
+ * sealed; a concurrent wrap of the same recipient counts as covered only
2992
+ * once the stored winner verifies.
2993
+ */
2994
+ async #wrapEnvelopeRecipient(entry, epoch, signer) {
2995
+ if (typeof entry.public_key !== "string" || entry.public_key.length === 0) {
2996
+ return { outcome: "skipped", skipped: { principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "missing_public_key" } };
2997
+ }
2998
+ let recipientPublicKey;
2999
+ try {
3000
+ recipientPublicKey = fromBase64url(entry.public_key, "public_key");
3001
+ }
3002
+ catch {
3003
+ return { outcome: "skipped", skipped: { principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "invalid_public_key" } };
3004
+ }
3005
+ // Defense in depth: a directory row whose printed fingerprint does not
3006
+ // derive from its own public_key is corrupt data, not a valid
3007
+ // recipient — fail loud here rather than let `sealKeyEnvelope` (which
3008
+ // derives the fingerprint itself and never trusts a caller-supplied
3009
+ // one) silently seal under a fingerprint that disagrees with the one
3010
+ // the directory printed.
3011
+ const derived = ekFingerprint(recipientPublicKey);
3012
+ if (derived !== entry.ek_fingerprint) {
3013
+ return { outcome: "skipped", skipped: { principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint, reason: "invalid_public_key", details: { derived_fingerprint: derived } } };
3014
+ }
3015
+ const sealed = await sealKeyEnvelope({
3016
+ k_repo: this.kRepo(),
3017
+ repo_id: this.repoId,
3018
+ epoch,
3019
+ recipient_public_key: recipientPublicKey,
3020
+ signer,
3021
+ created_at: formatVaultTimestamp(this.now()),
3022
+ });
3023
+ try {
3024
+ const byo = await this.resolveByoWriteTarget();
3025
+ await this.transport.putObject({
2995
3026
  repo_id: this.repoId,
2996
- epoch,
2997
- recipient_public_key: recipientPublicKey,
2998
- signer,
2999
- created_at: formatVaultTimestamp(this.now()),
3027
+ path: vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint),
3028
+ bytes: sealed.stored_bytes,
3029
+ expected_sha256: sealed.stored_bytes_sha256,
3030
+ expected_size_bytes: sealed.size_bytes,
3031
+ ...(byo ? { byo } : {}),
3000
3032
  });
3001
- try {
3002
- const byo = await this.resolveByoWriteTarget();
3003
- await this.transport.putObject({
3004
- repo_id: this.repoId,
3005
- path: vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint),
3006
- bytes: sealed.stored_bytes,
3007
- expected_sha256: sealed.stored_bytes_sha256,
3008
- expected_size_bytes: sealed.size_bytes,
3009
- ...(byo ? { byo } : {}),
3010
- });
3011
- wrapped.push({ principal_id: entry.principal_id, ek_fingerprint: entry.ek_fingerprint });
3012
- }
3013
- catch (e) {
3014
- // A concurrent reconcile (another machine/session) may have wrapped
3015
- // the SAME recipient first — HPKE seal is randomized, so two valid
3016
- // wraps of the same K_repo to the same recipient produce DIFFERENT
3017
- // ciphertext bytes, and the create-only path's read-and-compare
3018
- // reports that as `VAULT_OBJECT_EXISTS_DIFFERENT` even though the
3019
- // recipient is now genuinely covered. Treat exactly that code as a
3020
- // benign race, not a failure; anything else propagates.
3021
- if (isRun402Error(e) && e.code === "VAULT_OBJECT_EXISTS_DIFFERENT") {
3022
- // vault-agent-envelopes (consult 7.6): a benign race is only
3023
- // benign once the WINNING envelope has been read back and verified
3024
- // — same repo, same epoch, this recipient, signed by the vault's
3025
- // registered writer. Anything else is recorded as skipped, never as
3026
- // coverage, and never pinned.
3027
- const winnerPath = vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint);
3028
- const winnerBytes = await this.transport.getObject({ repo_id: this.repoId, path: winnerPath }).catch(() => null);
3029
- let winnerOk = false;
3030
- if (winnerBytes) {
3031
- try {
3032
- const winner = parseVaultStrict(new TextDecoder().decode(winnerBytes));
3033
- const g = (await this.genesis()).genesis;
3034
- winnerOk = winner.repo_id === this.repoId && winner.epoch === epoch && winner.recipient_fingerprint === entry.ek_fingerprint
3035
- && winner.created_by === g.writer_key_id
3036
- && verifyVaultObject(winner, g.creator_signing_pubkey);
3037
- }
3038
- catch {
3039
- winnerOk = false;
3040
- }
3033
+ return { outcome: "wrapped" };
3034
+ }
3035
+ catch (e) {
3036
+ // A concurrent reconcile (another machine/session) may have wrapped
3037
+ // the SAME recipient first — HPKE seal is randomized, so two valid
3038
+ // wraps of the same K_repo to the same recipient produce DIFFERENT
3039
+ // ciphertext bytes, and the create-only path's read-and-compare
3040
+ // reports that as `VAULT_OBJECT_EXISTS_DIFFERENT` even though the
3041
+ // recipient is now genuinely covered. Treat exactly that code as a
3042
+ // benign race, not a failure; anything else propagates.
3043
+ if (isRun402Error(e) && e.code === "VAULT_OBJECT_EXISTS_DIFFERENT") {
3044
+ // vault-agent-envelopes (consult 7.6): a benign race is only
3045
+ // benign once the WINNING envelope has been read back and verified
3046
+ // — same repo, same epoch, this recipient, signed by the vault's
3047
+ // registered writer. Anything else is recorded as skipped, never as
3048
+ // coverage, and never pinned.
3049
+ const winnerPath = vaultPaths.envelope(epoch, sealed.receipt.recipient_fingerprint);
3050
+ const winnerBytes = await this.transport.getObject({ repo_id: this.repoId, path: winnerPath }).catch(() => null);
3051
+ let winnerOk = false;
3052
+ if (winnerBytes) {
3053
+ try {
3054
+ const winner = parseVaultStrict(new TextDecoder().decode(winnerBytes));
3055
+ const g = (await this.genesis()).genesis;
3056
+ winnerOk = winner.repo_id === this.repoId && winner.epoch === epoch && winner.recipient_fingerprint === entry.ek_fingerprint
3057
+ && winner.created_by === g.writer_key_id
3058
+ && verifyVaultObject(winner, g.creator_signing_pubkey);
3041
3059
  }
3042
- if (!winnerOk) {
3043
- // Consult round 2 §7: a conflicting IMMUTABLE object whose stored
3044
- // winner does not verify is evidence of tampering, equivocation,
3045
- // or broken object identity — categorically different from a
3046
- // stale recipient. FATAL, never an ordinary skip.
3047
- fail("VAULT_ENVELOPE_ALTERED", `a conflicting key_envelope already stored at ${winnerPath} does not verify as this vault's writer-signed envelope for this recipient — refusing to treat the conflict as a benign race`, "reconciling vault envelope recipients", { repo_id: this.repoId, path: winnerPath, epoch, recipient_fingerprint: entry.ek_fingerprint });
3060
+ catch {
3061
+ winnerOk = false;
3048
3062
  }
3049
- alreadyCovered.push(entry.ek_fingerprint);
3050
3063
  }
3051
- else {
3052
- throw e;
3064
+ if (!winnerOk) {
3065
+ // Consult round 2 §7: a conflicting IMMUTABLE object whose stored
3066
+ // winner does not verify is evidence of tampering, equivocation,
3067
+ // or broken object identity — categorically different from a
3068
+ // stale recipient. FATAL, never an ordinary skip.
3069
+ fail("VAULT_ENVELOPE_ALTERED", `a conflicting key_envelope already stored at ${winnerPath} does not verify as this vault's writer-signed envelope for this recipient — refusing to treat the conflict as a benign race`, "reconciling vault envelope recipients", { repo_id: this.repoId, path: winnerPath, epoch, recipient_fingerprint: entry.ek_fingerprint });
3053
3070
  }
3071
+ return { outcome: "already_covered" };
3072
+ }
3073
+ else {
3074
+ throw e;
3054
3075
  }
3055
- pins[entry.principal_id] = entry.ek_fingerprint;
3056
- pinsChanged = true;
3057
3076
  }
3058
- if (pinsChanged)
3059
- this.keystore.updateRepo(this.repoId, { envelope_recipient_pins: pins });
3060
- return { repo_id: this.repoId, org_id: repo.org_id, epoch, wrapped, already_covered: alreadyCovered, skipped };
3077
+ }
3078
+ /**
3079
+ * Wrap the current epoch's key for ONE named recipient that is not in the
3080
+ * vault organization's key directory: the nominated recipient of a pending
3081
+ * project transfer (project-transfer-vault-handover). The caller passes the
3082
+ * recipient's public keys as the transfer read reports them; the gateway
3083
+ * accepts the envelope only for that transfer's nominee.
3084
+ */
3085
+ async wrapEnvelopeForRecipient(recipient) {
3086
+ const outcome = await this.#wrapEnvelopeRecipient(recipient, this.epoch(), this.signingKeypair());
3087
+ if (outcome.outcome === "skipped") {
3088
+ fail("VAULT_ENVELOPE_RECIPIENT_INVALID", `the transfer recipient's published key cannot be wrapped to (${outcome.skipped.reason})`, "wrapping the source key for the transfer recipient", { principal_id: recipient.principal_id, ek_fingerprint: recipient.ek_fingerprint });
3089
+ }
3090
+ return outcome;
3061
3091
  }
3062
3092
  // ── object building ──
3063
3093
  /**
@@ -4444,6 +4474,22 @@ export class Vault {
4444
4474
  }
4445
4475
  return this.rotateEpoch({ reason: "member_removed", recipient_state_version: state, recipient_revocation_version: revocation, client_idempotency_key: options.client_idempotency_key });
4446
4476
  }
4477
+ /**
4478
+ * The rotation that completes a retirement of a previous owner's writers
4479
+ * after a project transfer (`reason:"writer_key_revoked"`, owner + step-up):
4480
+ * the gateway already blocked the keys outside the vault's organization;
4481
+ * this removes exactly that set. Counters come off the envelope-recipients
4482
+ * read, as for a member removal.
4483
+ */
4484
+ async rotateEpochForWriterRetirement(options = {}) {
4485
+ const recipients = await this.transport.listEnvelopeRecipients({ repo_id: this.repoId });
4486
+ const state = recipients.recipient_state_version;
4487
+ const revocation = recipients.recipient_revocation_version;
4488
+ if (typeof state !== "string" || typeof revocation !== "string") {
4489
+ fail("VAULT_ROTATION_COUNTERS_UNAVAILABLE", "the gateway did not report the org's rotation counters on the envelope-recipients read — the retirement rotation cannot be fenced without them", "reading the rotation counters");
4490
+ }
4491
+ return this.rotateEpoch({ reason: "writer_key_revoked", recipient_state_version: state, recipient_revocation_version: revocation, client_idempotency_key: options.client_idempotency_key });
4492
+ }
4447
4493
  /**
4448
4494
  * The ONE fully self-contained rotation entry point: declares
4449
4495
  * `reason:"recipient_key_revoked"` (owner + step-up) for `principal_id`,