|
| 1 | +// ru-fork: spec for the legacy-plaintext AUTOHEAL of ServerSecretStore. |
| 2 | +// |
| 3 | +// Problem: ru-fork #4 added at-rest encryption (`decryptSecret`) to the WHOLE secret store, but |
| 4 | +// `server-signing-key` predates it and shipped as RAW plaintext. On an upgrading machine `get()` now |
| 5 | +// throws "Invalid encrypted secret format" (the 3-part `iv:tag:cipher` split fails), and because |
| 6 | +// `getOrCreateRandom` calls `get()` first, it never reaches its regenerate path ⇒ the server can't boot. |
| 7 | +// |
| 8 | +// Fix: `get()` must treat an UNPARSEABLE / legacy blob as ABSENT (null) so the key self-heals |
| 9 | +// (regenerated encrypted; only sessions reset). BUT it must do so ONLY for the *format* failure — a |
| 10 | +// VALID-format blob that fails GCM authentication (tampered / wrong host-key / a real MCP credential |
| 11 | +// that can't be unwrapped) must STILL error, never silently vanish. |
| 12 | +// |
| 13 | +// Test map: |
| 14 | +// #1, #2 RED now → GREEN after the fix (legacy plaintext ⇒ null / autoheal) |
| 15 | +// #3 GREEN now and after (proper round-trip still works — no regression) |
| 16 | +// #4 GREEN now; stays GREEN ONLY for the SCOPED fix; a blanket `orElseSucceed(null)` turns it |
| 17 | +// RED — this is the guardrail that forces "swallow the format error, not the GCM error." |
| 18 | + |
| 19 | +import { join } from "node:path"; |
| 20 | + |
| 21 | +import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 22 | +import { expect, it } from "@effect/vitest"; |
| 23 | +import * as Effect from "effect/Effect"; |
| 24 | +import * as FileSystem from "effect/FileSystem"; |
| 25 | +import * as Layer from "effect/Layer"; |
| 26 | + |
| 27 | +import { ServerConfig } from "../../../src/config.ts"; |
| 28 | +import { encryptSecret } from "../../../src/auth/secretCrypto.ts"; |
| 29 | +import { SecretStoreError, ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts"; |
| 30 | +import { ServerSecretStoreLive } from "../../../src/auth/Layers/ServerSecretStore.ts"; |
| 31 | + |
| 32 | +// ServerConfig is provideMerge'd so the test body can read `secretsDir` (to plant a raw .bin file) AND |
| 33 | +// resolve the SAME store — a fresh `{prefix}` temp dir is minted once and shared by both. |
| 34 | +const makeSharedLayer = () => |
| 35 | + ServerSecretStoreLive.pipe( |
| 36 | + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-autoheal-test-" })), |
| 37 | + ); |
| 38 | + |
| 39 | +// A pre-encryption key on disk: raw bytes with NO 0x3a (":"), so the `iv:tag:cipher` split yields one |
| 40 | +// part ⇒ `decryptSecret` throws the FORMAT error (not a GCM error). Mirrors a real legacy signing key. |
| 41 | +const LEGACY_PLAINTEXT_KEY = new Uint8Array(32).fill(7); |
| 42 | + |
| 43 | +// A VALID-format blob whose ciphertext has been flipped ⇒ GCM authentication fails at decrypt time. |
| 44 | +// Stands in for a tampered file / wrong-host key / an MCP credential that genuinely can't be unwrapped. |
| 45 | +function makeTamperedEncryptedBlob(): Uint8Array { |
| 46 | + const text = new TextDecoder().decode(encryptSecret(Uint8Array.from([9, 8, 7, 6, 5]))); |
| 47 | + const [ivHex, tagHex, cipherHex] = text.split(":"); |
| 48 | + const lastChar = cipherHex!.at(-1); |
| 49 | + const flippedCipher = cipherHex!.slice(0, -1) + (lastChar === "a" ? "b" : "a"); |
| 50 | + return new TextEncoder().encode(`${ivHex}:${tagHex}:${flippedCipher}`); |
| 51 | +} |
| 52 | + |
| 53 | +const plantSecretFile = (name: string, bytes: Uint8Array) => |
| 54 | + Effect.gen(function* () { |
| 55 | + const config = yield* ServerConfig; |
| 56 | + const fileSystem = yield* FileSystem.FileSystem; |
| 57 | + yield* fileSystem.makeDirectory(config.secretsDir, { recursive: true }); |
| 58 | + yield* fileSystem.writeFile(join(config.secretsDir, `${name}.bin`), bytes); |
| 59 | + }); |
| 60 | + |
| 61 | +it.layer(NodeServices.layer)("ServerSecretStore autoheal (legacy plaintext)", (it) => { |
| 62 | + it.effect("#1 RED→GREEN: get() returns null for a legacy plaintext (unparseable) secret file", () => |
| 63 | + Effect.gen(function* () { |
| 64 | + yield* plantSecretFile("server-signing-key", LEGACY_PLAINTEXT_KEY); |
| 65 | + const secretStore = yield* ServerSecretStore; |
| 66 | + |
| 67 | + // Today this THROWS SecretStoreError("Invalid encrypted secret format") ⇒ test fails. |
| 68 | + // After the fix it must read the legacy blob as "absent". |
| 69 | + const result = yield* secretStore.get("server-signing-key"); |
| 70 | + expect(result).toBeNull(); |
| 71 | + }).pipe(Effect.provide(makeSharedLayer())), |
| 72 | + ); |
| 73 | + |
| 74 | + it.effect("#2 RED→GREEN: getOrCreateRandom self-heals over a legacy plaintext signing key", () => |
| 75 | + Effect.gen(function* () { |
| 76 | + yield* plantSecretFile("server-signing-key", LEGACY_PLAINTEXT_KEY); |
| 77 | + const secretStore = yield* ServerSecretStore; |
| 78 | + |
| 79 | + // Must succeed by minting a FRESH encrypted key (not crash on the legacy blob). |
| 80 | + const key = yield* secretStore.getOrCreateRandom("server-signing-key", 32); |
| 81 | + expect(key.length).toBe(32); |
| 82 | + expect(Array.from(key)).not.toEqual(Array.from(LEGACY_PLAINTEXT_KEY)); // a new key, not the legacy bytes |
| 83 | + |
| 84 | + // The regenerated key is now persisted encrypted-at-rest and reads back identically + stably. |
| 85 | + const reread = yield* secretStore.get("server-signing-key"); |
| 86 | + expect(reread).not.toBeNull(); |
| 87 | + expect(Array.from(reread ?? new Uint8Array())).toEqual(Array.from(key)); |
| 88 | + const again = yield* secretStore.getOrCreateRandom("server-signing-key", 32); |
| 89 | + expect(Array.from(again)).toEqual(Array.from(key)); // healed once, then stable |
| 90 | + }).pipe(Effect.provide(makeSharedLayer())), |
| 91 | + ); |
| 92 | + |
| 93 | + it.effect("#3 GUARDRAIL (no regression): a properly encrypted secret still round-trips", () => |
| 94 | + Effect.gen(function* () { |
| 95 | + const secretStore = yield* ServerSecretStore; |
| 96 | + const value = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); |
| 97 | + |
| 98 | + yield* secretStore.set("mcp-credential-good", value); |
| 99 | + const roundTripped = yield* secretStore.get("mcp-credential-good"); |
| 100 | + |
| 101 | + expect(roundTripped).not.toBeNull(); |
| 102 | + expect(Array.from(roundTripped ?? new Uint8Array())).toEqual(Array.from(value)); |
| 103 | + }).pipe(Effect.provide(makeSharedLayer())), |
| 104 | + ); |
| 105 | + |
| 106 | + it.effect("#4 GUARDRAIL (scoped fix): a tampered/wrong-key (GCM auth) secret STILL errors, never null", () => |
| 107 | + Effect.gen(function* () { |
| 108 | + yield* plantSecretFile("mcp-credential", makeTamperedEncryptedBlob()); |
| 109 | + const secretStore = yield* ServerSecretStore; |
| 110 | + |
| 111 | + // Valid `iv:tag:cipher` shape but the GCM tag no longer matches ⇒ this is NOT the format error. |
| 112 | + // It must surface as a typed failure — a blanket swallow-to-null would silently drop a real |
| 113 | + // MCP credential, which this test forbids. |
| 114 | + const error = yield* Effect.flip(secretStore.get("mcp-credential")); |
| 115 | + expect(error).toBeInstanceOf(SecretStoreError); |
| 116 | + expect(error.message).toContain("Failed to decrypt secret mcp-credential"); |
| 117 | + }).pipe(Effect.provide(makeSharedLayer())), |
| 118 | + ); |
| 119 | +}); |
0 commit comments