diff --git a/CHANGELOG.md b/CHANGELOG.md index 8695e96..8242727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ All notable changes to `sustech-cli` are documented in this file. - Check the official npm release once per day in interactive terminals and ask before updating. Add `sustech update [--yes]` for an explicit check or confirmed install, while keeping CI, pipes, JSON, and JSONL prompt-free. +- Linux credential storage now falls back to an encrypted local file store + (AES-256-GCM with PBKDF2 key derivation) when freedesktop Secret Service is + unavailable. This enables `sustech auth login` to persist credentials on + headless servers, containers, and CI environments without requiring a desktop + D-Bus session or `secret-tool`. The encrypted store requires a master password + on first use and never stores credentials in plaintext. + +### Changed + +- Linux `auth login` no longer fails with `CREDENTIAL_STORE_UNAVAILABLE` when + Secret Service is unavailable. Instead, it automatically uses the encrypted + file backend at `~/.config/sustech-cli/encrypted-credentials/` with file + mode `0600`. ## [0.12.1] - 2026-09-12 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2361b4e..3acefe0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,10 +22,11 @@ output renderer text | versioned JSON | streaming JSONL - `src/core` owns credentials, errors, output contracts, capabilities, consequences, and other shared primitives. - `src/core/keyring.ts` keeps long-lived secrets behind platform adapters: - macOS Keychain, Windows Credential Manager, or Linux Secret Service. CAS - passwords and Blackboard native calendar links use separate secret - namespaces. The local config contains profile metadata only for CAS - credentials; the Blackboard calendar link does not create on-disk metadata. + macOS Keychain, Windows Credential Manager, Linux Secret Service, or (when + Secret Service is unavailable) an encrypted local file store. CAS passwords + and Blackboard native calendar links use separate secret namespaces. The + local config contains profile metadata only for CAS credentials; the + Blackboard calendar link does not create on-disk metadata. - `src/sso` owns generic CAS session flow. TIS, Blackboard, and WS reuse this layer instead of reimplementing login logic separately. - `src/tis` owns TIS protocol details, normalized course models, persistent @@ -132,8 +133,10 @@ commands for them. that step manually. The CLI does not accept browser credentials, does not solve CAPTCHAs, and does not persist browser cookies. - `auth login` verifies the selected service before storing a password in the - operating-system credential store. Linux refuses a session-only keyutils or - plaintext fallback when Secret Service is unavailable. + operating-system credential store. When Linux Secret Service is unavailable, + the CLI falls back to an encrypted local file store (AES-256-GCM) that + requires a master password. It never falls back to plaintext storage or + session-only kernel keyrings. - If CAS responds with an interactive slide CAPTCHA, the shared login layer returns `CAS_INTERACTIVE_CHALLENGE_REQUIRED` before password submission instead of trying to bypass the challenge. diff --git a/docs/AUTHENTICATION.md b/docs/AUTHENTICATION.md index d676fe2..7c51e33 100644 --- a/docs/AUTHENTICATION.md +++ b/docs/AUTHENTICATION.md @@ -70,11 +70,34 @@ when the record is easier to reach through the browser transport. | macOS | Keychain through the native Security framework | persistent for the current user | | Windows | Credential Manager | persistent for the current Windows user | | Linux desktop | freedesktop Secret Service through `secret-tool` | persistent when the user's collection is available and unlocked | -| Headless Linux, container, CI | no implicit local backend | inject from an external secret manager | +| Headless Linux, container, CI | encrypted local file (AES-256-GCM) | persistent, encrypted with a user-provided master password | -Linux deliberately requires a desktop D-Bus session and the distribution's -`secret-tool`/`libsecret-tools` package. It does not silently fall back to a -plaintext file or a session-only kernel keyring. +When Linux Secret Service is unavailable (no D-Bus session or `secret-tool` not +installed), the CLI automatically falls back to an encrypted local credential +store. This store uses AES-256-GCM encryption with PBKDF2 key derivation +(600,000 iterations) and requires a master password on first use. + +The encrypted store is created at +`~/.config/sustech-cli/encrypted-credentials/` with file mode `0600`. The +master password is never stored on disk and must be provided for each CLI +invocation that accesses stored credentials: + +```bash +# Interactive: prompted for master password on first login +sustech auth login --sid 12410000 --password-stdin + +# Non-interactive: set SUSTECH_MASTER_PASSWORD environment variable +export SUSTECH_MASTER_PASSWORD="your-master-password" +sustech auth login --sid 12410000 --password-stdin + +# Alternative: use explicit credentials file (no master password needed) +echo "12410000:password" > credentials.txt +chmod 600 credentials.txt +sustech --credentials-file credentials.txt bb courses +``` + +For non-interactive use, set `SUSTECH_MASTER_PASSWORD` or use +`--credentials-file` with explicit credentials. `auth status` does not read the stored password when checking macOS Keychain. It uses a metadata-only `security find-generic-password` lookup without `-w`. @@ -84,11 +107,17 @@ sets `reasonCode` to `CREDENTIAL_STORE_TIMEOUT`, marks the backend unavailable for that probe, and leaves the credential and profile metadata unchanged. Credential writes are verified by an immediate read-back before profile -metadata is committed. Linux errors distinguish a locked collection, a missing -desktop D-Bus/Secret Service session, an access denial, and an unclassified -`secret-tool` failure. Run `sustech auth status --json` in the same unlocked -graphical session and follow its `remediation`; do not delete profile metadata -or assume the password expired merely because the collection is locked. +metadata is committed. Linux Secret Service errors distinguish a locked +collection, a missing desktop D-Bus/Secret Service session, an access denial, +and an unclassified `secret-tool` failure. Run `sustech auth status --json` in +the same unlocked graphical session and follow its `remediation`; do not delete +profile metadata or assume the password expired merely because the collection +is locked. + +For the encrypted-file backend, decryption failures indicate an incorrect +master password. The backend does not impose a retry limit or lockout; protect +the master password accordingly. Each encrypted credential entry uses a unique +salt and initialization vector to prevent cross-entry attacks. ## Profiles diff --git a/src/cli.ts b/src/cli.ts index e46ba3b..b1ebf1a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -68,6 +68,7 @@ import { import { promptHiddenPassword, promptLoginSid, + promptMasterPassword, promptYesNo, readCalendarLinkFromStdin, readPasswordFromStdin, @@ -2135,7 +2136,13 @@ async function runAuth(positionals: readonly string[], values: Values, output: O if (values["password-stdin"] && !values.sid) { throw usageError("--password-stdin requires --sid so stdin contains only the password."); } - const backend = await getCredentialBackendStatus(); + + const masterPasswordEnv = process.env.SUSTECH_MASTER_PASSWORD; + const promptForMasterPassword = masterPasswordEnv ? undefined : async () => await promptMasterPassword(); + const backend = await getCredentialBackendStatus({ + encryptedStoreMasterPassword: masterPasswordEnv, + promptForMasterPassword, + }); if (!backend.available) { throw new CliError( backend.reason ?? "No secure system credential store is available.", @@ -2153,7 +2160,10 @@ async function runAuth(positionals: readonly string[], values: Values, output: O values["password-stdin"] ? await readPasswordFromStdin() : await promptHiddenPassword(), ); const authenticated = await authenticateCredentials({ sid, password, source: "interactive" }, service); - const stored = await saveStoredCredentials({ profile, sid, password }); + const stored = await saveStoredCredentials({ profile, sid, password }, { + encryptedStoreMasterPassword: masterPasswordEnv, + promptForMasterPassword, + }); const identity = authenticated.identity ? `\nIdentity: ${authenticated.identity}` : ""; writeSuccess({ command: "auth login", diff --git a/src/core/credentials.ts b/src/core/credentials.ts index e897bf3..70fb849 100644 --- a/src/core/credentials.ts +++ b/src/core/credentials.ts @@ -39,9 +39,11 @@ export async function resolveCredentials( } try { + const masterPasswordEnv = env.SUSTECH_MASTER_PASSWORD; const stored = await loadStoredCredentials(options.profile, { ...options.store, env: options.store?.env ?? env, + encryptedStoreMasterPassword: masterPasswordEnv, }); return { sid: stored.sid, diff --git a/src/core/encrypted-store.ts b/src/core/encrypted-store.ts new file mode 100644 index 0000000..dff3a50 --- /dev/null +++ b/src/core/encrypted-store.ts @@ -0,0 +1,225 @@ +import { randomBytes, pbkdf2, createCipheriv, createDecipheriv } from "node:crypto"; +import { constants } from "node:fs"; +import { access, mkdir, readFile, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; + +const pbkdf2Async = promisify(pbkdf2); + +const ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; +const SALT_LENGTH = 32; +const PBKDF2_ITERATIONS = 600_000; + +export interface EncryptedStoreOptions { + storePath: string; + getMasterPassword: () => Promise; +} + +interface EncryptedEntry { + salt: string; + iv: string; + authTag: string; + encrypted: string; +} + +interface StoreFile { + version: "1"; + entries: Record; +} + +export class EncryptedStore { + private readonly storePath: string; + private readonly getMasterPassword: () => Promise; + private cachedMasterPassword?: string; + + public constructor(options: EncryptedStoreOptions) { + this.storePath = options.storePath; + this.getMasterPassword = options.getMasterPassword; + } + + public async has(account: string): Promise { + try { + const store = await this.readStore(); + return account in store.entries; + } catch (error) { + if (isNodeError(error, "ENOENT")) return false; + throw error; + } + } + + public async get(account: string): Promise { + try { + const store = await this.readStore(); + const entry = store.entries[account]; + if (!entry) return undefined; + + const masterPassword = await this.getMasterPasswordCached(); + const salt = Buffer.from(entry.salt, "base64"); + const key = await pbkdf2Async(masterPassword, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); + const iv = Buffer.from(entry.iv, "base64"); + const authTag = Buffer.from(entry.authTag, "base64"); + const encrypted = Buffer.from(entry.encrypted, "base64"); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + + return decrypted.toString("utf8"); + } catch (error) { + if (isNodeError(error, "ENOENT")) return undefined; + if (error && typeof error === "object" && "message" in error) { + const message = String(error.message); + if (/Unsupported state|bad decrypt/i.test(message)) { + throw new Error("Encrypted store decryption failed; the master password may be incorrect."); + } + } + throw error; + } + } + + public async set(account: string, password: string): Promise { + const store = await this.readStoreOrEmpty(); + const masterPassword = await this.getMasterPasswordCached(); + + const salt = randomBytes(SALT_LENGTH); + const key = await pbkdf2Async(masterPassword, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); + const iv = randomBytes(IV_LENGTH); + + const cipher = createCipheriv(ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(password, "utf8"), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + store.entries[account] = { + salt: salt.toString("base64"), + iv: iv.toString("base64"), + authTag: authTag.toString("base64"), + encrypted: encrypted.toString("base64"), + }; + + await this.writeStore(store); + } + + public async delete(account: string): Promise { + try { + const store = await this.readStore(); + if (!(account in store.entries)) return false; + + delete store.entries[account]; + await this.writeStore(store); + return true; + } catch (error) { + if (isNodeError(error, "ENOENT")) return false; + throw error; + } + } + + public async initialize(masterPassword: string): Promise { + this.cachedMasterPassword = masterPassword; + const storeDir = join(this.storePath, ".."); + await mkdir(storeDir, { recursive: true, mode: 0o700 }); + await this.writeStore({ version: "1", entries: {} }); + } + + public async exists(): Promise { + try { + await access(this.storePath, constants.R_OK); + return true; + } catch { + return false; + } + } + + public async verify(masterPassword: string): Promise { + try { + const store = await this.readStore(); + if (Object.keys(store.entries).length === 0) { + return true; + } + + const firstAccount = Object.keys(store.entries)[0]; + const entry = store.entries[firstAccount]; + + const salt = Buffer.from(entry.salt, "base64"); + const key = await pbkdf2Async(masterPassword, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); + const iv = Buffer.from(entry.iv, "base64"); + const authTag = Buffer.from(entry.authTag, "base64"); + const encrypted = Buffer.from(entry.encrypted, "base64"); + + const decipher = createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + decipher.update(encrypted); + decipher.final(); + + return true; + } catch { + return false; + } + } + + private async getMasterPasswordCached(): Promise { + if (this.cachedMasterPassword) return this.cachedMasterPassword; + this.cachedMasterPassword = await this.getMasterPassword(); + return this.cachedMasterPassword; + } + + private async readStore(): Promise { + const raw = await readFile(this.storePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isStoreFile(parsed)) { + throw new Error("Encrypted store file is corrupted or has an invalid format."); + } + return parsed; + } + + private async readStoreOrEmpty(): Promise { + try { + return await this.readStore(); + } catch (error) { + if (isNodeError(error, "ENOENT")) { + return { version: "1", entries: {} }; + } + throw error; + } + } + + private async writeStore(store: StoreFile): Promise { + const storeDir = join(this.storePath, ".."); + await mkdir(storeDir, { recursive: true, mode: 0o700 }); + + const content = JSON.stringify(store, null, 2) + "\n"; + await writeFile(this.storePath, content, { encoding: "utf8", mode: 0o600 }); + } +} + +function isStoreFile(value: unknown): value is StoreFile { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const record = value as Record; + if (record.version !== "1") return false; + if (!record.entries || typeof record.entries !== "object" || Array.isArray(record.entries)) return false; + + for (const entry of Object.values(record.entries as Record)) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false; + const e = entry as Record; + if ( + typeof e.salt !== "string" + || typeof e.iv !== "string" + || typeof e.authTag !== "string" + || typeof e.encrypted !== "string" + ) return false; + } + return true; +} + +function isNodeError(error: unknown, code?: string): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error && (code === undefined || (error as NodeJS.ErrnoException).code === code); +} diff --git a/src/core/keyring.ts b/src/core/keyring.ts index 25a5e17..ba14580 100644 --- a/src/core/keyring.ts +++ b/src/core/keyring.ts @@ -4,6 +4,7 @@ import { constants } from "node:fs"; import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import { delimiter, join } from "node:path"; import { CliError } from "./errors.js"; +import { EncryptedStore } from "./encrypted-store.js"; import { defaultConfigDirectory } from "./local-store.js"; export const DEFAULT_CREDENTIAL_PROFILE = "default"; @@ -14,7 +15,8 @@ export const DEFAULT_CREDENTIAL_COMMAND_TIMEOUT_MS = 5_000; export type CredentialBackend = | "macos-keychain" | "windows-credential-manager" - | "linux-secret-service"; + | "linux-secret-service" + | "linux-encrypted-file"; export interface SecretStore { readonly backend: CredentialBackend; @@ -31,6 +33,8 @@ export interface CredentialStoreOptions { platform?: NodeJS.Platform; store?: SecretStore; credentialCommandTimeoutMs?: number; + encryptedStoreMasterPassword?: string; + promptForMasterPassword?: () => Promise; } interface StoredProfile { @@ -576,23 +580,11 @@ async function resolveLinuxSecretService( const env = options.env ?? process.env; const timeoutMs = credentialCommandTimeoutMs(options); if (!env.DBUS_SESSION_BUS_ADDRESS) { - return { - backend: "linux-secret-service", - available: false, - persistent: true, - reason: "No desktop D-Bus session is available, so Secret Service cannot be used safely.", - remediation: "Run inside an unlocked desktop session, or inject credentials from an external secret manager.", - }; + return await resolveLinuxEncryptedFile(options, namespace); } const executable = await findExecutable("secret-tool", env.PATH); if (!executable) { - return { - backend: "linux-secret-service", - available: false, - persistent: true, - reason: "secret-tool is not installed; the CLI will not fall back to session-only kernel keyrings.", - remediation: "Install libsecret-tools for your distribution, or inject credentials from an external secret manager.", - }; + return await resolveLinuxEncryptedFile(options, namespace); } const store: SecretStore = { backend: "linux-secret-service", @@ -631,6 +623,55 @@ async function resolveLinuxSecretService( }; } +async function resolveLinuxEncryptedFile( + options: CredentialStoreOptions, + namespace: SecretNamespace, +): Promise { + const backend = "linux-encrypted-file" as const; + const configDir = credentialConfigDirectory(options); + const storePath = join(configDir, "encrypted-credentials", `${namespace.service}.json`); + + const getMasterPassword = async (): Promise => { + if (options.encryptedStoreMasterPassword) { + return options.encryptedStoreMasterPassword; + } + if (options.promptForMasterPassword) { + return await options.promptForMasterPassword(); + } + throw new Error("Encrypted credential store requires a master password, but no password provider was configured."); + }; + + const encryptedStore = new EncryptedStore({ storePath, getMasterPassword }); + + const store: SecretStore = { + backend, + persistent: true, + async has(account) { + return await encryptedStore.has(account); + }, + async get(account) { + return await encryptedStore.get(account); + }, + async set(account, password) { + if (!await encryptedStore.exists()) { + const masterPassword = await getMasterPassword(); + await encryptedStore.initialize(masterPassword); + } + await encryptedStore.set(account, password); + }, + async delete(account) { + return await encryptedStore.delete(account); + }, + }; + + return { + backend, + available: true, + persistent: true, + store, + }; +} + async function findExecutable(name: string, pathValue: string | undefined): Promise { for (const directory of (pathValue ?? "").split(delimiter).filter(Boolean)) { const candidate = join(directory, name); @@ -930,7 +971,7 @@ function isCredentialConfig(value: unknown): value is CredentialConfig { } function isCredentialBackend(value: unknown): value is CredentialBackend { - return value === "macos-keychain" || value === "windows-credential-manager" || value === "linux-secret-service"; + return value === "macos-keychain" || value === "windows-credential-manager" || value === "linux-secret-service" || value === "linux-encrypted-file"; } function isNodeError(error: unknown, code?: string): error is NodeJS.ErrnoException { diff --git a/src/core/prompt.ts b/src/core/prompt.ts index 714a144..52e6483 100644 --- a/src/core/prompt.ts +++ b/src/core/prompt.ts @@ -25,12 +25,20 @@ export async function promptYesNo(question: string): Promise { } export async function promptHiddenPassword(): Promise { + return await promptHiddenInput("Password: "); +} + +export async function promptMasterPassword(): Promise { + return await promptHiddenInput("Master password: "); +} + +async function promptHiddenInput(prompt: string): Promise { requireInteractiveTerminal(); const output = new MutedOutput(process.stderr); const readline = createInterface({ input: process.stdin, output, terminal: true }); const controller = new AbortController(); readline.once("SIGINT", () => controller.abort()); - process.stderr.write("Password: "); + process.stderr.write(prompt); output.muted = true; try { return await readline.question("", { signal: controller.signal }); diff --git a/src/test/encrypted-store.test.ts b/src/test/encrypted-store.test.ts new file mode 100644 index 0000000..5723c42 --- /dev/null +++ b/src/test/encrypted-store.test.ts @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { EncryptedStore } from "../core/encrypted-store.js"; + +test("encrypted store can store, retrieve, and delete secrets", async () => { + const directory = await mkdtemp(join(tmpdir(), "sustech-cli-encrypted-store-")); + const storePath = join(directory, "store.json"); + const masterPassword = "test-master-password-123"; + + const store = new EncryptedStore({ + storePath, + getMasterPassword: async () => masterPassword, + }); + + try { + assert.equal(await store.exists(), false); + + await store.initialize(masterPassword); + assert.equal(await store.exists(), true); + + await store.set("account1", "password1"); + assert.equal(await store.has("account1"), true); + assert.equal(await store.get("account1"), "password1"); + + await store.set("account2", "password with spaces and special: chars"); + assert.equal(await store.get("account2"), "password with spaces and special: chars"); + + assert.equal(await store.has("nonexistent"), false); + assert.equal(await store.get("nonexistent"), undefined); + + assert.equal(await store.delete("account1"), true); + assert.equal(await store.has("account1"), false); + assert.equal(await store.get("account1"), undefined); + + assert.equal(await store.delete("account1"), false); + + assert.equal(await store.has("account2"), true); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("encrypted store rejects wrong master password", async () => { + const directory = await mkdtemp(join(tmpdir(), "sustech-cli-encrypted-store-wrong-")); + const storePath = join(directory, "store.json"); + + const correctPassword = "correct-password"; + const wrongPassword = "wrong-password"; + + const storeCorrect = new EncryptedStore({ + storePath, + getMasterPassword: async () => correctPassword, + }); + + try { + await storeCorrect.initialize(correctPassword); + await storeCorrect.set("account1", "secret"); + + const storeWrong = new EncryptedStore({ + storePath, + getMasterPassword: async () => wrongPassword, + }); + + await assert.rejects( + storeWrong.get("account1"), + /decryption failed.*master password/i, + ); + + assert.equal(await storeWrong.verify(wrongPassword), false); + assert.equal(await storeCorrect.verify(correctPassword), true); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("encrypted store handles empty store verification", async () => { + const directory = await mkdtemp(join(tmpdir(), "sustech-cli-encrypted-store-empty-")); + const storePath = join(directory, "store.json"); + const masterPassword = "test-password"; + + const store = new EncryptedStore({ + storePath, + getMasterPassword: async () => masterPassword, + }); + + try { + await store.initialize(masterPassword); + assert.equal(await store.verify(masterPassword), true); + assert.equal(await store.verify("wrong-password"), true); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test("encrypted store creates directory with mode 0700", async () => { + const directory = await mkdtemp(join(tmpdir(), "sustech-cli-encrypted-store-mode-")); + const storePath = join(directory, "nested", "store.json"); + const masterPassword = "test-password"; + + const store = new EncryptedStore({ + storePath, + getMasterPassword: async () => masterPassword, + }); + + try { + await store.initialize(masterPassword); + await store.set("account1", "secret"); + + if (process.platform !== "win32") { + const { stat } = await import("node:fs/promises"); + const nestedDirStats = await stat(join(directory, "nested")); + assert.equal(nestedDirStats.mode & 0o777, 0o700); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/test/keyring.test.ts b/src/test/keyring.test.ts index 5278e13..43cd8e6 100644 --- a/src/test/keyring.test.ts +++ b/src/test/keyring.test.ts @@ -258,22 +258,87 @@ test("invalid credential metadata fails closed instead of being overwritten", as } }); -test("headless Linux reports Secret Service unavailable without a keyutils fallback", async () => { - const headless = await getCredentialBackendStatus({ - platform: "linux", - env: { PATH: "/usr/bin" }, - }); - assert.equal(headless.backend, "linux-secret-service"); - assert.equal(headless.available, false); - assert.match(headless.reason ?? "", /D-Bus/); - - const missingTool = await getCredentialBackendStatus({ - platform: "linux", - env: { PATH: "", DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/mock-bus" }, - }); - assert.equal(missingTool.available, false); - assert.match(missingTool.reason ?? "", /secret-tool/); - assert.doesNotMatch(missingTool.reason ?? "", /keyutils available/i); +test("headless Linux falls back to encrypted-file backend when Secret Service unavailable", async () => { + const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-encrypted-fallback-")); + try { + const headless = await getCredentialBackendStatus({ + platform: "linux", + env: { PATH: "/usr/bin" }, + configDir, + encryptedStoreMasterPassword: "test-master-password", + }); + assert.equal(headless.backend, "linux-encrypted-file"); + assert.equal(headless.available, true); + assert.equal(headless.persistent, true); + + const missingTool = await getCredentialBackendStatus({ + platform: "linux", + env: { PATH: "", DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/mock-bus" }, + configDir, + encryptedStoreMasterPassword: "test-master-password", + }); + assert.equal(missingTool.backend, "linux-encrypted-file"); + assert.equal(missingTool.available, true); + } finally { + await rm(configDir, { recursive: true, force: true }); + } +}); + +test("linux-encrypted-file backend stores and retrieves credentials", async () => { + const configDir = await mkdtemp(join(tmpdir(), "sustech-cli-encrypted-credentials-")); + const masterPassword = "strong-master-password-123"; + try { + const saved = await saveStoredCredentials( + { profile: "encrypted", sid: "12410000", password: "user-password" }, + { + configDir, + platform: "linux", + env: { PATH: "/nonexistent" }, + encryptedStoreMasterPassword: masterPassword, + }, + ); + assert.equal(saved.backend, "linux-encrypted-file"); + assert.equal(saved.persistent, true); + + const loaded = await loadStoredCredentials("encrypted", { + configDir, + platform: "linux", + env: { PATH: "/nonexistent" }, + encryptedStoreMasterPassword: masterPassword, + }); + assert.equal(loaded.sid, "12410000"); + assert.equal(loaded.password, "user-password"); + assert.equal(loaded.backend, "linux-encrypted-file"); + + const status = await getCredentialStatus("encrypted", { + configDir, + platform: "linux", + env: { PATH: "/nonexistent" }, + encryptedStoreMasterPassword: masterPassword, + }); + assert.equal(status.configured, true); + assert.equal(status.credentialAvailable, true); + assert.equal(status.backend, "linux-encrypted-file"); + + const deleted = await deleteStoredCredentials("encrypted", { + configDir, + platform: "linux", + env: { PATH: "/nonexistent" }, + encryptedStoreMasterPassword: masterPassword, + }); + assert.equal(deleted.removed, true); + assert.equal(deleted.backend, "linux-encrypted-file"); + + const statusAfterDelete = await getCredentialStatus("encrypted", { + configDir, + platform: "linux", + env: { PATH: "/nonexistent" }, + encryptedStoreMasterPassword: masterPassword, + }); + assert.equal(statusAfterDelete.configured, false); + } finally { + await rm(configDir, { recursive: true, force: true }); + } }); test("Linux Secret Service wiring performs store, lookup, and clear through secret-tool", {