diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index 6e9609df2b8..f5415d5a97c 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -8,7 +8,12 @@ import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import { ServerConfig } from "../../config.ts"; -import { decryptSecret, encryptSecret, isLegacySecretFormatError } from "../secretCrypto.ts"; +import { + decryptSecret, + encryptSecret, + isLegacySecretFormatError, + isRegenerableSecret, +} from "../secretCrypto.ts"; import { SecretStoreError, ServerSecretStore, @@ -49,10 +54,14 @@ export const makeServerSecretStore = Effect.gen(function* () { ), ), // 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. + // decrypt failure is treated as ABSENT (so getOrCreateRandom self-heals — mints a fresh encrypted + // key, no error/log) in exactly two cases, else it stays a typed SecretStoreError: + // 1. a LEGACY plaintext blob (pre-encryption signing key — fails the iv:tag:cipher format check), + // regardless of the secret name; OR + // 2. a GCM-auth failure (tampered / wrong host-key — e.g. a host-bound blob left by the old + // host+user salt, or a wrong-host VDI file) ONLY for a regenerable secret (the signing key). + // A GCM-auth failure on any NON-regenerable secret (an MCP/OAuth credential) keeps erroring, so a + // real credential never silently vanishes. Effect.flatMap((bytes) => bytes === null ? Effect.succeed(null) @@ -62,7 +71,7 @@ export const makeServerSecretStore = Effect.gen(function* () { new SecretStoreError({ message: `Failed to decrypt secret ${name}.`, cause }), }).pipe( Effect.catch((error) => - isLegacySecretFormatError(error.cause) + isLegacySecretFormatError(error.cause) || isRegenerableSecret(name) ? Effect.succeed(null) : Effect.fail(error), ), diff --git a/apps/server/src/auth/secretCrypto.ts b/apps/server/src/auth/secretCrypto.ts index 0719304559e..ba416197a4d 100644 --- a/apps/server/src/auth/secretCrypto.ts +++ b/apps/server/src/auth/secretCrypto.ts @@ -1,18 +1,29 @@ // ru-fork #4: at-rest encryption for the ServerSecretStore `.bin` files. Mirrors qwen-code 0.13.1's -// headless file scheme (file-token-storage.ts): scrypt(host+user) → aes-256-gcm, format -// `ivHex:authTagHex:cipherHex`. Host+user-derived key matches qwen's threat model (protects a +// headless file scheme (file-token-storage.ts): scrypt(user) → aes-256-gcm, format +// `ivHex:authTagHex:cipherHex`. The user-derived key matches qwen's threat model (protects a // copied-off-host file, not a local same-user attacker). The MCP feature has never shipped, so there // is no legacy plaintext to migrate. +// +// ru-fork: the salt was originally `host+user`, but `os.hostname()` is UNSTABLE on non-persistent +// Citrix/VDI — the roaming profile (and these `.bin` files) lands on a different pooled host each logon, +// so a prior secret no longer authenticates (GCM failure) and the server crashed on boot. `username` +// roams WITH the profile and is stable across pooled hosts, so the salt is now username-only. An +// existing host-bound blob can't be unwrapped under the new key; the regenerable signing key self-heals +// over it (see `isRegenerableSecret` + ServerSecretStore.get). import * as Crypto from "node:crypto"; import * as os from "node:os"; -const deriveKey = (): Buffer => { - const salt = `${os.hostname()}-${os.userInfo().username}-qwen-code`; - return Crypto.scryptSync("qwen-code-oauth", salt, 32); -}; +export const deriveKey = (username: string): Buffer => + Crypto.scryptSync("qwen-code-oauth", `${username}-qwen-code`, 32); -// Derived once per process (scrypt is intentionally slow); host/user don't change at runtime. -const KEY = deriveKey(); +// Derived once per process (scrypt is intentionally slow); the login user doesn't change at runtime. +const KEY = deriveKey(os.userInfo().username); + +// ru-fork: a secret that is safe to discard-and-regenerate — losing it only invalidates existing +// sessions (users re-login), unlike an MCP/OAuth credential which must NEVER silently vanish. Only these +// may self-heal over a GCM-auth failure (e.g. a host-bound blob left by the pre-username-salt build, or +// a wrong-host VDI file); every other secret keeps erroring so a real credential is never dropped. +export const isRegenerableSecret = (name: string): boolean => name.endsWith("signing-key"); export function encryptSecret(plaintext: Uint8Array): Uint8Array { const iv = Crypto.randomBytes(16); diff --git a/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts b/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts index 3ffb17affa0..126d36cd1a8 100644 --- a/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts +++ b/apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts @@ -116,4 +116,42 @@ it.layer(NodeServices.layer)("ServerSecretStore autoheal (legacy plaintext)", (i expect(error.message).toContain("Failed to decrypt secret mcp-credential"); }).pipe(Effect.provide(makeSharedLayer())), ); + + // ru-fork: VDI/Citrix migration self-heal for the REGENERABLE signing key. + // ru-fork #4 bound the at-rest key to hostname+username. On non-persistent Citrix the user profile + // (and this .bin) roams onto a DIFFERENT pooled hostname each logon ⇒ a VALID-format blob that fails + // GCM auth ⇒ boot crashed with "Failed to decrypt secret server-signing-key". Now the salt is username + // ONLY (roams with the profile), and any EXISTING hostname-bound blob — un-authenticatable under the + // new key — must SELF-HEAL: the signing key is regenerable (loss only resets sessions), so a GCM + // failure is treated as ABSENT and re-minted. A tampered blob stands in for that stale wrong-host file. + // The contrast with #4 (same shape, mcp name ⇒ STILL errors) is the guardrail: the heal is scoped to + // the regenerable signing key, NOT a blanket swallow that would silently drop a real MCP credential. + it.effect("#5 RED→GREEN: get() heals a GCM-unauthenticatable signing-key blob to null", () => + Effect.gen(function* () { + yield* plantSecretFile("server-signing-key", makeTamperedEncryptedBlob()); + const secretStore = yield* ServerSecretStore; + + // Today this THROWS SecretStoreError (GCM auth). After the fix the regenerable signing key reads + // as absent so getOrCreateRandom can re-mint it. + const result = yield* secretStore.get("server-signing-key"); + expect(result).toBeNull(); + }).pipe(Effect.provide(makeSharedLayer())), + ); + + it.effect("#6 RED→GREEN: getOrCreateRandom() self-heals over a stale (wrong-host) signing key", () => + Effect.gen(function* () { + yield* plantSecretFile("server-signing-key", makeTamperedEncryptedBlob()); + const secretStore = yield* ServerSecretStore; + + const key = yield* secretStore.getOrCreateRandom("server-signing-key", 32); + expect(key.length).toBe(32); + + // Re-minted, persisted encrypted-at-rest under the new key, and stable on re-read. + 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())), + ); }); diff --git a/apps/server/tests/auth/secretCrypto.test.ts b/apps/server/tests/auth/secretCrypto.test.ts new file mode 100644 index 00000000000..5eaa3598342 --- /dev/null +++ b/apps/server/tests/auth/secretCrypto.test.ts @@ -0,0 +1,35 @@ +// ru-fork: spec for the at-rest key derivation. ru-fork #4 salted scrypt with hostname+username, which +// breaks on non-persistent Citrix/VDI: the roaming profile lands on a different pooled hostname each +// logon, so a previously-written secret no longer authenticates (GCM failure). Fix: salt with the +// USERNAME ONLY — it roams with the profile and is stable across pooled hosts. These tests pin (a) the +// cipher still round-trips and (b) the salt formula contains NO hostname, so a re-introduced hostname +// term goes RED here instead of silently re-breaking VDI. +import * as Crypto from "node:crypto"; + +import { describe, expect, it } from "vitest"; + +import { decryptSecret, deriveKey, encryptSecret } from "../../src/auth/secretCrypto.ts"; + +describe("secretCrypto", () => { + it("round-trips encrypt → decrypt (cipher unchanged)", () => { + const plaintext = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]); + const restored = decryptSecret(encryptSecret(plaintext)); + expect(Array.from(restored)).toEqual(Array.from(plaintext)); + }); + + // The exact salt vector — username-only, hostname EXCLUDED. Any salt that re-adds os.hostname() (the + // Citrix/VDI regression) produces different bytes and fails this. `equals` is a constant-time-ish + // Buffer compare; here it's just byte-equality of two 32-byte scrypt outputs. + it("derives the key from username only — salt excludes hostname", () => { + const usernameOnly = Crypto.scryptSync("qwen-code-oauth", "16718229-qwen-code", 32); + expect(deriveKey("16718229").equals(usernameOnly)).toBe(true); + }); + + it("is deterministic for a given username", () => { + expect(deriveKey("16718229").equals(deriveKey("16718229"))).toBe(true); + }); + + it("still binds to the username — different users derive different keys", () => { + expect(deriveKey("alice").equals(deriveKey("bob"))).toBe(false); + }); +}); diff --git a/install b/install index a9a65c3389c..26ce13a03f1 100644 --- a/install +++ b/install @@ -265,6 +265,13 @@ remove_bin() { # it too. Guarded: must exist, end in the app dir name, not a dangerous path. remove_legacy_root() { [ -n "${LEGACY_ROOT:-}" ] && [ -d "$LEGACY_ROOT" ] || return 0 + # /home/work/ IS the install target, never an orphan to delete. When home + # already is /home/work/, the resolver reports LEGACY_ROOT collapsed onto + # the fresh install; deleting it would rm -rf the app we just installed. Segment + # match (not string ==) so a symlinked home path can't slip past. + case "$LEGACY_ROOT" in + */work/*) warn "Отказ удалять: $LEGACY_ROOT — это целевой каталог установки (work), не легаси"; return 0 ;; + esac # Without the app dir name the */ guard below can't be trusted — skip. [ -n "$APP_DIR_NAME" ] || { warn "Отказ удалять: имя каталога приложения не определено"; return 0; } case "$LEGACY_ROOT" in