iop/packages/go/credentiallease/envelope_test.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

158 lines
6.6 KiB
Go

package credentiallease
import (
"context"
"crypto/ecdh"
"crypto/ed25519"
"crypto/rand"
"errors"
"strings"
"testing"
"time"
"google.golang.org/protobuf/proto"
)
func testKeys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey, []byte, []byte) {
t.Helper()
issuerPublic, issuerPrivate, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
recipient, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return issuerPublic, issuerPrivate, recipient.Bytes(), recipient.PublicKey().Bytes()
}
func testScope(now time.Time, id string) Scope {
return Scope{
LeaseID: id, PrincipalRef: "principal-1", CredentialSlotRef: "slot-1", RouteID: "route-1",
ProfileID: "openai", UpstreamTarget: "gpt-4o", NodeID: "node-1", RecipientKeyID: "recipient-1",
HeaderName: "Authorization", Scheme: "Bearer", CredentialRevision: 7, RouteRevision: 5,
ProjectionGeneration: 11, IssuedAtUnixNano: now.UnixNano(), ExpiresAtUnixNano: now.Add(30 * time.Second).UnixNano(),
}
}
func expected(scope Scope) ExpectedScope {
return ExpectedScope{
PrincipalRef: scope.PrincipalRef, CredentialSlotRef: scope.CredentialSlotRef, RouteID: scope.RouteID,
ProfileID: scope.ProfileID, UpstreamTarget: scope.UpstreamTarget, NodeID: scope.NodeID,
RecipientKeyID: scope.RecipientKeyID, CredentialRevision: scope.CredentialRevision,
RouteRevision: scope.RouteRevision, ProjectionGeneration: scope.ProjectionGeneration,
}
}
func TestIssueConsumeExactScopeAndProtoRedaction(t *testing.T) {
now := time.Unix(1700000000, 0).UTC()
issuerPublic, issuerPrivate, recipientPrivate, recipientPublic := testKeys(t)
secret := []byte("SENTINEL_PROVIDER_SECRET")
env, err := Issue(testScope(now, "lease-1"), secret, recipientPublic, "issuer-1", issuerPrivate, rand.Reader)
if err != nil {
t.Fatal(err)
}
if strings.Contains(env.ToProto().String(), string(secret)) {
t.Fatal("plaintext secret appeared in protobuf debug output")
}
wire, err := proto.Marshal(env.ToProto())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(wire), string(secret)) {
t.Fatal("plaintext secret appeared in protobuf wire bytes")
}
consumer, err := NewConsumer("node-1", "recipient-1", recipientPrivate, "issuer-1", issuerPublic, 8, func() time.Time { return now })
if err != nil {
t.Fatal(err)
}
material, err := consumer.Consume(context.Background(), env, expected(env.Scope))
if err != nil {
t.Fatal(err)
}
if got := string(material.Secret); got != string(secret) || material.HeaderName != "Authorization" || material.Scheme != "Bearer" {
t.Fatalf("unexpected material metadata")
}
material.Zero()
if material.Secret != nil {
t.Fatal("material was not released")
}
if _, err := consumer.Consume(context.Background(), env, expected(env.Scope)); !errors.Is(err, ErrReplay) {
t.Fatalf("duplicate consume error = %v, want replay", err)
}
}
func TestScopeTamperAndExpectedMismatchFailBeforePlaintext(t *testing.T) {
now := time.Unix(1700000000, 0).UTC()
issuerPublic, issuerPrivate, recipientPrivate, recipientPublic := testKeys(t)
base, err := Issue(testScope(now, "lease-tamper"), []byte("secret"), recipientPublic, "issuer-1", issuerPrivate, rand.Reader)
if err != nil {
t.Fatal(err)
}
fields := []func(*Scope){
func(s *Scope) { s.PrincipalRef += "x" }, func(s *Scope) { s.CredentialSlotRef += "x" },
func(s *Scope) { s.RouteID += "x" }, func(s *Scope) { s.ProfileID += "x" },
func(s *Scope) { s.UpstreamTarget += "x" }, func(s *Scope) { s.NodeID += "x" },
func(s *Scope) { s.RecipientKeyID += "x" }, func(s *Scope) { s.CredentialRevision++ },
func(s *Scope) { s.RouteRevision++ }, func(s *Scope) { s.ProjectionGeneration++ },
func(s *Scope) { s.ExpiresAtUnixNano++ },
}
for i, mutate := range fields {
copyEnvelope := *base
mutate(&copyEnvelope.Scope)
consumer, _ := NewConsumer("node-1", "recipient-1", recipientPrivate, "issuer-1", issuerPublic, 8, func() time.Time { return now })
if material, err := consumer.Consume(context.Background(), &copyEnvelope, expected(copyEnvelope.Scope)); err == nil || material != nil {
t.Fatalf("tamper case %d was accepted", i)
}
}
consumer, _ := NewConsumer("node-1", "recipient-1", recipientPrivate, "issuer-1", issuerPublic, 8, func() time.Time { return now })
mismatch := expected(base.Scope)
mismatch.RouteRevision++
if material, err := consumer.Consume(context.Background(), base, mismatch); !errors.Is(err, ErrScopeMismatch) || material != nil {
t.Fatalf("expected scope mismatch, got material=%v err=%v", material, err)
}
}
func TestTTLReplayBoundsOverflowAndExpiryPruning(t *testing.T) {
now := time.Unix(1700000000, 0).UTC()
_, issuerPrivate, _, recipientPublic := testKeys(t)
for _, ttl := range []time.Duration{MinTTL - time.Nanosecond, MaxTTL + time.Nanosecond} {
scope := testScope(now, "invalid-ttl")
scope.ExpiresAtUnixNano = now.Add(ttl).UnixNano()
if _, err := Issue(scope, []byte("secret"), recipientPublic, "issuer", issuerPrivate, rand.Reader); !errors.Is(err, ErrInvalid) {
t.Fatalf("ttl %s error = %v", ttl, err)
}
}
issuerPublic, issuerPrivate, recipientPrivate, recipientPublic := testKeys(t)
clock := now.Add(time.Second)
firstScope := testScope(now, "lease-first")
firstScope.ExpiresAtUnixNano = now.Add(MinTTL).UnixNano()
secondScope := testScope(now.Add(time.Second), "lease-second")
secondScope.ExpiresAtUnixNano = now.Add(time.Second + MinTTL).UnixNano()
first, _ := Issue(firstScope, []byte("one"), recipientPublic, "issuer", issuerPrivate, rand.Reader)
second, _ := Issue(secondScope, []byte("two"), recipientPublic, "issuer", issuerPrivate, rand.Reader)
consumer, err := NewConsumer("node-1", "recipient-1", recipientPrivate, "issuer", issuerPublic, 1, func() time.Time { return clock })
if err != nil {
t.Fatal(err)
}
material, err := consumer.Consume(context.Background(), first, expected(first.Scope))
if err != nil {
t.Fatal(err)
}
material.Zero()
if _, err := consumer.Consume(context.Background(), second, expected(second.Scope)); !errors.Is(err, ErrOverflow) {
t.Fatalf("live overflow error = %v", err)
}
clock = now.Add(MinTTL + time.Nanosecond)
material, err = consumer.Consume(context.Background(), second, expected(second.Scope))
if err != nil {
t.Fatalf("consume after expired prune: %v", err)
}
material.Zero()
if _, err := NewConsumer("node", "key", recipientPrivate, "issuer", issuerPublic, 0, nil); !errors.Is(err, ErrInvalid) {
t.Fatalf("zero replay bound error = %v", err)
}
if _, err := NewConsumer("node", "key", recipientPrivate, "issuer", issuerPublic, MaxSetSize+1, nil); !errors.Is(err, ErrInvalid) {
t.Fatalf("oversize replay bound error = %v", err)
}
}