diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index b3dd615c8d4d7..a3de090bffd3f 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -176,3 +176,98 @@ it.layer(NodeServices.layer)("ServerSecretStore.layer", (it) => { }).pipe(Effect.provide(layerRenameFailureSecretStore())), ); }); + +const makeMemoryKeyring = (options: { readonly unavailable?: boolean } = {}) => { + const entries = new Map(); + const unavailable = (account: string) => + Effect.fail( + new ServerSecretStore.SecretStoreKeyringError({ + resource: `keyring entry ${account}`, + cause: "No Secret Service on this session bus.", + }), + ); + const keyring = ServerSecretStore.SecretStoreKeyring.of({ + get: (account) => + options.unavailable + ? unavailable(account) + : Effect.sync(() => Option.fromUndefinedOr(entries.get(account))), + set: (account, value) => + options.unavailable + ? unavailable(account) + : Effect.sync(() => void entries.set(account, Uint8Array.from(value))), + remove: (account) => Effect.sync(() => void entries.delete(account)), + }); + return { entries, keyring }; +}; + +const openStore = ( + keyring: ServerSecretStore.SecretStoreKeyring["Service"], + secretStore?: ServerConfig.SecretStoreBackend, +) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + return yield* ServerSecretStore.make.pipe( + Effect.provideService(ServerConfig.ServerConfig, { ...config, secretStore }), + Effect.provideService(ServerSecretStore.SecretStoreKeyring, keyring), + ); + }); + +const readRaw = (name: string) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { secretsDir } = yield* ServerConfig.ServerConfig; + return new TextDecoder().decode(yield* fileSystem.readFile(`${secretsDir}/${name}.bin`)); + }); + +const encode = (value: string) => new TextEncoder().encode(value); +const decode = (value: Option.Option) => + new TextDecoder().decode(Option.getOrThrow(value)); + +it.layer(NodeServices.layer)("ServerSecretStore keyring backend", (it) => { + it.effect("moves secrets under a keyring key and back out again", () => + Effect.gen(function* () { + const { entries, keyring } = makeMemoryKeyring(); + const plaintext = yield* openStore(keyring); + yield* plaintext.set("relay-token", encode("before-keyring")); + + const encrypted = yield* openStore(keyring, "keyring"); + yield* encrypted.set("cli-token", encode("after-keyring")); + assert.equal(entries.size, 1); + assert.notInclude(yield* readRaw("relay-token"), "before-keyring"); + assert.notInclude(yield* readRaw("cli-token"), "after-keyring"); + + // A CLI command sharing the home passes no backend and follows the existing key. + const following = yield* openStore(keyring); + assert.equal(decode(yield* following.get("relay-token")), "before-keyring"); + assert.equal(decode(yield* following.get("cli-token")), "after-keyring"); + + yield* openStore(keyring, "file"); + assert.equal(entries.size, 0); + assert.equal(yield* readRaw("relay-token"), "before-keyring"); + assert.equal(yield* readRaw("cli-token"), "after-keyring"); + }).pipe(Effect.provide(layerServerConfig())), + ); + + it.effect("keeps plaintext files when the keyring is unavailable", () => + Effect.gen(function* () { + const { keyring } = makeMemoryKeyring({ unavailable: true }); + const store = yield* openStore(keyring, "keyring"); + yield* store.set("relay-token", encode("plain")); + + assert.equal(yield* readRaw("relay-token"), "plain"); + }).pipe(Effect.provide(layerServerConfig())), + ); + + it.effect("refuses to start when the keyring lost the key", () => + Effect.gen(function* () { + const { entries, keyring } = makeMemoryKeyring(); + const store = yield* openStore(keyring, "keyring"); + yield* store.set("relay-token", encode("sealed")); + entries.clear(); + + const error = yield* Effect.flip(openStore(keyring)); + + assert.instanceOf(error, ServerSecretStore.SecretStoreKeyringError); + }).pipe(Effect.provide(layerServerConfig())), + ); +}); diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index 100e0e19ca2ae..d14ea97671c00 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -1,5 +1,10 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Effect's Crypto has no AES-GCM. +import * as NodeCrypto from "node:crypto"; +import * as NodeModule from "node:module"; + import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -115,6 +120,17 @@ export class SecretStoreEncodeError extends Schema.TaggedError()( + "SecretStoreKeyringError", + { + ...secretStoreErrorContext, + }, +) { + override get message(): string { + return `Failed to use the OS keyring for ${this.resource}.`; + } +} + export const SecretStoreError = Schema.Union([ SecretStoreSecureError, SecretStoreReadError, @@ -125,6 +141,7 @@ export const SecretStoreError = Schema.Union([ SecretStoreRemoveError, SecretStoreDecodeError, SecretStoreEncodeError, + SecretStoreKeyringError, ]); export type SecretStoreError = typeof SecretStoreError.Type; export const isSecretStoreError = Schema.is(SecretStoreError); @@ -135,6 +152,106 @@ const isPlatformError = (value: unknown): value is PlatformError.PlatformError = export const isSecretAlreadyExistsError = (error: SecretStoreError): boolean => "cause" in error && isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"; +const KEYRING_SERVICE = "T3 Code"; +// A locked Secret Service or an unanswered Keychain prompt can block indefinitely. +const KEYRING_TIMEOUT = Duration.seconds(30); +const KEYRING_KEY_BYTES = 32; +// Names the keyring entry that holds this directory's key; not a `.bin`, so sweeps skip it. +const KEYRING_ID_FILE = "keyring-id"; + +const requireForKeyring = NodeModule.createRequire(import.meta.url); + +export class SecretStoreKeyring extends Context.Service< + SecretStoreKeyring, + { + readonly get: ( + account: string, + ) => Effect.Effect, SecretStoreKeyringError>; + readonly set: ( + account: string, + value: Uint8Array, + ) => Effect.Effect; + readonly remove: (account: string) => Effect.Effect; + } +>()("t3/auth/ServerSecretStore/SecretStoreKeyring") {} + +const useKeyringEntry = ( + account: string, + use: (entry: import("@napi-rs/keyring").AsyncEntry, signal: AbortSignal) => Promise, +) => + Effect.tryPromise({ + try: (signal) => { + // Loaded lazily so stores that never use the keyring never load the native binding. + const { AsyncEntry } = requireForKeyring( + "@napi-rs/keyring", + ) as typeof import("@napi-rs/keyring"); + return use(new AsyncEntry(KEYRING_SERVICE, account), signal); + }, + catch: (cause) => new SecretStoreKeyringError({ resource: `keyring entry ${account}`, cause }), + }).pipe( + Effect.timeoutOrElse({ + duration: KEYRING_TIMEOUT, + orElse: () => + Effect.fail( + new SecretStoreKeyringError({ + resource: `keyring entry ${account}`, + cause: "Timed out waiting for the OS keyring.", + }), + ), + }), + ); + +export const layerNativeKeyring = Layer.succeed( + SecretStoreKeyring, + SecretStoreKeyring.of({ + get: Effect.fn("SecretStoreKeyring.get")(function* (account) { + const secret = yield* useKeyringEntry(account, (entry, signal) => entry.getSecret(signal)); + // The binding resolves a plain number[] despite its Uint8Array typing. + return Option.map(Option.fromNullishOr(secret), (bytes) => Uint8Array.from(bytes)); + }), + set: Effect.fn("SecretStoreKeyring.set")(function* (account, value) { + yield* useKeyringEntry(account, (entry, signal) => entry.setSecret(value, signal)); + }), + remove: Effect.fn("SecretStoreKeyring.remove")(function* (account) { + yield* useKeyringEntry(account, (entry, signal) => entry.deleteCredential(signal)); + }), + }), +); + +// Starts with NUL so no plaintext secret the file backend wrote can look encrypted. +const ENCRYPTED_HEADER = Buffer.from("\0t3-secret-v1\0"); +const IV_BYTES = 12; +const AUTH_TAG_BYTES = 16; + +const isEncrypted = (bytes: Uint8Array) => + bytes.length >= ENCRYPTED_HEADER.length + IV_BYTES + AUTH_TAG_BYTES && + ENCRYPTED_HEADER.equals(bytes.subarray(0, ENCRYPTED_HEADER.length)); + +// The secret name is bound as associated data so a file renamed to another secret fails to decrypt. +const encrypt = (key: Uint8Array, name: string, value: Uint8Array): Uint8Array => { + const iv = NodeCrypto.randomBytes(IV_BYTES); + const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(Buffer.from(name)); + const body = Buffer.concat([cipher.update(value), cipher.final()]); + return Buffer.concat([ENCRYPTED_HEADER, iv, cipher.getAuthTag(), body]); +}; + +const decrypt = (key: Uint8Array, name: string, bytes: Uint8Array): Uint8Array => { + const ivStart = ENCRYPTED_HEADER.length; + const tagStart = ivStart + IV_BYTES; + const bodyStart = tagStart + AUTH_TAG_BYTES; + const decipher = NodeCrypto.createDecipheriv( + "aes-256-gcm", + key, + bytes.subarray(ivStart, tagStart), + ); + decipher.setAAD(Buffer.from(name)); + decipher.setAuthTag(bytes.subarray(tagStart, bodyStart)); + return Uint8Array.from( + Buffer.concat([decipher.update(bytes.subarray(bodyStart)), decipher.final()]), + ); +}; + export class ServerSecretStore extends Context.Service< ServerSecretStore, { @@ -156,21 +273,30 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; + const keyring = yield* SecretStoreKeyring; + const { secretsDir } = serverConfig; - yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true }); - yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe( + yield* fileSystem.makeDirectory(secretsDir, { recursive: true }); + yield* fileSystem.chmod(secretsDir, 0o700).pipe( Effect.mapError( (cause) => new SecretStoreSecureError({ - resource: `secrets directory ${serverConfig.secretsDir}`, + resource: `secrets directory ${secretsDir}`, cause, }), ), ); - const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + const resolveSecretPath = (name: string) => path.join(secretsDir, `${name}.bin`); + const keyringIdPath = path.join(secretsDir, KEYRING_ID_FILE); + // Hashing the directory in keeps a copied secrets directory from reading or deleting this key. + const directoryHash = NodeCrypto.createHash("sha256") + .update(path.resolve(secretsDir)) + .digest("hex") + .slice(0, 16); + const keyringAccount = (id: string) => `${directoryHash}-${id}`; - const get: ServerSecretStore["Service"]["get"] = (name) => + const readSecretFile = (name: string) => fileSystem.readFile(resolveSecretPath(name)).pipe( Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => @@ -183,10 +309,9 @@ export const make = Effect.gen(function* () { }), ), ), - Effect.withSpan("ServerSecretStore.get"), ); - const set: ServerSecretStore["Service"]["set"] = (name, value) => { + const writeSecretFile = (name: string, value: Uint8Array) => { const secretPath = resolveSecretPath(name); return crypto.randomUUIDv4().pipe( Effect.mapError( @@ -219,24 +344,16 @@ export const make = Effect.gen(function* () { ), ); }), - Effect.withSpan("ServerSecretStore.set"), ); }; // `link` publishes the fully written temp file under the final name, or fails with - // AlreadyExists, so a concurrent reader never observes a partially written secret. - const create: ServerSecretStore["Service"]["create"] = (name, value) => { - const secretPath = resolveSecretPath(name); - return crypto.randomUUIDv4().pipe( - Effect.mapError( - (cause) => - new SecretStoreTemporaryPathError({ - resource: `secret ${name}`, - cause, - }), - ), + // AlreadyExists, so a concurrent reader never observes a partially written file. + const publishExclusive = (targetPath: string, resource: string, value: Uint8Array) => + crypto.randomUUIDv4().pipe( + Effect.mapError((cause) => new SecretStoreTemporaryPathError({ resource, cause })), Effect.flatMap((uuid) => { - const tempPath = `${secretPath}.${uuid}.tmp`; + const tempPath = `${targetPath}.${uuid}.tmp`; return Effect.gen(function* () { yield* Effect.scoped( Effect.gen(function* () { @@ -246,21 +363,175 @@ export const make = Effect.gen(function* () { }), ); yield* fileSystem.chmod(tempPath, 0o600); - yield* fileSystem.link(tempPath, secretPath); + yield* fileSystem.link(tempPath, targetPath); }).pipe( Effect.ensuring(fileSystem.remove(tempPath).pipe(Effect.ignore)), - Effect.mapError( - (cause) => - new SecretStorePersistError({ - resource: `secret ${name}`, - cause, - }), - ), + Effect.mapError((cause) => new SecretStorePersistError({ resource, cause })), ); }), ); + + const readKeyringId = fileSystem.readFileString(keyringIdPath).pipe( + Effect.map((id) => Option.some(id.trim())), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeedNone + : Effect.fail(new SecretStoreReadError({ resource: "secret store keyring id", cause })), + ), + ); + + const loadKey = (id: string) => + keyring.get(keyringAccount(id)).pipe( + Effect.filterOrFail( + (key): key is Option.Some => + Option.isSome(key) && key.value.length === KEYRING_KEY_BYTES, + () => + new SecretStoreKeyringError({ + resource: `secret store key ${keyringAccount(id)}`, + cause: `The key is missing from the OS keyring. Remove ${secretsDir} to start over.`, + }), + ), + Effect.map((key) => key.value), + ); + + const rewriteSecretFiles = ( + transform: ( + name: string, + bytes: Uint8Array, + ) => Effect.Effect, SecretStoreError>, + ) => + fileSystem.readDirectory(secretsDir).pipe( + Effect.mapError( + (cause) => new SecretStoreReadError({ resource: `secrets directory ${secretsDir}`, cause }), + ), + Effect.flatMap((files) => + Effect.forEach( + files.flatMap((file) => (file.endsWith(".bin") ? [file.slice(0, -".bin".length)] : [])), + (name) => + readSecretFile(name).pipe( + Effect.flatMap((bytes) => + Option.isNone(bytes) ? Effect.succeedNone : transform(name, bytes.value), + ), + Effect.flatMap((next) => + Option.isNone(next) ? Effect.void : writeSecretFile(name, next.value), + ), + ), + { discard: true }, + ), + ), + ); + + const enableKeyring = Effect.gen(function* () { + const key = yield* crypto.randomBytes(KEYRING_KEY_BYTES); + const id = yield* crypto.randomUUIDv4(); + yield* keyring.set(keyringAccount(id), key); + const published = yield* publishExclusive( + keyringIdPath, + "secret store keyring id", + new TextEncoder().encode(id), + ).pipe( + Effect.as(true), + Effect.catchIf(isSecretAlreadyExistsError, () => Effect.succeed(false)), + ); + if (!published) { + // Another process enabled the keyring first; use its key. + yield* keyring.remove(keyringAccount(id)).pipe(Effect.ignore); + const winner = yield* readKeyringId; + if (Option.isNone(winner)) { + return yield* new SecretStoreConcurrentReadError({ resource: "secret store keyring id" }); + } + return yield* loadKey(winner.value); + } + yield* rewriteSecretFiles((name, bytes) => + Effect.succeed(isEncrypted(bytes) ? Option.none() : Option.some(encrypt(key, name, bytes))), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("Some secrets could not be moved under the keyring key", { cause }), + ), + ); + return Uint8Array.from(key); + }).pipe( + Effect.mapError((cause) => + isSecretStoreError(cause) + ? cause + : new SecretStoreRandomGenerationError({ resource: "secret store key", cause }), + ), + ); + + const disableKeyring = Effect.fn("ServerSecretStore.disableKeyring")(function* (id: string) { + const key = yield* loadKey(id); + yield* rewriteSecretFiles((name, bytes) => + isEncrypted(bytes) + ? Effect.try({ + try: () => Option.some(decrypt(key, name, bytes)), + catch: (cause) => new SecretStoreDecodeError({ resource: `secret ${name}`, cause }), + }) + : Effect.succeedNone, + ); + yield* fileSystem + .remove(keyringIdPath) + .pipe( + Effect.mapError( + (cause) => new SecretStoreRemoveError({ resource: "secret store keyring id", cause }), + ), + ); + yield* keyring.remove(keyringAccount(id)).pipe(Effect.ignore); + }); + + // `secretStore` only switches backends; when unset, every process follows the keyring id file, + // so CLI commands sharing a desktop home read what the desktop server wrote. + const keyringId = yield* readKeyringId; + const key: Uint8Array | undefined = Option.isSome(keyringId) + ? serverConfig.secretStore === "file" + ? yield* disableKeyring(keyringId.value).pipe(Effect.as(undefined)) + : yield* loadKey(keyringId.value) + : serverConfig.secretStore === "keyring" + ? yield* enableKeyring.pipe( + Effect.catch((cause) => + Effect.logWarning("OS keyring unavailable; secrets stay in plaintext files", { + cause, + }).pipe(Effect.as(undefined)), + ), + ) + : undefined; + + const encode = (name: string, value: Uint8Array) => + key === undefined ? value : encrypt(key, name, value); + + const decode = (name: string, bytes: Uint8Array): Effect.Effect => { + if (!isEncrypted(bytes)) return Effect.succeed(bytes); + if (key === undefined) { + return Effect.fail( + new SecretStoreKeyringError({ + resource: `secret ${name}`, + cause: "The secret is encrypted but this secrets directory has no keyring key.", + }), + ); + } + return Effect.try({ + try: () => decrypt(key, name, bytes), + catch: (cause) => new SecretStoreDecodeError({ resource: `secret ${name}`, cause }), + }); }; + const get: ServerSecretStore["Service"]["get"] = (name) => + readSecretFile(name).pipe( + Effect.flatMap((bytes) => + Option.isNone(bytes) ? Effect.succeedNone : decode(name, bytes.value).pipe(Effect.asSome), + ), + Effect.withSpan("ServerSecretStore.get"), + ); + + const set: ServerSecretStore["Service"]["set"] = (name, value) => + Effect.suspend(() => writeSecretFile(name, encode(name, value))).pipe( + Effect.withSpan("ServerSecretStore.set"), + ); + + const create: ServerSecretStore["Service"]["create"] = (name, value) => + Effect.suspend(() => + publishExclusive(resolveSecretPath(name), `secret ${name}`, encode(name, value)), + ); + const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => get(name).pipe( Effect.flatMap( @@ -319,7 +590,7 @@ export const make = Effect.gen(function* () { ); return ServerSecretStore.of({ - directory: serverConfig.secretsDir, + directory: secretsDir, get, set, create, @@ -328,4 +599,4 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(ServerSecretStore, make); +export const layer = Layer.effect(ServerSecretStore, make).pipe(Layer.provide(layerNativeKeyring)); diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index c7f47c655dc22..76fb8fc374aab 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -48,6 +48,7 @@ const minimalDesktopFlags = (baseDir: string) => ({ autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }); @@ -230,6 +231,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }; const layerConfig = ConfigProvider.layer( @@ -274,6 +276,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }; const layerConfig = ConfigProvider.layer( @@ -329,6 +332,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.some(false), logWebSocketEvents: Option.some(false), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }, Option.none(), @@ -405,6 +409,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }, Option.some("Debug"), @@ -420,6 +425,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", T3CODE_LOG_WS_EVENTS: "true", + T3CODE_SECRET_STORE: "keyring", }, }), ), @@ -446,6 +452,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + secretStore: "keyring", }); }), ); @@ -552,6 +559,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }, Option.none(), @@ -568,6 +576,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpMetricsUrl).toBeUndefined(); expect(resolved.otlpLogsUrl).toBeUndefined(); expect(resolved.otelEnvironment.disabled).toBe(true); + expect(resolved.secretStore).toBe("keyring"); }), ); @@ -600,6 +609,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }, Option.none(), @@ -640,6 +650,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }, Option.none(), @@ -702,6 +713,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }, Option.none(), @@ -741,6 +753,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), + secretStore: Option.none(), tailscaleServePort: Option.none(), }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 00621e332deb9..7ab25321ccaf7 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -73,6 +73,15 @@ const logWebSocketEventsFlag = Flag.Boolean("log-websocket-events").pipe( Flag.withAlias("log-ws-events"), Flag.optional, ); +const secretStoreFlag = Flag.Literals( + "secret-store", + ServerConfig.SecretStoreBackend.literals, +).pipe( + Flag.withDescription( + "Encrypt secrets with a key kept in the OS keyring, or store them as plaintext files. The choice persists for this T3 home (equivalent to T3CODE_SECRET_STORE).", + ), + Flag.optional, +); const tailscaleServeFlag = Flag.Boolean("tailscale-serve").pipe( Flag.withDescription( "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", @@ -156,6 +165,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + secretStore: Config.schema(ServerConfig.SecretStoreBackend, "T3CODE_SECRET_STORE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), tailscaleServeEnabled: Config.Boolean("T3CODE_TAILSCALE_SERVE").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -199,6 +212,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly secretStore: Option.Option; } export interface CliAuthLocationFlags { @@ -233,6 +247,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + secretStore: secretStoreFlag, } as const; const resolveOptionPrecedence = ( @@ -276,6 +291,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + secretStore: flags.secretStore ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -403,6 +419,14 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ), () => 443, ); + // Desktop moves secrets into the keyring by default; standalone servers opt in. + const secretStore = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.secretStore, + Option.fromUndefinedOr(env.secretStore), + mode === "desktop" ? Option.some("keyring" as const) : Option.none(), + ), + ); const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const explicitHost = resolveOptionPrecedence( normalizedFlags.host, @@ -505,6 +529,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + ...(secretStore === undefined ? {} : { secretStore }), }; return config; @@ -528,6 +553,7 @@ export const resolveCliAuthConfig = ( logWebSocketEvents: Option.none(), tailscaleServeEnabled: Option.none(), tailscaleServePort: Option.none(), + secretStore: Option.none(), }, cliLogLevel, ); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 91d443308776c..7667e94e3d861 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -31,6 +31,9 @@ export const DEFAULT_PORT = 3773; export const RuntimeMode = Schema.Literals(["web", "desktop"]); export type RuntimeMode = typeof RuntimeMode.Type; +export const SecretStoreBackend = Schema.Literals(["keyring", "file"]); +export type SecretStoreBackend = typeof SecretStoreBackend.Type; + export const StartupPresentation = Schema.Literals(["browser", "headless"]); export type StartupPresentation = typeof StartupPresentation.Type; @@ -106,6 +109,8 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** Switches the secrets directory to this backend; unset keeps whatever it already uses. */ + readonly secretStore?: SecretStoreBackend | undefined; } >()("t3/config/ServerConfig") { static readonly layerTest = ( diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index f16a91f26c0ad..06c9977432cc3 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -201,22 +201,24 @@ it.effect("a link whose row cannot be stored keeps none of its token", () => const held = yield* Ref.make>(new Set()); const noted = Layer.effect( ServerSecretStore.ServerSecretStore, - Effect.map(ServerSecretStore.make, (store) => - ServerSecretStore.ServerSecretStore.of({ - ...store, - set: (name, value) => - store - .set(name, value) - .pipe(Effect.andThen(Ref.update(held, (names) => new Set([...names, name])))), - remove: (name) => - store - .remove(name) - .pipe( - Effect.andThen( - Ref.update(held, (names) => new Set([...names].filter((at) => at !== name))), + Effect.map( + ServerSecretStore.make.pipe(Effect.provide(ServerSecretStore.layerNativeKeyring)), + (store) => + ServerSecretStore.ServerSecretStore.of({ + ...store, + set: (name, value) => + store + .set(name, value) + .pipe(Effect.andThen(Ref.update(held, (names) => new Set([...names, name])))), + remove: (name) => + store + .remove(name) + .pipe( + Effect.andThen( + Ref.update(held, (names) => new Set([...names].filter((at) => at !== name))), + ), ), - ), - }), + }), ), ); const a = yield* makeA(laptop, noted); diff --git a/docs/user/faq.md b/docs/user/faq.md index 62a5e1a8446d1..35b99fad617fd 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -71,6 +71,10 @@ No. T3 Code is a serious developer tool. Locally runnable models are not capable No. Your code and prompts go from your machine to your provider (Anthropic, OpenAI, etc.), not to us. With T3 Connect, traffic rides a Cloudflare tunnel (`cloudflared` on your machine). Our relay sets up the link. It does not proxy your HTTP or WebSocket sessions. T3 Code does send basic product usage events (provider, model, token totals, that kind of thing). No prompts, no file contents. That is the same on Tailscale, LAN, or T3 Connect. Set `T3CODE_TELEMETRY_ENABLED=false` if you want it off. [Product usage data](./telemetry.md) +## Where does T3 Code keep my tokens and API keys? + +In the `secrets` folder of your T3 home (`~/.t3/userdata/secrets`), readable only by your user. The desktop app encrypts them with a key it keeps in your OS keyring (Keychain, Credential Manager, or Secret Service). A server started with `npx t3` keeps plain files unless you run it once with `--secret-store keyring` (or `T3CODE_SECRET_STORE=keyring`); `--secret-store file` turns encryption back off. If no keyring is available, T3 Code keeps the plain files. Encrypted secrets only open on the machine and in the folder where they were written, so a copied `~/.t3` folder needs its accounts reconnected. + ## Worktrees are eating my disk. How do I clean them up? Open **Settings > Storage** and turn on automatic worktree cleanup. You can also run it now from there. Worktrees add up fast. One user cleared 500 GB. [Storage cleanup](./project-settings.md#storage-cleanup)