package main // Vaultwarden/Bitwarden API client: prelogin, login, sync, cipher create/edit. import ( "bytes" "encoding/binary" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "strings" "time" ) type Client struct { Server string // e.g. https://pwvault.turnsys.com HTTP *http.Client Email string Password string TOTPSecret string reloginDone bool AccessToken string RefreshToken string KDFType int KDFIter uint32 KDFMemory uint32 KDFParallel uint32 MasterKey []byte // 32B StretchedKey []byte // 64B UserSymKey []byte // 64B (decrypted from profile.Key) } func (c *Client) api(method, path string, body any, auth bool) ([]byte, error) { var rd io.Reader if body != nil { switch b := body.(type) { case url.Values: rd = strings.NewReader(b.Encode()) default: j, err := json.Marshal(body) if err != nil { return nil, err } rd = bytes.NewReader(j) } } else { rd = strings.NewReader("") } req, err := http.NewRequest(method, c.Server+path, rd) if err != nil { return nil, err } if body != nil { if _, ok := body.(url.Values); ok { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { req.Header.Set("Content-Type", "application/json") } } if auth { req.Header.Set("Authorization", "Bearer "+c.AccessToken) } resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() out, err := io.ReadAll(resp.Body) if err != nil { return nil, err } if resp.StatusCode >= 300 { // access token expired: refresh once and retry (never for the // identity endpoints themselves, which manage their own tokens) if resp.StatusCode == 401 && auth && c.RefreshToken != "" && !strings.HasPrefix(path, "/identity/") { if rerr := c.refresh(); rerr == nil { return c.api(method, path, body, auth) } } return out, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, truncate(string(out), 200)) } return out, nil } // refresh exchanges the persisted refresh_token for a fresh access token // (Vaultwarden rotates the refresh token on every use). Scope must match // the original grant (api offline_access). func (c *Client) refresh() error { if c.RefreshToken == "" { return errors.New("no refresh token in state; re-login required") } form := url.Values{} form.Set("grant_type", "refresh_token") form.Set("refresh_token", c.RefreshToken) form.Set("client_id", "cli") form.Set("scope", "api offline_access") out, err := c.apiRaw("POST", "/identity/connect/token", form, false) if err != nil { return fmt.Errorf("refresh: %w", err) } var t tokenResp if err := json.Unmarshal(out, &t); err != nil { return err } if t.AccessToken == "" { return fmt.Errorf("refresh failed: %s", truncate(string(out), 200)) } c.AccessToken = t.AccessToken if t.RefreshTok != "" { c.RefreshToken = t.RefreshTok } persistTokens(c) return nil } func truncate(s string, n int) string { if len(s) <= n { return s } return s[:n] + "..." } type preloginResp struct { KDF int `json:"kdf"` KDFIterations uint32 `json:"kdfIterations"` KDFMemory uint32 `json:"kdfMemory"` KDFParallelism uint32 `json:"kdfParallelism"` } func (c *Client) Prelogin() error { out, err := c.api("POST", "/api/accounts/prelogin", map[string]string{"email": c.Email}, false) if err != nil { return err } var p preloginResp if err := json.Unmarshal(out, &p); err != nil { return err } c.KDFType, c.KDFIter, c.KDFMemory, c.KDFParallel = p.KDF, p.KDFIterations, p.KDFMemory, p.KDFParallelism return nil } type tokenResp struct { AccessToken string `json:"access_token"` RefreshTok string `json:"refresh_token"` Key string `json:"Key"` PrivateKey string `json:"PrivateKey"` ErrorDesc string `json:"ErrorDescription"` } // Login performs prelogin + key derivation + password grant. func (c *Client) Login() error { if err := c.Prelogin(); err != nil { return fmt.Errorf("prelogin: %w", err) } c.MasterKey = deriveMasterKey(c.Password, c.Email, c.KDFType, c.KDFIter, c.KDFMemory, c.KDFParallel) c.StretchedKey = stretchKey(c.MasterKey) form := url.Values{} form.Set("grant_type", "password") form.Set("username", c.Email) form.Set("password", masterPasswordHash(c.MasterKey, c.Password)) form.Set("scope", "api offline_access") form.Set("client_id", "cli") form.Set("deviceIdentifier", deviceID()) form.Set("deviceName", "smcli") form.Set("deviceType", "9") var lastOut []byte out, err := c.apiRaw("POST", "/identity/connect/token", form, false) if err != nil { return fmt.Errorf("token: %w", err) } var t tokenResp if err := json.Unmarshal(out, &t); err != nil { return err } if t.AccessToken == "" { // 2FA retry path (provider 0 = authenticator TOTP) if strings.Contains(string(out), "Two factor required") { secret := c.TOTPSecret if secret != "" { code, terr := totpNow(secret, time.Now()) if terr != nil { return fmt.Errorf("totp: %w", terr) } form.Set("twoFactor", "0") form.Set("twoFactorProvider", "0") form.Set("twoFactorToken", code) form.Set("twoFactorRemember", "1") out2, err2 := c.api("POST", "/identity/connect/token", form, false) if err2 != nil { return fmt.Errorf("token(2fa): %w", err2) } if err := json.Unmarshal(out2, &t); err != nil { return err } lastOut = out2 } } } if t.AccessToken == "" { payload := string(out) if lastOut != nil { payload = string(lastOut) } return fmt.Errorf("login failed: %s", truncate(payload, 300)) } c.AccessToken = t.AccessToken c.RefreshToken = t.RefreshTok return nil } type syncResp struct { Profile struct { Key string `json:"key"` PrivateKey string `json:"privateKey"` Email string `json:"email"` } `json:"profile"` Folders []struct { ID string `json:"id"` Name string `json:"name"` } `json:"folders"` Ciphers []json.RawMessage `json:"ciphers"` } // Unlock performs sync-light: fetches profile key, decrypts the user sym key. func (c *Client) Unlock() error { out, err := c.api("GET", "/api/sync?excludeDomains=true", nil, true) if err != nil { return err } var s syncResp if err := json.Unmarshal(out, &s); err != nil { return err } if s.Profile.Key == "" { return errors.New("sync: empty profile key") } es, err := ParseEncString(s.Profile.Key) if err != nil { return err } c.UserSymKey, err = symDecrypt(c.StretchedKey, es) if err != nil { return fmt.Errorf("decrypt user key: %w", err) } return nil } // Sync returns the raw sync payload (cached by caller as needed). func (c *Client) Sync() ([]byte, error) { return c.api("GET", "/api/sync?excludeDomains=true", nil, true) } // CreateCipher posts an encrypted cipher. func (c *Client) CreateCipher(cipherJSON any) ([]byte, error) { return c.api("POST", "/api/ciphers", cipherJSON, true) } // EditCipher updates an encrypted cipher. func (c *Client) EditCipher(id string, cipherJSON any) ([]byte, error) { return c.api("PUT", "/api/ciphers/"+id, cipherJSON, true) } func deviceID() string { // stable per machine: hash of hostname (no secrets involved) hn := hostnameSafe() sum := sha256Sum([]byte("knel-secretsmgr:" + hn)) var b [16]byte copy(b[:], sum[:16]) return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", binary.BigEndian.Uint32(b[0:4]), binary.BigEndian.Uint16(b[4:6]), binary.BigEndian.Uint16(b[6:8]), binary.BigEndian.Uint16(b[8:10]), b[10:16]) } // apiRaw performs the request and returns the body regardless of status. func (c *Client) apiRaw(method, path string, body any, auth bool) ([]byte, error) { var rd io.Reader if body != nil { switch b := body.(type) { case url.Values: rd = strings.NewReader(b.Encode()) default: j, err := json.Marshal(body) if err != nil { return nil, err } rd = bytes.NewReader(j) } } else { rd = strings.NewReader("") } req, err := http.NewRequest(method, c.Server+path, rd) if err != nil { return nil, err } if body != nil { if _, ok := body.(url.Values); ok { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } else { req.Header.Set("Content-Type", "application/json") } } if auth { req.Header.Set("Authorization", "Bearer "+c.AccessToken) } resp, err := c.HTTP.Do(req) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } // DeleteCipher moves a cipher to trash (soft delete). Best-effort permanent // purge afterwards; Vaultwarden tolerates trash-only items. func (c *Client) DeleteCipher(id string) error { if _, err := c.api("DELETE", "/api/ciphers/"+id, nil, true); err != nil { return err } _, _ = c.api("PUT", "/api/ciphers/"+id+"/purge", map[string]any{}, true) return nil } // selfRelogin performs the full login+unlock using SM_* env credentials // (injected by the sm shims from the TSGCOO vault-account env). Saves state. func (c *Client) selfRelogin() error { if c.Password == "" { c.Password = os.Getenv("SM_PASSWORD") } if c.TOTPSecret == "" { c.TOTPSecret = os.Getenv("SM_TOTP_SECRET") } if c.Password == "" { return errors.New("relogin unavailable: SM_PASSWORD not set") } if err := c.Login(); err != nil { return err } if err := c.Unlock(); err != nil { return err } if s, err := loadState(); err == nil { s.AccessToken = c.AccessToken s.UserSymKey = toHex(c.UserSymKey) s.StretchedKey = toHex(c.StretchedKey) s.MasterKey = toHex(c.MasterKey) _ = saveState(s) } return nil }