Files
mrcharles c94c0b1de4 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.
2026-08-29 00:08:31 -05:00

399 lines
13 KiB
Go

// 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)
}