From 63e63e783fa86405af90d7307f36a28aa2914c61 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 21 Jul 2026 19:32:41 -0400 Subject: [PATCH 1/2] =?UTF-8?q?docs(adr):=20ADR=200001=20addendum=20?= =?UTF-8?q?=E2=80=94=20Pasqal=20password=20to=20OS=20keychain,=20evidence-?= =?UTF-8?q?forced=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- docs/adr/0001-credentials-on-disk.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/adr/0001-credentials-on-disk.md b/docs/adr/0001-credentials-on-disk.md index b7a2cd73..bb6417bf 100644 --- a/docs/adr/0001-credentials-on-disk.md +++ b/docs/adr/0001-credentials-on-disk.md @@ -1,6 +1,6 @@ # Credentials at rest are revocable tokens on disk under ~/.amico — passwords are never persisted -Status: accepted (2026-07-19) +Status: accepted (2026-07-19) · amended for Pasqal Cloud (2026-07-21, see addendum) Connection credentials are stored as `0600` JSON files under `~/.amico/`. The decided write discipline for the new storage seam is **atomic with the mode set at file birth** — the tmp file is created `0600` before rename, because the default write mode is not owner-only (`0o666 & ~umask`, typically `0644`). This is new work: no such write path exists yet (the repo's atomic-write helper takes no mode argument, and the in-flight key-entry command writes `cloud.json` mode-at-birth but non-atomically — it migrates onto the seam after its branch merges; the migration is scoped into the Connections-panel issue). **Only revocable tokens are ever at rest.** For Company Compute that is the service token (`cloud.json` — shape and location unchanged; its writers adopt the seam's discipline). For Pasqal Cloud (`pasqal.json`: `{project_id, token}` plus expiry metadata; exact shape finalized against the SDK in the implementing issue), the user's username/password are used **in memory only** at key-entry time — for the validation handshake and to mint an Auth0 token via the SDK's token-provider mechanism — and only the token is persisted; runtime authentication uses the stored token through a `TokenProvider`. If token refresh proves unsupported against the SDK, the fallback is **session-only** (re-prompt, token held in process memory) — never password-on-disk. Pasqal's own SDK documentation warns against persisting passwords. @@ -11,3 +11,13 @@ Connection credentials are stored as `0600` JSON files under `~/.amico/`. The de **Accepted residuals:** a stolen token file grants access until expiry/revocation (bounded by `0600` + the same-user trust model); token expiry can surface a "reconnect" prompt when no refresh path exists. Each connection type reads/writes through a single storage-seam interface, so a later **OS-keychain backend reachable from the server process** (e.g. shelling to the platform keychain CLI) remains a contained swap. VSCode SecretStorage specifically is *not* a contained swap — it is reachable only from the extension host, and per ADR 0002's flip condition a SecretStorage migration would flip the seam itself. This resolves the open question "where the extension stores it" in the cloud-solve-service team spec of 2026-06-28 (team vault, armonissima specs). + +## Addendum (2026-07-21): Pasqal password moves to the OS keychain — evidence-forced + +**What changed.** For the Pasqal Cloud connection ONLY, the username+password are stored in the **OS keychain** (macOS login Keychain / Linux Secret Service; never a plaintext file), and the fork server silently re-runs the password grant when the ~24h access token lapses. The token continues to persist in `pasqal.json` (`0600`, atomic, mode-at-birth) so every token consumer — the `amico-pasqal` launcher contract above all — is unchanged. Disconnect wipes both the keychain slots and the token file. Everything else in this ADR stands, including Company Compute (service token, no password ever exists). + +**Why the original stance broke.** This ADR's no-password design assumed at least one of: a mintable long-lived token, a working refresh path, or a tolerable re-prompt cadence. Live-API evidence (Jack's session of 2026-07-21, `pasqal-auth-bundle`; independently probed the same day) eliminated all three: (1) authorization-code/PKCE cannot complete — Pasqal's public client registers no localhost callback and we do not own the tenant; (2) the device-code grant is disabled on the client; (3) **refresh tokens are issued but unusable** — the API returns 403 for any access token whose `gty` claim includes `refresh_token`, with otherwise identical `aud`/`scope`/`permissions` to a working password-grant token (deliberate grant-type gating); (4) access tokens live ~24h. Net: for a user account, a valid token cannot exist for more than ~24h without the password being present somewhere. The named fallback (session-only re-prompt) would mean typing the password roughly daily; product decision (Kate, 2026-07-21) is that this friction is not acceptable for the panel, adopting Jack's keychain interim. + +**Revised risk position.** The keychain is same-user-readable, so a local process running as the user can obtain the password — the exact blast radius this ADR originally refused. Accepted knowingly because Pasqal's design caps what any client can do: the password must be *presentable* to keep working at all. Mitigations: keychain-only (no file fallback), per-connection slots, disconnect wipes, and the panel states plainly that the password is saved to the system keychain. The keychain backend is reached by shelling to the platform CLI from the server process — the "contained swap" this ADR explicitly reserved; the secret rides stdin, never argv. + +**Exit condition.** A Pasqal **service account** (`client_credentials`) retires the password entirely — client id+secret are revocable-credential class and fit this ADR's original at-rest rules with no exception. Request is with Pasqal (Baptiste Peyras, project `Harmoniqs Tests`); the day it is provisioned, the keychain password slots are deleted and this addendum's exception ends. Until then this exception is scoped to `pasqal-cloud` and must not be cited as precedent for any other connection. From 87e948c694838e3c6202e5403fddb22252fe8ec7 Mon Sep 17 00:00:00 2001 From: kate bonner Date: Tue, 21 Jul 2026 19:39:32 -0400 Subject: [PATCH 2/2] docs(adr): keychain backend is @napi-rs/keyring (Jack's mechanism), not shell-out Co-Authored-By: Claude Fable 5 --- docs/adr/0001-credentials-on-disk.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0001-credentials-on-disk.md b/docs/adr/0001-credentials-on-disk.md index bb6417bf..9cf21e5b 100644 --- a/docs/adr/0001-credentials-on-disk.md +++ b/docs/adr/0001-credentials-on-disk.md @@ -18,6 +18,6 @@ This resolves the open question "where the extension stores it" in the cloud-sol **Why the original stance broke.** This ADR's no-password design assumed at least one of: a mintable long-lived token, a working refresh path, or a tolerable re-prompt cadence. Live-API evidence (Jack's session of 2026-07-21, `pasqal-auth-bundle`; independently probed the same day) eliminated all three: (1) authorization-code/PKCE cannot complete — Pasqal's public client registers no localhost callback and we do not own the tenant; (2) the device-code grant is disabled on the client; (3) **refresh tokens are issued but unusable** — the API returns 403 for any access token whose `gty` claim includes `refresh_token`, with otherwise identical `aud`/`scope`/`permissions` to a working password-grant token (deliberate grant-type gating); (4) access tokens live ~24h. Net: for a user account, a valid token cannot exist for more than ~24h without the password being present somewhere. The named fallback (session-only re-prompt) would mean typing the password roughly daily; product decision (Kate, 2026-07-21) is that this friction is not acceptable for the panel, adopting Jack's keychain interim. -**Revised risk position.** The keychain is same-user-readable, so a local process running as the user can obtain the password — the exact blast radius this ADR originally refused. Accepted knowingly because Pasqal's design caps what any client can do: the password must be *presentable* to keep working at all. Mitigations: keychain-only (no file fallback), per-connection slots, disconnect wipes, and the panel states plainly that the password is saved to the system keychain. The keychain backend is reached by shelling to the platform CLI from the server process — the "contained swap" this ADR explicitly reserved; the secret rides stdin, never argv. +**Revised risk position.** The keychain is same-user-readable, so a local process running as the user can obtain the password — the exact blast radius this ADR originally refused. Accepted knowingly because Pasqal's design caps what any client can do: the password must be *presentable* to keep working at all. Mitigations: keychain-only (no plaintext file fallback), per-connection slots, disconnect wipes, and the panel states plainly that the password is saved to the system keychain. **Backend (per Jack's validated `pasqalAuth.ts`, adopted by Kate):** the OS keychain is reached via `@napi-rs/keyring` (macOS login Keychain / Linux Secret Service / Windows Credential Manager) — the same native module Jack's standalone session used, joining node-pty and tree-sitter as native deps already carried in the bun-compiled fork binary. Where the native binding cannot load (headless Linux with no Secret Service daemon), the store degrades to **session-memory** — password held in the server process only, re-prompt after restart — which is this ADR's original named fallback, never a plaintext file. (An earlier draft of this addendum proposed shelling to the platform CLI; superseded by Jack's native-module choice.) **Exit condition.** A Pasqal **service account** (`client_credentials`) retires the password entirely — client id+secret are revocable-credential class and fit this ADR's original at-rest rules with no exception. Request is with Pasqal (Baptiste Peyras, project `Harmoniqs Tests`); the day it is provisioned, the keychain password slots are deleted and this addendum's exception ends. Until then this exception is scoped to `pasqal-cloud` and must not be cited as precedent for any other connection.