diff --git a/config.go b/config.go index 582d0f8..6dbd823 100644 --- a/config.go +++ b/config.go @@ -98,4 +98,27 @@ type Config struct { // OPDesktopAccountID is the 1Password account name or UUIDj for Desktop Integration (1Password Desktop App only) OPDesktopAccountID string + + // ProtonPassPAT is the Proton Pass Personal Access Token ("pst_::"). + // The "pst_" half authenticates; the "::" half is the client-side + // vault-unwrap key. Falls back to the PROTON_PASS_PERSONAL_ACCESS_TOKEN env var. + ProtonPassPAT string + + // ProtonPassShareID is the Share ID of the Proton Pass vault that aws-vault uses. + // Falls back to the PROTON_PASS_SHARE_ID env var. + ProtonPassShareID string + + // ProtonPassItemTitlePrefix is the prefix to prepend to Proton Pass item titles. + ProtonPassItemTitlePrefix string + + // ProtonPassAPIBase overrides the Proton API base URL (default https://pass-api.proton.me). + ProtonPassAPIBase string + + // ProtonPassTimeout is the per-operation timeout for Proton Pass API calls. Zero + // selects a built-in default. + ProtonPassTimeout time.Duration + + // ProtonPassTokenFunc is an optional function used to prompt for the PAT when no + // config field or environment variable supplies one. + ProtonPassTokenFunc PromptFunc } diff --git a/go.mod b/go.mod index 91e855d..e5ad46c 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stretchr/testify v1.11.1 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 + google.golang.org/protobuf v1.36.11 ) require ( @@ -36,6 +37,5 @@ require ( github.com/uber/jaeger-lib v2.4.1+incompatible // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/internal/protonpass/client.go b/internal/protonpass/client.go new file mode 100644 index 0000000..491733d --- /dev/null +++ b/internal/protonpass/client.go @@ -0,0 +1,393 @@ +// Package protonpass is a minimal native-Go client for Proton Pass's internal HTTP +// API, used by the proton-pass keyring backend. It speaks the same wire protocol as +// the Proton Pass CLI, derived from observed traffic (clean-room): the PAT->session +// exchange (two POSTs) and the authenticated read endpoints (shares, items, share +// keys) here in client.go, the symmetric AES-GCM unwrap chain in crypto.go, and the +// item protobuf parse in proto.go. +package protonpass + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Proton API defaults. The app/SDK version headers gate access; these mirror the +// values the Proton Pass CLI sends and can be overridden via Config. +const ( + DefaultAPIBase = "https://pass-api.proton.me" + DefaultAppVersion = "cli-pass@2.1.4" + DefaultSDKVersions = "muon@2.5.0" + DefaultOriginSDK = "pass-cli@2.1.4" + + // ItemContentFormatVersion is the item-v1 content format version sent on + // create/update. It matches the version the live API currently issues for + // items; bump it if the server starts rejecting writes at this version. + ItemContentFormatVersion = 6 + + pathUnauthSession = "/auth/v4/sessions" + pathPATExchange = "/account/v4/personal-access-token/session" + pathShares = "/pass/v1/share" + + itemPageSize = 100 + maxItemPages = 50 + maxBodyBytes = 4 << 20 +) + +// API is the subset of the Proton Pass client the keyring backend depends on +// (kept small so the backend can mock it in tests). +type API interface { + Authenticate(ctx context.Context, pat string) (*Session, error) + ListShares(ctx context.Context, s *Session) ([]Share, error) + ListItems(ctx context.Context, s *Session, shareID string) ([]ItemRevision, error) + GetShareKeys(ctx context.Context, s *Session, shareID string) ([]ShareKey, error) + CreateItem(ctx context.Context, s *Session, shareID string, req CreateItemRequest) (*ItemRevision, error) + UpdateItem(ctx context.Context, s *Session, shareID, itemID string, req UpdateItemRequest) (*ItemRevision, error) + DeleteItem(ctx context.Context, s *Session, shareID, itemID string, revision int) error +} + +// Session is an authenticated Proton session: a UID plus bearer tokens. +type Session struct { + UID string + AccessToken string + RefreshToken string + + // AccessExpiry is the access token's expiry as reported by the exchange. + // Proton sends it as AccessExpirationTime; it is treated as Unix seconds + // when it looks like a plausible future epoch and ignored otherwise (0 if + // absent). The session-cache freshness check, not this client, decides how + // to interpret it. + AccessExpiry int64 +} + +// Share is one entry from GET /pass/v1/share (a vault the session can access). +// Content is the base64 vault metadata, AES-256-GCM encrypted under the share key +// (AAD "vaultcontent"); decryption is the backend's job (see OpenVaultContent). +type Share struct { + ShareID string `json:"ShareID"` + VaultID string `json:"VaultID"` + TargetID string `json:"TargetID"` + TargetType int `json:"TargetType"` + AddressID string `json:"AddressID"` + Content string `json:"Content"` + ContentFormatVersion int `json:"ContentFormatVersion"` + ContentKeyRotation int `json:"ContentKeyRotation"` + Permission int `json:"Permission"` + ShareRoleID string `json:"ShareRoleID"` + Owner bool `json:"Owner"` + Primary bool `json:"Primary"` + Shared bool `json:"Shared"` + CreateTime int64 `json:"CreateTime"` +} + +// ShareKey is one entry from GET /pass/v1/share/{shareID}/key. For a PAT session +// Key is the base64 share key, AES-256-GCM enveloped with the PAT's "::" +// (AAD "sharekey"); UserKeyID is unused on the PAT path. Decryption is the +// backend's job (see crypto.go OpenShareKey). +type ShareKey struct { + KeyRotation int `json:"KeyRotation"` + Key string `json:"Key"` + UserKeyID string `json:"UserKeyID"` + CreateTime int64 `json:"CreateTime"` +} + +// ItemRevision is one entry from GET /pass/v1/share/{shareID}/item. Content is +// the base64 AES-256-GCM ciphertext (decrypts to a protobuf); ItemKey is the +// per-item key wrapped to the share key. +type ItemRevision struct { + ItemID string `json:"ItemID"` + Revision int `json:"Revision"` + Content string `json:"Content"` + ItemKey string `json:"ItemKey"` + ContentFormatVersion int `json:"ContentFormatVersion"` + KeyRotation int `json:"KeyRotation"` + Flags int `json:"Flags"` + CreateTime int64 `json:"CreateTime"` + ModifyTime int64 `json:"ModifyTime"` + RevisionTime int64 `json:"RevisionTime"` +} + +// CreateItemRequest is the body of POST /pass/v1/share/{shareID}/item. Content is +// the base64 AES-GCM item ciphertext; ItemKey is the per-item key wrapped to the +// share key. KeyRotation is the share-key rotation ItemKey is wrapped under. +type CreateItemRequest struct { + KeyRotation int `json:"KeyRotation"` + ContentFormatVersion int `json:"ContentFormatVersion"` + Content string `json:"Content"` + ItemKey string `json:"ItemKey"` +} + +// UpdateItemRequest is the body of PUT /pass/v1/share/{shareID}/item/{itemID}. The +// per-item key is unchanged on update, so only the re-encrypted Content is sent; +// LastRevision is the revision being replaced (optimistic concurrency). +type UpdateItemRequest struct { + KeyRotation int `json:"KeyRotation"` + LastRevision int `json:"LastRevision"` + Content string `json:"Content"` + ContentFormatVersion int `json:"ContentFormatVersion"` +} + +// itemIDRevision identifies one item revision for trash/delete requests. +type itemIDRevision struct { + ItemID string `json:"ItemID"` + Revision int `json:"Revision"` +} + +// deleteItemsRequest is the body of DELETE /pass/v1/share/{shareID}/item. +// SkipTrash=true deletes outright rather than moving to trash first. +type deleteItemsRequest struct { + Items []itemIDRevision `json:"Items"` + SkipTrash bool `json:"SkipTrash"` +} + +// Client talks to the Proton Pass HTTP API over native net/http (HTTP/2, system +// trust roots). Proton pins server certs against its own clients, but that only +// affects the official clients; a standard Go client connects normally. +type Client struct { + HTTP *http.Client + APIBase string + AppVersion string + SDKVersions string + OriginSDK string +} + +// NormalizeAPIBase resolves an empty base to the default and trims a trailing +// slash, so callers and the session cache agree on one canonical form. +func NormalizeAPIBase(apiBase string) string { + if apiBase == "" { + apiBase = DefaultAPIBase + } + return strings.TrimRight(apiBase, "/") +} + +// New returns a Client with Proton defaults; apiBase may be "" for the default. +func New(apiBase string) *Client { + return &Client{ + HTTP: &http.Client{Timeout: 30 * time.Second}, + APIBase: NormalizeAPIBase(apiBase), + AppVersion: DefaultAppVersion, + SDKVersions: DefaultSDKVersions, + OriginSDK: DefaultOriginSDK, + } +} + +var _ API = (*Client)(nil) + +// APIError is a non-2xx response or a Proton error envelope ({Code, Error}). +type APIError struct { + Status int + Code int + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("proton api: http %d, code %d: %s", e.Status, e.Code, e.Message) +} + +// PATToken returns the "pst_" half of a compound "pst_::" PAT. +// The "::" half is the client-side vault-unwrap key and is never sent. +func PATToken(pat string) string { + if i := strings.Index(pat, "::"); i >= 0 { + return pat[:i] + } + return pat +} + +// Authenticate performs the PAT -> session exchange: create an anonymous session, +// then exchange the PAT for an authenticated one. +func (c *Client) Authenticate(ctx context.Context, pat string) (*Session, error) { + var unauth struct { + UID string `json:"UID"` + AccessToken string `json:"AccessToken"` + } + if err := c.do(ctx, http.MethodPost, pathUnauthSession, nil, "", "", &unauth); err != nil { + return nil, fmt.Errorf("create unauth session: %w", err) + } + if unauth.UID == "" || unauth.AccessToken == "" { + return nil, fmt.Errorf("create unauth session: missing UID/AccessToken in response") + } + + var exch struct { + Session struct { + SessionUID string `json:"SessionUID"` + AccessToken string `json:"AccessToken"` + RefreshToken string `json:"RefreshToken"` + AccessExpirationTime int64 `json:"AccessExpirationTime"` + } `json:"Session"` + } + body := map[string]string{"Token": PATToken(pat)} + if err := c.do(ctx, http.MethodPost, pathPATExchange, body, unauth.UID, unauth.AccessToken, &exch); err != nil { + return nil, fmt.Errorf("exchange personal access token: %w", err) + } + if exch.Session.SessionUID == "" || exch.Session.AccessToken == "" { + return nil, fmt.Errorf("exchange personal access token: missing session in response") + } + return &Session{ + UID: exch.Session.SessionUID, + AccessToken: exch.Session.AccessToken, + RefreshToken: exch.Session.RefreshToken, + AccessExpiry: exch.Session.AccessExpirationTime, + }, nil +} + +// ListShares returns the vaults/shares the session can access. +func (c *Client) ListShares(ctx context.Context, s *Session) ([]Share, error) { + var resp struct { + Shares []Share `json:"Shares"` + Total int `json:"Total"` + } + if err := c.do(ctx, http.MethodGet, pathShares, nil, s.UID, s.AccessToken, &resp); err != nil { + return nil, fmt.Errorf("list shares: %w", err) + } + return resp.Shares, nil +} + +// ListItems returns every item revision in a share, following pagination. +func (c *Client) ListItems(ctx context.Context, s *Session, shareID string) ([]ItemRevision, error) { + var all []ItemRevision + since := "" + for page := 0; page < maxItemPages; page++ { + path := fmt.Sprintf("%s/%s/item?PageSize=%d", pathShares, shareID, itemPageSize) + if since != "" { + path += "&Since=" + url.QueryEscape(since) + } + var resp struct { + Items struct { + LastToken string `json:"LastToken"` + RevisionsData []ItemRevision `json:"RevisionsData"` + } `json:"Items"` + } + if err := c.do(ctx, http.MethodGet, path, nil, s.UID, s.AccessToken, &resp); err != nil { + return nil, fmt.Errorf("list items in share %q: %w", shareID, err) + } + all = append(all, resp.Items.RevisionsData...) + if resp.Items.LastToken == "" || len(resp.Items.RevisionsData) == 0 { + break + } + since = resp.Items.LastToken + } + return all, nil +} + +// GetShareKeys returns the (still-encrypted) key rotations for a share. +func (c *Client) GetShareKeys(ctx context.Context, s *Session, shareID string) ([]ShareKey, error) { + var resp struct { + ShareKeys struct { + Keys []ShareKey `json:"Keys"` + Total int `json:"Total"` + } `json:"ShareKeys"` + } + path := fmt.Sprintf("%s/%s/key?Page=0", pathShares, shareID) + if err := c.do(ctx, http.MethodGet, path, nil, s.UID, s.AccessToken, &resp); err != nil { + return nil, fmt.Errorf("get share keys for %q: %w", shareID, err) + } + return resp.ShareKeys.Keys, nil +} + +// CreateItem creates a new item in the share and returns its new revision. +func (c *Client) CreateItem(ctx context.Context, s *Session, shareID string, req CreateItemRequest) (*ItemRevision, error) { + if req.ContentFormatVersion == 0 { + req.ContentFormatVersion = ItemContentFormatVersion + } + var resp struct { + Item ItemRevision `json:"Item"` + } + path := fmt.Sprintf("%s/%s/item", pathShares, shareID) + if err := c.do(ctx, http.MethodPost, path, req, s.UID, s.AccessToken, &resp); err != nil { + return nil, fmt.Errorf("create item in share %q: %w", shareID, err) + } + return &resp.Item, nil +} + +// UpdateItem replaces an item's content and returns its new revision. +func (c *Client) UpdateItem(ctx context.Context, s *Session, shareID, itemID string, req UpdateItemRequest) (*ItemRevision, error) { + if req.ContentFormatVersion == 0 { + req.ContentFormatVersion = ItemContentFormatVersion + } + var resp struct { + Item ItemRevision `json:"Item"` + } + path := fmt.Sprintf("%s/%s/item/%s", pathShares, shareID, itemID) + if err := c.do(ctx, http.MethodPut, path, req, s.UID, s.AccessToken, &resp); err != nil { + return nil, fmt.Errorf("update item %q in share %q: %w", itemID, shareID, err) + } + return &resp.Item, nil +} + +// DeleteItem permanently deletes an item (bypassing trash). revision is the +// item's current revision for the optimistic-concurrency check. +func (c *Client) DeleteItem(ctx context.Context, s *Session, shareID, itemID string, revision int) error { + body := deleteItemsRequest{ + Items: []itemIDRevision{{ItemID: itemID, Revision: revision}}, + SkipTrash: true, + } + path := fmt.Sprintf("%s/%s/item", pathShares, shareID) + if err := c.do(ctx, http.MethodDelete, path, body, s.UID, s.AccessToken, nil); err != nil { + return fmt.Errorf("delete item %q in share %q: %w", itemID, shareID, err) + } + return nil +} + +// do builds and sends one request, applies the Proton headers, checks the +// {Code,Error} envelope, and decodes a 2xx body into out (if non-nil). +func (c *Client) do(ctx context.Context, method, path string, body any, uid, bearer string, out any) error { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("marshal request body: %w", err) + } + reader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, c.APIBase+path, reader) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("x-pm-appversion", c.AppVersion) + req.Header.Set("x-pm-sdk-versions", c.SDKVersions) + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if uid != "" { + req.Header.Set("x-pm-uid", uid) + req.Header.Set("x-pm-origin-sdk", c.OriginSDK) + } + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := c.HTTP.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes)) + if err != nil { + return fmt.Errorf("read response body: %w", err) + } + + var env struct { + Code int `json:"Code"` + Error string `json:"Error"` + } + _ = json.Unmarshal(raw, &env) + if resp.StatusCode/100 != 2 || env.Error != "" { + return &APIError{Status: resp.StatusCode, Code: env.Code, Message: env.Error} + } + + if out != nil { + if err := json.Unmarshal(raw, out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + } + return nil +} diff --git a/internal/protonpass/client_test.go b/internal/protonpass/client_test.go new file mode 100644 index 0000000..128aee6 --- /dev/null +++ b/internal/protonpass/client_test.go @@ -0,0 +1,398 @@ +package protonpass + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const ( + testUnauthUID = "unauth-uid-123" + testUnauthAccess = "unauth.access.jwe" + testSessionUID = "session-uid-456" + testAccess = "auth.access.jwe" + testRefresh = "auth.refresh.jwe" + testPAT = "pst_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef::AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + testShareID = "share_AA__==" // base64url-ish with padding, like a real share id +) + +func TestPATToken(t *testing.T) { + want := "pst_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + if got := PATToken(testPAT); got != want { + t.Fatalf("PATToken kept the ::key half or mangled token: %q", got) + } + if got := PATToken("pst_nokey"); got != "pst_nokey" { + t.Fatalf("PATToken should pass through a token without ::key, got %q", got) + } +} + +func newTestClient(t *testing.T, mux *http.ServeMux) *Client { + t.Helper() + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + c := New(srv.URL) + c.HTTP = srv.Client() + return c +} + +func TestAuthenticate(t *testing.T) { + var gotExchange *http.Request + var gotBody string + + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/v4/sessions", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("x-pm-uid") != "" || r.Header.Get("Authorization") != "" { + t.Errorf("unauth session request must be anonymous; got uid=%q auth=%q", + r.Header.Get("x-pm-uid"), r.Header.Get("Authorization")) + } + writeJSON(w, map[string]any{"Code": 1000, "UID": testUnauthUID, "AccessToken": testUnauthAccess}) + }) + mux.HandleFunc("POST /account/v4/personal-access-token/session", func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + gotExchange = r + writeJSON(w, map[string]any{"Code": 1000, "Session": map[string]any{ + "SessionUID": testSessionUID, "AccessToken": testAccess, "RefreshToken": testRefresh, + "AccessExpirationTime": 1893456000, + }}) + }) + + c := newTestClient(t, mux) + sess, err := c.Authenticate(context.Background(), testPAT) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if sess.UID != testSessionUID || sess.AccessToken != testAccess || sess.RefreshToken != testRefresh { + t.Fatalf("session not mapped from nested Session{SessionUID,...}: %+v", sess) + } + if sess.AccessExpiry != 1893456000 { + t.Fatalf("AccessExpiry = %d, want 1893456000 (mapped from AccessExpirationTime)", sess.AccessExpiry) + } + + // the exchange must carry the unauth session creds + correct headers + if got := gotExchange.Header.Get("x-pm-uid"); got != testUnauthUID { + t.Errorf("exchange x-pm-uid = %q, want unauth UID %q", got, testUnauthUID) + } + if got := gotExchange.Header.Get("Authorization"); got != "Bearer "+testUnauthAccess { + t.Errorf("exchange Authorization = %q, want bearer of unauth access token", got) + } + for h, want := range map[string]string{ + "x-pm-appversion": DefaultAppVersion, + "x-pm-sdk-versions": DefaultSDKVersions, + "x-pm-origin-sdk": DefaultOriginSDK, + "Content-Type": "application/json", + } { + if got := gotExchange.Header.Get(h); got != want { + t.Errorf("exchange header %s = %q, want %q", h, got, want) + } + } + + // the body must send only the pst_ half, as {"Token": ...} + var sent struct{ Token string } + if err := json.Unmarshal([]byte(gotBody), &sent); err != nil { + t.Fatalf("exchange body not JSON: %q", gotBody) + } + if sent.Token != PATToken(testPAT) { + t.Errorf("exchange Token = %q, want pst_ half only (no ::key)", sent.Token) + } + if strings.Contains(gotBody, "::") { + t.Errorf("exchange body leaked the ::key half: %q", gotBody) + } +} + +func TestListSharesAndItems(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /pass/v1/share", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer "+testAccess || r.Header.Get("x-pm-uid") != testSessionUID { + t.Errorf("authed GET missing session headers: uid=%q auth=%q", + r.Header.Get("x-pm-uid"), r.Header.Get("Authorization")) + } + writeJSON(w, map[string]any{ + "Code": 1000, "Total": 1, + "Shares": []map[string]any{{ + "ShareID": testShareID, "VaultID": "vault1", "TargetType": 1, + "Content": "encrypted-vault-meta", "ContentKeyRotation": 1, "Primary": true, + }}, + }) + }) + mux.HandleFunc("GET /pass/v1/share/{shareID}/item", func(w http.ResponseWriter, r *http.Request) { + if got := r.PathValue("shareID"); got != testShareID { + t.Errorf("item list share id = %q, want %q", got, testShareID) + } + writeJSON(w, map[string]any{ + "Code": 1000, + "Items": map[string]any{ + "LastToken": "", + "RevisionsData": []map[string]any{{ + "ItemID": "item1", "Revision": 3, "Content": "enc-item", "ItemKey": "wrapped-key", + "ContentFormatVersion": 6, "KeyRotation": 1, + }}, + }, + }) + }) + + c := newTestClient(t, mux) + sess := &Session{UID: testSessionUID, AccessToken: testAccess} + + shares, err := c.ListShares(context.Background(), sess) + if err != nil { + t.Fatalf("ListShares: %v", err) + } + if len(shares) != 1 || shares[0].ShareID != testShareID || !shares[0].Primary { + t.Fatalf("unexpected shares: %+v", shares) + } + + items, err := c.ListItems(context.Background(), sess, testShareID) + if err != nil { + t.Fatalf("ListItems: %v", err) + } + if len(items) != 1 || items[0].ItemID != "item1" || items[0].ContentFormatVersion != 6 { + t.Fatalf("unexpected items: %+v", items) + } +} + +func TestAuthenticateRejection(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/v4/sessions", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, map[string]any{"Code": 1000, "UID": testUnauthUID, "AccessToken": testUnauthAccess}) + }) + mux.HandleFunc("POST /account/v4/personal-access-token/session", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + writeJSON(w, map[string]any{"Code": 2001, "Error": "Invalid or expired personal access token"}) + }) + + c := newTestClient(t, mux) + _, err := c.Authenticate(context.Background(), testPAT) + if err == nil { + t.Fatal("expected an error for a rejected PAT") + } + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("error is not *APIError: %v", err) + } + if apiErr.Status != http.StatusBadRequest || !strings.Contains(apiErr.Message, "Invalid or expired") { + t.Fatalf("APIError missing status/message: %+v", apiErr) + } +} + +func TestListItemsPagination(t *testing.T) { + const cursor = "tok==" // contains '=' to exercise query escaping/round-trip + var pages int + + mux := http.NewServeMux() + mux.HandleFunc("GET /pass/v1/share/{shareID}/item", func(w http.ResponseWriter, r *http.Request) { + pages++ + switch r.URL.Query().Get("Since") { + case "": + writeJSON(w, map[string]any{"Code": 1000, "Items": map[string]any{ + "LastToken": cursor, "RevisionsData": []map[string]any{{"ItemID": "item1"}}, + }}) + case cursor: // server sees the decoded cursor -> escaping round-tripped + writeJSON(w, map[string]any{"Code": 1000, "Items": map[string]any{ + "LastToken": "", "RevisionsData": []map[string]any{{"ItemID": "item2"}}, + }}) + default: + t.Errorf("page %d had unexpected Since=%q", pages, r.URL.Query().Get("Since")) + writeJSON(w, map[string]any{"Code": 1000, "Items": map[string]any{}}) + } + }) + + c := newTestClient(t, mux) + items, err := c.ListItems(context.Background(), &Session{UID: "u", AccessToken: "a"}, testShareID) + if err != nil { + t.Fatalf("ListItems: %v", err) + } + if pages != 2 { + t.Errorf("expected 2 page requests, got %d", pages) + } + if len(items) != 2 || items[0].ItemID != "item1" || items[1].ItemID != "item2" { + t.Fatalf("pagination did not accumulate both pages: %+v", items) + } +} + +func TestGetShareKeys(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /pass/v1/share/{shareID}/key", func(w http.ResponseWriter, r *http.Request) { + if got := r.PathValue("shareID"); got != testShareID { + t.Errorf("share keys path id = %q, want %q", got, testShareID) + } + if r.URL.Query().Get("Page") != "0" { + t.Errorf("expected Page=0, got %q", r.URL.Query().Get("Page")) + } + writeJSON(w, map[string]any{ + "Code": 1000, + "ShareKeys": map[string]any{ + "Total": 1, + "Keys": []map[string]any{{ + "KeyRotation": 1, "Key": "base64-share-key", "UserKeyID": "uk1", "CreateTime": 123, + }}, + }, + }) + }) + + c := newTestClient(t, mux) + keys, err := c.GetShareKeys(context.Background(), &Session{UID: testSessionUID, AccessToken: testAccess}, testShareID) + if err != nil { + t.Fatalf("GetShareKeys: %v", err) + } + if len(keys) != 1 || keys[0].KeyRotation != 1 || keys[0].Key != "base64-share-key" || keys[0].UserKeyID != "uk1" { + t.Fatalf("unexpected share keys: %+v", keys) + } +} + +func TestAuthenticateMalformed(t *testing.T) { + tests := []struct { + name string + unauth, exchange map[string]any + }{ + { + name: "unauth session missing access token", + unauth: map[string]any{"Code": 1000, "UID": testUnauthUID}, // no AccessToken + }, + { + name: "exchange missing session", + unauth: map[string]any{"Code": 1000, "UID": testUnauthUID, "AccessToken": testUnauthAccess}, + exchange: map[string]any{"Code": 1000}, // no Session + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /auth/v4/sessions", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, tt.unauth) + }) + mux.HandleFunc("POST /account/v4/personal-access-token/session", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, tt.exchange) + }) + c := newTestClient(t, mux) + if _, err := c.Authenticate(context.Background(), testPAT); err == nil { + t.Fatal("expected an error for a malformed response") + } + }) + } +} + +func TestCreateItem(t *testing.T) { + var gotBody map[string]any + mux := http.NewServeMux() + mux.HandleFunc("POST /pass/v1/share/{shareID}/item", func(w http.ResponseWriter, r *http.Request) { + if got := r.PathValue("shareID"); got != testShareID { + t.Errorf("create path share id = %q, want %q", got, testShareID) + } + if r.Header.Get("Authorization") != "Bearer "+testAccess || r.Header.Get("x-pm-uid") != testSessionUID { + t.Errorf("create missing session headers: uid=%q auth=%q", r.Header.Get("x-pm-uid"), r.Header.Get("Authorization")) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("create Content-Type = %q", r.Header.Get("Content-Type")) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + writeJSON(w, map[string]any{"Code": 1000, "Item": map[string]any{ + "ItemID": "newitem", "Revision": 1, "KeyRotation": 1, "ContentFormatVersion": 6, + }}) + }) + + c := newTestClient(t, mux) + sess := &Session{UID: testSessionUID, AccessToken: testAccess} + rev, err := c.CreateItem(context.Background(), sess, testShareID, CreateItemRequest{ + KeyRotation: 1, Content: "enc-content", ItemKey: "wrapped-item-key", + }) + if err != nil { + t.Fatalf("CreateItem: %v", err) + } + if rev.ItemID != "newitem" || rev.Revision != 1 { + t.Fatalf("create returned %+v, want ItemID=newitem Revision=1", rev) + } + // ContentFormatVersion defaults to the current version when left zero. + if gotBody["ContentFormatVersion"].(float64) != float64(ItemContentFormatVersion) { + t.Errorf("create ContentFormatVersion = %v, want default %d", gotBody["ContentFormatVersion"], ItemContentFormatVersion) + } + if gotBody["Content"] != "enc-content" || gotBody["ItemKey"] != "wrapped-item-key" || gotBody["KeyRotation"].(float64) != 1 { + t.Errorf("create body = %v", gotBody) + } +} + +func TestUpdateItem(t *testing.T) { + var gotBody map[string]any + mux := http.NewServeMux() + mux.HandleFunc("PUT /pass/v1/share/{shareID}/item/{itemID}", func(w http.ResponseWriter, r *http.Request) { + if r.PathValue("shareID") != testShareID || r.PathValue("itemID") != "item1" { + t.Errorf("update path = share %q item %q", r.PathValue("shareID"), r.PathValue("itemID")) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + writeJSON(w, map[string]any{"Code": 1000, "Item": map[string]any{"ItemID": "item1", "Revision": 4}}) + }) + + c := newTestClient(t, mux) + sess := &Session{UID: testSessionUID, AccessToken: testAccess} + rev, err := c.UpdateItem(context.Background(), sess, testShareID, "item1", UpdateItemRequest{ + KeyRotation: 1, LastRevision: 3, Content: "new-enc-content", + }) + if err != nil { + t.Fatalf("UpdateItem: %v", err) + } + if rev.Revision != 4 { + t.Fatalf("update returned revision %d, want 4", rev.Revision) + } + if gotBody["LastRevision"].(float64) != 3 || gotBody["Content"] != "new-enc-content" { + t.Errorf("update body = %v", gotBody) + } + // The per-item key is unchanged on update, so no ItemKey is sent. + if _, present := gotBody["ItemKey"]; present { + t.Errorf("update must not send ItemKey, body = %v", gotBody) + } +} + +func TestDeleteItem(t *testing.T) { + var gotBody map[string]any + mux := http.NewServeMux() + mux.HandleFunc("DELETE /pass/v1/share/{shareID}/item", func(w http.ResponseWriter, r *http.Request) { + if got := r.PathValue("shareID"); got != testShareID { + t.Errorf("delete path share id = %q, want %q", got, testShareID) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + writeJSON(w, map[string]any{"Code": 1000}) + }) + + c := newTestClient(t, mux) + sess := &Session{UID: testSessionUID, AccessToken: testAccess} + if err := c.DeleteItem(context.Background(), sess, testShareID, "item1", 3); err != nil { + t.Fatalf("DeleteItem: %v", err) + } + if gotBody["SkipTrash"] != true { + t.Errorf("delete must set SkipTrash=true, body = %v", gotBody) + } + items, ok := gotBody["Items"].([]any) + if !ok || len(items) != 1 { + t.Fatalf("delete Items = %v", gotBody["Items"]) + } + first := items[0].(map[string]any) + if first["ItemID"] != "item1" || first["Revision"].(float64) != 3 { + t.Errorf("delete item entry = %v, want ItemID=item1 Revision=3", first) + } +} + +func TestDeleteItemAPIError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("DELETE /pass/v1/share/{shareID}/item", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + writeJSON(w, map[string]any{"Code": 2001, "Error": "Item revision is stale"}) + }) + c := newTestClient(t, mux) + err := c.DeleteItem(context.Background(), &Session{UID: "u", AccessToken: "a"}, testShareID, "item1", 1) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Status != http.StatusUnprocessableEntity { + t.Fatalf("DeleteItem error = %v, want *APIError with 422", err) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + panic(err) + } +} diff --git a/internal/protonpass/crypto.go b/internal/protonpass/crypto.go new file mode 100644 index 0000000..113f3ff --- /dev/null +++ b/internal/protonpass/crypto.go @@ -0,0 +1,210 @@ +package protonpass + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strings" +) + +// Proton Pass PAT read model: the chain is symmetric AES-256-GCM, rooted at the +// PAT's "::" half. A PAT session uses no OpenPGP and no /core/v4/keys (that +// endpoint 403s for a PAT). +// +// PAT "::" --base64url--> 32-byte AES key (encKey) +// └─ opens the share key (GET /pass/v1/share/{id}/key, AAD "sharekey") +// └─ opens an item's ItemKey (AAD "itemkey") +// └─ opens the item Content (AAD "itemcontent") -> protobuf Item +// +// Older items carry no ItemKey; their Content opens with the share key directly. +// Each step prepends a 12-byte nonce and authenticates the PassEncryptionTag as AAD. + +// PassEncryptionTag values are AES-GCM associated-data domain separators. +const ( + TagItemContent = "itemcontent" + TagItemKey = "itemkey" + TagVaultContent = "vaultcontent" + TagShareKey = "sharekey" +) + +const ( + aesKeyBytes = 32 // AES-256 + gcmNonceBytes = 12 // prepended to the ciphertext +) + +// PATKey decodes the "::" half of a compound "pst_::" PAT into its +// raw 32-byte AES-256 key. For a PAT session this key is the symmetric root that +// opens the share key (see OpenShareKey); it is used directly: no salt, no KDF. +func PATKey(pat string) ([]byte, error) { + i := strings.Index(pat, "::") + if i < 0 { + return nil, errors.New(`pat: missing "::" half`) + } + keyPart := pat[i+2:] + if keyPart == "" { + return nil, errors.New(`pat: empty key after "::"`) + } + raw, err := base64.RawURLEncoding.DecodeString(keyPart) + if err != nil { + return nil, fmt.Errorf("pat: decode key half: %w", err) + } + return raw, nil +} + +// DecryptAESGCM opens Proton's symmetric envelope: nonce(12) || ciphertext+tag(16), +// AES-256-GCM, with tag as additional authenticated data. +func DecryptAESGCM(key, blob []byte, tag string) ([]byte, error) { + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + if len(blob) < gcmNonceBytes+gcm.Overhead() { + return nil, errors.New("aes-gcm: ciphertext too short") + } + nonce, ciphertext := blob[:gcmNonceBytes], blob[gcmNonceBytes:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, []byte(tag)) + if err != nil { + return nil, fmt.Errorf("aes-gcm open (tag %q): %w", tag, err) + } + return plaintext, nil +} + +// EncryptAESGCM produces nonce || ciphertext+tag. nonce must be 12 bytes and unique +// per key. +func EncryptAESGCM(key, plaintext, nonce []byte, tag string) ([]byte, error) { + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + if len(nonce) != gcmNonceBytes { + return nil, fmt.Errorf("aes-gcm: nonce must be %d bytes, got %d", gcmNonceBytes, len(nonce)) + } + return gcm.Seal(nonce, nonce, plaintext, []byte(tag)), nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + if len(key) != aesKeyBytes { + return nil, fmt.Errorf("aes-gcm: key must be %d bytes, got %d", aesKeyBytes, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// OpenItemKey decrypts a base64 ItemKey (from an item revision) with the share key. +func OpenItemKey(shareKey []byte, encItemKeyB64 string) ([]byte, error) { + blob, err := b64decode(encItemKeyB64) + if err != nil { + return nil, fmt.Errorf("item key: %w", err) + } + return DecryptAESGCM(shareKey, blob, TagItemKey) +} + +// OpenItemContent decrypts a base64 item Content with its item key, yielding the +// raw protobuf-encoded Item bytes. +func OpenItemContent(itemKey []byte, encContentB64 string) ([]byte, error) { + blob, err := b64decode(encContentB64) + if err != nil { + return nil, fmt.Errorf("item content: %w", err) + } + return DecryptAESGCM(itemKey, blob, TagItemContent) +} + +// OpenVaultContent decrypts a base64 vault Content (Share.Content) with the share key. +func OpenVaultContent(shareKey []byte, encContentB64 string) ([]byte, error) { + blob, err := b64decode(encContentB64) + if err != nil { + return nil, fmt.Errorf("vault content: %w", err) + } + return DecryptAESGCM(shareKey, blob, TagVaultContent) +} + +// OpenShareKey decrypts a share key (an entry from GET /pass/v1/share/{id}/key) into +// its raw 32-byte AES key, using the PAT enc-key (from PATKey) and AAD "sharekey". +func OpenShareKey(sk ShareKey, encKey []byte) ([]byte, error) { + blob, err := b64decode(sk.Key) + if err != nil { + return nil, fmt.Errorf("share key: %w", err) + } + shareKey, err := DecryptAESGCM(encKey, blob, TagShareKey) + if err != nil { + return nil, fmt.Errorf("share key: %w", err) + } + if len(shareKey) != aesKeyBytes { + return nil, fmt.Errorf("share key: want %d-byte AES key, got %d", aesKeyBytes, len(shareKey)) + } + return shareKey, nil +} + +// b64decode tolerates standard and raw (unpadded) base64 for Proton's blobs. +func b64decode(s string) ([]byte, error) { + if b, err := base64.StdEncoding.DecodeString(s); err == nil { + return b, nil + } + return base64.RawStdEncoding.DecodeString(s) +} + +// The write path reverses the read chain: a fresh per-item key seals the item +// content (AAD "itemcontent"), and the share key wraps that item key (AAD +// "itemkey"). Each seal prepends a fresh random 12-byte nonce. + +// NewItemKey returns a fresh random 32-byte AES-256 key for one item. +func NewItemKey() ([]byte, error) { + key := make([]byte, aesKeyBytes) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate item key: %w", err) + } + return key, nil +} + +// NewItemUUID returns a fresh random RFC 4122 v4 UUID string, used for an item's +// metadata.item_uuid on create. +func NewItemUUID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("generate item uuid: %w", err) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil +} + +// seal encrypts plaintext under key with a fresh random nonce, returning +// nonce(12) || ciphertext+tag(16). +func seal(key, plaintext []byte, tag string) ([]byte, error) { + nonce := make([]byte, gcmNonceBytes) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + return EncryptAESGCM(key, plaintext, nonce, tag) +} + +// SealItemContent encrypts an item's protobuf plaintext under its item key +// (AAD "itemcontent"), returning the base64 blob for ItemRevision.Content. It +// is the inverse of OpenItemContent. +func SealItemContent(itemKey, plaintext []byte) (string, error) { + blob, err := seal(itemKey, plaintext, TagItemContent) + if err != nil { + return "", fmt.Errorf("seal item content: %w", err) + } + return base64.StdEncoding.EncodeToString(blob), nil +} + +// SealItemKey wraps a per-item key with the share key (AAD "itemkey"), +// returning the base64 blob for ItemRevision.ItemKey. It is the inverse of +// OpenItemKey. +func SealItemKey(shareKey, itemKey []byte) (string, error) { + if len(itemKey) != aesKeyBytes { + return "", fmt.Errorf("seal item key: item key must be %d bytes, got %d", aesKeyBytes, len(itemKey)) + } + blob, err := seal(shareKey, itemKey, TagItemKey) + if err != nil { + return "", fmt.Errorf("seal item key: %w", err) + } + return base64.StdEncoding.EncodeToString(blob), nil +} diff --git a/internal/protonpass/crypto_test.go b/internal/protonpass/crypto_test.go new file mode 100644 index 0000000..bc79d67 --- /dev/null +++ b/internal/protonpass/crypto_test.go @@ -0,0 +1,208 @@ +package protonpass + +import ( + "bytes" + "encoding/base64" + "testing" +) + +func key32(b byte) []byte { return bytes.Repeat([]byte{b}, aesKeyBytes) } +func nonce12() []byte { return bytes.Repeat([]byte{0x42}, gcmNonceBytes) } + +func TestAESGCMRoundTrip(t *testing.T) { + key := key32(0x01) + plaintext := []byte("aws-vault secret blob") + + blob, err := EncryptAESGCM(key, plaintext, nonce12(), TagItemContent) + if err != nil { + t.Fatalf("EncryptAESGCM: %v", err) + } + if len(blob) <= gcmNonceBytes { + t.Fatalf("ciphertext envelope too short: %d bytes", len(blob)) + } + + got, err := DecryptAESGCM(key, blob, TagItemContent) + if err != nil { + t.Fatalf("DecryptAESGCM: %v", err) + } + if !bytes.Equal(got, plaintext) { + t.Fatalf("round-trip mismatch: got %q want %q", got, plaintext) + } +} + +func TestAESGCMTagIsAuthenticated(t *testing.T) { + key := key32(0x02) + blob, err := EncryptAESGCM(key, []byte("x"), nonce12(), TagItemContent) + if err != nil { + t.Fatal(err) + } + // Wrong AAD tag must fail (GCM authenticates the associated data). + if _, err := DecryptAESGCM(key, blob, TagItemKey); err == nil { + t.Fatal("decrypt with wrong tag must fail") + } + if _, err := DecryptAESGCM(key32(0x03), blob, TagItemContent); err == nil { + t.Fatal("decrypt with wrong key must fail") + } +} + +func TestAESGCMInputValidation(t *testing.T) { + if _, err := DecryptAESGCM(key32(0x04), []byte("short"), TagItemContent); err == nil { + t.Error("too-short ciphertext must fail") + } + if _, err := DecryptAESGCM([]byte("not-32-bytes"), bytes.Repeat([]byte{0}, 40), TagItemContent); err == nil { + t.Error("wrong key size must fail") + } + if _, err := EncryptAESGCM(key32(0x05), []byte("x"), []byte("short-nonce"), TagItemContent); err == nil { + t.Error("wrong nonce size must fail") + } +} + +// TestUnwrapChain exercises the share-key -> item-key -> item-content composition, +// matching how a real item decrypts. +func TestUnwrapChain(t *testing.T) { + shareKey := key32(0x10) + itemKey := key32(0x20) + contentPlain := []byte("serialized-item-protobuf") + + encItemKey, err := EncryptAESGCM(shareKey, itemKey, nonce12(), TagItemKey) + if err != nil { + t.Fatal(err) + } + encContent, err := EncryptAESGCM(itemKey, contentPlain, nonce12(), TagItemContent) + if err != nil { + t.Fatal(err) + } + + gotItemKey, err := OpenItemKey(shareKey, base64.StdEncoding.EncodeToString(encItemKey)) + if err != nil { + t.Fatalf("OpenItemKey: %v", err) + } + if !bytes.Equal(gotItemKey, itemKey) { + t.Fatal("OpenItemKey did not recover the item key") + } + + gotContent, err := OpenItemContent(gotItemKey, base64.StdEncoding.EncodeToString(encContent)) + if err != nil { + t.Fatalf("OpenItemContent: %v", err) + } + if !bytes.Equal(gotContent, contentPlain) { + t.Fatalf("OpenItemContent mismatch: got %q want %q", gotContent, contentPlain) + } +} + +func TestOpenShareKeyRoundTrip(t *testing.T) { + encKey := key32(0xee) // the PAT "::" enc-key + shareKey := key32(0x5a) // the 32-byte share key we expect to recover + + blob, err := EncryptAESGCM(encKey, shareKey, nonce12(), TagShareKey) + if err != nil { + t.Fatal(err) + } + sk := ShareKey{KeyRotation: 1, Key: base64.StdEncoding.EncodeToString(blob)} + + got, err := OpenShareKey(sk, encKey) + if err != nil { + t.Fatalf("OpenShareKey: %v", err) + } + if !bytes.Equal(got, shareKey) { + t.Fatalf("recovered %x, want %x", got, shareKey) + } + + if _, err := OpenShareKey(sk, key32(0x01)); err == nil { + t.Fatal("OpenShareKey with wrong enc-key must fail") + } + + short, err := EncryptAESGCM(encKey, []byte("not thirty-two bytes"), nonce12(), TagShareKey) + if err != nil { + t.Fatal(err) + } + if _, err := OpenShareKey(ShareKey{Key: base64.StdEncoding.EncodeToString(short)}, encKey); err == nil { + t.Fatal("OpenShareKey must reject a non-32-byte share key") + } +} + +func TestNewItemKey(t *testing.T) { + a, err := NewItemKey() + if err != nil { + t.Fatalf("NewItemKey: %v", err) + } + if len(a) != aesKeyBytes { + t.Fatalf("item key len = %d, want %d", len(a), aesKeyBytes) + } + b, err := NewItemKey() + if err != nil { + t.Fatal(err) + } + if bytes.Equal(a, b) { + t.Fatal("two NewItemKey calls returned identical keys") + } +} + +// TestSealChain seals an item the way the write path does (fresh item key wraps +// content, share key wraps the item key) and opens it back via the read path. +func TestSealChain(t *testing.T) { + shareKey := key32(0x5a) + itemKey, err := NewItemKey() + if err != nil { + t.Fatal(err) + } + proto := []byte("serialized-item-protobuf") + + encContent, err := SealItemContent(itemKey, proto) + if err != nil { + t.Fatalf("SealItemContent: %v", err) + } + encItemKey, err := SealItemKey(shareKey, itemKey) + if err != nil { + t.Fatalf("SealItemKey: %v", err) + } + + gotItemKey, err := OpenItemKey(shareKey, encItemKey) + if err != nil { + t.Fatalf("OpenItemKey: %v", err) + } + if !bytes.Equal(gotItemKey, itemKey) { + t.Fatal("OpenItemKey did not recover the sealed item key") + } + gotContent, err := OpenItemContent(gotItemKey, encContent) + if err != nil { + t.Fatalf("OpenItemContent: %v", err) + } + if !bytes.Equal(gotContent, proto) { + t.Fatalf("content round-trip mismatch: got %q want %q", gotContent, proto) + } + + // A fresh nonce per call means two seals of the same plaintext differ. + again, err := SealItemContent(itemKey, proto) + if err != nil { + t.Fatal(err) + } + if again == encContent { + t.Fatal("two SealItemContent calls produced identical ciphertext (nonce reuse)") + } +} + +func TestSealItemKeyRejectsBadKey(t *testing.T) { + if _, err := SealItemKey(key32(0x5a), []byte("not-32-bytes")); err == nil { + t.Fatal("SealItemKey must reject a non-32-byte item key") + } +} + +func TestPATKey(t *testing.T) { + raw := bytes.Repeat([]byte{0x7f}, aesKeyBytes) + pat := "pst_0123456789abcdef::" + base64.RawURLEncoding.EncodeToString(raw) + + got, err := PATKey(pat) + if err != nil { + t.Fatalf("PATKey: %v", err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("PATKey decoded %x, want %x", got, raw) + } + + for _, bad := range []string{"pst_nokey", "pst_tok::", "pst_tok::!!!not base64!!!"} { + if _, err := PATKey(bad); err == nil { + t.Errorf("PATKey(%q) must fail", bad) + } + } +} diff --git a/internal/protonpass/proto.go b/internal/protonpass/proto.go new file mode 100644 index 0000000..aeb0b31 --- /dev/null +++ b/internal/protonpass/proto.go @@ -0,0 +1,112 @@ +package protonpass + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/encoding/protowire" +) + +// Field numbers from Proton Pass's item-v1.proto (the wire contract, re-declared +// clean-room from the documented schema, not generated): +// +// message Item { Metadata metadata = 1; Content content = 2; ... } +// message Metadata { string name = 1; string note = 2; string item_uuid = 3; ... } +// message Content { oneof content { ItemNote note = 2; ... } } +// message ItemNote {} // empty marker; the note text lives in Metadata.note +// +// aws-vault keys items by metadata.name and stores its blob in metadata.note. +const ( + fieldItemMetadata = 1 + fieldItemContent = 2 + fieldMetadataName = 1 + fieldMetadataNote = 2 + fieldMetadataItemUUID = 3 + fieldContentNote = 2 +) + +// ItemMetadata is the subset of a decrypted Item the backend uses: the title and +// the note payload. +type ItemMetadata struct { + Name string + Note string +} + +// ParseItemMetadata extracts Metadata.name and Metadata.note from a decrypted Item +// protobuf (the plaintext returned by OpenItemContent). Unknown fields and other +// content types are skipped. +func ParseItemMetadata(item []byte) (ItemMetadata, error) { + meta, found, err := bytesField(item, fieldItemMetadata) + if err != nil { + return ItemMetadata{}, fmt.Errorf("item: read metadata: %w", err) + } + if !found { + return ItemMetadata{}, errors.New("item: missing metadata field") + } + + name, _, err := bytesField(meta, fieldMetadataName) + if err != nil { + return ItemMetadata{}, fmt.Errorf("metadata: read name: %w", err) + } + note, _, err := bytesField(meta, fieldMetadataNote) + if err != nil { + return ItemMetadata{}, fmt.Errorf("metadata: read note: %w", err) + } + return ItemMetadata{Name: string(name), Note: string(note)}, nil +} + +// EncodeItem serializes a note-type item-v1 Item protobuf: metadata.name (title), +// metadata.note (the blob), and metadata.item_uuid, plus a Content oneof selecting +// the empty ItemNote variant. It is the inverse of ParseItemMetadata. +func EncodeItem(meta ItemMetadata, itemUUID string) []byte { + var m []byte + m = protowire.AppendTag(m, fieldMetadataName, protowire.BytesType) + m = protowire.AppendBytes(m, []byte(meta.Name)) + m = protowire.AppendTag(m, fieldMetadataNote, protowire.BytesType) + m = protowire.AppendBytes(m, []byte(meta.Note)) + m = protowire.AppendTag(m, fieldMetadataItemUUID, protowire.BytesType) + m = protowire.AppendBytes(m, []byte(itemUUID)) + + // Content { note = 2: ItemNote {} } — an empty submessage selecting the note type. + var content []byte + content = protowire.AppendTag(content, fieldContentNote, protowire.BytesType) + content = protowire.AppendBytes(content, nil) + + var item []byte + item = protowire.AppendTag(item, fieldItemMetadata, protowire.BytesType) + item = protowire.AppendBytes(item, m) + item = protowire.AppendTag(item, fieldItemContent, protowire.BytesType) + item = protowire.AppendBytes(item, content) + return item +} + +// bytesField returns the last length-delimited field number `num` in msg. found is +// false (with a nil slice) when the field is absent. +func bytesField(msg []byte, num protowire.Number) (value []byte, found bool, err error) { + for len(msg) > 0 { + fieldNum, typ, tagLen := protowire.ConsumeTag(msg) + if tagLen < 0 { + return nil, false, protowire.ParseError(tagLen) + } + msg = msg[tagLen:] + + if typ == protowire.BytesType { + v, vLen := protowire.ConsumeBytes(msg) + if vLen < 0 { + return nil, false, protowire.ParseError(vLen) + } + if fieldNum == num { + value, found = v, true // keep the last occurrence (proto3 semantics) + } + msg = msg[vLen:] + continue + } + + skip := protowire.ConsumeFieldValue(fieldNum, typ, msg) + if skip < 0 { + return nil, false, protowire.ParseError(skip) + } + msg = msg[skip:] + } + return value, found, nil +} diff --git a/internal/protonpass/proto_test.go b/internal/protonpass/proto_test.go new file mode 100644 index 0000000..eedb49b --- /dev/null +++ b/internal/protonpass/proto_test.go @@ -0,0 +1,110 @@ +package protonpass + +import ( + "testing" + + "google.golang.org/protobuf/encoding/protowire" +) + +// buildMetadata encodes a Metadata{name, note} sub-message and interleaves an +// unknown varint field (item_uuid-ish) to prove the parser skips what it doesn't model. +func buildMetadata(name, note string) []byte { + var m []byte + m = protowire.AppendTag(m, fieldMetadataName, protowire.BytesType) + m = protowire.AppendBytes(m, []byte(name)) + m = protowire.AppendTag(m, 9, protowire.VarintType) // unknown field, must be skipped + m = protowire.AppendVarint(m, 42) + m = protowire.AppendTag(m, fieldMetadataNote, protowire.BytesType) + m = protowire.AppendBytes(m, []byte(note)) + return m +} + +// buildItem encodes an Item{metadata}, plus an unknown content sub-message and a +// fixed32 field, so the top-level walk also exercises skipping. +func buildItem(name, note string) []byte { + var item []byte + item = protowire.AppendTag(item, 2, protowire.BytesType) // Item.content, unmodeled + item = protowire.AppendBytes(item, []byte("ignored-content")) + item = protowire.AppendTag(item, fieldItemMetadata, protowire.BytesType) + item = protowire.AppendBytes(item, buildMetadata(name, note)) + item = protowire.AppendTag(item, 7, protowire.Fixed32Type) // unknown, must be skipped + item = protowire.AppendFixed32(item, 0xdeadbeef) + return item +} + +func TestParseItemMetadata(t *testing.T) { + const title = "aws-vault/dev" + const blob = `{"AccessKeyID":"AKIA","SecretAccessKey":"s3cr3t"}` + + got, err := ParseItemMetadata(buildItem(title, blob)) + if err != nil { + t.Fatalf("ParseItemMetadata: %v", err) + } + if got.Name != title { + t.Errorf("Name = %q, want %q", got.Name, title) + } + if got.Note != blob { + t.Errorf("Note = %q, want %q", got.Note, blob) + } +} + +func TestParseItemMetadataEmptyNote(t *testing.T) { + got, err := ParseItemMetadata(buildItem("title-only", "")) + if err != nil { + t.Fatalf("ParseItemMetadata: %v", err) + } + if got.Name != "title-only" || got.Note != "" { + t.Fatalf("unexpected: %+v", got) + } +} + +func TestEncodeItemRoundTrip(t *testing.T) { + const title = "aws-vault/prod" + const blob = `{"AccessKeyID":"AKIAPROD"}` + const uuid = "0123abcd-0000-4000-8000-000000000001" + + raw := EncodeItem(ItemMetadata{Name: title, Note: blob}, uuid) + + got, err := ParseItemMetadata(raw) + if err != nil { + t.Fatalf("ParseItemMetadata(EncodeItem(...)): %v", err) + } + if got.Name != title || got.Note != blob { + t.Fatalf("round-trip mismatch: %+v", got) + } + + // The encoded Item carries metadata.item_uuid (field 3) and a Content oneof + // (Item field 2) selecting the empty ItemNote (Content field 2). + meta, ok, err := bytesField(raw, fieldItemMetadata) + if err != nil || !ok { + t.Fatalf("metadata field: ok=%v err=%v", ok, err) + } + gotUUID, ok, err := bytesField(meta, fieldMetadataItemUUID) + if err != nil || !ok || string(gotUUID) != uuid { + t.Fatalf("item_uuid = %q ok=%v err=%v, want %q", gotUUID, ok, err, uuid) + } + content, ok, err := bytesField(raw, fieldItemContent) + if err != nil || !ok { + t.Fatalf("content field: ok=%v err=%v", ok, err) + } + if note, ok, err := bytesField(content, fieldContentNote); err != nil || !ok || len(note) != 0 { + t.Fatalf("content.note marker: note=%v ok=%v err=%v, want empty present", note, ok, err) + } +} + +func TestParseItemMetadataMissing(t *testing.T) { + // An Item with no metadata field is an error. + var item []byte + item = protowire.AppendTag(item, 2, protowire.BytesType) + item = protowire.AppendBytes(item, []byte("only-content")) + if _, err := ParseItemMetadata(item); err == nil { + t.Fatal("ParseItemMetadata must fail when metadata is absent") + } +} + +func TestParseItemMetadataTruncated(t *testing.T) { + good := buildItem("t", "n") + if _, err := ParseItemMetadata(good[:len(good)-1]); err == nil { + t.Fatal("ParseItemMetadata must fail on truncated input") + } +} diff --git a/keyring.go b/keyring.go index 22658d7..d08b62c 100644 --- a/keyring.go +++ b/keyring.go @@ -25,6 +25,7 @@ const ( OPBackend BackendType = "op" OPConnectBackend BackendType = "op-connect" OPDesktopBackend BackendType = "op-desktop" + ProtonPassBackend BackendType = "proton-pass" ) // This order makes sure the OS-specific backends @@ -51,6 +52,8 @@ var backendOrder = []BackendType{ OPConnectBackend, OPBackend, OPDesktopBackend, + // Proton Pass (after FileBackend: never auto-selected, must be chosen explicitly) + ProtonPassBackend, } var supportedBackends = map[BackendType]opener{} diff --git a/protonpass.go b/protonpass.go new file mode 100644 index 0000000..8d66b9c --- /dev/null +++ b/protonpass.go @@ -0,0 +1,602 @@ +//go:build !keyring_noprotonpass + +package keyring + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "slices" + "strings" + "time" + + "github.com/byteness/keyring/internal/protonpass" +) + +// Environment variables and defaults for the Proton Pass backend. +const ( + // ProtonPassEnvPAT is the env var holding the "pst_::" PAT + // (matches the Proton Pass CLI's own variable). + ProtonPassEnvPAT = "PROTON_PASS_PERSONAL_ACCESS_TOKEN" + // ProtonPassEnvShareID is the env var holding the target vault's Share ID. + ProtonPassEnvShareID = "PROTON_PASS_SHARE_ID" + // ProtonPassEnvAPIBase optionally overrides the Proton API base URL. + ProtonPassEnvAPIBase = "PROTON_PASS_API_BASE" + // ProtonPassDefaultItemTitlePrefix namespaces aws-vault's items in the vault. + ProtonPassDefaultItemTitlePrefix = "aws-vault" + + // protonPassDefaultTimeout bounds a single backend operation (which may issue + // several HTTP calls) when no timeout is configured. + protonPassDefaultTimeout = 30 * time.Second + + // Proton API error codes the backend recognises. + protonCodeTooManyLogins = 2028 // accompanies HTTP 429 "Too many recent logins" + protonCodeHumanVerification = 9001 // human-verification / CAPTCHA challenge required +) + +// Errors returned by the Proton Pass backend. +var ( + errProtonPassKeyring = errors.New("unable to create a Proton Pass keyring") + // errEnvUnsetOrEmpty is Proton-local so the backend does not depend on a + // symbol another backend defines (keyring_no1password removes opcommon.go). + errEnvUnsetOrEmpty = errors.New("environment variable unset or empty") + ErrProtonPassNoPAT = fmt.Errorf("%w: %w: %#v or Config.ProtonPassPAT", errProtonPassKeyring, errEnvUnsetOrEmpty, ProtonPassEnvPAT) + ErrProtonPassNoShareID = fmt.Errorf("%w: %w: %#v or Config.ProtonPassShareID", errProtonPassKeyring, errEnvUnsetOrEmpty, ProtonPassEnvShareID) + + // ErrProtonPassShareNotAccessible is returned when the configured Share ID is + // not among the shares the PAT can access (usually a missing access grant). + ErrProtonPassShareNotAccessible = errors.New("proton-pass backend: configured share id is not accessible to this PAT (grant it access)") + + // ErrProtonPassRateLimited wraps Proton's "too many recent logins" response. + // Session caching should keep operations well under the limit; hitting this + // usually means a burst of fresh logins, so wait a few minutes before retrying. + ErrProtonPassRateLimited = errors.New("proton-pass backend: rate limited by Proton (too many recent logins); wait a few minutes and retry") + + // ErrProtonPassSessionExpired is returned when a session is rejected as expired + // or revoked and re-exchanging the PAT did not recover it. + ErrProtonPassSessionExpired = errors.New("proton-pass backend: Proton session expired or revoked") + + // ErrProtonPassPATRejected is returned when Proton rejects the PAT itself + // (invalid, expired, or revoked); mint or re-grant a personal access token. + ErrProtonPassPATRejected = errors.New("proton-pass backend: personal access token rejected (invalid, expired, or revoked)") + + // ErrProtonPassHumanVerification is returned when Proton demands human + // verification (CAPTCHA / 2FA), which this headless client cannot satisfy; + // re-authenticate with the Proton Pass app or CLI to clear it. + ErrProtonPassHumanVerification = errors.New("proton-pass backend: Proton requires human verification, which this client cannot satisfy; re-authenticate with the Proton Pass app or CLI") +) + +func init() { + supportedBackends[ProtonPassBackend] = opener(func(cfg Config) (Keyring, error) { + return NewProtonPassKeyring(&cfg) + }) +} + +// ProtonPassKeyring implements the Keyring interface backed by Proton Pass over +// its native HTTP API. Client is exported so tests can inject a mock. +type ProtonPassKeyring struct { + Client protonpass.API + ShareID string + ItemTitlePrefix string + + pat string + tokenFunc PromptFunc + + apiBase string + cache protonSessionStore + timeout time.Duration + nowFunc func() time.Time // overridable in tests; nil means time.Now +} + +// NewProtonPassKeyring builds a Proton Pass keyring from config + environment. +// The Share ID is required up front; the PAT is resolved lazily at first use +// (config, then env, then prompt). +func NewProtonPassKeyring(cfg *Config) (*ProtonPassKeyring, error) { + shareID := cfg.ProtonPassShareID + if shareID == "" { + shareID = os.Getenv(ProtonPassEnvShareID) + } + if shareID == "" { + return nil, ErrProtonPassNoShareID + } + + apiBase := cfg.ProtonPassAPIBase + if apiBase == "" { + apiBase = os.Getenv(ProtonPassEnvAPIBase) + } + apiBase = protonpass.NormalizeAPIBase(apiBase) + + itemTitlePrefix := cfg.ProtonPassItemTitlePrefix + if itemTitlePrefix == "" { + itemTitlePrefix = ProtonPassDefaultItemTitlePrefix + } + + return &ProtonPassKeyring{ + Client: protonpass.New(apiBase), + ShareID: shareID, + ItemTitlePrefix: itemTitlePrefix, + pat: cfg.ProtonPassPAT, + tokenFunc: cfg.ProtonPassTokenFunc, + apiBase: apiBase, + cache: newKeychainSessionStore(), + timeout: cfg.ProtonPassTimeout, + }, nil +} + +// opContext derives a per-operation context carrying the configured timeout, so +// every Proton API call an operation makes shares one deadline. +func (k ProtonPassKeyring) opContext() (context.Context, context.CancelFunc) { + timeout := k.timeout + if timeout <= 0 { + timeout = protonPassDefaultTimeout + } + return context.WithTimeout(context.Background(), timeout) +} + +// now returns the current time, allowing tests to control session-cache aging. +func (k ProtonPassKeyring) now() time.Time { + if k.nowFunc != nil { + return k.nowFunc() + } + return time.Now() +} + +// resolvePAT returns the PAT from config, then env, then a prompt. +func (k ProtonPassKeyring) resolvePAT() (string, error) { + if k.pat != "" { + return k.pat, nil + } + if v := os.Getenv(ProtonPassEnvPAT); v != "" { + return v, nil + } + if k.tokenFunc != nil { + return k.tokenFunc("Enter Proton Pass personal access token") + } + return "", ErrProtonPassNoPAT +} + +// patAndKey resolves the PAT once and derives the AES enc-key from its "::" +// half. Resolving once per operation means a prompt-sourced PAT is requested at +// most once, even when an operation retries after a session expiry. +func (k ProtonPassKeyring) patAndKey() (pat string, encKey []byte, err error) { + pat, err = k.resolvePAT() + if err != nil { + return "", nil, err + } + encKey, err = protonpass.PATKey(pat) + if err != nil { + return "", nil, err + } + return pat, encKey, nil +} + +// authSession returns a usable Proton session for pat, reusing a fresh cached one +// when available and otherwise exchanging the PAT and caching the result. +// forceFresh clears the cache and forces a new exchange, recovering from a cached +// session that the server has expired or revoked. Caching is best-effort: a store +// failure degrades to a per-operation exchange, never an operation failure. +func (k ProtonPassKeyring) authSession(ctx context.Context, pat string, forceFresh bool) (*protonpass.Session, error) { + account := protonSessionAccount(k.apiBase, pat) + if k.cache != nil { + if forceFresh { + k.cache.invalidate(account) + } else if cs, ok := k.cache.load(account); ok && cs.fresh(k.now()) { + return cs.toSession(), nil + } + } + session, err := k.Client.Authenticate(ctx, pat) + if err != nil { + return nil, err + } + if k.cache != nil { + k.cache.save(account, newCachedSession(session, k.now())) + } + return session, nil +} + +// isUnauthorized reports whether the API error is a 401 (by HTTP status or Proton +// code) — a session the server treats as expired or revoked. +func isUnauthorized(apiErr *protonpass.APIError) bool { + return apiErr.Status == http.StatusUnauthorized || apiErr.Code == http.StatusUnauthorized +} + +// isSessionExpired reports whether err is an authentication failure that a fresh +// PAT exchange could recover from (a session token expired or revoked server-side). +func isSessionExpired(err error) bool { + var apiErr *protonpass.APIError + return errors.As(err, &apiErr) && isUnauthorized(apiErr) +} + +// classifyProtonErr maps a raw Proton API error to an actionable backend sentinel, +// keeping the original error unwrappable for diagnostics. Non-API errors (e.g. +// ErrProtonPassShareNotAccessible, ErrKeyNotFound) pass through unchanged. +func classifyProtonErr(err error) error { + if err == nil { + return nil + } + var apiErr *protonpass.APIError + if !errors.As(err, &apiErr) { + return err + } + switch { + case apiErr.Status == http.StatusTooManyRequests || apiErr.Code == protonCodeTooManyLogins: + return fmt.Errorf("%w: %w", ErrProtonPassRateLimited, err) + case apiErr.Code == protonCodeHumanVerification: + return fmt.Errorf("%w: %w", ErrProtonPassHumanVerification, err) + // A PAT-rejection message is more specific than the bare status, so check it + // before the generic 401/unauthorized case. + case strings.Contains(strings.ToLower(apiErr.Message), "personal access token"): + return fmt.Errorf("%w: %w", ErrProtonPassPATRejected, err) + case isUnauthorized(apiErr): + return fmt.Errorf("%w: %w", ErrProtonPassSessionExpired, err) + default: + return err + } +} + +// decryptedItem is one aws-vault item recovered from the vault: its key (the title +// with the namespace prefix stripped), the stored blob (the item note), and the +// identifiers + keys the write path needs to update or delete it in place. +type decryptedItem struct { + key string + note string + itemID string + revision int + keyRotation int + contentKey []byte // the AES key Content is sealed under (per-item key, or share key for old items) +} + +// openVault verifies the configured Share is accessible and decrypts every +// share-key rotation (AES-GCM with the PAT enc-key) into a rotation -> 32-byte +// share key map. +func (k ProtonPassKeyring) openVault(ctx context.Context, session *protonpass.Session, encKey []byte) (map[int][]byte, error) { + shares, err := k.Client.ListShares(ctx, session) + if err != nil { + return nil, err + } + if !slices.ContainsFunc(shares, func(s protonpass.Share) bool { return s.ShareID == k.ShareID }) { + return nil, ErrProtonPassShareNotAccessible + } + + shareKeys, err := k.Client.GetShareKeys(ctx, session, k.ShareID) + if err != nil { + return nil, err + } + vaultKeys := make(map[int][]byte, len(shareKeys)) + for _, sk := range shareKeys { + raw, err := protonpass.OpenShareKey(sk, encKey) + if err != nil { + return nil, fmt.Errorf("open share key (rotation %d): %w", sk.KeyRotation, err) + } + vaultKeys[sk.KeyRotation] = raw + } + return vaultKeys, nil +} + +// loadVaultOnce authenticates (cache-aware), decrypts the share keys, then lists +// and decrypts every aws-vault item into its key, note, and the identifiers + +// content key the write path needs. Items whose title lacks this keyring's +// namespace prefix are dropped. forceFresh forces a new PAT exchange. +func (k ProtonPassKeyring) loadVaultOnce(ctx context.Context, pat string, encKey []byte, forceFresh bool) (session *protonpass.Session, vaultKeys map[int][]byte, items []decryptedItem, err error) { + session, err = k.authSession(ctx, pat, forceFresh) + if err != nil { + return nil, nil, nil, err + } + vaultKeys, err = k.openVault(ctx, session, encKey) + if err != nil { + return nil, nil, nil, err + } + // Past this point vaultKeys (and any per-item keys decrypted below) hold key + // material. If we return an error instead of handing them to the caller, zero + // them rather than leaving them for the GC. + defer func() { + if err != nil { + zeroVaultKeys(vaultKeys) + zeroItems(items) + session, vaultKeys, items = nil, nil, nil + } + }() + + revisions, err := k.Client.ListItems(ctx, session, k.ShareID) + if err != nil { + return session, vaultKeys, items, err + } + + for _, rev := range revisions { + shareKey, ok := vaultKeys[rev.KeyRotation] + if !ok { + continue // item encrypted under a rotation we did not fetch + } + content, contentKey, cerr := k.openContent(shareKey, rev) + if cerr != nil { + err = fmt.Errorf("item %s: %w", rev.ItemID, cerr) + return session, vaultKeys, items, err + } + meta, perr := protonpass.ParseItemMetadata(content) + if perr != nil { + zeroBytes(contentKey) // this revision's key is not yet recorded in items + err = fmt.Errorf("item %s: parse: %w", rev.ItemID, perr) + return session, vaultKeys, items, err + } + key, ok := k.keyFromTitle(meta.Name) + if !ok { + continue // not an aws-vault item + } + items = append(items, decryptedItem{ + key: key, + note: meta.Note, + itemID: rev.ItemID, + revision: rev.Revision, + keyRotation: rev.KeyRotation, + contentKey: contentKey, + }) + } + return session, vaultKeys, items, nil +} + +// withVault loads the vault, then runs fn against it exactly once. The 401 retry +// is scoped to the load phase: if loading fails because a cached session was +// expired or revoked server-side, the cache is invalidated, the PAT re-exchanged, +// and the load retried — all before any mutation runs, so a write (fn) is never +// re-issued. If fn itself fails with a session-expired error (the token died after +// the load), the cache is dropped so the next invocation re-exchanges, but fn is +// not retried, since a create may already have been applied. +func (k ProtonPassKeyring) withVault(ctx context.Context, pat string, encKey []byte, fn func(session *protonpass.Session, vaultKeys map[int][]byte, items []decryptedItem) error) error { + session, vaultKeys, items, err := k.loadVaultOnce(ctx, pat, encKey, false) + if err != nil && k.cache != nil && isSessionExpired(err) { + session, vaultKeys, items, err = k.loadVaultOnce(ctx, pat, encKey, true) + } + if err != nil { + return err + } + defer zeroVaultKeys(vaultKeys) + defer zeroItems(items) + + err = fn(session, vaultKeys, items) + if err != nil && k.cache != nil && isSessionExpired(err) { + k.cache.invalidate(protonSessionAccount(k.apiBase, pat)) + } + return err +} + +// openContent decrypts one item revision's Content and returns the plaintext plus +// the key it was sealed under. Newer items wrap a per-item key (ItemKey) with the +// share key; older items have no ItemKey and their Content is opened with the share +// key directly. The returned key is what an update must re-encrypt under. +func (k ProtonPassKeyring) openContent(shareKey []byte, rev protonpass.ItemRevision) (content, contentKey []byte, err error) { + if rev.ItemKey == "" { + content, err = protonpass.OpenItemContent(shareKey, rev.Content) + if err != nil { + return nil, nil, fmt.Errorf("open content: %w", err) + } + return content, shareKey, nil + } + itemKey, err := protonpass.OpenItemKey(shareKey, rev.ItemKey) + if err != nil { + return nil, nil, fmt.Errorf("open item key: %w", err) + } + content, err = protonpass.OpenItemContent(itemKey, rev.Content) + if err != nil { + return nil, nil, fmt.Errorf("open content: %w", err) + } + return content, itemKey, nil +} + +// itemTitle maps an aws-vault key to a Proton Pass item title. An empty prefix +// means the title is the key verbatim. +func (k ProtonPassKeyring) itemTitle(key string) string { + if k.ItemTitlePrefix == "" { + return key + } + return k.ItemTitlePrefix + "/" + key +} + +// keyFromTitle is the inverse of itemTitle: it strips the namespace prefix, +// reporting false for titles that do not belong to this keyring. +func (k ProtonPassKeyring) keyFromTitle(title string) (string, bool) { + if k.ItemTitlePrefix == "" { + return title, true + } + return strings.CutPrefix(title, k.ItemTitlePrefix+"/") +} + +// Keys lists the aws-vault item keys in the configured vault. Titles live inside +// the encrypted item content, so this fetches and decrypts every item. +func (k ProtonPassKeyring) Keys() ([]string, error) { + pat, encKey, err := k.patAndKey() + if err != nil { + return nil, err + } + defer zeroBytes(encKey) + + ctx, cancel := k.opContext() + defer cancel() + + var keys []string + err = k.withVault(ctx, pat, encKey, func(_ *protonpass.Session, _ map[int][]byte, items []decryptedItem) error { + keys = make([]string, 0, len(items)) + for _, it := range items { + keys = append(keys, it.key) + } + slices.Sort(keys) + return nil + }) + if err != nil { + return nil, classifyProtonErr(err) + } + return keys, nil +} + +// Get returns the Item for key, decrypting its stored blob, or ErrKeyNotFound. +func (k ProtonPassKeyring) Get(key string) (Item, error) { + pat, encKey, err := k.patAndKey() + if err != nil { + return Item{}, err + } + defer zeroBytes(encKey) + + ctx, cancel := k.opContext() + defer cancel() + + var found bool + var out Item + err = k.withVault(ctx, pat, encKey, func(_ *protonpass.Session, _ map[int][]byte, items []decryptedItem) error { + for _, it := range items { + if it.key == key { + out, found = Item{Key: key, Data: []byte(it.note)}, true + return nil + } + } + return nil + }) + if err != nil { + return Item{}, classifyProtonErr(err) + } + if !found { + return Item{}, ErrKeyNotFound + } + return out, nil +} + +// GetMetadata reports that Proton requires credentials even for metadata. +func (k ProtonPassKeyring) GetMetadata(_ string) (Metadata, error) { + return Metadata{}, ErrMetadataNeedsCredentials +} + +// Set creates or updates the aws-vault item for item.Key. The blob (item.Data) is +// stored as the item note inside an encrypted item-v1 protobuf. An existing item +// with the same key is updated in place (re-encrypted under its current per-item +// key); otherwise a new item is created under the current share-key rotation. +func (k ProtonPassKeyring) Set(item Item) error { + pat, encKey, err := k.patAndKey() + if err != nil { + return err + } + defer zeroBytes(encKey) + + ctx, cancel := k.opContext() + defer cancel() + return classifyProtonErr(k.withVault(ctx, pat, encKey, func(session *protonpass.Session, vaultKeys map[int][]byte, items []decryptedItem) error { + return k.setItem(ctx, session, vaultKeys, items, item) + })) +} + +// setItem performs the create-or-update against an already-loaded vault. +func (k ProtonPassKeyring) setItem(ctx context.Context, session *protonpass.Session, vaultKeys map[int][]byte, items []decryptedItem, item Item) error { + uuid, err := protonpass.NewItemUUID() + if err != nil { + return err + } + plaintext := protonpass.EncodeItem( + protonpass.ItemMetadata{Name: k.itemTitle(item.Key), Note: string(item.Data)}, uuid) + + if existing, ok := findItem(items, item.Key); ok { + content, err := protonpass.SealItemContent(existing.contentKey, plaintext) + if err != nil { + return err + } + _, err = k.Client.UpdateItem(ctx, session, k.ShareID, existing.itemID, protonpass.UpdateItemRequest{ + KeyRotation: existing.keyRotation, + LastRevision: existing.revision, + Content: content, + }) + return err + } + + rotation, shareKey, ok := currentRotation(vaultKeys) + if !ok { + return fmt.Errorf("proton-pass backend: no share key available to encrypt %q", item.Key) + } + itemKey, err := protonpass.NewItemKey() + if err != nil { + return err + } + defer zeroBytes(itemKey) + content, err := protonpass.SealItemContent(itemKey, plaintext) + if err != nil { + return err + } + wrappedKey, err := protonpass.SealItemKey(shareKey, itemKey) + if err != nil { + return err + } + _, err = k.Client.CreateItem(ctx, session, k.ShareID, protonpass.CreateItemRequest{ + KeyRotation: rotation, + Content: content, + ItemKey: wrappedKey, + }) + return err +} + +// Remove permanently deletes the item with the matching key, or returns +// ErrKeyNotFound if no aws-vault item carries that key. +func (k ProtonPassKeyring) Remove(key string) error { + pat, encKey, err := k.patAndKey() + if err != nil { + return err + } + defer zeroBytes(encKey) + + ctx, cancel := k.opContext() + defer cancel() + return classifyProtonErr(k.withVault(ctx, pat, encKey, func(session *protonpass.Session, _ map[int][]byte, items []decryptedItem) error { + existing, ok := findItem(items, key) + if !ok { + return ErrKeyNotFound + } + return k.Client.DeleteItem(ctx, session, k.ShareID, existing.itemID, existing.revision) + })) +} + +// zeroBytes overwrites b with zeros. Best-effort: Go's GC may already have copied +// the bytes elsewhere, but clearing the live copy shrinks the window in which +// derived key material sits in process memory. +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} + +// zeroVaultKeys clears every decrypted share key in m. +func zeroVaultKeys(m map[int][]byte) { + for _, k := range m { + zeroBytes(k) + } +} + +// zeroItems clears the per-item content key held by each decrypted item. For +// legacy items this aliases a share key (already cleared by zeroVaultKeys); a +// second zeroing is harmless. Call after fn has run, since the update path reads +// the content key. +func zeroItems(items []decryptedItem) { + for _, it := range items { + zeroBytes(it.contentKey) + } +} + +// findItem returns the decrypted item with the given key. +func findItem(items []decryptedItem, key string) (decryptedItem, bool) { + for _, it := range items { + if it.key == key { + return it, true + } + } + return decryptedItem{}, false +} + +// currentRotation returns the highest share-key rotation and its key, the rotation +// a new item is encrypted under. ok is false when no share key is available. +func currentRotation(vaultKeys map[int][]byte) (rotation int, shareKey []byte, ok bool) { + best := -1 + for rot := range vaultKeys { + if rot > best { + best = rot + } + } + if best < 0 { + return 0, nil, false + } + return best, vaultKeys[best], true +} diff --git a/protonpass_integration_test.go b/protonpass_integration_test.go new file mode 100644 index 0000000..652092e --- /dev/null +++ b/protonpass_integration_test.go @@ -0,0 +1,216 @@ +//go:build !keyring_noprotonpass + +package keyring + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os" + "slices" + "testing" + + "github.com/byteness/keyring/internal/protonpass" +) + +// TestProtonPassIntegration exercises the full read path against the real Proton +// Pass API. It is opt-in (like the 1Password live tests): it only runs when +// PROTON_PASS_INTEGRATION=1, and needs a PAT granted access to one known item. +// +// Setup (personal account, read-only): +// +// pass-cli personal-access-token access grant --role viewer --item-title "" ... +// export PROTON_PASS_INTEGRATION=1 +// export PROTON_PASS_PERSONAL_ACCESS_TOKEN='pst_...::...' +// export PROTON_PASS_SHARE_ID='<recipient share id>' +// export PROTON_PASS_TEST_ITEM_TITLE='<title>' +// export PROTON_PASS_TEST_ITEM_NOTE='<expected note>' # optional +// go test -run TestProtonPassIntegration -v ./... +// # afterwards: pass-cli personal-access-token access revoke ... +func TestProtonPassIntegration(t *testing.T) { + if os.Getenv("PROTON_PASS_INTEGRATION") != "1" { + t.Skip("set PROTON_PASS_INTEGRATION=1 (plus PAT + share id) to run the live read-path test") + } + pat := os.Getenv(ProtonPassEnvPAT) + shareID := os.Getenv(ProtonPassEnvShareID) + wantTitle := os.Getenv("PROTON_PASS_TEST_ITEM_TITLE") + if pat == "" || shareID == "" || wantTitle == "" { + t.Fatalf("need %s, %s, and PROTON_PASS_TEST_ITEM_TITLE set", ProtonPassEnvPAT, ProtonPassEnvShareID) + } + + // Empty prefix: every item title is a key verbatim, so an arbitrary granted + // item (not created by aws-vault) round-trips. + k := ProtonPassKeyring{ + Client: protonpass.New(os.Getenv(ProtonPassEnvAPIBase)), + ShareID: shareID, + ItemTitlePrefix: "", + pat: pat, + } + + keys, err := k.Keys() + if err != nil { + t.Fatalf("Keys: %v", err) + } + t.Logf("decrypted %d item title(s)", len(keys)) + if !slices.Contains(keys, wantTitle) { + t.Fatalf("Keys did not include the granted item title %q; got %v", wantTitle, keys) + } + + item, err := k.Get(wantTitle) + if err != nil { + t.Fatalf("Get(%q): %v", wantTitle, err) + } + if want := os.Getenv("PROTON_PASS_TEST_ITEM_NOTE"); want != "" && string(item.Data) != want { + t.Fatalf("Get(%q) note = %q, want %q", wantTitle, item.Data, want) + } + + if _, err := k.Get("definitely-not-a-real-title-zzz"); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("Get(missing) err = %v, want ErrKeyNotFound", err) + } +} + +// TestProtonPassIntegrationWrite round-trips the write path against the real Proton +// Pass API: create -> Keys -> Get -> update -> Remove. It is the generalized P0.5 +// roundtrip_smoke. +// +// DANGER: this CREATES and DELETES items in the configured vault. Run it ONLY +// against a disposable test account / vault, never a personal one. It is doubly +// gated: PROTON_PASS_INTEGRATION_WRITE=1 plus a writable PAT. The item key is +// uniquely randomized and the test removes it (including via t.Cleanup on failure), +// but a misconfigured share id could still touch real data — point it at a throwaway. +// +// pass-cli personal-access-token access grant --role editor --vault-name "<throwaway>" ... +// export PROTON_PASS_INTEGRATION_WRITE=1 +// export PROTON_PASS_PERSONAL_ACCESS_TOKEN='pst_...::...' +// export PROTON_PASS_SHARE_ID='<writable share id>' +// go test -run TestProtonPassIntegrationWrite -v ./... +func TestProtonPassIntegrationWrite(t *testing.T) { + if os.Getenv("PROTON_PASS_INTEGRATION_WRITE") != "1" { + t.Skip("set PROTON_PASS_INTEGRATION_WRITE=1 (disposable account only) to run the live write round-trip") + } + pat := os.Getenv(ProtonPassEnvPAT) + shareID := os.Getenv(ProtonPassEnvShareID) + if pat == "" || shareID == "" { + t.Fatalf("need %s and %s set", ProtonPassEnvPAT, ProtonPassEnvShareID) + } + + buf := make([]byte, 6) + if _, err := rand.Read(buf); err != nil { + t.Fatal(err) + } + key := "it-write-" + hex.EncodeToString(buf) + + k := ProtonPassKeyring{ + Client: protonpass.New(os.Getenv(ProtonPassEnvAPIBase)), + ShareID: shareID, + ItemTitlePrefix: ProtonPassDefaultItemTitlePrefix, + pat: pat, + } + + // Best-effort cleanup even if an assertion fails mid-test. + t.Cleanup(func() { + if err := k.Remove(key); err != nil && !errors.Is(err, ErrKeyNotFound) { + t.Logf("cleanup Remove(%q): %v", key, err) + } + }) + + const blob = `{"AccessKeyID":"AKIAINTEGRATION","SecretAccessKey":"s3cr3t"}` + if err := k.Set(Item{Key: key, Data: []byte(blob)}); err != nil { + t.Fatalf("Set(create): %v", err) + } + + keys, err := k.Keys() + if err != nil { + t.Fatalf("Keys after create: %v", err) + } + if !slices.Contains(keys, key) { + t.Fatalf("Keys did not include the created key %q; got %v", key, keys) + } + + got, err := k.Get(key) + if err != nil { + t.Fatalf("Get after create: %v", err) + } + if string(got.Data) != blob { + t.Fatalf("Get(%q) = %q, want %q", key, got.Data, blob) + } + + // Update in place via a second Set, then confirm the new value. + const updated = `{"AccessKeyID":"AKIAUPDATED","SecretAccessKey":"n3w"}` + if err := k.Set(Item{Key: key, Data: []byte(updated)}); err != nil { + t.Fatalf("Set(update): %v", err) + } + got, err = k.Get(key) + if err != nil { + t.Fatalf("Get after update: %v", err) + } + if string(got.Data) != updated { + t.Fatalf("Get(%q) after update = %q, want %q", key, got.Data, updated) + } + + if err := k.Remove(key); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := k.Get(key); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("Get after Remove err = %v, want ErrKeyNotFound", err) + } +} + +// TestProtonPassIntegrationUpdateRemove confirms the update + delete path against an +// EXISTING item. It exists for accounts/PATs where whole-vault grants (needed to +// create) are not permitted but a per-item editor grant is, so the create-based +// round-trip above cannot run. +// +// DANGER: it OVERWRITES then PERMANENTLY DELETES the named item — point it ONLY at a +// throwaway item. Gated by PROTON_PASS_INTEGRATION_WRITE=1. +// +// export PROTON_PASS_INTEGRATION_WRITE=1 +// export PROTON_PASS_PERSONAL_ACCESS_TOKEN='pst_...::...' +// export PROTON_PASS_SHARE_ID='<recipient share id from a per-item editor grant>' +// export PROTON_PASS_TEST_ITEM_TITLE='<throwaway item title>' +// go test -run TestProtonPassIntegrationUpdateRemove -v ./... +func TestProtonPassIntegrationUpdateRemove(t *testing.T) { + if os.Getenv("PROTON_PASS_INTEGRATION_WRITE") != "1" { + t.Skip("set PROTON_PASS_INTEGRATION_WRITE=1 (throwaway item only) to run the live update/delete test") + } + pat := os.Getenv(ProtonPassEnvPAT) + shareID := os.Getenv(ProtonPassEnvShareID) + title := os.Getenv("PROTON_PASS_TEST_ITEM_TITLE") + if pat == "" || shareID == "" || title == "" { + t.Fatalf("need %s, %s, and PROTON_PASS_TEST_ITEM_TITLE set", ProtonPassEnvPAT, ProtonPassEnvShareID) + } + + // Empty prefix: the item title is the key verbatim. + k := ProtonPassKeyring{ + Client: protonpass.New(os.Getenv(ProtonPassEnvAPIBase)), + ShareID: shareID, + ItemTitlePrefix: "", + pat: pat, + } + + // The target item must already exist and be readable (the per-item grant). + if _, err := k.Get(title); err != nil { + t.Fatalf("Get(%q) before update: %v (is the per-item editor grant in place?)", title, err) + } + + // Update in place — exercises Set's update branch (reuses the existing item key). + const updated = `{"phase3":"update-confirm"}` + if err := k.Set(Item{Key: title, Data: []byte(updated)}); err != nil { + t.Fatalf("Set(update) on %q: %v", title, err) + } + got, err := k.Get(title) + if err != nil { + t.Fatalf("Get(%q) after update: %v", title, err) + } + if string(got.Data) != updated { + t.Fatalf("Get(%q) after update = %q, want %q", title, got.Data, updated) + } + + // Permanent delete. + if err := k.Remove(title); err != nil { + t.Fatalf("Remove(%q): %v", title, err) + } + if _, err := k.Get(title); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("Get(%q) after Remove err = %v, want ErrKeyNotFound", title, err) + } +} diff --git a/protonpass_session.go b/protonpass_session.go new file mode 100644 index 0000000..8f2adb0 --- /dev/null +++ b/protonpass_session.go @@ -0,0 +1,158 @@ +//go:build !keyring_noprotonpass + +package keyring + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "time" + + "github.com/byteness/keyring/internal/protonpass" +) + +const ( + // protonSessionServiceName is the keychain service under which cached Proton + // sessions live, kept separate from any aws-vault credential items. + protonSessionServiceName = "aws-vault-proton-pass-session" + + // protonSessionFallbackTTL bounds reuse of a cached session when the server + // expiry is absent or implausible. A stale-but-cached session is still caught + // by the 401 re-exchange path, so this only caps proactive re-logins; it is + // deliberately well below typical Proton token lifetimes. + protonSessionFallbackTTL = time.Hour + + // protonSessionExpirySkew re-exchanges slightly before the reported expiry so a + // token does not die mid-operation. + protonSessionExpirySkew = 60 * time.Second + + // protonSessionMaxPlausibleLifetime is the largest server expiry treated as a + // real timestamp. Anything further out (e.g. a millisecond epoch mistaken for + // seconds) is implausible for an access token and falls back to the local TTL. + protonSessionMaxPlausibleLifetime = 30 * 24 * time.Hour +) + +// protonSecureSessionBackends are the OS-protected keyring backends eligible to +// hold the cached session, in platform-preference order. File/pass-style backends +// are intentionally excluded: the cached session is a live bearer credential. +var protonSecureSessionBackends = []BackendType{ + WinCredBackend, + WinHelloBackend, + KeychainBackend, + SecretServiceBackend, + KWalletBackend, +} + +// protonSessionStore persists and retrieves a cached Proton session keyed by an +// opaque, non-secret account id. Every method is best-effort: a caching failure +// must never fail the underlying keyring operation. +type protonSessionStore interface { + load(account string) (cachedSession, bool) + save(account string, cs cachedSession) + invalidate(account string) +} + +// cachedSession is the persisted form of an authenticated session plus the +// bookkeeping the freshness check needs. The refresh token is deliberately not +// stored: recovery re-exchanges the PAT rather than using a refresh grant, so +// persisting a long-lived refresh token would widen exposure for no benefit. +type cachedSession struct { + UID string `json:"uid"` + AccessToken string `json:"access_token"` + AccessExpiry int64 `json:"access_expiry,omitempty"` // server epoch seconds, if plausible + CachedAt int64 `json:"cached_at"` // unix seconds at save time +} + +func newCachedSession(s *protonpass.Session, now time.Time) cachedSession { + return cachedSession{ + UID: s.UID, + AccessToken: s.AccessToken, + AccessExpiry: s.AccessExpiry, + CachedAt: now.Unix(), + } +} + +func (cs cachedSession) toSession() *protonpass.Session { + return &protonpass.Session{ + UID: cs.UID, + AccessToken: cs.AccessToken, + AccessExpiry: cs.AccessExpiry, + } +} + +// fresh reports whether the cached session can still be reused. It trusts the +// server expiry only when it is a plausible future epoch (bounded above to guard +// against a duration or a millisecond epoch); otherwise it falls back to a local +// TTL. Either way a wrongly-fresh session is caught by the 401 re-exchange path. +func (cs cachedSession) fresh(now time.Time) bool { + if cs.AccessToken == "" { + return false + } + nowUnix := now.Unix() + maxPlausible := now.Add(protonSessionMaxPlausibleLifetime).Unix() + if cs.AccessExpiry > nowUnix && cs.AccessExpiry <= maxPlausible { + return nowUnix+int64(protonSessionExpirySkew.Seconds()) < cs.AccessExpiry + } + return now.Before(time.Unix(cs.CachedAt, 0).Add(protonSessionFallbackTTL)) +} + +// keyringSessionStore stores the cached session as a JSON item in an underlying +// (OS-protected) keyring. +type keyringSessionStore struct { + kr Keyring +} + +func (s *keyringSessionStore) load(account string) (cachedSession, bool) { + item, err := s.kr.Get(account) + if err != nil { + return cachedSession{}, false + } + var cs cachedSession + if err := json.Unmarshal(item.Data, &cs); err != nil { + debugf("proton-pass: discarding unreadable session cache entry: %v", err) + return cachedSession{}, false + } + return cs, true +} + +func (s *keyringSessionStore) save(account string, cs cachedSession) { + data, err := json.Marshal(cs) + if err != nil { + return + } + _ = s.kr.Set(Item{ + Key: account, + Data: data, + Label: protonSessionServiceName, + Description: "aws-vault cached Proton Pass session", + }) +} + +func (s *keyringSessionStore) invalidate(account string) { + _ = s.kr.Remove(account) +} + +// newKeychainSessionStore opens an OS-protected keyring for caching the Proton +// session. It returns nil when no secure backend is available (for example a +// headless host with no Secret Service): the backend then re-exchanges the PAT on +// every operation, so calls in quick succession may hit Proton's login rate limit. +func newKeychainSessionStore() protonSessionStore { + kr, err := Open(Config{ + ServiceName: protonSessionServiceName, + AllowedBackends: protonSecureSessionBackends, + }) + if err != nil { + debugf("proton-pass: no secure backend for session cache (%v); each operation will re-exchange the PAT", err) + return nil + } + return &keyringSessionStore{kr: kr} +} + +// protonSessionAccount derives a stable, non-secret keychain account id from the +// API base and the PAT's token half, so rotating the PAT or pointing at a +// different API naturally invalidates the cache. Only the "pst_<token>" half feeds +// the hash; the "::<key>" crypto half never leaves the process. +func protonSessionAccount(apiBase, pat string) string { + sum := sha256.Sum256([]byte(apiBase + "\x00" + protonpass.PATToken(pat))) + return hex.EncodeToString(sum[:]) +} diff --git a/protonpass_session_test.go b/protonpass_session_test.go new file mode 100644 index 0000000..fbf890f --- /dev/null +++ b/protonpass_session_test.go @@ -0,0 +1,419 @@ +//go:build !keyring_noprotonpass + +package keyring + +import ( + "context" + "errors" + "fmt" + "slices" + "testing" + "time" + + "github.com/byteness/keyring/internal/protonpass" +) + +func TestKeyringSessionStoreRoundTrip(t *testing.T) { + store := &keyringSessionStore{kr: NewArrayKeyring(nil)} + + if _, ok := store.load("acct"); ok { + t.Fatal("load on empty store returned ok") + } + + cs := cachedSession{UID: "u", AccessToken: "tok", AccessExpiry: 123, CachedAt: 456} + store.save("acct", cs) + + got, ok := store.load("acct") + if !ok || got != cs { + t.Fatalf("round trip = %+v ok=%v, want %+v", got, ok, cs) + } + + store.invalidate("acct") + if _, ok := store.load("acct"); ok { + t.Fatal("load after invalidate returned ok") + } +} + +func TestCachedSessionFresh(t *testing.T) { + now := time.Unix(1_700_000_000, 0) + tests := []struct { + name string + cs cachedSession + want bool + }{ + {"empty token is never fresh", cachedSession{AccessToken: "", CachedAt: now.Unix()}, false}, + {"future epoch beyond skew", cachedSession{AccessToken: "a", AccessExpiry: now.Unix() + 3600}, true}, + {"future epoch within skew", cachedSession{AccessToken: "a", AccessExpiry: now.Unix() + 30}, false}, + {"duration-like value falls back to TTL", cachedSession{AccessToken: "a", AccessExpiry: 3600, CachedAt: now.Unix()}, true}, + {"implausibly far epoch falls back to TTL", cachedSession{AccessToken: "a", AccessExpiry: now.Add(60 * 24 * time.Hour).Unix(), CachedAt: now.Unix()}, true}, + {"no expiry, fresh within TTL", cachedSession{AccessToken: "a", CachedAt: now.Unix() - 60}, true}, + {"no expiry, stale past TTL", cachedSession{AccessToken: "a", CachedAt: now.Add(-2 * time.Hour).Unix()}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.cs.fresh(now); got != tt.want { + t.Errorf("fresh() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestZeroBytes(t *testing.T) { + b := []byte{1, 2, 3, 4} + zeroBytes(b) + for i, v := range b { + if v != 0 { + t.Fatalf("byte %d = %d, want 0", i, v) + } + } + zeroBytes(nil) // must not panic + + m := map[int][]byte{1: {9, 9}, 2: {7}} + zeroVaultKeys(m) + for rot, k := range m { + for i, v := range k { + if v != 0 { + t.Fatalf("rotation %d byte %d = %d, want 0", rot, i, v) + } + } + } +} + +func TestClassifyProtonErr(t *testing.T) { + tests := []struct { + name string + apiErr *protonpass.APIError + want error + }{ + {"rate limit by HTTP 429", &protonpass.APIError{Status: 429}, ErrProtonPassRateLimited}, + {"rate limit by code 2028", &protonpass.APIError{Status: 200, Code: 2028}, ErrProtonPassRateLimited}, + {"human verification code 9001", &protonpass.APIError{Status: 422, Code: 9001}, ErrProtonPassHumanVerification}, + {"session expired HTTP 401", &protonpass.APIError{Status: 401}, ErrProtonPassSessionExpired}, + {"pat rejected by message", &protonpass.APIError{Status: 400, Message: "Invalid or expired personal access token"}, ErrProtonPassPATRejected}, + {"401 carrying a PAT message is pat-rejected", &protonpass.APIError{Status: 401, Message: "Invalid or expired personal access token"}, ErrProtonPassPATRejected}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := classifyProtonErr(fmt.Errorf("op: %w", tt.apiErr)) + if !errors.Is(got, tt.want) { + t.Errorf("classifyProtonErr = %v, want it to wrap %v", got, tt.want) + } + var ae *protonpass.APIError + if !errors.As(got, &ae) { + t.Error("classified error must still unwrap to the original APIError") + } + }) + } + + if classifyProtonErr(nil) != nil { + t.Error("classifyProtonErr(nil) must be nil") + } + plain := errors.New("not an api error") + if !errors.Is(classifyProtonErr(plain), plain) { + t.Error("non-API errors must pass through unchanged") + } +} + +func TestProtonPassRateLimitSurfaced(t *testing.T) { + m := mockProtonAPI{ + auth: func(_ context.Context, _ string) (*protonpass.Session, error) { + return nil, &protonpass.APIError{Status: 429, Code: 2028, Message: "Too many recent logins"} + }, + } + k := ProtonPassKeyring{Client: m, ShareID: "target", pat: "pst_x::AAAA"} + if _, err := k.Keys(); !errors.Is(err, ErrProtonPassRateLimited) { + t.Fatalf("Keys err = %v, want ErrProtonPassRateLimited", err) + } +} + +func TestProtonPassOpContextDeadline(t *testing.T) { + k := ProtonPassKeyring{timeout: 5 * time.Second} + ctx, cancel := k.opContext() + defer cancel() + dl, ok := ctx.Deadline() + if !ok { + t.Fatal("opContext returned a context with no deadline") + } + if d := time.Until(dl); d <= 0 || d > 6*time.Second { + t.Fatalf("deadline in %v, want ~5s", d) + } + + // A zero timeout falls back to the built-in default. + dctx, dcancel := ProtonPassKeyring{}.opContext() + defer dcancel() + dl2, ok := dctx.Deadline() + if !ok || time.Until(dl2) <= 5*time.Second { + t.Fatalf("default timeout not applied; ok=%v remaining=%v", ok, time.Until(dl2)) + } +} + +// TestProtonPassLoadVaultDecryptError exercises the post-load error path (an +// undecryptable item), where loadVaultOnce zeros the decrypted share keys before +// returning: it must surface the error cleanly and not panic. +func TestProtonPassLoadVaultDecryptError(t *testing.T) { + fx := buildVaultFixture(t, nil) // valid share key (rotation 1), no items + m := readMock(fx) + m.items = func(context.Context, *protonpass.Session, string) ([]protonpass.ItemRevision, error) { + return []protonpass.ItemRevision{{ + ItemID: "bad", + Revision: 1, + KeyRotation: 1, + Content: "not-valid-ciphertext", + }}, nil + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat} + + if _, err := k.Keys(); err == nil { + t.Fatal("Keys must return an error for an undecryptable item") + } +} + +func TestProtonPassTimeoutExcludesPrompt(t *testing.T) { + fx := buildVaultFixture(t, nil) + const timeout = 30 * time.Second + var promptAt, deadlineSeen time.Time + var hadDeadline bool + + m := readMock(fx) + baseAuth := m.auth + m.auth = func(ctx context.Context, pat string) (*protonpass.Session, error) { + deadlineSeen, hadDeadline = ctx.Deadline() + return baseAuth(ctx, pat) + } + k := ProtonPassKeyring{ + Client: *m, + ShareID: "target", + ItemTitlePrefix: "aws-vault", + timeout: timeout, + tokenFunc: func(string) (string, error) { + promptAt = time.Now() + return fx.pat, nil + }, + } + + if _, err := k.Keys(); err != nil { + t.Fatalf("Keys: %v", err) + } + if !hadDeadline { + t.Fatal("operation ran without a deadline") + } + // The deadline is now()+timeout taken when opContext runs, so deadline-timeout is + // when the timeout window opened. It must be at or after the prompt returned — + // otherwise interactive prompt time is being charged against the op timeout. + if windowStart := deadlineSeen.Add(-timeout); windowStart.Before(promptAt) { + t.Fatalf("timeout window opened before the PAT prompt; prompt time counts against the op timeout") + } +} + +func TestProtonPassTimeoutCancels(t *testing.T) { + m := mockProtonAPI{ + auth: func(ctx context.Context, _ string) (*protonpass.Session, error) { + <-ctx.Done() // simulate a slow Proton call that outlives the deadline + return nil, ctx.Err() + }, + } + k := ProtonPassKeyring{Client: m, ShareID: "target", pat: "pst_x::AAAA", timeout: time.Millisecond} + + if _, err := k.Keys(); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Keys err = %v, want context.DeadlineExceeded", err) + } +} + +func TestProtonSessionAccount(t *testing.T) { + a := protonSessionAccount("https://api", "pst_one::k") + if a != protonSessionAccount("https://api", "pst_one::different-crypto-half") { + t.Error("account must depend only on the pst_ token half, not the ::<key> crypto half") + } + if a == protonSessionAccount("https://api", "pst_two::k") { + t.Error("a different PAT token must produce a different account") + } + if a == protonSessionAccount("https://other", "pst_one::k") { + t.Error("a different API base must produce a different account") + } +} + +// TestProtonPassSessionCacheReuse proves the headline 429 fix: a second operation +// reuses the cached session instead of re-exchanging the PAT. +func TestProtonPassSessionCacheReuse(t *testing.T) { + fx := buildVaultFixture(t, map[string]string{"aws-vault/dev": "blob"}) + m := readMock(fx) + var authCalls int + base := m.auth + m.auth = func(ctx context.Context, pat string) (*protonpass.Session, error) { + authCalls++ + return base(ctx, pat) + } + now := time.Unix(1_700_000_000, 0) + k := ProtonPassKeyring{ + Client: *m, + ShareID: "target", + ItemTitlePrefix: "aws-vault", + pat: fx.pat, + cache: &keyringSessionStore{kr: NewArrayKeyring(nil)}, + nowFunc: func() time.Time { return now }, + } + + if _, err := k.Keys(); err != nil { + t.Fatalf("Keys: %v", err) + } + if _, err := k.Get("dev"); err != nil { + t.Fatalf("Get: %v", err) + } + if authCalls != 1 { + t.Fatalf("Authenticate called %d times across two operations, want 1 (session cached)", authCalls) + } +} + +// TestProtonPassSetRetriesLoadIssuesWriteOnce proves a stale cached session is +// recovered during the load phase and the create is issued exactly once. +func TestProtonPassSetRetriesLoadIssuesWriteOnce(t *testing.T) { + fx := buildVaultFixture(t, nil) // empty vault: Set must create + now := time.Unix(1_700_000_000, 0) + store := &keyringSessionStore{kr: NewArrayKeyring(nil)} + account := protonSessionAccount("", fx.pat) + store.save(account, newCachedSession(&protonpass.Session{UID: "stale", AccessToken: "stale-token"}, now)) + + var authCalls, shareCalls, createCalls int + m := readMock(fx) + m.auth = func(_ context.Context, _ string) (*protonpass.Session, error) { + authCalls++ + return &protonpass.Session{UID: "fresh", AccessToken: "fresh-token"}, nil + } + m.shares = func(_ context.Context, s *protonpass.Session) ([]protonpass.Share, error) { + shareCalls++ + if s.AccessToken == "stale-token" { + return nil, &protonpass.APIError{Status: 401, Code: 401, Message: "Invalid access token"} + } + return []protonpass.Share{{ShareID: "target", ContentKeyRotation: 1}}, nil + } + m.create = func(_ context.Context, _ *protonpass.Session, _ string, _ protonpass.CreateItemRequest) (*protonpass.ItemRevision, error) { + createCalls++ + return &protonpass.ItemRevision{ItemID: "new", Revision: 1}, nil + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat, cache: store, nowFunc: func() time.Time { return now }} + + if err := k.Set(Item{Key: "dev", Data: []byte("blob")}); err != nil { + t.Fatalf("Set after stale-session retry: %v", err) + } + if authCalls != 1 { + t.Fatalf("Authenticate called %d times, want 1 (re-exchange during load)", authCalls) + } + if shareCalls != 2 { + t.Fatalf("ListShares called %d times, want 2 (stale 401, then fresh)", shareCalls) + } + if createCalls != 1 { + t.Fatalf("CreateItem called %d times, want exactly 1 (write must not be re-issued)", createCalls) + } +} + +// TestProtonPassWritePhase401NotRetried proves a 401 raised by the write itself is +// not retried (the create may already be applied); the cache is dropped so the next +// invocation re-exchanges, and the error is surfaced as session-expired. +func TestProtonPassWritePhase401NotRetried(t *testing.T) { + fx := buildVaultFixture(t, nil) + now := time.Unix(1_700_000_000, 0) + store := &keyringSessionStore{kr: NewArrayKeyring(nil)} + account := protonSessionAccount("", fx.pat) + store.save(account, newCachedSession(&protonpass.Session{UID: "u", AccessToken: "good-token"}, now)) + + var authCalls, createCalls int + m := readMock(fx) + m.auth = func(_ context.Context, _ string) (*protonpass.Session, error) { + authCalls++ + return &protonpass.Session{UID: "u2", AccessToken: "good-token-2"}, nil + } + m.create = func(_ context.Context, _ *protonpass.Session, _ string, _ protonpass.CreateItemRequest) (*protonpass.ItemRevision, error) { + createCalls++ + return nil, &protonpass.APIError{Status: 401, Code: 401, Message: "Invalid access token"} + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat, cache: store, nowFunc: func() time.Time { return now }} + + err := k.Set(Item{Key: "dev", Data: []byte("blob")}) + if !errors.Is(err, ErrProtonPassSessionExpired) { + t.Fatalf("Set err = %v, want ErrProtonPassSessionExpired", err) + } + if createCalls != 1 { + t.Fatalf("CreateItem called %d times, want exactly 1 (write-phase 401 must not retry)", createCalls) + } + if authCalls != 0 { + t.Fatalf("Authenticate called %d times, want 0 (load used the cached session)", authCalls) + } + if _, ok := store.load(account); ok { + t.Fatal("cache must be invalidated after a write-phase 401") + } +} + +// TestProtonPassSessionCacheNilNoReuse confirms that without a cache the backend +// behaves as before: it re-exchanges on every operation. +func TestProtonPassSessionCacheNilNoReuse(t *testing.T) { + fx := buildVaultFixture(t, map[string]string{"aws-vault/dev": "blob"}) + m := readMock(fx) + var authCalls int + base := m.auth + m.auth = func(ctx context.Context, pat string) (*protonpass.Session, error) { + authCalls++ + return base(ctx, pat) + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat} + + if _, err := k.Keys(); err != nil { + t.Fatalf("Keys: %v", err) + } + if _, err := k.Get("dev"); err != nil { + t.Fatalf("Get: %v", err) + } + if authCalls != 2 { + t.Fatalf("Authenticate called %d times, want 2 (no cache configured)", authCalls) + } +} + +// TestProtonPassSessionCacheRetriesOn401 proves a cached-but-revoked session is +// recovered: the 401 invalidates the cache, the PAT is re-exchanged once, and the +// operation retries and succeeds. +func TestProtonPassSessionCacheRetriesOn401(t *testing.T) { + fx := buildVaultFixture(t, map[string]string{"aws-vault/dev": "blob"}) + now := time.Unix(1_700_000_000, 0) + store := &keyringSessionStore{kr: NewArrayKeyring(nil)} + + account := protonSessionAccount("", fx.pat) + store.save(account, newCachedSession(&protonpass.Session{UID: "stale", AccessToken: "stale-token"}, now)) + + var authCalls, shareCalls int + m := readMock(fx) + m.auth = func(_ context.Context, _ string) (*protonpass.Session, error) { + authCalls++ + return &protonpass.Session{UID: "fresh", AccessToken: "fresh-token"}, nil + } + m.shares = func(_ context.Context, s *protonpass.Session) ([]protonpass.Share, error) { + shareCalls++ + if s.AccessToken == "stale-token" { + return nil, &protonpass.APIError{Status: 401, Code: 401, Message: "Invalid access token"} + } + return []protonpass.Share{{ShareID: "target", ContentKeyRotation: 1}}, nil + } + k := ProtonPassKeyring{ + Client: *m, + ShareID: "target", + ItemTitlePrefix: "aws-vault", + pat: fx.pat, + cache: store, + nowFunc: func() time.Time { return now }, + } + + keys, err := k.Keys() + if err != nil { + t.Fatalf("Keys after stale-session retry: %v", err) + } + if !slices.Equal(keys, []string{"dev"}) { + t.Fatalf("Keys = %v, want [dev]", keys) + } + if authCalls != 1 { + t.Fatalf("Authenticate called %d times, want 1 (single re-exchange after 401)", authCalls) + } + if shareCalls != 2 { + t.Fatalf("ListShares called %d times, want 2 (stale 401, then fresh)", shareCalls) + } + if cs, ok := store.load(account); !ok || cs.AccessToken != "fresh-token" { + t.Fatalf("cache not refreshed after retry: %+v ok=%v", cs, ok) + } +} diff --git a/protonpass_test.go b/protonpass_test.go new file mode 100644 index 0000000..5782f14 --- /dev/null +++ b/protonpass_test.go @@ -0,0 +1,538 @@ +//go:build !keyring_noprotonpass + +package keyring + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "slices" + "testing" + + "google.golang.org/protobuf/encoding/protowire" + + "github.com/byteness/keyring/internal/protonpass" +) + +// mockProtonAPI is an injectable protonpass.API for backend tests. +type mockProtonAPI struct { + auth func(ctx context.Context, pat string) (*protonpass.Session, error) + shares func(ctx context.Context, s *protonpass.Session) ([]protonpass.Share, error) + items func(ctx context.Context, s *protonpass.Session, shareID string) ([]protonpass.ItemRevision, error) + shareKeys func(ctx context.Context, s *protonpass.Session, shareID string) ([]protonpass.ShareKey, error) + create func(ctx context.Context, s *protonpass.Session, shareID string, req protonpass.CreateItemRequest) (*protonpass.ItemRevision, error) + update func(ctx context.Context, s *protonpass.Session, shareID, itemID string, req protonpass.UpdateItemRequest) (*protonpass.ItemRevision, error) + del func(ctx context.Context, s *protonpass.Session, shareID, itemID string, revision int) error +} + +func (m mockProtonAPI) Authenticate(ctx context.Context, pat string) (*protonpass.Session, error) { + return m.auth(ctx, pat) +} + +func (m mockProtonAPI) ListShares(ctx context.Context, s *protonpass.Session) ([]protonpass.Share, error) { + return m.shares(ctx, s) +} + +func (m mockProtonAPI) ListItems(ctx context.Context, s *protonpass.Session, shareID string) ([]protonpass.ItemRevision, error) { + return m.items(ctx, s, shareID) +} + +func (m mockProtonAPI) GetShareKeys(ctx context.Context, s *protonpass.Session, shareID string) ([]protonpass.ShareKey, error) { + if m.shareKeys != nil { + return m.shareKeys(ctx, s, shareID) + } + return nil, nil +} + +func (m mockProtonAPI) CreateItem(ctx context.Context, s *protonpass.Session, shareID string, req protonpass.CreateItemRequest) (*protonpass.ItemRevision, error) { + if m.create != nil { + return m.create(ctx, s, shareID, req) + } + return nil, errors.New("CreateItem not stubbed") +} + +func (m mockProtonAPI) UpdateItem(ctx context.Context, s *protonpass.Session, shareID, itemID string, req protonpass.UpdateItemRequest) (*protonpass.ItemRevision, error) { + if m.update != nil { + return m.update(ctx, s, shareID, itemID, req) + } + return nil, errors.New("UpdateItem not stubbed") +} + +func (m mockProtonAPI) DeleteItem(ctx context.Context, s *protonpass.Session, shareID, itemID string, revision int) error { + if m.del != nil { + return m.del(ctx, s, shareID, itemID, revision) + } + return errors.New("DeleteItem not stubbed") +} + +func TestNewProtonPassKeyring(t *testing.T) { + t.Setenv(ProtonPassEnvShareID, "") + + if _, err := NewProtonPassKeyring(&Config{}); !errors.Is(err, ErrProtonPassNoShareID) { + t.Fatalf("missing share id: got %v, want ErrProtonPassNoShareID", err) + } + + k, err := NewProtonPassKeyring(&Config{ProtonPassShareID: "share1"}) + if err != nil { + t.Fatalf("NewProtonPassKeyring: %v", err) + } + if k.ShareID != "share1" { + t.Errorf("ShareID = %q, want share1", k.ShareID) + } + if k.ItemTitlePrefix != ProtonPassDefaultItemTitlePrefix { + t.Errorf("ItemTitlePrefix = %q, want default %q", k.ItemTitlePrefix, ProtonPassDefaultItemTitlePrefix) + } + + // Share ID falls back to the environment. + t.Setenv(ProtonPassEnvShareID, "envshare") + envK, err := NewProtonPassKeyring(&Config{}) + if err != nil { + t.Fatalf("NewProtonPassKeyring (env): %v", err) + } + if envK.ShareID != "envshare" { + t.Errorf("ShareID from env = %q, want envshare", envK.ShareID) + } +} + +func TestProtonPassRegistered(t *testing.T) { + if !slices.Contains(AvailableBackends(), ProtonPassBackend) { + t.Fatal("proton-pass backend not registered in AvailableBackends()") + } +} + +func TestOpenProtonPass(t *testing.T) { + t.Setenv(ProtonPassEnvShareID, "") + + // Good config: the opener builds a *ProtonPassKeyring. + kr, err := Open(Config{ + AllowedBackends: []BackendType{ProtonPassBackend}, + ProtonPassShareID: "share1", + }) + if err != nil { + t.Fatalf("Open: %v", err) + } + if _, ok := kr.(*ProtonPassKeyring); !ok { + t.Fatalf("Open returned %T, want *ProtonPassKeyring", kr) + } + + // Missing required config: the opener errors, so Open finds no usable backend. + if _, err := Open(Config{AllowedBackends: []BackendType{ProtonPassBackend}}); !errors.Is(err, ErrNoAvailImpl) { + t.Fatalf("Open with bad config: got %v, want ErrNoAvailImpl", err) + } +} + +func TestProtonPassResolvePAT(t *testing.T) { + t.Run("config wins over env", func(t *testing.T) { + t.Setenv(ProtonPassEnvPAT, "pst_env::k") + got, err := ProtonPassKeyring{pat: "pst_cfg::k"}.resolvePAT() + if err != nil || got != "pst_cfg::k" { + t.Fatalf("resolvePAT = %q, %v; want config value", got, err) + } + }) + t.Run("env fallback", func(t *testing.T) { + t.Setenv(ProtonPassEnvPAT, "pst_env::k") + got, err := ProtonPassKeyring{}.resolvePAT() + if err != nil || got != "pst_env::k" { + t.Fatalf("resolvePAT = %q, %v; want env value", got, err) + } + }) + t.Run("prompt fallback", func(t *testing.T) { + t.Setenv(ProtonPassEnvPAT, "") + k := ProtonPassKeyring{tokenFunc: func(string) (string, error) { return "pst_prompt::k", nil }} + got, err := k.resolvePAT() + if err != nil || got != "pst_prompt::k" { + t.Fatalf("resolvePAT = %q, %v; want prompt value", got, err) + } + }) +} + +// fixtureShareKey and fixtureItemKey mirror the constants buildVaultFixture seals +// with, so write tests can decrypt the captured request bodies. +func fixtureShareKey() []byte { return bytes.Repeat([]byte{0x5a}, 32) } +func fixtureItemKey(id int) []byte { return bytes.Repeat([]byte{byte(0x30 + id)}, 32) } + +// readMock returns a mock wired to serve the fixture's read path; write hooks are +// left nil for the test to set. +func readMock(fx vaultFixture) *mockProtonAPI { + return &mockProtonAPI{ + auth: func(context.Context, string) (*protonpass.Session, error) { + return &protonpass.Session{UID: "u", AccessToken: "a"}, nil + }, + shares: func(context.Context, *protonpass.Session) ([]protonpass.Share, error) { + return []protonpass.Share{{ShareID: "target", ContentKeyRotation: 1}}, nil + }, + shareKeys: func(context.Context, *protonpass.Session, string) ([]protonpass.ShareKey, error) { + return fx.shareKeys, nil + }, + items: func(context.Context, *protonpass.Session, string) ([]protonpass.ItemRevision, error) { + return fx.revisions, nil + }, + } +} + +func TestProtonPassSetCreate(t *testing.T) { + fx := buildVaultFixture(t, nil) // empty vault: Set must create + m := readMock(fx) + var gotReq protonpass.CreateItemRequest + var createCalls int + m.create = func(_ context.Context, _ *protonpass.Session, shareID string, req protonpass.CreateItemRequest) (*protonpass.ItemRevision, error) { + createCalls++ + if shareID != "target" { + t.Errorf("create share id = %q, want target", shareID) + } + gotReq = req + return &protonpass.ItemRevision{ItemID: "new", Revision: 1}, nil + } + m.update = func(context.Context, *protonpass.Session, string, string, protonpass.UpdateItemRequest) (*protonpass.ItemRevision, error) { + t.Error("Set on a missing key must create, not update") + return nil, nil + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat} + + const blob = `{"AccessKeyID":"AKIA"}` + if err := k.Set(Item{Key: "dev", Data: []byte(blob)}); err != nil { + t.Fatalf("Set: %v", err) + } + if createCalls != 1 { + t.Fatalf("create called %d times, want 1", createCalls) + } + if gotReq.KeyRotation != 1 { + t.Errorf("create KeyRotation = %d, want 1 (current rotation)", gotReq.KeyRotation) + } + + // The wrapped ItemKey unwraps with the share key; the content then decrypts to + // the item-v1 protobuf carrying the prefixed title and the blob. + itemKey, err := protonpass.OpenItemKey(fixtureShareKey(), gotReq.ItemKey) + if err != nil { + t.Fatalf("unwrap created ItemKey: %v", err) + } + plain, err := protonpass.OpenItemContent(itemKey, gotReq.Content) + if err != nil { + t.Fatalf("open created content: %v", err) + } + meta, err := protonpass.ParseItemMetadata(plain) + if err != nil { + t.Fatalf("parse created item: %v", err) + } + if meta.Name != "aws-vault/dev" || meta.Note != blob { + t.Fatalf("created item = %+v, want name=aws-vault/dev note=%q", meta, blob) + } +} + +func TestProtonPassSetUpdate(t *testing.T) { + fx := buildVaultFixture(t, map[string]string{"aws-vault/dev": "old-blob"}) + m := readMock(fx) + var gotReq protonpass.UpdateItemRequest + var gotItemID string + var updateCalls int + m.update = func(_ context.Context, _ *protonpass.Session, _ string, itemID string, req protonpass.UpdateItemRequest) (*protonpass.ItemRevision, error) { + updateCalls++ + gotItemID, gotReq = itemID, req + return &protonpass.ItemRevision{ItemID: itemID, Revision: req.LastRevision + 1}, nil + } + m.create = func(context.Context, *protonpass.Session, string, protonpass.CreateItemRequest) (*protonpass.ItemRevision, error) { + t.Error("Set on an existing key must update, not create") + return nil, nil + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat} + + if err := k.Set(Item{Key: "dev", Data: []byte("new-blob")}); err != nil { + t.Fatalf("Set: %v", err) + } + if updateCalls != 1 { + t.Fatalf("update called %d times, want 1", updateCalls) + } + if gotItemID != "itemaws-vault/dev" { + t.Errorf("update itemID = %q, want itemaws-vault/dev", gotItemID) + } + if gotReq.KeyRotation != 1 || gotReq.LastRevision != 1 { + t.Errorf("update req = %+v, want KeyRotation=1 LastRevision=1", gotReq) + } + + // Update re-encrypts under the existing per-item key (id 1), not a fresh one. + plain, err := protonpass.OpenItemContent(fixtureItemKey(1), gotReq.Content) + if err != nil { + t.Fatalf("open updated content with existing item key: %v", err) + } + if meta, _ := protonpass.ParseItemMetadata(plain); meta.Note != "new-blob" { + t.Fatalf("updated note = %q, want new-blob", meta.Note) + } +} + +func TestProtonPassRemove(t *testing.T) { + fx := buildVaultFixture(t, map[string]string{"aws-vault/dev": "blob"}) + m := readMock(fx) + var gotItemID string + var gotRevision, delCalls int + m.del = func(_ context.Context, _ *protonpass.Session, _ string, itemID string, revision int) error { + delCalls++ + gotItemID, gotRevision = itemID, revision + return nil + } + k := ProtonPassKeyring{Client: *m, ShareID: "target", ItemTitlePrefix: "aws-vault", pat: fx.pat} + + if err := k.Remove("dev"); err != nil { + t.Fatalf("Remove: %v", err) + } + if delCalls != 1 || gotItemID != "itemaws-vault/dev" || gotRevision != 1 { + t.Fatalf("delete called %d times (itemID=%q rev=%d), want 1 (itemaws-vault/dev, 1)", delCalls, gotItemID, gotRevision) + } + + // Removing an unknown key is ErrKeyNotFound and issues no delete. + if err := k.Remove("missing"); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("Remove(missing) err = %v, want ErrKeyNotFound", err) + } + if delCalls != 1 { + t.Fatalf("delete was called for a missing key (%d calls)", delCalls) + } +} + +// vaultFixture is a fully-encrypted Proton Pass vault (one share-key rotation and a +// set of items) so backend tests exercise the real symmetric decryption chain: +// PAT enc-key -> share key -> item key -> content -> protobuf. +type vaultFixture struct { + pat string + shareKeys []protonpass.ShareKey + revisions []protonpass.ItemRevision +} + +// encodeItemProto encodes Item{ metadata=1: Metadata{ name=1, note=2 } } the way a +// real Proton Pass item is laid out (field numbers from item-v1.proto). +func encodeItemProto(name, note string) []byte { + var meta []byte + meta = protowire.AppendTag(meta, 1, protowire.BytesType) + meta = protowire.AppendBytes(meta, []byte(name)) + meta = protowire.AppendTag(meta, 2, protowire.BytesType) + meta = protowire.AppendBytes(meta, []byte(note)) + + var item []byte + item = protowire.AppendTag(item, 1, protowire.BytesType) + item = protowire.AppendBytes(item, meta) + return item +} + +// buildVaultFixture builds the encrypted vault for the given titled notes. The PAT +// "::<key>" half decodes to the AES enc-key that wraps the share key. +func buildVaultFixture(t *testing.T, titledNotes map[string]string) vaultFixture { + t.Helper() + encKey := bytes.Repeat([]byte{0xa7}, 32) + pat := "pst_token::" + base64.RawURLEncoding.EncodeToString(encKey) + shareKeyRaw := bytes.Repeat([]byte{0x5a}, 32) + + var nonceCounter byte + seal := func(key, plain []byte, tag string) string { + nonceCounter++ + nonce := make([]byte, 12) + nonce[0] = nonceCounter + blob, err := protonpass.EncryptAESGCM(key, plain, nonce, tag) + if err != nil { + t.Fatalf("seal %q: %v", tag, err) + } + return base64.StdEncoding.EncodeToString(blob) + } + + fx := vaultFixture{ + pat: pat, + shareKeys: []protonpass.ShareKey{{KeyRotation: 1, Key: seal(encKey, shareKeyRaw, protonpass.TagShareKey)}}, + } + + id := 0 + for title, note := range titledNotes { + id++ + itemKeyRaw := bytes.Repeat([]byte{byte(0x30 + id)}, 32) + fx.revisions = append(fx.revisions, protonpass.ItemRevision{ + ItemID: "item" + title, + Revision: 1, + KeyRotation: 1, + ContentFormatVersion: 6, + ItemKey: seal(shareKeyRaw, itemKeyRaw, protonpass.TagItemKey), + Content: seal(itemKeyRaw, encodeItemProto(title, note), protonpass.TagItemContent), + }) + } + return fx +} + +// newFixtureKeyring wires a fixture into a keyring with the given prefix, recording +// the API call order into calls. +func newFixtureKeyring(fx vaultFixture, prefix string, calls *[]string) ProtonPassKeyring { + record := func(name string) { *calls = append(*calls, name) } + return ProtonPassKeyring{ + Client: mockProtonAPI{ + auth: func(_ context.Context, _ string) (*protonpass.Session, error) { + record("auth") + return &protonpass.Session{UID: "u", AccessToken: "a"}, nil + }, + shares: func(_ context.Context, _ *protonpass.Session) ([]protonpass.Share, error) { + record("shares") + return []protonpass.Share{{ShareID: "target"}}, nil + }, + shareKeys: func(_ context.Context, _ *protonpass.Session, _ string) ([]protonpass.ShareKey, error) { + record("shareKeys") + return fx.shareKeys, nil + }, + items: func(_ context.Context, _ *protonpass.Session, shareID string) ([]protonpass.ItemRevision, error) { + record("items") + if shareID != "target" { + return nil, errors.New("unexpected share id") + } + return fx.revisions, nil + }, + }, + ShareID: "target", + ItemTitlePrefix: prefix, + pat: fx.pat, + } +} + +func TestProtonPassKeyringReadPath(t *testing.T) { + fx := buildVaultFixture(t, map[string]string{ + "aws-vault/dev": `{"AccessKeyID":"AKIADEV"}`, + "aws-vault/prod": `{"AccessKeyID":"AKIAPROD"}`, + "someone-else": "not an aws-vault item", // wrong prefix -> filtered out + }) + var calls []string + k := newFixtureKeyring(fx, ProtonPassDefaultItemTitlePrefix, &calls) + + keys, err := k.Keys() + if err != nil { + t.Fatalf("Keys: %v", err) + } + if want := []string{"dev", "prod"}; !slices.Equal(keys, want) { + t.Fatalf("Keys = %v, want %v (sorted, prefix-stripped, foreign items dropped)", keys, want) + } + wantOrder := []string{"auth", "shares", "shareKeys", "items"} + if !slices.Equal(calls, wantOrder) { + t.Fatalf("call order = %v, want %v", calls, wantOrder) + } + + got, err := k.Get("prod") + if err != nil { + t.Fatalf("Get: %v", err) + } + if string(got.Data) != `{"AccessKeyID":"AKIAPROD"}` || got.Key != "prod" { + t.Fatalf("Get(prod) = %+v", got) + } + + if _, err := k.Get("missing"); !errors.Is(err, ErrKeyNotFound) { + t.Fatalf("Get(missing) err = %v, want ErrKeyNotFound", err) + } +} + +func TestProtonPassKeyringReadPathNoPrefix(t *testing.T) { + // With an empty prefix every item title is a key verbatim. + fx := buildVaultFixture(t, map[string]string{"raw-title": "blob"}) + var calls []string + k := newFixtureKeyring(fx, "", &calls) + + keys, err := k.Keys() + if err != nil { + t.Fatalf("Keys: %v", err) + } + if !slices.Equal(keys, []string{"raw-title"}) { + t.Fatalf("Keys = %v, want [raw-title]", keys) + } + got, err := k.Get("raw-title") + if err != nil || string(got.Data) != "blob" { + t.Fatalf("Get(raw-title) = %+v, %v", got, err) + } +} + +func TestProtonPassOpenContent(t *testing.T) { + shareKey := bytes.Repeat([]byte{0x5a}, 32) + proto := encodeItemProto("title", "blob") + seal := func(key, plain []byte, tag string, n byte) string { + nonce := make([]byte, 12) + nonce[0] = n + b, err := protonpass.EncryptAESGCM(key, plain, nonce, tag) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(b) + } + k := ProtonPassKeyring{} + + // Newer item: a per-item key wraps the content. + itemKey := bytes.Repeat([]byte{0x31}, 32) + withKey := protonpass.ItemRevision{ + ItemKey: seal(shareKey, itemKey, protonpass.TagItemKey, 1), + Content: seal(itemKey, proto, protonpass.TagItemContent, 2), + } + got, gotKey, err := k.openContent(shareKey, withKey) + if err != nil { + t.Fatalf("with item key: %v", err) + } + if meta, _ := protonpass.ParseItemMetadata(got); meta.Name != "title" || meta.Note != "blob" { + t.Fatalf("with item key: %+v", meta) + } + if !bytes.Equal(gotKey, itemKey) { + t.Fatal("with item key: content key must be the per-item key") + } + + // Older item: no ItemKey, content opened with the share key directly. + noKey := protonpass.ItemRevision{Content: seal(shareKey, proto, protonpass.TagItemContent, 3)} + gotOld, gotOldKey, err := k.openContent(shareKey, noKey) + if err != nil { + t.Fatalf("no item key: %v", err) + } + if meta, _ := protonpass.ParseItemMetadata(gotOld); meta.Name != "title" { + t.Fatalf("no item key: %+v", meta) + } + if !bytes.Equal(gotOldKey, shareKey) { + t.Fatal("no item key: content key must be the share key") + } +} + +func TestProtonPassItemTitleRoundTrip(t *testing.T) { + k := ProtonPassKeyring{ItemTitlePrefix: "aws-vault"} + if got := k.itemTitle("dev"); got != "aws-vault/dev" { + t.Errorf("itemTitle = %q", got) + } + if key, ok := k.keyFromTitle("aws-vault/dev"); !ok || key != "dev" { + t.Errorf("keyFromTitle = %q, %v", key, ok) + } + if _, ok := k.keyFromTitle("other/dev"); ok { + t.Error("keyFromTitle must reject a foreign prefix") + } +} + +func TestProtonPassKeyringShareNotAccessible(t *testing.T) { + k := ProtonPassKeyring{ + Client: mockProtonAPI{ + auth: func(_ context.Context, _ string) (*protonpass.Session, error) { + return &protonpass.Session{UID: "u"}, nil + }, + shares: func(_ context.Context, _ *protonpass.Session) ([]protonpass.Share, error) { + return []protonpass.Share{{ShareID: "someone-elses-vault"}}, nil + }, + items: func(_ context.Context, _ *protonpass.Session, _ string) ([]protonpass.ItemRevision, error) { + t.Error("ListItems must not be called when the share is inaccessible") + return nil, nil + }, + }, + ShareID: "target", + pat: "pst_x::AAAA", + } + + if _, err := k.Keys(); !errors.Is(err, ErrProtonPassShareNotAccessible) { + t.Fatalf("Keys err = %v, want ErrProtonPassShareNotAccessible", err) + } +} + +func TestProtonPassKeyringNoPAT(t *testing.T) { + t.Setenv(ProtonPassEnvPAT, "") + k := ProtonPassKeyring{ + Client: mockProtonAPI{auth: func(_ context.Context, _ string) (*protonpass.Session, error) { return nil, nil }}, + ShareID: "target", + } + if _, err := k.Keys(); !errors.Is(err, ErrProtonPassNoPAT) { + t.Fatalf("Keys err = %v, want ErrProtonPassNoPAT", err) + } +} + +func TestProtonPassKeyringGetMetadata(t *testing.T) { + k := ProtonPassKeyring{ShareID: "target"} + if _, err := k.GetMetadata("x"); !errors.Is(err, ErrMetadataNeedsCredentials) { + t.Fatalf("GetMetadata err = %v, want ErrMetadataNeedsCredentials", err) + } +}