Skip to content

Commit 2526aa9

Browse files
committed
feat(teams): add CredentialStore interface + AES-256-GCM BBolt backend
Foundation for upstream token brokering (spec 074, MCP-1034). New server-edition package internal/teams/broker provides: - CredentialStore interface (Get/Put/Delete/List) as the abstraction seam for future external secret-manager backends (FR-023). - BBoltAESStore: bucket "user_upstream_credentials", keyed "<userID>:<serverKey>" for upstream creds and bare "<userID>" for idp subject tokens. serverKey follows the existing SHA256(name+url) scheme. - UpstreamCredential value model, JSON-serialized then AES-256-GCM encrypted with a per-record random nonce (FR-020) and isolated per user (FR-021). - Master key resolved from MCPPROXY_CRED_KEY env or teams.credential_encryption_key (32-byte base64). Missing key -> store disabled gracefully; present-but-invalid -> loud error (FR-022). - Decryption failure (e.g. rotated key) treated as record-absent, never fatal; List skips undecryptable records. Adds teams.credential_encryption_key config field as the config-side key source. TDD: roundtrip, per-user isolation, nonce uniqueness, expiry helpers, missing-key disabled path, key-changed -> not-found. Related spec: specs/074-upstream-token-brokering
1 parent 206bee3 commit 2526aa9

4 files changed

Lines changed: 752 additions & 0 deletions

File tree

internal/config/teams_config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@ type TeamsConfig struct {
1717
BearerTokenTTL Duration `json:"bearer_token_ttl,omitempty" mapstructure:"bearer-token-ttl"`
1818
WorkspaceIdleTimeout Duration `json:"workspace_idle_timeout,omitempty" mapstructure:"workspace-idle-timeout"`
1919
MaxUserServers int `json:"max_user_servers,omitempty" mapstructure:"max-user-servers"`
20+
21+
// CredentialEncryptionKey is the base64-encoded 32-byte AES-256 master key
22+
// used by the upstream token broker (spec 074) to encrypt stored
23+
// credentials at rest. The MCPPROXY_CRED_KEY environment variable takes
24+
// precedence over this value. When neither is set, the broker is disabled
25+
// gracefully (the rest of the gateway is unaffected).
26+
CredentialEncryptionKey string `json:"credential_encryption_key,omitempty" mapstructure:"credential-encryption-key"`
2027
}
2128

2229
// TeamsOAuthConfig holds OAuth identity provider configuration for the server edition.

internal/teams/broker/bbolt_aes.go

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
//go:build server
2+
3+
package broker
4+
5+
import (
6+
"crypto/aes"
7+
"crypto/cipher"
8+
"crypto/rand"
9+
"encoding/base64"
10+
"encoding/json"
11+
"fmt"
12+
"io"
13+
"strings"
14+
"time"
15+
16+
"go.etcd.io/bbolt"
17+
"go.uber.org/zap"
18+
)
19+
20+
// credentialBucket holds AES-256-GCM encrypted UpstreamCredential records.
21+
//
22+
// Key scheme:
23+
// - upstream credential: "<userID>:<serverKey>"
24+
// - idp subject token: "<userID>" (no colon)
25+
//
26+
// serverKey follows the existing SHA256(name+url) scheme from
27+
// internal/oauth.GenerateServerKey.
28+
const credentialBucket = "user_upstream_credentials" //nolint:gosec // bucket name, not a credential
29+
30+
// aesKeyLen is the required key length for AES-256.
31+
const aesKeyLen = 32
32+
33+
// BBoltAESStore is the default CredentialStore backed by BBolt with
34+
// AES-256-GCM authenticated encryption and a per-record random nonce.
35+
//
36+
// When no master key is configured the store is constructed in a disabled
37+
// state: every operation returns ErrStoreDisabled and the rest of the gateway
38+
// is unaffected (FR-022).
39+
type BBoltAESStore struct {
40+
db *bbolt.DB
41+
gcm cipher.AEAD // nil when disabled
42+
enabled bool
43+
logger *zap.Logger
44+
}
45+
46+
// NewBBoltAESStore constructs a credential store over the given BBolt database.
47+
//
48+
// base64Key is the base64-encoded 32-byte AES-256 master key (see
49+
// ResolveMasterKey). Behaviour by key state:
50+
// - empty key -> store disabled (no error); the condition is logged so it
51+
// can be surfaced at startup.
52+
// - present but invalid (bad base64 / wrong length) -> error (loud
53+
// misconfiguration, not silent degradation).
54+
// - valid 32-byte -> store enabled.
55+
func NewBBoltAESStore(db *bbolt.DB, base64Key string, logger *zap.Logger) (*BBoltAESStore, error) {
56+
if logger == nil {
57+
logger = zap.NewNop()
58+
}
59+
logger = logger.Named("credential-store")
60+
61+
if strings.TrimSpace(base64Key) == "" {
62+
logger.Warn("upstream credential broker disabled: no encryption key configured " +
63+
"(set MCPPROXY_CRED_KEY or teams.credential_encryption_key to enable)")
64+
return &BBoltAESStore{db: db, enabled: false, logger: logger}, nil
65+
}
66+
67+
key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(base64Key))
68+
if err != nil {
69+
return nil, fmt.Errorf("decode credential encryption key (must be base64): %w", err)
70+
}
71+
if len(key) != aesKeyLen {
72+
return nil, fmt.Errorf("credential encryption key must be %d bytes (got %d) for AES-256", aesKeyLen, len(key))
73+
}
74+
75+
block, err := aes.NewCipher(key)
76+
if err != nil {
77+
return nil, fmt.Errorf("create AES cipher: %w", err)
78+
}
79+
gcm, err := cipher.NewGCM(block)
80+
if err != nil {
81+
return nil, fmt.Errorf("create GCM: %w", err)
82+
}
83+
84+
// Ensure the bucket exists up front so reads on a fresh DB don't trip on a
85+
// missing bucket.
86+
if err := db.Update(func(tx *bbolt.Tx) error {
87+
_, e := tx.CreateBucketIfNotExists([]byte(credentialBucket))
88+
return e
89+
}); err != nil {
90+
return nil, fmt.Errorf("init credential bucket: %w", err)
91+
}
92+
93+
logger.Info("upstream credential broker enabled (AES-256-GCM at rest)")
94+
return &BBoltAESStore{db: db, gcm: gcm, enabled: true, logger: logger}, nil
95+
}
96+
97+
// Enabled reports whether a usable encryption key is configured.
98+
func (s *BBoltAESStore) Enabled() bool { return s.enabled }
99+
100+
// recordKey builds the BBolt key for a (userID, serverKey) pair. An empty
101+
// serverKey yields the bare userID, used for the idp subject token.
102+
func recordKey(userID, serverKey string) string {
103+
if serverKey == "" {
104+
return userID
105+
}
106+
return userID + ":" + serverKey
107+
}
108+
109+
// Get implements CredentialStore.
110+
func (s *BBoltAESStore) Get(userID, serverKey string) (*UpstreamCredential, error) {
111+
if !s.enabled {
112+
return nil, ErrStoreDisabled
113+
}
114+
key := recordKey(userID, serverKey)
115+
116+
var ciphertext []byte
117+
if err := s.db.View(func(tx *bbolt.Tx) error {
118+
b := tx.Bucket([]byte(credentialBucket))
119+
if b == nil {
120+
return nil
121+
}
122+
if v := b.Get([]byte(key)); v != nil {
123+
ciphertext = append(ciphertext, v...)
124+
}
125+
return nil
126+
}); err != nil {
127+
return nil, fmt.Errorf("read credential: %w", err)
128+
}
129+
if ciphertext == nil {
130+
return nil, ErrNotFound
131+
}
132+
133+
cred, err := s.decrypt(ciphertext)
134+
if err != nil {
135+
// Undecryptable (e.g. rotated key) -> treat as absent, never crash.
136+
s.logger.Warn("credential record could not be decrypted; treating as absent",
137+
zap.String("user", userID), zap.String("server_key", serverKey), zap.Error(err))
138+
return nil, ErrNotFound
139+
}
140+
return cred, nil
141+
}
142+
143+
// Put implements CredentialStore.
144+
func (s *BBoltAESStore) Put(userID, serverKey string, cred *UpstreamCredential) error {
145+
if !s.enabled {
146+
return ErrStoreDisabled
147+
}
148+
if cred == nil {
149+
return fmt.Errorf("nil credential")
150+
}
151+
cred.UpdatedAt = time.Now().UTC()
152+
153+
ciphertext, err := s.encrypt(cred)
154+
if err != nil {
155+
return fmt.Errorf("encrypt credential: %w", err)
156+
}
157+
key := recordKey(userID, serverKey)
158+
if err := s.db.Update(func(tx *bbolt.Tx) error {
159+
b, e := tx.CreateBucketIfNotExists([]byte(credentialBucket))
160+
if e != nil {
161+
return e
162+
}
163+
return b.Put([]byte(key), ciphertext)
164+
}); err != nil {
165+
return fmt.Errorf("write credential: %w", err)
166+
}
167+
return nil
168+
}
169+
170+
// Delete implements CredentialStore.
171+
func (s *BBoltAESStore) Delete(userID, serverKey string) error {
172+
if !s.enabled {
173+
return ErrStoreDisabled
174+
}
175+
key := recordKey(userID, serverKey)
176+
if err := s.db.Update(func(tx *bbolt.Tx) error {
177+
b := tx.Bucket([]byte(credentialBucket))
178+
if b == nil {
179+
return nil
180+
}
181+
return b.Delete([]byte(key))
182+
}); err != nil {
183+
return fmt.Errorf("delete credential: %w", err)
184+
}
185+
return nil
186+
}
187+
188+
// List implements CredentialStore. It returns only upstream credentials
189+
// (prefix "<userID>:"); the idp subject token (bare userID) is excluded.
190+
// Undecryptable records are skipped rather than failing the whole listing.
191+
func (s *BBoltAESStore) List(userID string) ([]CredentialEntry, error) {
192+
if !s.enabled {
193+
return nil, ErrStoreDisabled
194+
}
195+
prefix := []byte(userID + ":")
196+
197+
var entries []CredentialEntry
198+
if err := s.db.View(func(tx *bbolt.Tx) error {
199+
b := tx.Bucket([]byte(credentialBucket))
200+
if b == nil {
201+
return nil
202+
}
203+
c := b.Cursor()
204+
for k, v := c.Seek(prefix); k != nil && hasPrefix(k, prefix); k, v = c.Next() {
205+
cred, err := s.decrypt(v)
206+
if err != nil {
207+
s.logger.Warn("skipping undecryptable credential in list",
208+
zap.String("user", userID), zap.ByteString("key", k), zap.Error(err))
209+
continue
210+
}
211+
entries = append(entries, CredentialEntry{
212+
ServerKey: string(k[len(prefix):]),
213+
Credential: cred,
214+
})
215+
}
216+
return nil
217+
}); err != nil {
218+
return nil, fmt.Errorf("list credentials: %w", err)
219+
}
220+
return entries, nil
221+
}
222+
223+
// hasPrefix reports whether b begins with prefix.
224+
func hasPrefix(b, prefix []byte) bool {
225+
if len(b) < len(prefix) {
226+
return false
227+
}
228+
for i := range prefix {
229+
if b[i] != prefix[i] {
230+
return false
231+
}
232+
}
233+
return true
234+
}
235+
236+
// encrypt serializes the credential to JSON and AES-256-GCM encrypts it,
237+
// returning nonce||ciphertext with a fresh random nonce per call (FR-020).
238+
func (s *BBoltAESStore) encrypt(cred *UpstreamCredential) ([]byte, error) {
239+
plaintext, err := json.Marshal(cred)
240+
if err != nil {
241+
return nil, err
242+
}
243+
nonce := make([]byte, s.gcm.NonceSize())
244+
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
245+
return nil, fmt.Errorf("generate nonce: %w", err)
246+
}
247+
// Seal appends the ciphertext to nonce, yielding nonce||ciphertext.
248+
return s.gcm.Seal(nonce, nonce, plaintext, nil), nil
249+
}
250+
251+
// decrypt reverses encrypt. It returns an error on any tampering, truncation,
252+
// or wrong-key condition (callers treat that as ErrNotFound).
253+
func (s *BBoltAESStore) decrypt(data []byte) (*UpstreamCredential, error) {
254+
ns := s.gcm.NonceSize()
255+
if len(data) < ns {
256+
return nil, fmt.Errorf("ciphertext too short")
257+
}
258+
nonce, ciphertext := data[:ns], data[ns:]
259+
plaintext, err := s.gcm.Open(nil, nonce, ciphertext, nil)
260+
if err != nil {
261+
return nil, err
262+
}
263+
var cred UpstreamCredential
264+
if err := json.Unmarshal(plaintext, &cred); err != nil {
265+
return nil, err
266+
}
267+
return &cred, nil
268+
}

0 commit comments

Comments
 (0)