1000 lines
41 KiB
TypeScript
1000 lines
41 KiB
TypeScript
/**
|
|
* E2E at-rest contract for the remote-gateway session token (issue #77486).
|
|
*
|
|
* The reported bug: configuring a remote gateway persisted the dashboard
|
|
* session token as PLAINTEXT into `connection.json` under the app's userData
|
|
* dir (macOS `~/Library/Application Support/Hermes/connection.json`, Windows
|
|
* `AppData\Roaming\Hermes\connection.json`). Anything that can read the file
|
|
* — a backup, a sync client, another local process, a support bundle — got a
|
|
* live gateway credential.
|
|
*
|
|
* The contract these tests encode is deliberately stated WITHOUT naming a
|
|
* storage strategy:
|
|
*
|
|
* 1. ABSENT FROM DISK. After the app has been configured with a remote
|
|
* gateway token, the token's plaintext value must not appear anywhere in
|
|
* `connection.json`, in any sibling file the app writes under userData,
|
|
* or in HERMES_HOME (logs included).
|
|
* 2. STILL FUNCTIONAL. After a restart, the app must still be able to USE
|
|
* that credential — it decrypts the stored blob and puts the exact
|
|
* original token on the wire.
|
|
* 3. UNREADABLE BY OTHER LOCAL ACCOUNTS. `connection.json` must not be
|
|
* group/other-accessible, whether the app just wrote it or inherited it
|
|
* from an older install.
|
|
*
|
|
* All three matter and none is sufficient alone. (1) alone is trivially
|
|
* satisfied by a "fix" that drops the token on the floor; (2) alone is
|
|
* satisfied by the bug itself. So (2) is verified through the app's own
|
|
* connection test against a fake gateway that records the
|
|
* `X-Hermes-Session-Token` header it receives — a dropped or mangled token
|
|
* cannot produce that header.
|
|
*
|
|
* (3) is orthogonal to (1) and invisible to it: safeStorage keeps the token
|
|
* opaque no matter what the file's mode is, so a 0644 `connection.json` passes
|
|
* the raw-bytes scan every time while still exposing the ciphertext blob, the
|
|
* gateway URL and the SSH host/user/keyPath to any other local account. It is
|
|
* asserted explicitly (see `expectOwnerOnlyMode`) because no amount of
|
|
* encryption evidence implies it.
|
|
*
|
|
* We deliberately do NOT assert `encoding === 'safeStorage'` or any other
|
|
* shape of the stored blob. That would be a change-detector: a fix that moved
|
|
* to the OS keychain proper, to an async safeStorage provider, or to a
|
|
* separate credential file would break the test while being *more* correct.
|
|
* The load-bearing assertion is the raw-bytes absence of the secret.
|
|
*
|
|
* Four at-rest paths, hence four tests — three enforced, one a documented gap:
|
|
*
|
|
* 1. A NEWLY configured token (ACTIVE). The app's own write path routes
|
|
* through the strict `encryptDesktopSecret`; this test holds it there
|
|
* against regression, and pins the mode of the file it actually wrote.
|
|
* 2. An EXISTING `connection.json` at the old 0644 (ACTIVE). Covers the
|
|
* read-side tighten, and ONLY the mode — its token is already ciphertext,
|
|
* which is what keeps it independent of the migration test 4 defers.
|
|
* 3. A CORRUPT `connection.json` at 0644 (ACTIVE). The tighten must not be
|
|
* gated on the parse succeeding: a truncated file still holds the token
|
|
* bytes, and the parse failure is swallowed, so nothing would ever come
|
|
* back for it.
|
|
* 4. An EXISTING plaintext `connection.json` (`test.fixme`). Legacy payloads
|
|
* are deliberately NOT migrated yet. The test is kept, disabled, with a
|
|
* precise reason — see the block comment above it.
|
|
*
|
|
* ── Correcting the record on test 4 ─────────────────────────────────────
|
|
*
|
|
* An earlier revision of this file asserted that migration and justified it by
|
|
* claiming the first implementation (`d3d177283`) fell back to
|
|
* `{ encoding: 'plain', value }` when `isEncryptionAvailable()` was false.
|
|
* That citation is FALSE for this codebase. What is actually true:
|
|
*
|
|
* git merge-base --is-ancestor d3d1772837a7b0552940b55455ae734c72e0a8f1 HEAD -> 1 (NOT an ancestor)
|
|
* git merge-base --is-ancestor 51c68d4ab1a9e3c62fb1048fccb84144c409f0e7 HEAD -> 0 (IS an ancestor)
|
|
* git log -S 'Fall through to plaintext' upstream/main -- apps/desktop -> (no commits)
|
|
*
|
|
* `d3d177283` exists only on `upstream/bb/gui-mainmerge-tmp`,
|
|
* `brooklyn/gui-installer-prereqs`, and the `desktop-pr20059-installers`
|
|
* pre-release tag. Mainline NEVER shipped a code path that wrote a plaintext
|
|
* gateway token: `51c68d4ab` ("Add Hermes desktop app (#20059)"), the commit
|
|
* that brought the desktop app to mainline, already contained the strict
|
|
* throw ("Secure token storage is unavailable, …") in `hardening.cjs`.
|
|
*
|
|
* One `{ encoding: 'plain', value }` literal does remain on mainline
|
|
* (`electron/main.ts`, in `coerceDesktopConnectionConfig`), but it is
|
|
* unreachable as an at-rest write: it is gated on `persistToken === false`,
|
|
* whose only caller is the connection-TEST handler, which never calls
|
|
* `writeDesktopConnectionConfig`. That token stays in memory for the duration
|
|
* of one probe.
|
|
*
|
|
* So the affected population is not "anyone who configured a gateway before
|
|
* the fix". It is narrow and non-mainline: pre-release `bb/gui` installs
|
|
* (including the `desktop-pr20059-installers` build) plus hand-edited or
|
|
* hand-migrated `connection.json` files. Those files DO still work, because
|
|
* `decryptDesktopSecret` returns any non-safeStorage `value` verbatim on read
|
|
* — the read path is intentionally unchanged, so nobody is signed out. That
|
|
* read-path acceptance, not a mainline writer, is what makes the fixme'd
|
|
* fixture realistic.
|
|
*
|
|
* Migration is DEFERRED, not forgotten. An adversarial review of the
|
|
* migration that briefly lived here returned DO NOT SHIP, having reproduced
|
|
* two token-loss scenarios: it silently reverts and then destroys the opt-in
|
|
* plaintext choice that open upstream PR #62319 deliberately adds; and it
|
|
* converts a portable credential into a keychain-bound one with no consent,
|
|
* destroying the only recoverable copy while not actually remediating the
|
|
* exposure (the plaintext is already in backups, so the real remedy is
|
|
* ROTATION). The prerequisites are enumerated above test 2.
|
|
*
|
|
* Environment limits are encoded rather than papered over. Electron's
|
|
* safeStorage is unavailable on Linux with no keyring, which is the shape of
|
|
* this suite's CI runner (ubuntu-latest, see .github/workflows/e2e-desktop.yml).
|
|
* The absence assertion is unconditional there — it is the security
|
|
* requirement, and it must hold in every environment. Only the *other* half is
|
|
* conditional: with secure storage the save must succeed, and without it the
|
|
* save must fail loudly (which is what the current strict `encryptDesktopSecret`
|
|
* does) instead of quietly writing plaintext. See the branch comments in each
|
|
* test for the reasoning, including the one case this spec refuses to invent a
|
|
* policy for.
|
|
*
|
|
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
|
*/
|
|
|
|
import * as fs from 'node:fs'
|
|
import * as http from 'node:http'
|
|
import type { AddressInfo } from 'node:net'
|
|
import * as path from 'node:path'
|
|
|
|
import { buildAppEnv, createSandbox, launchDesktop, type Sandbox } from './fixtures'
|
|
import { allowErrorBanners, type ElectronApplication, expect, type Page, test } from './test'
|
|
|
|
/**
|
|
* The secret under test. Long, random-looking, and unique to this spec so a
|
|
* raw-bytes scan cannot produce a false negative by colliding with ordinary
|
|
* config content. Kept to `[A-Za-z0-9-]` on purpose: encodeURIComponent() is
|
|
* the identity function over this alphabet, so the raw-bytes needle also
|
|
* covers the URL-encoded form the WS dialer builds (`?token=…`).
|
|
*/
|
|
const SENTINEL_TOKEN = 'hermes-e2e-at-rest-sentinel-Zq7Z4hV9nX2pL8sK3tB6wR1yM5jD0fG'
|
|
|
|
/** Skip absurdly large files during the leak scan (Chromium caches). */
|
|
const MAX_SCAN_BYTES = 16 * 1024 * 1024
|
|
|
|
/**
|
|
* One fixed Electron app name for this spec, instead of the timestamped one
|
|
* `buildAppEnv` generates. On macOS the safeStorage keychain item is derived
|
|
* from the app name, so a per-launch name would (a) make the post-restart
|
|
* decrypt fail for the wrong reason and (b) leave a fresh keychain entry on
|
|
* the developer's login keychain on every run. Safe because the suite runs
|
|
* one worker at a time and both launches here are sequential; the
|
|
* single-instance lock keys off userData, which is per-sandbox.
|
|
*/
|
|
const STABLE_APP_NAME = 'HermesE2EAtRestStorage'
|
|
|
|
// ─── Fake gateway ───────────────────────────────────────────────────────
|
|
|
|
interface FakeGateway {
|
|
url: string
|
|
/** Every `X-Hermes-Session-Token` value the app has sent us. */
|
|
sessionTokens: string[]
|
|
close: () => Promise<void>
|
|
}
|
|
|
|
/**
|
|
* A minimal stand-in for a remote Hermes gateway. It serves the public
|
|
* `/api/status` probe (which the desktop connection test hits first, with the
|
|
* session token in a header) and refuses the WebSocket upgrade immediately so
|
|
* the second leg of the connection test fails fast instead of burning the
|
|
* probe's 10s connect timeout. We only care about the header it captured.
|
|
*
|
|
* The e2e mock-server is an OpenAI-compatible *inference* mock, not a gateway,
|
|
* so it cannot answer /api/status — hence this small local server.
|
|
*/
|
|
async function startFakeGateway(): Promise<FakeGateway> {
|
|
const sessionTokens: string[] = []
|
|
|
|
const server = http.createServer((req, res) => {
|
|
const token = req.headers['x-hermes-session-token']
|
|
|
|
if (typeof token === 'string' && token) {
|
|
sessionTokens.push(token)
|
|
}
|
|
|
|
if (req.url?.startsWith('/api/status')) {
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ auth_required: false, ok: true, version: '0.0.0-e2e-fake' }))
|
|
|
|
return
|
|
}
|
|
|
|
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ detail: 'not found' }))
|
|
})
|
|
|
|
// Refuse the WS leg at once: the connection test's WS probe should return a
|
|
// fast failure rather than hang. The status header is already captured.
|
|
server.on('upgrade', (req, socket) => {
|
|
const token = new URL(req.url ?? '/', 'http://127.0.0.1').searchParams.get('token')
|
|
|
|
if (token) {
|
|
sessionTokens.push(token)
|
|
}
|
|
|
|
socket.destroy()
|
|
})
|
|
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
|
|
const { port } = server.address() as AddressInfo
|
|
|
|
return {
|
|
close: () =>
|
|
new Promise<void>(resolve => {
|
|
server.closeAllConnections?.()
|
|
server.close(() => resolve())
|
|
}),
|
|
sessionTokens,
|
|
url: `http://127.0.0.1:${port}`,
|
|
}
|
|
}
|
|
|
|
// ─── On-disk leak scanning ──────────────────────────────────────────────
|
|
|
|
interface Needle {
|
|
bytes: Buffer
|
|
label: string
|
|
}
|
|
|
|
/**
|
|
* The forms a leak could take. Raw bytes, not JSON.parse + field inspection:
|
|
* the point is that the secret is nowhere in the file — including inside a
|
|
* nested field, a cached WS URL, or a field name nobody thought to check.
|
|
*
|
|
* The base64 needle catches the cheapest wrong "fix": base64 is an encoding,
|
|
* not encryption, so a token that is merely base64'd is still plaintext at
|
|
* rest. A real ciphertext will contain neither needle.
|
|
*/
|
|
function secretNeedles(secret: string): Needle[] {
|
|
return [
|
|
{ bytes: Buffer.from(secret, 'utf8'), label: 'plaintext' },
|
|
{ bytes: Buffer.from(Buffer.from(secret, 'utf8').toString('base64'), 'utf8'), label: 'base64' },
|
|
]
|
|
}
|
|
|
|
/** Relative paths of every file under `root` whose bytes contain a needle. */
|
|
function scanTreeForSecret(root: string, needles: Needle[]): string[] {
|
|
const hits: string[] = []
|
|
|
|
const walk = (dir: string): void => {
|
|
let entries: fs.Dirent[]
|
|
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name)
|
|
|
|
if (entry.isDirectory()) {
|
|
walk(full)
|
|
|
|
continue
|
|
}
|
|
|
|
if (!entry.isFile()) {
|
|
continue
|
|
}
|
|
|
|
try {
|
|
if (fs.statSync(full).size > MAX_SCAN_BYTES) {
|
|
continue
|
|
}
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
let buf: Buffer
|
|
|
|
try {
|
|
buf = fs.readFileSync(full)
|
|
} catch {
|
|
continue
|
|
}
|
|
|
|
for (const needle of needles) {
|
|
if (buf.includes(needle.bytes)) {
|
|
hits.push(`${path.relative(root, full)} [${needle.label}]`)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(root)
|
|
|
|
return hits
|
|
}
|
|
|
|
/**
|
|
* Read a file's bytes, or an empty buffer when it does not exist. A correct
|
|
* fix is allowed to delete/replace `connection.json` rather than rewrite it,
|
|
* and the refusal path may never create it at all — neither should crash the
|
|
* scan before its assertion runs.
|
|
*/
|
|
function readIfExists(filePath: string): Buffer {
|
|
try {
|
|
return fs.readFileSync(filePath)
|
|
} catch {
|
|
return Buffer.alloc(0)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The stored token's `encoding` tag, for diagnostics only — never its value.
|
|
* Reported on failure so a red run says *why* (e.g. still `plain`) instead of
|
|
* only that a scan matched. Deliberately NOT an assertion: which encoding a
|
|
* correct fix chooses is its own business.
|
|
*/
|
|
function storedTokenEncoding(connectionFile: string): string {
|
|
try {
|
|
const parsed = JSON.parse(readIfExists(connectionFile).toString('utf8'))
|
|
|
|
return String(parsed?.remote?.token?.encoding ?? '<none>')
|
|
} catch {
|
|
return '<unparsable>'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assert a credential file is not readable or writable by group/other.
|
|
*
|
|
* This is the one contract the raw-bytes scan above structurally cannot see:
|
|
* safeStorage keeps the token opaque regardless of the file's mode, so a
|
|
* world-readable `connection.json` passes every absence assertion in this file
|
|
* while still handing the URL, the SSH host/user/keyPath, and the ciphertext
|
|
* blob to any other local account. Encryption and permissions are independent
|
|
* halves of "at rest", and only one of them was covered here.
|
|
*
|
|
* Asserted as `mode & 0o077 === 0` rather than `=== 0o600`: the requirement is
|
|
* that nobody else can reach the file, and pinning the exact bits would make
|
|
* this a change-detector against a future 0400 or a setgid-dir umask.
|
|
*
|
|
* POSIX only. `tightenSecretFileMode` no-ops on Windows deliberately (Node maps
|
|
* chmod to the read-only bit there, and userData is already ACL'd to the user
|
|
* profile — see the docstring in electron/hardening.ts, and PR #77527 for the
|
|
* one place ACLs are being handled). Mode bits are advisory on Windows, so
|
|
* asserting them would go red for behaviour the fix never claimed. The suite
|
|
* runs ubuntu-latest today (.github/workflows/e2e-desktop.yml); nothing else in
|
|
* this spec is platform-specific, and this assertion should not be what
|
|
* changes that.
|
|
*/
|
|
function expectOwnerOnlyMode(filePath: string, why: string): void {
|
|
if (process.platform === 'win32') {
|
|
return
|
|
}
|
|
|
|
const mode = fs.statSync(filePath).mode & 0o777
|
|
|
|
expect(mode & 0o077, `${why} (mode ${mode.toString(8)})`).toBe(0)
|
|
}
|
|
|
|
// ─── App helpers ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Launch the desktop app against `sandbox` with a fake boot failure injected.
|
|
*
|
|
* The credential path we are testing is entirely main-process (IPC handler →
|
|
* coerce → safeStorage → userData write) and does not need a live agent
|
|
* backend, so we skip spawning `hermes serve` (no Python needed, ~3s launch,
|
|
* hermetic). This is also a real user situation rather than an artificial one:
|
|
* the boot-failure overlay's own recovery affordance is "Connection settings",
|
|
* i.e. pointing the app at a remote gateway is exactly what a user does from
|
|
* this state. BOOT_FAKE_ERROR short-circuits startHermes() *before* remote
|
|
* resolution, so no launch ever dials the fake gateway on its own.
|
|
*/
|
|
async function launchAgainst(sandbox: Sandbox): Promise<{ app: ElectronApplication; page: Page }> {
|
|
const env = buildAppEnv(sandbox, {
|
|
HERMES_DESKTOP_APP_NAME: STABLE_APP_NAME,
|
|
HERMES_DESKTOP_BOOT_FAKE_ERROR: 'E2E at-rest storage spec: local backend intentionally not started',
|
|
})
|
|
|
|
const { app, page } = await launchDesktop(env)
|
|
|
|
// The capability bridge is what we drive; it lands with the preload, well
|
|
// before the app would be "ready" in the boot sense.
|
|
await page.waitForFunction(
|
|
() => Boolean((window as unknown as { hermesDesktop?: Record<string, unknown> }).hermesDesktop?.saveConnectionConfig),
|
|
undefined,
|
|
{ timeout: 60_000 },
|
|
)
|
|
|
|
return { app, page }
|
|
}
|
|
|
|
/**
|
|
* Ask the running app where userData actually is, the same way the app does
|
|
* (`app.getPath('userData')`). The fixtures point userData at a temp sandbox,
|
|
* so a home-relative hardcoded path would test the wrong file — or no file.
|
|
*/
|
|
async function resolveUserDataDir(app: ElectronApplication): Promise<string> {
|
|
return app.evaluate(({ app: electronApp }) => electronApp.getPath('userData'))
|
|
}
|
|
|
|
interface SafeStorageCapability {
|
|
available: boolean
|
|
backend: string
|
|
}
|
|
|
|
/**
|
|
* What secure storage is actually capable of on THIS host, asked after ready
|
|
* (on Linux the answer is meaningless before then).
|
|
*
|
|
* `backend` matters for the honest reading of a green run: on Linux with no
|
|
* keyring, Electron can still report encryption as available while selecting
|
|
* the `basic_text` backend, which encrypts with a hardcoded password — the
|
|
* bytes on disk are not the plaintext, but they are not meaningfully
|
|
* protected either. We record it rather than assert on it, because which
|
|
* posture Hermes should take there (refuse to save vs. accept basic_text) is
|
|
* a product decision, not something this test should silently ratify.
|
|
*/
|
|
async function readSafeStorageCapability(app: ElectronApplication): Promise<SafeStorageCapability> {
|
|
return app.evaluate(async ({ app: electronApp, safeStorage }) => {
|
|
await electronApp.whenReady()
|
|
|
|
let available = false
|
|
let backend = 'unavailable'
|
|
|
|
try {
|
|
available = safeStorage.isEncryptionAvailable()
|
|
} catch {
|
|
available = false
|
|
}
|
|
|
|
try {
|
|
// Linux-oriented API; other platforms may not implement it.
|
|
backend = safeStorage.getSelectedStorageBackend?.() ?? 'n/a'
|
|
} catch {
|
|
backend = 'n/a'
|
|
}
|
|
|
|
return { available, backend }
|
|
})
|
|
}
|
|
|
|
interface SaveOutcome {
|
|
config: { remoteTokenPreview?: null | string; remoteTokenSet?: boolean; remoteUrl?: string } | null
|
|
error: null | string
|
|
}
|
|
|
|
/**
|
|
* Drive the app's REAL save surface: the same `saveConnectionConfig` payload
|
|
* Settings → Gateway sends (see src/app/settings/gateway-settings.tsx). We use
|
|
* save rather than apply so the app persists the credential without trying to
|
|
* re-home onto the fake gateway.
|
|
*/
|
|
async function saveRemoteToken(page: Page, remoteUrl: string, remoteToken?: string): Promise<SaveOutcome> {
|
|
return page.evaluate(
|
|
async ([url, token]) => {
|
|
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
|
|
|
try {
|
|
const config = await desktop.saveConnectionConfig({
|
|
mode: 'remote',
|
|
remoteAuthMode: 'token',
|
|
...(token ? { remoteToken: token } : {}),
|
|
remoteUrl: url,
|
|
})
|
|
|
|
return { config, error: null }
|
|
} catch (error) {
|
|
return { config: null, error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
},
|
|
[remoteUrl, remoteToken ?? ''] as const,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Make the app USE the stored credential. No token in the payload, so the main
|
|
* process must read `connection.json`, decrypt what it stored, and put the
|
|
* plaintext on the wire itself. `buildRemoteBlock` throws "Remote gateway
|
|
* session token is required." when the stored blob no longer decrypts, so a
|
|
* fix that dropped the token fails here instead of quietly passing the
|
|
* absence assertion.
|
|
*/
|
|
async function exerciseStoredToken(page: Page, remoteUrl: string): Promise<{ error: null | string }> {
|
|
return page.evaluate(async url => {
|
|
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
|
|
|
try {
|
|
await desktop.testConnectionConfig({ mode: 'remote', remoteUrl: url })
|
|
|
|
return { error: null }
|
|
} catch (error) {
|
|
// A failing WS leg is expected (the fake gateway refuses the upgrade).
|
|
// The assertion is on what the gateway received, not on this result.
|
|
return { error: error instanceof Error ? error.message : String(error) }
|
|
}
|
|
}, remoteUrl)
|
|
}
|
|
|
|
// ─── Tests ──────────────────────────────────────────────────────────────
|
|
|
|
let gateway: FakeGateway | null = null
|
|
let sandbox: Sandbox | null = null
|
|
let app: ElectronApplication | null = null
|
|
|
|
test.beforeAll(async () => {
|
|
gateway = await startFakeGateway()
|
|
})
|
|
|
|
test.afterAll(async () => {
|
|
await gateway?.close()
|
|
gateway = null
|
|
})
|
|
|
|
test.beforeEach(() => {
|
|
// Boot is intentionally failed in this spec (see launchAgainst), so the
|
|
// boot-failure overlay's error banner is expected, not a failure.
|
|
allowErrorBanners()
|
|
})
|
|
|
|
test.afterEach(async () => {
|
|
await app?.close().catch(() => undefined)
|
|
app = null
|
|
sandbox?.cleanup()
|
|
sandbox = null
|
|
})
|
|
|
|
test.describe('remote gateway session token at rest', () => {
|
|
test('with keychain encryption opted IN, a newly configured token is never written to userData in plaintext, and still works after restart', async () => {
|
|
const fake = gateway!
|
|
sandbox = createSandbox('at-rest-fresh')
|
|
|
|
// Keychain-backed encryption is opt-in (default OFF — see
|
|
// electron/secret-storage-policy.ts). This test covers the opted-IN
|
|
// posture, so seed the policy the way the Settings toggle writes it.
|
|
fs.writeFileSync(
|
|
path.join(sandbox.userDataDir, 'secure-token-storage.json'),
|
|
JSON.stringify({ migrated: true, on: true }),
|
|
'utf8',
|
|
)
|
|
|
|
const first = await launchAgainst(sandbox)
|
|
app = first.app
|
|
|
|
const capability = await readSafeStorageCapability(app)
|
|
const userDataDir = await resolveUserDataDir(app)
|
|
const connectionFile = path.join(userDataDir, 'connection.json')
|
|
|
|
test.info().annotations.push({
|
|
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
|
type: 'safeStorage',
|
|
})
|
|
|
|
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
|
|
|
// Defined degradation, not a silent plaintext write. Where secure storage
|
|
// works, the save must succeed. Where it genuinely does not (headless
|
|
// Linux with no keyring, per Electron's safeStorage docs), refusing the
|
|
// save with a loud error is an acceptable outcome — what is NEVER
|
|
// acceptable is reporting success while leaving the secret readable on
|
|
// disk. The absence assertion below runs in both branches.
|
|
if (capability.available) {
|
|
expect(
|
|
saved.error,
|
|
'secure storage is available on this host, so saving a remote gateway token must succeed',
|
|
).toBeNull()
|
|
expect(saved.config?.remoteTokenSet).toBe(true)
|
|
} else {
|
|
expect(
|
|
saved.error,
|
|
'secure storage is unavailable, so the save must fail loudly rather than persist a plaintext token',
|
|
).not.toBeNull()
|
|
}
|
|
|
|
// Guard against a vacuous pass: when the save succeeded, the artifact must
|
|
// exist and must be the file the app really wrote for THIS connection.
|
|
// Without this, "no plaintext on disk" would also be true if nothing had
|
|
// been saved at all. Only asserted on the success branch — a refused save
|
|
// legitimately leaves no file behind.
|
|
const rawConnection = readIfExists(connectionFile)
|
|
|
|
if (capability.available) {
|
|
expect(fs.existsSync(connectionFile), `expected the app to write ${connectionFile}`).toBe(true)
|
|
expect(
|
|
rawConnection.includes(Buffer.from(fake.url, 'utf8')),
|
|
'connection.json should record the configured gateway URL (proves this is the real artifact)',
|
|
).toBe(true)
|
|
|
|
// The write path's OTHER half of at-rest: opaque bytes AND owner-only
|
|
// permissions. Deliberately here, on the file this test just proved the
|
|
// app really wrote, rather than in a unit test — nothing in the repo
|
|
// imports electron/main.ts (it imports electron), so this is the only
|
|
// place that can witness the app's own write actually going out at 0600
|
|
// instead of the 0644 umask default.
|
|
expectOwnerOnlyMode(
|
|
connectionFile,
|
|
'connection.json is group/other-accessible, so the encrypted token blob, gateway URL and SSH fields are readable by other local accounts',
|
|
)
|
|
}
|
|
|
|
// ── The load-bearing assertion ─────────────────────────────────────
|
|
const needles = secretNeedles(SENTINEL_TOKEN)
|
|
|
|
const connectionHits = needles.filter(needle => rawConnection.includes(needle.bytes)).map(needle => needle.label)
|
|
expect(
|
|
connectionHits,
|
|
`the gateway session token must not be recoverable from ${connectionFile} ` +
|
|
`(stored token encoding is "${storedTokenEncoding(connectionFile)}")`,
|
|
).toEqual([])
|
|
|
|
// …and not in any sibling file the app writes alongside it, nor in
|
|
// HERMES_HOME (desktop.log lives there).
|
|
expect(
|
|
scanTreeForSecret(userDataDir, needles),
|
|
'the gateway session token leaked into a userData file',
|
|
).toEqual([])
|
|
expect(
|
|
scanTreeForSecret(sandbox.hermesHome, needles),
|
|
'the gateway session token leaked into a HERMES_HOME file (logs included)',
|
|
).toEqual([])
|
|
|
|
if (!capability.available) {
|
|
// Nothing was stored, so there is no round trip to verify. The refusal
|
|
// itself was already asserted above.
|
|
return
|
|
}
|
|
|
|
// ── Secondary: the credential must still be USABLE ─────────────────
|
|
// Restart against the same userData so the token comes off disk, not out
|
|
// of a live process's memory.
|
|
await app.close().catch(() => undefined)
|
|
app = null
|
|
|
|
const second = await launchAgainst(sandbox)
|
|
app = second.app
|
|
|
|
expect(
|
|
await resolveUserDataDir(app),
|
|
'the restarted app must resolve the same userData dir, or this is not a round trip',
|
|
).toBe(userDataDir)
|
|
|
|
const reread = await second.page.evaluate(async () => {
|
|
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
|
|
|
return desktop.getConnectionConfig()
|
|
})
|
|
|
|
expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
|
|
expect(reread.remoteUrl).toBe(fake.url)
|
|
|
|
const before = fake.sessionTokens.length
|
|
await exerciseStoredToken(second.page, fake.url)
|
|
|
|
// The gateway is the witness: the app decrypted its stored blob and put
|
|
// the original secret on the wire. A dropped, truncated, or re-encoded
|
|
// token cannot produce this.
|
|
expect(
|
|
fake.sessionTokens.slice(before),
|
|
'the app must send the exact stored token to the gateway after a restart',
|
|
).toContain(SENTINEL_TOKEN)
|
|
})
|
|
|
|
/**
|
|
* The DEFAULT posture: keychain encryption opted out (no policy file at
|
|
* all). Saving a token must (a) succeed without ever touching safeStorage
|
|
* — this is the whole point of the opt-in: no macOS Keychain dialog on
|
|
* machines with a broken login keychain — (b) store the token with a
|
|
* non-safeStorage encoding at 0600, and (c) round-trip it across a
|
|
* restart. The plaintext-on-disk trade-off is the user's chosen (default)
|
|
* mode; owner-only file bits remain the at-rest boundary.
|
|
*/
|
|
test('with the default policy (no keychain), a token saves without secure storage, is owner-only on disk, and survives a restart', async () => {
|
|
const fake = gateway!
|
|
sandbox = createSandbox('at-rest-default')
|
|
|
|
const first = await launchAgainst(sandbox)
|
|
app = first.app
|
|
|
|
const userDataDir = await resolveUserDataDir(app)
|
|
const connectionFile = path.join(userDataDir, 'connection.json')
|
|
|
|
// Must succeed regardless of host keyring state — the default policy
|
|
// never consults safeStorage, so "no keyring" cannot refuse the save.
|
|
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
|
|
|
expect(saved.error, 'the default (opted-out) policy must save without secure storage').toBeNull()
|
|
expect(saved.config?.remoteTokenSet).toBe(true)
|
|
|
|
// Not a safeStorage blob, and owner-only on disk.
|
|
expect(storedTokenEncoding(connectionFile)).not.toBe('safeStorage')
|
|
expectOwnerOnlyMode(
|
|
connectionFile,
|
|
'connection.json is group/other-accessible; owner-only bits are the at-rest boundary for opted-out storage',
|
|
)
|
|
|
|
// Round trip across a restart, same witness as the opted-in test.
|
|
await app.close().catch(() => undefined)
|
|
app = null
|
|
|
|
const second = await launchAgainst(sandbox)
|
|
app = second.app
|
|
|
|
const reread = await second.page.evaluate(async () => {
|
|
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
|
|
|
return desktop.getConnectionConfig()
|
|
})
|
|
|
|
expect(reread.remoteTokenSet, 'the stored token must survive a restart').toBe(true)
|
|
|
|
const before = fake.sessionTokens.length
|
|
await exerciseStoredToken(second.page, fake.url)
|
|
|
|
expect(
|
|
fake.sessionTokens.slice(before),
|
|
'the app must send the exact stored token to the gateway after a restart',
|
|
).toContain(SENTINEL_TOKEN)
|
|
})
|
|
|
|
/**
|
|
* The read side of the same contract: an install written BEFORE the file was
|
|
* owner-only keeps its 0644 bits until something chmods it, and the write
|
|
* path cannot fix it — `fs.writeFileSync(path, data, { mode })` applies
|
|
* `mode` only when it CREATES the file. Waiting for the user's next Settings
|
|
* save would leave the file group/other-readable indefinitely, which is why
|
|
* `readDesktopConnectionConfig` tightens on a cache miss.
|
|
*
|
|
* Scoped to the MODE, and deliberately independent of the deferred migration
|
|
* below. The fixture's token is already safeStorage ciphertext (the app wrote
|
|
* it), so nothing here re-encrypts anything, touches the #62319 opt-in
|
|
* plaintext marker, or needs rotation guidance — the three prerequisites that
|
|
* keep the next test fixme'd. Tightening a permission bit neither performs a
|
|
* migration nor claims to, so it can be covered now while migration stays
|
|
* deferred.
|
|
*
|
|
* The fixture is produced by the app itself rather than hand-written, so the
|
|
* only difference from a real pre-fix install is the one bit under test.
|
|
*/
|
|
test('an install whose connection.json predates owner-only mode is tightened on read', async () => {
|
|
const fake = gateway!
|
|
sandbox = createSandbox('at-rest-tighten')
|
|
|
|
const first = await launchAgainst(sandbox)
|
|
app = first.app
|
|
|
|
const capability = await readSafeStorageCapability(app)
|
|
|
|
test.info().annotations.push({
|
|
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
|
type: 'safeStorage',
|
|
})
|
|
|
|
if (!capability.available) {
|
|
// Without secure storage the save is refused by design, so there is no
|
|
// app-written artifact to loosen and re-read. The refusal itself is
|
|
// already asserted in the first test.
|
|
test.skip(true, 'secure storage unavailable on this host — no app-written connection.json to tighten')
|
|
|
|
return
|
|
}
|
|
|
|
const userDataDir = await resolveUserDataDir(app)
|
|
const connectionFile = path.join(userDataDir, 'connection.json')
|
|
|
|
const saved = await saveRemoteToken(first.page, fake.url, SENTINEL_TOKEN)
|
|
expect(saved.error, 'the fixture write must succeed, or there is nothing to tighten').toBeNull()
|
|
|
|
await app.close().catch(() => undefined)
|
|
app = null
|
|
|
|
// Regress the file to what a pre-fix install has on disk. Everything else
|
|
// about it — including the encrypted token — is exactly what the app wrote.
|
|
fs.chmodSync(connectionFile, 0o644)
|
|
expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
|
|
|
|
const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
|
|
|
|
// A fresh process starts with an empty config cache, so the first read is a
|
|
// miss and the tighten runs. `getConnectionConfig()` forces that read
|
|
// through the app's own IPC surface.
|
|
const second = await launchAgainst(sandbox)
|
|
app = second.app
|
|
|
|
const reread = await second.page.evaluate(async () => {
|
|
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
|
|
|
return desktop.getConnectionConfig()
|
|
})
|
|
|
|
expectOwnerOnlyMode(
|
|
connectionFile,
|
|
'a pre-existing world-readable connection.json was not tightened when the app read it',
|
|
)
|
|
|
|
// The tighten must be a chmod, not a rewrite. It sits INSIDE the function
|
|
// whose cache keys on mtimeMs, so if it ever moved mtime it would
|
|
// invalidate that cache on every read and re-tighten forever. chmod moves
|
|
// ctime only, which is what makes the placement safe — this pins it.
|
|
expect(
|
|
Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
|
|
'tightening must not rewrite the file: mtime is the config cache key, so moving it would invalidate the cache the tighten sits inside',
|
|
).toBeLessThan(1)
|
|
|
|
// And tightening must not have cost the user their credential — the whole
|
|
// reason this happens on read instead of by deleting the file.
|
|
expect(reread.remoteTokenSet, 'the stored token must survive being tightened').toBe(true)
|
|
expect(reread.remoteUrl).toBe(fake.url)
|
|
})
|
|
|
|
/**
|
|
* The tighten must not be gated on the file being valid JSON.
|
|
*
|
|
* A truncated `connection.json` — an interrupted write on an older build, a
|
|
* half-finished hand edit, a partially restored backup — still contains the
|
|
* token bytes, and `JSON.parse` throws straight into the `catch` that falls
|
|
* back to local mode. That fallback is never written back, so nothing
|
|
* re-tightens the file later. With the chmod sequenced AFTER the parse,
|
|
* exactly the file that is both corrupt AND world-readable would be the one
|
|
* file never tightened, permanently.
|
|
*
|
|
* This is the only test that can tell the two orderings apart: every other
|
|
* test here uses a parseable file, where either ordering tightens. Asserting
|
|
* `mode === 'local'` is what makes it load-bearing — it proves the parse
|
|
* really threw, so a green mode assertion cannot be explained by anything
|
|
* downstream of the parse.
|
|
*
|
|
* Needs no secure storage: it is a chmod on a file that is never decrypted,
|
|
* so it holds on the keyring-less CI runner too.
|
|
*/
|
|
test('a corrupt connection.json is tightened even though it never parses', async () => {
|
|
sandbox = createSandbox('at-rest-tighten-corrupt')
|
|
|
|
const connectionFile = path.join(sandbox.userDataDir, 'connection.json')
|
|
|
|
// Truncated mid-token: unparseable, yet the secret bytes are right there.
|
|
fs.writeFileSync(
|
|
connectionFile,
|
|
`{"mode":"remote","remote":{"authMode":"token","token":{"encoding":"plain","value":"${SENTINEL_TOKEN}`,
|
|
{ encoding: 'utf8', mode: 0o644 },
|
|
)
|
|
fs.chmodSync(connectionFile, 0o644)
|
|
expect(fs.statSync(connectionFile).mode & 0o077, 'the fixture must start group/other-accessible').not.toBe(0)
|
|
|
|
const seededMtimeMs = fs.statSync(connectionFile).mtimeMs
|
|
|
|
const launched = await launchAgainst(sandbox)
|
|
app = launched.app
|
|
|
|
const reread = await launched.page.evaluate(async () => {
|
|
const desktop = (window as unknown as { hermesDesktop: any }).hermesDesktop
|
|
|
|
return desktop.getConnectionConfig()
|
|
})
|
|
|
|
expect(
|
|
reread.mode,
|
|
'the fixture must be unparseable, so the app falls back to local — otherwise this test proves nothing about ordering',
|
|
).toBe('local')
|
|
|
|
expectOwnerOnlyMode(
|
|
connectionFile,
|
|
'a corrupt world-readable connection.json still holding token bytes was left group/other-accessible',
|
|
)
|
|
|
|
// Same cache invariant as above: chmod, not rewrite.
|
|
expect(
|
|
Math.abs(fs.statSync(connectionFile).mtimeMs - seededMtimeMs),
|
|
'tightening must not rewrite the file: mtime is the config cache key',
|
|
).toBeLessThan(1)
|
|
})
|
|
|
|
/**
|
|
* DEFERRED GAP — legacy plaintext payloads are not migrated.
|
|
*
|
|
* Held as `fixme` rather than deleted: the fixture below is the correct
|
|
* fixture for the population that a migration must eventually cover, and
|
|
* the harness (seed → boot-poll → authoritative re-save → raw-bytes scan →
|
|
* wire check) is the harness such a migration needs. Keeping it typechecked
|
|
* and listed makes the gap visible in `--list` and in every report; deleting
|
|
* it would make the gap invisible and cost the next implementer this setup.
|
|
*
|
|
* It is NOT enabled because the migration it asserted was reviewed
|
|
* DO NOT SHIP. Before this can be un-fixme'd, three prerequisites (see the
|
|
* header, and the matching note in electron/main.ts readDesktopConnectionConfig):
|
|
*
|
|
* 1. Sequence with #62319's opt-in plaintext marker, so a user who
|
|
* deliberately chose plaintext is not silently overridden. This
|
|
* fixture has NO marker, so it stays in scope for migration — but the
|
|
* implementation must be able to tell the two apart.
|
|
* 2. Write through the config sanitizer, not around it.
|
|
* 3. Surface ROTATION guidance. Re-encrypting cannot un-expose a secret
|
|
* that is already in a backup; it only prevents future exposure.
|
|
*
|
|
* Un-fixme'ing this without (1) risks destroying a deliberate user choice,
|
|
* and without (3) it reports a remediation it did not actually perform.
|
|
*/
|
|
test('an existing plaintext connection.json is migrated off plaintext and keeps working', async () => {
|
|
test.fixme(
|
|
true,
|
|
'Deferred: legacy plaintext connection.json is intentionally NOT migrated. ' +
|
|
'Affected population is pre-release bb/gui installs (incl. the desktop-pr20059-installers build) ' +
|
|
'plus hand-edited configs — mainline never wrote a plaintext gateway token. ' +
|
|
'Blocked on: (1) #62319 opt-in-marker coordination, (2) writing through the config sanitizer, ' +
|
|
'(3) surfacing token-rotation guidance. Re-encrypting alone does not remediate an already-backed-up secret.',
|
|
)
|
|
|
|
const fake = gateway!
|
|
sandbox = createSandbox('at-rest-migrate')
|
|
|
|
// Seed the file an affected user has on disk. This is live, usable
|
|
// plaintext rather than a strawman, because `decryptDesktopSecret` returns
|
|
// `value` verbatim for any non-safeStorage encoding — the READ path
|
|
// accepts it. Note what does NOT justify this fixture: mainline never
|
|
// WROTE this shape to disk. `coerceDesktopConnectionConfig` does build it,
|
|
// but only under `persistToken: false`, whose sole caller is the
|
|
// connection-test handler, which never persists. The writers were
|
|
// non-mainline pre-release builds and hand edits. There is deliberately no
|
|
// opt-in marker here, so this payload is in scope for a future migration.
|
|
fs.writeFileSync(
|
|
path.join(sandbox.userDataDir, 'connection.json'),
|
|
JSON.stringify(
|
|
{
|
|
mode: 'remote',
|
|
profiles: {},
|
|
remote: {
|
|
authMode: 'token',
|
|
token: { encoding: 'plain', value: SENTINEL_TOKEN },
|
|
url: fake.url,
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
'utf8',
|
|
)
|
|
|
|
const launched = await launchAgainst(sandbox)
|
|
app = launched.app
|
|
|
|
const capability = await readSafeStorageCapability(app)
|
|
|
|
test.info().annotations.push({
|
|
description: `isEncryptionAvailable=${capability.available} backend=${capability.backend}`,
|
|
type: 'safeStorage',
|
|
})
|
|
|
|
if (!capability.available) {
|
|
// With no secure storage there is nowhere to migrate the secret TO, and
|
|
// scrubbing it would silently sign the user out of a working gateway.
|
|
// Asserting either outcome here would be inventing policy.
|
|
test.skip(
|
|
true,
|
|
'secure storage unavailable on this host — the correct migration policy for an existing plaintext file is undecided',
|
|
)
|
|
|
|
return
|
|
}
|
|
|
|
const userDataDir = await resolveUserDataDir(app)
|
|
const connectionFile = path.join(userDataDir, 'connection.json')
|
|
const needles = secretNeedles(SENTINEL_TOKEN)
|
|
|
|
// Two chances, so the test does not depend on WHERE the fix hooks the
|
|
// migration: (a) on read at boot, (b) on the next authoritative write.
|
|
// Poll for (a) first.
|
|
const deadline = Date.now() + 15_000
|
|
let stillPlaintext = true
|
|
|
|
while (Date.now() < deadline) {
|
|
stillPlaintext = readIfExists(connectionFile).includes(needles[0].bytes)
|
|
|
|
if (!stillPlaintext) {
|
|
break
|
|
}
|
|
|
|
await launched.page.waitForTimeout(500)
|
|
}
|
|
|
|
if (stillPlaintext) {
|
|
// (b) A real save through the app's own surface, carrying no new token —
|
|
// the stored blob is inherited. Re-persisting an inherited secret is the
|
|
// other place plaintext must not survive.
|
|
const resaved = await saveRemoteToken(launched.page, fake.url)
|
|
expect(resaved.error, 'a re-save that inherits the stored token must not fail').toBeNull()
|
|
}
|
|
|
|
expect(
|
|
scanTreeForSecret(userDataDir, needles),
|
|
'an existing plaintext gateway token must not remain readable under userData after the app has run ' +
|
|
`(stored token encoding is still "${storedTokenEncoding(connectionFile)}")`,
|
|
).toEqual([])
|
|
|
|
// And the migration must not have cost the user their credential.
|
|
const before = fake.sessionTokens.length
|
|
await exerciseStoredToken(launched.page, fake.url)
|
|
|
|
expect(
|
|
fake.sessionTokens.slice(before),
|
|
'the migrated token must still reach the gateway unchanged',
|
|
).toContain(SENTINEL_TOKEN)
|
|
})
|
|
})
|