| 1 | package bitwarden |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "sync" |
| 9 | "time" |
| 10 | |
| 11 | "sourcecraft.dev/bigbes/go-bitwarden/crypto" |
| 12 | "sourcecraft.dev/bigbes/go-bitwarden/internal/api" |
| 13 | ) |
| 14 | |
| 15 | // DirectClientOption configures the DirectClient using the functional options pattern. |
| 16 | type DirectClientOption func(*DirectClient) |
| 17 | |
| 18 | // WithDirectHTTPClient sets a custom http.Client for direct server communication. |
| 19 | // Use this to configure timeouts, proxies, or custom transport. |
| 20 | func WithDirectHTTPClient(hc *http.Client) DirectClientOption { |
| 21 | return func(dc *DirectClient) { |
| 22 | dc.httpClient = hc |
| 23 | } |
| 24 | } |
| 25 | |
| 26 | // WithDeviceInfo sets device information for token requests. |
| 27 | // This information appears in the Bitwarden account's device list. |
| 28 | // If not provided, default device info will be used. |
| 29 | func WithDeviceInfo(name, identifier string, deviceType int) DirectClientOption { |
| 30 | return func(dc *DirectClient) { |
| 31 | dc.device = api.DeviceInfo{Name: name, Identifier: identifier, Type: deviceType} |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | // WithIdentityURL sets a separate identity URL for authentication. |
| 36 | // This is typically used for Bitwarden cloud where identity and API URLs differ. |
| 37 | // For self-hosted Vaultwarden, this is usually not needed. |
| 38 | func WithIdentityURL(url string) DirectClientOption { |
| 39 | return func(dc *DirectClient) { |
| 40 | dc.identityURL = url |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // WithTwoFactorProvider sets a callback function for handling two-factor authentication. |
| 45 | // The callback receives the available 2FA providers and should return the selected |
| 46 | // provider type and code. This is called during Login/Unlock if 2FA is enabled. |
| 47 | func WithTwoFactorProvider(p api.TwoFactorProvider) DirectClientOption { |
| 48 | return func(dc *DirectClient) { |
| 49 | dc.twoFA = p |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | // WithAPIKey configures API key authentication using the client_credentials grant. |
| 54 | // API keys can be generated in the Bitwarden web vault and bypass 2FA requirements, |
| 55 | // but the master password is still required for vault decryption. |
| 56 | // The clientID is typically in the format "user.<user_uuid>". |
| 57 | // |
| 58 | // Example: |
| 59 | // |
| 60 | // client := bitwarden.NewDirectClient( |
| 61 | // serverURL, email, masterPassword, |
| 62 | // bitwarden.WithAPIKey("user.abc-123", "secret-key"), |
| 63 | // ) |
| 64 | func WithAPIKey(clientID, clientSecret string) DirectClientOption { |
| 65 | return func(dc *DirectClient) { |
| 66 | dc.apiKeyClientID = clientID |
| 67 | dc.apiKeyClientSecret = clientSecret |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | // DirectClient communicates directly with a Bitwarden/Vaultwarden server, |
| 72 | // handling authentication and end-to-end encryption entirely in Go without |
| 73 | // requiring the Bitwarden CLI. This is useful for applications that need |
| 74 | // to integrate Bitwarden functionality without external dependencies. |
| 75 | // |
| 76 | // DirectClient implements the VaultClient interface and provides full access |
| 77 | // to vault operations including items, folders, collections, organizations, |
| 78 | // Send, and attachments. |
| 79 | // |
| 80 | // Security considerations: |
| 81 | // - The master password is kept in memory for re-authentication |
| 82 | // - Encryption keys are derived from the master password using PBKDF2 or Argon2id |
| 83 | // - All vault data is encrypted/decrypted locally |
| 84 | // - Keys can be cleared from memory using Lock() |
| 85 | type DirectClient struct { |
| 86 | serverURL string |
| 87 | identityURL string |
| 88 | httpClient *http.Client |
| 89 | device api.DeviceInfo |
| 90 | twoFA api.TwoFactorProvider |
| 91 | apiKeyClientID string |
| 92 | apiKeyClientSecret string |
| 93 | |
| 94 | mu sync.RWMutex |
| 95 | transport *api.Transport |
| 96 | auth *api.AuthClient |
| 97 | keyChain *crypto.KeyChain |
| 98 | profile *api.SyncProfile |
| 99 | email string |
| 100 | password string |
| 101 | cache vaultCache |
| 102 | notifications *api.NotificationsClient |
| 103 | notifyHandler NotificationHandler |
| 104 | |
| 105 | items *DirectItemsService |
| 106 | folders *DirectFoldersService |
| 107 | collections *DirectCollectionsService |
| 108 | orgCollections *DirectOrgCollectionsService |
| 109 | organizations *DirectOrganizationsService |
| 110 | orgMembers *DirectOrgMembersService |
| 111 | send *DirectSendService |
| 112 | attachments *DirectAttachmentsService |
| 113 | } |
| 114 | |
| 115 | // NewDirectClient creates a new client that communicates directly with the |
| 116 | // Bitwarden/Vaultwarden server without requiring the CLI. The client handles |
| 117 | // authentication and end-to-end encryption automatically. |
| 118 | // |
| 119 | // Parameters: |
| 120 | // - serverURL: The base URL of the Bitwarden/Vaultwarden server (e.g., "https://vault.bitwarden.com") |
| 121 | // - email: The user's email address for authentication |
| 122 | // - password: The master password for vault decryption |
| 123 | // - opts: Optional configuration using With* functions |
| 124 | // |
| 125 | // The client is created in a locked state. Call Login() first, then Unlock() |
| 126 | // or call Unlock() directly which handles both authentication and key derivation. |
| 127 | // |
| 128 | // Example: |
| 129 | // |
| 130 | // client := bitwarden.NewDirectClient( |
| 131 | // "https://vault.bitwarden.com", |
| 132 | // "user@example.com", |
| 133 | // "master-password", |
| 134 | // bitwarden.WithTwoFactorProvider(my2FAHandler), |
| 135 | // ) |
| 136 | // err := client.Unlock(context.Background(), "master-password") |
| 137 | // client.Sync(context.Background()) |
| 138 | func NewDirectClient(serverURL, email, password string, opts ...DirectClientOption) *DirectClient { |
| 139 | dc := &DirectClient{ |
| 140 | serverURL: serverURL, |
| 141 | email: email, |
| 142 | password: password, |
| 143 | device: api.DefaultDeviceInfo(), |
| 144 | httpClient: &http.Client{ |
| 145 | Timeout: 30 * time.Second, |
| 146 | }, |
| 147 | } |
| 148 | |
| 149 | for _, opt := range opts { |
| 150 | opt(dc) |
| 151 | } |
| 152 | |
| 153 | dc.transport = api.NewTransport(serverURL, dc.httpClient) |
| 154 | dc.auth = api.NewAuthClient(dc.transport, dc.device, dc.identityURL) |
| 155 | if dc.twoFA != nil { |
| 156 | dc.auth.SetTwoFactorProvider(dc.twoFA) |
| 157 | } |
| 158 | |
| 159 | dc.items = &DirectItemsService{dc: dc} |
| 160 | dc.folders = &DirectFoldersService{dc: dc} |
| 161 | dc.collections = &DirectCollectionsService{dc: dc} |
| 162 | dc.orgCollections = &DirectOrgCollectionsService{dc: dc} |
| 163 | dc.organizations = &DirectOrganizationsService{dc: dc} |
| 164 | dc.orgMembers = &DirectOrgMembersService{dc: dc} |
| 165 | dc.send = &DirectSendService{dc: dc} |
| 166 | dc.attachments = &DirectAttachmentsService{dc: dc} |
| 167 | |
| 168 | return dc |
| 169 | } |
| 170 | |
| 171 | // Login authenticates with the Bitwarden server using the configured credentials. |
| 172 | // This must be called before other operations (or use Unlock which calls Login internally). |
| 173 | // |
| 174 | // If an API key was configured via WithAPIKey, it uses client_credentials grant |
| 175 | // which bypasses 2FA requirements. Otherwise, it uses the standard password grant |
| 176 | // which may trigger the 2FA callback if two-factor is enabled on the account. |
| 177 | // |
| 178 | // After successful login, the encryption keys are available but the vault data |
| 179 | // still needs to be synced using Sync(). |
| 180 | func (dc *DirectClient) Login(ctx context.Context) error { |
| 181 | var kc *crypto.KeyChain |
| 182 | var err error |
| 183 | |
| 184 | if dc.apiKeyClientID != "" { |
| 185 | _, kc, err = dc.auth.LoginWithAPIKey(ctx, dc.apiKeyClientID, dc.apiKeyClientSecret, dc.email, dc.password) |
| 186 | } else { |
| 187 | _, kc, err = dc.auth.Login(ctx, dc.email, dc.password) |
| 188 | } |
| 189 | if err != nil { |
| 190 | return fmt.Errorf("login: %w", err) |
| 191 | } |
| 192 | |
| 193 | dc.mu.Lock() |
| 194 | dc.keyChain = kc |
| 195 | dc.mu.Unlock() |
| 196 | |
| 197 | return nil |
| 198 | } |
| 199 | |
| 200 | // Register creates a new Bitwarden account on the server. |
| 201 | // This is typically only used for testing or automated account provisioning. |
| 202 | // The cfg parameter specifies the key derivation function configuration (PBKDF2 or Argon2id). |
| 203 | func (dc *DirectClient) Register(ctx context.Context, name string, cfg crypto.DeriveKeyConfig) error { |
| 204 | return dc.auth.Register(ctx, dc.email, dc.password, name, cfg) |
| 205 | } |
| 206 | |
| 207 | // Status returns the current client status including lock state, user information, |
| 208 | // and server URL. This does not require the vault to be unlocked. |
| 209 | func (dc *DirectClient) Status(ctx context.Context) (*Status, error) { |
| 210 | dc.mu.RLock() |
| 211 | kc := dc.keyChain |
| 212 | profile := dc.profile |
| 213 | dc.mu.RUnlock() |
| 214 | |
| 215 | s := &Status{ |
| 216 | ServerURL: dc.serverURL, |
| 217 | } |
| 218 | |
| 219 | if profile != nil { |
| 220 | s.UserEmail = profile.Email |
| 221 | s.UserID = profile.ID |
| 222 | } |
| 223 | |
| 224 | if kc != nil { |
| 225 | s.Status = "unlocked" |
| 226 | } else { |
| 227 | s.Status = "locked" |
| 228 | } |
| 229 | |
| 230 | return s, nil |
| 231 | } |
| 232 | |
| 233 | // Sync downloads the vault data from the server and decrypts it locally. |
| 234 | // The vault must be unlocked before calling Sync. After sync, the decrypted |
| 235 | // data is available through the various service APIs (Items, Folders, etc.). |
| 236 | // |
| 237 | // Sync also loads organization keys and populates the local cache for faster access. |
| 238 | func (dc *DirectClient) Sync(ctx context.Context) error { |
| 239 | kc := dc.getKeyChain() |
| 240 | if kc == nil { |
| 241 | return &VaultLockedError{} |
| 242 | } |
| 243 | |
| 244 | data, err := dc.transport.Get(ctx, "/api/sync", nil) |
| 245 | if err != nil { |
| 246 | return fmt.Errorf("sync: %w", err) |
| 247 | } |
| 248 | |
| 249 | var syncResp api.SyncResponse |
| 250 | if err := json.Unmarshal(data, &syncResp); err != nil { |
| 251 | return fmt.Errorf("parse sync response: %w", err) |
| 252 | } |
| 253 | |
| 254 | // Load org keys |
| 255 | for _, org := range syncResp.Profile.Organizations { |
| 256 | if org.Key != "" { |
| 257 | if err := kc.AddOrgKey(org.ID, org.Key); err != nil { |
| 258 | return fmt.Errorf("load org key %s: %w", org.ID, err) |
| 259 | } |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | dc.mu.Lock() |
| 264 | dc.profile = &syncResp.Profile |
| 265 | dc.mu.Unlock() |
| 266 | |
| 267 | // Decrypt and cache vault data |
| 268 | items := make([]Item, 0, len(syncResp.Ciphers)) |
| 269 | for _, raw := range syncResp.Ciphers { |
| 270 | item, err := decryptCipher(raw, kc) |
| 271 | if err != nil { |
| 272 | continue |
| 273 | } |
| 274 | items = append(items, *item) |
| 275 | } |
| 276 | |
| 277 | folders := make([]Folder, 0, len(syncResp.Folders)) |
| 278 | for _, raw := range syncResp.Folders { |
| 279 | f, err := decryptFolder(raw, kc) |
| 280 | if err != nil { |
| 281 | continue |
| 282 | } |
| 283 | folders = append(folders, *f) |
| 284 | } |
| 285 | |
| 286 | sends := make([]Send, 0, len(syncResp.Sends)) |
| 287 | for _, raw := range syncResp.Sends { |
| 288 | s, err := decryptSend(raw, kc) |
| 289 | if err != nil { |
| 290 | continue |
| 291 | } |
| 292 | sends = append(sends, *s) |
| 293 | } |
| 294 | |
| 295 | dc.cache.populate(items, folders, sends) |
| 296 | |
| 297 | return nil |
| 298 | } |
| 299 | |
| 300 | // Lock clears the encryption keys and cached vault data from memory. |
| 301 | // If real-time notifications are running, they are stopped. |
| 302 | // After locking, Unlock must be called before accessing vault data again. |
| 303 | func (dc *DirectClient) Lock(ctx context.Context) error { |
| 304 | _ = dc.StopNotifications() |
| 305 | dc.mu.Lock() |
| 306 | if dc.keyChain != nil { |
| 307 | dc.keyChain.Clear() |
| 308 | dc.keyChain = nil |
| 309 | } |
| 310 | dc.mu.Unlock() |
| 311 | dc.cache.invalidate() |
| 312 | return nil |
| 313 | } |
| 314 | |
| 315 | // Unlock re-authenticates with the server and re-derives the encryption keys |
| 316 | // from the master password. This combines Login and key derivation into one step. |
| 317 | // |
| 318 | // If 2FA is enabled, the configured TwoFactorProvider callback will be invoked. |
| 319 | // After unlock, call Sync to download and decrypt the vault data. |
| 320 | func (dc *DirectClient) Unlock(ctx context.Context, password string) error { |
| 321 | // Get the protected keys from the server via a fresh login. |
| 322 | _, kc, err := dc.auth.Login(ctx, dc.email, password) |
| 323 | if err != nil { |
| 324 | return fmt.Errorf("unlock: %w", err) |
| 325 | } |
| 326 | |
| 327 | dc.mu.Lock() |
| 328 | dc.keyChain = kc |
| 329 | dc.password = password |
| 330 | dc.mu.Unlock() |
| 331 | |
| 332 | return nil |
| 333 | } |
| 334 | |
| 335 | // Generate generates a password or passphrase locally using cryptographically |
| 336 | // secure random number generation. This does not require server communication. |
| 337 | // Use GenerateOptions to configure the password complexity or passphrase settings. |
| 338 | func (dc *DirectClient) Generate(ctx context.Context, opts GenerateOptions) (string, error) { |
| 339 | return generatePassword(opts) |
| 340 | } |
| 341 | |
| 342 | // Items returns the vault items (ciphers) service for CRUD operations on vault items. |
| 343 | func (dc *DirectClient) Items() ItemsAPI { return dc.items } |
| 344 | |
| 345 | // Folders returns the folders service for organizing vault items. |
| 346 | func (dc *DirectClient) Folders() FoldersAPI { return dc.folders } |
| 347 | |
| 348 | // Collections returns the collections service for accessing user collections. |
| 349 | func (dc *DirectClient) Collections() CollectionsAPI { return dc.collections } |
| 350 | |
| 351 | // OrgCollections returns the organization collections service for managing org collections. |
| 352 | func (dc *DirectClient) OrgCollections() OrgCollectionsAPI { return dc.orgCollections } |
| 353 | |
| 354 | // Organizations returns the organizations service for listing user organizations. |
| 355 | func (dc *DirectClient) Organizations() OrganizationsAPI { return dc.organizations } |
| 356 | |
| 357 | // OrgMembers returns the organization members service for member management. |
| 358 | func (dc *DirectClient) OrgMembers() OrgMembersAPI { return dc.orgMembers } |
| 359 | |
| 360 | // Send returns the Bitwarden Send service for creating secure shares. |
| 361 | func (dc *DirectClient) Send() SendAPI { return dc.send } |
| 362 | |
| 363 | // Attachments returns the attachments service for file attachment operations. |
| 364 | func (dc *DirectClient) Attachments() AttachmentsAPI { return dc.attachments } |
| 365 | |
| 366 | // GetAPIKey retrieves an API key for the current user. The client must be |
| 367 | // logged in (have a valid access token). The API key can be used for |
| 368 | // authenticating without 2FA in automated environments. |
| 369 | // |
| 370 | // Returns: |
| 371 | // - clientID: The API key identifier (typically "user.<uuid>") |
| 372 | // - clientSecret: The API key secret |
| 373 | func (dc *DirectClient) GetAPIKey(ctx context.Context) (clientID, clientSecret string, err error) { |
| 374 | return dc.auth.GetAPIKey(ctx, dc.email, dc.password) |
| 375 | } |
| 376 | |
| 377 | // ServerURL returns the configured Bitwarden/Vaultwarden server URL. |
| 378 | func (dc *DirectClient) ServerURL() string { |
| 379 | return dc.serverURL |
| 380 | } |
| 381 | |
| 382 | func (dc *DirectClient) getKeyChain() *crypto.KeyChain { |
| 383 | dc.mu.RLock() |
| 384 | defer dc.mu.RUnlock() |
| 385 | return dc.keyChain |
| 386 | } |
| 387 | |
| 388 | // Verify DirectClient satisfies VaultClient at compile time. |
| 389 | var _ VaultClient = (*DirectClient)(nil) |
| 390 | |