fix(orcad): explain a locked macOS Keychain to the user and recover Linear/Jira without a restart (#29608)

* fix(orcad): tell the user when a locked macOS Keychain hides Linear and Jira, and recover without a restart

A managed macOS host reached only over SSH keeps its login Keychain locked
after a reboot, so the server's credential key failed closed and the app
asked the user to reconnect a token that was fine. Exit 36 from
security find-generic-password (errSecInteractionNotAllowed) is now a
'locked' lookup: the server logs it once, retries the Keychain on each
credential use and before each Linear/Jira status read, and status carries
an optional credentialErrorReason: 'keychain_locked' that the app shows in
place of the generic text. Once unlocked, Settings offers a one-click move
of the same key into the owner-only key file.

* fix(orcad): keep retrying a locked Keychain after a timeout, fence the key move to the host that asked, report the lock only for credentials that need the key

* fix(settings): offer the file-storage switch to a paired browser's implicit server too

* fix(settings): route the Keychain notice and key move by the host that reported the status; sweep stale key-record staging; say the moved key is not machine-bound

* fix(orcad): sweep only this machine's key-record staging, so a shared home's other hosts keep theirs
This commit is contained in:
Neil
2026-10-16 04:09:33 -07:00
committed by GitHub
parent dfc94d7e3c
commit 839f553dee
35 changed files with 1325 additions and 255 deletions
+39 -4
View File
@@ -13,10 +13,10 @@ That is the directory the credentials live in, whatever `ORCA_USER_DATA` says, s
exactly as long as the credentials it seals. There is one record per machine, so hosts that share
a home each keep their own key.
| Host | Where the key goes |
| --- | --- |
| macOS | The login Keychain (through `/usr/bin/security`) if the server can reach it on first start |
| Linux | The Secret Service keyring (`secret-tool`) if the session has a D-Bus session bus and a keyring answers |
| Host | Where the key goes |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| macOS | The login Keychain (through `/usr/bin/security`) if the server can reach it on first start |
| Linux | The Secret Service keyring (`secret-tool`) if the session has a D-Bus session bus and a keyring answers |
| Otherwise, and on Windows | the record file itself, mode `0600` (an owner-only ACL on Windows), mixed with the machine ID (`/etc/machine-id`, macOS hardware UUID, Windows `MachineGuid`) |
The keyring is never used for a key directory under the system temp directory or `/tmp` (tests),
@@ -30,6 +30,41 @@ keeps the old behavior. It stores credentials unencrypted and never creates a ke
Windows does not use DPAPI. Reaching it from Node takes PowerShell or a native addon, and running
PowerShell for each operation is an EDR signal.
## A locked macOS login Keychain
The login Keychain is unlocked when you sign in at the Mac's screen. After a reboot, a Mac you
only reach over SSH keeps it locked. The server's key lookup (`security find-generic-password`)
then exits 36 (`errSecInteractionNotAllowed`: locked, and the session may not show an unlock
prompt). The server does not treat that like a missing or broken key:
- It fails closed as usual, and logs one `[secrets]` line saying the Keychain is locked.
- Linear and Jira status carry `credentialErrorReason: 'keychain_locked'`. The app then names the
host and the cause in the integration settings and on the task page, instead of asking you to
reconnect. The canonical decrypt error also gets an English hint, for clients too old to read
the field.
- It looks the key up again whenever a credential is used (at most every 2 seconds) and before
each Linear or Jira status read. So once the Keychain opens, credentials work again without a
server restart.
To unlock it, sign in to the Mac at its screen once. Running `security unlock-keychain` in a
separate SSH login may not help: the server was started from an earlier SSH login, and macOS can
track Keychain unlock state per login session. This has not yet been checked on a real headless
Mac.
Once the key has loaded again after a lock, status sets `canMoveCredentialKeyToFile`, and
Settings offers **Switch to file storage** (RPC `integrationCredentials.moveKeyToFile`). This reads
the master key from the Keychain, then replaces the record with an owner-only (`0600`) key-file
record that holds the same key, not bound to the machine ID. Keychain keys are never machine-bound,
so every sealed credential still opens and nothing is re-sealed. The Keychain item stays where it
is. The switch is refused while the Keychain is locked, because the key can't be read then. After
the switch, the key file's limitations below apply. In particular, the moved key is not bound to
the machine ID, so a backup of `~/.orca`, or a copy on a shared (NFS) home, can open the
credentials on another machine.
On startup the server also deletes key-record staging files (`*.staging`) that a crashed write
left in the credential directory. It deletes only this machine's record's staging files owned by
this user, and only when the process that wrote them has exited.
## Limitations
- **The key file is weaker than a keychain.** The key sits next to the credentials, and the
+6 -2
View File
@@ -9,11 +9,12 @@ import {
unlinkSync,
writeSync
} from 'node:fs'
import { getSecretStore } from '../shared/secret-store'
import { getSecretStore, hasSecretStore } from '../shared/secret-store'
import { fsyncFileSync } from '../shared/secure-file'
import { writeRestrictedWindowsFile } from '../shared/secure-path-windows-acl'
import {
credentialDecryptionMessage,
keychainLockedCredentialHint,
type IntegrationCredentialService
} from '../shared/integration-credential-errors'
@@ -123,7 +124,10 @@ export function restrictCredentialFileToOwner(path: string): void {
export class CredentialDecryptionError extends Error {
constructor(service: IntegrationCredentialService) {
super(credentialDecryptionMessage(service))
const message = credentialDecryptionMessage(service)
// Why read the store here: the reason must survive RPC, where only the message travels.
const locked = hasSecretStore() && getSecretStore().keychainLock?.state() === 'locked'
super(locked ? `${message} ${keychainLockedCredentialHint()}` : message)
this.name = 'CredentialDecryptionError'
}
}
@@ -0,0 +1,55 @@
/**
* What Linear and Jira status report about a host whose credential key sits in a lockable
* keychain (orcad on macOS). Every field is optional so older clients ignore it.
*/
import type { IntegrationCredentialErrorReason } from '../shared/integration-credential-errors'
import {
getSecretStore,
hasSecretStore,
type SecretStoreKeyMoveResult
} from '../shared/secret-store'
import { forgetIntegrationCredentialDecryptFailures } from './integration-token-file-cache'
export type IntegrationCredentialKeychainFields = {
credentialErrorReason?: IntegrationCredentialErrorReason
canMoveCredentialKeyToFile?: true
}
function keychainLock() {
return hasSecretStore() ? getSecretStore().keychainLock : undefined
}
/** Retries a locked key before status is read, so an unlock shows up without a restart. */
export async function recoverIntegrationCredentialKey(): Promise<void> {
if (await keychainLock()?.recover()) {
forgetIntegrationCredentialDecryptFailures()
}
}
export function integrationCredentialKeychainFields(credentials: {
saved: boolean
/** A saved credential failed to decrypt; legacy plaintext ones still read while locked. */
decryptFailed: boolean
}): IntegrationCredentialKeychainFields {
if (!credentials.saved) {
return {}
}
const state = keychainLock()?.state()
if (state === 'locked') {
return credentials.decryptFailed ? { credentialErrorReason: 'keychain_locked' } : {}
}
// Why only after a lock: file storage is weaker, so offer it only to hosts that need it.
return state === 'unlocked_after_lock' ? { canMoveCredentialKeyToFile: true } : {}
}
export async function moveIntegrationCredentialKeyToFile(): Promise<SecretStoreKeyMoveResult> {
const lock = keychainLock()
if (!lock) {
return {
ok: false,
reason: 'not_in_keychain',
message: 'This host does not keep its credential key in a keychain.'
}
}
return lock.moveKeyToFile()
}
+15
View File
@@ -12,6 +12,14 @@ import {
type TokenFileState = { present: boolean; token?: string; error?: string }
const MAX_CREDENTIAL_ENTRIES = 128
const decryptFailureResets = new Set<() => void>()
/** Drops every recorded decrypt failure, e.g. once a locked keychain opens, so status stops citing it. */
export function forgetIntegrationCredentialDecryptFailures(): void {
for (const reset of decryptFailureResets) {
reset()
}
}
export function createIntegrationTokenFileCache(args: {
service: IntegrationCredentialService
@@ -37,6 +45,13 @@ export function createIntegrationTokenFileCache(args: {
}
return entry.get()
}
decryptFailureResets.add(() => {
for (const [id, entry] of entries) {
if (entry.get().error !== undefined) {
entries.delete(id)
}
}
})
return {
has(id: string): boolean {
return stateFor(id).present
+5
View File
@@ -1,4 +1,5 @@
import { CredentialDecryptionError } from '../integration-credential-file'
import { integrationCredentialKeychainFields } from '../integration-credential-keychain'
import type {
JiraAuthType,
JiraConnectArgs,
@@ -75,6 +76,10 @@ export function getStatus(): JiraConnectionStatus {
activeSiteId: activeSite?.id ?? null,
selectedSiteId: file.selectedSiteId ?? activeSite?.id ?? null,
...(credentialError ? { credentialError } : {}),
...integrationCredentialKeychainFields({
saved: sites.length > 0,
decryptFailed: credentialError !== undefined
}),
credentialProtection,
supportsScopedTokens: true
}
+5
View File
@@ -25,6 +25,7 @@ import {
getWorkspaceTokenProtection
} from './linear-token-store'
import { CredentialDecryptionError } from '../integration-credential-file'
import { integrationCredentialKeychainFields } from '../integration-credential-keychain'
import type {
LinearConnectionStatus,
LinearViewer,
@@ -197,6 +198,10 @@ export function getStatus(): LinearConnectionStatus {
activeWorkspaceId: state.activeWorkspaceId,
selectedWorkspaceId: state.selectedWorkspaceId,
...(credentialError ? { credentialError } : {}),
...integrationCredentialKeychainFields({
saved: state.workspaces.length > 0,
decryptFailed: credentialError !== undefined
}),
credentialProtection
}
}
+12 -1
View File
@@ -22,7 +22,18 @@ describe('resolveOrcadOsKeyring', () => {
expect(await keychain.lookup('acct')).toEqual({ kind: 'found', value: 'a2V5' })
runner.mockResolvedValueOnce(result({ code: 44 }))
expect(await keychain.lookup('acct')).toEqual({ kind: 'missing' })
runner.mockResolvedValueOnce(result({ code: 36, stderr: 'User interaction is not allowed.' }))
// errSecInteractionNotAllowed: locked, and an SSH session may not prompt to unlock it.
runner.mockResolvedValueOnce(
result({
code: 36,
stderr: 'security: SecKeychainSearchCopyNext: User interaction is not allowed.\nmore detail'
})
)
expect(await keychain.lookup('acct')).toEqual({
kind: 'locked',
reason: 'exited 36: security: SecKeychainSearchCopyNext: User interaction is not allowed.'
})
runner.mockResolvedValueOnce(result({ code: 51, stderr: 'Unknown failure' }))
expect((await keychain.lookup('acct')).kind).toBe('unreachable')
runner.mockResolvedValueOnce(result({ code: null, timedOut: true }))
expect(await keychain.lookup('acct')).toEqual({
+12 -4
View File
@@ -15,10 +15,14 @@ const MAX_OUTPUT_BYTES = 4096
export type OrcadKeyringRunner = (spec: ProcessSpec) => Promise<ProcessResult>
/** `missing`: the keyring answered and has no item. `unreachable`: absent, locked, or timed out. */
/**
* `missing`: the keyring answered and has no item. `locked`: the macOS Keychain is locked and this
* session may not prompt to unlock it. `unreachable`: absent, otherwise locked, or timed out.
*/
export type OrcadKeyringLookup =
| { kind: 'found'; value: string }
| { kind: 'missing' }
| { kind: 'locked'; reason: string }
| { kind: 'unreachable'; reason: string }
export type OrcadOsKeyring = {
@@ -64,9 +68,13 @@ function macosKeychain(runner: OrcadKeyringRunner): OrcadOsKeyring {
if (result.code === 0 && result.stdout.trim()) {
return { kind: 'found', value: result.stdout.trim() }
}
// errSecItemNotFound.
return result.code === 44
? { kind: 'missing' }
// Why exit codes: `security` exits with the low byte of the OSStatus, so errSecItemNotFound
// (-25300) is 44 and errSecInteractionNotAllowed (-25308, locked with no UI) is 36.
if (result.code === 44) {
return { kind: 'missing' }
}
return result.code === 36 && !result.timedOut
? { kind: 'locked', reason: describeFailure(result) }
: { kind: 'unreachable', reason: describeFailure(result) }
},
async store(account, value) {
+60
View File
@@ -0,0 +1,60 @@
/**
* Moves orcad's master key out of the macOS Keychain into the owner-only key record, for a host
* reached only over SSH whose login Keychain stays locked after a reboot.
*
* Why the same master key, unbound: Keychain-backed keys derive with no machine binding, so an
* unbound file record derives the identical sealing key and every sealed credential still opens.
* Re-sealing each credential under a new key would risk losing one halfway through.
*/
import type { SecretStoreKeyMoveResult } from '../../shared/secret-store'
import type { OrcadKeyLocation } from './orcad-secret-key-resolution'
import { decodeMasterKey, readStoredRecord, replaceRecord } from './orcad-secret-key-record'
import { deriveOrcadSealingKey, orcadSealingKeyCheck } from './orcad-secret-sealing'
type MoveFailure = Extract<SecretStoreKeyMoveResult, { ok: false }>
export type OrcadKeyMoveOutcome = { ok: true; key: Buffer } | MoveFailure
export async function moveOrcadKeyToFile(location: OrcadKeyLocation): Promise<OrcadKeyMoveOutcome> {
const existing = readStoredRecord(location.statePath)
if (existing.kind !== 'record' || existing.stored.backend !== 'os-keyring') {
return {
ok: false,
reason: 'not_in_keychain',
message: 'This server does not keep its credential key in the Keychain.'
}
}
const { keyring } = location
if (!keyring) {
return {
ok: false,
reason: 'failed',
message: 'The Keychain that holds the credential key is not reachable.'
}
}
const { account, check } = existing.stored
// Why read the Keychain again: the store holds only the derived key, and the record needs the
// master key; reading it also proves the Keychain is unlocked right now.
const found = await keyring.lookup(account)
if (found.kind === 'locked') {
return { ok: false, reason: 'keychain_locked', message: 'The macOS login Keychain is locked.' }
}
const master = found.kind === 'found' ? decodeMasterKey(found.value) : null
const key = master ? deriveOrcadSealingKey(master, '') : null
if (!master || !key || orcadSealingKeyCheck(key) !== check) {
return {
ok: false,
reason: 'failed',
message: 'The Keychain did not return the credential key this server seals with.'
}
}
const replaced = replaceRecord(
location.statePath,
{ version: 1, backend: 'host-file', key: master.toString('base64'), hostBound: false, check },
check
)
if (replaced !== 'replaced') {
return { ok: false, reason: 'failed', message: `The key could not be moved: ${replaced}.` }
}
return { ok: true, key }
}
+76 -2
View File
@@ -1,8 +1,17 @@
/** orcad's key record on disk: naming, reading, and exclusive first publication. */
import { createHash, randomBytes } from 'node:crypto'
import { linkSync, readFileSync, realpathSync, rmSync, statSync } from 'node:fs'
import {
linkSync,
lstatSync,
readdirSync,
readFileSync,
realpathSync,
renameSync,
rmSync,
statSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, relative, resolve } from 'node:path'
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
import process from 'node:process'
import {
bestEffortFsyncDirectorySync,
@@ -152,6 +161,71 @@ export function publishRecordExclusively(path: string, record: StoredKeyState):
}
}
/** Replaces the record only while it still holds `expectedCheck`'s key; otherwise says why not. */
export function replaceRecord(path: string, record: StoredKeyState, expectedCheck: string): string {
const staging = `${path}.${process.pid}.${randomBytes(4).toString('hex')}.staging`
try {
if (!writeSecureFile(staging, JSON.stringify(record), { durable: true })) {
return `its key record ${path} could not be restricted to this user`
}
const current = readStoredRecord(path)
if (current.kind !== 'record' || current.stored.check !== expectedCheck) {
return 'its key record changed while it was being moved'
}
renameSync(staging, path)
bestEffortFsyncDirectorySync(dirname(path))
return 'replaced'
} catch (error) {
return `its key record ${path} could not be written (${error instanceof Error ? error.message : String(error)})`
} finally {
rmSync(staging, { force: true })
}
}
function isLiveProcess(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (error) {
// EPERM: alive, owned by someone else.
return errorCode(error) === 'EPERM'
}
}
/**
* Removes staging files a crashed write of this record left beside it; they can hold a master key.
* Why only this record's: on a shared (NFS) home another machine's writer PID means nothing here.
* Only this user's regular files, whose local writer is gone.
*/
export function sweepStaleRecordStaging(statePath: string): void {
const dir = dirname(statePath)
const prefix = `${basename(statePath)}.`
let names: string[]
try {
names = readdirSync(dir)
} catch {
return
}
const uid = typeof process.getuid === 'function' ? process.getuid() : null
for (const name of names) {
const pid = name.startsWith(prefix)
? /^(\d+)\.[0-9a-f]{8}\.staging$/.exec(name.slice(prefix.length))?.[1]
: undefined
if (!pid || Number(pid) === process.pid || isLiveProcess(Number(pid))) {
continue
}
const path = join(dir, name)
try {
const stat = lstatSync(path)
if (stat.isFile() && (uid === null || stat.uid === uid)) {
rmSync(path, { force: true })
}
} catch {
// Gone already, or not ours to remove.
}
}
}
export function withdrawRecord(path: string, check: string): void {
try {
if (parseStoredState(readFileSync(path, 'utf8'))?.check === check) {
@@ -0,0 +1,267 @@
/**
* How orcad finds or first creates its master key: the record beside the credentials says whether
* the key is in the OS keyring or in the record itself (see orcad-secret-store.ts).
*/
import { randomBytes } from 'node:crypto'
import { join } from 'node:path'
import process from 'node:process'
import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity'
import { resolveOrcadOsKeyring, type OrcadOsKeyring } from './orcad-os-keyring'
import {
decodeMasterKey,
isTemporaryKeyDir,
keyringAccount,
orcadSecretStateFileName,
publishRecordExclusively,
readStoredRecord,
restrictExistingRecord,
withdrawRecord,
type StoredKeyState
} from './orcad-secret-key-record'
import {
describeOrcadHostKeyGap,
describeOrcadDisposableProfile,
describeOrcadKeychainLocked,
describeOrcadPlaintextOptOut,
describeOrcadSealingUnavailable,
ORCAD_PLAINTEXT_SECRETS_ENV
} from './orcad-secret-protection-gap'
import {
deriveOrcadSealingKey,
ORCAD_MASTER_KEY_BYTES,
orcadSealingKeyCheck
} from './orcad-secret-sealing'
export type StoreState =
| { kind: 'pending' }
| { kind: 'plaintext'; reason: string }
| { kind: 'ready'; key: Buffer; gap: string | null; inKeychain: boolean }
| { kind: 'unavailable'; message: string; keychainLocked?: true }
export type OrcadSecretStorePrepareOptions = {
/** Orca's credential directory (`~/.orca`), so the key lives exactly as long as what it seals. */
keyDir: string
/** A temporary profile keeps the legacy behavior and leaves `keyDir` untouched. */
userDataPath?: string
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
keyring?: OrcadOsKeyring | null
machineIdentity?: () => string | null
isTemporaryKeyDir?: (keyDir: string) => boolean
/** How long to wait for a concurrent first start to finish storing its keyring key. */
claimSettleMs?: number
}
// A claimant's keyring store and read-back can each take the full 5s keyring timeout.
const CLAIM_SETTLE_MS = 12_000
type Paths = { statePath: string; unavailable: (reason: string) => StoreState }
type KeyContext = {
platform: NodeJS.Platform
keyring: OrcadOsKeyring | null
machineId: string | null
claimSettleMs?: number
}
async function loadStoredRecord(
stored: StoredKeyState,
paths: Paths,
context: KeyContext
): Promise<StoreState | 'record-changed'> {
const { unavailable } = paths
if (stored.backend === 'os-keyring') {
const { keyring } = context
if (!keyring) {
return unavailable('the OS keyring that holds its key is not reachable from this session')
}
let found = await keyring.lookup(stored.account)
// Why wait: a concurrent first start may have claimed the record and still be storing the key
// (one keyring store plus one lookup), or may withdraw the claim and switch to the file.
const deadline = Date.now() + (context.claimSettleMs ?? CLAIM_SETTLE_MS)
while (found.kind === 'missing' && Date.now() < deadline) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
const current = readStoredRecord(paths.statePath)
if (current.kind !== 'record' || current.stored.check !== stored.check) {
return 'record-changed'
}
found = await keyring.lookup(stored.account)
}
if (found.kind === 'locked') {
return { kind: 'unavailable', message: describeOrcadKeychainLocked(), keychainLocked: true }
}
if (found.kind !== 'found') {
return unavailable(
found.kind === 'missing'
? `its key is missing from ${keyring.label}`
: `${keyring.label} is locked or unreachable (${found.reason}); unlock it`
)
}
const master = decodeMasterKey(found.value)
const key = master ? deriveOrcadSealingKey(master, '') : null
if (!key || orcadSealingKeyCheck(key) !== stored.check) {
return unavailable(`the key in ${keyring.label} does not match the one it sealed with`)
}
return { kind: 'ready', key, gap: null, inKeychain: true }
}
if (!(await restrictExistingRecord(paths.statePath))) {
return unavailable(`its key record ${paths.statePath} could not be restricted to this user`)
}
const master = decodeMasterKey(stored.key)
const binding = stored.hostBound ? context.machineId : ''
if (!master || binding === null) {
return unavailable('this machine no longer reports the machine ID its key is bound to')
}
const key = deriveOrcadSealingKey(master, binding)
if (orcadSealingKeyCheck(key) !== stored.check) {
return unavailable('its key is bound to a different machine ID')
}
return {
kind: 'ready',
key,
gap: describeOrcadHostKeyGap(context.platform, stored.hostBound),
inKeychain: false
}
}
export type OrcadKeyLocation = {
platform: NodeJS.Platform
keyring: OrcadOsKeyring | null
machineId: string | null
statePath: string
}
export function resolveKeyLocation(options: OrcadSecretStorePrepareOptions): OrcadKeyLocation {
const platform = options.platform ?? process.platform
const keyring =
options.keyring === undefined
? resolveOrcadOsKeyring(platform, options.env ?? process.env)
: options.keyring
const machineId = (options.machineIdentity ?? profileStateAccessMachineIdentity)()
return {
platform,
keyring,
machineId,
statePath: join(options.keyDir, orcadSecretStateFileName(machineId))
}
}
export async function resolveState(options: OrcadSecretStorePrepareOptions): Promise<StoreState> {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
if (env[ORCAD_PLAINTEXT_SECRETS_ENV] === 'plaintext') {
return { kind: 'plaintext', reason: describeOrcadPlaintextOptOut() }
}
// Why: a disposable run (tests, smoke launchers) must not create keys in, or re-seal, the
// user's persistent ~/.orca; credentials it saves would outlive the run unreadable by others.
if (
options.userDataPath &&
(options.isTemporaryKeyDir ?? isTemporaryKeyDir)(options.userDataPath)
) {
return { kind: 'plaintext', reason: describeOrcadDisposableProfile() }
}
const { keyring, machineId, statePath } = resolveKeyLocation(options)
const paths: Paths = {
statePath,
unavailable: (reason) => ({
kind: 'unavailable',
message: describeOrcadSealingUnavailable(reason, statePath)
})
}
const context: KeyContext = {
platform,
keyring,
machineId,
claimSettleMs: options.claimSettleMs
}
// Why loop: losing an exclusive-create race means another start's record is the one to load.
for (let attempt = 0; attempt < 3; attempt += 1) {
const existing = readStoredRecord(paths.statePath)
if (existing.kind === 'record') {
const loaded = await loadStoredRecord(existing.stored, paths, context)
if (loaded !== 'record-changed') {
return loaded
}
continue
}
if (existing.kind === 'unreadable') {
return paths.unavailable(existing.reason)
}
const created = await createKey(options, paths, context, env)
if (created !== 'lost-race') {
return created
}
}
return paths.unavailable('another server kept replacing its key record')
}
async function createKey(
options: OrcadSecretStorePrepareOptions,
paths: Paths,
context: KeyContext,
env: NodeJS.ProcessEnv
): Promise<StoreState | 'lost-race'> {
const { keyring, machineId, platform } = context
const mayUseKeyring =
env[ORCAD_PLAINTEXT_SECRETS_ENV] !== 'file' &&
!(options.isTemporaryKeyDir ?? isTemporaryKeyDir)(options.keyDir)
if (keyring && mayUseKeyring) {
const account = keyringAccount(options.keyDir, machineId)
const found = await keyring.lookup(account)
const adopted = found.kind === 'found' ? decodeMasterKey(found.value) : null
if (adopted || found.kind === 'missing') {
const master = adopted ?? randomBytes(ORCAD_MASTER_KEY_BYTES)
const key = deriveOrcadSealingKey(master, '')
const record: StoredKeyState = {
version: 1,
backend: 'os-keyring',
account,
check: orcadSealingKeyCheck(key)
}
// Why claim the record first: only the start that wins the claim may write the keyring.
const claim = publishRecordExclusively(paths.statePath, record)
if (claim !== 'published') {
return claim === 'exists' ? 'lost-race' : paths.unavailable(claim)
}
if (adopted) {
return { kind: 'ready', key, gap: null, inKeychain: true }
}
const encoded = master.toString('base64')
// Why read back: `security -i` exits 0 even when its command failed.
await keyring.store(account, encoded)
// Why trust only the read-back: a store can time out after it succeeded, and a concurrent
// start may already have adopted the claimed key.
const stored = await keyring.lookup(account)
if (stored.kind === 'found' && stored.value === encoded) {
return { kind: 'ready', key, gap: null, inKeychain: true }
}
if (stored.kind !== 'missing') {
// Uncertain: the key may be in the keyring and in use, so keep the claim and fail closed.
return paths.unavailable(`${keyring.label} did not confirm its new key; restart to retry`)
}
// Definitely never stored, so nobody can have adopted it: withdraw and use the file.
withdrawRecord(paths.statePath, record.check)
}
}
const master = randomBytes(ORCAD_MASTER_KEY_BYTES)
const key = deriveOrcadSealingKey(master, machineId ?? '')
const record: StoredKeyState = {
version: 1,
backend: 'host-file',
key: master.toString('base64'),
hostBound: machineId !== null,
check: orcadSealingKeyCheck(key)
}
// Why fail closed: sealing with a key that did not reach disk loses every credential on restart.
const published = publishRecordExclusively(paths.statePath, record)
if (published !== 'published') {
return published === 'exists' ? 'lost-race' : paths.unavailable(published)
}
return {
kind: 'ready',
key,
gap: describeOrcadHostKeyGap(platform, record.hostBound),
inKeychain: false
}
}
@@ -41,6 +41,16 @@ export function describeOrcadHostKeyGap(platform: NodeJS.Platform, hostBound: bo
)
}
/** Why its own message: a locked login Keychain clears without a restart, unlike other failures. */
export function describeOrcadKeychainLocked(): string {
return (
"Orca's headless server cannot unseal its credentials: the macOS login Keychain that holds " +
'its key is locked. This happens after a reboot when you only sign in over SSH. Sign in to ' +
'this Mac at its screen once; the server retries on its own. Saved credentials are kept as ' +
'they are and new ones are refused until then.'
)
}
export function describeOrcadSealingUnavailable(reason: string, stateFile: string): string {
return (
`Orca's headless server cannot unseal its credentials: ${reason}. Saved credentials are kept ` +
@@ -0,0 +1,212 @@
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isIntegrationCredentialDecryptionError } from '../../shared/integration-credential-errors'
import { _resetSecretStoreForTests, setSecretStore } from '../../shared/secret-store'
import { CredentialDecryptionError } from '../integration-credential-file'
import {
integrationCredentialKeychainFields,
recoverIntegrationCredentialKey
} from '../integration-credential-keychain'
import { createIntegrationTokenFileCache } from '../integration-token-file-cache'
import type { OrcadKeyringLookup, OrcadOsKeyring } from './orcad-os-keyring'
import { orcadSecretStateFileName } from './orcad-secret-key-record'
import { createOrcadSecretStore } from './orcad-secret-store'
const MACHINE = '0123456789abcdef0123456789abcdef'
const LOCKED: OrcadKeyringLookup = {
kind: 'locked',
reason: 'exited 36: User interaction is not allowed.'
}
function fakeKeychain(): OrcadOsKeyring & { items: Map<string, string>; locked: boolean } {
const keyring = {
label: 'the test Keychain',
items: new Map<string, string>(),
locked: false,
async lookup(account: string): Promise<OrcadKeyringLookup> {
if (keyring.locked) {
return LOCKED
}
const value = keyring.items.get(account)
return value ? { kind: 'found', value } : { kind: 'missing' }
},
async store(account: string, value: string): Promise<boolean> {
keyring.items.set(account, value)
return true
}
}
return keyring
}
describe('orcad secret store with a locked macOS login Keychain', () => {
let keyDir: string
let log: ReturnType<typeof vi.fn<(message: string) => void>>
beforeEach(() => {
keyDir = mkdtempSync(join(tmpdir(), 'orcad-keychain-lock-'))
log = vi.fn<(message: string) => void>()
})
afterEach(() => {
_resetSecretStoreForTests()
rmSync(keyDir, { recursive: true, force: true })
})
const prepare = async (keyring: OrcadOsKeyring) => {
const store = createOrcadSecretStore(log)
const ready = await store.prepare({
keyDir,
platform: 'darwin',
env: {},
keyring,
machineIdentity: () => MACHINE,
isTemporaryKeyDir: () => false,
claimSettleMs: 0
})
return { store, ready }
}
/** A server that sealed a credential, then restarted after a reboot with the Keychain locked. */
const lockedAfterReboot = async () => {
const keyring = fakeKeychain()
const sealed = (await prepare(keyring)).store.encryptString('lin_api_secret')
keyring.locked = true
const { store, ready } = await prepare(keyring)
return { keyring, sealed, store, ready }
}
it('reports the lock, logs it once, and fails closed', async () => {
const { store, ready, sealed } = await lockedAfterReboot()
expect(ready).toBe(false)
expect(store.keychainLock?.state()).toBe('locked')
expect(() => store.decryptString(sealed)).toThrow(/login Keychain that holds its key is locked/)
expect(() => store.encryptString('x')).toThrow(/locked/)
expect(log.mock.calls.filter(([line]) => line.startsWith('[secrets]'))).toHaveLength(1)
expect(await store.keychainLock?.recover()).toBe(false)
expect(log).toHaveBeenCalledTimes(1)
})
it('opens the credentials once the Keychain unlocks, without a restart', async () => {
const { keyring, store, sealed } = await lockedAfterReboot()
keyring.locked = false
expect(await store.keychainLock?.recover()).toBe(true)
expect(store.decryptString(sealed)).toBe('lin_api_secret')
expect(store.keychainLock?.state()).toBe('unlocked_after_lock')
// Reported once, so status clears stale failures once.
expect(await store.keychainLock?.recover()).toBe(false)
})
it('retries the Keychain when a credential is used', async () => {
const { keyring, store, sealed } = await lockedAfterReboot()
keyring.locked = false
expect(() => store.decryptString(sealed)).toThrow(/locked/)
await vi.waitFor(() => expect(store.decryptString(sealed)).toBe('lin_api_secret'))
})
it('keeps retrying after a lookup times out while locked', async () => {
const { keyring, store, sealed } = await lockedAfterReboot()
const lookup = keyring.lookup
keyring.lookup = async () => ({ kind: 'unreachable', reason: 'timed out after 5s' })
expect(await store.keychainLock?.recover()).toBe(false)
expect(store.keychainLock?.state()).toBe('locked')
keyring.lookup = lookup
keyring.locked = false
expect(await store.keychainLock?.recover()).toBe(true)
expect(store.decryptString(sealed)).toBe('lin_api_secret')
})
it('never moves the key of a host whose Keychain was never seen locked', async () => {
const { store } = await prepare(fakeKeychain())
expect(await store.keychainLock?.moveKeyToFile()).toMatchObject({
ok: false,
reason: 'not_in_keychain'
})
const record = readFileSync(join(keyDir, orcadSecretStateFileName(MACHINE)), 'utf8')
expect(JSON.parse(record)).toMatchObject({ backend: 'os-keyring' })
})
it('moves the key to the file only while unlocked, keeping every credential', async () => {
const { keyring, store, sealed } = await lockedAfterReboot()
const statePath = join(keyDir, orcadSecretStateFileName(MACHINE))
const recordWhileLocked = readFileSync(statePath, 'utf8')
expect(await store.keychainLock?.moveKeyToFile()).toMatchObject({
ok: false,
reason: 'keychain_locked'
})
expect(readFileSync(statePath, 'utf8')).toBe(recordWhileLocked)
keyring.locked = false
await store.keychainLock?.recover()
expect(await store.keychainLock?.moveKeyToFile()).toEqual({ ok: true })
expect(JSON.parse(readFileSync(statePath, 'utf8'))).toMatchObject({
backend: 'host-file',
hostBound: false
})
expect(store.keychainLock?.state()).toBeNull()
expect(store.decryptString(sealed)).toBe('lin_api_secret')
// After the next reboot the Keychain is locked again, and no longer matters.
keyring.locked = true
const restarted = await prepare(keyring)
expect(restarted.ready).toBe(true)
expect(restarted.store.decryptString(sealed)).toBe('lin_api_secret')
expect(restarted.store.keychainLock?.state()).toBeNull()
})
it('tells clients why Linear and Jira cannot read, then clears it after the unlock', async () => {
const keyring = fakeKeychain()
const first = await prepare(keyring)
const tokenPath = join(keyDir, 'linear-token.enc')
writeFileSync(tokenPath, first.store.encryptString('lin_api_secret'))
keyring.locked = true
const { store } = await prepare(keyring)
setSecretStore(store)
const cache = createIntegrationTokenFileCache({ service: 'Linear', filePath: () => tokenPath })
let failure: unknown
try {
cache.read('ws')
} catch (error) {
failure = error
}
expect(failure).toBeInstanceOf(CredentialDecryptionError)
// Old clients match on the canonical message; the hint rides along for them.
expect(isIntegrationCredentialDecryptionError(failure)).toBe(true)
expect(String(failure)).toContain('macOS login Keychain is locked')
expect(cache.error('ws')).toBeDefined()
expect(integrationCredentialKeychainFields({ saved: true, decryptFailed: true })).toEqual({
credentialErrorReason: 'keychain_locked'
})
// Legacy plaintext credentials still read while locked, so nothing is unavailable.
expect(integrationCredentialKeychainFields({ saved: true, decryptFailed: false })).toEqual({})
expect(integrationCredentialKeychainFields({ saved: false, decryptFailed: false })).toEqual({})
keyring.locked = false
await recoverIntegrationCredentialKey()
expect(cache.error('ws')).toBeUndefined()
expect(cache.read('ws')).toBe('lin_api_secret')
expect(integrationCredentialKeychainFields({ saved: true, decryptFailed: false })).toEqual({
canMoveCredentialKeyToFile: true
})
})
it('sweeps key-record staging files a crashed write left behind, and nothing else', async () => {
const record = orcadSecretStateFileName(MACHINE)
const stale = join(keyDir, `${record}.999999999.0123abcd.staging`)
const inFlight = join(keyDir, `${record}.${process.ppid}.0123abcd.staging`)
// Another machine sharing this home: its writer's PID means nothing on this host.
const unrelated = join(
keyDir,
`${orcadSecretStateFileName('fedcba9876543210fedcba9876543210')}.999999999.0123abcd.staging`
)
for (const path of [stale, inFlight, unrelated]) {
writeFileSync(path, '{}', { mode: 0o600 })
}
await prepare(fakeKeychain())
expect(existsSync(stale)).toBe(false)
expect(existsSync(inFlight)).toBe(true)
expect(existsSync(unrelated)).toBe(true)
})
})
+114 -233
View File
@@ -7,251 +7,80 @@
* When the recorded key cannot be loaded (locked keyring, changed machine), seal and unseal
* throw instead, so saves fail closed and existing sealed files are left untouched.
*/
import { randomBytes } from 'node:crypto'
import { join } from 'node:path'
import process from 'node:process'
import type { SecretStore } from '../../shared/secret-store'
import { profileStateAccessMachineIdentity } from '../persistence/profile-state/profile-state-access-identity'
import { resolveOrcadOsKeyring, type OrcadOsKeyring } from './orcad-os-keyring'
import type { SecretStore, SecretStoreKeychainLockState } from '../../shared/secret-store'
import { moveOrcadKeyToFile } from './orcad-secret-key-move'
import { sweepStaleRecordStaging } from './orcad-secret-key-record'
import {
decodeMasterKey,
isTemporaryKeyDir,
keyringAccount,
orcadSecretStateFileName,
publishRecordExclusively,
readStoredRecord,
restrictExistingRecord,
withdrawRecord,
type StoredKeyState
} from './orcad-secret-key-record'
import {
describeOrcadHostKeyGap,
describeOrcadDisposableProfile,
describeOrcadPlaintextOptOut,
describeOrcadSealingUnavailable,
ORCAD_PLAINTEXT_SECRETS_ENV
} from './orcad-secret-protection-gap'
import {
deriveOrcadSealingKey,
openOrcadSecret,
ORCAD_MASTER_KEY_BYTES,
orcadSealingKeyCheck,
sealOrcadSecret
} from './orcad-secret-sealing'
resolveKeyLocation,
resolveState,
type OrcadSecretStorePrepareOptions,
type StoreState
} from './orcad-secret-key-resolution'
import { describeOrcadHostKeyGap } from './orcad-secret-protection-gap'
import { openOrcadSecret, sealOrcadSecret } from './orcad-secret-sealing'
type StoreState =
| { kind: 'pending' }
| { kind: 'plaintext'; reason: string }
| { kind: 'ready'; key: Buffer; gap: string | null }
| { kind: 'unavailable'; message: string }
export type OrcadSecretStorePrepareOptions = {
/** Orca's credential directory (`~/.orca`), so the key lives exactly as long as what it seals. */
keyDir: string
/** A temporary profile keeps the legacy behavior and leaves `keyDir` untouched. */
userDataPath?: string
platform?: NodeJS.Platform
env?: NodeJS.ProcessEnv
keyring?: OrcadOsKeyring | null
machineIdentity?: () => string | null
isTemporaryKeyDir?: (keyDir: string) => boolean
/** How long to wait for a concurrent first start to finish storing its keyring key. */
claimSettleMs?: number
}
export type { OrcadSecretStorePrepareOptions } from './orcad-secret-key-resolution'
export type OrcadSecretStore = SecretStore & {
/** Resolves whether sealing works now, i.e. the key loaded. */
prepare(options: OrcadSecretStorePrepareOptions): Promise<boolean>
}
// A claimant's keyring store and read-back can each take the full 5s keyring timeout.
const CLAIM_SETTLE_MS = 12_000
// Why throttle: a renderer refresh can read many credentials at once, each one a retry.
const LOCKED_RETRY_INTERVAL_MS = 2_000
type Paths = { statePath: string; unavailable: (reason: string) => StoreState }
type KeyContext = {
platform: NodeJS.Platform
keyring: OrcadOsKeyring | null
machineId: string | null
claimSettleMs?: number
}
async function loadStoredRecord(
stored: StoredKeyState,
paths: Paths,
context: KeyContext
): Promise<StoreState | 'record-changed'> {
const { unavailable } = paths
if (stored.backend === 'os-keyring') {
const { keyring } = context
if (!keyring) {
return unavailable('the OS keyring that holds its key is not reachable from this session')
}
let found = await keyring.lookup(stored.account)
// Why wait: a concurrent first start may have claimed the record and still be storing the key
// (one keyring store plus one lookup), or may withdraw the claim and switch to the file.
const deadline = Date.now() + (context.claimSettleMs ?? CLAIM_SETTLE_MS)
while (found.kind === 'missing' && Date.now() < deadline) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
const current = readStoredRecord(paths.statePath)
if (current.kind !== 'record' || current.stored.check !== stored.check) {
return 'record-changed'
}
found = await keyring.lookup(stored.account)
}
if (found.kind !== 'found') {
return unavailable(
found.kind === 'missing'
? `its key is missing from ${keyring.label}`
: `${keyring.label} is locked or unreachable (${found.reason}); unlock it`
)
}
const master = decodeMasterKey(found.value)
const key = master ? deriveOrcadSealingKey(master, '') : null
if (!key || orcadSealingKeyCheck(key) !== stored.check) {
return unavailable(`the key in ${keyring.label} does not match the one it sealed with`)
}
return { kind: 'ready', key, gap: null }
}
if (!(await restrictExistingRecord(paths.statePath))) {
return unavailable(`its key record ${paths.statePath} could not be restricted to this user`)
}
const master = decodeMasterKey(stored.key)
const binding = stored.hostBound ? context.machineId : ''
if (!master || binding === null) {
return unavailable('this machine no longer reports the machine ID its key is bound to')
}
const key = deriveOrcadSealingKey(master, binding)
if (orcadSealingKeyCheck(key) !== stored.check) {
return unavailable('its key is bound to a different machine ID')
}
return { kind: 'ready', key, gap: describeOrcadHostKeyGap(context.platform, stored.hostBound) }
}
async function resolveState(options: OrcadSecretStorePrepareOptions): Promise<StoreState> {
const platform = options.platform ?? process.platform
const env = options.env ?? process.env
if (env[ORCAD_PLAINTEXT_SECRETS_ENV] === 'plaintext') {
return { kind: 'plaintext', reason: describeOrcadPlaintextOptOut() }
}
// Why: a disposable run (tests, smoke launchers) must not create keys in, or re-seal, the
// user's persistent ~/.orca; credentials it saves would outlive the run unreadable by others.
if (
options.userDataPath &&
(options.isTemporaryKeyDir ?? isTemporaryKeyDir)(options.userDataPath)
) {
return { kind: 'plaintext', reason: describeOrcadDisposableProfile() }
}
const keyring =
options.keyring === undefined ? resolveOrcadOsKeyring(platform, env) : options.keyring
const machineId = (options.machineIdentity ?? profileStateAccessMachineIdentity)()
const statePath = join(options.keyDir, orcadSecretStateFileName(machineId))
const paths: Paths = {
statePath,
unavailable: (reason) => ({
kind: 'unavailable',
message: describeOrcadSealingUnavailable(reason, statePath)
})
}
const context: KeyContext = {
platform,
keyring,
machineId,
claimSettleMs: options.claimSettleMs
}
// Why loop: losing an exclusive-create race means another start's record is the one to load.
for (let attempt = 0; attempt < 3; attempt += 1) {
const existing = readStoredRecord(paths.statePath)
if (existing.kind === 'record') {
const loaded = await loadStoredRecord(existing.stored, paths, context)
if (loaded !== 'record-changed') {
return loaded
}
continue
}
if (existing.kind === 'unreadable') {
return paths.unavailable(existing.reason)
}
const created = await createKey(options, paths, context, env)
if (created !== 'lost-race') {
return created
}
}
return paths.unavailable('another server kept replacing its key record')
}
async function createKey(
options: OrcadSecretStorePrepareOptions,
paths: Paths,
context: KeyContext,
env: NodeJS.ProcessEnv
): Promise<StoreState | 'lost-race'> {
const { keyring, machineId, platform } = context
const mayUseKeyring =
env[ORCAD_PLAINTEXT_SECRETS_ENV] !== 'file' &&
!(options.isTemporaryKeyDir ?? isTemporaryKeyDir)(options.keyDir)
if (keyring && mayUseKeyring) {
const account = keyringAccount(options.keyDir, machineId)
const found = await keyring.lookup(account)
const adopted = found.kind === 'found' ? decodeMasterKey(found.value) : null
if (adopted || found.kind === 'missing') {
const master = adopted ?? randomBytes(ORCAD_MASTER_KEY_BYTES)
const key = deriveOrcadSealingKey(master, '')
const record: StoredKeyState = {
version: 1,
backend: 'os-keyring',
account,
check: orcadSealingKeyCheck(key)
}
// Why claim the record first: only the start that wins the claim may write the keyring.
const claim = publishRecordExclusively(paths.statePath, record)
if (claim !== 'published') {
return claim === 'exists' ? 'lost-race' : paths.unavailable(claim)
}
if (adopted) {
return { kind: 'ready', key, gap: null }
}
const encoded = master.toString('base64')
// Why read back: `security -i` exits 0 even when its command failed.
await keyring.store(account, encoded)
// Why trust only the read-back: a store can time out after it succeeded, and a concurrent
// start may already have adopted the claimed key.
const stored = await keyring.lookup(account)
if (stored.kind === 'found' && stored.value === encoded) {
return { kind: 'ready', key, gap: null }
}
if (stored.kind !== 'missing') {
// Uncertain: the key may be in the keyring and in use, so keep the claim and fail closed.
return paths.unavailable(`${keyring.label} did not confirm its new key; restart to retry`)
}
// Definitely never stored, so nobody can have adopted it: withdraw and use the file.
withdrawRecord(paths.statePath, record.check)
}
}
const master = randomBytes(ORCAD_MASTER_KEY_BYTES)
const key = deriveOrcadSealingKey(master, machineId ?? '')
const record: StoredKeyState = {
version: 1,
backend: 'host-file',
key: master.toString('base64'),
hostBound: machineId !== null,
check: orcadSealingKeyCheck(key)
}
// Why fail closed: sealing with a key that did not reach disk loses every credential on restart.
const published = publishRecordExclusively(paths.statePath, record)
if (published !== 'published') {
return published === 'exists' ? 'lost-race' : paths.unavailable(published)
}
return { kind: 'ready', key, gap: describeOrcadHostKeyGap(platform, record.hostBound) }
}
export function createOrcadSecretStore(): OrcadSecretStore {
export function createOrcadSecretStore(
log: (message: string) => void = (message) => console.warn(message)
): OrcadSecretStore {
let state: StoreState = { kind: 'pending' }
let options: OrcadSecretStorePrepareOptions | null = null
let sawLock = false
let recoveredSinceAck = false
let retry: Promise<void> | null = null
let lastRetryAt = 0
// Why a generation: a prepare() or key move that lands during a retry owns the newer state.
let generation = 0
const isLocked = (): boolean => state.kind === 'unavailable' && state.keychainLocked === true
const adopt = (next: StoreState): void => {
if (next.kind === 'unavailable' && next.keychainLocked && !sawLock) {
sawLock = true
log(`[secrets] ${next.message}`)
} else if (next.kind === 'ready' && isLocked()) {
recoveredSinceAck = true
log('[secrets] The macOS login Keychain is unlocked; saved credentials are readable again.')
}
state = next
}
const retryLockedKey = (): Promise<void> => {
if (!retry && options) {
const prepared = options
const startedAt = generation
lastRetryAt = Date.now()
retry = resolveState(prepared)
.then((next) => {
// Why ignore other failures: a timed-out lookup must not end retries while still locked.
const settled =
next.kind === 'ready' || (next.kind === 'unavailable' && next.keychainLocked)
if (startedAt === generation && isLocked() && settled) {
adopt(next)
}
})
.catch((error: unknown) => log(`[secrets] Keychain retry failed: ${String(error)}`))
.finally(() => {
retry = null
})
}
return retry ?? Promise.resolve()
}
const readyKey = (): Buffer => {
if (state.kind === 'ready') {
return state.key
}
// Why retry on use: an unlock must take effect without restarting the server.
if (isLocked() && Date.now() - lastRetryAt >= LOCKED_RETRY_INTERVAL_MS) {
void retryLockedKey()
}
throw new Error(
state.kind === 'unavailable'
? state.message
@@ -274,8 +103,60 @@ export function createOrcadSecretStore(): OrcadSecretStore {
return "Orca's headless server has not loaded its credential key yet."
}
},
async prepare(options) {
state = await resolveState(options)
keychainLock: {
state: (): SecretStoreKeychainLockState | null => {
if (isLocked()) {
return 'locked'
}
return state.kind === 'ready' && state.inKeychain && sawLock ? 'unlocked_after_lock' : null
},
async recover() {
// Why a fresh lookup after the in-flight one: that one may predate the unlock.
await retry
if (isLocked()) {
await retryLockedKey()
}
const recovered = recoveredSinceAck
recoveredSinceAck = false
return recovered
},
async moveKeyToFile() {
if (isLocked()) {
return {
ok: false,
reason: 'keychain_locked',
message: 'The macOS login Keychain is locked.'
}
}
// Why only after a lock: the file is weaker, so no caller may downgrade a host that never needed it.
if (state.kind !== 'ready' || !state.inKeychain || !sawLock || !options) {
return {
ok: false,
reason: 'not_in_keychain',
message: 'This server does not keep its credential key in the Keychain.'
}
}
const location = resolveKeyLocation(options)
const moved = await moveOrcadKeyToFile(location)
if (!moved.ok) {
return moved
}
generation += 1
state = {
kind: 'ready',
key: moved.key,
gap: describeOrcadHostKeyGap(location.platform, false),
inKeychain: false
}
log('[secrets] Moved the credential key from the macOS Keychain to the key file.')
return { ok: true }
}
},
async prepare(next) {
options = next
sweepStaleRecordStaging(resolveKeyLocation(next).statePath)
generation += 1
adopt(await resolveState(next))
return state.kind === 'ready'
}
}
+2
View File
@@ -35,6 +35,7 @@ import { HOSTED_REVIEW_METHODS } from './hosted-review'
import { LINEAR_METHODS } from './linear'
import { LINEAR_AGENT_ACCESS_METHODS } from './linear-agent-access'
import { JIRA_METHODS } from './jira'
import { INTEGRATION_CREDENTIAL_KEY_METHODS } from './integration-credential-key'
import { SSH_METHODS } from './ssh'
import { MANAGED_SERVER_METHODS } from './managed-server'
import { SPEECH_METHODS } from './speech'
@@ -116,6 +117,7 @@ export const ALL_RPC_METHODS = [
...LINEAR_METHODS,
...LINEAR_AGENT_ACCESS_METHODS,
...JIRA_METHODS,
...INTEGRATION_CREDENTIAL_KEY_METHODS,
...SSH_METHODS,
...MANAGED_SERVER_METHODS,
...SPEECH_METHODS,
@@ -0,0 +1,12 @@
import { defineMethod } from '../core'
import { moveIntegrationCredentialKeyToFile } from '../../../integration-credential-keychain'
export const INTEGRATION_CREDENTIAL_KEY_METHODS = [
// Why no capability flag: clients call it only when status sets canMoveCredentialKeyToFile.
defineMethod({
name: 'integrationCredentials.moveKeyToFile',
permission: 'accounts-admin',
params: null,
handler: async () => moveIntegrationCredentialKeyToFile()
})
]
+9 -2
View File
@@ -3,6 +3,7 @@ import {
JIRA_PAYLOAD_MAX_CHARS
} from '../../../../shared/jira-payload-stream'
import { defineMethod, defineStreamingMethod, type RpcContext } from '../core'
import { recoverIntegrationCredentialKey } from '../../../integration-credential-keychain'
import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image'
import { JiraFavouriteFilterParams } from '../../../../shared/rpc-contract/jira-favourite-filter-params'
import {
@@ -96,13 +97,19 @@ export const JIRA_METHODS = [
name: 'jira.status',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.jiraStatus()
handler: async (_params, { runtime }) => {
await recoverIntegrationCredentialKey()
return runtime.jiraStatus()
}
}),
defineMethod({
name: 'jira.readStatus',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.jiraReadStatus()
handler: async (_params, { runtime }) => {
await recoverIntegrationCredentialKey()
return runtime.jiraReadStatus()
}
}),
defineMethod({
name: 'jira.testConnection',
+5 -1
View File
@@ -1,4 +1,5 @@
import { defineMethod } from '../core'
import { recoverIntegrationCredentialKey } from '../../../integration-credential-keychain'
import { LINEAR_PROJECT_CREATE_METHOD } from './linear-project-create'
import { LINEAR_ISSUE_LIST_METHOD, LINEAR_MCP_ISSUE_LIST_METHOD } from './linear-issue-list-method'
import {
@@ -43,7 +44,10 @@ export const LINEAR_METHODS = [
name: 'linear.status',
permission: 'workspace',
params: null,
handler: async (_params, { runtime }) => runtime.linearStatus()
handler: async (_params, { runtime }) => {
await recoverIntegrationCredentialKey()
return runtime.linearStatus()
}
}),
defineMethod({
name: 'linear.testConnection',
@@ -1,4 +1,5 @@
import { UnsealedCredentialNotice } from './UnsealedCredentialNotice'
import { KeychainCredentialNotice } from './keychain-credential-notice'
import { useState } from 'react'
import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react'
import { JiraConnectDialog } from '@/components/jira-connect-dialog'
@@ -138,6 +139,11 @@ export function JiraIntegrationCard(): React.JSX.Element {
'Your Jira API token'
)}
/>
<KeychainCredentialNotice
service="Jira"
status={contextMatches ? jiraStatus : {}}
statusContextKey={jiraStatusContextKey}
/>
<ProviderHostScopeControl
labelPrefix={translate(
'auto.components.settings.task.tracker.integration.cards.account_scope_prefix',
@@ -0,0 +1,115 @@
// @vitest-environment happy-dom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string, params?: Record<string, string>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_match, name: string) => params?.[name] ?? '')
}))
const mocks = vi.hoisted(() => ({
callRuntimeRpc: vi.fn(),
checkLinearConnection: vi.fn(async () => {}),
checkJiraConnection: vi.fn(async () => {})
}))
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('sonner', () => ({ toast: { success: vi.fn() } }))
vi.mock('@/lib/default-creation-host', () => ({
defaultScopeHost: (settings: { activeRuntimeEnvironmentId: string | null }) =>
settings.activeRuntimeEnvironmentId
? { kind: 'environment', environmentId: settings.activeRuntimeEnvironmentId }
: { kind: 'local' }
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: unknown) => unknown) =>
selector({
settings: { activeRuntimeEnvironmentId: 'env-1' },
runtimeEnvironments: [{ id: 'env-1', name: 'm4air' }],
checkLinearConnection: mocks.checkLinearConnection,
checkJiraConnection: mocks.checkJiraConnection
})
}))
const { KeychainCredentialNotice } = await import('./keychain-credential-notice')
const { getEnvironmentRuntimeContextKey } = await import('@/lib/provider-runtime-context')
const CONTEXT = getEnvironmentRuntimeContextKey('env-1')
describe('KeychainCredentialNotice', () => {
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
it('names the host and the locked Keychain instead of asking to reconnect', () => {
render(
<KeychainCredentialNotice
statusContextKey={CONTEXT}
service="Jira"
status={{ credentialErrorReason: 'keychain_locked' }}
/>
)
const text = screen.getByRole('alert').textContent ?? ''
expect(text).toContain('Jira is unavailable on m4air: its macOS login Keychain is locked.')
expect(text).toContain('Sign in to that Mac at its screen once')
expect(text).not.toMatch(/reconnect/i)
// No switch while locked: the key cannot be read to move it.
expect(screen.queryByRole('button')).toBeNull()
})
it('offers the switch to file storage once unlocked, then refreshes both providers', async () => {
mocks.callRuntimeRpc.mockResolvedValueOnce({ ok: true })
render(
<KeychainCredentialNotice
statusContextKey={CONTEXT}
service="Linear"
status={{ canMoveCredentialKeyToFile: true }}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch to file storage' }))
await waitFor(() => expect(mocks.checkJiraConnection).toHaveBeenCalled())
expect(mocks.callRuntimeRpc).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'env-1' },
'integrationCredentials.moveKeyToFile',
undefined,
{ timeoutMs: 15_000 }
)
expect(mocks.checkLinearConnection).toHaveBeenCalledWith(true)
})
it('renders nothing for a host that reports neither field', () => {
const { container } = render(
<KeychainCredentialNotice statusContextKey={CONTEXT} service="Linear" status={{}} />
)
expect(container.textContent).toBe('')
})
it('moves the key on the host that reported the status, not the selected one', async () => {
mocks.callRuntimeRpc.mockResolvedValueOnce({ ok: true })
render(
<KeychainCredentialNotice
statusContextKey={getEnvironmentRuntimeContextKey('env-0')}
service="Linear"
status={{ canMoveCredentialKeyToFile: true }}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch to file storage' }))
await waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
expect(mocks.callRuntimeRpc.mock.calls[0][0]).toEqual({
kind: 'environment',
environmentId: 'env-0'
})
})
it('says the moved key is not tied to the machine', () => {
render(
<KeychainCredentialNotice
statusContextKey={CONTEXT}
service="Linear"
status={{ canMoveCredentialKeyToFile: true }}
/>
)
expect(document.body.textContent).toContain('a backup or shared copy of ~/.orca can open')
})
})
@@ -0,0 +1,157 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { TriangleAlert } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import { runtimeTargetForContextKey } from '@/lib/provider-runtime-context'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { useAppStore } from '@/store'
import type { IntegrationCredentialErrorReason } from '../../../../shared/integration-credential-errors'
import type { SecretStoreKeyMoveResult } from '../../../../shared/secret-store'
/**
* Why this exists: a macOS host reached only over SSH keeps a locked login Keychain after a
* reboot, so its server cannot open Linear or Jira credentials. The generic "reconnect" text
* would send the user to re-enter a token that is fine.
*/
export type KeychainCredentialStatus = {
credentialErrorReason?: IntegrationCredentialErrorReason
canMoveCredentialKeyToFile?: boolean
}
type CredentialService = 'Linear' | 'Jira'
export function keychainLockedCredentialMessage(service: CredentialService, host: string): string {
return translate(
'auto.components.settings.keychainCredentialNotice.locked',
'{{service}} is unavailable on {{host}}: its macOS login Keychain is locked. This happens after a reboot when you only sign in over SSH. Sign in to that Mac at its screen once. Orca then offers to switch the host to file storage so this stops happening.',
{ service, host }
)
}
/** The host a provider status was read from (its context key); read only when `needed`. */
export function useCredentialHostName(statusContextKey: string | null, needed: boolean): string {
const name = useAppStore((state) => {
if (!needed || !statusContextKey) {
return null
}
const target = runtimeTargetForContextKey(statusContextKey)
return target.kind === 'environment'
? (state.runtimeEnvironments.find((environment) => environment.id === target.environmentId)
?.name ?? null)
: null
})
return (
name ??
translate('auto.components.settings.keychainCredentialNotice.unnamedHost', 'the remote server')
)
}
export function KeychainCredentialNotice({
service,
status,
statusContextKey
}: {
service: CredentialService
status: KeychainCredentialStatus
/** The provider context `status` was read from; the move goes to that host, its owner. */
statusContextKey: string | null
}): React.JSX.Element | null {
return statusContextKey &&
(status.credentialErrorReason === 'keychain_locked' || status.canMoveCredentialKeyToFile) ? (
<KeychainCredentialNoticeBody
service={service}
status={status}
statusContextKey={statusContextKey}
/>
) : null
}
function KeychainCredentialNoticeBody({
service,
status,
statusContextKey
}: {
service: CredentialService
status: KeychainCredentialStatus
statusContextKey: string
}): React.JSX.Element | null {
const host = useCredentialHostName(statusContextKey, true)
const checkLinearConnection = useAppStore((state) => state.checkLinearConnection)
const checkJiraConnection = useAppStore((state) => state.checkJiraConnection)
const [moving, setMoving] = useState(false)
const [moveError, setMoveError] = useState<string | null>(null)
if (status.credentialErrorReason === 'keychain_locked') {
return (
<div
role="alert"
className="flex items-start gap-2.5 rounded-lg border border-status-warning-border bg-status-warning-background px-3 py-2 text-status-warning"
>
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
<p className="min-w-0 text-xs leading-snug">
{keychainLockedCredentialMessage(service, host)}
</p>
</div>
)
}
// Why the status's own host: it reported the flag. A paired browser's local target reaches its
// server; desktop never sets the flag.
const target = runtimeTargetForContextKey(statusContextKey)
if (!status.canMoveCredentialKeyToFile) {
return null
}
const handleMove = async (): Promise<void> => {
setMoving(true)
setMoveError(null)
try {
const result = await callRuntimeRpc<SecretStoreKeyMoveResult>(
target,
'integrationCredentials.moveKeyToFile',
undefined,
{ timeoutMs: 15_000 }
)
if (result.ok) {
toast.success(
translate(
'auto.components.settings.keychainCredentialNotice.moved',
'Switched {{host}} to file storage',
{ host }
)
)
} else {
setMoveError(result.message)
}
} catch (error) {
setMoveError(error instanceof Error ? error.message : String(error))
} finally {
setMoving(false)
// Why both: the key is host-wide, so both providers' status changes.
void checkLinearConnection(true)
void checkJiraConnection()
}
}
return (
<div className="flex items-start gap-2.5 rounded-lg border border-status-warning-border bg-status-warning-background px-3 py-2 text-status-warning">
<TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 flex-1 space-y-2">
<p className="text-xs leading-snug">
{translate(
'auto.components.settings.keychainCredentialNotice.offerMove',
'{{host}} keeps its credential key in its macOS login Keychain, which was locked after a reboot. Switch to file storage to keep Linear and Jira working when you only sign in over SSH. The key then sits in a file only this account can read, which protects it less than the Keychain: it is not tied to this machine, so a backup or shared copy of ~/.orca can open the credentials.',
{ host }
)}
</p>
{moveError ? <p className="text-xs leading-snug text-destructive">{moveError}</p> : null}
<Button variant="outline" size="sm" disabled={moving} onClick={() => void handleMove()}>
{translate(
'auto.components.settings.keychainCredentialNotice.moveAction',
'Switch to file storage'
)}
</Button>
</div>
</div>
)
}
@@ -1,4 +1,5 @@
import { UnsealedCredentialNotice } from './UnsealedCredentialNotice'
import { KeychainCredentialNotice } from './keychain-credential-notice'
import { useState } from 'react'
import { AlertCircle, CheckCircle2, LoaderCircle, Unlink } from 'lucide-react'
import { LinearIcon } from '@/components/icons/LinearIcon'
@@ -136,6 +137,11 @@ export function LinearIntegrationCard(): React.JSX.Element {
'Your Linear API token'
)}
/>
<KeychainCredentialNotice
service="Linear"
status={contextMatches ? linearStatus : {}}
statusContextKey={linearStatusContextKey}
/>
{connected ? (
<div className="space-y-2">
{workspaces.map((workspace) => {
@@ -4,6 +4,10 @@ import type { TaskPageComposerActionsModel } from '../../use-task-page-composer-
import { LoaderCircle } from 'lucide-react'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { translate } from '@/i18n/i18n'
import {
keychainLockedCredentialMessage,
useCredentialHostName
} from '@/components/settings/keychain-credential-notice'
import { Button } from '@/components/ui/button'
import { jiraListGridStyle } from '../../jira-list-columns'
import { JiraListColumnsMenu } from '../../jira-list-columns-menu'
@@ -67,6 +71,15 @@ export function TaskPageJiraContent({
accountBackedTaskSourceHostId,
setJiraRefreshNonce
} = model
const { jiraStatusContextKey } = model
const credentialHost = useCredentialHostName(
jiraStatusContextKey,
jiraStatus.credentialErrorReason === 'keychain_locked'
)
const jiraCredentialError =
jiraStatus.credentialErrorReason === 'keychain_locked'
? keychainLockedCredentialMessage('Jira', credentialHost)
: jiraStatus.credentialError
const boardTarget = ipcOrRuntimeTargetForHostId(
jiraTaskSourceContext?.hostId ?? accountBackedTaskSourceHostId
)
@@ -142,12 +155,12 @@ export function TaskPageJiraContent({
/>
) : null}
{jiraStatus.credentialError ? (
{jiraCredentialError ? (
<div className="border-b border-border px-4 py-4 text-sm text-destructive">
{jiraStatus.credentialError}
{jiraCredentialError}
</div>
) : null}
{!jiraStatus.credentialError && jiraError ? (
{!jiraCredentialError && jiraError ? (
<TaskPageJiraErrorBanner
error={jiraError}
open={jiraErrorDetailsOpen}
@@ -155,7 +168,7 @@ export function TaskPageJiraContent({
/>
) : null}
<TaskPageJiraTextFallbackNotice
rejection={jiraStatus.credentialError ? null : jiraJqlRejection}
rejection={jiraCredentialError ? null : jiraJqlRejection}
/>
{jiraLoading && jiraIssues.length === 0 ? (
@@ -174,7 +187,7 @@ export function TaskPageJiraContent({
{!jiraLoading &&
displayedJiraIssues.length === 0 &&
!jiraError &&
!jiraStatus.credentialError ? (
!jiraCredentialError ? (
<div className="px-4 py-10 text-center">
<p className="text-sm font-medium text-foreground">
{translate('auto.components.TaskPage.eba87f2edb', 'No Jira issues found')}
@@ -12,6 +12,10 @@ import {
storedLinearTeamSelection
} from '@/components/task-page-linear-team-selection'
import { useTaskPageLinearFilterSelection } from './use-task-page-linear-filter-selection'
import {
keychainLockedCredentialMessage,
useCredentialHostName
} from './settings/keychain-credential-notice'
export type TaskPageLinearListSelectionPreludeModel = ReturnType<
typeof useTaskPageLinearListSelectionPrelude
>
@@ -70,8 +74,15 @@ export function useTaskPageLinearListSelectionPrelude(model: TaskPageGitLabLoadi
: selectedLinearCustomView?.model === 'issue'
? linearCustomViewContentsLoading
: linearLoading
const { linearStatusContextKey } = model
const credentialHost = useCredentialHostName(
linearStatusContextKey,
linearStatus.credentialErrorReason === 'keychain_locked'
)
const activeLinearIssueError =
linearStatus.credentialError ??
(linearStatus.credentialErrorReason === 'keychain_locked'
? keychainLockedCredentialMessage('Linear', credentialHost)
: linearStatus.credentialError) ??
(selectedLinearProject && linearProjectTab === 'issues'
? linearProjectIssuesError
: selectedLinearCustomView?.model === 'issue'
+7
View File
@@ -12880,6 +12880,13 @@
"confirm": "Restart",
"terminalsUnknown": "Orca couldn’t confirm whether terminals are running on this host, so it didn’t restart the managed server.",
"terminalsRunning": "{{count}} terminals are still running on this host. Close them, then restart the managed server."
},
"keychainCredentialNotice": {
"locked": "{{service}} is unavailable on {{host}}: its macOS login Keychain is locked. This happens after a reboot when you only sign in over SSH. Sign in to that Mac at its screen once. Orca then offers to switch the host to file storage so this stops happening.",
"unnamedHost": "the remote server",
"moved": "Switched {{host}} to file storage",
"offerMove": "{{host}} keeps its credential key in its macOS login Keychain, which was locked after a reboot. Switch to file storage to keep Linear and Jira working when you only sign in over SSH. The key then sits in a file only this account can read, which protects it less than the Keychain: it is not tied to this machine, so a backup or shared copy of ~/.orca can open the credentials.",
"moveAction": "Switch to file storage"
}
},
"right": {
@@ -1,4 +1,5 @@
import type { GlobalSettings } from '../../../shared/global-settings-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
export function getProviderRuntimeContextKey(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
@@ -17,6 +18,14 @@ export function isEnvironmentRuntimeContextKey(key: string | null | undefined):
return key?.startsWith('runtime:') ?? false
}
/** The host a context key from {@link getEnvironmentRuntimeContextKey} was read from. */
export function runtimeTargetForContextKey(key: string): RuntimeClientTarget {
const base = key.slice(0, key.lastIndexOf('#') === -1 ? key.length : key.lastIndexOf('#'))
return base.startsWith('runtime:')
? { kind: 'environment', environmentId: base.slice('runtime:'.length) }
: { kind: 'local' }
}
export function hasRemoteProviderRuntime(
settings: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
): boolean {
@@ -170,4 +170,26 @@ describe('parseJiraConnectionStatus', () => {
]
})
})
it("keeps a newer host's locked-Keychain reason and drops values it does not know", () => {
const locked = parseJiraConnectionStatus({
connected: true,
viewer: null,
credentialError: 'Could not decrypt saved Jira credential.',
credentialErrorReason: 'keychain_locked'
})
expect(locked.credentialErrorReason).toBe('keychain_locked')
expect(
parseJiraConnectionStatus({ connected: true, viewer: null, canMoveCredentialKeyToFile: true })
.canMoveCredentialKeyToFile
).toBe(true)
const future = parseJiraConnectionStatus({
connected: true,
viewer: null,
credentialErrorReason: 'some_future_reason',
canMoveCredentialKeyToFile: 'yes'
})
expect(future.credentialErrorReason).toBeUndefined()
expect(future.canMoveCredentialKeyToFile).toBeUndefined()
})
})
@@ -1,3 +1,4 @@
import { isIntegrationCredentialErrorReason } from '../../../shared/integration-credential-errors'
import type {
JiraAuthType,
JiraConnectionStatus,
@@ -93,6 +94,13 @@ export function parseJiraConnectionStatus(value: unknown): JiraConnectionStatus
activeSiteId: parseOptionalSiteId(value.activeSiteId),
selectedSiteId: parseOptionalSiteId(value.selectedSiteId),
credentialError: typeof value.credentialError === 'string' ? value.credentialError : undefined,
credentialErrorReason: isIntegrationCredentialErrorReason(value.credentialErrorReason)
? value.credentialErrorReason
: undefined,
canMoveCredentialKeyToFile:
typeof value.canMoveCredentialKeyToFile === 'boolean'
? value.canMoveCredentialKeyToFile
: undefined,
credentialProtection:
value.credentialProtection === null || isSecretAtRestProtection(value.credentialProtection)
? value.credentialProtection
@@ -41,6 +41,8 @@ function hasJiraStatusChanged(
return (
previous.connected !== next.connected ||
previous.credentialError !== next.credentialError ||
previous.credentialErrorReason !== next.credentialErrorReason ||
previous.canMoveCredentialKeyToFile !== next.canMoveCredentialKeyToFile ||
previous.credentialProtection !== next.credentialProtection ||
previous.supportsScopedTokens !== next.supportsScopedTokens ||
previous.activeSiteId !== next.activeSiteId ||
@@ -128,6 +128,8 @@ export function linearStatusScopeSignature(status: LinearConnectionStatus): stri
return JSON.stringify({
connected: status.connected,
credentialError: status.credentialError ?? null,
credentialErrorReason: status.credentialErrorReason ?? null,
canMoveCredentialKeyToFile: status.canMoveCredentialKeyToFile ?? false,
activeWorkspaceId: status.activeWorkspaceId ?? null,
selectedWorkspaceId: getSelectedWorkspaceId(status),
viewer: status.viewer
@@ -4,6 +4,23 @@ export function credentialDecryptionMessage(service: IntegrationCredentialServic
return `Could not decrypt saved ${service} credential. Approve Keychain access or reconnect ${service}.`
}
/** Why a decrypt failed, when the host can say; absent on older hosts. */
export type IntegrationCredentialErrorReason = 'keychain_locked'
export function isIntegrationCredentialErrorReason(
value: unknown
): value is IntegrationCredentialErrorReason {
return value === 'keychain_locked'
}
/** Appended on the host so a client too old to read the typed reason still sees the cause. */
export function keychainLockedCredentialHint(): string {
return (
"This host's macOS login Keychain is locked, which happens after a reboot when you only " +
'sign in over SSH. Sign in to the Mac at its screen once, or switch the host to file storage.'
)
}
// Why: decrypt errors cross IPC/RPC boundaries where only the message
// survives serialization, so detection matches on the canonical message.
export function isIntegrationCredentialDecryptionError(error: unknown): boolean {
+5
View File
@@ -1,3 +1,4 @@
import type { IntegrationCredentialErrorReason } from './integration-credential-errors'
import type { SecretAtRestProtection } from './secret-at-rest-protection'
// 'cloud' = Atlassian Cloud (email + API token, Basic auth, REST v3).
// 'server' = self-hosted Jira Server/Data Center (personal access token,
@@ -33,6 +34,10 @@ export type JiraConnectionStatus = {
// Set when a stored token file exists but could not be decrypted, so the
// UI can explain reads failing while the connection still looks saved.
credentialError?: string
// Optional, from a newer host: why credentialError happened, and whether the host can move its
// credential key out of a lockable keychain. Older hosts omit both; older clients ignore them.
credentialErrorReason?: IntegrationCredentialErrorReason
canMoveCredentialKeyToFile?: boolean
// 'plaintext' when any stored token is unsealed, so Settings can warn. Optional:
// an older remote host omits it, and absent must read as "unknown", not "sealed".
credentialProtection?: SecretAtRestProtection | null
+5
View File
@@ -1,3 +1,4 @@
import type { IntegrationCredentialErrorReason } from '../integration-credential-errors'
import type { SecretAtRestProtection } from '../secret-at-rest-protection'
export type LinearViewer = {
displayName: string
@@ -40,6 +41,10 @@ export type LinearConnectionStatus = {
// Set when a stored token file exists but could not be decrypted, so the
// UI can explain reads failing while the connection still looks saved.
credentialError?: string
// Optional, from a newer host: why credentialError happened, and whether the host can move its
// credential key out of a lockable keychain. Older hosts omit both; older clients ignore them.
credentialErrorReason?: IntegrationCredentialErrorReason
canMoveCredentialKeyToFile?: boolean
// 'plaintext' when any stored token is unsealed, so Settings can warn. Optional:
// an older remote host omits it, and absent must read as "unknown", not "sealed".
credentialProtection?: SecretAtRestProtection | null
+1
View File
@@ -1008,6 +1008,7 @@ export const RPC_PARAMS_BY_METHOD = {
'hostedReview.createStacked': HostedReviewCreate,
'hostedReview.forBranch': HostedReviewForBranch,
'hostedReview.getCreationEligibility': HostedReviewCreationEligibility,
'integrationCredentials.moveKeyToFile': null,
'jira.addIssueComment': IssueCommentOfJiraParams,
'jira.attachmentImage': AttachmentImage,
'jira.cancelGenerateIssueSummary': CancelGenerateIssueSummary,
+17
View File
@@ -25,6 +25,23 @@ export type SecretStore = {
* or sealing with a backend that does not meaningfully protect. Null means sealed.
*/
describeProtectionGap(): string | null
/** Only a store whose key can live in a lockable keychain (orcad on macOS) has this. */
keychainLock?: SecretStoreKeychainLock
}
/** `unlocked_after_lock`: the key loaded again after this server saw its keychain locked. */
export type SecretStoreKeychainLockState = 'locked' | 'unlocked_after_lock'
export type SecretStoreKeyMoveResult =
| { ok: true }
| { ok: false; reason: 'keychain_locked' | 'not_in_keychain' | 'failed'; message: string }
export type SecretStoreKeychainLock = {
state(): SecretStoreKeychainLockState | null
/** Retries a locked key; resolves true once each time it became loadable since the last call. */
recover(): Promise<boolean>
/** Moves the key from the keychain into the owner-only key file, keeping every credential. */
moveKeyToFile(): Promise<SecretStoreKeyMoveResult>
}
/**