hermes-agent/apps/desktop/electron/native-token-store.ts

165 lines
6.2 KiB
TypeScript

/**
* native-token-store.ts
*
* The encrypted-at-rest persistence seam for RFC 8252 native OAuth tokens:
* NativeTokenSet → JSON → safeStorage blob → store file, and back again on the
* next launch.
*
* Kept standalone (no `import 'electron'`) so the whole restart path unit-tests
* with the `electron` vitest project — the same pattern as native-oauth.ts.
* main.ts owns the electron-coupled halves and injects them: the safeStorage
* encrypt/decrypt pair and the userData store-file read/write.
*
* The parser direction is the load-bearing detail. What lands on disk is the
* *normalized* camelCase NativeTokenSet, so the reload boundary is
* parseStoredTokenSet(). Gateway `/auth/native/token` responses are snake_case
* and stay with parseTokenResponse(); crossing the two made the decrypted set
* throw on every launch, which surfaced as "signed out after restart" (#73271).
*/
import { type NativeTokenSet, parseStoredTokenSet } from './native-oauth'
/** One encrypted blob as written per gateway base URL. */
export interface StoredTokenSecret {
encoding?: string
value?: string
}
/**
* The narrow set of side effects main.ts owns. Everything here is injected so
* the store/load round trip can be exercised without an Electron runtime, and
* so production keeps using safeStorage unchanged.
*/
export interface NativeTokenStoreIo {
/**
* Encrypt one plaintext blob. main.ts passes the strict safeStorage helper,
* which THROWS when the OS keychain is unavailable — that must stay loud.
* A `null` return is treated as the same authoritative failure: the caller
* throws rather than persisting an empty entry over good tokens.
*/
encrypt: (plaintext: string) => StoredTokenSecret | null
/** Decrypt a stored payload; returns '' when it cannot be read. */
decrypt: (secret: any) => string
/** Raw store-file text. Throws when the file is absent — treated as empty. */
readStoreText: () => string
/** Persist the store-file text (main.ts writes mode 0600 under userData). */
writeStoreText: (text: string) => void
rememberLog?: (message: string) => void
}
/**
* baseUrl → encrypted payload. A missing, unreadable, or hand-mangled store
* reads as empty rather than throwing: a failed *read* falls to the next rung.
*
* Arrays are rejected alongside every other non-object shape: assigning
* store[baseUrl] on an array would set a non-index property, which
* JSON.stringify drops on the way back out — the write would look like it
* succeeded and the tokens would be gone on the next launch.
*/
function readStore(io: NativeTokenStoreIo): Record<string, any> {
try {
const parsed = JSON.parse(io.readStoreText())
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
} catch {
return {}
}
}
/**
* A gateway URL safe to write into a log line.
*
* normalizeRemoteBaseUrl() strips query, fragment, and trailing slashes but
* NOT userinfo, so a configured gateway can legitimately carry
* `user:password@` all the way down to this store. Interpolating that into a
* failure log would spill the credentials into the desktop log file, so drop
* the userinfo and keep only what makes the line useful — scheme, host, port,
* path. A value URL cannot parse never falls back to the raw input: echoing it
* is the exact leak this guards against.
*/
function redactGatewayUrl(baseUrl: string): string {
try {
const parsed = new URL(baseUrl)
parsed.username = ''
parsed.password = ''
// `host` already carries a non-default port.
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`
} catch {
return '<invalid gateway URL>'
}
}
/**
* Write (or, with `tokens === null`, drop) one gateway's token set, merging
* into whatever other gateways are already stored.
*/
export function persistNativeTokenSet(baseUrl: string, tokens: NativeTokenSet | null, io: NativeTokenStoreIo): void {
const store = readStore(io)
if (tokens) {
// Encrypt the whole set as one blob so the refresh token never lands in
// plaintext on disk. Deliberately outside the try below: an unusable
// keychain is an authoritative write failure and must surface to the
// caller, not be logged away as if the tokens were saved.
const secret = io.encrypt(JSON.stringify(tokens))
if (!secret) {
// A null blob is the same failure as a throw, only quieter. Storing it
// would replace a good entry with nothing: the write would report
// success, the next launch would show signed out, and the refresh token
// would be unrecoverable. Fail before touching the store.
throw new Error('Secure token storage returned no encrypted payload; refusing to overwrite stored native tokens.')
}
store[baseUrl] = secret
} else {
delete store[baseUrl]
}
try {
io.writeStoreText(JSON.stringify(store))
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
io.rememberLog?.(`[native-oauth] failed to persist tokens: ${detail}`)
}
}
/**
* Reconstruct a gateway's token set from the stored encrypted payload. Returns
* null when nothing is stored, when the blob cannot be decrypted, or when it
* does not parse — never a partially-populated set.
*/
export function loadNativeTokenSet(baseUrl: string, io: NativeTokenStoreIo): NativeTokenSet | null {
// The UNREDACTED url is the store key — redaction is for logs only.
const secret = readStore(io)[baseUrl]
if (!secret) {
return null
}
try {
const plaintext = io.decrypt(secret)
if (!plaintext) {
// A keychain that is merely locked/unavailable right now must not cost
// the user their refresh token — leave the entry for the next attempt.
io.rememberLog?.(
`[native-oauth] failed to decrypt stored tokens for ${redactGatewayUrl(baseUrl)}; keeping stored entry for retry`
)
return null
}
// Stored blobs are normalized camelCase sets, never raw gateway responses.
return parseStoredTokenSet(JSON.parse(plaintext))
} catch (error) {
const detail = error instanceof Error ? error.message : String(error)
io.rememberLog?.(`[native-oauth] failed to load stored tokens for ${redactGatewayUrl(baseUrl)}: ${detail}`)
return null
}
}