Add the Secrets Manager REST client with a fake-server test suite
Public surface Authenticate/GetSecret/ListSecrets/ListProjects: OAuth client_credentials against /identity/connect/token (with the encrypted_payload organization-key unwrap), refresh-before-expiry, and bearer reads under /api with in-memory decryption. Errors are fixed reason enums that can never embed material. Everything is tested against an in-process fake Secrets Manager speaking the same protocol and crypto (auth failure, expiry, refresh, missing secrets, malformed payloads, tampered MACs, plaintext mode, and redaction sweeps over every error path); the real vault is never contacted.
This commit is contained in:
@@ -0,0 +1,398 @@
|
||||
// Package fakesm is an in-process fake Bitwarden Secrets Manager for
|
||||
// tests. It speaks the real wire protocol — OAuth client_credentials at
|
||||
// /identity/connect/token (including the encrypted_payload organization
|
||||
// key handoff), bearer-authenticated reads under /api — and encrypts
|
||||
// secret names and values as type-2 EncStrings under its own organization
|
||||
// key, so the client's full decrypt chain is exercised. It is imported
|
||||
// ONLY by tests and the smoke script; nothing in the library depends on
|
||||
// it, and it never touches a real vault.
|
||||
package fakesm
|
||||
|
||||
import (
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bw "git.knownelement.com/ukrrs/mopac-bitwarden-go"
|
||||
)
|
||||
|
||||
// Secret is one fake secret: plaintext on the server side; the wire form
|
||||
// is encrypted with the server's organization key.
|
||||
type Secret struct {
|
||||
ID string
|
||||
Name string
|
||||
Value string
|
||||
Note string
|
||||
}
|
||||
|
||||
// Project is one fake project.
|
||||
type Project struct {
|
||||
ID string
|
||||
Name string
|
||||
}
|
||||
|
||||
// Server is the fake Secrets Manager. Behavior knobs are failure
|
||||
// injectors for table-driven tests; zero values mean "behave correctly".
|
||||
type Server struct {
|
||||
// Credential the server accepts (the parts of the machine access
|
||||
// token). NewMinted generates a consistent set and the matching
|
||||
// credential string.
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
TokenKey []byte // 16 bytes, the ":key" part of the credential
|
||||
OrgKey []byte // 64 bytes; nil = plaintext mode
|
||||
OrgID string
|
||||
TokenTTL time.Duration
|
||||
|
||||
Secrets []Secret
|
||||
Projects []Project
|
||||
|
||||
// Failure injectors.
|
||||
RejectAuth bool // token endpoint: 400 invalid_grant
|
||||
AuthStatusOverride int // token endpoint: raw status (e.g. 500)
|
||||
MalformedTokenBody bool // token endpoint: 200 with non-JSON body
|
||||
MalformedListBody bool // secret list: 200 with non-JSON body
|
||||
DropEncryptedPayload bool // successful login without encrypted_payload
|
||||
TamperSecretMAC bool // corrupt the value MAC in responses
|
||||
OmitValueInList bool // list omits value (forces by-id fetch)
|
||||
RejectRefresh bool // refresh grant: 400 invalid_grant
|
||||
RejectBearer bool // API endpoints: 401 for every token
|
||||
|
||||
// Counters for assertions.
|
||||
TokenCalls int
|
||||
RefreshCalls int
|
||||
APICalls int
|
||||
|
||||
mu sync.Mutex
|
||||
issued map[string]time.Time // access token -> expiry
|
||||
lastAccess string
|
||||
lastRefresh string
|
||||
srv *httptest.Server
|
||||
}
|
||||
|
||||
// NewServer builds a server with a fixed, deterministic credential set
|
||||
// (matching the published SDK sample format) and a random organization
|
||||
// key, one project and two secrets. The matching machine credential
|
||||
// string is returned for client configuration.
|
||||
func NewServer() (*Server, string) {
|
||||
orgKey := make([]byte, 64)
|
||||
if _, err := rand.Read(orgKey); err != nil {
|
||||
panic("fakesm: entropy: " + err.Error())
|
||||
}
|
||||
tokenKey := make([]byte, 16)
|
||||
if _, err := rand.Read(tokenKey); err != nil {
|
||||
panic("fakesm: entropy: " + err.Error())
|
||||
}
|
||||
secretTail := make([]byte, 12)
|
||||
_, _ = rand.Read(secretTail)
|
||||
clientSecret := "C2IgxjjLF7qSshsbwe8JGcbM075YXw"
|
||||
s := &Server{
|
||||
ClientID: "ec2c1d46-6a4b-4751-a310-af9601317f2d",
|
||||
ClientSecret: clientSecret,
|
||||
TokenKey: tokenKey,
|
||||
OrgKey: orgKey,
|
||||
OrgID: "3fb1c0de-0000-4000-8000-000000000000",
|
||||
TokenTTL: time.Hour,
|
||||
Projects: []Project{
|
||||
{ID: "ac1d0000-0000-4000-8000-000000000001", Name: "harness"},
|
||||
},
|
||||
Secrets: []Secret{
|
||||
{ID: "5ec1e700-0000-4000-8000-00000000000a", Name: "redmine-api-key", Value: "rm-live-" + hex.EncodeToString(secretTail)},
|
||||
{ID: "5ec1e700-0000-4000-8000-00000000000b", Name: "litellm-key", Value: "lm-live-" + hex.EncodeToString(secretTail)},
|
||||
},
|
||||
issued: map[string]time.Time{},
|
||||
}
|
||||
cred := fmt.Sprintf("0.%s.%s:%s", s.ClientID, clientSecret, base64.StdEncoding.EncodeToString(tokenKey))
|
||||
return s, cred
|
||||
}
|
||||
|
||||
// Start boots the HTTP server; BaseURL is the server root for clients.
|
||||
func (s *Server) Start() *Server {
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(s.handle))
|
||||
return s
|
||||
}
|
||||
|
||||
// ListenAndServe runs the fake on a plain listener (used by the smoke
|
||||
// script, which boots it inside a container). It blocks until the process
|
||||
// is killed.
|
||||
func (s *Server) ListenAndServe(addr string) error {
|
||||
if s.issued == nil {
|
||||
s.issued = map[string]time.Time{}
|
||||
}
|
||||
return http.ListenAndServe(addr, http.HandlerFunc(s.handle))
|
||||
}
|
||||
|
||||
// Close shuts the server down.
|
||||
func (s *Server) Close() {
|
||||
if s.srv != nil {
|
||||
s.srv.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// BaseURL returns the server root ("" before Start).
|
||||
func (s *Server) BaseURL() string {
|
||||
if s.srv == nil {
|
||||
return ""
|
||||
}
|
||||
return s.srv.URL
|
||||
}
|
||||
|
||||
// LastAccessToken returns the most recently minted access token (for
|
||||
// redaction assertions: this exact string must never appear in output).
|
||||
func (s *Server) LastAccessToken() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastAccess
|
||||
}
|
||||
|
||||
// LastRefreshToken returns the most recently issued refresh token.
|
||||
func (s *Server) LastRefreshToken() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.lastRefresh
|
||||
}
|
||||
|
||||
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/identity/connect/token":
|
||||
s.handleToken(w, r)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/"):
|
||||
s.handleAPI(w, r)
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"})
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
grant := r.PostFormValue("grant_type")
|
||||
if grant == "refresh_token" {
|
||||
s.RefreshCalls++
|
||||
} else {
|
||||
s.TokenCalls++
|
||||
}
|
||||
if s.AuthStatusOverride != 0 {
|
||||
writeJSON(w, s.AuthStatusOverride, map[string]string{"message": "boom"})
|
||||
return
|
||||
}
|
||||
if s.MalformedTokenBody {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{this-is-not-json`)
|
||||
return
|
||||
}
|
||||
if s.RejectAuth {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_username_or_password"})
|
||||
return
|
||||
}
|
||||
switch grant {
|
||||
case "client_credentials":
|
||||
if r.PostFormValue("client_id") != s.ClientID || r.PostFormValue("client_secret") != s.ClientSecret || r.PostFormValue("scope") != "api.secrets" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_username_or_password"})
|
||||
return
|
||||
}
|
||||
body := s.mintAccessToken()
|
||||
if !s.DropEncryptedPayload && s.OrgKey != nil {
|
||||
body["encrypted_payload"] = s.encryptPayload()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
case "refresh_token":
|
||||
if s.RejectRefresh || r.PostFormValue("refresh_token") == "" || r.PostFormValue("client_id") != s.ClientID {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_grant", "error_description": "invalid_refresh_token"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.mintAccessToken())
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported_grant_type"})
|
||||
}
|
||||
}
|
||||
|
||||
// mintAccessToken issues and records a fresh access/refresh pair and
|
||||
// returns the response body (without encrypted_payload; the caller adds
|
||||
// it for client_credentials logins only, mirroring the real server).
|
||||
func (s *Server) mintAccessToken() map[string]any {
|
||||
claims := map[string]any{
|
||||
"sub": s.ClientID,
|
||||
"organization": s.OrgID,
|
||||
"scope": []string{"api.secrets"},
|
||||
"exp": time.Now().Add(s.TokenTTL).Unix(),
|
||||
"nbf": time.Now().Add(-time.Minute).Unix(),
|
||||
}
|
||||
payload, _ := json.Marshal(claims)
|
||||
access := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) +
|
||||
"." + base64.RawURLEncoding.EncodeToString(payload) +
|
||||
"." + base64.RawURLEncoding.EncodeToString([]byte("fake-signature"))
|
||||
refreshBytes := make([]byte, 16)
|
||||
_, _ = rand.Read(refreshBytes)
|
||||
refresh := "rt-" + hex.EncodeToString(refreshBytes)
|
||||
s.issued[access] = time.Now().Add(s.TokenTTL)
|
||||
s.lastAccess = access
|
||||
s.lastRefresh = refresh
|
||||
return map[string]any{
|
||||
"access_token": access,
|
||||
"expires_in": int64(s.TokenTTL.Seconds()),
|
||||
"refresh_token": refresh,
|
||||
"token_type": "Bearer",
|
||||
"scope": "api.secrets",
|
||||
}
|
||||
}
|
||||
|
||||
// encryptPayload seals the organization key for this credential exactly
|
||||
// as the real server does: HKDF-SHA256 over the credential's 16-byte key
|
||||
// (salt "bitwarden-accesstoken", info "sm-access-token"), then a type-2
|
||||
// EncString of {"encryptionKey": "<b64 org key>"}.
|
||||
func (s *Server) encryptPayload() string {
|
||||
okm, err := hkdf.Key(sha256.New, s.TokenKey, []byte("bitwarden-accesstoken"), "sm-access-token", 64)
|
||||
if err != nil {
|
||||
panic("fakesm: hkdf: " + err.Error())
|
||||
}
|
||||
key, err := bw.NewSymmetricKey(okm)
|
||||
if err != nil {
|
||||
panic("fakesm: key: " + err.Error())
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]string{"encryptionKey": base64.StdEncoding.EncodeToString(s.OrgKey)})
|
||||
enc, err := bw.Encrypt(key, payload)
|
||||
if err != nil {
|
||||
panic("fakesm: encrypt: " + err.Error())
|
||||
}
|
||||
return enc.String()
|
||||
}
|
||||
|
||||
func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.APICalls++
|
||||
if s.RejectBearer || !s.bearerValidLocked(r) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid_token"})
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/")
|
||||
switch {
|
||||
case path == "accounts/"+s.ClientID+"/secrets":
|
||||
if s.MalformedListBody {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"data": [broken`)
|
||||
return
|
||||
}
|
||||
list := make([]map[string]any, 0, len(s.Secrets))
|
||||
for _, sec := range s.Secrets {
|
||||
item := map[string]any{
|
||||
"object": "secret",
|
||||
"id": sec.ID,
|
||||
"organizationId": s.OrgID,
|
||||
"key": s.seal(sec.Name),
|
||||
"note": s.seal(sec.Note),
|
||||
"creationDate": "2026-08-28T00:00:00Z",
|
||||
"revisionDate": "2026-08-28T00:00:00Z",
|
||||
"read": true,
|
||||
"write": false,
|
||||
}
|
||||
if !s.OmitValueInList {
|
||||
item["value"] = s.sealValue(sec.Value)
|
||||
}
|
||||
list = append(list, item)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": list, "continuationToken": nil})
|
||||
case strings.HasPrefix(path, "secrets/"):
|
||||
id := strings.TrimPrefix(path, "secrets/")
|
||||
for _, sec := range s.Secrets {
|
||||
if sec.ID == id {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"object": "secretDetails",
|
||||
"id": sec.ID,
|
||||
"organizationId": s.OrgID,
|
||||
"key": s.seal(sec.Name),
|
||||
"value": s.sealValue(sec.Value),
|
||||
"note": s.seal(sec.Note),
|
||||
"creationDate": "2026-08-28T00:00:00Z",
|
||||
"revisionDate": "2026-08-28T00:00:00Z",
|
||||
"read": true,
|
||||
"write": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
|
||||
case path == "accounts/"+s.ClientID+"/projects":
|
||||
list := make([]map[string]any, 0, len(s.Projects))
|
||||
for _, p := range s.Projects {
|
||||
list = append(list, map[string]any{
|
||||
"object": "project",
|
||||
"id": p.ID,
|
||||
"organizationId": s.OrgID,
|
||||
"name": s.seal(p.Name),
|
||||
"creationDate": "2026-08-28T00:00:00Z",
|
||||
"revisionDate": "2026-08-28T00:00:00Z",
|
||||
"read": true,
|
||||
"write": false,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"object": "list", "data": list, "continuationToken": nil})
|
||||
default:
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"message": "Not found."})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) bearerValidLocked(r *http.Request) bool {
|
||||
auth := r.Header.Get("Authorization")
|
||||
const prefix = "Bearer "
|
||||
if !strings.HasPrefix(auth, prefix) {
|
||||
return false
|
||||
}
|
||||
exp, ok := s.issued[strings.TrimPrefix(auth, prefix)]
|
||||
return ok && exp.After(time.Now())
|
||||
}
|
||||
|
||||
// seal encrypts v with the organization key (nil org key = plaintext
|
||||
// server mode).
|
||||
func (s *Server) seal(v string) string {
|
||||
if s.OrgKey == nil {
|
||||
return v
|
||||
}
|
||||
key, err := bw.NewSymmetricKey(s.OrgKey)
|
||||
if err != nil {
|
||||
panic("fakesm: org key: " + err.Error())
|
||||
}
|
||||
enc, err := bw.Encrypt(key, []byte(v))
|
||||
if err != nil {
|
||||
panic("fakesm: encrypt: " + err.Error())
|
||||
}
|
||||
return enc.String()
|
||||
}
|
||||
|
||||
// sealValue additionally supports MAC tampering for integrity tests.
|
||||
func (s *Server) sealValue(v string) string {
|
||||
out := s.seal(v)
|
||||
if !s.TamperSecretMAC || s.OrgKey == nil {
|
||||
return out
|
||||
}
|
||||
parts := strings.Split(out, ".")
|
||||
ct, err := base64.StdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
ct[0] ^= 0xFF
|
||||
parts[2] = base64.StdEncoding.EncodeToString(ct)
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
Reference in New Issue
Block a user