package vaultwarden import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strings" "sync" "time" "github.com/Turbootzz/vaultwarden-api/pkg/logger" "github.com/google/uuid" ) // PreloginResponse contains the KDF parameters from the server. type PreloginResponse struct { KDF int `json:"kdf"` KDFIterations int `json:"kdfIterations"` KDFMemory *int `json:"kdfMemory"` KDFParallelism *int `json:"kdfParallelism"` } // TokenResponse is returned by the /identity/connect/token endpoint. type TokenResponse struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` TokenType string `json:"token_type"` RefreshToken string `json:"refresh_token"` Key string `json:"Key"` PrivateKey string `json:"PrivateKey"` } // SyncResponse contains the full vault sync data. type SyncResponse struct { Profile SyncProfile `json:"profile"` Ciphers []SyncCipher `json:"ciphers"` Collections []SyncCollection `json:"collections"` Folders []SyncFolder `json:"folders"` } // SyncProfile contains user profile info. type SyncProfile struct { ID string `json:"id"` Email string `json:"email"` Key string `json:"key"` PrivateKey string `json:"privateKey"` Organizations []SyncOrganization `json:"organizations"` } // SyncOrganization represents an organization the user belongs to. type SyncOrganization struct { ID string `json:"id"` Name string `json:"name"` Key string `json:"key"` } // SyncCollection represents a collection that logins can be assigned to. type SyncCollection struct { ID string `json:"id"` OrganizationID string `json:"organizationId"` Name string `json:"name"` } // SyncFolder represents a folder that logins can be assigned to. type SyncFolder struct { ID string `json:"id"` Name string `json:"name"` } // SyncCipher represents an encrypted vault item from the sync response. type SyncCipher struct { ID string `json:"id"` Type int `json:"type"` OrganizationID *string `json:"organizationId"` CollectionIDs []string `json:"collectionIds"` FolderID *string `json:"folderId"` Name string `json:"name"` Notes *string `json:"notes"` Login *SyncLogin `json:"login"` Card *SyncCard `json:"card"` Identity *SyncIdentity `json:"identity"` Fields []SyncField `json:"fields"` } // SyncLogin contains encrypted login data. type SyncLogin struct { Username *string `json:"username"` Password *string `json:"password"` URI *string `json:"uri"` URIs []struct { URI *string `json:"uri"` } `json:"uris"` } // SyncCard contains encrypted card data. type SyncCard struct { CardholderName *string `json:"cardholderName"` Number *string `json:"number"` Code *string `json:"code"` Brand *string `json:"brand"` ExpMonth *string `json:"expMonth"` ExpYear *string `json:"expYear"` } // SyncIdentity contains encrypted identity data. type SyncIdentity struct { Title *string `json:"title"` FirstName *string `json:"firstName"` MiddleName *string `json:"middleName"` LastName *string `json:"lastName"` Address1 *string `json:"address1"` Address2 *string `json:"address2"` Address3 *string `json:"address3"` City *string `json:"city"` State *string `json:"state"` PostalCode *string `json:"postalCode"` Country *string `json:"country"` Company *string `json:"company"` Email *string `json:"email"` Phone *string `json:"phone"` SSN *string `json:"ssn"` Username *string `json:"username"` PassportNumber *string `json:"passportNumber"` LicenseNumber *string `json:"licenseNumber"` } // SyncField contains encrypted custom field data. type SyncField struct { Name *string `json:"name"` Value *string `json:"value"` Type int `json:"type"` } // Bitwarden cipher types. const ( CipherTypeLogin = 1 CipherTypeSecureNote = 2 CipherTypeCard = 3 CipherTypeIdentity = 4 ) // APIClient communicates directly with the Vaultwarden HTTP API. type APIClient struct { baseURL string email string password string clientID string // Optional: for API key login (bypasses 2FA) clientSecret string // Optional: for API key login (bypasses 2FA) httpClient *http.Client deviceID string mu sync.RWMutex accessToken string refreshToken string tokenExpiry time.Time symKey SymmetricKey } // NewAPIClient creates a new Vaultwarden API client. // clientID and clientSecret are optional — if provided, API key login is used (bypasses 2FA). func NewAPIClient(baseURL, email, password, clientID, clientSecret string) *APIClient { return &APIClient{ baseURL: strings.TrimSuffix(baseURL, "/"), email: email, password: password, clientID: clientID, clientSecret: clientSecret, httpClient: &http.Client{ Timeout: 30 * time.Second, }, deviceID: uuid.New().String(), } } // Authenticate performs the full login flow. // If API key credentials are set, uses client_credentials grant (bypasses 2FA). // Otherwise, uses password grant (requires 2FA to be disabled or handled). func (ac *APIClient) Authenticate() error { // Step 1: Get KDF parameters. prelogin, err := ac.prelogin() if err != nil { return fmt.Errorf("prelogin: %w", err) } logger.Info.Printf("KDF type: %d, iterations: %d", prelogin.KDF, prelogin.KDFIterations) // Step 2: Derive master key (always needed for decryption). masterKey, err := MakeMasterKey(ac.password, ac.email, prelogin.KDF, prelogin.KDFIterations, prelogin.KDFMemory, prelogin.KDFParallelism) if err != nil { return fmt.Errorf("derive master key: %w", err) } // Step 3: Login. var tokenResp *TokenResponse if ac.clientID != "" && ac.clientSecret != "" { // API key login — bypasses 2FA. logger.Info.Println("Using API key authentication (2FA bypass)") tokenResp, err = ac.loginWithAPIKey() } else { // Password login — requires no 2FA or 2FA handling. hashedPassword := HashPassword(ac.password, masterKey) tokenResp, err = ac.loginWithPassword(hashedPassword) } if err != nil { return fmt.Errorf("login: %w", err) } // Step 4: Get the encrypted symmetric key. // API key login doesn't return the Key in the token response, // so we get it from the sync/profile endpoint. encryptedKey := tokenResp.Key if encryptedKey == "" { // Fetch from sync profile. ac.mu.Lock() ac.accessToken = tokenResp.AccessToken ac.refreshToken = tokenResp.RefreshToken ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) ac.mu.Unlock() encryptedKey, err = ac.fetchProfileKey() if err != nil { return fmt.Errorf("fetch profile key: %w", err) } } else { ac.mu.Lock() ac.accessToken = tokenResp.AccessToken ac.refreshToken = tokenResp.RefreshToken ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) ac.mu.Unlock() } // Step 5: Decrypt the symmetric key. symKey, err := DecryptSymmetricKey(encryptedKey, masterKey) if err != nil { return fmt.Errorf("decrypt symmetric key: %w", err) } ac.mu.Lock() ac.symKey = symKey ac.mu.Unlock() logger.Info.Println("Authentication successful") return nil } // RefreshAccessToken uses the refresh token to get a new access token. func (ac *APIClient) RefreshAccessToken() error { ac.mu.RLock() rt := ac.refreshToken ac.mu.RUnlock() if rt == "" { return fmt.Errorf("no refresh token available, re-authentication required") } data := url.Values{ "grant_type": {"refresh_token"}, "refresh_token": {rt}, "client_id": {"web"}, } resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data) if err != nil { return fmt.Errorf("refresh request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(body)) } var tokenResp TokenResponse if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { return fmt.Errorf("decode refresh response: %w", err) } ac.mu.Lock() ac.accessToken = tokenResp.AccessToken if tokenResp.RefreshToken != "" { ac.refreshToken = tokenResp.RefreshToken } ac.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) ac.mu.Unlock() logger.Debug.Println("Token refreshed successfully") return nil } // EnsureValidToken refreshes the access token if it's expired or about to expire. func (ac *APIClient) EnsureValidToken() error { ac.mu.RLock() expiry := ac.tokenExpiry ac.mu.RUnlock() // Refresh 60 seconds before actual expiry. if time.Now().After(expiry.Add(-60 * time.Second)) { logger.Debug.Println("Token expiring soon, refreshing...") if err := ac.RefreshAccessToken(); err != nil { // If refresh fails, try full re-authentication. logger.Warn.Println("Token refresh failed, attempting full re-authentication") return ac.Authenticate() } } return nil } // SyncNameMaps holds decrypted display names keyed by Vaultwarden UUID (from sync). type SyncNameMaps struct { Organizations map[string]string // organization id -> name (decrypted with user symmetric key) Folders map[string]string // folder id -> name (decrypted with user symmetric key) Collections map[string]string // collection id -> name (decrypted with org symmetric key) } func emptySyncNameMaps() SyncNameMaps { return SyncNameMaps{ Organizations: make(map[string]string), Folders: make(map[string]string), Collections: make(map[string]string), } } // decryptVaultLabel decrypts a cipher string using the given keys in order. // decryptVaultLabel decrypts a cipher string using the given keys in order. // The entityKind and entityID are used for tracing during logging. func decryptVaultLabel(raw, entityKind, entityID string, keys ...SymmetricKey) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } for _, k := range keys { if len(k.EncKey) == 0 { continue } out, err := DecryptStr(raw, k) if err == nil && out != "" { return out } } // If not a ciphertext, return as-is // NOTE: This does not mean that the decryption will silently fail and return the raw value if // the decryption fails during MAC verification (that part still throws an error as it should) if _, err := ParseCipherString(raw); err != nil { return raw } logger.Warn.Printf("decryptVaultLabel: could not decrypt ciphertext-form %s name (id=%s)", entityKind, entityID) return "" } // buildSyncNameMaps decrypts organization, folder, and collection names from a sync response. // Organization names are tried with the user symmetric key first, then that org's symmetric key // Folder names use the user key; collection names use the org key. func buildSyncNameMaps(syncResp SyncResponse, userKey SymmetricKey, orgKeys map[string]SymmetricKey) SyncNameMaps { out := emptySyncNameMaps() for _, org := range syncResp.Profile.Organizations { if org.ID == "" { continue } var keys []SymmetricKey keys = append(keys, userKey) if k, ok := orgKeys[org.ID]; ok { keys = append(keys, k) } name := decryptVaultLabel(org.Name, "organization", org.ID, keys...) if name == "" { logger.Debug.Printf("Empty organization display name for org %s (raw len=%d)", org.ID, len(org.Name)) continue } out.Organizations[org.ID] = name } for _, f := range syncResp.Folders { if f.ID == "" { continue } name := decryptVaultLabel(f.Name, "folder", f.ID, userKey) if name == "" { logger.Debug.Printf("Empty folder name for folder %s", f.ID) continue } out.Folders[f.ID] = name } for _, col := range syncResp.Collections { if col.ID == "" || col.OrganizationID == "" { continue } orgKey, ok := orgKeys[col.OrganizationID] if !ok { logger.Debug.Printf("No org key for collection %s (org %s), skipping name decrypt", col.ID, col.OrganizationID) continue } name := decryptVaultLabel(col.Name, "collection", col.ID, orgKey) if name == "" { logger.Debug.Printf("Empty collection name for collection %s", col.ID) continue } out.Collections[col.ID] = name } return out } // LookupIDByName returns an id whose display name equals target (case-insensitive, trimmed). // If several ids share that name, it returns the lexicographically smallest id. func LookupIDByName(idToName map[string]string, target string) (id string, ok bool) { target = strings.TrimSpace(target) if target == "" || len(idToName) == 0 { return "", false } var matches []string for id, n := range idToName { if strings.EqualFold(strings.TrimSpace(n), target) { matches = append(matches, id) } } if len(matches) == 0 { return "", false } sort.Strings(matches) return matches[0], true } // Sync fetches and decrypts all vault items and returns them along with maps of decrypted // organization, folder, and collection names. func (ac *APIClient) Sync() ([]DecryptedItem, SyncNameMaps, error) { if err := ac.EnsureValidToken(); err != nil { return nil, emptySyncNameMaps(), fmt.Errorf("ensure valid token: %w", err) } ac.mu.RLock() token := ac.accessToken key := ac.symKey ac.mu.RUnlock() req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil) if err != nil { return nil, emptySyncNameMaps(), fmt.Errorf("create sync request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) resp, err := ac.httpClient.Do(req) if err != nil { return nil, emptySyncNameMaps(), fmt.Errorf("sync request: %w", err) } if resp.StatusCode == http.StatusUnauthorized { _, _ = io.Copy(io.Discard, resp.Body) if closeErr := resp.Body.Close(); closeErr != nil { logger.Warn.Printf("close sync 401 response body: %v", closeErr) } // Token might be invalid, try to refresh and retry once. if err := ac.RefreshAccessToken(); err != nil { return nil, emptySyncNameMaps(), fmt.Errorf("sync auth failed, refresh failed: %w", err) } ac.mu.RLock() token = ac.accessToken ac.mu.RUnlock() req.Header.Set("Authorization", "Bearer "+token) resp, err = ac.httpClient.Do(req) if err != nil { return nil, emptySyncNameMaps(), fmt.Errorf("sync retry: %w", err) } } defer func() { if err := resp.Body.Close(); err != nil { logger.Warn.Printf("close sync response body: %v", err) } }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, emptySyncNameMaps(), fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body)) } var syncResp SyncResponse if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { return nil, emptySyncNameMaps(), fmt.Errorf("decode sync response: %w", err) } // Decrypt org keys if organizations are present. orgKeys := make(map[string]SymmetricKey) if len(syncResp.Profile.Organizations) > 0 && syncResp.Profile.PrivateKey != "" { privateKey, err := DecryptPrivateKey(syncResp.Profile.PrivateKey, key) if err != nil { logger.Warn.Printf("Failed to decrypt RSA private key, org items will be skipped: %v", err) } else { for _, org := range syncResp.Profile.Organizations { orgKey, err := DecryptOrgKey(org.Key, privateKey) if err != nil { logger.Warn.Printf("Failed to decrypt org key for %s: %v", org.ID, err) continue } orgKeys[org.ID] = orgKey logger.Debug.Printf("Decrypted org key for organization %s", org.ID) } logger.Info.Printf("Decrypted %d organization key(s)", len(orgKeys)) } } // Decrypt all ciphers. items := make([]DecryptedItem, 0, len(syncResp.Ciphers)) for _, c := range syncResp.Ciphers { // Select the correct decryption key. decryptKey := key if c.OrganizationID != nil && *c.OrganizationID != "" { if orgKey, ok := orgKeys[*c.OrganizationID]; ok { decryptKey = orgKey } else { logger.Debug.Printf("No org key for cipher %s (org %s), skipping", c.ID, *c.OrganizationID) continue } } item, err := decryptCipher(c, decryptKey) if err != nil { logger.Debug.Printf("Failed to decrypt cipher %s: %v", c.ID, err) continue } items = append(items, item) } logger.Info.Printf("Synced and decrypted %d vault items", len(items)) nameMaps := buildSyncNameMaps(syncResp, key, orgKeys) logger.Info.Printf( "Synced %d organizations, %d folders, and %d collections", len(syncResp.Profile.Organizations), len(syncResp.Folders), len(syncResp.Collections), ) return items, nameMaps, nil } // DecryptedItem is a decrypted vault item ready for cache lookup. type DecryptedItem struct { ID string Type int Name string Username string Password string Notes string URI string Fields map[string]string OrganizationID string CollectionIDs []string FolderID string } // decryptCipher decrypts a single vault cipher into a DecryptedItem. func decryptCipher(c SyncCipher, key SymmetricKey) (DecryptedItem, error) { item := DecryptedItem{ ID: c.ID, Type: c.Type, Fields: make(map[string]string), } var err error item.Name, err = DecryptStr(c.Name, key) if err != nil { return item, fmt.Errorf("decrypt name: %w", err) } if c.Notes != nil { item.Notes, _ = DecryptStr(*c.Notes, key) } if c.Login != nil { if c.Login.Username != nil { item.Username, _ = DecryptStr(*c.Login.Username, key) } if c.Login.Password != nil { item.Password, _ = DecryptStr(*c.Login.Password, key) } if c.Login.URI != nil { item.URI, _ = DecryptStr(*c.Login.URI, key) } if item.URI == "" && len(c.Login.URIs) > 0 && c.Login.URIs[0].URI != nil { item.URI, _ = DecryptStr(*c.Login.URIs[0].URI, key) } } if c.Card != nil { if c.Card.CardholderName != nil { if v, err := DecryptStr(*c.Card.CardholderName, key); err == nil && v != "" { item.Fields["cardholder_name"] = v } } if c.Card.Number != nil { if v, err := DecryptStr(*c.Card.Number, key); err == nil && v != "" { item.Fields["number"] = v } } if c.Card.Code != nil { if v, err := DecryptStr(*c.Card.Code, key); err == nil && v != "" { item.Fields["code"] = v } } if c.Card.Brand != nil { if v, err := DecryptStr(*c.Card.Brand, key); err == nil && v != "" { item.Fields["brand"] = v } } if c.Card.ExpMonth != nil { if v, err := DecryptStr(*c.Card.ExpMonth, key); err == nil && v != "" { item.Fields["exp_month"] = v } } if c.Card.ExpYear != nil { if v, err := DecryptStr(*c.Card.ExpYear, key); err == nil && v != "" { item.Fields["exp_year"] = v } } } if c.Identity != nil { idFields := map[string]*string{ "title": c.Identity.Title, "first_name": c.Identity.FirstName, "middle_name": c.Identity.MiddleName, "last_name": c.Identity.LastName, "address1": c.Identity.Address1, "address2": c.Identity.Address2, "address3": c.Identity.Address3, "city": c.Identity.City, "state": c.Identity.State, "postal_code": c.Identity.PostalCode, "country": c.Identity.Country, "company": c.Identity.Company, "email": c.Identity.Email, "phone": c.Identity.Phone, "ssn": c.Identity.SSN, "username": c.Identity.Username, "passport_number": c.Identity.PassportNumber, "license_number": c.Identity.LicenseNumber, } for name, ptr := range idFields { if ptr != nil { if v, err := DecryptStr(*ptr, key); err == nil && v != "" { item.Fields[name] = v } } } } for _, f := range c.Fields { var name, value string if f.Name != nil { name, _ = DecryptStr(*f.Name, key) } if f.Value != nil { value, _ = DecryptStr(*f.Value, key) } if name != "" { item.Fields[name] = value } } if c.OrganizationID != nil { item.OrganizationID = strings.TrimSpace(*c.OrganizationID) } if len(c.CollectionIDs) > 0 { item.CollectionIDs = append([]string(nil), c.CollectionIDs...) } if c.FolderID != nil { item.FolderID = strings.TrimSpace(*c.FolderID) } return item, nil } // prelogin fetches KDF parameters for the given email. func (ac *APIClient) prelogin() (*PreloginResponse, error) { body := fmt.Sprintf(`{"email":"%s"}`, ac.email) resp, err := ac.httpClient.Post( ac.baseURL+"/identity/accounts/prelogin", "application/json", strings.NewReader(body), ) if err != nil { return nil, fmt.Errorf("prelogin request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("prelogin failed (HTTP %d): %s", resp.StatusCode, string(respBody)) } var result PreloginResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("decode prelogin: %w", err) } return &result, nil } // loginWithPassword authenticates with email + hashed password (requires no 2FA or 2FA handling). func (ac *APIClient) loginWithPassword(hashedPassword string) (*TokenResponse, error) { data := url.Values{ "grant_type": {"password"}, "username": {ac.email}, "password": {hashedPassword}, "scope": {"api offline_access"}, "client_id": {"web"}, "deviceType": {"14"}, "deviceIdentifier": {ac.deviceID}, "deviceName": {"vaultwarden-api"}, } return ac.doTokenRequest(data) } // loginWithAPIKey authenticates with API key (client_credentials). Bypasses 2FA. func (ac *APIClient) loginWithAPIKey() (*TokenResponse, error) { data := url.Values{ "grant_type": {"client_credentials"}, "client_id": {ac.clientID}, "client_secret": {ac.clientSecret}, "scope": {"api"}, "deviceType": {"14"}, "deviceIdentifier": {ac.deviceID}, "deviceName": {"vaultwarden-api"}, } return ac.doTokenRequest(data) } // doTokenRequest sends a token request and parses the response. func (ac *APIClient) doTokenRequest(data url.Values) (*TokenResponse, error) { resp, err := ac.httpClient.PostForm(ac.baseURL+"/identity/connect/token", data) if err != nil { return nil, fmt.Errorf("token request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(body)) } var tokenResp TokenResponse if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { return nil, fmt.Errorf("decode login response: %w", err) } return &tokenResp, nil } // CipherInput describes a new vault item to create. type CipherInput struct { Type int // 1=Login, 2=SecureNote, 3=Card, 4=Identity Name string Notes string Username string Password string URI string Fields map[string]string // custom fields (login only) FolderID string // optional folder UUID to place the item in // Card (type 3) CardholderName string Brand string // plaintext enum string, NOT encrypted Number string ExpMonth string // plaintext "01".."12" ExpYear string // plaintext "2026" Code string // CVV // Identity (type 4) Title string FirstName string MiddleName string LastName string Address1 string Address2 string Address3 string City string State string PostalCode string Country string Company string Email string Phone string SSN string Passport string License string // Identity also reuses Username field above. } // CreateCipher encrypts and writes a new vault item via the Vaultwarden API. func (ac *APIClient) CreateCipher(in CipherInput) (string, error) { if err := ac.EnsureValidToken(); err != nil { return "", fmt.Errorf("ensure valid token: %w", err) } ac.mu.RLock() token := ac.accessToken key := ac.symKey ac.mu.RUnlock() encName, err := EncryptStr(in.Name, key) if err != nil { return "", fmt.Errorf("encrypt name: %w", err) } cipher := map[string]interface{}{ "type": in.Type, "name": encName, "notes": nil, "login": nil, "card": nil, "identity": nil, "fields": nil, "reprompt": 0, "edit": true, "viewPassword": true, "favorite": false, "folderId": nil, "organizationId": nil, "collectionIds": []string{}, } if in.FolderID != "" { cipher["folderId"] = in.FolderID } if in.Notes != "" { encNotes, e := EncryptStr(in.Notes, key) if e == nil { cipher["notes"] = encNotes } } if in.Type == CipherTypeLogin { login := map[string]interface{}{} if in.Username != "" { u, e := EncryptStr(in.Username, key) if e == nil { login["username"] = u } } if in.Password != "" { pw, e := EncryptStr(in.Password, key) if e == nil { login["password"] = pw } } if in.URI != "" { uri, e := EncryptStr(in.URI, key) if e == nil { login["uris"] = []map[string]interface{}{{"uri": uri, "match": nil}} } } cipher["login"] = login if len(in.Fields) > 0 { fields := make([]map[string]interface{}, 0, len(in.Fields)) for k, v := range in.Fields { fk, e1 := EncryptStr(k, key) fv, e2 := EncryptStr(v, key) if e1 == nil && e2 == nil { fields = append(fields, map[string]interface{}{"name": fk, "value": fv, "type": 0}) } } cipher["fields"] = fields } } else if in.Type == CipherTypeSecureNote { // Vaultwarden expects type=2 data in the `secureNote` sub-object (camelCase serde). if in.Notes != "" { encNotes, e := EncryptStr(in.Notes, key) if e == nil { cipher["secureNote"] = map[string]interface{}{"notes": encNotes} } } } else if in.Type == CipherTypeCard { // Vaultwarden expects type=3 data in the `card` sub-object (camelCase serde). // brand/expMonth/expYear are plaintext enum strings; others are encrypted. card := map[string]interface{}{} if in.CardholderName != "" { if s, e := EncryptStr(in.CardholderName, key); e == nil { card["cardholderName"] = s } } if in.Brand != "" { card["brand"] = in.Brand } if in.Number != "" { if s, e := EncryptStr(in.Number, key); e == nil { card["number"] = s } } if in.ExpMonth != "" { card["expMonth"] = in.ExpMonth } if in.ExpYear != "" { card["expYear"] = in.ExpYear } if in.Code != "" { if s, e := EncryptStr(in.Code, key); e == nil { card["code"] = s } } cipher["card"] = card } else if in.Type == CipherTypeIdentity { // Vaultwarden expects type=4 data in the `identity` sub-object (camelCase serde). idn := map[string]interface{}{} enc := func(field, val string) { if val == "" { return } if s, e := EncryptStr(val, key); e == nil { idn[field] = s } } enc("title", in.Title) enc("firstName", in.FirstName) enc("middleName", in.MiddleName) enc("lastName", in.LastName) enc("address1", in.Address1) enc("address2", in.Address2) enc("address3", in.Address3) enc("city", in.City) enc("state", in.State) enc("postalCode", in.PostalCode) enc("country", in.Country) enc("company", in.Company) enc("email", in.Email) enc("phone", in.Phone) enc("username", in.Username) enc("ssn", in.SSN) enc("passportNumber", in.Passport) enc("licenseNumber", in.License) cipher["identity"] = idn } else { return "", fmt.Errorf("cipher type %d write not supported yet", in.Type) } body, err := json.Marshal(cipher) if err != nil { return "", fmt.Errorf("marshal cipher: %w", err) } req, err := http.NewRequest("POST", ac.baseURL+"/api/ciphers", bytes.NewReader(body)) if err != nil { return "", fmt.Errorf("create request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Bitwarden-Client-Version", "2024.1.0") req.Header.Set("User-Agent", "Bitwarden/v2024.1.0") req.Header.Set("Device-Type", "8") req.Header.Set("Accept", "application/json") resp, err := ac.httpClient.Do(req) if err != nil { return "", fmt.Errorf("create cipher request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("create cipher failed (HTTP %d): %s", resp.StatusCode, string(b)) } var out struct { ID string `json:"Id"` Name string `json:"Name"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", fmt.Errorf("decode create response: %w", err) } if out.ID == "" { return "", fmt.Errorf("create cipher returned empty id") } return out.ID, nil } // DeleteCipher removes an existing vault item by its cipher ID. func (ac *APIClient) DeleteCipher(id string) error { if err := ac.EnsureValidToken(); err != nil { return fmt.Errorf("ensure valid token: %w", err) } ac.mu.RLock() token := ac.accessToken ac.mu.RUnlock() req, err := http.NewRequest("DELETE", ac.baseURL+"/api/ciphers/"+id, nil) if err != nil { return fmt.Errorf("delete request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Bitwarden-Client-Version", "2024.1.0") req.Header.Set("User-Agent", "Bitwarden/v2024.1.0") req.Header.Set("Device-Type", "8") req.Header.Set("Accept", "application/json") resp, err := ac.httpClient.Do(req) if err != nil { return fmt.Errorf("delete cipher request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("delete cipher failed (HTTP %d): %s", resp.StatusCode, string(b)) } return nil } // UpdateCipher edits an existing vault item by its cipher ID. It re-encrypts // the supplied fields and sends a PUT to /api/ciphers/{id} (same payload shape // as CreateCipher). Fields left empty in CipherInput keep their existing values // ONLY when re-fetched; for simplicity callers should supply the full desired state. func (ac *APIClient) UpdateCipher(id string, in CipherInput) error { if err := ac.EnsureValidToken(); err != nil { return fmt.Errorf("ensure valid token: %w", err) } ac.mu.RLock() token := ac.accessToken key := ac.symKey ac.mu.RUnlock() encName, err := EncryptStr(in.Name, key) if err != nil { return fmt.Errorf("encrypt name: %w", err) } cipher := map[string]interface{}{ "type": in.Type, "name": encName, "notes": nil, "login": nil, "card": nil, "identity": nil, "fields": nil, "reprompt": 0, "edit": true, "viewPassword": true, "favorite": false, "folderId": nil, "organizationId": nil, "collectionIds": []string{}, } if in.FolderID != "" { cipher["folderId"] = in.FolderID } if in.Notes != "" { if encNotes, e := EncryptStr(in.Notes, key); e == nil { cipher["notes"] = encNotes } } if in.Type == CipherTypeLogin { login := map[string]interface{}{} if in.Username != "" { if u, e := EncryptStr(in.Username, key); e == nil { login["username"] = u } } if in.Password != "" { if pw, e := EncryptStr(in.Password, key); e == nil { login["password"] = pw } } if in.URI != "" { if uri, e := EncryptStr(in.URI, key); e == nil { login["uris"] = []map[string]interface{}{{"uri": uri, "match": nil}} } } cipher["login"] = login if len(in.Fields) > 0 { fields := make([]map[string]interface{}, 0, len(in.Fields)) for k, v := range in.Fields { fk, e1 := EncryptStr(k, key) fv, e2 := EncryptStr(v, key) if e1 == nil && e2 == nil { fields = append(fields, map[string]interface{}{"name": fk, "value": fv, "type": 0}) } } cipher["fields"] = fields } } else if in.Type == CipherTypeSecureNote { if in.Notes != "" { if encNotes, e := EncryptStr(in.Notes, key); e == nil { cipher["secureNote"] = map[string]interface{}{"notes": encNotes} } } } else if in.Type == CipherTypeCard { card := map[string]interface{}{} if in.CardholderName != "" { if s, e := EncryptStr(in.CardholderName, key); e == nil { card["cardholderName"] = s } } if in.Brand != "" { card["brand"] = in.Brand } if in.Number != "" { if s, e := EncryptStr(in.Number, key); e == nil { card["number"] = s } } if in.ExpMonth != "" { card["expMonth"] = in.ExpMonth } if in.ExpYear != "" { card["expYear"] = in.ExpYear } if in.Code != "" { if s, e := EncryptStr(in.Code, key); e == nil { card["code"] = s } } cipher["card"] = card } else if in.Type == CipherTypeIdentity { idn := map[string]interface{}{} enc := func(field, val string) { if val == "" { return } if s, e := EncryptStr(val, key); e == nil { idn[field] = s } } enc("title", in.Title) enc("firstName", in.FirstName) enc("middleName", in.MiddleName) enc("lastName", in.LastName) enc("address1", in.Address1) enc("address2", in.Address2) enc("address3", in.Address3) enc("city", in.City) enc("state", in.State) enc("postalCode", in.PostalCode) enc("country", in.Country) enc("company", in.Company) enc("email", in.Email) enc("phone", in.Phone) enc("username", in.Username) enc("ssn", in.SSN) enc("passportNumber", in.Passport) enc("licenseNumber", in.License) cipher["identity"] = idn } else { return fmt.Errorf("cipher type %d write not supported yet", in.Type) } body, err := json.Marshal(cipher) if err != nil { return fmt.Errorf("marshal cipher: %w", err) } req, err := http.NewRequest("PUT", ac.baseURL+"/api/ciphers/"+id, bytes.NewReader(body)) if err != nil { return fmt.Errorf("update request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req.Header.Set("Bitwarden-Client-Version", "2024.1.0") req.Header.Set("User-Agent", "Bitwarden/v2024.1.0") req.Header.Set("Device-Type", "8") req.Header.Set("Accept", "application/json") resp, err := ac.httpClient.Do(req) if err != nil { return fmt.Errorf("update cipher request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("update cipher failed (HTTP %d): %s", resp.StatusCode, string(b)) } return nil } // fetchProfileKey gets the encrypted symmetric key from the user's profile. // Used when API key login doesn't return the Key in the token response. func (ac *APIClient) fetchProfileKey() (string, error) { ac.mu.RLock() token := ac.accessToken ac.mu.RUnlock() req, err := http.NewRequest("GET", ac.baseURL+"/api/sync", nil) if err != nil { return "", fmt.Errorf("create sync request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) resp, err := ac.httpClient.Do(req) if err != nil { return "", fmt.Errorf("sync request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("sync failed (HTTP %d): %s", resp.StatusCode, string(body)) } var syncResp SyncResponse if err := json.NewDecoder(resp.Body).Decode(&syncResp); err != nil { return "", fmt.Errorf("decode sync: %w", err) } if syncResp.Profile.Key == "" { return "", fmt.Errorf("profile key is empty") } return syncResp.Profile.Key, nil }