| 1 | package api |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/base64" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "net/url" |
| 9 | "strconv" |
| 10 | |
| 11 | "sourcecraft.dev/bigbes/go-bitwarden/crypto" |
| 12 | ) |
| 13 | |
| 14 | // DeviceInfo describes the client device for token requests. |
| 15 | type DeviceInfo struct { |
| 16 | Name string |
| 17 | Identifier string |
| 18 | Type int // 14 = CLI/SDK |
| 19 | } |
| 20 | |
| 21 | // DefaultDeviceInfo returns sensible defaults for a Go SDK client. |
| 22 | func DefaultDeviceInfo() DeviceInfo { |
| 23 | return DeviceInfo{ |
| 24 | Name: "go-bitwarden", |
| 25 | Identifier: "go-bitwarden-sdk", |
| 26 | Type: 14, |
| 27 | } |
| 28 | } |
| 29 | |
| 30 | // TwoFactorProvider is called when the server requires 2FA during login. |
| 31 | type TwoFactorProvider interface { |
| 32 | // ProvideTwoFactor is called with the available 2FA provider types and their params. |
| 33 | // It should return the chosen provider type, the token/code, and whether to remember. |
| 34 | ProvideTwoFactor(ctx context.Context, providers map[string]map[string]any) (providerType int, token string, remember bool, err error) |
| 35 | } |
| 36 | |
| 37 | // AuthClient handles authentication against the Bitwarden/Vaultwarden identity API. |
| 38 | type AuthClient struct { |
| 39 | transport *Transport |
| 40 | device DeviceInfo |
| 41 | twoFA TwoFactorProvider |
| 42 | |
| 43 | // identityURL is the base URL for identity endpoints. |
| 44 | // For Vaultwarden/self-hosted, this is the same as the API base URL. |
| 45 | // For Bitwarden cloud, this would be https://identity.bitwarden.com. |
| 46 | identityURL string |
| 47 | } |
| 48 | |
| 49 | // NewAuthClient creates a new authentication client. |
| 50 | func NewAuthClient(transport *Transport, device DeviceInfo, identityURL string) *AuthClient { |
| 51 | if identityURL == "" { |
| 52 | identityURL = transport.BaseURL() |
| 53 | } |
| 54 | return &AuthClient{ |
| 55 | transport: transport, |
| 56 | device: device, |
| 57 | identityURL: identityURL, |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | // SetTwoFactorProvider sets the 2FA callback. |
| 62 | func (a *AuthClient) SetTwoFactorProvider(p TwoFactorProvider) { |
| 63 | a.twoFA = p |
| 64 | } |
| 65 | |
| 66 | // Prelogin retrieves KDF parameters for the given email. |
| 67 | func (a *AuthClient) Prelogin(ctx context.Context, email string) (*PreloginResponse, error) { |
| 68 | data, err := a.transport.PostNoAuth(ctx, "/identity/accounts/prelogin", PreloginRequest{Email: email}) |
| 69 | if err != nil { |
| 70 | return nil, fmt.Errorf("prelogin: %w", err) |
| 71 | } |
| 72 | |
| 73 | var resp PreloginResponse |
| 74 | if err := json.Unmarshal(data, &resp); err != nil { |
| 75 | return nil, fmt.Errorf("parse prelogin response: %w", err) |
| 76 | } |
| 77 | return &resp, nil |
| 78 | } |
| 79 | |
| 80 | // Login authenticates with email and master password, returning the token response. |
| 81 | // It also sets up the transport with the received tokens and configures token refresh. |
| 82 | func (a *AuthClient) Login(ctx context.Context, email, password string) (*TokenResponse, *crypto.KeyChain, error) { |
| 83 | // 1. Prelogin to get KDF params. |
| 84 | prelogin, err := a.Prelogin(ctx, email) |
| 85 | if err != nil { |
| 86 | return nil, nil, err |
| 87 | } |
| 88 | |
| 89 | cfg := crypto.DeriveKeyConfig{ |
| 90 | KdfType: crypto.KdfType(prelogin.Kdf), |
| 91 | KdfIterations: prelogin.KdfIterations, |
| 92 | KdfMemory: prelogin.KdfMemory, |
| 93 | KdfParallelism: prelogin.KdfParallelism, |
| 94 | } |
| 95 | |
| 96 | // 2. Derive master key and hash. |
| 97 | masterKey, err := crypto.DeriveMasterKey(password, email, cfg) |
| 98 | if err != nil { |
| 99 | return nil, nil, fmt.Errorf("derive master key: %w", err) |
| 100 | } |
| 101 | |
| 102 | masterPWHash := crypto.HashMasterPassword(masterKey, password) |
| 103 | masterPWHashB64 := base64.StdEncoding.EncodeToString(masterPWHash) |
| 104 | |
| 105 | // 3. Request token. |
| 106 | tokenResp, err := a.requestToken(ctx, email, masterPWHashB64, nil) |
| 107 | if err != nil { |
| 108 | return nil, nil, err |
| 109 | } |
| 110 | |
| 111 | // 4. Handle 2FA if needed. |
| 112 | if tokenResp.TwoFactorProviders2 != nil && len(tokenResp.TwoFactorProviders2) > 0 { |
| 113 | if a.twoFA == nil { |
| 114 | return nil, nil, fmt.Errorf("server requires 2FA but no TwoFactorProvider configured") |
| 115 | } |
| 116 | |
| 117 | providerType, token, remember, err := a.twoFA.ProvideTwoFactor(ctx, tokenResp.TwoFactorProviders2) |
| 118 | if err != nil { |
| 119 | return nil, nil, fmt.Errorf("2FA provider: %w", err) |
| 120 | } |
| 121 | |
| 122 | twoFactorParams := &twoFactorParams{ |
| 123 | Provider: providerType, |
| 124 | Token: token, |
| 125 | Remember: remember, |
| 126 | } |
| 127 | |
| 128 | tokenResp, err = a.requestToken(ctx, email, masterPWHashB64, twoFactorParams) |
| 129 | if err != nil { |
| 130 | return nil, nil, fmt.Errorf("login with 2FA: %w", err) |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // 5. Set tokens on transport. |
| 135 | a.transport.SetTokens(tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.ExpiresIn) |
| 136 | a.transport.SetRefreshFunc(a.doRefreshToken) |
| 137 | |
| 138 | // 6. Build KeyChain. |
| 139 | keyChain, err := crypto.NewKeyChain(email, password, cfg, tokenResp.Key, tokenResp.PrivateKey) |
| 140 | if err != nil { |
| 141 | return nil, nil, fmt.Errorf("build keychain: %w", err) |
| 142 | } |
| 143 | |
| 144 | return tokenResp, keyChain, nil |
| 145 | } |
| 146 | |
| 147 | // RefreshToken refreshes the access token using the stored refresh token. |
| 148 | func (a *AuthClient) RefreshToken(ctx context.Context) error { |
| 149 | _, err := a.transport.AccessToken(ctx) // triggers refresh if needed |
| 150 | return err |
| 151 | } |
| 152 | |
| 153 | // Register creates a new account on the server. |
| 154 | func (a *AuthClient) Register(ctx context.Context, email, password, name string, cfg crypto.DeriveKeyConfig) error { |
| 155 | masterKey, err := crypto.DeriveMasterKey(password, email, cfg) |
| 156 | if err != nil { |
| 157 | return fmt.Errorf("derive master key: %w", err) |
| 158 | } |
| 159 | |
| 160 | masterPWHash := crypto.HashMasterPassword(masterKey, password) |
| 161 | masterPWHashB64 := base64.StdEncoding.EncodeToString(masterPWHash) |
| 162 | |
| 163 | encKey, macKey := crypto.StretchMasterKey(masterKey) |
| 164 | stretchedBytes := make([]byte, 64) |
| 165 | copy(stretchedBytes[:32], encKey) |
| 166 | copy(stretchedBytes[32:], macKey) |
| 167 | stretchedKey, err := crypto.NewSymmetricKey(stretchedBytes) |
| 168 | if err != nil { |
| 169 | return fmt.Errorf("create stretched key: %w", err) |
| 170 | } |
| 171 | |
| 172 | encSymKey, pubKeyB64, encPrivKey, err := crypto.GenerateUserKeys(stretchedKey) |
| 173 | if err != nil { |
| 174 | return fmt.Errorf("generate user keys: %w", err) |
| 175 | } |
| 176 | |
| 177 | req := RegisterRequest{ |
| 178 | Name: name, |
| 179 | Email: email, |
| 180 | MasterPasswordHash: masterPWHashB64, |
| 181 | Key: encSymKey, |
| 182 | Kdf: int(cfg.KdfType), |
| 183 | KdfIterations: cfg.KdfIterations, |
| 184 | KdfMemory: cfg.KdfMemory, |
| 185 | KdfParallelism: cfg.KdfParallelism, |
| 186 | Keys: RegisterKeys{ |
| 187 | PublicKey: pubKeyB64, |
| 188 | EncryptedPrivateKey: encPrivKey, |
| 189 | }, |
| 190 | } |
| 191 | |
| 192 | data, err := a.transport.PostNoAuth(ctx, "/identity/accounts/register", req) |
| 193 | if err != nil { |
| 194 | // Check if it's "already exists" (HTTP 400 with specific message). |
| 195 | if se, ok := err.(*ServerError); ok && se.StatusCode == 400 { |
| 196 | return fmt.Errorf("registration failed: %s", se.Msg) |
| 197 | } |
| 198 | return fmt.Errorf("register: %w", err) |
| 199 | } |
| 200 | _ = data |
| 201 | return nil |
| 202 | } |
| 203 | |
| 204 | // LoginWithAPIKey authenticates using an API key (client_credentials grant). |
| 205 | // API keys bypass 2FA but do NOT return encryption keys — the master password |
| 206 | // is still required to derive the KeyChain for vault operations. |
| 207 | func (a *AuthClient) LoginWithAPIKey(ctx context.Context, clientID, clientSecret, email, password string) (*TokenResponse, *crypto.KeyChain, error) { |
| 208 | // 1. Request token with client_credentials. |
| 209 | tokenResp, err := a.requestTokenAPIKey(ctx, clientID, clientSecret) |
| 210 | if err != nil { |
| 211 | return nil, nil, err |
| 212 | } |
| 213 | |
| 214 | // 2. Set tokens on transport. |
| 215 | a.transport.SetTokens(tokenResp.AccessToken, tokenResp.RefreshToken, tokenResp.ExpiresIn) |
| 216 | a.transport.SetRefreshFunc(a.doRefreshToken) |
| 217 | |
| 218 | // 3. Prelogin to get KDF params (needed for key derivation). |
| 219 | prelogin, err := a.Prelogin(ctx, email) |
| 220 | if err != nil { |
| 221 | return nil, nil, fmt.Errorf("prelogin for key derivation: %w", err) |
| 222 | } |
| 223 | |
| 224 | cfg := crypto.DeriveKeyConfig{ |
| 225 | KdfType: crypto.KdfType(prelogin.Kdf), |
| 226 | KdfIterations: prelogin.KdfIterations, |
| 227 | KdfMemory: prelogin.KdfMemory, |
| 228 | KdfParallelism: prelogin.KdfParallelism, |
| 229 | } |
| 230 | |
| 231 | // 4. API key response doesn't include Key/PrivateKey. |
| 232 | // We need to fetch them from the sync endpoint. |
| 233 | syncData, err := a.transport.Get(ctx, "/api/sync", nil) |
| 234 | if err != nil { |
| 235 | return nil, nil, fmt.Errorf("sync for keys: %w", err) |
| 236 | } |
| 237 | |
| 238 | var syncResp struct { |
| 239 | Profile struct { |
| 240 | Key string `json:"key"` |
| 241 | PrivateKey string `json:"privateKey"` |
| 242 | } `json:"profile"` |
| 243 | } |
| 244 | if err := json.Unmarshal(syncData, &syncResp); err != nil { |
| 245 | return nil, nil, fmt.Errorf("parse sync for keys: %w", err) |
| 246 | } |
| 247 | |
| 248 | if syncResp.Profile.Key == "" { |
| 249 | return nil, nil, fmt.Errorf("sync response missing encryption key") |
| 250 | } |
| 251 | |
| 252 | // 5. Build KeyChain from derived keys. |
| 253 | keyChain, err := crypto.NewKeyChain(email, password, cfg, syncResp.Profile.Key, syncResp.Profile.PrivateKey) |
| 254 | if err != nil { |
| 255 | return nil, nil, fmt.Errorf("build keychain: %w", err) |
| 256 | } |
| 257 | |
| 258 | return tokenResp, keyChain, nil |
| 259 | } |
| 260 | |
| 261 | // GetAPIKey retrieves (or creates) an API key for the current user. |
| 262 | // Requires an authenticated session (access token set on transport). |
| 263 | func (a *AuthClient) GetAPIKey(ctx context.Context, email, password string) (clientID, clientSecret string, err error) { |
| 264 | // Derive master password hash for verification. |
| 265 | prelogin, err := a.Prelogin(ctx, email) |
| 266 | if err != nil { |
| 267 | return "", "", fmt.Errorf("prelogin: %w", err) |
| 268 | } |
| 269 | |
| 270 | cfg := crypto.DeriveKeyConfig{ |
| 271 | KdfType: crypto.KdfType(prelogin.Kdf), |
| 272 | KdfIterations: prelogin.KdfIterations, |
| 273 | KdfMemory: prelogin.KdfMemory, |
| 274 | KdfParallelism: prelogin.KdfParallelism, |
| 275 | } |
| 276 | |
| 277 | masterKey, err := crypto.DeriveMasterKey(password, email, cfg) |
| 278 | if err != nil { |
| 279 | return "", "", fmt.Errorf("derive master key: %w", err) |
| 280 | } |
| 281 | |
| 282 | masterPWHash := crypto.HashMasterPassword(masterKey, password) |
| 283 | masterPWHashB64 := base64.StdEncoding.EncodeToString(masterPWHash) |
| 284 | |
| 285 | body := map[string]string{ |
| 286 | "masterPasswordHash": masterPWHashB64, |
| 287 | } |
| 288 | |
| 289 | data, err := a.transport.Post(ctx, "/api/accounts/api-key", body) |
| 290 | if err != nil { |
| 291 | return "", "", fmt.Errorf("get api key: %w", err) |
| 292 | } |
| 293 | |
| 294 | var resp struct { |
| 295 | APIKey string `json:"apiKey"` |
| 296 | } |
| 297 | if err := json.Unmarshal(data, &resp); err != nil { |
| 298 | return "", "", fmt.Errorf("parse api key response: %w", err) |
| 299 | } |
| 300 | |
| 301 | // Get user ID from profile for client_id |
| 302 | syncData, err := a.transport.Get(ctx, "/api/sync", nil) |
| 303 | if err != nil { |
| 304 | return "", "", fmt.Errorf("get profile for user id: %w", err) |
| 305 | } |
| 306 | |
| 307 | var syncResp struct { |
| 308 | Profile struct { |
| 309 | ID string `json:"id"` |
| 310 | } `json:"profile"` |
| 311 | } |
| 312 | if err := json.Unmarshal(syncData, &syncResp); err != nil { |
| 313 | return "", "", fmt.Errorf("parse profile: %w", err) |
| 314 | } |
| 315 | |
| 316 | return "user." + syncResp.Profile.ID, resp.APIKey, nil |
| 317 | } |
| 318 | |
| 319 | func (a *AuthClient) requestTokenAPIKey(ctx context.Context, clientID, clientSecret string) (*TokenResponse, error) { |
| 320 | form := url.Values{ |
| 321 | "grant_type": {"client_credentials"}, |
| 322 | "client_id": {clientID}, |
| 323 | "client_secret": {clientSecret}, |
| 324 | "scope": {"api"}, |
| 325 | "deviceType": {strconv.Itoa(a.device.Type)}, |
| 326 | "deviceIdentifier": {a.device.Identifier}, |
| 327 | "deviceName": {a.device.Name}, |
| 328 | } |
| 329 | |
| 330 | data, err := a.transport.PostFormNoAuth(ctx, a.identityURL+"/identity/connect/token", form) |
| 331 | if err != nil { |
| 332 | return nil, fmt.Errorf("api key token request: %w", err) |
| 333 | } |
| 334 | |
| 335 | var resp TokenResponse |
| 336 | if err := json.Unmarshal(data, &resp); err != nil { |
| 337 | return nil, fmt.Errorf("parse api key token response: %w", err) |
| 338 | } |
| 339 | return &resp, nil |
| 340 | } |
| 341 | |
| 342 | type twoFactorParams struct { |
| 343 | Provider int |
| 344 | Token string |
| 345 | Remember bool |
| 346 | } |
| 347 | |
| 348 | func (a *AuthClient) requestToken(ctx context.Context, email, passwordHash string, twoFA *twoFactorParams) (*TokenResponse, error) { |
| 349 | form := url.Values{ |
| 350 | "grant_type": {"password"}, |
| 351 | "username": {email}, |
| 352 | "password": {passwordHash}, |
| 353 | "scope": {"api offline_access"}, |
| 354 | "client_id": {"cli"}, |
| 355 | "deviceType": {strconv.Itoa(a.device.Type)}, |
| 356 | "deviceIdentifier": {a.device.Identifier}, |
| 357 | "deviceName": {a.device.Name}, |
| 358 | } |
| 359 | |
| 360 | if twoFA != nil { |
| 361 | form.Set("twoFactorProvider", strconv.Itoa(twoFA.Provider)) |
| 362 | form.Set("twoFactorToken", twoFA.Token) |
| 363 | if twoFA.Remember { |
| 364 | form.Set("twoFactorRemember", "1") |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | data, err := a.transport.PostFormNoAuth(ctx, a.identityURL+"/identity/connect/token", form) |
| 369 | if err != nil { |
| 370 | // Check if it's a 2FA challenge (HTTP 400 with TwoFactorProviders2). |
| 371 | if se, ok := err.(*ServerError); ok && se.StatusCode == 400 { |
| 372 | var tokenResp TokenResponse |
| 373 | if jsonErr := json.Unmarshal(data, &tokenResp); jsonErr == nil && len(tokenResp.TwoFactorProviders2) > 0 { |
| 374 | return &tokenResp, nil |
| 375 | } |
| 376 | } |
| 377 | return nil, fmt.Errorf("token request: %w", err) |
| 378 | } |
| 379 | |
| 380 | var resp TokenResponse |
| 381 | if err := json.Unmarshal(data, &resp); err != nil { |
| 382 | return nil, fmt.Errorf("parse token response: %w", err) |
| 383 | } |
| 384 | return &resp, nil |
| 385 | } |
| 386 | |
| 387 | func (a *AuthClient) doRefreshToken(ctx context.Context, refreshToken string) (*TokenResponse, error) { |
| 388 | form := url.Values{ |
| 389 | "grant_type": {"refresh_token"}, |
| 390 | "refresh_token": {refreshToken}, |
| 391 | "client_id": {"cli"}, |
| 392 | } |
| 393 | |
| 394 | data, err := a.transport.PostFormNoAuth(ctx, a.identityURL+"/identity/connect/token", form) |
| 395 | if err != nil { |
| 396 | return nil, fmt.Errorf("refresh token: %w", err) |
| 397 | } |
| 398 | |
| 399 | var resp TokenResponse |
| 400 | if err := json.Unmarshal(data, &resp); err != nil { |
| 401 | return nil, fmt.Errorf("parse refresh response: %w", err) |
| 402 | } |
| 403 | return &resp, nil |
| 404 | } |
| 405 | |