From 0485126abd9cb13e27cf441923a7579a70656a44 Mon Sep 17 00:00:00 2001 From: Lior Date: Thu, 28 May 2026 06:53:36 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(B-0883):=20add=20determineEncryptionPa?= =?UTF-8?q?th=20discriminator=20=E2=80=94=20EncryptionContext=20=E2=86=92?= =?UTF-8?q?=20PlannedEncryptionPath=20Result-shape=20(structurally=20paral?= =?UTF-8?q?lel=20to=20PR=20#5758=20determineReviewLevel)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substantive encryption-lane work per Aaron's 3-lane substrate-check (Amara ferry §33.2 PR #5757) + standing PoC permission. Structurally parallel to workflow-engine's determineReviewLevel discriminator (PR #5758) at encryption-substrate scope. Adds: - PlannedEncryptionPath interface (algKem + algKdf + algWrap + algContent + algSig + recipientCount + senderIdentity + composesWith) - PlanResult discriminated union (ok: true with path | ok: false with feedback) - determineEncryptionPath(context): PlanResult function with v1 design memo policy: * Empty recipients → EmptyRecipientSet * Sender not in recipient set → SenderNotInRecipientSet * Mixed KEM algs across recipients → RecipientKeyInvalid (v1 single-KEM) * Unknown / deferred-alternate KEM → AlgUnsupported * Unknown / deferred-alternate signature → AlgUnsupported * Defaults: HKDF-SHA256 + ChaCha20-Poly1305-AEAD (wrap + content) Tests (9 new): - v1 path for single-recipient self-encrypt - v1 path for multi-recipient with sender included - EmptyRecipientSet for empty recipients - SenderNotInRecipientSet when sender absent - RecipientKeyInvalid for mixed KEM across recipients - AlgUnsupported for deferred-alternate KEM (Saber) - AlgUnsupported for unknown KEM - AlgUnsupported for unknown signature - Planned path composesWith B-0867.20 cross-lane substrate-engineering 31 tests pass / 0 fail. Composes with substrate: - B-0883 v1 design memo (algorithm selection per v1) - B-0867.20 PR #5758 (structurally parallel discriminator at workflow-engine scope) - B-0897 (Persist-as-bridge OPLE primitive — encryption IS Persist-as-bridge instance) - PR #5728 (workflow-engine PoC scaffold) - PR #5757 (Amara ferry substrate-check) - PR #5516 (asymmetric-authorship — function authors TFeedback via EncryptionFeedback) - PR #5511 (monad-propagation — Result shape) Per Aaron's 3-lane substrate-check ('so you finished the 3 lanes?'): NO, not finished. This is incremental progress on the encryption lane; better-git-crypt PoC now has a planning discriminator structurally parallel to what shipped for workflow-engine. Phase 2 actual Noble integration + KEM operations still deferred. Co-Authored-By: Claude Opus 4.7 --- tools/crypto/better-git-crypt/types.test.ts | 179 ++++++++++++++++++++ tools/crypto/better-git-crypt/types.ts | 171 +++++++++++++++++++ 2 files changed, 350 insertions(+) diff --git a/tools/crypto/better-git-crypt/types.test.ts b/tools/crypto/better-git-crypt/types.test.ts index 680e3ee9de..8df98b0872 100644 --- a/tools/crypto/better-git-crypt/types.test.ts +++ b/tools/crypto/better-git-crypt/types.test.ts @@ -9,6 +9,7 @@ import { describe, expect, it } from "bun:test"; import { ALG_REGISTRY, + determineEncryptionPath, findAlg, validateAlgRegistry, validateEnvelopeStructure, @@ -206,3 +207,181 @@ describe("B-0883 v1 encryption context validation", () => { expect(fb?.kind).toBe("RecipientKeyInvalid"); }); }); + +describe("B-0883 determineEncryptionPath discriminator", () => { + // Substantive workflow-engine-parallel substrate-engineering work per + // Aaron 3-lane substrate-check (Amara ferry §33.2 PR #5757) + standing + // PoC permission. Structurally parallel to PR #5758 determineReviewLevel + // at encryption-substrate scope. + const makeKey = (identity: string): RecipientKey => ({ + identity, + kemAlgId: "ML-KEM-768+X25519", + sigAlgId: "ML-DSA-65", + publicKemKey: new Uint8Array([1, 2, 3]), + publicSigKey: new Uint8Array([4, 5, 6]), + seedSource: "random-bytes", + composesWith: [], + }); + + it("plans v1 path for single-recipient self-encrypt scenario", () => { + const key = makeKey("solo@zeta"); + const ctx: EncryptionContext = { + plaintext: new Uint8Array([0, 1, 2]), + recipients: [key], + sender: key, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.path.algKem).toBe("ML-KEM-768+X25519"); + expect(result.path.algSig).toBe("ML-DSA-65"); + expect(result.path.algKdf).toBe("HKDF-SHA256"); + expect(result.path.algWrap).toBe("ChaCha20-Poly1305-AEAD"); + expect(result.path.algContent).toBe("ChaCha20-Poly1305-AEAD"); + expect(result.path.recipientCount).toBe(1); + expect(result.path.senderIdentity).toBe("solo@zeta"); + } + }); + + it("plans v1 path for multi-recipient with sender included", () => { + const sender = makeKey("sender@zeta"); + const r2 = makeKey("recipient2@zeta"); + const r3 = makeKey("recipient3@zeta"); + const ctx: EncryptionContext = { + plaintext: new Uint8Array([1, 2, 3, 4]), + recipients: [sender, r2, r3], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.path.recipientCount).toBe(3); + } + }); + + it("returns EmptyRecipientSet for empty recipients", () => { + const sender = makeKey("sender@zeta"); + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("EmptyRecipientSet"); + } + }); + + it("returns SenderNotInRecipientSet when sender absent from recipients", () => { + const sender = makeKey("absent@zeta"); + const other = makeKey("other@zeta"); + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [other], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("SenderNotInRecipientSet"); + if (result.feedback.kind === "SenderNotInRecipientSet") { + expect(result.feedback.senderIdentity).toBe("absent@zeta"); + } + } + }); + + it("returns RecipientKeyInvalid when KEM algs differ across recipients (v1 single-KEM constraint)", () => { + const sender = makeKey("sender@zeta"); + const mixedKem: RecipientKey = { + ...makeKey("mixed@zeta"), + kemAlgId: "Saber", // different from the sender's ML-KEM-768+X25519 + }; + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [sender, mixedKem], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("RecipientKeyInvalid"); + if (result.feedback.kind === "RecipientKeyInvalid") { + expect(result.feedback.identity).toBe("mixed@zeta"); + expect(result.feedback.reason).toMatch(/v1 requires single KEM/); + } + } + }); + + it("returns AlgUnsupported for deferred-alternate KEM (e.g. Saber)", () => { + const sender = { ...makeKey("sender@zeta"), kemAlgId: "Saber" }; + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [sender], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("AlgUnsupported"); + if (result.feedback.kind === "AlgUnsupported") { + expect(result.feedback.algId).toBe("Saber"); + } + } + }); + + it("returns AlgUnsupported for unknown KEM alg id", () => { + const sender = { ...makeKey("sender@zeta"), kemAlgId: "NONEXISTENT-KEM" }; + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [sender], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("AlgUnsupported"); + } + }); + + it("returns AlgUnsupported for unknown signature alg id", () => { + const sender = { ...makeKey("sender@zeta"), sigAlgId: "NONEXISTENT-SIG" }; + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [sender], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.feedback.kind).toBe("AlgUnsupported"); + if (result.feedback.kind === "AlgUnsupported") { + expect(result.feedback.algId).toBe("NONEXISTENT-SIG"); + } + } + }); + + it("planned path composesWith B-0867.20 (structurally parallel substrate-engineering)", () => { + const sender = makeKey("sender@zeta"); + const ctx: EncryptionContext = { + plaintext: new Uint8Array(0), + recipients: [sender], + sender, + seedSource: "random-bytes", + }; + const result = determineEncryptionPath(ctx); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.path.composesWith).toContain("B-0867.20"); + expect(result.path.composesWith).toContain("B-0883"); + } + }); +}); diff --git a/tools/crypto/better-git-crypt/types.ts b/tools/crypto/better-git-crypt/types.ts index 51ed5b2dc8..193ec2a49b 100644 --- a/tools/crypto/better-git-crypt/types.ts +++ b/tools/crypto/better-git-crypt/types.ts @@ -263,6 +263,177 @@ export function findAlg(id: string): AlgSpec | undefined { return ALG_REGISTRY.find((a) => a.id === id); } +/** + * B-0883 — determineEncryptionPath: substantive lane work per + * Aaron's 3-lane substrate-check (Amara ferry §33.2 PR #5757). + * + * Structurally parallel to workflow-engine's determineReviewLevel + * discriminator (PR #5758) at encryption-substrate scope: + * - Input: EncryptionContext (recipients + sender + seed source) + * - Output: Result + * - Path declares the KEM + KDF + WRAP + CONTENT + SIG algorithms + * to use for the operation, per the v1 design memo + * + * Per asymmetric-authorship rule (PR #5516): the function authors + * its own TFeedback channel via existing EncryptionFeedback variants. + * Per monad-propagation rule (PR #5511): Result shape. + * Per OPLE primitive rule (B-0897 Persist-as-bridge): encryption IS + * Persist-as-bridge instantiated at file-substrate scope. + * + * Composes with: + * - B-0867.20 determineReviewLevel discriminator (PR #5758) — same + * shape at different substrate scope + * - B-0883 v1 design memo (algorithm selection per v1) + * - validateEncryptionContext (called as precondition) + * + * PoC scope: pure-function selection of algorithms from ALG_REGISTRY + * based on context invariants. Actual crypto operations deferred to + * Phase 2. + */ + +/** + * Planned encryption path — declares which algorithms will be used + * for an encryption operation. All AlgSpec.id values; validated to + * exist in ALG_REGISTRY and have CipherClass matching the slot. + */ +export interface PlannedEncryptionPath { + readonly algKem: string; // CipherClass: kem + readonly algKdf: string; // CipherClass: kdf + readonly algWrap: string; // CipherClass: aead (for CEK wrap) + readonly algContent: string; // CipherClass: aead (for plaintext) + readonly algSig: string; // CipherClass: signature + readonly recipientCount: number; + readonly senderIdentity: string; + readonly composesWith: ReadonlyArray; +} + +/** + * Discriminator-shape Result for path-planning per monad-propagation rule. + * + * Aligned with the substrate's existing EncryptionFeedback discriminator + * so the path-planner composes cleanly with downstream encrypt operations + * via Result.bind chains. + */ +export type PlanResult = + | { ok: true; path: PlannedEncryptionPath } + | { ok: false; feedback: EncryptionFeedback }; + +/** + * `determineEncryptionPath` — discriminator that maps an EncryptionContext + * to its PlannedEncryptionPath, or returns the EncryptionFeedback variant + * naming why planning failed. + * + * Policy per v1 design memo: + * - Recipients' kemAlgIds must all reference the same KEM (no per-recipient + * KEM variation in v1 — single envelope KEM column per FileEnvelope.algKem) + * - Sender's sigAlgId determines envelope signature algorithm + * - KDF defaults to HKDF-SHA256 (only ships-v1 KDF in registry) + * - WRAP and CONTENT default to ChaCha20-Poly1305-AEAD (per design memo) + * - Empty recipient set → EmptyRecipientSet + * - Sender not in recipient set → SenderNotInRecipientSet + * - Algorithm not in registry → AlgUnsupported + * - Algorithm with non-ships-v1 status → AlgUnsupported (deferred-alternate + * algorithms can't ship encrypt operations until Phase 2) + * + * Exhaustive over the failure-mode space the v1 design memo names; future + * extensions to EncryptionFeedback union must update this function (TS + * strict mode enforces). + */ +export function determineEncryptionPath( + context: EncryptionContext, +): PlanResult { + // Empty recipient set: + if (context.recipients.length === 0) { + return { ok: false, feedback: { kind: "EmptyRecipientSet" } }; + } + + // Sender must be in recipient set (sender encrypts to self too for + // round-trip recovery, per design memo): + const senderInRecipients = context.recipients.some( + (r) => r.identity === context.sender.identity, + ); + if (!senderInRecipients) { + return { + ok: false, + feedback: { + kind: "SenderNotInRecipientSet", + senderIdentity: context.sender.identity, + }, + }; + } + + // All recipient KEM algs must be the same (single envelope KEM column in v1): + const firstKemId = context.recipients[0]?.kemAlgId; + if (!firstKemId) { + return { ok: false, feedback: { kind: "EmptyRecipientSet" } }; + } + const allSameKem = context.recipients.every( + (r) => r.kemAlgId === firstKemId, + ); + if (!allSameKem) { + // Use AlgUnsupported as the failure variant — v1 doesn't support + // per-recipient KEM variation; consumers see the first variant + // mismatched recipient names. + const mismatched = context.recipients.find( + (r) => r.kemAlgId !== firstKemId, + ); + return { + ok: false, + feedback: { + kind: "RecipientKeyInvalid", + identity: mismatched?.identity ?? "unknown", + reason: `v1 requires single KEM across recipients; expected ${firstKemId}`, + }, + }; + } + + // KEM alg must be ships-v1: + const kemAlg = findAlg(firstKemId); + if (!kemAlg || kemAlg.class !== "kem" || kemAlg.status !== "ships-v1") { + return { + ok: false, + feedback: { kind: "AlgUnsupported", algId: firstKemId }, + }; + } + + // Signature alg must be ships-v1: + const sigAlgId = context.sender.sigAlgId; + const sigAlg = findAlg(sigAlgId); + if (!sigAlg || sigAlg.class !== "signature" || sigAlg.status !== "ships-v1") { + return { + ok: false, + feedback: { kind: "AlgUnsupported", algId: sigAlgId }, + }; + } + + // KDF + WRAP + CONTENT defaults per v1 design memo: + const algKdf = "HKDF-SHA256"; + const algWrap = "ChaCha20-Poly1305-AEAD"; + const algContent = "ChaCha20-Poly1305-AEAD"; + + // Sanity-check the defaults exist (should always hold given seed registry): + for (const id of [algKdf, algWrap, algContent]) { + const alg = findAlg(id); + if (!alg || alg.status !== "ships-v1") { + return { ok: false, feedback: { kind: "AlgUnsupported", algId: id } }; + } + } + + return { + ok: true, + path: { + algKem: firstKemId, + algKdf, + algWrap, + algContent, + algSig: sigAlgId, + recipientCount: context.recipients.length, + senderIdentity: context.sender.identity, + composesWith: ["B-0883", "B-0883.1", "B-0867.20"], + }, + }; +} + /** * Validate algorithm registry invariants. * From 6439958ae7d91b18e201c24ea385e825a5cf14ba Mon Sep 17 00:00:00 2001 From: Lior Date: Thu, 28 May 2026 06:58:15 -0400 Subject: [PATCH 2/2] fix(B-0883): correct stale comment on mixed-KEM branch (Copilot catch on PR #5760) Comment incorrectly claimed 'Use AlgUnsupported as the failure variant' but the code returns RecipientKeyInvalid. Updated comment to accurately describe the chosen variant + reason: - The per-recipient KEM is itself well-formed and supported - The failure is v1's single-envelope-KEM-column constraint - RecipientKeyInvalid surfaces the specific mismatched identity + reason Per .claude/rules/blocked-green-ci-investigate-threads.md verify-before-fix discipline: Copilot finding verified via direct inspection of code lines 381-388; comment-vs-code contradiction confirmed real; substrate-honest fix. Tests still pass (31 / 0 fail). Co-Authored-By: Claude Opus 4.7 --- tools/crypto/better-git-crypt/types.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/crypto/better-git-crypt/types.ts b/tools/crypto/better-git-crypt/types.ts index 193ec2a49b..070781bdea 100644 --- a/tools/crypto/better-git-crypt/types.ts +++ b/tools/crypto/better-git-crypt/types.ts @@ -371,9 +371,11 @@ export function determineEncryptionPath( (r) => r.kemAlgId === firstKemId, ); if (!allSameKem) { - // Use AlgUnsupported as the failure variant — v1 doesn't support - // per-recipient KEM variation; consumers see the first variant - // mismatched recipient names. + // Use RecipientKeyInvalid (not AlgUnsupported) — the per-recipient + // KEM is itself well-formed and supported; the failure is that v1's + // single-envelope-KEM-column constraint requires all recipients use + // the SAME KEM. RecipientKeyInvalid surfaces the specific mismatched + // identity + the v1 constraint reason for the caller's handler. const mismatched = context.recipients.find( (r) => r.kemAlgId !== firstKemId, );