Vaultwarden post_ciphers expects type=2 data in 'secureNote' field (camelCase serde), not 'notes'/'secure_note'. Verified end-to-end: SecureNote create + read works; Login write unchanged.
926 lines
27 KiB
Go
926 lines
27 KiB
Go
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)
|
|
}
|
|
|
|
// 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.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 {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|