mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user