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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 9 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 38 additions & 9 deletions docs/AUTHENTICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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

Expand Down
14 changes: 12 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
import {
promptHiddenPassword,
promptLoginSid,
promptMasterPassword,
promptYesNo,
readCalendarLinkFromStdin,
readPasswordFromStdin,
Expand Down Expand Up @@ -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.",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/core/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
225 changes: 225 additions & 0 deletions src/core/encrypted-store.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}

interface EncryptedEntry {
salt: string;
iv: string;
authTag: string;
encrypted: string;
}

interface StoreFile {
version: "1";
entries: Record<string, EncryptedEntry>;
}

export class EncryptedStore {
private readonly storePath: string;
private readonly getMasterPassword: () => Promise<string>;
private cachedMasterPassword?: string;

public constructor(options: EncryptedStoreOptions) {
this.storePath = options.storePath;
this.getMasterPassword = options.getMasterPassword;
}

public async has(account: string): Promise<boolean> {
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<string | undefined> {
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<void> {
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<boolean> {
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<void> {
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<boolean> {
try {
await access(this.storePath, constants.R_OK);
return true;
} catch {
return false;
}
}

public async verify(masterPassword: string): Promise<boolean> {
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<string> {
if (this.cachedMasterPassword) return this.cachedMasterPassword;
this.cachedMasterPassword = await this.getMasterPassword();
return this.cachedMasterPassword;
}

private async readStore(): Promise<StoreFile> {
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<StoreFile> {
try {
return await this.readStore();
} catch (error) {
if (isNodeError(error, "ENOENT")) {
return { version: "1", entries: {} };
}
throw error;
}
}

private async writeStore(store: StoreFile): Promise<void> {
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<string, unknown>;
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<string, unknown>)) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return false;
const e = entry as Record<string, unknown>;
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);
}
Loading