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
21 changes: 15 additions & 6 deletions apps/server/src/auth/Layers/ServerSecretStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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),
),
Expand Down
27 changes: 19 additions & 8 deletions apps/server/src/auth/secretCrypto.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down
38 changes: 38 additions & 0 deletions apps/server/tests/auth/Layers/ServerSecretStoreAutoheal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())),
);
});
35 changes: 35 additions & 0 deletions apps/server/tests/auth/secretCrypto.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
7 changes: 7 additions & 0 deletions install
Original file line number Diff line number Diff line change
Expand Up @@ -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/<user> IS the install target, never an orphan to delete. When home
# already is /home/work/<user>, 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 */<appdir> guard below can't be trusted — skip.
[ -n "$APP_DIR_NAME" ] || { warn "Отказ удалять: имя каталога приложения не определено"; return 0; }
case "$LEGACY_ROOT" in
Expand Down
Loading