사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
158 lines
6.6 KiB
Go
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(©Envelope.Scope)
|
|
consumer, _ := NewConsumer("node-1", "recipient-1", recipientPrivate, "issuer-1", issuerPublic, 8, func() time.Time { return now })
|
|
if material, err := consumer.Consume(context.Background(), ©Envelope, 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)
|
|
}
|
|
}
|