feat(proton-pass): add Proton Pass backend#98
Open
alexsavio wants to merge 14 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
proton-passkeyring backend that stores each secret as an item in a Proton Pass vault, authenticated with a Proton Personal Access Token (PAT) — no browser, nopass-clisubprocess, purenet/http.Keyringinterface (Get/Set/Remove/Keys) against Proton's Pass API, with a clean-room crypto layer and OS-keychain session caching.keyring_noprotonpassbuild tag, so builds that don't want it pay nothing — not even the single new dependency.How it works
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.go-proton-api.keyring.Openrestricted to the secure backends only (Keychain / Secret Service / KWallet / WinCred), keyed bysha256(apiBase + PAT)so rotating the PAT invalidates it. This keeps a normaladd → list → execburst to a single login and avoids Proton'sHTTP 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
google.golang.org/protobufpromoted from indirect to direct (Proton item payloads are protobuf). No other new modules.-tags keyring_noprotonpasscompiles the backend out entirely, so consumers who don't need it keep the current dependency footprint.Public API
BackendType"proton-pass"and its registration.Configfields:ProtonPassShareID,ProtonPassItemTitlePrefix,ProtonPassAPIBase,ProtonPassTimeout, and aProtonPassTokenFuncprompt hook. The PAT is read fromPROTON_PASS_PERSONAL_ACCESS_TOKENor the prompt — never a CLI flag/arg, to avoid leaking it into the process list or shell history.Security
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.ProtonPassTimeout(default 30s), covering the several HTTP calls a single command makes.Testing
protonpass_integration_test.go) exercises live round-trips.add/list/removecreate-read-delete, and anadd → list → execburst confirming the session cache stays under the 429 rate limit.go build/go vet/go testgreen on both the default build and-tags keyring_noprotonpass.Notes for reviewers
main.byteness/aws-vaultconsumes this once tagged (plannedv1.12.0). It currently bridges to this branch via a localreplacedirective and will bump itsrequireand drop the bridge after the tag is published.