Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions apps/desktop/src/ipc/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1654,11 +1654,14 @@ export interface VolliWebAccessIpcContract {
result: WebAccessResult;
};
/**
* Stores the Brave API key. The one inbound secret on this surface.
* Stores one keyed provider's API key. The one inbound secret on this surface.
*
* It crosses once, is encrypted with the OS keychain, and is never echoed,
* returned, logged, or included in an error string. A machine that cannot
* encrypt refuses rather than storing it in the clear.
* It crosses once and is never echoed, returned, logged, or included in an
* error string. It is saved in the profile database, in the clear, which
* stays its one source of truth until the read switch (VC-644); a sealed
* copy follows (VC-643), and the answer's `sealing` says whether that copy
* is current ("saved; sealing pending" when it is not). Nothing here claims
* the key is encrypted.
*/
"volli:web-access-set-key": {
args: [provider: KeyedWebAccessProvider, key: string];
Expand Down
45 changes: 42 additions & 3 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,14 @@ import {
type RuntimeAssemblyOptions,
} from "@volli/host-core/session-runtime/assembly";
import { createRuntimeContextResolver } from "@volli/host-core/session-runtime/context";
import { SecretStore } from "@volli/host-core/secrets";
import {
CREDENTIAL_INVENTORY_FILE_NAME,
CREDENTIAL_KEYCHAIN_KEY_FILE_NAME,
keychainCredentialKeyring,
SecretStore,
} from "@volli/host-core/secrets";
import { describeWebSealing } from "@volli/host-core/web/credential-mirror";
import { observeKeychainUse, webSealingLifecycle } from "./web/sealing-lifecycle";
import { keychainSecretCodec } from "./secrets/codec";
import { SecretService } from "@volli/host-core/secrets/service";
import { retiresSessionSecrets } from "@volli/host-core/secrets/lifetime";
Expand Down Expand Up @@ -868,8 +875,12 @@ const appStartup = app.whenReady().then(async () => {
// runs here, ahead of the stores, so no Session and no Settings open can see
// a key half-moved. Counts only in the log: how many rows moved is not a fact
// about any key.
// Whether this launch has used the keychain successfully yet (VC-643): the
// web keys' unattended launch reconcile may fetch its own key only then.
const keychainUse = observeKeychainUse(safeStorage);
if (dbHandle.ok) {
const moved = migrateLegacySafeStorageSecrets(dbHandle.db);
if (moved.carried > 0) keychainUse.markUsed();
if (moved.carried + moved.dropped + moved.deferred > 0) {
console.info(
`[volli] web search keys out of the OS keychain: ${moved.carried} carried, ` +
Expand All @@ -888,7 +899,10 @@ const appStartup = app.whenReady().then(async () => {
// Lazy: no keychain access until a stored secret is used or a person saves one.
// Session-only storage never needs the keychain. No plaintext fallback.
const secrets = new SecretService(
new SecretStore(join(dirname(dbPath), "session-secrets.enc"), keychainSecretCodec(safeStorage)),
new SecretStore(
join(dirname(dbPath), "session-secrets.enc"),
keychainSecretCodec(keychainUse.keychain),
),
);
sessionWakeBus?.subscribe(({ event }) => {
if (retiresSessionSecrets(event.payload)) void secrets.endSession(event.sessionId);
Expand Down Expand Up @@ -933,7 +947,25 @@ const appStartup = app.whenReady().then(async () => {
policy: () => (dbHandle.ok ? readCodeModePolicy(dbHandle.db) : DEFAULT_CODE_MODE_POLICY),
sandboxAvailable: codeModeSandbox.codeModeSandbox !== undefined,
});
const webAccess = hostCore.runtimeServices.createWebAccess();
// Web search keys, step E (VC-643): the database stays their one source of
// truth, and a sealed mirror of them is kept in the typed inventory under a
// keychain-wrapped key. The keychain is asked only through safeStorage's
// asynchronous API, never under the credential lock or on a synchronous
// path, and only to seal or open. A person's save or clear may ask it; the
// unattended launch reconcile (after first paint, below) only once this
// launch has already used the keychain. Stopped when a quit is accepted.
// Never awaited, never a reason to fail a boot. Logged as counts and reason
// codes only.
const webAccess = hostCore.runtimeServices.createWebAccess({
keyring: keychainCredentialKeyring({
path: join(dirname(dbPath), CREDENTIAL_KEYCHAIN_KEY_FILE_NAME),
keychain: safeStorage,
inventoryPath: join(dirname(dbPath), CREDENTIAL_INVENTORY_FILE_NAME),
}),
mayUnlockUnattended: keychainUse.used,
onResult: (result) => console.info(`[volli] web search keys: ${describeWebSealing(result)}`),
});
const webSealing = webSealingLifecycle(webAccess);
/** The cursor overlay is a desktop-only port, constructed beside the window. */
let cursorOverlayRef: CursorOverlay | null = null;
// Agent observability (VC-119): the opt-in export switch, and the sink the
Expand Down Expand Up @@ -1368,6 +1400,7 @@ const appStartup = app.whenReady().then(async () => {
lifecycle: app,
shutdownNativeSessions: close,
shutdownAgentSocket,
stopBackgroundWork: () => webSealing.stop(),
reportFailure: (error) =>
console.error("[volli] failed to coordinate app shutdown:", errorMessage(error)),
}),
Expand Down Expand Up @@ -1982,6 +2015,12 @@ const appStartup = app.whenReady().then(async () => {
void loginPathBootstrap.applyInteractive().catch((error) => {
console.error("[volli] failed to apply interactive login PATH:", errorMessage(error));
});
// Web search keys' sealed mirror (VC-643): rebuilt from the database on
// every launch, deletes included. Like the repack above, it waits out
// first paint and boot. Cancelled, with anything it started, the moment a
// quit is accepted (`stopBackgroundWork` above). Never rejects; the
// outcome is logged above.
webSealing.afterFirstPaint();
});

// Startup orphan SCAN (VC-284). This used to be a destructive sweep: launching
Expand Down
17 changes: 17 additions & 0 deletions apps/desktop/src/main/quit-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ export function registerAcceptedQuitCoordinator(options: {
lifecycle: AcceptedQuitLifecycle;
shutdownNativeSessions(): Promise<void>;
shutdownAgentSocket(): Promise<void>;
/**
* Called once, synchronously, the moment a quit is accepted and before any
* drain starts: background work that must not START during teardown stops
* here (VC-643: the web keys' launch reconcile, its busy retries and any
* keychain fetch). It must not wait on anything; a throw is reported and
* the quit goes on unchanged.
*/
stopBackgroundWork?(): void;
shutdownDeadlineMs?: number;
reportFailure(error: unknown): void;
}): void {
Expand All @@ -192,6 +200,15 @@ export function registerAcceptedQuitCoordinator(options: {
void Promise.resolve().then(() => {
if (quitAlreadyRefused(event) || shutdownInFlight) return;
shutdownInFlight = true;
try {
options.stopBackgroundWork?.();
} catch (error) {
try {
options.reportFailure(error);
} catch {
// A reporter that throws cannot hold the quit open either.
}
}
const exitAfterCheckpoint = () => {
// app.exit destroys native windows synchronously. VC-536 captured
// macOS compositor teardown waiting on synchronous Viz IPC while
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/web/ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ describe("the Web Access door", () => {
provider: "off",
searxngUrl: null,
keys: { brave: "absent", exa: "absent" },
sealing: "none",
},
});
});
Expand Down
14 changes: 8 additions & 6 deletions apps/desktop/src/main/web/legacy-safe-storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* The last time Volli ever asks the OS keychain for anything.
* The last time Volli asks the OS keychain about the pre-023 web key ciphertext.
*
* Until migration 023 the Brave and Exa keys were `safeStorage` ciphertext, and
* that ciphertext is openable by exactly one party: the same machine, through
Expand All @@ -8,11 +8,13 @@
* `legacy_safe_storage_secrets` for this module, which opens what it can, writes
* it the new way, and empties the table.
*
* **This is the only file in the app that imports `safeStorage`, and it is a
* leftover rather than a dependency.** It runs once per profile — a `SELECT`
* against an empty table on every launch after that, and never a keychain call.
* That is the whole point of the exercise: the prompt this module may raise once
* is the prompt that used to appear on every Session attach.
* **This module is a leftover rather than a dependency.** It runs once per
* profile — a `SELECT` against an empty table on every launch after that, and
* never a keychain call. That is the whole point of the exercise: the prompt
* this module may raise once is the prompt that used to appear on every Session
* attach. (It is no longer the app's only keychain use: persistent Session
* secrets (VC-481) and the web keys' sealed mirror (VC-643) each unwrap a data
* key lazily, at most once per launch, never per attach.)
*
* **A row is never asked about twice.** If `decryptString` fails — a profile
* carried to another machine, an item replaced underneath it, a person clicking
Expand Down
Loading
Loading