| 1 | package bitwarden |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/url" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | const defaultBaseURL = "http://localhost:8087" |
| 13 | |
| 14 | // ClientOption configures the Client using the functional options pattern. |
| 15 | type ClientOption func(*Client) |
| 16 | |
| 17 | // WithBaseURL sets the base URL for the bw serve API. |
| 18 | // Default is "http://localhost:8087". |
| 19 | func WithBaseURL(u string) ClientOption { |
| 20 | return func(c *Client) { |
| 21 | c.transport.baseURL = u |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | // WithHTTPClient sets a custom http.Client for API requests. |
| 26 | // Use this to configure timeouts, proxies, or custom transport. |
| 27 | func WithHTTPClient(hc *http.Client) ClientOption { |
| 28 | return func(c *Client) { |
| 29 | c.transport.httpClient = hc |
| 30 | } |
| 31 | } |
| 32 | |
| 33 | // Client provides access to a Bitwarden vault via the bw serve API. |
| 34 | // It requires the Bitwarden CLI to be running in serve mode (bw serve). |
| 35 | // Client implements the VaultClient interface. |
| 36 | // |
| 37 | // For direct server communication without the CLI, use DirectClient instead. |
| 38 | type Client struct { |
| 39 | transport *transport |
| 40 | |
| 41 | items *ItemsService |
| 42 | folders *FoldersService |
| 43 | collections *CollectionsService |
| 44 | orgCollections *OrgCollectionsService |
| 45 | organizations *OrganizationsService |
| 46 | orgMembers *OrgMembersService |
| 47 | send *SendService |
| 48 | attachments *AttachmentsService |
| 49 | } |
| 50 | |
| 51 | // NewClient creates a new Bitwarden vault management client that communicates |
| 52 | // via the bw serve API. The client is created in a locked state and must be |
| 53 | // unlocked using Unlock() before accessing vault data. |
| 54 | // |
| 55 | // Options can be provided to customize the client behavior: |
| 56 | // |
| 57 | // client := bitwarden.NewClient( |
| 58 | // bitwarden.WithBaseURL("http://localhost:8087"), |
| 59 | // bitwarden.WithHTTPClient(customHTTPClient), |
| 60 | // ) |
| 61 | func NewClient(opts ...ClientOption) *Client { |
| 62 | t := newTransport(defaultBaseURL, &http.Client{ |
| 63 | Timeout: 30 * time.Second, |
| 64 | }) |
| 65 | |
| 66 | c := &Client{transport: t} |
| 67 | |
| 68 | for _, opt := range opts { |
| 69 | opt(c) |
| 70 | } |
| 71 | |
| 72 | c.items = &ItemsService{t: t} |
| 73 | c.folders = &FoldersService{t: t} |
| 74 | c.collections = &CollectionsService{t: t} |
| 75 | c.orgCollections = &OrgCollectionsService{t: t} |
| 76 | c.organizations = &OrganizationsService{t: t} |
| 77 | c.orgMembers = &OrgMembersService{t: t} |
| 78 | c.send = &SendService{t: t} |
| 79 | c.attachments = &AttachmentsService{t: t} |
| 80 | |
| 81 | return c |
| 82 | } |
| 83 | |
| 84 | // Items returns the vault items (ciphers) service for CRUD operations on vault items. |
| 85 | func (c *Client) Items() ItemsAPI { return c.items } |
| 86 | |
| 87 | // Folders returns the folders service for organizing vault items. |
| 88 | func (c *Client) Folders() FoldersAPI { return c.folders } |
| 89 | |
| 90 | // Collections returns the collections service for accessing user collections. |
| 91 | func (c *Client) Collections() CollectionsAPI { return c.collections } |
| 92 | |
| 93 | // OrgCollections returns the organization collections service for managing org collections. |
| 94 | func (c *Client) OrgCollections() OrgCollectionsAPI { return c.orgCollections } |
| 95 | |
| 96 | // Organizations returns the organizations service for listing user organizations. |
| 97 | func (c *Client) Organizations() OrganizationsAPI { return c.organizations } |
| 98 | |
| 99 | // OrgMembers returns the organization members service for member management. |
| 100 | func (c *Client) OrgMembers() OrgMembersAPI { return c.orgMembers } |
| 101 | |
| 102 | // Send returns the Bitwarden Send service for creating secure shares. |
| 103 | func (c *Client) Send() SendAPI { return c.send } |
| 104 | |
| 105 | // Attachments returns the attachments service for file attachment operations. |
| 106 | func (c *Client) Attachments() AttachmentsAPI { return c.attachments } |
| 107 | |
| 108 | // Verify Client satisfies VaultClient at compile time. |
| 109 | var _ VaultClient = (*Client)(nil) |
| 110 | |
| 111 | // Status returns the current status of the Bitwarden CLI including |
| 112 | // lock state, user information, and server URL. |
| 113 | func (c *Client) Status(ctx context.Context) (*Status, error) { |
| 114 | data, err := c.transport.get(ctx, "/status", nil) |
| 115 | if err != nil { |
| 116 | return nil, err |
| 117 | } |
| 118 | resp, err := decodeJSON[StatusResponse](data) |
| 119 | if err != nil { |
| 120 | return nil, err |
| 121 | } |
| 122 | return parseStatusData(resp.Data) |
| 123 | } |
| 124 | |
| 125 | // parseStatusData handles both formats: |
| 126 | // - bw serve: {"object":"template","template":{...}} |
| 127 | // - flat: {"serverUrl":...,"status":"unlocked",...} |
| 128 | func parseStatusData(raw json.RawMessage) (*Status, error) { |
| 129 | var wrapped struct { |
| 130 | Object string `json:"object"` |
| 131 | Template *Status `json:"template"` |
| 132 | } |
| 133 | if err := json.Unmarshal(raw, &wrapped); err == nil && wrapped.Template != nil { |
| 134 | return wrapped.Template, nil |
| 135 | } |
| 136 | var s Status |
| 137 | if err := json.Unmarshal(raw, &s); err != nil { |
| 138 | return nil, fmt.Errorf("parse status: %w", err) |
| 139 | } |
| 140 | return &s, nil |
| 141 | } |
| 142 | |
| 143 | // Sync triggers a vault synchronization with the server. |
| 144 | // This downloads the latest vault data and updates the local cache. |
| 145 | func (c *Client) Sync(ctx context.Context) error { |
| 146 | _, err := c.transport.post(ctx, "/sync", nil) |
| 147 | return err |
| 148 | } |
| 149 | |
| 150 | // Lock locks the vault, clearing encryption keys from memory. |
| 151 | // After locking, Unlock must be called before accessing vault data. |
| 152 | func (c *Client) Lock(ctx context.Context) error { |
| 153 | _, err := c.transport.post(ctx, "/lock", nil) |
| 154 | return err |
| 155 | } |
| 156 | |
| 157 | // Unlock unlocks the vault with the given master password. |
| 158 | // The vault must be unlocked before accessing any vault data. |
| 159 | func (c *Client) Unlock(ctx context.Context, password string) error { |
| 160 | _, err := c.transport.post(ctx, "/unlock", &UnlockRequest{Password: password}) |
| 161 | return err |
| 162 | } |
| 163 | |
| 164 | // Generate generates a password or passphrase based on the provided options. |
| 165 | // Use GenerateOptions to configure password length, character sets, or passphrase settings. |
| 166 | func (c *Client) Generate(ctx context.Context, opts GenerateOptions) (string, error) { |
| 167 | q := url.Values{} |
| 168 | if opts.Lowercase != nil && *opts.Lowercase { |
| 169 | q.Set("lowercase", "true") |
| 170 | } |
| 171 | if opts.Uppercase != nil && *opts.Uppercase { |
| 172 | q.Set("uppercase", "true") |
| 173 | } |
| 174 | if opts.Number != nil && *opts.Number { |
| 175 | q.Set("number", "true") |
| 176 | } |
| 177 | if opts.Special != nil && *opts.Special { |
| 178 | q.Set("special", "true") |
| 179 | } |
| 180 | if opts.Length != nil { |
| 181 | q.Set("length", fmt.Sprintf("%d", *opts.Length)) |
| 182 | } |
| 183 | if opts.Passphrase != nil && *opts.Passphrase { |
| 184 | q.Set("passphrase", "true") |
| 185 | } |
| 186 | if opts.Words != nil { |
| 187 | q.Set("words", fmt.Sprintf("%d", *opts.Words)) |
| 188 | } |
| 189 | if opts.Separator != nil { |
| 190 | q.Set("separator", *opts.Separator) |
| 191 | } |
| 192 | if opts.Capitalize != nil && *opts.Capitalize { |
| 193 | q.Set("capitalize", "true") |
| 194 | } |
| 195 | if opts.IncludeNumber != nil && *opts.IncludeNumber { |
| 196 | q.Set("includeNumber", "true") |
| 197 | } |
| 198 | data, err := c.transport.get(ctx, "/generate", q) |
| 199 | if err != nil { |
| 200 | return "", err |
| 201 | } |
| 202 | resp, err := decodeJSON[GenerateResponse](data) |
| 203 | if err != nil { |
| 204 | return "", err |
| 205 | } |
| 206 | return resp.Data.Data, nil |
| 207 | } |
| 208 | |