feat: 建立三项目机器身份与安全传输 v1 (#151)
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ version = "0.1.0"
|
|||||||
description = "Headless inference delivery unit for YoVision"
|
description = "Headless inference delivery unit for YoVision"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = "==3.11.*"
|
requires-python = "==3.11.*"
|
||||||
dependencies = []
|
dependencies = ["cryptography==50.0.1"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
# The wheel backend is selected by the official PyTorch index documented in
|
# The wheel backend is selected by the official PyTorch index documented in
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Independent service-to-service machine identity for Brain connectors."""
|
||||||
|
|
||||||
|
from .token import (
|
||||||
|
ALLOWED_SKEW_SECONDS,
|
||||||
|
MAX_KEY_OVERLAP_SECONDS,
|
||||||
|
MAX_LIFETIME_SECONDS,
|
||||||
|
VERSION,
|
||||||
|
Claims,
|
||||||
|
KeyRecord,
|
||||||
|
MachineIdentityError,
|
||||||
|
Registry,
|
||||||
|
ReplayStore,
|
||||||
|
Signer,
|
||||||
|
Verifier,
|
||||||
|
load_private_key,
|
||||||
|
load_registry,
|
||||||
|
bearer_token,
|
||||||
|
)
|
||||||
|
from .transport import TransportPolicy
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALLOWED_SKEW_SECONDS",
|
||||||
|
"MAX_KEY_OVERLAP_SECONDS",
|
||||||
|
"MAX_LIFETIME_SECONDS",
|
||||||
|
"VERSION",
|
||||||
|
"Claims",
|
||||||
|
"KeyRecord",
|
||||||
|
"MachineIdentityError",
|
||||||
|
"Registry",
|
||||||
|
"ReplayStore",
|
||||||
|
"Signer",
|
||||||
|
"TransportPolicy",
|
||||||
|
"Verifier",
|
||||||
|
"load_private_key",
|
||||||
|
"load_registry",
|
||||||
|
"bearer_token",
|
||||||
|
]
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""Ed25519 request-bound machine tokens.
|
||||||
|
|
||||||
|
This module never accepts browser cookies, GoAdmin JWTs, query tokens, or
|
||||||
|
shared secrets. HTTP adapters must obtain the compact token exclusively from
|
||||||
|
the Authorization bearer header and pass the request body unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Iterable, Protocol
|
||||||
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||||
|
Ed25519PrivateKey,
|
||||||
|
Ed25519PublicKey,
|
||||||
|
)
|
||||||
|
|
||||||
|
VERSION = "yovision.machine-identity/v1"
|
||||||
|
TOKEN_TYPE = "YOVISION-MACHINE+JWT"
|
||||||
|
MAX_LIFETIME_SECONDS = 300
|
||||||
|
ALLOWED_SKEW_SECONDS = 30
|
||||||
|
MAX_KEY_OVERLAP_SECONDS = 24 * 60 * 60
|
||||||
|
_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"})
|
||||||
|
_AUDIENCES = frozenset({"yovision-sense", "yovision-brain", "yovision-bell"})
|
||||||
|
_SCOPES = frozenset({"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"})
|
||||||
|
_PRINCIPAL = re.compile(r"^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$")
|
||||||
|
_KEY_ID = re.compile(r"^[A-Za-z0-9._-]{8,64}$")
|
||||||
|
_TOKEN_ID = re.compile(r"^[A-Za-z0-9_-]{22,64}$")
|
||||||
|
|
||||||
|
|
||||||
|
class MachineIdentityError(ValueError):
|
||||||
|
"""A stable, non-secret authentication failure."""
|
||||||
|
|
||||||
|
def __init__(self, code: str) -> None:
|
||||||
|
super().__init__(code)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def bearer_token(authorization: str) -> str:
|
||||||
|
"""Extract only an Authorization bearer token; there is no cookie/query fallback."""
|
||||||
|
parts = authorization.split(" ")
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1] or any(character in parts[1] for character in " \t\r\n,"):
|
||||||
|
raise MachineIdentityError("machine_token_missing")
|
||||||
|
return parts[1]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Claims:
|
||||||
|
ver: str
|
||||||
|
iss: str
|
||||||
|
sub: str
|
||||||
|
aud: str
|
||||||
|
scope: tuple[str, ...]
|
||||||
|
iat: int
|
||||||
|
nbf: int
|
||||||
|
exp: int
|
||||||
|
jti: str
|
||||||
|
htm: str
|
||||||
|
htu: str
|
||||||
|
body_sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class KeyRecord:
|
||||||
|
principal: str
|
||||||
|
key_id: str
|
||||||
|
public_key: Ed25519PublicKey
|
||||||
|
audience: str
|
||||||
|
scopes: frozenset[str]
|
||||||
|
enabled: bool = True
|
||||||
|
revoked: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class Registry:
|
||||||
|
def __init__(self, records: Iterable[KeyRecord]) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self._records: dict[str, KeyRecord] = {}
|
||||||
|
for record in records:
|
||||||
|
if not _KEY_ID.fullmatch(record.key_id) or not _PRINCIPAL.fullmatch(record.principal) or record.audience not in _AUDIENCES or not _valid_scopes(record.scopes):
|
||||||
|
raise ValueError("invalid machine key record")
|
||||||
|
if record.key_id in self._records:
|
||||||
|
raise ValueError("duplicate machine key id")
|
||||||
|
self._records[record.key_id] = record
|
||||||
|
|
||||||
|
def lookup(self, key_id: str) -> KeyRecord | None:
|
||||||
|
with self._lock:
|
||||||
|
return self._records.get(key_id)
|
||||||
|
|
||||||
|
def revoke(self, key_id: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
record = self._records.get(key_id)
|
||||||
|
if record is None:
|
||||||
|
return False
|
||||||
|
self._records[key_id] = KeyRecord(
|
||||||
|
principal=record.principal,
|
||||||
|
key_id=record.key_id,
|
||||||
|
public_key=record.public_key,
|
||||||
|
audience=record.audience,
|
||||||
|
scopes=record.scopes,
|
||||||
|
enabled=record.enabled,
|
||||||
|
revoked=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayStore:
|
||||||
|
"""Process-local replay cache for tests or one uninterrupted process."""
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._used: dict[tuple[str, str], int] = {}
|
||||||
|
|
||||||
|
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
self._used = {key: expiry for key, expiry in self._used.items() if expiry > now}
|
||||||
|
key = (principal, token_id)
|
||||||
|
if key in self._used:
|
||||||
|
return False
|
||||||
|
self._used[key] = expires_at
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class ReplayCache(Protocol):
|
||||||
|
"""Connector implementations provide an atomic durable implementation."""
|
||||||
|
|
||||||
|
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool: ...
|
||||||
|
|
||||||
|
|
||||||
|
def load_private_key(path: str | Path) -> Ed25519PrivateKey:
|
||||||
|
if not str(path).strip():
|
||||||
|
raise ValueError("machine private key path is required")
|
||||||
|
try:
|
||||||
|
raw = Path(path).read_bytes()
|
||||||
|
key = serialization.load_pem_private_key(raw, password=None)
|
||||||
|
except (OSError, ValueError, TypeError) as exc:
|
||||||
|
raise ValueError("invalid machine private key file") from exc
|
||||||
|
if not isinstance(key, Ed25519PrivateKey):
|
||||||
|
raise ValueError("machine private key is not Ed25519")
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def load_registry(path: str | Path, expected_audience: str) -> Registry:
|
||||||
|
if not str(path).strip() or expected_audience not in _AUDIENCES:
|
||||||
|
raise ValueError("machine principal registry path and audience are required")
|
||||||
|
try:
|
||||||
|
document = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError("invalid machine principal registry") from exc
|
||||||
|
if not isinstance(document, dict) or set(document) != {"version", "audience", "principals"} or document["version"] != "yovision.machine-principal-registry/v1" or document["audience"] != expected_audience or not isinstance(document["principals"], list) or not document["principals"]:
|
||||||
|
raise ValueError("invalid machine principal registry")
|
||||||
|
records: list[KeyRecord] = []
|
||||||
|
try:
|
||||||
|
for principal in document["principals"]:
|
||||||
|
if not isinstance(principal, dict) or set(principal) != {"principal_id", "enabled", "keys"} or not isinstance(principal["enabled"], bool) or not isinstance(principal["keys"], list) or not principal["keys"]:
|
||||||
|
raise ValueError
|
||||||
|
for key in principal["keys"]:
|
||||||
|
if not isinstance(key, dict) or set(key) != {"kid", "public_key_base64url", "status", "scopes"} or key["status"] not in {"active", "revoked"} or not isinstance(key["scopes"], list):
|
||||||
|
raise ValueError
|
||||||
|
public_key = Ed25519PublicKey.from_public_bytes(_b64decode(key["public_key_base64url"]))
|
||||||
|
records.append(KeyRecord(principal["principal_id"], key["kid"], public_key, expected_audience, frozenset(key["scopes"]), principal["enabled"], key["status"] == "revoked"))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
raise ValueError("invalid machine principal registry") from None
|
||||||
|
return Registry(records)
|
||||||
|
|
||||||
|
|
||||||
|
class Signer:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
principal: str,
|
||||||
|
key_id: str,
|
||||||
|
private_key: Ed25519PrivateKey,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], int] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if not _PRINCIPAL.fullmatch(principal) or not _KEY_ID.fullmatch(key_id) or not isinstance(private_key, Ed25519PrivateKey):
|
||||||
|
raise ValueError("incomplete machine signer configuration")
|
||||||
|
self._principal = principal
|
||||||
|
self._key_id = key_id
|
||||||
|
self._private_key = private_key
|
||||||
|
self._clock = clock or (lambda: int(time.time()))
|
||||||
|
|
||||||
|
def mint(self, audience: str, scopes: Iterable[str], method: str, request_path: str, body: bytes) -> str:
|
||||||
|
normalized_path = _normalize_path(request_path)
|
||||||
|
normalized_method = method.upper()
|
||||||
|
scope_values = tuple(scopes)
|
||||||
|
if audience not in _AUDIENCES or not _valid_scopes(scope_values) or normalized_method not in _METHODS:
|
||||||
|
raise ValueError("invalid machine token request")
|
||||||
|
now = int(self._clock())
|
||||||
|
header = {"alg": "EdDSA", "typ": TOKEN_TYPE, "kid": self._key_id, "ver": VERSION}
|
||||||
|
claims = {
|
||||||
|
"ver": VERSION,
|
||||||
|
"iss": self._principal,
|
||||||
|
"sub": self._principal,
|
||||||
|
"aud": audience,
|
||||||
|
"scope": list(scope_values),
|
||||||
|
"iat": now,
|
||||||
|
"nbf": now,
|
||||||
|
"exp": now + MAX_LIFETIME_SECONDS,
|
||||||
|
"jti": secrets.token_urlsafe(16),
|
||||||
|
"htm": normalized_method,
|
||||||
|
"htu": normalized_path,
|
||||||
|
"body_sha256": hashlib.sha256(body).hexdigest(),
|
||||||
|
}
|
||||||
|
encoded_header = _encode_json(header)
|
||||||
|
encoded_claims = _encode_json(claims)
|
||||||
|
signing_input = f"{encoded_header}.{encoded_claims}".encode("ascii")
|
||||||
|
signature = self._private_key.sign(signing_input)
|
||||||
|
return f"{encoded_header}.{encoded_claims}.{_b64encode(signature)}"
|
||||||
|
|
||||||
|
|
||||||
|
class Verifier:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
registry: Registry,
|
||||||
|
replay_store: ReplayCache,
|
||||||
|
*,
|
||||||
|
clock: Callable[[], int] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._registry = registry
|
||||||
|
self._replay_store = replay_store
|
||||||
|
self._clock = clock or (lambda: int(time.time()))
|
||||||
|
|
||||||
|
def verify(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
audience: str,
|
||||||
|
required_scope: str,
|
||||||
|
method: str,
|
||||||
|
request_path: str,
|
||||||
|
body: bytes,
|
||||||
|
) -> Claims:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3 or "=" in token:
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
header = _decode_object(parts[0], {"alg", "typ", "kid", "ver"})
|
||||||
|
if header.get("alg") != "EdDSA" or header.get("typ") != TOKEN_TYPE or header.get("ver") != VERSION or not isinstance(header.get("kid"), str) or not _KEY_ID.fullmatch(header["kid"]):
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
record = self._registry.lookup(header["kid"])
|
||||||
|
if record is None:
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
try:
|
||||||
|
record.public_key.verify(_b64decode(parts[2]), f"{parts[0]}.{parts[1]}".encode("ascii"))
|
||||||
|
except (InvalidSignature, ValueError):
|
||||||
|
raise MachineIdentityError("machine_token_invalid") from None
|
||||||
|
if not record.enabled or record.revoked:
|
||||||
|
raise MachineIdentityError("machine_identity_revoked")
|
||||||
|
|
||||||
|
raw = _decode_object(parts[1], {"ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"})
|
||||||
|
claims = _claims_from_object(raw)
|
||||||
|
if claims.iss != record.principal or claims.sub != record.principal:
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
now = int(self._clock())
|
||||||
|
if claims.exp - claims.iat <= 0 or claims.exp - claims.iat > MAX_LIFETIME_SECONDS or claims.nbf < claims.iat or claims.nbf > claims.exp or claims.iat > now + ALLOWED_SKEW_SECONDS:
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
if claims.nbf > now + ALLOWED_SKEW_SECONDS or claims.exp < now - ALLOWED_SKEW_SECONDS:
|
||||||
|
raise MachineIdentityError("machine_token_expired")
|
||||||
|
if claims.aud != audience or record.audience != audience:
|
||||||
|
raise MachineIdentityError("machine_audience_denied")
|
||||||
|
if required_scope not in claims.scope or required_scope not in record.scopes:
|
||||||
|
raise MachineIdentityError("machine_scope_denied")
|
||||||
|
if claims.htm != method.upper() or claims.htu != _normalize_path(request_path) or claims.body_sha256 != hashlib.sha256(body).hexdigest():
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
if not self._replay_store.consume(claims.iss, claims.jti, claims.exp + ALLOWED_SKEW_SECONDS, now):
|
||||||
|
raise MachineIdentityError("machine_token_replayed")
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def _claims_from_object(value: dict[str, object]) -> Claims:
|
||||||
|
try:
|
||||||
|
scope = value["scope"]
|
||||||
|
if not isinstance(scope, list) or not _valid_scopes(scope):
|
||||||
|
raise ValueError
|
||||||
|
integer_fields = ("iat", "nbf", "exp")
|
||||||
|
if any(not isinstance(value[field], int) or isinstance(value[field], bool) for field in integer_fields):
|
||||||
|
raise ValueError
|
||||||
|
string_fields = ("ver", "iss", "sub", "aud", "jti", "htm", "htu", "body_sha256")
|
||||||
|
if any(not isinstance(value[field], str) for field in string_fields):
|
||||||
|
raise ValueError
|
||||||
|
claims = Claims(scope=tuple(scope), **{key: value[key] for key in string_fields + integer_fields})
|
||||||
|
if claims.ver != VERSION or not _PRINCIPAL.fullmatch(claims.iss) or claims.iss != claims.sub or claims.aud not in _AUDIENCES or not _TOKEN_ID.fullmatch(claims.jti) or claims.htm not in _METHODS or len(claims.body_sha256) != 64:
|
||||||
|
raise ValueError
|
||||||
|
bytes.fromhex(claims.body_sha256)
|
||||||
|
_normalize_path(claims.htu)
|
||||||
|
return claims
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
raise MachineIdentityError("machine_token_invalid") from None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_path(value: str) -> str:
|
||||||
|
split = urlsplit(value)
|
||||||
|
if not value.startswith("/") or split.scheme or split.netloc or split.query or split.fragment or "\\" in split.path or "//" in split.path or posixpath.normpath(split.path) != split.path:
|
||||||
|
raise ValueError("machine request path must be normalized and contain no query or fragment")
|
||||||
|
return split.path
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_scopes(scopes: Iterable[str]) -> bool:
|
||||||
|
values = tuple(scopes)
|
||||||
|
return 1 <= len(values) <= 4 and len(set(values)) == len(values) and all(scope in _SCOPES for scope in values)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_json(value: dict[str, object]) -> str:
|
||||||
|
return _b64encode(json.dumps(value, ensure_ascii=True, separators=(",", ":"), sort_keys=True).encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_object(value: str, expected_keys: set[str]) -> dict[str, object]:
|
||||||
|
try:
|
||||||
|
decoded = json.loads(_b64decode(value).decode("utf-8"))
|
||||||
|
except (UnicodeDecodeError, ValueError, json.JSONDecodeError):
|
||||||
|
raise MachineIdentityError("machine_token_invalid") from None
|
||||||
|
if not isinstance(decoded, dict) or set(decoded) != expected_keys:
|
||||||
|
raise MachineIdentityError("machine_token_invalid")
|
||||||
|
return decoded
|
||||||
|
|
||||||
|
|
||||||
|
def _b64encode(value: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _b64decode(value: str) -> bytes:
|
||||||
|
if not value or "=" in value:
|
||||||
|
raise ValueError("invalid base64url")
|
||||||
|
decoded = base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)
|
||||||
|
if _b64encode(decoded) != value:
|
||||||
|
raise ValueError("non-canonical base64url")
|
||||||
|
return decoded
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Fail-closed HTTPS transport policy for Brain connectors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ssl
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TransportPolicy:
|
||||||
|
tls_min_version: str
|
||||||
|
verify_certificate: bool
|
||||||
|
verify_hostname: bool
|
||||||
|
connect_timeout_ms: int
|
||||||
|
response_header_timeout_ms: int
|
||||||
|
request_timeout_ms: int
|
||||||
|
max_request_bytes: int
|
||||||
|
|
||||||
|
def validate(self) -> None:
|
||||||
|
if (
|
||||||
|
self.tls_min_version not in {"1.2", "1.3"}
|
||||||
|
or not self.verify_certificate
|
||||||
|
or not self.verify_hostname
|
||||||
|
or not 100 <= self.connect_timeout_ms <= 30_000
|
||||||
|
or not 100 <= self.response_header_timeout_ms <= 30_000
|
||||||
|
or not 100 <= self.request_timeout_ms <= 60_000
|
||||||
|
or not 1 <= self.max_request_bytes <= 10 * 1024 * 1024
|
||||||
|
):
|
||||||
|
raise ValueError("machine transport policy is unsafe")
|
||||||
|
|
||||||
|
def ssl_context(self) -> ssl.SSLContext:
|
||||||
|
self.validate()
|
||||||
|
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
||||||
|
context.minimum_version = ssl.TLSVersion.TLSv1_3 if self.tls_min_version == "1.3" else ssl.TLSVersion.TLSv1_2
|
||||||
|
context.check_hostname = True
|
||||||
|
context.verify_mode = ssl.CERT_REQUIRED
|
||||||
|
return context
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Machine identity contract v1
|
||||||
|
|
||||||
|
`yovision.machine-identity/v1` defines service-to-service identity for YoVision connectors. It is deliberately separate from Sense and Bell users, GoAdmin JWT/Cookie state, database roles and operating-system accounts.
|
||||||
|
|
||||||
|
## Authentication mechanism
|
||||||
|
|
||||||
|
Every request uses HTTPS and one compact Ed25519 JWS in `Authorization: Bearer <token>`. The protected header is closed and contains `alg=EdDSA`, `typ=YOVISION-MACHINE+JWT`, `kid` and `ver=yovision.machine-identity/v1`. The closed claims object contains:
|
||||||
|
|
||||||
|
mTLS is not the primary v1 identity mechanism. A customer PKI may add mTLS later as transport hardening, but it cannot replace or weaken the v1 principal, audience, scope, request binding, replay and revocation checks.
|
||||||
|
|
||||||
|
- one instance-specific `iss`/`sub` principal;
|
||||||
|
- one exact service `aud`;
|
||||||
|
- the minimum required `scope` values;
|
||||||
|
- `iat`, `nbf`, `exp` and a single-use random `jti`;
|
||||||
|
- uppercase HTTP method `htm`, normalized absolute-path reference `htu`, and lowercase SHA-256 `body_sha256`.
|
||||||
|
|
||||||
|
Tokens live for at most 300 seconds. Consumers allow at most 30 seconds of clock skew, verify the signature and active key/principal before authorization, then atomically consume `jti` until `exp + skew`. Retrying transport creates a new token and `jti`; business idempotency keys remain unchanged.
|
||||||
|
|
||||||
|
Production consumers persist the replay key `(principal, jti)` in their own durable store so a process restart cannot reopen the replay window. The checked-in process-local replay stores are adapter test/default primitives only; connector tasks must inject an atomic durable implementation and test restart behavior without sharing a database across products.
|
||||||
|
|
||||||
|
The v1 scopes are:
|
||||||
|
|
||||||
|
| Caller | Audience | Scope |
|
||||||
|
|---|---|---|
|
||||||
|
| Sense | `yovision-brain` | `source-config:write` |
|
||||||
|
| Brain | `yovision-sense` | `runtime-status:write` |
|
||||||
|
| Brain or Sense | `yovision-bell` | `events:ingest` |
|
||||||
|
| Bell | `yovision-sense` | `evidence:read` |
|
||||||
|
|
||||||
|
No wildcard audience or scope exists. A relay authenticates as its own transport principal and never replaces the original event producer identity.
|
||||||
|
|
||||||
|
## Key lifecycle
|
||||||
|
|
||||||
|
Private Ed25519 keys are generated per product instance and stored outside the repository in an OS-protected file or secret store. Runtime configuration contains only the private-key path. Public registries are local consumer configuration, not a shared database.
|
||||||
|
|
||||||
|
Rotation first registers a new `kid`, switches the caller, and removes the old key after an overlap no longer than 24 hours. A disabled principal or revoked `kid` is rejected on every request, including tokens that have not expired. Emergency rollback disables the connector; it never enables a shared password, browser token, query token, plaintext transport or signature bypass.
|
||||||
|
|
||||||
|
## Threat boundary
|
||||||
|
|
||||||
|
v1 protects against token modification, wrong audience/scope, expired or premature tokens, captured-token replay, key/principal revocation and accidental credential mixing. It does not protect a host after administrator/root compromise, a stolen usable private key before revocation, compromised TLS trust roots, endpoint implementation flaws or denial of service. Rate and body-size limits remain consumer responsibilities.
|
||||||
|
|
||||||
|
See `../transport/v1/README.md` for HTTPS and request policy. Stable failures are defined in `errors.md`; callers and logs must expose only the stable code, principal/kid when already authenticated, and correlation ID—never the token, signature, private/public key material or complete Authorization header.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
v1 is closed. New optional claims require all consumers to accept them before producers emit them. Any change to signing input, algorithm, claim meaning, replay semantics, maximum lifetime, audience or scope meaning publishes a new major version. Consumers keep the last accepted major during a controlled migration; rollback disables the new producer version without weakening verification.
|
||||||
|
|
||||||
|
## Reproducible verification
|
||||||
|
|
||||||
|
From the repository root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
|
||||||
|
|
||||||
|
cd Sense/server
|
||||||
|
go test -race ./app/sense/integration/machine_identity
|
||||||
|
|
||||||
|
cd ../../Bell/server
|
||||||
|
go test -race ./app/bell/integration/machine_identity
|
||||||
|
```
|
||||||
|
|
||||||
|
The isolated contract test validates both JSON Schemas, the fixed Go/Python Ed25519 vector, request binding, exact audience/scope, expiry, replay, rotation overlap, revocation, bearer-only extraction and verified TLS policy. Product connector tasks remain responsible for injecting a durable replay store and testing restart recovery.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Machine identity v1 stable errors
|
||||||
|
|
||||||
|
| Code | Meaning | Retry |
|
||||||
|
|---|---|---|
|
||||||
|
| `machine_token_missing` | Authorization bearer token is absent or malformed | No, fix request |
|
||||||
|
| `machine_token_invalid` | Header, claims, signature, request binding or key is invalid | No |
|
||||||
|
| `machine_token_expired` | Token is outside its accepted time window | Mint a new token |
|
||||||
|
| `machine_audience_denied` | Exact audience does not match | No |
|
||||||
|
| `machine_scope_denied` | Required scope is absent or not granted to the key | No |
|
||||||
|
| `machine_identity_revoked` | Principal or key is disabled/revoked | No; operator action |
|
||||||
|
| `machine_token_replayed` | The same principal/jti was already accepted | Retry with a new token and the same business idempotency key |
|
||||||
|
| `machine_transport_required` | HTTPS policy is not satisfied | No; fix deployment |
|
||||||
|
|
||||||
|
Responses and audit facts never include the token, signature, key material or Authorization header.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://yovision.local/contracts/machine-identity/v1/machine-token.schema.json",
|
||||||
|
"title": "YoVision machine token claims v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["ver", "iss", "sub", "aud", "scope", "iat", "nbf", "exp", "jti", "htm", "htu", "body_sha256"],
|
||||||
|
"properties": {
|
||||||
|
"ver": {"const": "yovision.machine-identity/v1"},
|
||||||
|
"iss": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||||
|
"sub": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||||
|
"aud": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
|
||||||
|
"scope": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 4,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}
|
||||||
|
},
|
||||||
|
"iat": {"type": "integer", "minimum": 0},
|
||||||
|
"nbf": {"type": "integer", "minimum": 0},
|
||||||
|
"exp": {"type": "integer", "minimum": 0},
|
||||||
|
"jti": {"type": "string", "pattern": "^[A-Za-z0-9_-]{22,64}$"},
|
||||||
|
"htm": {"type": "string", "pattern": "^(GET|POST|PUT|PATCH|DELETE)$"},
|
||||||
|
"htu": {"type": "string", "pattern": "^/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*$"},
|
||||||
|
"body_sha256": {"type": "string", "pattern": "^[a-f0-9]{64}$"}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://yovision.local/contracts/machine-identity/v1/principal-registry.schema.json",
|
||||||
|
"title": "YoVision machine principal registry v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["version", "audience", "principals"],
|
||||||
|
"properties": {
|
||||||
|
"version": {"const": "yovision.machine-principal-registry/v1"},
|
||||||
|
"audience": {"enum": ["yovision-sense", "yovision-brain", "yovision-bell"]},
|
||||||
|
"principals": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["principal_id", "enabled", "keys"],
|
||||||
|
"properties": {
|
||||||
|
"principal_id": {"type": "string", "pattern": "^yv:(sense|brain|bell):[a-z0-9][a-z0-9.-]{0,62}$"},
|
||||||
|
"enabled": {"type": "boolean"},
|
||||||
|
"keys": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["kid", "public_key_base64url", "status", "scopes"],
|
||||||
|
"properties": {
|
||||||
|
"kid": {"type": "string", "pattern": "^[A-Za-z0-9._-]{8,64}$"},
|
||||||
|
"public_key_base64url": {"type": "string", "pattern": "^[A-Za-z0-9_-]{43}$"},
|
||||||
|
"status": {"enum": ["active", "revoked"]},
|
||||||
|
"scopes": {"type": "array", "minItems": 1, "maxItems": 4, "uniqueItems": true, "items": {"enum": ["source-config:write", "runtime-status:write", "events:ingest", "evidence:read"]}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "yovision.machine-token-vector/v1",
|
||||||
|
"public_key_base64url": "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ",
|
||||||
|
"token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImJyYWluLXZlY3Rvci0wMDAxIiwidHlwIjoiWU9WSVNJT04tTUFDSElORStKV1QiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.eyJhdWQiOiJ5b3Zpc2lvbi1zZW5zZSIsImJvZHlfc2hhMjU2IjoiNDA5NDQzYTZlZTVhYTI5NmRjY2Q2YzBkMTkzZTIxNDU2OGRhYTAwNTNiNjYxNTVmYmE4YWRjYTk5NWI3ODIzZCIsImV4cCI6MTgwMDAwMDMwMCwiaHRtIjoiUE9TVCIsImh0dSI6Ii9tYWNoaW5lL3YxL3J1bnRpbWUtc3RhdHVzIiwiaWF0IjoxODAwMDAwMDAwLCJpc3MiOiJ5djpicmFpbjp2ZWN0b3IiLCJqdGkiOiJBUUlEQkFVR0J3Z0pDZ3NNRFE0UEVBIiwibmJmIjoxODAwMDAwMDAwLCJzY29wZSI6WyJydW50aW1lLXN0YXR1czp3cml0ZSJdLCJzdWIiOiJ5djpicmFpbjp2ZWN0b3IiLCJ2ZXIiOiJ5b3Zpc2lvbi5tYWNoaW5lLWlkZW50aXR5L3YxIn0.zrvo_7lRiDX1D6po6OlfQg4hDg6XXlyUEmNMYgpuRl3ArXSjvGuOLivDousIyLtRO4bYJu9xMAWX1cea7MdVBQ",
|
||||||
|
"now": 1800000000,
|
||||||
|
"audience": "yovision-sense",
|
||||||
|
"required_scope": "runtime-status:write",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/machine/v1/runtime-status",
|
||||||
|
"body_base64": "eyJzdGF0dXMiOiJydW5uaW5nIn0="
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
cryptography==50.0.1
|
||||||
|
jsonschema==4.25.1
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
[CmdletBinding()]
|
||||||
|
param()
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$testDirectory = $PSScriptRoot
|
||||||
|
$requirements = Join-Path $testDirectory 'requirements.txt'
|
||||||
|
$tempRoot = [IO.Path]::GetFullPath([IO.Path]::GetTempPath())
|
||||||
|
$workDirectory = Join-Path $tempRoot ("yovision-machine-identity-v1-{0}" -f [Guid]::NewGuid().ToString('N'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
New-Item -ItemType Directory -Path $workDirectory | Out-Null
|
||||||
|
$virtualEnvironment = Join-Path $workDirectory '.venv'
|
||||||
|
python -m venv $virtualEnvironment
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Failed to create the isolated Python environment.' }
|
||||||
|
|
||||||
|
$python = Join-Path $virtualEnvironment 'Scripts\python.exe'
|
||||||
|
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
||||||
|
$env:PYTHONDONTWRITEBYTECODE = '1'
|
||||||
|
& $python -m pip install --quiet --requirement $requirements
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Failed to install pinned machine-identity test dependencies.' }
|
||||||
|
|
||||||
|
& $python $testDirectory\test_contract.py
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Machine-identity v1 contract tests failed.' }
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
$resolvedWorkDirectory = [IO.Path]::GetFullPath($workDirectory)
|
||||||
|
if (-not $resolvedWorkDirectory.StartsWith($tempRoot, [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
throw "Refusing to remove a temporary directory outside $tempRoot"
|
||||||
|
}
|
||||||
|
if (Test-Path -LiteralPath $resolvedWorkDirectory) {
|
||||||
|
Remove-Item -LiteralPath $resolvedWorkDirectory -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import base64
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
sys.path.insert(0, str(ROOT / "Brain" / "src"))
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
|
from yovision_brain.integration.machine_identity import (
|
||||||
|
KeyRecord,
|
||||||
|
MachineIdentityError,
|
||||||
|
Registry,
|
||||||
|
ReplayStore,
|
||||||
|
Signer,
|
||||||
|
TransportPolicy,
|
||||||
|
Verifier,
|
||||||
|
load_registry,
|
||||||
|
bearer_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContractFilesTest(unittest.TestCase):
|
||||||
|
def test_closed_claim_and_registry_schemas(self) -> None:
|
||||||
|
claims = json.loads((ROOT / "contracts/machine-identity/v1/machine-token.schema.json").read_text(encoding="utf-8"))
|
||||||
|
registry = json.loads((ROOT / "contracts/machine-identity/v1/principal-registry.schema.json").read_text(encoding="utf-8"))
|
||||||
|
transport = json.loads((ROOT / "contracts/transport/v1/transport-policy.schema.json").read_text(encoding="utf-8"))
|
||||||
|
Draft202012Validator.check_schema(claims)
|
||||||
|
Draft202012Validator.check_schema(registry)
|
||||||
|
Draft202012Validator.check_schema(transport)
|
||||||
|
self.assertFalse(claims["additionalProperties"])
|
||||||
|
self.assertEqual(claims["properties"]["ver"]["const"], "yovision.machine-identity/v1")
|
||||||
|
self.assertEqual(claims["properties"]["scope"]["items"]["enum"], [
|
||||||
|
"source-config:write", "runtime-status:write", "events:ingest", "evidence:read"
|
||||||
|
])
|
||||||
|
self.assertFalse(registry["additionalProperties"])
|
||||||
|
self.assertEqual(transport["properties"]["verify_certificate"]["const"], True)
|
||||||
|
self.assertEqual(transport["properties"]["verify_hostname"]["const"], True)
|
||||||
|
|
||||||
|
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
|
||||||
|
claims_object = json.loads(base64.urlsafe_b64decode(vector["token"].split(".")[1] + "=="))
|
||||||
|
Draft202012Validator(claims).validate(claims_object)
|
||||||
|
Draft202012Validator(transport).validate({
|
||||||
|
"version": "yovision.transport/v1", "tls_min_version": "1.2", "verify_certificate": True,
|
||||||
|
"verify_hostname": True, "connect_timeout_ms": 1000, "response_header_timeout_ms": 1000,
|
||||||
|
"request_timeout_ms": 5000, "max_request_bytes": 1048576,
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_brain_dependency_is_frozen(self) -> None:
|
||||||
|
pyproject = (ROOT / "Brain/pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('dependencies = ["cryptography==50.0.1"]', pyproject)
|
||||||
|
|
||||||
|
def test_contract_documents_fail_closed(self) -> None:
|
||||||
|
identity = (ROOT / "contracts/machine-identity/v1/README.md").read_text(encoding="utf-8")
|
||||||
|
transport = (ROOT / "contracts/transport/v1/README.md").read_text(encoding="utf-8")
|
||||||
|
for required in ("300 seconds", "30 seconds", "24 hours", "jti", "revoked", "browser token"):
|
||||||
|
self.assertIn(required, identity)
|
||||||
|
for required in ("TLS 1.2", "hostname verification", "query-string credentials", "disables the connector"):
|
||||||
|
self.assertIn(required, transport)
|
||||||
|
|
||||||
|
def test_python_verifies_cross_language_vector(self) -> None:
|
||||||
|
vector = json.loads((ROOT / "contracts/tests/machine-identity-v1/cross-language-vector.json").read_text(encoding="utf-8"))
|
||||||
|
raw_key = base64.urlsafe_b64decode(vector["public_key_base64url"] + "=")
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
registry = Registry([KeyRecord("yv:brain:vector", "brain-vector-0001", Ed25519PublicKey.from_public_bytes(raw_key), vector["audience"], frozenset({vector["required_scope"]}))])
|
||||||
|
verifier = Verifier(registry, ReplayStore(), clock=lambda: vector["now"])
|
||||||
|
claims = verifier.verify(vector["token"], vector["audience"], vector["required_scope"], vector["method"], vector["path"], base64.b64decode(vector["body_base64"]))
|
||||||
|
self.assertEqual(claims.iss, "yv:brain:vector")
|
||||||
|
|
||||||
|
|
||||||
|
class BrainAdapterTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.now = 1_800_000_000
|
||||||
|
self.private = Ed25519PrivateKey.generate()
|
||||||
|
self.record = KeyRecord(
|
||||||
|
principal="yv:brain:node-a",
|
||||||
|
key_id="brain-key-0001",
|
||||||
|
public_key=self.private.public_key(),
|
||||||
|
audience="yovision-sense",
|
||||||
|
scopes=frozenset({"runtime-status:write"}),
|
||||||
|
)
|
||||||
|
self.registry = Registry([self.record])
|
||||||
|
self.signer = Signer("yv:brain:node-a", "brain-key-0001", self.private, clock=lambda: self.now)
|
||||||
|
|
||||||
|
def mint(self, body: bytes = b"{}") -> str:
|
||||||
|
return self.signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", body)
|
||||||
|
|
||||||
|
def verify(self, token: str, body: bytes = b"{}", **changes: str):
|
||||||
|
verifier = Verifier(self.registry, changes.pop("replay", ReplayStore()), clock=lambda: int(changes.pop("now", self.now)))
|
||||||
|
return verifier.verify(
|
||||||
|
token,
|
||||||
|
changes.pop("audience", "yovision-sense"),
|
||||||
|
changes.pop("scope", "runtime-status:write"),
|
||||||
|
changes.pop("method", "POST"),
|
||||||
|
changes.pop("path", "/machine/v1/runtime-status"),
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_code(self, code: str, callback) -> None:
|
||||||
|
with self.assertRaises(MachineIdentityError) as caught:
|
||||||
|
callback()
|
||||||
|
self.assertEqual(caught.exception.code, code)
|
||||||
|
self.assertEqual(str(caught.exception), code)
|
||||||
|
|
||||||
|
def test_valid_token_and_replay_rejection(self) -> None:
|
||||||
|
token = self.mint()
|
||||||
|
replay = ReplayStore()
|
||||||
|
first = Verifier(self.registry, replay, clock=lambda: self.now)
|
||||||
|
claims = first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
||||||
|
self.assertEqual(claims.iss, "yv:brain:node-a")
|
||||||
|
self.assert_code("machine_token_replayed", lambda: first.verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}"))
|
||||||
|
|
||||||
|
def test_bearer_token_has_no_cookie_or_query_fallback(self) -> None:
|
||||||
|
self.assertEqual(bearer_token("Bearer compact.token.value"), "compact.token.value")
|
||||||
|
for value in ("", "compact.token.value", "Bearer", "Bearer one two", "Cookie compact.token.value"):
|
||||||
|
self.assert_code("machine_token_missing", lambda value=value: bearer_token(value))
|
||||||
|
|
||||||
|
def test_wrong_audience_scope_body_and_expiry(self) -> None:
|
||||||
|
self.assert_code("machine_audience_denied", lambda: self.verify(self.mint(), audience="yovision-bell"))
|
||||||
|
self.assert_code("machine_scope_denied", lambda: self.verify(self.mint(), scope="events:ingest"))
|
||||||
|
self.assert_code("machine_token_invalid", lambda: self.verify(self.mint(), body=b"changed"))
|
||||||
|
self.assert_code("machine_token_expired", lambda: self.verify(self.mint(), now=str(self.now + 361)))
|
||||||
|
|
||||||
|
def test_tampering_revocation_and_rotation(self) -> None:
|
||||||
|
token = self.mint()
|
||||||
|
parts = token.split(".")
|
||||||
|
tampered = f"{parts[0]}.{parts[1][:-1]}A.{parts[2]}"
|
||||||
|
self.assert_code("machine_token_invalid", lambda: self.verify(tampered))
|
||||||
|
self.assertTrue(self.registry.revoke("brain-key-0001"))
|
||||||
|
self.assert_code("machine_identity_revoked", lambda: self.verify(self.mint()))
|
||||||
|
|
||||||
|
new_private = Ed25519PrivateKey.generate()
|
||||||
|
overlap = Registry([
|
||||||
|
self.record,
|
||||||
|
KeyRecord("yv:brain:node-a", "brain-key-0002", new_private.public_key(), "yovision-sense", frozenset({"runtime-status:write"})),
|
||||||
|
])
|
||||||
|
new_signer = Signer("yv:brain:node-a", "brain-key-0002", new_private, clock=lambda: self.now)
|
||||||
|
verifier = Verifier(overlap, ReplayStore(), clock=lambda: self.now)
|
||||||
|
verifier.verify(self.mint(), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
||||||
|
verifier.verify(new_signer.mint("yovision-sense", ["runtime-status:write"], "POST", "/machine/v1/runtime-status", b"{}"), "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", b"{}")
|
||||||
|
|
||||||
|
def test_transport_policy_requires_verified_tls(self) -> None:
|
||||||
|
policy = TransportPolicy("1.2", True, True, 1000, 1000, 2000, 1024)
|
||||||
|
context = policy.ssl_context()
|
||||||
|
self.assertGreaterEqual(context.minimum_version, ssl.TLSVersion.TLSv1_2)
|
||||||
|
self.assertTrue(context.check_hostname)
|
||||||
|
self.assertEqual(context.verify_mode, ssl.CERT_REQUIRED)
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
TransportPolicy("1.1", True, True, 1000, 1000, 2000, 1024).validate()
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
TransportPolicy("1.2", True, False, 1000, 1000, 2000, 1024).validate()
|
||||||
|
|
||||||
|
def test_loads_external_public_registry_and_rejects_wrong_audience(self) -> None:
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
raw_public = self.private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
|
||||||
|
document = {
|
||||||
|
"version": "yovision.machine-principal-registry/v1",
|
||||||
|
"audience": "yovision-sense",
|
||||||
|
"principals": [{
|
||||||
|
"principal_id": "yv:brain:node-a", "enabled": True,
|
||||||
|
"keys": [{"kid": "brain-key-0001", "public_key_base64url": base64.urlsafe_b64encode(raw_public).rstrip(b"=").decode(), "status": "active", "scopes": ["runtime-status:write"]}],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "principals.json"
|
||||||
|
path.write_text(json.dumps(document), encoding="utf-8")
|
||||||
|
registry = load_registry(path, "yovision-sense")
|
||||||
|
self.assertIsNotNone(registry.lookup("brain-key-0001"))
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
load_registry(path, "yovision-bell")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main(verbosity=2)
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Machine transport policy v1
|
||||||
|
|
||||||
|
`yovision.transport/v1` applies to every connector protected by machine identity v1.
|
||||||
|
|
||||||
|
- HTTPS is mandatory. TLS 1.2 is the minimum and TLS 1.3 is preferred.
|
||||||
|
- Certificate-chain and hostname verification are mandatory. `InsecureSkipVerify`, plaintext fallback and query-string credentials are forbidden.
|
||||||
|
- Connection, response-header and total request timeouts are explicit and bounded; consumers enforce a route-specific body limit before decoding.
|
||||||
|
- `X-Request-ID` is an opaque 16–128 character correlation value. It may be generated by the caller or first trusted hop, is never an authentication factor, and must not contain credentials or personal data.
|
||||||
|
- Retry only timeout, connection loss, `429` and `5xx` according to the connector policy. Authentication/authorization failures and contract `4xx` responses are terminal until configuration changes.
|
||||||
|
- Each retry signs a new machine token and `jti`. The business idempotency key and payload remain unchanged.
|
||||||
|
|
||||||
|
Transport failures must not start Sense, Brain or Bell with weakened authentication. Rollback disables the connector and preserves local facts, Outbox/Receipt state and last-known-good configuration.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://yovision.local/contracts/transport/v1/transport-policy.schema.json",
|
||||||
|
"title": "YoVision machine transport policy v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["version", "tls_min_version", "verify_certificate", "verify_hostname", "connect_timeout_ms", "response_header_timeout_ms", "request_timeout_ms", "max_request_bytes"],
|
||||||
|
"properties": {
|
||||||
|
"version": {"const": "yovision.transport/v1"},
|
||||||
|
"tls_min_version": {"enum": ["1.2", "1.3"]},
|
||||||
|
"verify_certificate": {"const": true},
|
||||||
|
"verify_hostname": {"const": true},
|
||||||
|
"connect_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 30000},
|
||||||
|
"response_header_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 30000},
|
||||||
|
"request_timeout_ms": {"type": "integer", "minimum": 100, "maximum": 60000},
|
||||||
|
"max_request_bytes": {"type": "integer", "minimum": 1, "maximum": 10485760}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user