mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
* fix(orcad): seal integration credentials on the headless server instead of storing them in plaintext The Node SecretStore always reported encryption unavailable, so every credential writer on orcad fell back to plaintext. orcad now seals with AES-256-GCM using a host-owned master key kept in the OS keyring (macOS Keychain, Linux Secret Service) when reachable at first start, else in a 0600 profile file bound to the machine ID. A key that cannot load fails closed instead of downgrading. Existing plaintext credentials are sealed on startup without loss. * docs: describe how the headless server seals credentials and its limits * fix(orcad): unique credential staging files, no keyring items for temp profiles, isolate startup test * fix(orcad): keep the credential key with the credentials, one record per machine * fix(orcad): never re-key over an unreadable record, serialize first-start key creation, refuse an unrestricted key file * fix(orcad): verify a key record's Windows ACL and wait out a concurrent first start's keyring write * fix(orcad): keep a keyring claim and fail closed when its store result is uncertain * fix(credentials): restrict the staged file's Windows ACL before it replaces a credential * fix(orcad): a temporary profile never creates a key in, or re-seals, the real credential home * ci: baseline the two single-file credential ACL restrictions * fix(credentials): stage Windows credentials born restricted and verify the key record asynchronously; document rollback * test(bitbucket): inject the write failure into the Windows staging writer too
109 lines
3.5 KiB
TypeScript
109 lines
3.5 KiB
TypeScript
import { unlinkSync } from 'node:fs'
|
|
import { writeEncryptedCredential } from '../integration-credential-file'
|
|
import {
|
|
LEGACY_WORKSPACE_ID,
|
|
ensureOrcaDir,
|
|
ensureWorkspaceTokenDir,
|
|
getWorkspaceTokenPath
|
|
} from './linear-credential-paths'
|
|
import {
|
|
clearLegacyViewerOnDisk,
|
|
forgetLegacyViewer,
|
|
resetLegacyViewerCache
|
|
} from './linear-legacy-viewer-store'
|
|
import { emptyWorkspaceFile } from './linear-workspace-record'
|
|
import {
|
|
getWorkspaceFile,
|
|
getWorkspaceState,
|
|
resetCredentialCaches,
|
|
resetWorkspaceFileCacheToEmpty,
|
|
resolveWorkspaceId,
|
|
upsertWorkspace,
|
|
writeWorkspaceFile,
|
|
workspaceTokenFileCache
|
|
} from './linear-workspace-registry'
|
|
import type { LinearWorkspace } from '../../shared/linear/workspace-types'
|
|
import { readCredentialFileProtection } from '../credential-file-protection'
|
|
import type { SecretAtRestProtection } from '../../shared/secret-at-rest-protection'
|
|
|
|
export function saveWorkspaceToken(workspaceId: string, apiKey: string): void {
|
|
ensureOrcaDir()
|
|
if (workspaceId !== LEGACY_WORKSPACE_ID) {
|
|
ensureWorkspaceTokenDir()
|
|
}
|
|
const tokenPath = getWorkspaceTokenPath(workspaceId)
|
|
// Why the shared writer: it re-applies 0600 to an existing file and never truncates it mid-write.
|
|
writeEncryptedCredential('Linear', tokenPath, apiKey)
|
|
workspaceTokenFileCache.forget(workspaceId)
|
|
}
|
|
|
|
// Backward-compatible export for the legacy single-workspace storage path.
|
|
export function saveToken(apiKey: string): void {
|
|
saveWorkspaceToken(LEGACY_WORKSPACE_ID, apiKey)
|
|
}
|
|
|
|
export function loadToken(options: { force?: boolean; workspaceId?: string } = {}): string | null {
|
|
const workspaceId = options.workspaceId ?? resolveWorkspaceId()
|
|
if (!workspaceId) {
|
|
return null
|
|
}
|
|
return workspaceTokenFileCache.read(workspaceId, options.force === true)
|
|
}
|
|
|
|
/** How a workspace's stored token sits on disk, or null when none is stored. */
|
|
export function getWorkspaceTokenProtection(workspaceId: string): SecretAtRestProtection | null {
|
|
return readCredentialFileProtection(getWorkspaceTokenPath(workspaceId))
|
|
}
|
|
|
|
export function clearTokenFile(workspaceId: string): void {
|
|
workspaceTokenFileCache.forget(workspaceId)
|
|
try {
|
|
unlinkSync(getWorkspaceTokenPath(workspaceId))
|
|
} catch {
|
|
// File may not exist — safe to ignore.
|
|
}
|
|
}
|
|
|
|
export function clearToken(workspaceId?: string): void {
|
|
if (!workspaceId) {
|
|
const state = getWorkspaceState()
|
|
for (const workspace of state.workspaces) {
|
|
clearTokenFile(workspace.id)
|
|
}
|
|
resetCredentialCaches()
|
|
resetLegacyViewerCache()
|
|
resetWorkspaceFileCacheToEmpty()
|
|
clearLegacyViewerOnDisk()
|
|
writeWorkspaceFile(emptyWorkspaceFile())
|
|
return
|
|
}
|
|
|
|
clearTokenFile(workspaceId)
|
|
if (workspaceId === LEGACY_WORKSPACE_ID) {
|
|
resetLegacyViewerCache()
|
|
clearLegacyViewerOnDisk()
|
|
return
|
|
}
|
|
|
|
const file = getWorkspaceFile()
|
|
const workspaces = file.workspaces.filter((workspace) => workspace.id !== workspaceId)
|
|
const activeWorkspaceId =
|
|
file.activeWorkspaceId === workspaceId ? (workspaces[0]?.id ?? null) : file.activeWorkspaceId
|
|
const selectedWorkspaceId =
|
|
file.selectedWorkspaceId === workspaceId ? activeWorkspaceId : file.selectedWorkspaceId
|
|
writeWorkspaceFile({
|
|
version: 1,
|
|
activeWorkspaceId,
|
|
selectedWorkspaceId,
|
|
workspaces
|
|
})
|
|
}
|
|
|
|
export function replaceLegacyWorkspace(workspace: LinearWorkspace, token: string): void {
|
|
saveWorkspaceToken(workspace.id, token)
|
|
clearTokenFile(LEGACY_WORKSPACE_ID)
|
|
clearLegacyViewerOnDisk()
|
|
forgetLegacyViewer()
|
|
upsertWorkspace(workspace, { select: true })
|
|
}
|