사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2034 lines
66 KiB
Go
2034 lines
66 KiB
Go
package credentialops
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zapcore"
|
|
"go.uber.org/zap/zaptest"
|
|
|
|
"iop/apps/control-plane/internal/credentialseal"
|
|
"iop/apps/control-plane/internal/credentialstore"
|
|
)
|
|
|
|
// fakeSealer is a deterministic test sealer that produces a fixed envelope
|
|
// from any input. It records each Seal call for assertion.
|
|
type fakeSealer struct {
|
|
mu sync.Mutex
|
|
calls []sealCall
|
|
envelope credentialstore.SecretEnvelope
|
|
err error
|
|
}
|
|
|
|
type sealCall struct {
|
|
plaintext []byte
|
|
ctx_ credentialseal.Context
|
|
}
|
|
|
|
func newFakeSealer(t *testing.T) *fakeSealer {
|
|
t.Helper()
|
|
return &fakeSealer{
|
|
envelope: credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "fake-key",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("fake-nonce-01234"),
|
|
Ciphertext: []byte("fake-ciphertext-val"),
|
|
AAD: []byte("fake-aad-val"),
|
|
},
|
|
}
|
|
}
|
|
|
|
func (f *fakeSealer) Seal(_ context.Context, plaintext []byte, ctx_ credentialseal.Context) (credentialstore.SecretEnvelope, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.err != nil {
|
|
return credentialstore.SecretEnvelope{}, f.err
|
|
}
|
|
f.calls = append(f.calls, sealCall{plaintext: append([]byte(nil), plaintext...), ctx_: ctx_})
|
|
return f.envelope, nil
|
|
}
|
|
|
|
func (f *fakeSealer) callCount() int {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return len(f.calls)
|
|
}
|
|
|
|
type logCapture struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (lc *logCapture) Write(p []byte) (n int, err error) {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
return lc.buf.Write(p)
|
|
}
|
|
|
|
func (lc *logCapture) String() string {
|
|
lc.mu.Lock()
|
|
defer lc.mu.Unlock()
|
|
return lc.buf.String()
|
|
}
|
|
|
|
func newTestServiceWithLogs(t *testing.T, sealer SecretSealer) (*Service, *credentialstore.Store, *credentialstore.IssuedPrincipal, *logCapture) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
reg := credentialstore.EnvelopeKeyRegistry(newFakeKeyRegistryForService())
|
|
dbPath := "file:iop_credentialops_test_" + strings.ReplaceAll(t.Name(), "/", "_") + ".db?mode=memory&cache=shared"
|
|
store, err := credentialstore.Open(ctx, dbPath, credentialstore.WithEnvelopeKeyRegistry(reg))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, credentialstore.CreatePrincipalInput{Alias: "test-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create principal: %v", err)
|
|
}
|
|
|
|
capture := &logCapture{}
|
|
core := zapcore.NewCore(zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()), zapcore.AddSync(capture), zapcore.DebugLevel)
|
|
logger := zap.New(core)
|
|
|
|
svc := NewService(store, logger, sealer, nil)
|
|
return svc, store, issued, capture
|
|
}
|
|
|
|
func newTestService(t *testing.T, sealer SecretSealer) (*Service, *credentialstore.Store, *credentialstore.IssuedPrincipal) {
|
|
t.Helper()
|
|
svc, store, issued, _ := newTestServiceWithLogs(t, sealer)
|
|
return svc, store, issued
|
|
}
|
|
|
|
type fakeKeyRegistryForService struct {
|
|
keys map[string]map[uint64]bool
|
|
}
|
|
|
|
func newFakeKeyRegistryForService() *fakeKeyRegistryForService {
|
|
f := &fakeKeyRegistryForService{keys: make(map[string]map[uint64]bool)}
|
|
f.RegisterKey("fake-key", 1)
|
|
return f
|
|
}
|
|
|
|
func (f *fakeKeyRegistryForService) RegisterKey(keyID string, version uint64) {
|
|
if f.keys[keyID] == nil {
|
|
f.keys[keyID] = make(map[uint64]bool)
|
|
}
|
|
f.keys[keyID][version] = true
|
|
}
|
|
|
|
func (f *fakeKeyRegistryForService) HasEnvelopeKey(_ context.Context, keyID string, keyVersion uint64) (bool, error) {
|
|
if versions, ok := f.keys[keyID]; ok {
|
|
return versions[keyVersion], nil
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
var _ credentialstore.EnvelopeKeyRegistry = (*fakeKeyRegistryForService)(nil)
|
|
|
|
func digestOf(raw []byte) string {
|
|
sum := sha256.Sum256(raw)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Own-principal lifecycle & Create/Rotate Secret Handoff
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceCreateAndRotatePassSuppliedSecretOnlyToSealer(t *testing.T) {
|
|
fake := newFakeSealer(t)
|
|
svc, store, issued := newTestService(t, fake)
|
|
ctx := context.Background()
|
|
|
|
secret1 := []byte("provider-secret-payload-alpha")
|
|
created, err := svc.CreateSlot(ctx, []byte(issued.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "create-slot-1",
|
|
ProviderSecret: secret1,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
if created.Alias != "create-slot-1" {
|
|
t.Fatalf("CreateSlot alias mismatch: got %q want create-slot-1", created.Alias)
|
|
}
|
|
if created.Status != credentialstore.StatusDraft {
|
|
t.Fatalf("CreateSlot initial status: got %q want draft", created.Status)
|
|
}
|
|
|
|
if fake.callCount() != 1 {
|
|
t.Fatalf("sealer call count after create: got %d want 1", fake.callCount())
|
|
}
|
|
if string(fake.calls[0].plaintext) != "provider-secret-payload-alpha" {
|
|
t.Fatalf("sealer received plaintext mismatch: got %q", string(fake.calls[0].plaintext))
|
|
}
|
|
if fake.calls[0].ctx_.PrincipalID != issued.Principal.ID {
|
|
t.Fatalf("sealer ctx PrincipalID mismatch: got %q want %q", fake.calls[0].ctx_.PrincipalID, issued.Principal.ID)
|
|
}
|
|
|
|
storedSlot, err := store.GetSlot(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot from store: %v", err)
|
|
}
|
|
if string(storedSlot.Envelope.Ciphertext) != "fake-ciphertext-val" {
|
|
t.Fatalf("stored envelope ciphertext mismatch: got %q", string(storedSlot.Envelope.Ciphertext))
|
|
}
|
|
|
|
_, err = svc.CreateRoute(ctx, []byte(issued.RawToken), CreateRouteInput{
|
|
SlotID: created.ID,
|
|
Alias: "route-1",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoute: %v", err)
|
|
}
|
|
activeSlot, err := svc.GetSlot(ctx, []byte(issued.RawToken), created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
|
|
secret2 := []byte("provider-secret-payload-beta")
|
|
rotated, err := svc.RotateSlot(ctx, []byte(issued.RawToken), RotateSlotInput{
|
|
SlotID: created.ID,
|
|
Revision: activeSlot.Revision,
|
|
ProviderSecret: secret2,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RotateSlot: %v", err)
|
|
}
|
|
if rotated.Revision != activeSlot.Revision+1 {
|
|
t.Fatalf("RotateSlot revision: got %d want %d", rotated.Revision, activeSlot.Revision+1)
|
|
}
|
|
|
|
if fake.callCount() != 2 {
|
|
t.Fatalf("sealer call count after rotate: got %d want 2", fake.callCount())
|
|
}
|
|
if string(fake.calls[1].plaintext) != "provider-secret-payload-beta" {
|
|
t.Fatalf("sealer received plaintext mismatch on rotate: got %q", string(fake.calls[1].plaintext))
|
|
}
|
|
if fake.calls[1].ctx_.SlotID != created.ID {
|
|
t.Fatalf("sealer ctx SlotID mismatch: got %q want %q", fake.calls[1].ctx_.SlotID, created.ID)
|
|
}
|
|
}
|
|
|
|
func TestServiceScopesOperationsToAuthenticatedPrincipal(t *testing.T) {
|
|
svc, _, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
slot, err := svc.CreateSlot(ctx, []byte(issued.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "lifecycle-slot",
|
|
ProviderSecret: []byte("secret-data"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
route, err := svc.CreateRoute(ctx, []byte(issued.RawToken), CreateRouteInput{
|
|
SlotID: slot.ID,
|
|
Alias: "lifecycle-route",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoute: %v", err)
|
|
}
|
|
_ = route
|
|
|
|
activeSlot, err := svc.GetSlot(ctx, []byte(issued.RawToken), slot.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
if activeSlot.Status != credentialstore.StatusActive {
|
|
t.Fatalf("GetSlot status: got %q want active", activeSlot.Status)
|
|
}
|
|
|
|
disabled, err := svc.DisableSlot(ctx, []byte(issued.RawToken), slot.ID, activeSlot.Revision)
|
|
if err != nil {
|
|
t.Fatalf("DisableSlot: %v", err)
|
|
}
|
|
if disabled.Status != credentialstore.StatusDisabled {
|
|
t.Fatalf("DisableSlot status: got %q want disabled", disabled.Status)
|
|
}
|
|
|
|
enabled, err := svc.EnableSlot(ctx, []byte(issued.RawToken), slot.ID, disabled.Revision)
|
|
if err != nil {
|
|
t.Fatalf("EnableSlot: %v", err)
|
|
}
|
|
if enabled.Status != credentialstore.StatusActive {
|
|
t.Fatalf("EnableSlot status: got %q want active", enabled.Status)
|
|
}
|
|
|
|
rotated, err := svc.RotateSlot(ctx, []byte(issued.RawToken), RotateSlotInput{
|
|
SlotID: slot.ID,
|
|
Revision: enabled.Revision,
|
|
ProviderSecret: []byte("new-secret-data"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RotateSlot: %v", err)
|
|
}
|
|
if rotated.Revision != enabled.Revision+1 {
|
|
t.Fatalf("RotateSlot revision: got %d want %d", rotated.Revision, enabled.Revision+1)
|
|
}
|
|
|
|
revoked, err := svc.RevokeSlot(ctx, []byte(issued.RawToken), slot.ID, rotated.Revision)
|
|
if err != nil {
|
|
t.Fatalf("RevokeSlot: %v", err)
|
|
}
|
|
if revoked.Status != credentialstore.StatusRevoked {
|
|
t.Fatalf("RevokeSlot status: got %q want revoked", revoked.Status)
|
|
}
|
|
if revoked.RevokedAt == nil {
|
|
t.Fatal("RevokeSlot must set RevokedAt")
|
|
}
|
|
|
|
slots, err := svc.ListSlots(ctx, []byte(issued.RawToken))
|
|
if err != nil {
|
|
t.Fatalf("ListSlots: %v", err)
|
|
}
|
|
if len(slots) != 1 {
|
|
t.Fatalf("ListSlots count: got %d want 1", len(slots))
|
|
}
|
|
if slots[0].Status != credentialstore.StatusRevoked {
|
|
t.Fatalf("ListSlots status: got %q want revoked", slots[0].Status)
|
|
}
|
|
}
|
|
|
|
func TestServiceTokenCreateListLifecycle(t *testing.T) {
|
|
svc, _, issued1 := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
token1 := []byte(issued1.RawToken)
|
|
|
|
tok2, err := svc.CreateToken(ctx, token1)
|
|
if err != nil {
|
|
t.Fatalf("CreateToken 2: %v", err)
|
|
}
|
|
if tok2.RawToken == "" {
|
|
t.Fatal("CreateToken must return raw token once")
|
|
}
|
|
|
|
tok3, err := svc.CreateToken(ctx, token1)
|
|
if err != nil {
|
|
t.Fatalf("CreateToken 3: %v", err)
|
|
}
|
|
if tok3.RawToken == "" {
|
|
t.Fatal("CreateToken must return raw token once")
|
|
}
|
|
|
|
tokens, err := svc.ListTokens(ctx, token1)
|
|
if err != nil {
|
|
t.Fatalf("ListTokens: %v", err)
|
|
}
|
|
if len(tokens) != 3 {
|
|
t.Fatalf("ListTokens count: got %d want 3", len(tokens))
|
|
}
|
|
for _, tok := range tokens {
|
|
if tok.Status != credentialstore.StatusActive {
|
|
t.Fatalf("token status: got %q want active", tok.Status)
|
|
}
|
|
}
|
|
|
|
disabledTok2, err := svc.DisableToken(ctx, token1, tok2.Token.TokenRef, tok2.Token.Revision)
|
|
if err != nil {
|
|
t.Fatalf("DisableToken tok2: %v", err)
|
|
}
|
|
if disabledTok2.Status != credentialstore.StatusDisabled {
|
|
t.Fatalf("DisableToken status: got %q want disabled", disabledTok2.Status)
|
|
}
|
|
|
|
_, err = svc.DisableToken(ctx, token1, tok2.Token.TokenRef, tok2.Token.Revision)
|
|
if !errors.Is(err, ErrStaleRevision) {
|
|
t.Fatalf("expected ErrStaleRevision for stale DisableToken, got: %v", err)
|
|
}
|
|
|
|
revokedTok2, err := svc.RevokeToken(ctx, token1, tok2.Token.TokenRef, disabledTok2.Revision)
|
|
if err != nil {
|
|
t.Fatalf("RevokeToken tok2: %v", err)
|
|
}
|
|
if revokedTok2.Status != credentialstore.StatusRevoked {
|
|
t.Fatalf("RevokeToken status: got %q want revoked", revokedTok2.Status)
|
|
}
|
|
if revokedTok2.RevokedAt == nil {
|
|
t.Fatal("RevokeToken must set RevokedAt")
|
|
}
|
|
|
|
_, err = svc.DisableToken(ctx, token1, issued1.Token.TokenRef, issued1.Token.Revision)
|
|
if err != nil {
|
|
t.Fatalf("DisableToken tok1: %v", err)
|
|
}
|
|
_, err = svc.ListSlots(ctx, token1)
|
|
if !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("expected ErrUnauthorized using disabled tok1, got: %v", err)
|
|
}
|
|
|
|
slotsTok3, err := svc.ListSlots(ctx, []byte(tok3.RawToken))
|
|
if err != nil {
|
|
t.Fatalf("ListSlots using active tok3: %v", err)
|
|
}
|
|
if len(slotsTok3) != 0 {
|
|
t.Fatalf("expected 0 slots, got %d", len(slotsTok3))
|
|
}
|
|
}
|
|
|
|
func TestServiceRejectsCrossPrincipalTokenReferences(t *testing.T) {
|
|
svc, store, issued1 := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
issued2, err := store.CreatePrincipalWithToken(ctx, credentialstore.CreatePrincipalInput{Alias: "p2-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create principal 2: %v", err)
|
|
}
|
|
|
|
token1 := []byte(issued1.RawToken)
|
|
token2 := []byte(issued2.RawToken)
|
|
|
|
tok1_2, err := svc.CreateToken(ctx, token1)
|
|
if err != nil {
|
|
t.Fatalf("CreateToken for p1: %v", err)
|
|
}
|
|
|
|
_, err = svc.DisableToken(ctx, token2, tok1_2.Token.TokenRef, tok1_2.Token.Revision)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal DisableToken, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.RevokeToken(ctx, token2, tok1_2.Token.TokenRef, tok1_2.Token.Revision)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal RevokeToken, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceRouteCreateUpdateLifecycle(t *testing.T) {
|
|
svc, _, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
token := []byte(issued.RawToken)
|
|
slot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "route-slot",
|
|
ProviderSecret: []byte("secret-payload"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
route, err := svc.CreateRoute(ctx, token, CreateRouteInput{
|
|
SlotID: slot.ID,
|
|
Alias: "route-alias",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
ResourceSelector: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoute: %v", err)
|
|
}
|
|
if route.Alias != "route-alias" {
|
|
t.Fatalf("Route alias mismatch: got %q want route-alias", route.Alias)
|
|
}
|
|
|
|
updatedRoute, err := svc.UpdateRoute(ctx, token, UpdateRouteInput{
|
|
RouteID: route.ID,
|
|
CurrentRevision: route.Revision,
|
|
SlotID: slot.ID,
|
|
Alias: "updated-route-alias",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o-mini",
|
|
ResourceSelector: "default",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateRoute: %v", err)
|
|
}
|
|
if updatedRoute.Alias != "updated-route-alias" {
|
|
t.Fatalf("Updated route alias: got %q want updated-route-alias", updatedRoute.Alias)
|
|
}
|
|
if updatedRoute.UpstreamModel != "gpt-4o-mini" {
|
|
t.Fatalf("Updated route model: got %q want gpt-4o-mini", updatedRoute.UpstreamModel)
|
|
}
|
|
if updatedRoute.Revision != route.Revision+1 {
|
|
t.Fatalf("Updated route revision: got %d want %d", updatedRoute.Revision, route.Revision+1)
|
|
}
|
|
|
|
_, err = svc.UpdateRoute(ctx, token, UpdateRouteInput{
|
|
RouteID: route.ID,
|
|
CurrentRevision: route.Revision,
|
|
SlotID: slot.ID,
|
|
Alias: "stale-update",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
ResourceSelector: "default",
|
|
})
|
|
if !errors.Is(err, ErrStaleRevision) {
|
|
t.Fatalf("expected ErrStaleRevision on UpdateRoute with stale rev, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Cross-principal denial
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceRejectsCrossPrincipalTargets(t *testing.T) {
|
|
svc, store, issued1 := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
issued2, err := store.CreatePrincipalWithToken(ctx, credentialstore.CreatePrincipalInput{Alias: "other-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create second principal: %v", err)
|
|
}
|
|
|
|
token1 := []byte(issued1.RawToken)
|
|
token2 := []byte(issued2.RawToken)
|
|
|
|
slot, err := svc.CreateSlot(ctx, token1, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "cross-slot",
|
|
ProviderSecret: []byte("p1-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
route, err := svc.CreateRoute(ctx, token1, CreateRouteInput{
|
|
SlotID: slot.ID,
|
|
Alias: "cross-route",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoute: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetSlot(ctx, token2, slot.ID)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal GetSlot, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.DisableSlot(ctx, token2, slot.ID, slot.Revision)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal DisableSlot, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.RotateSlot(ctx, token2, RotateSlotInput{SlotID: slot.ID, Revision: slot.Revision, ProviderSecret: []byte("hack")})
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal RotateSlot, got: %v", err)
|
|
}
|
|
|
|
slots, err := svc.ListSlots(ctx, token2)
|
|
if err != nil {
|
|
t.Fatalf("ListSlots for other principal: %v", err)
|
|
}
|
|
if len(slots) != 0 {
|
|
t.Fatalf("expected empty list for cross-principal, got %d slots", len(slots))
|
|
}
|
|
|
|
_, err = svc.GetRoute(ctx, token2, route.ID)
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal GetRoute, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.UpdateRoute(ctx, token2, UpdateRouteInput{
|
|
RouteID: route.ID,
|
|
CurrentRevision: route.Revision,
|
|
SlotID: slot.ID,
|
|
Alias: "p2-alias",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal UpdateRoute, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.CreateRoute(ctx, token2, CreateRouteInput{
|
|
SlotID: slot.ID,
|
|
Alias: "cross-bind",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for cross-principal CreateRoute targeting p1 slot, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Stale revision conflict
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceRejectsStaleRevision(t *testing.T) {
|
|
svc, _, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
token := []byte(issued.RawToken)
|
|
slot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "stale-slot",
|
|
ProviderSecret: []byte("stale-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
route, err := svc.CreateRoute(ctx, token, CreateRouteInput{
|
|
SlotID: slot.ID,
|
|
Alias: "stale-route",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoute: %v", err)
|
|
}
|
|
|
|
activeSlot, err := svc.GetSlot(ctx, token, slot.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
|
|
disabledSlot, err := svc.DisableSlot(ctx, token, slot.ID, activeSlot.Revision)
|
|
if err != nil {
|
|
t.Fatalf("first disable: %v", err)
|
|
}
|
|
|
|
_, err = svc.DisableSlot(ctx, token, slot.ID, activeSlot.Revision)
|
|
if !errors.Is(err, ErrStaleRevision) {
|
|
t.Fatalf("expected ErrStaleRevision on second DisableSlot, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.RotateSlot(ctx, token, RotateSlotInput{SlotID: slot.ID, Revision: activeSlot.Revision, ProviderSecret: []byte("new-sec")})
|
|
if !errors.Is(err, ErrStaleRevision) {
|
|
t.Fatalf("expected ErrStaleRevision on RotateSlot, got: %v", err)
|
|
}
|
|
|
|
enabledSlot, err := svc.EnableSlot(ctx, token, slot.ID, disabledSlot.Revision)
|
|
if err != nil {
|
|
t.Fatalf("EnableSlot: %v", err)
|
|
}
|
|
|
|
updatedRoute, err := svc.UpdateRoute(ctx, token, UpdateRouteInput{
|
|
RouteID: route.ID,
|
|
CurrentRevision: route.Revision,
|
|
SlotID: enabledSlot.ID,
|
|
Alias: "updated-route",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o-mini",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("first UpdateRoute: %v", err)
|
|
}
|
|
_ = updatedRoute
|
|
|
|
_, err = svc.UpdateRoute(ctx, token, UpdateRouteInput{
|
|
RouteID: route.ID,
|
|
CurrentRevision: route.Revision,
|
|
SlotID: enabledSlot.ID,
|
|
Alias: "stale-update",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if !errors.Is(err, ErrStaleRevision) {
|
|
t.Fatalf("expected ErrStaleRevision on UpdateRoute with stale rev, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Missing sealer failure (fail-closed)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceRequiresSealerForSecretMutation(t *testing.T) {
|
|
ctx := context.Background()
|
|
reg := credentialstore.EnvelopeKeyRegistry(newFakeKeyRegistryForService())
|
|
dbPath := "file:iop_credentialops_nosealer_" + strings.ReplaceAll(t.Name(), "/", "_") + ".db?mode=memory&cache=shared"
|
|
store, err := credentialstore.Open(ctx, dbPath, credentialstore.WithEnvelopeKeyRegistry(reg))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, credentialstore.CreatePrincipalInput{Alias: "nosealer-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create principal: %v", err)
|
|
}
|
|
|
|
capture := &logCapture{}
|
|
core := zapcore.NewCore(zapcore.NewConsoleEncoder(zap.NewDevelopmentEncoderConfig()), zapcore.AddSync(capture), zapcore.DebugLevel)
|
|
logger := zap.New(core)
|
|
|
|
svcWithoutSealer := NewService(store, logger, nil, nil)
|
|
|
|
_, err = svcWithoutSealer.CreateSlot(ctx, []byte(issued.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "nosealer-slot",
|
|
ProviderSecret: []byte("nosealer-secret"),
|
|
})
|
|
if !errors.Is(err, ErrSealerUnavailable) {
|
|
t.Fatalf("expected ErrSealerUnavailable on CreateSlot without sealer, got: %v", err)
|
|
}
|
|
|
|
slots, err := store.ListSlots(ctx, issued.Principal.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSlots: %v", err)
|
|
}
|
|
if len(slots) != 0 {
|
|
t.Fatalf("expected 0 slots after failed CreateSlot, got %d", len(slots))
|
|
}
|
|
|
|
svcWithSealer := NewService(store, logger, newFakeSealer(t), nil)
|
|
slot, err := svcWithSealer.CreateSlot(ctx, []byte(issued.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "sealer-slot",
|
|
ProviderSecret: []byte("sealer-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot with sealer: %v", err)
|
|
}
|
|
|
|
_, err = svcWithoutSealer.RotateSlot(ctx, []byte(issued.RawToken), RotateSlotInput{
|
|
SlotID: slot.ID,
|
|
Revision: slot.Revision,
|
|
ProviderSecret: []byte("new-secret"),
|
|
})
|
|
if !errors.Is(err, ErrSealerUnavailable) {
|
|
t.Fatalf("expected ErrSealerUnavailable on RotateSlot without sealer, got: %v", err)
|
|
}
|
|
|
|
slotAfter, err := store.GetSlot(ctx, issued.Principal.ID, slot.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
if slotAfter.Revision != slot.Revision {
|
|
t.Fatalf("revision changed after failed RotateSlot: got %d want %d", slotAfter.Revision, slot.Revision)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Secret-blind responses & Redaction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceResponsesAreSecretBlind(t *testing.T) {
|
|
if _, ok := reflect.TypeOf(SlotRecord{}).FieldByName("Envelope"); ok {
|
|
t.Fatal("secret-blind SlotRecord must not expose an envelope")
|
|
}
|
|
|
|
rawIOPTokenSentinel := "RAW_IOP_TOKEN_SENTINEL_998877"
|
|
providerSecretSentinel := "PROVIDER_SECRET_SENTINEL_665544"
|
|
ciphertextSentinel := "CIPHERTEXT_SENTINEL_332211"
|
|
nonceSentinel := "NONCE_SENTINEL_009988"
|
|
aadSentinel := "AAD_SENTINEL_776655"
|
|
digestSentinel := digestOf([]byte(rawIOPTokenSentinel))
|
|
|
|
fake := &fakeSealer{
|
|
envelope: credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "fake-key",
|
|
KeyVersion: 1,
|
|
Nonce: []byte(nonceSentinel),
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
},
|
|
}
|
|
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, fake)
|
|
ctx := context.Background()
|
|
|
|
// Direct DB transaction to set digest sentinel on token row for auth test setup.
|
|
err := store.RunInTransaction(ctx, func(ctx context.Context, tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx, "UPDATE tokens SET digest=?, token_ref=? WHERE principal_id=?", digestSentinel, tokenRefForSentinel(digestSentinel), issued.Principal.ID)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update token digest sentinel: %v", err)
|
|
}
|
|
|
|
token := []byte(rawIOPTokenSentinel)
|
|
slot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "blind-slot",
|
|
ProviderSecret: []byte(providerSecretSentinel),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
route, err := svc.CreateRoute(ctx, token, CreateRouteInput{
|
|
SlotID: slot.ID,
|
|
Alias: "blind-route",
|
|
ProfileID: "openai",
|
|
UpstreamModel: "gpt-4o",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateRoute: %v", err)
|
|
}
|
|
|
|
activeSlot, err := svc.GetSlot(ctx, token, slot.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
|
|
rotatedSlot, err := svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: slot.ID,
|
|
Revision: activeSlot.Revision,
|
|
ProviderSecret: []byte("SECOND_SECRET_SENTINEL"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RotateSlot: %v", err)
|
|
}
|
|
|
|
gotSlot, err := svc.GetSlot(ctx, token, slot.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
listedSlots, err := svc.ListSlots(ctx, token)
|
|
if err != nil {
|
|
t.Fatalf("ListSlots: %v", err)
|
|
}
|
|
gotRoute, err := svc.GetRoute(ctx, token, route.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetRoute: %v", err)
|
|
}
|
|
listedRoutes, err := svc.ListRoutes(ctx, token)
|
|
if err != nil {
|
|
t.Fatalf("ListRoutes: %v", err)
|
|
}
|
|
|
|
_, errNotFound := svc.GetSlot(ctx, token, "nonexistent-slot")
|
|
|
|
var rendered strings.Builder
|
|
rendered.WriteString(fmt.Sprintf("%+v\n", slot))
|
|
rendered.WriteString(fmt.Sprintf("%+v\n", rotatedSlot))
|
|
rendered.WriteString(fmt.Sprintf("%+v\n", gotSlot))
|
|
rendered.WriteString(fmt.Sprintf("%+v\n", listedSlots))
|
|
rendered.WriteString(fmt.Sprintf("%+v\n", gotRoute))
|
|
rendered.WriteString(fmt.Sprintf("%+v\n", listedRoutes))
|
|
if errNotFound != nil {
|
|
rendered.WriteString(errNotFound.Error() + "\n")
|
|
}
|
|
rendered.WriteString(capture.String() + "\n")
|
|
|
|
renderedStr := rendered.String()
|
|
for _, forbidden := range []string{rawIOPTokenSentinel, providerSecretSentinel, ciphertextSentinel, nonceSentinel, aadSentinel, digestSentinel, "SECOND_SECRET_SENTINEL"} {
|
|
if strings.Contains(renderedStr, forbidden) {
|
|
t.Fatalf("rendered responses/errors/logs contains sensitive sentinel %q", forbidden)
|
|
}
|
|
}
|
|
}
|
|
|
|
func tokenRefForSentinel(digest string) string {
|
|
if len(digest) > 16 {
|
|
return "tok_" + digest[:16]
|
|
}
|
|
return "tok_" + digest
|
|
}
|
|
|
|
func TestServiceRedact(t *testing.T) {
|
|
svc, _, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
token := []byte(issued.RawToken)
|
|
|
|
_, err := svc.GetSlot(ctx, token, "nonexistent")
|
|
if err == nil {
|
|
t.Fatal("expected error")
|
|
}
|
|
if strings.Contains(err.Error(), issued.RawToken) {
|
|
t.Fatalf("error leaks raw token: %v", err)
|
|
}
|
|
}
|
|
|
|
type retainingSealer struct {
|
|
retained []byte
|
|
envelope credentialstore.SecretEnvelope
|
|
err error
|
|
}
|
|
|
|
func (r *retainingSealer) Seal(_ context.Context, plaintext []byte, _ credentialseal.Context) (credentialstore.SecretEnvelope, error) {
|
|
r.retained = plaintext
|
|
return r.envelope, r.err
|
|
}
|
|
|
|
func TestServiceZeroizesOwnedProviderSecret(t *testing.T) {
|
|
ctx := context.Background()
|
|
validEnv := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "fake-key",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("fake-nonce-01234"),
|
|
Ciphertext: []byte("fake-ciphertext-val"),
|
|
}
|
|
|
|
t.Run("success paths zeroize owned and preserve caller", func(t *testing.T) {
|
|
retSealer := &retainingSealer{envelope: validEnv}
|
|
svc, _, issued := newTestService(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
secret := []byte("super-secret-provider-key")
|
|
secretCopy := append([]byte(nil), secret...)
|
|
slot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "zeroize-slot",
|
|
ProviderSecret: secret,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext slice")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d is non-zero (%d), expected zeroization", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(secret, secretCopy) {
|
|
t.Fatalf("caller secret buffer mutated on CreateSlot: got %q want %q", secret, secretCopy)
|
|
}
|
|
|
|
retSealer.retained = nil
|
|
rotateSecret := []byte("rotated-super-secret-key")
|
|
rotateCopy := append([]byte(nil), rotateSecret...)
|
|
_, err = svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: slot.ID,
|
|
Revision: slot.Revision,
|
|
ProviderSecret: rotateSecret,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RotateSlot: %v", err)
|
|
}
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext slice on rotate")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d on rotate is non-zero (%d), expected zeroization", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(rotateSecret, rotateCopy) {
|
|
t.Fatalf("caller rotate secret buffer mutated on RotateSlot: got %q want %q", rotateSecret, rotateCopy)
|
|
}
|
|
})
|
|
|
|
t.Run("failing paths zeroize owned and preserve caller", func(t *testing.T) {
|
|
retSealer := &retainingSealer{
|
|
envelope: validEnv,
|
|
err: errors.New("sealer error"),
|
|
}
|
|
svc, _, issued := newTestService(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
failCreateSecret := []byte("fail-create-secret")
|
|
failCreateCopy := append([]byte(nil), failCreateSecret...)
|
|
_, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "fail-create-slot",
|
|
ProviderSecret: failCreateSecret,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error on CreateSlot with failing sealer")
|
|
}
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext slice on failing CreateSlot")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d on failing CreateSlot is non-zero (%d), expected zeroization", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(failCreateSecret, failCreateCopy) {
|
|
t.Fatalf("caller secret buffer mutated on failing CreateSlot: got %q want %q", failCreateSecret, failCreateCopy)
|
|
}
|
|
|
|
retSealer.err = nil
|
|
retSealer.retained = nil
|
|
validSlot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "valid-slot-for-rotate",
|
|
ProviderSecret: []byte("valid-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot for valid slot: %v", err)
|
|
}
|
|
|
|
retSealer.err = errors.New("rotate sealer error")
|
|
retSealer.retained = nil
|
|
failRotateSecret := []byte("fail-rotate-secret")
|
|
failRotateCopy := append([]byte(nil), failRotateSecret...)
|
|
_, err = svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: validSlot.ID,
|
|
Revision: validSlot.Revision,
|
|
ProviderSecret: failRotateSecret,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error on RotateSlot with failing sealer")
|
|
}
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext slice on failing RotateSlot")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d on failing RotateSlot is non-zero (%d), expected zeroization", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(failRotateSecret, failRotateCopy) {
|
|
t.Fatalf("caller secret buffer mutated on failing RotateSlot: got %q want %q", failRotateSecret, failRotateCopy)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestServiceSecretHandlingFailureIsBlindAndDoesNotMutate(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
plaintextSentinel := "REVIEWER_PLAINTEXT_SECRET_SENTINEL_12345"
|
|
errorSentinel := "REVIEWER_SEALER_ERROR_SENTINEL_67890"
|
|
keyIDSentinel := "REVIEWER_KEY_ID_SENTINEL_UNKNOWN_999"
|
|
keyVersionSentinel := uint64(9998877)
|
|
keyVersionSentinelStr := "9998877"
|
|
ciphertextSentinel := "REVIEWER_CIPHERTEXT_SENTINEL_112233"
|
|
nonceSentinel := "REVIEWER_NONCE_SENTINEL_445566"
|
|
aadSentinel := "REVIEWER_AAD_SENTINEL_778899"
|
|
|
|
forbiddenSentinels := []string{
|
|
plaintextSentinel,
|
|
errorSentinel,
|
|
keyIDSentinel,
|
|
keyVersionSentinelStr,
|
|
ciphertextSentinel,
|
|
nonceSentinel,
|
|
aadSentinel,
|
|
}
|
|
|
|
validEnv := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: "fake-key",
|
|
KeyVersion: 1,
|
|
Nonce: []byte("fake-nonce-01234"),
|
|
Ciphertext: []byte("fake-ciphertext-val"),
|
|
}
|
|
|
|
mustGetSlot := func(t *testing.T, store *credentialstore.Store, pid, slotID string) *credentialstore.CredentialSlotRecord {
|
|
t.Helper()
|
|
s, err := store.GetSlot(ctx, pid, slotID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
mustListSlots := func(t *testing.T, store *credentialstore.Store, pid string) []credentialstore.CredentialSlotRecord {
|
|
t.Helper()
|
|
slots, err := store.ListSlots(ctx, pid)
|
|
if err != nil {
|
|
t.Fatalf("ListSlots: %v", err)
|
|
}
|
|
return slots
|
|
}
|
|
|
|
mustListSlotRevisions := func(t *testing.T, store *credentialstore.Store, pid, slotID string) []credentialstore.SlotRevisionRecord {
|
|
t.Helper()
|
|
revs, err := store.ListSlotRevisions(ctx, pid, slotID)
|
|
if err != nil {
|
|
t.Fatalf("ListSlotRevisions: %v", err)
|
|
}
|
|
return revs
|
|
}
|
|
|
|
mustProjectionGeneration := func(t *testing.T, store *credentialstore.Store) uint64 {
|
|
t.Helper()
|
|
gen, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration: %v", err)
|
|
}
|
|
return gen
|
|
}
|
|
|
|
assertBlindAndNoSentinels := func(t *testing.T, err error, capture fmt.Stringer) {
|
|
t.Helper()
|
|
if !errors.Is(err, ErrSecretHandlingFailed) {
|
|
t.Fatalf("expected ErrSecretHandlingFailed, got: %v", err)
|
|
}
|
|
errMsg := ""
|
|
if err != nil {
|
|
errMsg = err.Error()
|
|
}
|
|
logMsg := ""
|
|
if capture != nil {
|
|
logMsg = capture.String()
|
|
}
|
|
for _, s := range forbiddenSentinels {
|
|
if s == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(errMsg, s) {
|
|
t.Fatalf("error leaks sensitive sentinel %q: %v", s, err)
|
|
}
|
|
if strings.Contains(logMsg, s) {
|
|
t.Fatalf("logs leak sensitive sentinel %q: %s", s, logMsg)
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Run("CreateSlot failing sealer", func(t *testing.T) {
|
|
failingEnvWithSentinels := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: keyIDSentinel,
|
|
KeyVersion: keyVersionSentinel,
|
|
Nonce: []byte(nonceSentinel),
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
}
|
|
retSealer := &retainingSealer{
|
|
envelope: failingEnvWithSentinels,
|
|
err: fmt.Errorf("sealer failed with sensitive error %s, plaintext %s, key %s, ver %d, nonce %s, cipher %s, aad %s", errorSentinel, plaintextSentinel, keyIDSentinel, keyVersionSentinel, nonceSentinel, ciphertextSentinel, aadSentinel),
|
|
}
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
genBefore := mustProjectionGeneration(t, store)
|
|
slotsBefore := mustListSlots(t, store, issued.Principal.ID)
|
|
|
|
callerSecret := []byte("secret-payload-" + plaintextSentinel)
|
|
callerCopy := append([]byte(nil), callerSecret...)
|
|
|
|
_, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "failing-slot",
|
|
ProviderSecret: callerSecret,
|
|
})
|
|
assertBlindAndNoSentinels(t, err, capture)
|
|
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d is non-zero (%d)", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(callerSecret, callerCopy) {
|
|
t.Fatalf("caller secret buffer mutated on failed CreateSlot")
|
|
}
|
|
|
|
slotsAfter := mustListSlots(t, store, issued.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) {
|
|
t.Fatalf("slots mutated on failed CreateSlot: got %+v want %+v", slotsAfter, slotsBefore)
|
|
}
|
|
|
|
genAfter := mustProjectionGeneration(t, store)
|
|
if genAfter != genBefore {
|
|
t.Fatalf("ProjectionGeneration changed on failed CreateSlot: got %d want %d", genAfter, genBefore)
|
|
}
|
|
})
|
|
|
|
t.Run("RotateSlot failing sealer", func(t *testing.T) {
|
|
retSealer := &retainingSealer{envelope: validEnv}
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
validSlot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "valid-slot-for-rotate-failure",
|
|
ProviderSecret: []byte("initial-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
genBefore := mustProjectionGeneration(t, store)
|
|
slotBefore := mustGetSlot(t, store, issued.Principal.ID, validSlot.ID)
|
|
historyBefore := mustListSlotRevisions(t, store, issued.Principal.ID, validSlot.ID)
|
|
slotsBefore := mustListSlots(t, store, issued.Principal.ID)
|
|
|
|
failingEnvWithSentinels := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: keyIDSentinel,
|
|
KeyVersion: keyVersionSentinel,
|
|
Nonce: []byte(nonceSentinel),
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
}
|
|
retSealer.envelope = failingEnvWithSentinels
|
|
retSealer.err = fmt.Errorf("sealer rotate failed with sensitive error %s, plaintext %s, key %s, ver %d, nonce %s, cipher %s, aad %s", errorSentinel, plaintextSentinel, keyIDSentinel, keyVersionSentinel, nonceSentinel, ciphertextSentinel, aadSentinel)
|
|
retSealer.retained = nil
|
|
|
|
callerSecret := []byte("rotate-payload-" + plaintextSentinel)
|
|
callerCopy := append([]byte(nil), callerSecret...)
|
|
|
|
_, err = svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: validSlot.ID,
|
|
Revision: validSlot.Revision,
|
|
ProviderSecret: callerSecret,
|
|
})
|
|
assertBlindAndNoSentinels(t, err, capture)
|
|
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext on failing RotateSlot")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d on failing RotateSlot is non-zero (%d)", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(callerSecret, callerCopy) {
|
|
t.Fatalf("caller secret buffer mutated on failed RotateSlot")
|
|
}
|
|
|
|
slotAfter := mustGetSlot(t, store, issued.Principal.ID, validSlot.ID)
|
|
if !reflect.DeepEqual(slotAfter, slotBefore) {
|
|
t.Fatalf("slot mutated on failed RotateSlot: got %+v want %+v", slotAfter, slotBefore)
|
|
}
|
|
|
|
historyAfter := mustListSlotRevisions(t, store, issued.Principal.ID, validSlot.ID)
|
|
if !reflect.DeepEqual(historyAfter, historyBefore) {
|
|
t.Fatalf("slot revision history mutated on failed RotateSlot: got %+v want %+v", historyAfter, historyBefore)
|
|
}
|
|
|
|
slotsAfter := mustListSlots(t, store, issued.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) {
|
|
t.Fatalf("slots mutated on failed RotateSlot: got %+v want %+v", slotsAfter, slotsBefore)
|
|
}
|
|
|
|
genAfter := mustProjectionGeneration(t, store)
|
|
if genAfter != genBefore {
|
|
t.Fatalf("ProjectionGeneration mutated on failed RotateSlot: got %d want %d", genAfter, genBefore)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateSlot unknown key ID", func(t *testing.T) {
|
|
unknownEnv := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: keyIDSentinel,
|
|
KeyVersion: keyVersionSentinel,
|
|
Nonce: []byte(nonceSentinel),
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
}
|
|
retSealer := &retainingSealer{envelope: unknownEnv}
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
genBefore := mustProjectionGeneration(t, store)
|
|
slotsBefore := mustListSlots(t, store, issued.Principal.ID)
|
|
|
|
callerSecret := []byte("secret-payload-" + plaintextSentinel)
|
|
callerCopy := append([]byte(nil), callerSecret...)
|
|
|
|
_, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "unknown-key-slot",
|
|
ProviderSecret: callerSecret,
|
|
})
|
|
assertBlindAndNoSentinels(t, err, capture)
|
|
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext on unknown key CreateSlot")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d is non-zero (%d)", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(callerSecret, callerCopy) {
|
|
t.Fatalf("caller secret buffer mutated on unknown key CreateSlot")
|
|
}
|
|
|
|
slotsAfter := mustListSlots(t, store, issued.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) {
|
|
t.Fatalf("slots mutated on unknown key CreateSlot: got %+v want %+v", slotsAfter, slotsBefore)
|
|
}
|
|
|
|
genAfter := mustProjectionGeneration(t, store)
|
|
if genAfter != genBefore {
|
|
t.Fatalf("ProjectionGeneration changed on unknown key CreateSlot: got %d want %d", genAfter, genBefore)
|
|
}
|
|
})
|
|
|
|
t.Run("RotateSlot unknown key ID", func(t *testing.T) {
|
|
retSealer := &retainingSealer{envelope: validEnv}
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
validSlot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "valid-slot-for-unknown-key-rotate",
|
|
ProviderSecret: []byte("initial-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
genBefore := mustProjectionGeneration(t, store)
|
|
slotBefore := mustGetSlot(t, store, issued.Principal.ID, validSlot.ID)
|
|
historyBefore := mustListSlotRevisions(t, store, issued.Principal.ID, validSlot.ID)
|
|
slotsBefore := mustListSlots(t, store, issued.Principal.ID)
|
|
|
|
unknownEnv := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: keyIDSentinel,
|
|
KeyVersion: keyVersionSentinel,
|
|
Nonce: []byte(nonceSentinel),
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
}
|
|
retSealer.envelope = unknownEnv
|
|
retSealer.retained = nil
|
|
|
|
callerSecret := []byte("rotate-payload-" + plaintextSentinel)
|
|
callerCopy := append([]byte(nil), callerSecret...)
|
|
|
|
_, err = svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: validSlot.ID,
|
|
Revision: validSlot.Revision,
|
|
ProviderSecret: callerSecret,
|
|
})
|
|
assertBlindAndNoSentinels(t, err, capture)
|
|
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext on unknown key RotateSlot")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d is non-zero (%d)", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(callerSecret, callerCopy) {
|
|
t.Fatalf("caller secret buffer mutated on unknown key RotateSlot")
|
|
}
|
|
|
|
slotAfter := mustGetSlot(t, store, issued.Principal.ID, validSlot.ID)
|
|
if !reflect.DeepEqual(slotAfter, slotBefore) {
|
|
t.Fatalf("slot mutated on unknown key RotateSlot: got %+v want %+v", slotAfter, slotBefore)
|
|
}
|
|
|
|
historyAfter := mustListSlotRevisions(t, store, issued.Principal.ID, validSlot.ID)
|
|
if !reflect.DeepEqual(historyAfter, historyBefore) {
|
|
t.Fatalf("slot revision history mutated on unknown key RotateSlot: got %+v want %+v", historyAfter, historyBefore)
|
|
}
|
|
|
|
slotsAfter := mustListSlots(t, store, issued.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) {
|
|
t.Fatalf("slots mutated on unknown key RotateSlot: got %+v want %+v", slotsAfter, slotsBefore)
|
|
}
|
|
|
|
genAfter := mustProjectionGeneration(t, store)
|
|
if genAfter != genBefore {
|
|
t.Fatalf("ProjectionGeneration mutated on unknown key RotateSlot: got %d want %d", genAfter, genBefore)
|
|
}
|
|
})
|
|
|
|
t.Run("CreateSlot invalid envelope variant", func(t *testing.T) {
|
|
invalidEnv := credentialstore.SecretEnvelope{
|
|
Algorithm: "",
|
|
KeyID: keyIDSentinel,
|
|
KeyVersion: keyVersionSentinel,
|
|
Nonce: []byte(nonceSentinel),
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
}
|
|
retSealer := &retainingSealer{envelope: invalidEnv}
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
genBefore := mustProjectionGeneration(t, store)
|
|
slotsBefore := mustListSlots(t, store, issued.Principal.ID)
|
|
|
|
callerSecret := []byte("secret-payload-" + plaintextSentinel)
|
|
callerCopy := append([]byte(nil), callerSecret...)
|
|
|
|
_, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "invalid-env-slot",
|
|
ProviderSecret: callerSecret,
|
|
})
|
|
assertBlindAndNoSentinels(t, err, capture)
|
|
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d is non-zero (%d)", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(callerSecret, callerCopy) {
|
|
t.Fatalf("caller secret buffer mutated on invalid envelope CreateSlot")
|
|
}
|
|
|
|
slotsAfter := mustListSlots(t, store, issued.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) {
|
|
t.Fatalf("slots mutated on invalid envelope CreateSlot: got %+v want %+v", slotsAfter, slotsBefore)
|
|
}
|
|
|
|
genAfter := mustProjectionGeneration(t, store)
|
|
if genAfter != genBefore {
|
|
t.Fatalf("ProjectionGeneration changed on invalid envelope CreateSlot: got %d want %d", genAfter, genBefore)
|
|
}
|
|
})
|
|
|
|
t.Run("RotateSlot invalid envelope variant", func(t *testing.T) {
|
|
retSealer := &retainingSealer{envelope: validEnv}
|
|
svc, store, issued, capture := newTestServiceWithLogs(t, retSealer)
|
|
token := []byte(issued.RawToken)
|
|
|
|
validSlot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "valid-slot-for-invalid-env-rotate",
|
|
ProviderSecret: []byte("initial-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
genBefore := mustProjectionGeneration(t, store)
|
|
slotBefore := mustGetSlot(t, store, issued.Principal.ID, validSlot.ID)
|
|
historyBefore := mustListSlotRevisions(t, store, issued.Principal.ID, validSlot.ID)
|
|
slotsBefore := mustListSlots(t, store, issued.Principal.ID)
|
|
|
|
invalidEnv := credentialstore.SecretEnvelope{
|
|
Algorithm: "AES-256-GCM",
|
|
KeyID: keyIDSentinel,
|
|
KeyVersion: keyVersionSentinel,
|
|
Nonce: nil,
|
|
Ciphertext: []byte(ciphertextSentinel),
|
|
AAD: []byte(aadSentinel),
|
|
}
|
|
retSealer.envelope = invalidEnv
|
|
retSealer.retained = nil
|
|
|
|
callerSecret := []byte("rotate-payload-" + plaintextSentinel)
|
|
callerCopy := append([]byte(nil), callerSecret...)
|
|
|
|
_, err = svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: validSlot.ID,
|
|
Revision: validSlot.Revision,
|
|
ProviderSecret: callerSecret,
|
|
})
|
|
assertBlindAndNoSentinels(t, err, capture)
|
|
|
|
if len(retSealer.retained) == 0 {
|
|
t.Fatal("retainingSealer did not capture plaintext")
|
|
}
|
|
for i, b := range retSealer.retained {
|
|
if b != 0 {
|
|
t.Fatalf("retained slice byte at index %d is non-zero (%d)", i, b)
|
|
}
|
|
}
|
|
if !bytes.Equal(callerSecret, callerCopy) {
|
|
t.Fatalf("caller secret buffer mutated on invalid envelope RotateSlot")
|
|
}
|
|
|
|
slotAfter := mustGetSlot(t, store, issued.Principal.ID, validSlot.ID)
|
|
if !reflect.DeepEqual(slotAfter, slotBefore) {
|
|
t.Fatalf("slot mutated on invalid envelope RotateSlot: got %+v want %+v", slotAfter, slotBefore)
|
|
}
|
|
|
|
historyAfter := mustListSlotRevisions(t, store, issued.Principal.ID, validSlot.ID)
|
|
if !reflect.DeepEqual(historyAfter, historyBefore) {
|
|
t.Fatalf("slot revision history mutated on invalid envelope RotateSlot: got %+v want %+v", historyAfter, historyBefore)
|
|
}
|
|
|
|
slotsAfter := mustListSlots(t, store, issued.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) {
|
|
t.Fatalf("slots mutated on invalid envelope RotateSlot: got %+v want %+v", slotsAfter, slotsBefore)
|
|
}
|
|
|
|
genAfter := mustProjectionGeneration(t, store)
|
|
if genAfter != genBefore {
|
|
t.Fatalf("ProjectionGeneration mutated on invalid envelope RotateSlot: got %d want %d", genAfter, genBefore)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-3: Service construction has no network side effect
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceConstructionHasNoNetworkSideEffect(t *testing.T) {
|
|
logger := zaptest.NewLogger(t)
|
|
reg := credentialstore.EnvelopeKeyRegistry(newFakeKeyRegistryForService())
|
|
dbPath := "file:iop_credentialops_no_net_" + strings.ReplaceAll(t.Name(), "/", "_") + ".db?mode=memory&cache=shared"
|
|
store, err := credentialstore.Open(context.Background(), dbPath, credentialstore.WithEnvelopeKeyRegistry(reg))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
svcWithSealer := NewService(store, logger, newFakeSealer(t), nil)
|
|
if svcWithSealer == nil {
|
|
t.Fatal("NewService returned nil with sealer")
|
|
}
|
|
|
|
svcWithoutSealer := NewService(store, logger, nil, nil)
|
|
if svcWithoutSealer == nil {
|
|
t.Fatal("NewService returned nil without sealer")
|
|
}
|
|
|
|
issued, err := store.CreatePrincipalWithToken(context.Background(), credentialstore.CreatePrincipalInput{Alias: "no-net-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create principal: %v", err)
|
|
}
|
|
|
|
slots, err := svcWithoutSealer.ListSlots(context.Background(), []byte(issued.RawToken))
|
|
if err != nil {
|
|
t.Fatalf("ListSlots without wire server: %v", err)
|
|
}
|
|
if len(slots) != 0 {
|
|
t.Fatalf("expected 0 slots, got %d", len(slots))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Authenticate rejects empty/invalid tokens
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceRejectsEmptyToken(t *testing.T) {
|
|
svc, _, _ := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.GetSlot(ctx, nil, "any-id")
|
|
if !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("expected ErrUnauthorized for nil token, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetSlot(ctx, []byte{}, "any-id")
|
|
if !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("expected ErrUnauthorized for empty token, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.ListSlots(ctx, []byte("nonexistent-token"))
|
|
if !errors.Is(err, ErrUnauthorized) {
|
|
t.Fatalf("expected ErrUnauthorized for unknown token, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1: Concurrent access (race test)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceConcurrentOperations(t *testing.T) {
|
|
svc, _, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
token := []byte(issued.RawToken)
|
|
slot, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "concurrent-slot",
|
|
ProviderSecret: []byte("concurrent-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
errs := make(chan error, 10)
|
|
|
|
for range 5 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, err := svc.GetSlot(ctx, token, slot.ID)
|
|
if err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
}
|
|
|
|
for range 5 {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
_, err := svc.ListSlots(ctx, token)
|
|
if err != nil {
|
|
errs <- err
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errs)
|
|
|
|
for err := range errs {
|
|
t.Fatalf("concurrent error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceMapsStoreErrorsCorrectly(t *testing.T) {
|
|
svc, _, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
_, err := svc.GetSlot(ctx, []byte(issued.RawToken), "does-not-exist")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound, got: %v", err)
|
|
}
|
|
|
|
_, err = svc.GetRoute(ctx, []byte(issued.RawToken), "does-not-exist")
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Fatalf("expected ErrNotFound for route, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestServiceAuthenticatesViaDigest(t *testing.T) {
|
|
svc, store, issued := newTestService(t, newFakeSealer(t))
|
|
ctx := context.Background()
|
|
|
|
expectedDigest := digestOf([]byte(issued.RawToken))
|
|
p, tkr, err := store.LookupTokenByDigest(ctx, expectedDigest)
|
|
if err != nil {
|
|
t.Fatalf("lookup by expected digest: %v", err)
|
|
}
|
|
if p.ID != issued.Principal.ID {
|
|
t.Fatalf("principal ID mismatch: got %q want %q", p.ID, issued.Principal.ID)
|
|
}
|
|
if tkr.ID != issued.Token.ID {
|
|
t.Fatalf("token ID mismatch: got %q want %q", tkr.ID, issued.Token.ID)
|
|
}
|
|
|
|
slot, err := svc.CreateSlot(ctx, []byte(issued.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "digest-slot",
|
|
ProviderSecret: []byte("digest-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
slots, err := svc.ListSlots(ctx, []byte(issued.RawToken))
|
|
if err != nil {
|
|
t.Fatalf("ListSlots with raw token: %v", err)
|
|
}
|
|
if len(slots) != 1 {
|
|
t.Fatalf("expected 1 slot, got %d", len(slots))
|
|
}
|
|
if slots[0].ID != slot.ID {
|
|
t.Fatalf("slot ID mismatch: got %q want %q", slots[0].ID, slot.ID)
|
|
}
|
|
}
|
|
|
|
func TestServiceHasNoProtoDependency(t *testing.T) {
|
|
svc, _, _ := newTestService(t, newFakeSealer(t))
|
|
if svc == nil {
|
|
t.Fatal("service must not be nil")
|
|
}
|
|
}
|
|
|
|
func TestServiceImplementsExpectedInterface(t *testing.T) {
|
|
var _ SecretSealer = (*fakeSealer)(nil)
|
|
|
|
svc, _, _ := newTestService(t, nil)
|
|
if svc == nil {
|
|
t.Fatal("NewService must not return nil")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-2: create-time AAD binds to the persisted slot id
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceCreateBindsSealContextToPersistedSlotID(t *testing.T) {
|
|
fake := newFakeSealer(t)
|
|
svc, _, issued := newTestService(t, fake)
|
|
ctx := context.Background()
|
|
|
|
created, err := svc.CreateSlot(ctx, []byte(issued.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "bound-slot",
|
|
ProviderSecret: []byte("secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
if fake.callCount() != 1 {
|
|
t.Fatalf("sealer call count: got %d want 1", fake.callCount())
|
|
}
|
|
if fake.calls[0].ctx_.SlotID == "" {
|
|
t.Fatal("create-time seal context must carry a slot id")
|
|
}
|
|
if fake.calls[0].ctx_.SlotID != created.ID {
|
|
t.Fatalf("seal context slot id mismatch: got %q want persisted %q", fake.calls[0].ctx_.SlotID, created.ID)
|
|
}
|
|
if fake.calls[0].ctx_.Kind != credentialstore.CredentialKindBearer {
|
|
t.Fatalf("seal context kind mismatch: got %q", fake.calls[0].ctx_.Kind)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// API-1/API-2: production keyring end-to-end at-rest encryption
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newKeyringForTest(t *testing.T) *credentialseal.Keyring {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "manifest.yaml")
|
|
material := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x5A}, 32))
|
|
body := "keys:\n - id: \"primary\"\n version: 1\n material: \"" + material + "\"\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
kr, err := credentialseal.LoadFile(credentialseal.FileConfig{KeyFile: path, ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
if err != nil {
|
|
t.Fatalf("load keyring: %v", err)
|
|
}
|
|
return kr
|
|
}
|
|
|
|
func TestServiceKeyringStoresInspectableCiphertextAndDecrypts(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "encrypted.db")
|
|
keyring := newKeyringForTest(t)
|
|
|
|
store, err := credentialstore.Open(ctx, dbPath, credentialstore.WithEnvelopeKeyRegistry(keyring))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
logger := zaptest.NewLogger(t)
|
|
svc := NewService(store, logger, keyring, nil)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, credentialstore.CreatePrincipalInput{Alias: "keyring-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create principal: %v", err)
|
|
}
|
|
token := []byte(issued.RawToken)
|
|
|
|
const secret1 = "PROVIDER_SECRET_SENTINEL_AAA_111"
|
|
const secret2 = "PROVIDER_SECRET_SENTINEL_BBB_222"
|
|
|
|
created, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: credentialstore.CredentialKindBearer,
|
|
Alias: "keyring-slot",
|
|
ProviderSecret: []byte(secret1),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot: %v", err)
|
|
}
|
|
|
|
rotated, err := svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: created.ID,
|
|
Revision: created.Revision,
|
|
ProviderSecret: []byte(secret2),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RotateSlot: %v", err)
|
|
}
|
|
|
|
// Inspect the persisted row: only algorithm/key metadata/nonce/ciphertext/aad,
|
|
// and no plaintext, are stored.
|
|
var algorithm, keyID string
|
|
var keyVersion uint64
|
|
var nonce, ciphertext, aad []byte
|
|
err = store.DB().QueryRowContext(ctx,
|
|
`SELECT algorithm, key_id, key_version, nonce, ciphertext, aad FROM credential_slots WHERE id=?`, created.ID,
|
|
).Scan(&algorithm, &keyID, &keyVersion, &nonce, &ciphertext, &aad)
|
|
if err != nil {
|
|
t.Fatalf("inspect slot row: %v", err)
|
|
}
|
|
if algorithm != credentialseal.AlgorithmAES256GCM {
|
|
t.Fatalf("stored algorithm: got %q", algorithm)
|
|
}
|
|
if keyID != "primary" || keyVersion != 1 {
|
|
t.Fatalf("stored key metadata: got id=%q version=%d", keyID, keyVersion)
|
|
}
|
|
if len(nonce) != 12 || len(ciphertext) == 0 || len(aad) == 0 {
|
|
t.Fatalf("stored envelope shape: nonce=%d ciphertext=%d aad=%d", len(nonce), len(ciphertext), len(aad))
|
|
}
|
|
for _, blob := range [][]byte{nonce, ciphertext, aad} {
|
|
if strings.Contains(string(blob), secret1) || strings.Contains(string(blob), secret2) {
|
|
t.Fatal("stored envelope leaks plaintext secret")
|
|
}
|
|
}
|
|
|
|
sealCtx := credentialseal.Context{PrincipalID: issued.Principal.ID, SlotID: created.ID, Kind: credentialstore.CredentialKindBearer}
|
|
|
|
// Every historical revision decrypts to its original plaintext with the
|
|
// exact context.
|
|
revisions, err := store.ListSlotRevisions(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSlotRevisions: %v", err)
|
|
}
|
|
if len(revisions) != 2 {
|
|
t.Fatalf("revision count: got %d want 2", len(revisions))
|
|
}
|
|
wantByRevision := map[int64]string{0: secret1, 1: secret2}
|
|
for _, rev := range revisions {
|
|
got, err := keyring.Open(ctx, rev.Envelope, sealCtx)
|
|
if err != nil {
|
|
t.Fatalf("decrypt revision %d: %v", rev.Revision, err)
|
|
}
|
|
if string(got) != wantByRevision[rev.Revision] {
|
|
t.Fatalf("revision %d plaintext mismatch: got %q want %q", rev.Revision, got, wantByRevision[rev.Revision])
|
|
}
|
|
}
|
|
|
|
// A swapped principal/slot/kind context cannot decrypt the current envelope.
|
|
current, err := store.GetSlot(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot current: %v", err)
|
|
}
|
|
_ = rotated
|
|
for _, wrong := range []credentialseal.Context{
|
|
{PrincipalID: "other", SlotID: created.ID, Kind: credentialstore.CredentialKindBearer},
|
|
{PrincipalID: issued.Principal.ID, SlotID: "other-slot", Kind: credentialstore.CredentialKindBearer},
|
|
{PrincipalID: issued.Principal.ID, SlotID: created.ID, Kind: credentialstore.CredentialKindAPIKey},
|
|
} {
|
|
if _, err := keyring.Open(ctx, current.Envelope, wrong); !errors.Is(err, credentialseal.ErrOpenFailed) {
|
|
t.Fatalf("expected ErrOpenFailed for swapped context, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// Close and reopen the DB with a freshly reloaded keyring holding the same
|
|
// external material: the current revision still decrypts (restart + reload).
|
|
if err := store.Close(); err != nil {
|
|
t.Fatalf("close store: %v", err)
|
|
}
|
|
keyring2 := newKeyringForTest(t)
|
|
store2, err := credentialstore.Open(ctx, dbPath, credentialstore.WithEnvelopeKeyRegistry(keyring2))
|
|
if err != nil {
|
|
t.Fatalf("reopen store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = store2.Close() })
|
|
|
|
reloaded, err := store2.GetSlot(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot after reopen: %v", err)
|
|
}
|
|
got, err := keyring2.Open(ctx, reloaded.Envelope, sealCtx)
|
|
if err != nil {
|
|
t.Fatalf("decrypt after reopen: %v", err)
|
|
}
|
|
if string(got) != secret2 {
|
|
t.Fatalf("decrypt after reopen mismatch: got %q want %q", got, secret2)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// REVIEW_API-1: canonical credential kind binds AAD to persisted kind
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestServiceCreateCanonicalizesCredentialKindForAAD(t *testing.T) {
|
|
ctx := context.Background()
|
|
|
|
// Table-driven: each input kind normalizes to a canonical value.
|
|
kindCases := []struct {
|
|
name string
|
|
input string
|
|
canonical string
|
|
}{
|
|
{"bearer-lowercase", "bearer", credentialstore.CredentialKindBearer},
|
|
{"bearer-uppercase", "BEARER", credentialstore.CredentialKindBearer},
|
|
{"bearer-mixed-case", "BeArEr", credentialstore.CredentialKindBearer},
|
|
{"bearer-leading-trailing-space", " bearer ", credentialstore.CredentialKindBearer},
|
|
{"bearer-tabs-and-newlines", "\tbearer\n", credentialstore.CredentialKindBearer},
|
|
{"api-key-lowercase", "api_key", credentialstore.CredentialKindAPIKey},
|
|
{"api-key-uppercase", "API_KEY", credentialstore.CredentialKindAPIKey},
|
|
{"api-key-mixed-case", "Api_Key", credentialstore.CredentialKindAPIKey},
|
|
{"api-key-spaces", " api_key ", credentialstore.CredentialKindAPIKey},
|
|
}
|
|
|
|
for _, tc := range kindCases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
subDir := t.TempDir()
|
|
dbPath := filepath.Join(subDir, "canonical.db")
|
|
keyring := newKeyringForTest(t)
|
|
|
|
store, err := credentialstore.Open(ctx, dbPath, credentialstore.WithEnvelopeKeyRegistry(keyring))
|
|
if err != nil {
|
|
t.Fatalf("open store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
logger := zaptest.NewLogger(t)
|
|
svc := NewService(store, logger, keyring, nil)
|
|
|
|
issued, err := store.CreatePrincipalWithToken(ctx, credentialstore.CreatePrincipalInput{Alias: "canonical-principal"})
|
|
if err != nil {
|
|
t.Fatalf("create principal: %v", err)
|
|
}
|
|
token := []byte(issued.RawToken)
|
|
|
|
created, err := svc.CreateSlot(ctx, token, CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: tc.input,
|
|
Alias: "canonical-" + tc.name,
|
|
ProviderSecret: []byte("canonical-secret-payload"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateSlot with kind %q: %v", tc.input, err)
|
|
}
|
|
|
|
// Persisted kind must equal the canonical value.
|
|
if created.CredentialKind != tc.canonical {
|
|
t.Fatalf("persisted CredentialKind: got %q want %q", created.CredentialKind, tc.canonical)
|
|
}
|
|
|
|
// Stored slot row must carry the canonical kind.
|
|
stored, err := store.GetSlot(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot: %v", err)
|
|
}
|
|
if stored.CredentialKind != tc.canonical {
|
|
t.Fatalf("stored CredentialKind: got %q want %q", stored.CredentialKind, tc.canonical)
|
|
}
|
|
|
|
// Initial envelope must open with the exact persisted context.
|
|
sealCtx := credentialseal.Context{
|
|
PrincipalID: issued.Principal.ID,
|
|
SlotID: created.ID,
|
|
Kind: stored.CredentialKind,
|
|
}
|
|
got, err := keyring.Open(ctx, stored.Envelope, sealCtx)
|
|
if err != nil {
|
|
t.Fatalf("open initial envelope with persisted context: %v", err)
|
|
}
|
|
if string(got) != "canonical-secret-payload" {
|
|
t.Fatalf("initial envelope plaintext: got %q", got)
|
|
}
|
|
|
|
// Rotate with the same canonical kind.
|
|
rotated, err := svc.RotateSlot(ctx, token, RotateSlotInput{
|
|
SlotID: created.ID,
|
|
Revision: created.Revision,
|
|
ProviderSecret: []byte("canonical-rotated-secret"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("RotateSlot: %v", err)
|
|
}
|
|
if rotated.CredentialKind != tc.canonical {
|
|
t.Fatalf("rotated CredentialKind: got %q want %q", rotated.CredentialKind, tc.canonical)
|
|
}
|
|
|
|
// All revisions must open with the persisted context.
|
|
revisions, err := store.ListSlotRevisions(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSlotRevisions: %v", err)
|
|
}
|
|
for _, rev := range revisions {
|
|
g, err := keyring.Open(ctx, rev.Envelope, sealCtx)
|
|
if err != nil {
|
|
t.Fatalf("open revision %d: %v", rev.Revision, err)
|
|
}
|
|
if rev.Revision == 0 && string(g) != "canonical-secret-payload" {
|
|
t.Fatalf("revision 0 plaintext: got %q", g)
|
|
}
|
|
if rev.Revision == 1 && string(g) != "canonical-rotated-secret" {
|
|
t.Fatalf("revision 1 plaintext: got %q", g)
|
|
}
|
|
}
|
|
|
|
// Close and reopen: every revision must still decrypt with the row context.
|
|
if err := store.Close(); err != nil {
|
|
t.Fatalf("close store: %v", err)
|
|
}
|
|
keyring2 := newKeyringForTest(t)
|
|
store2, err := credentialstore.Open(ctx, dbPath, credentialstore.WithEnvelopeKeyRegistry(keyring2))
|
|
if err != nil {
|
|
t.Fatalf("reopen store: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = store2.Close() })
|
|
|
|
reloaded, err := store2.GetSlot(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSlot after reopen: %v", err)
|
|
}
|
|
if reloaded.CredentialKind != tc.canonical {
|
|
t.Fatalf("reloaded CredentialKind: got %q want %q", reloaded.CredentialKind, tc.canonical)
|
|
}
|
|
reopenedCtx := credentialseal.Context{
|
|
PrincipalID: issued.Principal.ID,
|
|
SlotID: created.ID,
|
|
Kind: reloaded.CredentialKind,
|
|
}
|
|
reopenedRevs, err := store2.ListSlotRevisions(ctx, issued.Principal.ID, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListSlotRevisions after reopen: %v", err)
|
|
}
|
|
for _, rev := range reopenedRevs {
|
|
g, err := keyring2.Open(ctx, rev.Envelope, reopenedCtx)
|
|
if err != nil {
|
|
t.Fatalf("decrypt revision %d after reopen: %v", rev.Revision, err)
|
|
}
|
|
if len(g) == 0 {
|
|
t.Fatalf("empty plaintext after reopen for revision %d", rev.Revision)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// Unknown kind must be rejected before the sealer is called.
|
|
t.Run("unknown-kind-rejected-before-sealer", func(t *testing.T) {
|
|
fake := newFakeSealer(t)
|
|
svcFake, storeFake, issuedFake := newTestService(t, fake)
|
|
|
|
credentialState := func(t *testing.T, store *credentialstore.Store, pid string) ([]credentialstore.CredentialSlotRecord, int, uint64) {
|
|
t.Helper()
|
|
slots, err := store.ListSlots(ctx, pid)
|
|
if err != nil {
|
|
t.Fatalf("ListSlots: %v", err)
|
|
}
|
|
var revCount int
|
|
if err := store.DB().QueryRowContext(ctx, "SELECT COUNT(*) FROM credential_slot_revisions").Scan(&revCount); err != nil {
|
|
t.Fatalf("QueryRowContext credential_slot_revisions count: %v", err)
|
|
}
|
|
gen, err := store.ProjectionGeneration(ctx)
|
|
if err != nil {
|
|
t.Fatalf("ProjectionGeneration: %v", err)
|
|
}
|
|
return slots, revCount, gen
|
|
}
|
|
|
|
slotsBefore, revisionsBefore, genBefore := credentialState(t, storeFake, issuedFake.Principal.ID)
|
|
|
|
_, err := svcFake.CreateSlot(ctx, []byte(issuedFake.RawToken), CreateSlotInput{
|
|
Vendor: "openai",
|
|
CredentialKind: "unknown_kind",
|
|
Alias: "unknown-kind-slot",
|
|
ProviderSecret: []byte("unknown-secret"),
|
|
})
|
|
if !errors.Is(err, ErrInvalidCredentialKind) {
|
|
t.Fatalf("expected ErrInvalidCredentialKind, got: %v", err)
|
|
}
|
|
if fake.callCount() != 0 {
|
|
t.Fatalf("sealer must not be called for unknown kind, got %d calls", fake.callCount())
|
|
}
|
|
|
|
slotsAfter, revisionsAfter, genAfter := credentialState(t, storeFake, issuedFake.Principal.ID)
|
|
if !reflect.DeepEqual(slotsAfter, slotsBefore) || revisionsAfter != revisionsBefore || genAfter != genBefore {
|
|
t.Fatalf("unknown credential kind mutated durable state: slotsBefore=%+v slotsAfter=%+v revsBefore=%d revsAfter=%d genBefore=%d genAfter=%d",
|
|
slotsBefore, slotsAfter, revisionsBefore, revisionsAfter, genBefore, genAfter)
|
|
}
|
|
if len(slotsAfter) != 0 || revisionsAfter != 0 {
|
|
t.Fatalf("expected empty slots and zero revisions, got slots=%d revs=%d", len(slotsAfter), revisionsAfter)
|
|
}
|
|
})
|
|
}
|