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
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@ vi.mock("../../../lib/automation-credential-api.js",()=>({
}));

describe("AutomationCredentialManager",()=>{
beforeEach(()=>{vi.clearAllMocks();vi.mocked(fetchAutomationCredentials).mockResolvedValue([{id:"credential-1",name:"Deployment token",kind:"api-token",scope:"project",projectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"}]);vi.mocked(fetchCredentialHealth).mockResolvedValue({available:false,secure:false,provider:"electron-safe-storage",keyId:null,keyVersion:null,reason:"OS secure storage is unavailable."});});
beforeEach(()=>{vi.clearAllMocks();vi.mocked(fetchAutomationCredentials).mockResolvedValue([{id:"credential-1",name:"Deployment token",kind:"api-token",scope:"project",projectId:"project-1",managementProjectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"}]);vi.mocked(fetchCredentialHealth).mockResolvedValue({available:false,secure:false,provider:"electron-safe-storage",keyId:null,keyVersion:null,reason:"OS secure storage is unavailable."});});
it("renders metadata and disables secret writes when secure storage is unavailable",async()=>{render(<AutomationCredentialManager projectId="project-1"/>);expect(await screen.findByText("Deployment token")).toBeTruthy();expect(screen.getByRole("alert").textContent).toContain("OS secure storage is unavailable.");await waitFor(()=>expect((screen.getByRole("button",{name:"Store credential"}) as HTMLButtonElement).disabled).toBe(true));expect(document.body.textContent).not.toContain("plain-secret");});
});
14 changes: 11 additions & 3 deletions docs-web/content/docs/operations-credential-security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,36 @@ Code UX resolves canonical node credential IDs and named project binding keys th
## Scope and policy

- Project credentials are owned by one project.
- Global credentials require an explicit project allowlist.
- Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it.
- Both the binding and credential must approve the requested capability.
- Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed.

Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values.

Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays and control characters are rejected rather than coerced.

## Runtime redaction boundary

Node-flow credentials exist in plaintext only for the active node attempt. Exact resolved values are replaced with `[REDACTED]` before provider responses, HTTP bodies, retry errors, external-effect payloads, diagnostics, invocation messages, attempts, node outputs, or run summaries are stored. Credential IDs and non-secret metadata remain available for auditability.

The same redactor protects provider activity and raw usage telemetry. Temporary credential references are cleared after the attempt and are never logged as redaction input. Custom-node outputs, stderr logs, and diagnostics follow the same rule.

Authorization is rechecked after decryption. Concurrent revocation, rotation, restriction, promotion, or rebinding clears the plaintext buffer and causes a retry or denial instead of returning stale access.

## Encryption and key custody

The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys.

Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a mounted file containing a base64- or hexadecimal-encoded 32-byte key. Electron uses the OS `safeStorage` boundary. Vault and KMS adapters report explicit health states. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.
Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.

## Recovery and rotation

Back up root keys separately from `app.db`; the database alone cannot recover credentials. Rotation creates a fresh encrypted envelope, increments the credential version, and records metadata about the transition. Revocation prevents subsequent resolution while retaining audit metadata.
Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata.

Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist.

## Dashboard API

Credential management uses project-scoped dashboard routes. List, health, and mutation responses return metadata only. Secret values are accepted only by create, rotate, and replace operations.

Validation failures return `400`, project/management denials return `403`, and concurrent-write conflicts return `409` for a safe caller retry.
14 changes: 11 additions & 3 deletions docs-web/operations/credential-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,36 @@ Code UX resolves canonical node credential IDs and named project binding keys th
## Scope and policy

- Project credentials are owned by one project.
- Global credentials require an explicit project allowlist.
- Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it.
- Both the binding and credential must approve the requested capability.
- Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed.

Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values.

Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays and control characters are rejected rather than coerced.

## Runtime redaction boundary

Node-flow credentials exist in plaintext only for the active node attempt. Exact resolved values are replaced with `[REDACTED]` before provider responses, HTTP bodies, retry errors, external-effect payloads, diagnostics, invocation messages, attempts, node outputs, or run summaries are stored. Credential IDs and non-secret metadata remain available for auditability.

The same redactor protects provider activity and raw usage telemetry. Temporary credential references are cleared after the attempt and are never logged as redaction input. Custom-node outputs, stderr logs, and diagnostics follow the same rule.

Authorization is rechecked after decryption. Concurrent revocation, rotation, restriction, promotion, or rebinding clears the plaintext buffer and causes a retry or denial instead of returning stale access.

## Encryption and key custody

The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys.

Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a mounted file containing a base64- or hexadecimal-encoded 32-byte key. Electron uses the OS `safeStorage` boundary. Vault and KMS adapters report explicit health states. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.
Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback.

## Recovery and rotation

Back up root keys separately from `app.db`; the database alone cannot recover credentials. Rotation creates a fresh encrypted envelope, increments the credential version, and records metadata about the transition. Revocation prevents subsequent resolution while retaining audit metadata.
Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata.

Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist.

## Dashboard API

Credential management uses project-scoped dashboard routes. List, health, and mutation responses return metadata only. Secret values are accepted only by create, rotate, and replace operations.

Validation failures return `400`, project/management denials return `403`, and concurrent-write conflicts return `409` for a safe caller retry.
16 changes: 12 additions & 4 deletions docs/operations/credential-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,32 +5,40 @@ Code UX stores automation credentials through a broker rather than exposing secr
## Scope and policy

- Project credentials can be managed only through their owning project.
- Global credentials are opt-in and require an explicit project allowlist containing the configuring project.
- Global credentials are opt-in and require an explicit project allowlist containing the configuring project. The configuring project remains the credential's management owner after promotion; other allowlisted projects may bind and resolve it but cannot rotate, replace, revoke, promote, or restrict it.
- Resolution succeeds only when both the credential and binding approve the requested capability.
- Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed.

The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material.

Management inputs are validated at runtime rather than trusted from TypeScript types. Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner.

## Runtime redaction boundary

Node-flow execution resolves credential values only for the active node attempt. Before any provider response, HTTP body, retry error, external-effect payload, diagnostic, invocation message, attempt, node output, or run summary is persisted, the runtime replaces exact resolved values with `[REDACTED]` in addition to masking secret-shaped keys. Credential IDs and non-secret metadata remain available for audit and attempt correlation.

Provider activity persistence uses the same invocation-scoped redactor, including raw usage telemetry and provider session identifiers. Temporary credential references are cleared after each attempt and are never included in redaction logs or diagnostics. Custom-node containers apply the equivalent policy to structured output, stderr logs, and diagnostics before returning control to the flow runtime.

Resolution authorization is checked both before and after decryption. If a credential is revoked, rotated, restricted, promoted, or rebound while a read is in flight, the plaintext buffer is cleared and the broker denies or retries against the current version; stale authorization is never returned to the caller.

## Encryption and key custody

The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions.

Root keys are never stored in SQLite. Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to identify a mounted file whose contents decode from base64 or hexadecimal to exactly 32 bytes. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace.
Root keys are never stored in SQLite. Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to identify a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace.

Electron uses the OS-backed `safeStorage` boundary and refuses credential operations when secure OS storage is unavailable. Vault and KMS adapters expose explicit health states. No provider silently falls back to plaintext or an insecure locally derived key.
Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key.

## Recovery and rotation

Back up root keys independently from `app.db`. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient.

Credential rotation writes a fresh envelope with a new data key and nonces, increments the credential version, and records metadata about the transition. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation prevents resolution immediately while preserving audit metadata.
Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards allow only one overlapping value change to commit; losing callers must retry instead of overwriting a newer secret. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata.

Existing global credentials created before management ownership was stored are migrated with their first valid allowlisted project as the management owner. Operators should verify that owner before expanding a legacy global credential's allowlist.

## API surface

Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bind, test, rotate, replace, revoke, promote, and restrict. List and health endpoints return metadata only. Existing dashboard authentication and middleware apply before these routes.

Runtime validation failures return `400`, project/management denials return `403`, and compare-and-swap conflicts return `409` so callers can retry without treating policy failures as server crashes.
3 changes: 3 additions & 0 deletions src/app/lifecycle/dashboard-lifecycle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ import type {
LocalMcpSetupInfo,
} from "../../services/local-mcp-cli-config-service.js";
import type { ProjectInitializationStateService } from "../../services/project-initialization-state-service.js";
import type { CredentialBroker } from "../../services/credentials/credential-broker.js";

const updateCheckerService = new UpdateCheckerService();

Expand Down Expand Up @@ -143,6 +144,7 @@ export interface BootDashboardDeps {
speechModelManager: SpeechModelManager;
chatProviderOutboundService?: ChatProviderOutboundService;
nodeFlowService?: NodeFlowService;
credentialBroker: CredentialBroker;
headlessAuthService: HeadlessAuthService;
automationAuditService: AutomationAuditExportService;
headlessReadinessService: HeadlessOperationalReadinessService;
Expand Down Expand Up @@ -502,6 +504,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise<DashboardS
speechSynthesisService: deps.speechSynthesisService,
speechModelManager: deps.speechModelManager,
nodeFlowService: deps.nodeFlowService,
credentialBroker: deps.credentialBroker,
headlessAuthService: deps.headlessAuthService,
automationAuditService: deps.automationAuditService,
headlessReadinessService: deps.headlessReadinessService,
Expand Down
2 changes: 2 additions & 0 deletions src/contracts/automation-credential-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface AutomationCredentialMetadata {
kind: string;
scope: AutomationCredentialScope;
projectId: string | null;
/** Project whose credential administrators may mutate this record. */
managementProjectId: string | null;
allowedProjectIds: string[];
capabilities: AutomationCredentialCapability[];
status: AutomationCredentialStatus;
Expand Down
44 changes: 36 additions & 8 deletions src/electron/credential-key-persistence.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { link, mkdir, open, unlink } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { dirname } from "node:path";
import type { ProtectedKeyPersistence } from "../infrastructure/security/electron-safe-storage-key-provider.js";

Expand All @@ -7,17 +8,44 @@ export class ElectronCredentialKeyPersistence implements ProtectedKeyPersistence
constructor(private readonly filePath: string) {}

async read(): Promise<Buffer | null> {
try { return await readFile(this.filePath); }
catch (error) {
let handle;
try {
handle = await open(this.filePath, "r");
const info = await handle.stat();
if (!info.isFile()) throw new Error("Protected credential key path must resolve to a regular file.");
if ((info.mode & 0o077) !== 0) throw new Error("Protected credential key file must use owner-only permissions.");
if (info.size > 64 * 1024) throw new Error("Protected credential key file is unexpectedly large.");
return await handle.readFile();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
} finally {
await handle?.close().catch(() => undefined);
}
}

async write(value: Buffer): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true });
const temporaryPath = `${this.filePath}.tmp`;
await writeFile(temporaryPath, value, { mode: 0o600 });
await rename(temporaryPath, this.filePath);
async writeIfAbsent(value: Buffer): Promise<boolean> {
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 });
const temporaryPath = `${this.filePath}.${randomUUID()}.tmp`;
try {
const handle = await open(temporaryPath, "wx", 0o600);
try {
await handle.writeFile(value);
await handle.sync();
} finally {
await handle.close();
}
try {
await link(temporaryPath, this.filePath);
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
throw error;
}
} catch (error) {
throw error;
} finally {
await unlink(temporaryPath).catch(() => undefined);
}
}
}
Loading
Loading