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.
399 lines
13 KiB
Go
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)
|
|
}
|