Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 179 additions & 0 deletions tools/crypto/better-git-crypt/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { describe, expect, it } from "bun:test";
import {
ALG_REGISTRY,
determineEncryptionPath,
findAlg,
validateAlgRegistry,
validateEnvelopeStructure,
Expand Down Expand Up @@ -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");
}
});
});
173 changes: 173 additions & 0 deletions tools/crypto/better-git-crypt/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,179 @@ 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<PlannedEncryptionPath, EncryptionFeedback>
* - 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<T, TFeedback> 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<string>;
}

/**
* 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 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,
);
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.
*
Expand Down
Loading