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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ staging
.vscode
dist-bundle
ru-fork-instrumental
preflight.mjs
preflight.mjs
project-specs
29 changes: 0 additions & 29 deletions apps/server/CHANGELOG.md

This file was deleted.

27 changes: 18 additions & 9 deletions apps/server/src/auth/Layers/ServerSecretStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import * as Predicate from "effect/Predicate";
import * as PlatformError from "effect/PlatformError";

import { ServerConfig } from "../../config.ts";
import { decryptSecret, encryptSecret } from "../secretCrypto.ts";
import { decryptSecret, encryptSecret, isLegacySecretFormatError } from "../secretCrypto.ts";
import {
SecretStoreError,
ServerSecretStore,
Expand Down Expand Up @@ -48,16 +48,25 @@ export const makeServerSecretStore = Effect.gen(function* () {
}),
),
),
// ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null; a
// tampered/unwrappable blob becomes a typed SecretStoreError (Effect.try converts the throw).
// ru-fork #4: at-rest decryption runs AFTER the read-error catch — a NotFound stays null. A
// LEGACY plaintext blob (a pre-encryption signing key — fails the iv:tag:cipher format check) is
// treated as ABSENT so getOrCreateRandom self-heals (mints a fresh encrypted key, no error/log).
// Any OTHER decrypt failure (GCM auth: tampered / wrong host-key) stays a typed SecretStoreError,
// so a real encrypted secret never silently vanishes.
Effect.flatMap((bytes) =>
bytes === null
? Effect.succeed(null)
: Effect.try({
try: () => decryptSecret(Uint8Array.from(bytes)),
catch: (cause) =>
new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }),
}),
}).pipe(
Effect.catch((error) =>
isLegacySecretFormatError(error.cause)
? Effect.succeed(null)
: Effect.fail(error),
),
),
),
);

Expand Down Expand Up @@ -127,11 +136,11 @@ export const makeServerSecretStore = Effect.gen(function* () {
Effect.flatMap((created) =>
created !== null
? Effect.succeed(created)
: Effect.fail(
new SecretStoreError({
message: `Failed to read secret ${name} after concurrent creation.`,
}),
),
: // ru-fork #4: the blocking file is present but UNREADABLE (a legacy plaintext
// secret — `get` healed it to null — not a concurrent writer's valid one), so the
// exclusive `create` can never overwrite it. Replace it atomically via `set` so a
// legacy signing key self-heals instead of stranding the boot.
set(name, generated).pipe(Effect.as(Uint8Array.from(generated))),
),
)
: Effect.fail(error),
Expand Down
11 changes: 10 additions & 1 deletion apps/server/src/auth/secretCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,19 @@ export function encryptSecret(plaintext: Uint8Array): Uint8Array {
return new TextEncoder().encode(blob);
}

// ru-fork #4: a blob that isn't our `ivHex:authTagHex:cipherHex` shape — i.e. a LEGACY plaintext secret
// written BEFORE at-rest encryption existed (e.g. a `server-signing-key` from an older build). Kept
// distinct from a GCM auth failure (tampered / wrong host-key) so the store can self-heal a legacy key
// by treating it as absent WITHOUT silently dropping a real encrypted secret that fails to authenticate.
export const LEGACY_SECRET_FORMAT_ERROR = "Invalid encrypted secret format";

export const isLegacySecretFormatError = (cause: unknown): boolean =>
cause instanceof Error && cause.message === LEGACY_SECRET_FORMAT_ERROR;

export function decryptSecret(blob: Uint8Array): Uint8Array {
const parts = new TextDecoder().decode(blob).split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted secret format");
throw new Error(LEGACY_SECRET_FORMAT_ERROR);
}
const iv = Buffer.from(parts[0]!, "hex");
const authTag = Buffer.from(parts[1]!, "hex");
Expand Down
89 changes: 52 additions & 37 deletions apps/server/src/ru-fork/mcp/mcpBuiltinDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,48 +23,63 @@ export const MCP_BUILTINS: ReadonlyArray<McpBuiltinDefinition> = [
vars: [],
},
{
builtinId: "context7",
name: "context7",
description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.",
websiteUrl: "https://context7.com",
config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } },
vars: [],
},
{
builtinId: "atlassian",
name: "atlassian",
builtinId: "context7_local",
name: "Context 7 Local",
description:
"Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.",
websiteUrl: "https://github.com/sooperset/mcp-atlassian",
"Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.",
websiteUrl: "https://context7.com",
config: {
default: {
transport: "stdio",
command: "uvx",
args: ["mcp-atlassian"],
command: "npx",
args: ["-y", "@upstash/context7-mcp"],
},
},
// All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the
// user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a
// real secret). Until the unfilled ones are set, the catalog server shows «требует настройки».
vars: [
{
name: "JIRA_URL",
secret: false,
perProject: false,
required: true,
value: "https://your-company.atlassian.net/wiki",
},
{ name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null },
{ name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null },
{
name: "CONFLUENCE_URL",
secret: false,
perProject: false,
required: true,
value: "https://your-company.atlassian.net/wiki",
},
{ name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null },
{ name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null },
],
vars: [],
},
{
builtinId: "context7_remote",
name: "Context 7 Remote",
description: "Актуальная документация библиотек и фреймворков. Публичный HTTP-эндпоинт.",
websiteUrl: "https://context7.com",
config: { default: { transport: "http", httpUrl: "https://mcp.context7.com/mcp", headers: {} } },
vars: [],
},
// {
// builtinId: "atlassian",
// name: "atlassian",
// description:
// "Доступ к Jira и Confluence: задачи, страницы, поиск. Укажите логины и API-токены в каталоге.",
// websiteUrl: "https://github.com/sooperset/mcp-atlassian",
// config: {
// default: {
// transport: "stdio",
// command: "uvx",
// args: ["mcp-atlassian"],
// },
// },
// // All catalog-level (perProject:false), all required. The two URLs ship a placeholder value (the
// // user edits them); the logins are unfilled; the API tokens are secrets (value:null — never ship a
// // real secret). Until the unfilled ones are set, the catalog server shows «требует настройки».
// vars: [
// {
// name: "JIRA_URL",
// secret: false,
// perProject: false,
// required: true,
// value: "https://your-company.atlassian.net/wiki",
// },
// { name: "JIRA_USERNAME", secret: false, perProject: false, required: true, value: null },
// { name: "JIRA_API_TOKEN", secret: true, perProject: false, required: true, value: null },
// {
// name: "CONFLUENCE_URL",
// secret: false,
// perProject: false,
// required: true,
// value: "https://your-company.atlassian.net/wiki",
// },
// { name: "CONFLUENCE_USERNAME", secret: false, perProject: false, required: true, value: null },
// { name: "CONFLUENCE_API_TOKEN", secret: true, perProject: false, required: true, value: null },
// ],
// },
];
119 changes: 119 additions & 0 deletions apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// ru-fork: spec for the legacy-plaintext AUTOHEAL of ServerSecretStore.
//
// Problem: ru-fork #4 added at-rest encryption (`decryptSecret`) to the WHOLE secret store, but
// `server-signing-key` predates it and shipped as RAW plaintext. On an upgrading machine `get()` now
// throws "Invalid encrypted secret format" (the 3-part `iv:tag:cipher` split fails), and because
// `getOrCreateRandom` calls `get()` first, it never reaches its regenerate path ⇒ the server can't boot.
//
// Fix: `get()` must treat an UNPARSEABLE / legacy blob as ABSENT (null) so the key self-heals
// (regenerated encrypted; only sessions reset). BUT it must do so ONLY for the *format* failure — a
// VALID-format blob that fails GCM authentication (tampered / wrong host-key / a real MCP credential
// that can't be unwrapped) must STILL error, never silently vanish.
//
// Test map:
// #1, #2 RED now → GREEN after the fix (legacy plaintext ⇒ null / autoheal)
// #3 GREEN now and after (proper round-trip still works — no regression)
// #4 GREEN now; stays GREEN ONLY for the SCOPED fix; a blanket `orElseSucceed(null)` turns it
// RED — this is the guardrail that forces "swallow the format error, not the GCM error."

import { join } from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
import { expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";

import { ServerConfig } from "../../../src/config.ts";
import { encryptSecret } from "../../../src/auth/secretCrypto.ts";
import { SecretStoreError, ServerSecretStore } from "../../../src/auth/Services/ServerSecretStore.ts";
import { ServerSecretStoreLive } from "../../../src/auth/Layers/ServerSecretStore.ts";

// ServerConfig is provideMerge'd so the test body can read `secretsDir` (to plant a raw .bin file) AND
// resolve the SAME store — a fresh `{prefix}` temp dir is minted once and shared by both.
const makeSharedLayer = () =>
ServerSecretStoreLive.pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-autoheal-test-" })),
);

// A pre-encryption key on disk: raw bytes with NO 0x3a (":"), so the `iv:tag:cipher` split yields one
// part ⇒ `decryptSecret` throws the FORMAT error (not a GCM error). Mirrors a real legacy signing key.
const LEGACY_PLAINTEXT_KEY = new Uint8Array(32).fill(7);

// A VALID-format blob whose ciphertext has been flipped ⇒ GCM authentication fails at decrypt time.
// Stands in for a tampered file / wrong-host key / an MCP credential that genuinely can't be unwrapped.
function makeTamperedEncryptedBlob(): Uint8Array {
const text = new TextDecoder().decode(encryptSecret(Uint8Array.from([9, 8, 7, 6, 5])));
const [ivHex, tagHex, cipherHex] = text.split(":");
const lastChar = cipherHex!.at(-1);
const flippedCipher = cipherHex!.slice(0, -1) + (lastChar === "a" ? "b" : "a");
return new TextEncoder().encode(`${ivHex}:${tagHex}:${flippedCipher}`);
}

const plantSecretFile = (name: string, bytes: Uint8Array) =>
Effect.gen(function* () {
const config = yield* ServerConfig;
const fileSystem = yield* FileSystem.FileSystem;
yield* fileSystem.makeDirectory(config.secretsDir, { recursive: true });
yield* fileSystem.writeFile(join(config.secretsDir, `${name}.bin`), bytes);
});

it.layer(NodeServices.layer)("ServerSecretStore autoheal (legacy plaintext)", (it) => {
it.effect("#1 RED→GREEN: get() returns null for a legacy plaintext (unparseable) secret file", () =>
Effect.gen(function* () {
yield* plantSecretFile("server-signing-key", LEGACY_PLAINTEXT_KEY);
const secretStore = yield* ServerSecretStore;

// Today this THROWS SecretStoreError("Invalid encrypted secret format") ⇒ test fails.
// After the fix it must read the legacy blob as "absent".
const result = yield* secretStore.get("server-signing-key");
expect(result).toBeNull();
}).pipe(Effect.provide(makeSharedLayer())),
);

it.effect("#2 RED→GREEN: getOrCreateRandom self-heals over a legacy plaintext signing key", () =>
Effect.gen(function* () {
yield* plantSecretFile("server-signing-key", LEGACY_PLAINTEXT_KEY);
const secretStore = yield* ServerSecretStore;

// Must succeed by minting a FRESH encrypted key (not crash on the legacy blob).
const key = yield* secretStore.getOrCreateRandom("server-signing-key", 32);
expect(key.length).toBe(32);
expect(Array.from(key)).not.toEqual(Array.from(LEGACY_PLAINTEXT_KEY)); // a new key, not the legacy bytes

// The regenerated key is now persisted encrypted-at-rest and reads back identically + stably.
const reread = yield* secretStore.get("server-signing-key");
expect(reread).not.toBeNull();
expect(Array.from(reread ?? new Uint8Array())).toEqual(Array.from(key));
const again = yield* secretStore.getOrCreateRandom("server-signing-key", 32);
expect(Array.from(again)).toEqual(Array.from(key)); // healed once, then stable
}).pipe(Effect.provide(makeSharedLayer())),
);

it.effect("#3 GUARDRAIL (no regression): a properly encrypted secret still round-trips", () =>
Effect.gen(function* () {
const secretStore = yield* ServerSecretStore;
const value = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);

yield* secretStore.set("mcp-credential-good", value);
const roundTripped = yield* secretStore.get("mcp-credential-good");

expect(roundTripped).not.toBeNull();
expect(Array.from(roundTripped ?? new Uint8Array())).toEqual(Array.from(value));
}).pipe(Effect.provide(makeSharedLayer())),
);

it.effect("#4 GUARDRAIL (scoped fix): a tampered/wrong-key (GCM auth) secret STILL errors, never null", () =>
Effect.gen(function* () {
yield* plantSecretFile("mcp-credential", makeTamperedEncryptedBlob());
const secretStore = yield* ServerSecretStore;

// Valid `iv:tag:cipher` shape but the GCM tag no longer matches ⇒ this is NOT the format error.
// It must surface as a typed failure — a blanket swallow-to-null would silently drop a real
// MCP credential, which this test forbids.
const error = yield* Effect.flip(secretStore.get("mcp-credential"));
expect(error).toBeInstanceOf(SecretStoreError);
expect(error.message).toContain("Failed to decrypt secret mcp-credential");
}).pipe(Effect.provide(makeSharedLayer())),
);
});
Loading
Loading