feat: 建立三项目机器身份与安全传输 v1 (#151)

This commit is contained in:
QiuSW
2026-08-31 10:53:51 +08:00
parent 573113eb3b
commit 009dc3cca0
22 changed files with 2188 additions and 1 deletions
@@ -0,0 +1,68 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"strings"
)
type registryDocument struct {
Version string `json:"version"`
Audience string `json:"audience"`
Principals []registryPrincipal `json:"principals"`
}
type registryPrincipal struct {
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
Keys []registryKey `json:"keys"`
}
type registryKey struct {
KeyID string `json:"kid"`
PublicKey string `json:"public_key_base64url"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
}
func LoadRegistry(filePath, expectedAudience string) (*Registry, error) {
if strings.TrimSpace(filePath) == "" || !validAudiences[expectedAudience] {
return nil, errors.New("machine principal registry path and audience are required")
}
raw, err := os.ReadFile(filePath)
if err != nil {
return nil, errors.New("read machine principal registry")
}
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
var document registryDocument
if err = decoder.Decode(&document); err != nil {
return nil, errors.New("invalid machine principal registry")
}
if err = decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("invalid machine principal registry")
}
if document.Version != "yovision.machine-principal-registry/v1" || document.Audience != expectedAudience || len(document.Principals) == 0 {
return nil, errors.New("invalid machine principal registry")
}
records := make([]KeyRecord, 0)
for _, principal := range document.Principals {
if len(principal.Keys) == 0 {
return nil, errors.New("invalid machine principal registry")
}
for _, key := range principal.Keys {
publicKey, decodeErr := base64.RawURLEncoding.Strict().DecodeString(key.PublicKey)
if decodeErr != nil || len(publicKey) != ed25519.PublicKeySize || (key.Status != "active" && key.Status != "revoked") {
return nil, errors.New("invalid machine principal registry")
}
records = append(records, KeyRecord{Principal: principal.PrincipalID, KeyID: key.KeyID, PublicKey: ed25519.PublicKey(publicKey), Audience: document.Audience,
Scopes: key.Scopes, Enabled: principal.Enabled, Revoked: key.Status == "revoked"})
}
}
return NewRegistry(records...)
}
@@ -0,0 +1,363 @@
package machine_identity
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"net/url"
"os"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
)
var (
principalPattern = regexp.MustCompile(`^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$`)
keyIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{8,64}$`)
tokenIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{22,64}$`)
validAudiences = map[string]bool{"yovision-sense": true, "yovision-brain": true, "yovision-bell": true}
validScopes = map[string]bool{"source-config:write": true, "runtime-status:write": true, "events:ingest": true, "evidence:read": true}
)
const (
Version = "yovision.machine-identity/v1"
TokenType = "YOVISION-MACHINE+JWT"
MaxLifetime = 5 * time.Minute
AllowedSkew = 30 * time.Second
MaxKeyOverlap = 24 * time.Hour
)
type Error struct{ Code string }
func (e *Error) Error() string { return e.Code }
func codeError(code string) error { return &Error{Code: code} }
// BearerToken deliberately has no cookie or query fallback.
func BearerToken(authorization string) (string, error) {
parts := strings.Split(authorization, " ")
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] == "" || strings.ContainsAny(parts[1], " \t\r\n,") {
return "", codeError("machine_token_missing")
}
return parts[1], nil
}
type Claims struct {
Version string `json:"ver"`
Issuer string `json:"iss"`
Subject string `json:"sub"`
Audience string `json:"aud"`
Scopes []string `json:"scope"`
IssuedAt int64 `json:"iat"`
NotBefore int64 `json:"nbf"`
ExpiresAt int64 `json:"exp"`
TokenID string `json:"jti"`
Method string `json:"htm"`
Path string `json:"htu"`
BodySHA256 string `json:"body_sha256"`
}
type protectedHeader struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version string `json:"ver"`
}
type KeyRecord struct {
Principal string
KeyID string
PublicKey ed25519.PublicKey
Audience string
Scopes []string
Enabled bool
Revoked bool
}
type Registry struct {
mu sync.RWMutex
keys map[string]KeyRecord
}
func NewRegistry(records ...KeyRecord) (*Registry, error) {
r := &Registry{keys: make(map[string]KeyRecord, len(records))}
for _, record := range records {
if !keyIDPattern.MatchString(record.KeyID) || !principalPattern.MatchString(record.Principal) || !validAudiences[record.Audience] || len(record.PublicKey) != ed25519.PublicKeySize || !validScopeList(record.Scopes) {
return nil, errors.New("invalid machine key record")
}
if _, exists := r.keys[record.KeyID]; exists {
return nil, errors.New("duplicate machine key id")
}
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
r.keys[record.KeyID] = record
}
return r, nil
}
func (r *Registry) Lookup(keyID string) (KeyRecord, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
record, ok := r.keys[keyID]
record.PublicKey = slices.Clone(record.PublicKey)
record.Scopes = slices.Clone(record.Scopes)
return record, ok
}
func (r *Registry) Revoke(keyID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.keys[keyID]
if !ok {
return false
}
record.Revoked = true
r.keys[keyID] = record
return true
}
type ReplayStore struct {
mu sync.Mutex
used map[string]time.Time
}
// ReplayCache must atomically persist accepted (principal, jti) pairs until
// expiry. ReplayStore is process-local and intended for tests or a single
// uninterrupted process; connector implementations inject a durable store.
type ReplayCache interface {
Consume(principal, tokenID string, expiresAt, now time.Time) bool
}
func NewReplayStore() *ReplayStore { return &ReplayStore{used: map[string]time.Time{}} }
func (s *ReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
for key, expiry := range s.used {
if !expiry.After(now) {
delete(s.used, key)
}
}
key := principal + "\x00" + tokenID
if _, exists := s.used[key]; exists {
return false
}
s.used[key] = expiresAt
return true
}
type Signer struct {
Principal string
KeyID string
PrivateKey ed25519.PrivateKey
Now func() time.Time
}
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
if strings.TrimSpace(path) == "" {
return nil, errors.New("machine private key path is required")
}
raw, err := os.ReadFile(path)
if err != nil {
return nil, errors.New("read machine private key")
}
block, rest := pem.Decode(raw)
if block == nil || len(bytes.TrimSpace(rest)) != 0 || block.Type != "PRIVATE KEY" {
return nil, errors.New("machine private key must be one PKCS#8 PEM block")
}
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, errors.New("parse machine private key")
}
key, ok := parsed.(ed25519.PrivateKey)
if !ok || len(key) != ed25519.PrivateKeySize {
return nil, errors.New("machine private key is not Ed25519")
}
return slices.Clone(key), nil
}
func (s Signer) Mint(audience string, scopes []string, method, requestPath string, body []byte) (string, error) {
if !principalPattern.MatchString(s.Principal) || !keyIDPattern.MatchString(s.KeyID) || len(s.PrivateKey) != ed25519.PrivateKeySize || !validAudiences[audience] || !validScopeList(scopes) {
return "", errors.New("incomplete machine signer configuration")
}
normalizedPath, err := normalizePath(requestPath)
if err != nil {
return "", err
}
method = strings.ToUpper(method)
if !allowedMethod(method) {
return "", errors.New("unsupported machine request method")
}
now := time.Now().UTC()
if s.Now != nil {
now = s.Now().UTC()
}
tokenID, err := randomTokenID()
if err != nil {
return "", err
}
digest := sha256.Sum256(body)
claims := Claims{Version: Version, Issuer: s.Principal, Subject: s.Principal, Audience: audience,
Scopes: slices.Clone(scopes), IssuedAt: now.Unix(), NotBefore: now.Unix(), ExpiresAt: now.Add(MaxLifetime).Unix(),
TokenID: tokenID, Method: method, Path: normalizedPath, BodySHA256: hex.EncodeToString(digest[:])}
header := protectedHeader{Algorithm: "EdDSA", Type: TokenType, KeyID: s.KeyID, Version: Version}
headerJSON, _ := json.Marshal(header)
claimsJSON, _ := json.Marshal(claims)
signingInput := rawBase64(headerJSON) + "." + rawBase64(claimsJSON)
signature := ed25519.Sign(s.PrivateKey, []byte(signingInput))
return signingInput + "." + rawBase64(signature), nil
}
type Verifier struct {
Registry *Registry
Replay ReplayCache
Now func() time.Time
}
func (v Verifier) Verify(token, audience, requiredScope, method, requestPath string, body []byte) (Claims, error) {
if v.Registry == nil || v.Replay == nil {
return Claims{}, codeError("machine_token_invalid")
}
parts := strings.Split(token, ".")
if len(parts) != 3 || strings.Contains(token, "=") {
return Claims{}, codeError("machine_token_invalid")
}
headerBytes, err := decodeRaw(parts[0])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var header protectedHeader
if err = decodeClosed(headerBytes, &header); err != nil || header.Algorithm != "EdDSA" || header.Type != TokenType || header.Version != Version || !keyIDPattern.MatchString(header.KeyID) {
return Claims{}, codeError("machine_token_invalid")
}
record, ok := v.Registry.Lookup(header.KeyID)
if !ok {
return Claims{}, codeError("machine_token_invalid")
}
signature, err := decodeRaw(parts[2])
if err != nil || len(signature) != ed25519.SignatureSize || !ed25519.Verify(record.PublicKey, []byte(parts[0]+"."+parts[1]), signature) {
return Claims{}, codeError("machine_token_invalid")
}
if !record.Enabled || record.Revoked {
return Claims{}, codeError("machine_identity_revoked")
}
claimsBytes, err := decodeRaw(parts[1])
if err != nil {
return Claims{}, codeError("machine_token_invalid")
}
var claims Claims
if err = decodeClosed(claimsBytes, &claims); err != nil || !validClaimsShape(claims) || claims.Issuer != record.Principal || claims.Subject != record.Principal {
return Claims{}, codeError("machine_token_invalid")
}
now := time.Now().UTC()
if v.Now != nil {
now = v.Now().UTC()
}
nowUnix := now.Unix()
if claims.ExpiresAt-claims.IssuedAt <= 0 || claims.ExpiresAt-claims.IssuedAt > int64(MaxLifetime/time.Second) ||
claims.NotBefore < claims.IssuedAt || claims.NotBefore > claims.ExpiresAt || claims.IssuedAt > nowUnix+int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_invalid")
}
if claims.NotBefore > nowUnix+int64(AllowedSkew/time.Second) || claims.ExpiresAt < nowUnix-int64(AllowedSkew/time.Second) {
return Claims{}, codeError("machine_token_expired")
}
if claims.Audience != audience || record.Audience != audience {
return Claims{}, codeError("machine_audience_denied")
}
if !slices.Contains(claims.Scopes, requiredScope) || !slices.Contains(record.Scopes, requiredScope) {
return Claims{}, codeError("machine_scope_denied")
}
normalizedPath, err := normalizePath(requestPath)
digest := sha256.Sum256(body)
if err != nil || claims.Method != strings.ToUpper(method) || claims.Path != normalizedPath || claims.BodySHA256 != hex.EncodeToString(digest[:]) {
return Claims{}, codeError("machine_token_invalid")
}
if !v.Replay.Consume(claims.Issuer, claims.TokenID, time.Unix(claims.ExpiresAt, 0).Add(AllowedSkew), now) {
return Claims{}, codeError("machine_token_replayed")
}
return claims, nil
}
func decodeClosed(raw []byte, target any) error {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("trailing JSON value")
}
return err
}
return nil
}
func validClaimsShape(claims Claims) bool {
if claims.Version != Version || !principalPattern.MatchString(claims.Issuer) || claims.Subject != claims.Issuer || !validAudiences[claims.Audience] || !tokenIDPattern.MatchString(claims.TokenID) ||
len(claims.Scopes) == 0 || len(claims.Scopes) > 4 || !allowedMethod(claims.Method) || claims.Path == "" || len(claims.BodySHA256) != 64 {
return false
}
if !validScopeList(claims.Scopes) {
return false
}
_, err := hex.DecodeString(claims.BodySHA256)
return err == nil
}
func validScopeList(scopes []string) bool {
if len(scopes) == 0 || len(scopes) > 4 {
return false
}
seen := map[string]bool{}
for _, scope := range scopes {
if !validScopes[scope] || seen[scope] {
return false
}
seen[scope] = true
}
return true
}
func normalizePath(value string) (string, error) {
parsed, err := url.ParseRequestURI(value)
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.Path == "" || !strings.HasPrefix(parsed.Path, "/") || strings.Contains(parsed.Path, "\\") || strings.Contains(parsed.Path, "//") || path.Clean(parsed.Path) != parsed.Path {
return "", errors.New("machine request path must be a normalized absolute path without query or fragment")
}
return parsed.EscapedPath(), nil
}
func allowedMethod(method string) bool {
switch method {
case "GET", "POST", "PUT", "PATCH", "DELETE":
return true
default:
return false
}
}
func randomTokenID() (string, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate machine token id: %w", err)
}
return rawBase64(raw), nil
}
func rawBase64(value []byte) string { return base64.RawURLEncoding.EncodeToString(value) }
func decodeRaw(value string) ([]byte, error) {
return base64.RawURLEncoding.Strict().DecodeString(value)
}
@@ -0,0 +1,219 @@
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")
}
}
@@ -0,0 +1,39 @@
package machine_identity
import (
"crypto/tls"
"errors"
"net/http"
"time"
)
type TransportPolicy struct {
TLSMinVersion uint16
VerifyCertificate bool
VerifyHostname bool
ConnectTimeout time.Duration
ResponseHeaderTimeout time.Duration
RequestTimeout time.Duration
MaxRequestBytes int64
}
func (p TransportPolicy) Validate() error {
if p.TLSMinVersion < tls.VersionTLS12 || !p.VerifyCertificate || !p.VerifyHostname || p.ConnectTimeout < 100*time.Millisecond || p.ConnectTimeout > 30*time.Second ||
p.ResponseHeaderTimeout < 100*time.Millisecond || p.ResponseHeaderTimeout > 30*time.Second || p.RequestTimeout < 100*time.Millisecond || p.RequestTimeout > 60*time.Second ||
p.MaxRequestBytes < 1 || p.MaxRequestBytes > 10*1024*1024 {
return errors.New("machine transport policy is unsafe")
}
return nil
}
func (p TransportPolicy) HTTPClient() (*http.Client, error) {
if err := p.Validate(); err != nil {
return nil, err
}
transport := &http.Transport{
TLSClientConfig: &tls.Config{MinVersion: p.TLSMinVersion},
TLSHandshakeTimeout: p.ConnectTimeout,
ResponseHeaderTimeout: p.ResponseHeaderTimeout,
}
return &http.Client{Transport: transport, Timeout: p.RequestTimeout}, nil
}