사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
364 lines
13 KiB
Go
364 lines
13 KiB
Go
package credentialseal
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"iop/apps/control-plane/internal/credentialstore"
|
|
)
|
|
|
|
// keyMaterial returns base64 of a deterministic 32-byte key filled with fill.
|
|
func keyMaterial(fill byte) string {
|
|
return base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{fill}, aeadKeyLength))
|
|
}
|
|
|
|
// manifestYAML renders a strict manifest for the given entries.
|
|
func manifestYAML(entries ...manifestKey) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("keys:\n")
|
|
for _, e := range entries {
|
|
sb.WriteString(fmt.Sprintf(" - id: %q\n version: %d\n material: %q\n", e.ID, e.Version, e.Material))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func newTestKeyring(t *testing.T, active keyRef, entries ...manifestKey) *Keyring {
|
|
t.Helper()
|
|
kr, err := newKeyring([]byte(manifestYAML(entries...)), active, nil)
|
|
require.NoError(t, err)
|
|
return kr
|
|
}
|
|
|
|
func writeFileMode(t *testing.T, path, body string, perm os.FileMode) {
|
|
t.Helper()
|
|
require.NoError(t, os.WriteFile(path, []byte(body), perm))
|
|
}
|
|
|
|
func testContext() Context {
|
|
return Context{PrincipalID: "principal-1", SlotID: "slot-1", Kind: "bearer"}
|
|
}
|
|
|
|
func TestKeyringSealOpenRoundTrip(t *testing.T) {
|
|
kr := newTestKeyring(t, keyRef{"primary", 1}, manifestKey{"primary", 1, keyMaterial(0x11)})
|
|
ctx := context.Background()
|
|
plaintext := []byte("provider-secret-payload")
|
|
|
|
env, err := kr.Seal(ctx, plaintext, testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, AlgorithmAES256GCM, env.Algorithm)
|
|
require.Equal(t, "primary", env.KeyID)
|
|
require.Equal(t, uint64(1), env.KeyVersion)
|
|
require.Len(t, env.Nonce, 12)
|
|
require.NotEmpty(t, env.Ciphertext)
|
|
require.NotEmpty(t, env.AAD)
|
|
require.NotEqual(t, plaintext, env.Ciphertext)
|
|
require.NotContains(t, string(env.Ciphertext), "provider-secret-payload")
|
|
|
|
opened, err := kr.Open(ctx, env, testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, plaintext, opened)
|
|
}
|
|
|
|
func TestKeyringNonceUniqueness(t *testing.T) {
|
|
kr := newTestKeyring(t, keyRef{"primary", 1}, manifestKey{"primary", 1, keyMaterial(0x22)})
|
|
ctx := context.Background()
|
|
plaintext := []byte("same-plaintext")
|
|
|
|
env1, err := kr.Seal(ctx, plaintext, testContext())
|
|
require.NoError(t, err)
|
|
env2, err := kr.Seal(ctx, plaintext, testContext())
|
|
require.NoError(t, err)
|
|
|
|
require.NotEqual(t, env1.Nonce, env2.Nonce)
|
|
require.NotEqual(t, env1.Ciphertext, env2.Ciphertext)
|
|
}
|
|
|
|
func TestKeyringWrongContextFails(t *testing.T) {
|
|
kr := newTestKeyring(t, keyRef{"primary", 1}, manifestKey{"primary", 1, keyMaterial(0x33)})
|
|
ctx := context.Background()
|
|
env, err := kr.Seal(ctx, []byte("secret"), testContext())
|
|
require.NoError(t, err)
|
|
|
|
for _, wrong := range []Context{
|
|
{PrincipalID: "other", SlotID: "slot-1", Kind: "bearer"},
|
|
{PrincipalID: "principal-1", SlotID: "other", Kind: "bearer"},
|
|
{PrincipalID: "principal-1", SlotID: "slot-1", Kind: "api_key"},
|
|
} {
|
|
_, err := kr.Open(ctx, env, wrong)
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
}
|
|
}
|
|
|
|
func TestKeyringTamperFails(t *testing.T) {
|
|
kr := newTestKeyring(t, keyRef{"primary", 1}, manifestKey{"primary", 1, keyMaterial(0x44)})
|
|
ctx := context.Background()
|
|
base, err := kr.Seal(ctx, []byte("tamper-target"), testContext())
|
|
require.NoError(t, err)
|
|
|
|
tamperCiphertext := clone(base)
|
|
tamperCiphertext.Ciphertext[0] ^= 0xFF
|
|
_, err = kr.Open(ctx, tamperCiphertext, testContext())
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
|
|
tamperNonce := clone(base)
|
|
tamperNonce.Nonce[0] ^= 0xFF
|
|
_, err = kr.Open(ctx, tamperNonce, testContext())
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
|
|
tamperAAD := clone(base)
|
|
tamperAAD.AAD[len(tamperAAD.AAD)-1] ^= 0xFF
|
|
_, err = kr.Open(ctx, tamperAAD, testContext())
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
}
|
|
|
|
func clone(env credentialstore.SecretEnvelope) credentialstore.SecretEnvelope {
|
|
return credentialstore.SecretEnvelope{
|
|
Algorithm: env.Algorithm,
|
|
KeyID: env.KeyID,
|
|
KeyVersion: env.KeyVersion,
|
|
Nonce: append([]byte(nil), env.Nonce...),
|
|
Ciphertext: append([]byte(nil), env.Ciphertext...),
|
|
AAD: append([]byte(nil), env.AAD...),
|
|
}
|
|
}
|
|
|
|
func TestKeyringKeyVersionSelection(t *testing.T) {
|
|
ctx := context.Background()
|
|
// Two versions of the same key id with distinct material.
|
|
entries := []manifestKey{{"primary", 1, keyMaterial(0x55)}, {"primary", 2, keyMaterial(0x66)}}
|
|
|
|
sealV1 := newTestKeyring(t, keyRef{"primary", 1}, entries...)
|
|
envV1, err := sealV1.Seal(ctx, []byte("v1-secret"), testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint64(1), envV1.KeyVersion)
|
|
|
|
sealV2 := newTestKeyring(t, keyRef{"primary", 2}, entries...)
|
|
envV2, err := sealV2.Seal(ctx, []byte("v2-secret"), testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint64(2), envV2.KeyVersion)
|
|
|
|
// A keyring holding both versions opens either revision by its version.
|
|
both := newTestKeyring(t, keyRef{"primary", 2}, entries...)
|
|
openedV1, err := both.Open(ctx, envV1, testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte("v1-secret"), openedV1)
|
|
openedV2, err := both.Open(ctx, envV2, testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte("v2-secret"), openedV2)
|
|
|
|
// A keyring missing v1 cannot open a v1 envelope.
|
|
v2Only := newTestKeyring(t, keyRef{"primary", 2}, manifestKey{"primary", 2, keyMaterial(0x66)})
|
|
_, err = v2Only.Open(ctx, envV1, testContext())
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
|
|
has, err := both.HasEnvelopeKey(ctx, "primary", 1)
|
|
require.NoError(t, err)
|
|
require.True(t, has)
|
|
has, err = v2Only.HasEnvelopeKey(ctx, "primary", 1)
|
|
require.NoError(t, err)
|
|
require.False(t, has)
|
|
}
|
|
|
|
func TestKeyringRestartOpensAcrossReload(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "manifest.yaml")
|
|
writeFileMode(t, path, manifestYAML(manifestKey{"primary", 1, keyMaterial(0x77)}), 0o600)
|
|
|
|
cfg := FileConfig{KeyFile: path, ActiveKeyID: "primary", ActiveKeyVersion: 1}
|
|
kr1, err := LoadFile(cfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, kr1)
|
|
env, err := kr1.Seal(ctx, []byte("restart-secret"), testContext())
|
|
require.NoError(t, err)
|
|
|
|
// A fresh process reloads the same external file and opens the envelope.
|
|
kr2, err := LoadFile(cfg)
|
|
require.NoError(t, err)
|
|
opened, err := kr2.Open(ctx, env, testContext())
|
|
require.NoError(t, err)
|
|
require.Equal(t, []byte("restart-secret"), opened)
|
|
}
|
|
|
|
func TestKeyringErrorsAndEnvelopeCarryNoSecret(t *testing.T) {
|
|
ctx := context.Background()
|
|
material := keyMaterial(0x88)
|
|
kr := newTestKeyring(t, keyRef{"primary", 1}, manifestKey{"primary", 1, material})
|
|
|
|
secret := "PLAINTEXT_SECRET_SENTINEL_1234567890"
|
|
env, err := kr.Seal(ctx, []byte(secret), testContext())
|
|
require.NoError(t, err)
|
|
|
|
// No envelope field may carry the plaintext or the raw key material.
|
|
for _, field := range [][]byte{env.Nonce, env.Ciphertext, env.AAD} {
|
|
require.NotContains(t, string(field), secret)
|
|
require.NotContains(t, string(field), material)
|
|
}
|
|
|
|
// Constant errors never carry secret material.
|
|
_, openErr := kr.Open(ctx, env, Context{PrincipalID: "wrong"})
|
|
require.ErrorIs(t, openErr, ErrOpenFailed)
|
|
for _, e := range []error{ErrSealFailed, ErrOpenFailed, openErr} {
|
|
require.NotContains(t, e.Error(), secret)
|
|
require.NotContains(t, e.Error(), material)
|
|
}
|
|
}
|
|
|
|
func TestKeyringUnknownVersionAtOpen(t *testing.T) {
|
|
ctx := context.Background()
|
|
kr := newTestKeyring(t, keyRef{"primary", 1}, manifestKey{"primary", 1, keyMaterial(0x99)})
|
|
env, err := kr.Seal(ctx, []byte("secret"), testContext())
|
|
require.NoError(t, err)
|
|
|
|
env.KeyVersion = 99
|
|
_, err = kr.Open(ctx, env, testContext())
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// LoadFile: config, manifest, and secure-file behavior
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestLoadFileDisabledWhenAllOmitted(t *testing.T) {
|
|
kr, err := LoadFile(FileConfig{})
|
|
require.NoError(t, err)
|
|
require.Nil(t, kr)
|
|
}
|
|
|
|
func TestLoadFilePartialConfigFails(t *testing.T) {
|
|
for name, cfg := range map[string]FileConfig{
|
|
"only-file": {KeyFile: "/tmp/x"},
|
|
"only-id": {ActiveKeyID: "primary"},
|
|
"only-version": {ActiveKeyVersion: 1},
|
|
"file-id": {KeyFile: "/tmp/x", ActiveKeyID: "primary"},
|
|
"file-version": {KeyFile: "/tmp/x", ActiveKeyVersion: 1},
|
|
"id-version": {ActiveKeyID: "primary", ActiveKeyVersion: 1},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := LoadFile(cfg)
|
|
require.ErrorIs(t, err, ErrConfigIncomplete)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFileValidManifest(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "manifest.yaml")
|
|
writeFileMode(t, path, manifestYAML(manifestKey{"primary", 1, keyMaterial(0x01)}), 0o600)
|
|
|
|
kr, err := LoadFile(FileConfig{KeyFile: path, ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, kr)
|
|
_, err = kr.Seal(context.Background(), []byte("secret"), testContext())
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestLoadFileAtomicSymlinkAndSwap(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
|
|
// Kubernetes-style secret mount: manifest.yaml -> ..data/manifest.yaml,
|
|
// ..data -> a versioned data directory that is swapped atomically.
|
|
dataV1 := filepath.Join(dir, "..2026_01_data")
|
|
require.NoError(t, os.Mkdir(dataV1, 0o700))
|
|
writeFileMode(t, filepath.Join(dataV1, "manifest.yaml"), manifestYAML(manifestKey{"primary", 1, keyMaterial(0x0A)}), 0o600)
|
|
require.NoError(t, os.Symlink(dataV1, filepath.Join(dir, "..data")))
|
|
require.NoError(t, os.Symlink(filepath.Join("..data", "manifest.yaml"), filepath.Join(dir, "manifest.yaml")))
|
|
|
|
keyFile := filepath.Join(dir, "manifest.yaml")
|
|
cfg := FileConfig{KeyFile: keyFile, ActiveKeyID: "primary", ActiveKeyVersion: 1}
|
|
|
|
krOld, err := LoadFile(cfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, krOld)
|
|
envOld, err := krOld.Seal(ctx, []byte("secret"), testContext())
|
|
require.NoError(t, err)
|
|
|
|
// Atomically swap ..data to a new versioned directory with different key
|
|
// material for the same id/version.
|
|
dataV2 := filepath.Join(dir, "..2026_02_data")
|
|
require.NoError(t, os.Mkdir(dataV2, 0o700))
|
|
writeFileMode(t, filepath.Join(dataV2, "manifest.yaml"), manifestYAML(manifestKey{"primary", 1, keyMaterial(0x0B)}), 0o600)
|
|
tmpLink := filepath.Join(dir, "..data_tmp")
|
|
require.NoError(t, os.Symlink(dataV2, tmpLink))
|
|
require.NoError(t, os.Rename(tmpLink, filepath.Join(dir, "..data")))
|
|
|
|
krNew, err := LoadFile(cfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, krNew)
|
|
// The reload picked up the swapped material: the new keyring cannot open a
|
|
// ciphertext sealed under the old material even at the same id/version.
|
|
_, err = krNew.Open(ctx, envOld, testContext())
|
|
require.ErrorIs(t, err, ErrOpenFailed)
|
|
}
|
|
|
|
func TestLoadFileDanglingSymlink(t *testing.T) {
|
|
dir := t.TempDir()
|
|
link := filepath.Join(dir, "manifest.yaml")
|
|
require.NoError(t, os.Symlink(filepath.Join(dir, "nonexistent"), link))
|
|
|
|
_, err := LoadFile(FileConfig{KeyFile: link, ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
require.ErrorIs(t, err, ErrKeyFileAccess)
|
|
}
|
|
|
|
func TestLoadFileMissingFile(t *testing.T) {
|
|
_, err := LoadFile(FileConfig{KeyFile: filepath.Join(t.TempDir(), "absent.yaml"), ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
require.ErrorIs(t, err, ErrKeyFileAccess)
|
|
}
|
|
|
|
func TestLoadFileNonRegularTarget(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sub := filepath.Join(dir, "adir")
|
|
require.NoError(t, os.Mkdir(sub, 0o700))
|
|
_, err := LoadFile(FileConfig{KeyFile: sub, ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
require.ErrorIs(t, err, ErrKeyFileInsecure)
|
|
}
|
|
|
|
func TestLoadFileInsecurePermissions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "manifest.yaml")
|
|
writeFileMode(t, path, manifestYAML(manifestKey{"primary", 1, keyMaterial(0x02)}), 0o644)
|
|
_, err := LoadFile(FileConfig{KeyFile: path, ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
require.ErrorIs(t, err, ErrKeyFileInsecure)
|
|
}
|
|
|
|
func TestLoadFileManifestErrors(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cases := map[string]struct {
|
|
body string
|
|
want error
|
|
}{
|
|
"malformed": {"keys: [this is not valid", ErrManifest},
|
|
"unknown-field": {manifestYAML(manifestKey{"primary", 1, keyMaterial(0x03)}) + "extra: value\n", ErrManifest},
|
|
"empty": {"keys: []\n", ErrManifest},
|
|
"duplicate": {manifestYAML(manifestKey{"primary", 1, keyMaterial(0x03)}, manifestKey{"primary", 1, keyMaterial(0x04)}), ErrManifest},
|
|
"zero-version": {manifestYAML(manifestKey{"primary", 0, keyMaterial(0x03)}), ErrManifest},
|
|
"empty-id": {manifestYAML(manifestKey{"", 1, keyMaterial(0x03)}), ErrManifest},
|
|
"bad-key-length": {manifestYAML(manifestKey{"primary", 1, base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x1}, 16))}), ErrKeyMaterial},
|
|
"bad-key-encoding": {manifestYAML(manifestKey{"primary", 1, "not-base64!!!"}), ErrKeyMaterial},
|
|
}
|
|
for name, tc := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
path := filepath.Join(dir, name+".yaml")
|
|
writeFileMode(t, path, tc.body, 0o600)
|
|
_, err := LoadFile(FileConfig{KeyFile: path, ActiveKeyID: "primary", ActiveKeyVersion: 1})
|
|
require.ErrorIs(t, err, tc.want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadFileUnknownActiveKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "manifest.yaml")
|
|
writeFileMode(t, path, manifestYAML(manifestKey{"primary", 1, keyMaterial(0x05)}), 0o600)
|
|
_, err := LoadFile(FileConfig{KeyFile: path, ActiveKeyID: "primary", ActiveKeyVersion: 9})
|
|
require.ErrorIs(t, err, ErrUnknownActiveKey)
|
|
}
|