// 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": ""}. 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) }