220 lines
8.9 KiB
Go
220 lines
8.9 KiB
Go
package machine_identity
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type crossLanguageVector struct {
|
|
PublicKey string `json:"public_key_base64url"`
|
|
Token string `json:"token"`
|
|
Now int64 `json:"now"`
|
|
Audience string `json:"audience"`
|
|
Scope string `json:"required_scope"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Body string `json:"body_base64"`
|
|
}
|
|
|
|
func testIdentity(t *testing.T) (Signer, *Registry, time.Time) {
|
|
t.Helper()
|
|
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Unix(1_800_000_000, 0).UTC()
|
|
registry, err := NewRegistry(KeyRecord{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PublicKey: publicKey,
|
|
Audience: "yovision-brain", Scopes: []string{"source-config:write"}, Enabled: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return Signer{Principal: "yv:sense:site-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}, registry, now
|
|
}
|
|
|
|
func errorCode(t *testing.T, err error) string {
|
|
t.Helper()
|
|
var coded *Error
|
|
if !errors.As(err, &coded) {
|
|
t.Fatalf("expected coded error, got %v", err)
|
|
}
|
|
return coded.Code
|
|
}
|
|
|
|
func TestMintAndVerifyRequestBoundToken(t *testing.T) {
|
|
signer, registry, now := testIdentity(t)
|
|
body := []byte(`{"revision":7}`)
|
|
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
|
claims, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if claims.Issuer != signer.Principal || claims.Subject != signer.Principal || claims.ExpiresAt-claims.IssuedAt != 300 {
|
|
t.Fatalf("unexpected claims: %+v", claims)
|
|
}
|
|
}
|
|
|
|
func TestBearerTokenHasNoCookieOrQueryFallback(t *testing.T) {
|
|
if token, err := BearerToken("Bearer compact.token.value"); err != nil || token != "compact.token.value" {
|
|
t.Fatal(err)
|
|
}
|
|
for _, value := range []string{"", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"} {
|
|
if _, err := BearerToken(value); errorCode(t, err) != "machine_token_missing" {
|
|
t.Fatalf("accepted %q", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRejectsReplayWrongAudienceScopeAndRequest(t *testing.T) {
|
|
signer, registry, now := testIdentity(t)
|
|
body := []byte(`{"revision":7}`)
|
|
mint := func() string {
|
|
token, err := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return token
|
|
}
|
|
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
|
token := mint()
|
|
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_replayed" {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.Verify(mint(), "yovision-bell", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_audience_denied" {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.Verify(mint(), "yovision-brain", "events:ingest", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_scope_denied" {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := verifier.Verify(mint(), "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", []byte("changed")); errorCode(t, err) != "machine_token_invalid" {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestExpiryRevocationAndRotation(t *testing.T) {
|
|
signer, registry, now := testIdentity(t)
|
|
body := []byte("{}")
|
|
token, _ := signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", "/machine/v1/source-config", body)
|
|
expired := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return now.Add(6 * time.Minute) }}
|
|
if _, err := expired.Verify(token, "yovision-brain", "source-config:write", "POST", "/machine/v1/source-config", body); errorCode(t, err) != "machine_token_expired" {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
oldPublic, oldPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
|
newPublic, newPrivate, _ := ed25519.GenerateKey(rand.Reader)
|
|
rotation, err := NewRegistry(
|
|
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PublicKey: oldPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
|
KeyRecord{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PublicKey: newPublic, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-old-0001", PrivateKey: oldPrivate, Now: func() time.Time { return now }}
|
|
newSigner := Signer{Principal: "yv:brain:node-a", KeyID: "brain-new-0002", PrivateKey: newPrivate, Now: func() time.Time { return now }}
|
|
oldToken, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
|
newToken, _ := newSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
|
verify := Verifier{Registry: rotation, Replay: NewReplayStore(), Now: func() time.Time { return now }}
|
|
if _, err = verify.Verify(oldToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err = verify.Verify(newToken, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !rotation.Revoke("brain-old-0001") {
|
|
t.Fatal("old key was not revoked")
|
|
}
|
|
oldAfterRevoke, _ := oldSigner.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
|
if _, err = verify.Verify(oldAfterRevoke, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); errorCode(t, err) != "machine_identity_revoked" {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestTransportPolicyRejectsUnsafeTLS(t *testing.T) {
|
|
safe := TransportPolicy{TLSMinVersion: tls.VersionTLS12, VerifyCertificate: true, VerifyHostname: true,
|
|
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second, RequestTimeout: 2 * time.Second, MaxRequestBytes: 1024}
|
|
if err := safe.Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unsafe := safe
|
|
unsafe.VerifyHostname = false
|
|
if err := unsafe.Validate(); err == nil {
|
|
t.Fatal("unsafe hostname policy accepted")
|
|
}
|
|
unsafe = safe
|
|
unsafe.TLSMinVersion = tls.VersionTLS11
|
|
if err := unsafe.Validate(); err == nil {
|
|
t.Fatal("TLS 1.1 accepted")
|
|
}
|
|
}
|
|
|
|
func TestVerifiesCrossLanguageVector(t *testing.T) {
|
|
vectorPath := filepath.Join("..", "..", "..", "..", "..", "..", "contracts", "tests", "machine-identity-v1", "cross-language-vector.json")
|
|
raw, err := os.ReadFile(vectorPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var vector crossLanguageVector
|
|
if err = json.Unmarshal(raw, &vector); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
publicKey, err := base64.RawURLEncoding.DecodeString(vector.PublicKey)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
body, err := base64.StdEncoding.DecodeString(vector.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry, err := NewRegistry(KeyRecord{Principal: "yv:brain:vector", KeyID: "brain-vector-0001", PublicKey: ed25519.PublicKey(publicKey), Audience: vector.Audience, Scopes: []string{vector.Scope}, Enabled: true})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
verifier := Verifier{Registry: registry, Replay: NewReplayStore(), Now: func() time.Time { return time.Unix(vector.Now, 0) }}
|
|
claims, err := verifier.Verify(vector.Token, vector.Audience, vector.Scope, vector.Method, vector.Path, body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if claims.Issuer != "yv:brain:vector" {
|
|
t.Fatalf("unexpected issuer: %s", claims.Issuer)
|
|
}
|
|
}
|
|
|
|
func TestLoadsExternalPublicRegistryAndRejectsWrongAudience(t *testing.T) {
|
|
publicKey, _, _ := ed25519.GenerateKey(rand.Reader)
|
|
document := map[string]any{
|
|
"version": "yovision.machine-principal-registry/v1", "audience": "yovision-bell",
|
|
"principals": []any{map[string]any{"principal_id": "yv:sense:site-a", "enabled": true, "keys": []any{map[string]any{
|
|
"kid": "sense-key-0001", "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey), "status": "active", "scopes": []string{"events:ingest"},
|
|
}}}},
|
|
}
|
|
raw, _ := json.Marshal(document)
|
|
file := filepath.Join(t.TempDir(), "principals.json")
|
|
if err := os.WriteFile(file, raw, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
registry, err := LoadRegistry(file, "yovision-bell")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if record, ok := registry.Lookup("sense-key-0001"); !ok || record.Principal != "yv:sense:site-a" {
|
|
t.Fatal("registry record missing")
|
|
}
|
|
if _, err = LoadRegistry(file, "yovision-sense"); err == nil {
|
|
t.Fatal("wrong registry audience accepted")
|
|
}
|
|
}
|