Skip to content

Commit 359bbd2

Browse files
committed
feat(ru-code): fix security error on secure storage check
1 parent db5b929 commit 359bbd2

47 files changed

Lines changed: 699 additions & 13016 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ staging
3535
.vscode
3636
dist-bundle
3737
ru-fork-instrumental
38-
preflight.mjs
38+
preflight.mjs
39+
project-specs

apps/server/CHANGELOG.md

Lines changed: 0 additions & 29 deletions
This file was deleted.

apps/server/src/auth/Layers/ServerSecretStore.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import * as Predicate from "effect/Predicate";
88
import * as PlatformError from "effect/PlatformError";
99

1010
import { ServerConfig } from "../../config.ts";
11-
import { decryptSecret, encryptSecret } from "../secretCrypto.ts";
11+
import { decryptSecret, encryptSecret, isLegacySecretFormatError } from "../secretCrypto.ts";
1212
import {
1313
SecretStoreError,
1414
ServerSecretStore,
@@ -48,16 +48,25 @@ export const makeServerSecretStore = Effect.gen(function* () {
4848
}),
4949
),
5050
),
51-
// ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null; a
52-
// tampered/unwrappable blob becomes a typed SecretStoreError (Effect.try converts the throw).
51+
// ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null. A
52+
// LEGACY plaintext blob (a pre-encryption signing key — fails the iv:tag:cipher format check) is
53+
// treated as ABSENT so getOrCreateRandom self-heals (mints a fresh encrypted key, no error/log).
54+
// Any OTHER decrypt failure (GCM auth: tampered / wrong host-key) stays a typed SecretStoreError,
55+
// so a real encrypted secret never silently vanishes.
5356
Effect.flatMap((bytes) =>
5457
bytes === null
5558
? Effect.succeed(null)
5659
: Effect.try({
5760
try: () => decryptSecret(Uint8Array.from(bytes)),
5861
catch: (cause) =>
5962
new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }),
60-
}),
63+
}).pipe(
64+
Effect.catch((error) =>
65+
isLegacySecretFormatError(error.cause)
66+
? Effect.succeed(null)
67+
: Effect.fail(error),
68+
),
69+
),
6170
),
6271
);
6372

@@ -127,11 +136,11 @@ export const makeServerSecretStore = Effect.gen(function* () {
127136
Effect.flatMap((created) =>
128137
created !== null
129138
? Effect.succeed(created)
130-
: Effect.fail(
131-
new SecretStoreError({
132-
message: `Failed to read secret ${name} after concurrent creation.`,
133-
}),
134-
),
139+
: // ru-fork #4: the blocking file is present but UNREADABLE (a legacy plaintext
140+
// secret — `get` healed it to null — not a concurrent writer's valid one), so the
141+
// exclusive `create` can never overwrite it. Replace it atomically via `set` so a
142+
// legacy signing key self-heals instead of stranding the boot.
143+
set(name, generated).pipe(Effect.as(Uint8Array.from(generated))),
135144
),
136145
)
137146
: Effect.fail(error),

apps/server/src/auth/secretCrypto.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,19 @@ export function encryptSecret(plaintext: Uint8Array): Uint8Array {
2323
return new TextEncoder().encode(blob);
2424
}
2525

26+
// ru-fork #4: a blob that isn't our `ivHex:authTagHex:cipherHex` shape — i.e. a LEGACY plaintext secret
27+
// written BEFORE at-rest encryption existed (e.g. a `server-signing-key` from an older build). Kept
28+
// distinct from a GCM auth failure (tampered / wrong host-key) so the store can self-heal a legacy key
29+
// by treating it as absent WITHOUT silently dropping a real encrypted secret that fails to authenticate.
30+
export const LEGACY_SECRET_FORMAT_ERROR = "Invalid encrypted secret format";
31+
32+
export const isLegacySecretFormatError = (cause: unknown): boolean =>
33+
cause instanceof Error && cause.message === LEGACY_SECRET_FORMAT_ERROR;
34+
2635
export function decryptSecret(blob: Uint8Array): Uint8Array {
2736
const parts = new TextDecoder().decode(blob).split(":");
2837
if (parts.length !== 3) {
29-
throw new Error("Invalid encrypted secret format");
38+
throw new Error(LEGACY_SECRET_FORMAT_ERROR);
3039
}
3140
const iv = Buffer.from(parts[0]!, "hex");
3241
const authTag = Buffer.from(parts[1]!, "hex");

apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -23,48 +23,63 @@ export const MCP_BUILTINS: ReadonlyArray<McpBuiltinDefinition> = [
2323
vars: [],
2424
},
2525
{
26-
builtinId: "context7",
27-
name: "context7",
28-
description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.",
29-
websiteUrl: "https://context7.com",
30-
config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } },
31-
vars: [],
32-
},
33-
{
34-
builtinId: "atlassian",
35-
name: "atlassian",
26+
builtinId: "context7_local",
27+
name: "Context 7 Local",
3628
description:
37-
"Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.",
38-
websiteUrl: "https://github.com/sooperset/mcp-atlassian",
29+
"Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.",
30+
websiteUrl: "https://context7.com",
3931
config: {
4032
default: {
4133
transport: "stdio",
42-
command: "uvx",
43-
args: ["mcp-atlassian"],
34+
command: "npx",
35+
args: ["-y", "@upstash/context7-mcp"],
4436
},
4537
},
46-
// All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the
47-
// user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a
48-
// real secret). Until the unfilled ones are set, the catalog server shows «требует настройки».
49-
vars: [
50-
{
51-
name: "JIRA_URL",
52-
secret: false,
53-
perProject: false,
54-
required: true,
55-
value: "https://your-company.atlassian.net/wiki",
56-
},
57-
{ name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null },
58-
{ name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null },
59-
{
60-
name: "CONFLUENCE_URL",
61-
secret: false,
62-
perProject: false,
63-
required: true,
64-
value: "https://your-company.atlassian.net/wiki",
65-
},
66-
{ name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null },
67-
{ name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null },
68-
],
38+
vars: [],
39+
},
40+
{
41+
builtinId: "context7_remote",
42+
name: "Context 7 Remote",
43+
description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.",
44+
websiteUrl: "https://context7.com",
45+
config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } },
46+
vars: [],
6947
},
48+
// {
49+
// builtinId: "atlassian",
50+
// name: "atlassian",
51+
// description:
52+
// "Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.",
53+
// websiteUrl: "https://github.com/sooperset/mcp-atlassian",
54+
// config: {
55+
// default: {
56+
// transport: "stdio",
57+
// command: "uvx",
58+
// args: ["mcp-atlassian"],
59+
// },
60+
// },
61+
// // All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the
62+
// // user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a
63+
// // real secret). Until the unfilled ones are set, the catalog server shows «требует настройки».
64+
// vars: [
65+
// {
66+
// name: "JIRA_URL",
67+
// secret: false,
68+
// perProject: false,
69+
// required: true,
70+
// value: "https://your-company.atlassian.net/wiki",
71+
// },
72+
// { name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null },
73+
// { name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null },
74+
// {
75+
// name: "CONFLUENCE_URL",
76+
// secret: false,
77+
// perProject: false,
78+
// required: true,
79+
// value: "https://your-company.atlassian.net/wiki",
80+
// },
81+
// { name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null },
82+
// { name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null },
83+
// ],
84+
// },
7085
];
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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

Comments
 (0)