diff --git a/cmd/api/main.go b/cmd/api/main.go index fb6c7f0..00dd131 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -122,6 +122,7 @@ func main() { api.Use(auth.Middleware(auth.NewStore(cfg.APIKeys))) api.Get("/secret/:name", h.GetSecret) + api.Post("/secret", h.CreateSecret) api.Post("/refresh", h.RefreshCache) // Graceful shutdown. diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 7f0517a..dc55a83 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -270,3 +270,65 @@ func (h *Handler) RefreshCache(c *fiber.Ctx) error { "message": "cache cleared successfully", }) } + +// CreateSecret handles POST /secret. Body (JSON): +// +// { +// "type": 1, // 1=Login (default), 2=SecureNote +// "name": "...", // required +// "notes": "...", // secure note body (type 2) or login notes +// "username": "...", // login only +// "password": "...", // login only +// "uri": "...", // login only +// "fields": {"k":"v"} // login custom fields (optional) +// } +func (h *Handler) CreateSecret(c *fiber.Ctx) error { + var body struct { + Type int `json:"type"` + Name string `json:"name"` + Notes string `json:"notes"` + Username string `json:"username"` + Password string `json:"password"` + URI string `json:"uri"` + Fields map[string]string `json:"fields"` + } + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON body"}) + } + + body.Name = strings.TrimSpace(body.Name) + if body.Name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) + } + if !validators.IsValidSecretName(body.Name) { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid secret name format"}) + } + + if body.Type == 0 { + body.Type = 1 // default to Login + } + if body.Type != 1 && body.Type != 2 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "only Login (1) and SecureNote (2) are supported for writing"}) + } + + id, err := h.vaultClient.CreateSecret(vaultwarden.CreateSecretInput{ + Type: body.Type, + Name: body.Name, + Notes: body.Notes, + Username: body.Username, + Password: body.Password, + URI: body.URI, + Fields: body.Fields, + }) + if err != nil { + logger.Error.Printf("Failed to create secret (requested by IP: %s): %v", c.IP(), err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + "status": "created", + "id": id, + "name": body.Name, + "type": body.Type, + }) +} diff --git a/internal/vaultwarden/api_client.go b/internal/vaultwarden/api_client.go index 498e39c..fc8d52a 100644 --- a/internal/vaultwarden/api_client.go +++ b/internal/vaultwarden/api_client.go @@ -1,6 +1,7 @@ package vaultwarden import ( + "bytes" "encoding/json" "fmt" "io" @@ -755,6 +756,128 @@ func (ac *APIClient) doTokenRequest(data url.Values) (*TokenResponse, error) { 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 { + // notes already set + } 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") + + 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) { diff --git a/internal/vaultwarden/client.go b/internal/vaultwarden/client.go index 6a08c2f..3811aa6 100644 --- a/internal/vaultwarden/client.go +++ b/internal/vaultwarden/client.go @@ -197,6 +197,40 @@ func (c *Client) ClearCache() { } } +// CreateSecretInput is the public input for writing a new secret. +type CreateSecretInput struct { + Type int // 1=Login, 2=SecureNote + Name string + Notes string + Username string + Password string + URI string + Fields map[string]string +} + +// CreateSecret writes a new vault item and refreshes the local cache so it is +// immediately readable via GetSecret. +func (c *Client) CreateSecret(in CreateSecretInput) (string, error) { + if c.api == nil { + return "", fmt.Errorf("vault API client not configured") + } + id, err := c.api.CreateCipher(CipherInput{ + Type: in.Type, + Name: in.Name, + Notes: in.Notes, + Username: in.Username, + Password: in.Password, + URI: in.URI, + Fields: in.Fields, + }) + if err != nil { + return "", err + } + // Refresh cache so the new secret is immediately visible. + c.ClearCache() + return id, nil +} + // Stop stops the background sync goroutine. func (c *Client) Stop() { close(c.stopSync) diff --git a/internal/vaultwarden/crypto.go b/internal/vaultwarden/crypto.go index b4cc04e..bc48cbd 100644 --- a/internal/vaultwarden/crypto.go +++ b/internal/vaultwarden/crypto.go @@ -3,9 +3,11 @@ package vaultwarden import ( + "bytes" "crypto/aes" "crypto/cipher" "crypto/hmac" + "crypto/rand" "crypto/rsa" "crypto/sha1" "crypto/sha256" @@ -333,6 +335,8 @@ func DecryptOrgKey(encryptedOrgKey string, privateKey *rsa.PrivateKey) (Symmetri }, nil } +// EncryptStr encrypts a plaintext string using the symmetric key and returns a +// Bitwarden cipher string (EncType 2: AES-256-CBC + HMAC-SHA256). // pkcs7Unpad removes PKCS#7 padding. func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { if len(data) == 0 { @@ -341,17 +345,75 @@ func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) { if len(data)%blockSize != 0 { return nil, errors.New("data not block-aligned") } - padLen := int(data[len(data)-1]) if padLen == 0 || padLen > blockSize { return nil, fmt.Errorf("invalid padding length: %d", padLen) } - - for i := len(data) - padLen; i < len(data); i++ { - if data[i] != byte(padLen) { - return nil, errors.New("invalid PKCS7 padding") - } - } - return data[:len(data)-padLen], nil } + +func EncryptStr(plaintext string, key SymmetricKey) (string, error) { + if len(key.EncKey) != 32 { + return "", fmt.Errorf("invalid enc key length: %d", len(key.EncKey)) + } + if len(key.MacKey) != 32 { + // Legacy mode (no MAC key, type 0). Unlikely for modern vaults. + return encryptAesCbc256_B64(plaintext, key.EncKey) + } + + iv := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("generate iv: %w", err) + } + + block, err := aes.NewCipher(key.EncKey) + if err != nil { + return "", fmt.Errorf("aes cipher: %w", err) + } + + // PKCS7 pad. + plain := []byte(plaintext) + pad := aes.BlockSize - len(plain)%aes.BlockSize + padded := append(plain, bytes.Repeat([]byte{byte(pad)}, pad)...) + + ct := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ct, padded) + + // MAC over iv || ct. + mac := hmac.New(sha256.New, key.MacKey) + mac.Write(iv) + mac.Write(ct) + expectedMAC := mac.Sum(nil) + + cs := fmt.Sprintf("%d.%s|%s|%s", + EncTypeAesCbc256_HmacSha256_B64, + base64.StdEncoding.EncodeToString(iv), + base64.StdEncoding.EncodeToString(ct), + base64.StdEncoding.EncodeToString(expectedMAC), + ) + return cs, nil +} + +// encryptAesCbc256_B64 encrypts without a MAC (legacy EncType 0). +func encryptAesCbc256_B64(plaintext string, encKey []byte) (string, error) { + iv := make([]byte, aes.BlockSize) + if _, err := io.ReadFull(rand.Reader, iv); err != nil { + return "", fmt.Errorf("generate iv: %w", err) + } + block, err := aes.NewCipher(encKey) + if err != nil { + return "", fmt.Errorf("aes cipher: %w", err) + } + plain := []byte(plaintext) + pad := aes.BlockSize - len(plain)%aes.BlockSize + padded := append(plain, bytes.Repeat([]byte{byte(pad)}, pad)...) + ct := make([]byte, len(padded)) + mode := cipher.NewCBCEncrypter(block, iv) + mode.CryptBlocks(ct, padded) + return fmt.Sprintf("%d.%s|%s", + EncTypeAesCbc256_B64, + base64.StdEncoding.EncodeToString(iv), + base64.StdEncoding.EncodeToString(ct), + ), nil +}