Skip to content

feat(proton-pass): add Proton Pass backend#98

Open
alexsavio wants to merge 14 commits into
ByteNess:mainfrom
alexsavio:feat/proton-pass-backend
Open

feat(proton-pass): add Proton Pass backend#98
alexsavio wants to merge 14 commits into
ByteNess:mainfrom
alexsavio:feat/proton-pass-backend

Conversation

@alexsavio

Copy link
Copy Markdown

Summary

  • Adds a proton-pass keyring backend that stores each secret as an item in a Proton Pass vault, authenticated with a Proton Personal Access Token (PAT) — no browser, no pass-cli subprocess, pure net/http.
  • Implements the full Keyring interface (Get / Set / Remove / Keys) against Proton's Pass API, with a clean-room crypto layer and OS-keychain session caching.
  • Gated behind a keyring_noprotonpass build tag, so builds that don't want it pay nothing — not even the single new dependency.

How it works

  • The PAT is a compound credential pst_<token>::<key>: pst_<token> is exchanged for a Proton session; ::<key> is the symmetric key that unwraps the vault. Auth is a reimplemented HTTP exchange — SRP login is interactive / CAPTCHA-gated and deliberately not used.
  • The read path is fully symmetric AES-GCM down the Proton key hierarchy (encryption key → share key → item key → content), clean-roomed from Proton's published security model and open-source WebClients. No OpenPGP / gopenpgp and no go-proton-api.
  • The exchanged session (UID + access token + expiry; no refresh token) is cached in the OS keychain — a second keyring.Open restricted to the secure backends only (Keychain / Secret Service / KWallet / WinCred), keyed by sha256(apiBase + PAT) so rotating the PAT invalidates it. This keeps a normal add → list → exec burst to a single login and avoids Proton's HTTP 429 / code 2028 "Too many recent logins". On a host with no secure backend (headless / CI) the session is re-exchanged per operation rather than cached — degraded, not broken.

Dependencies

  • Exactly one change: google.golang.org/protobuf promoted from indirect to direct (Proton item payloads are protobuf). No other new modules.
  • -tags keyring_noprotonpass compiles the backend out entirely, so consumers who don't need it keep the current dependency footprint.

Public API

  • New BackendType "proton-pass" and its registration.
  • New Config fields: ProtonPassShareID, ProtonPassItemTitlePrefix, ProtonPassAPIBase, ProtonPassTimeout, and a ProtonPassTokenFunc prompt hook. The PAT is read from PROTON_PASS_PERSONAL_ACCESS_TOKEN or the prompt — never a CLI flag/arg, to avoid leaking it into the process list or shell history.

Security

  • No PAT / token / session / key is logged (audited). Best-effort zeroization of the PAT encryption key, decrypted share keys, and per-item content keys after use.
  • Clear sentinel errors: 429 / code 2028 → rate-limited (never retried); 401 → session-expired (re-exchange, with the retry scoped to the load phase so a write is never re-issued); code 9001 → human-verification (CAPTCHA / 2FA the headless client cannot satisfy); plus explicit PAT-rejection.
  • Per-operation timeout via ProtonPassTimeout (default 30s), covering the several HTTP calls a single command makes.

Testing

  • Unit tests for the backend, the AES-GCM crypto chain, the session cache, and error classification; a gated integration test (protonpass_integration_test.go) exercises live round-trips.
  • Live-verified end-to-end: add / list / remove create-read-delete, and an add → list → exec burst confirming the session cache stays under the 429 rate limit.
  • go build / go vet / go test green on both the default build and -tags keyring_noprotonpass.

Notes for reviewers

  • 14 phased conventional commits (skeleton → crypto → read path → write path → session/timeout/errors → hardening); reviewable commit-by-commit. Squash or keep as you prefer.
  • Merges cleanly into main.
  • Downstream: byteness/aws-vault consumes this once tagged (planned v1.12.0). It currently bridges to this branch via a local replace directive and will bump its require and drop the bridge after the tag is published.

alexsavio added 14 commits June 17, 2026 18:09
Register a proton-pass backend (build tag keyring_noprotonpass) backed by Proton
Pass's native HTTP API. Adds internal/protonpass: the PAT->session exchange plus
the list-shares/list-items read endpoints, behind a mockable API interface, with
httptest unit tests asserting the wire spec. Wires Config fields + env fallbacks
into NewProtonPassKeyring. Item decryption (Phase 2) and the write path (Phase 3)
return ErrProtonPassNotImplemented for now.
Implement the symmetric half of the Proton Pass key hierarchy in
internal/protonpass/crypto.go: AES-256-GCM open/seal (12-byte nonce prefix,
PassEncryptionTag as AAD) and the share-key -> item-key -> item-content
composition, with round-trip tests. Add the GetShareKeys client call
(GET /pass/v1/share/{id}/key) to the API interface. The OpenPGP share-key
unwrap and the PAT-specific key delivery remain (ErrCryptoNotImplemented).
Wire the full read path: PAT -> share key -> item key -> AES-256-GCM
content -> Item protobuf -> title + blob.

The PAT read model is fully symmetric (confirmed empirically against the
live API): the "::<key>" half of the PAT base64url-decodes to a 32-byte
AES key that opens the share key (AAD "sharekey"); the share key opens the
item key (AAD "itemkey") and content (AAD "itemcontent"). Older items
carry no item key and decrypt content with the share key directly. There
is no OpenPGP and no /core/v4/keys (that endpoint 403s for a PAT).

- crypto: PATKey decodes the "::<key>" enc-key; OpenShareKey is a thin
  AES-GCM("sharekey") wrapper (pure stdlib, no gopenpgp)
- proto: clean-room item-v1 metadata parse (name + note) via protowire
- backend: Keys() lists+decrypts titles, Get() unwraps one item;
  openContent handles items with and without a per-item key;
  drop ErrProtonPassNotImplemented from the read path

Covered by unit tests (AES chain, share-key round-trip, protobuf parse,
an offline end-to-end vault fixture) and an opt-in live integration test
that decrypts a real granted item end-to-end.
Implement Set (create/update) and Remove (delete) by reversing the
confirmed symmetric chain: encode an item-v1 protobuf (metadata.name =
the namespaced key, metadata.note = the blob), seal it with a fresh
per-item key (AAD "itemcontent"), wrap that key with the vault's current
share key (AAD "itemkey"), and call the create/update/delete endpoints.

- crypto.go: NewItemKey, NewItemUUID, SealItemContent, SealItemKey
- proto.go: EncodeItem (inverse of ParseItemMetadata) with the content
  oneof note marker and item_uuid
- client.go: CreateItem (POST), UpdateItem (PUT, reuses the existing item
  key), DeleteItem (permanent DELETE, SkipTrash) plus request structs
- protonpass.go: Set creates, or updates in place under the current
  rotation; Remove deletes or returns ErrKeyNotFound; loadVault threads
  the per-item key and revision through for the write path
- gated live round-trip test (PROTON_PASS_INTEGRATION_WRITE=1)

Write-endpoint shapes are clean-room-derived from WebClients/proto and
exercised offline by mocked unit tests; the gated integration test and
the hack/proton-spikes "write" probe confirm them against a live account.
No new dependencies; the keyring_noprotonpass opt-out build is unaffected.
…test

TestProtonPassIntegrationUpdateRemove confirms the update + delete path
against an existing item, for accounts where whole-vault grants (needed to
create) aren't available but a per-item editor grant is. Opt-in via
PROTON_PASS_INTEGRATION_WRITE=1; overwrites then permanently deletes the
named throwaway item.

The write-path wire shapes were confirmed live (create/update/delete
round-trip in a scratch vault): the create/update response wrapper is
`Item` and permanent delete uses `SkipTrash:true`.
Capture AccessExpirationTime from the personal-access-token/session
response into Session.AccessExpiry so the session cache can decide
freshness. Treated as Unix seconds only when it is a plausible future
epoch; the cache's freshness check owns that interpretation.
The backend re-ran the full PAT->session exchange on every operation, so
a few aws-vault commands in quick succession tripped Proton's HTTP 429
'too many recent logins' (code 2028).

Cache the exchanged session (UID + tokens + expiry) in an OS-protected
keyring opened over the secure backends only (Keychain / Secret Service /
KWallet / WinCred), keyed by sha256(apiBase + PAT token) so rotating the
PAT invalidates it. Reuse the cached session while fresh; on a 401 from a
revoked or expired cached session, invalidate, re-exchange once, and retry.
Freshness honors the server expiry only when it is a plausible future epoch,
else a conservative local TTL, with the 401 path as the safety net.

When no secure backend exists (e.g. a headless host with no Secret
Service) the cache is nil and the backend degrades to a per-operation
exchange, exactly as before. The PAT is resolved once per operation so a
prompt-sourced PAT is requested at most once even across a retry.
The backend used context.Background() for every Proton call, so a hung
request could block indefinitely. Add Config.ProtonPassTimeout and derive
a per-operation context (default 30s) shared across the several HTTP calls
an operation makes, mirroring the 1Password backend's OPTimeout.
Translate raw Proton responses into actionable sentinels: HTTP 429 /
code 2028 -> ErrProtonPassRateLimited, 401 -> ErrProtonPassSessionExpired,
code 9001 -> ErrProtonPassHumanVerification (CAPTCHA/2FA the headless
client cannot satisfy), and PAT-rejection messages -> ErrProtonPassPATRejected.
The original APIError stays unwrappable for diagnostics; non-API errors
(share-not-accessible, key-not-found) pass through. Applied at the Keys/
Get/Set/Remove boundaries. Rate-limit errors are never retried.
Best-effort zeroization of the PAT-derived enc-key (per operation), the
decrypted share keys, and a freshly generated per-item key once they are
no longer needed, shrinking the window they sit in process memory.
Confirmed the backend logs no PAT, token, session, or key: the sole debugf
reports only an Open failure when no secure session-cache backend exists.
Code review (H1): withVault re-ran the whole callback on a 401, so a write
that reached the server and then saw a 401 could be re-issued (a duplicate
create, or a stale-revision update). Scope the 401 retry to the load phase
only: re-exchange and reload before any mutation, then run the callback
exactly once. If the callback itself hits a session-expired error, drop the
cached session so the next invocation re-exchanges, but do not retry it.

Also zero the per-item content keys recovered during a load (M2), not just
the share keys, once the callback has run.
Follow-ups from code review:
- M1: normalize the API base (NormalizeAPIBase) so an explicit default URL or
  a trailing slash maps to the same cache key as the implicit default, instead
  of forcing a needless re-login.
- M3: bound the trusted server expiry to ~30 days, so a millisecond epoch or a
  duration mistaken for a timestamp falls back to the local TTL.
- M4: stop persisting the refresh token; recovery re-exchanges the PAT, so the
  refresh token is never used and caching it only widens exposure.
- L1: classify a PAT-rejection message before the generic 401 for a more
  actionable error.
- L2: share one isUnauthorized helper between the retry test and classification.
- L4: log (debug) when an unreadable cache entry is discarded.
opContext() created the deadline before patAndKey(), so an interactive PAT
prompt counted against the per-operation timeout: a user who took longer
than the timeout to enter the token would see the first API call fail with
a deadline already exceeded. Resolve the PAT first, then open the timeout
window, so it bounds only the network work.
The post-load error paths (ListItems error, item decrypt/parse failure)
returned without zeroing the share keys already decrypted by openVault.
Use named returns plus a deferred cleanup so any error after openVault
zeros the share keys and any per-item keys before returning nil.
@alexsavio alexsavio requested a review from mbevc1 as a code owner July 1, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant