Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82afce1f81 | ||
|
|
cf00d73436 | ||
|
|
ae9bd015c2 | ||
|
|
06e0790f00 | ||
|
|
009dc3cca0 | ||
|
|
573113eb3b |
@@ -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"
|
||||
readme = "README.md"
|
||||
requires-python = "==3.11.*"
|
||||
dependencies = []
|
||||
dependencies = ["cryptography==50.0.1"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# 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,7 @@
|
||||
"""Credential-free Sense control-plane connector for Brain."""
|
||||
|
||||
from .consumer import ApplyResult, SourceConfigConsumer, SourceConfigError
|
||||
from .replay import SQLiteReplayStore
|
||||
from .status import RuntimeStatusPublisher
|
||||
|
||||
__all__ = ["ApplyResult", "SourceConfigConsumer", "SourceConfigError", "SQLiteReplayStore", "RuntimeStatusPublisher"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Machine-authenticated adapters with bounded timeout/backoff and a kill switch."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from yovision_brain.integration.machine_identity.token import Signer, Verifier, bearer_token
|
||||
|
||||
from .consumer import ApplyResult, SourceConfigConsumer
|
||||
|
||||
_REQUEST_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConnectorResponse:
|
||||
status: int
|
||||
body: bytes
|
||||
correlation_id: str
|
||||
|
||||
|
||||
class SourceConfigEndpoint:
|
||||
def __init__(self, consumer: SourceConfigConsumer, verifier: Verifier, *, enabled: bool = True, max_body_bytes: int = 10 * 1024 * 1024) -> None:
|
||||
self._consumer, self._verifier, self._enabled, self._max = consumer, verifier, enabled, max_body_bytes
|
||||
|
||||
def receive(self, authorization: str, body: bytes, correlation_id: str) -> ApplyResult:
|
||||
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
|
||||
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
|
||||
if len(body) > self._max: raise ValueError("REQUEST_TOO_LARGE")
|
||||
token=bearer_token(authorization)
|
||||
self._verifier.verify(token,"yovision-brain","source-config:write","POST","/machine/v1/source-config",body)
|
||||
return self._consumer.apply(body)
|
||||
|
||||
|
||||
class StatusSender:
|
||||
def __init__(self, signer: Signer, send: Callable[[str, bytes, str, float], ConnectorResponse], *, enabled: bool = True, timeout_seconds: float = 5.0, max_attempts: int = 4, sleeper: Callable[[float], None] = time.sleep) -> None:
|
||||
if timeout_seconds <= 0 or max_attempts < 1: raise ValueError("invalid connector retry policy")
|
||||
self._signer,self._send,self._enabled,self._timeout,self._attempts,self._sleep=signer,send,enabled,timeout_seconds,max_attempts,sleeper
|
||||
|
||||
def publish(self, body: bytes, correlation_id: str) -> ConnectorResponse:
|
||||
if not self._enabled: raise RuntimeError("CONNECTOR_DISABLED")
|
||||
if not _REQUEST_ID.fullmatch(correlation_id): raise ValueError("INVALID_CORRELATION_ID")
|
||||
last: Exception|None=None
|
||||
for attempt in range(self._attempts):
|
||||
try:
|
||||
# A retry gets a fresh jti: the previous request may have been
|
||||
# accepted even when its response was lost.
|
||||
token=self._signer.mint("yovision-sense",("runtime-status:write",),"POST","/machine/v1/runtime-status",body)
|
||||
response=self._send("Bearer "+token,body,correlation_id,self._timeout)
|
||||
if 200<=response.status<300:return response
|
||||
if response.status<500:raise RuntimeError(f"STATUS_REJECTED_{response.status}")
|
||||
last=RuntimeError(f"STATUS_REMOTE_{response.status}")
|
||||
except (TimeoutError,ConnectionError) as exc:last=exc
|
||||
if attempt+1<self._attempts:self._sleep(min(2**attempt,30))
|
||||
raise RuntimeError("STATUS_DELIVERY_EXHAUSTED") from last
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Strict source-config/v1 validation and atomic last-known-good application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import closing
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Mapping
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from yovision_brain.rules.models import AreaDefinition, DirectionalLineDefinition, NormalizedPoint, RuleSet
|
||||
|
||||
VERSION = "yovision.source-config/v1"
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$")
|
||||
_EXTENSION_NAMESPACE = re.compile(r"^[A-Za-z][A-Za-z0-9.-]{0,127}$")
|
||||
_SECRET = re.compile(r"password|secret|credential|cookie|jwt|username|stream_uri", re.I)
|
||||
|
||||
|
||||
class SourceConfigError(ValueError):
|
||||
def __init__(self, code: str) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AppliedConfig:
|
||||
config_id: str
|
||||
revision: int
|
||||
logical_device_id: str
|
||||
media_ref: str
|
||||
profile_encoding: str
|
||||
frame_rate: float
|
||||
rule_state: str
|
||||
rules: RuleSet | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplyResult:
|
||||
config_id: str
|
||||
revision: int
|
||||
state: str
|
||||
config: AppliedConfig | None
|
||||
|
||||
|
||||
class SourceConfigConsumer:
|
||||
"""Persists validated snapshots before atomically changing the active pointer."""
|
||||
|
||||
def __init__(self, state_path: str | Path, *, clock: Callable[[], float] | None = None) -> None:
|
||||
self._path = str(state_path)
|
||||
self._clock = clock or time.time
|
||||
self._lock = threading.RLock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS source_snapshots (
|
||||
config_id TEXT NOT NULL, revision INTEGER NOT NULL, effective_at INTEGER NOT NULL,
|
||||
state TEXT NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(config_id, revision));
|
||||
CREATE TABLE IF NOT EXISTS source_active (
|
||||
config_id TEXT PRIMARY KEY, revision INTEGER NOT NULL,
|
||||
FOREIGN KEY(config_id, revision) REFERENCES source_snapshots(config_id, revision));
|
||||
"""
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self._path, timeout=5)
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
return connection
|
||||
|
||||
def apply(self, body: bytes) -> ApplyResult:
|
||||
document = _parse_and_validate(body)
|
||||
config_id, revision = document["config_id"], document["revision"]
|
||||
mapped = _map(document)
|
||||
effective_at = int(_timestamp(document["effective_at"]))
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
latest = connection.execute(
|
||||
"SELECT revision, payload, state, effective_at FROM source_snapshots WHERE config_id=? ORDER BY revision DESC LIMIT 1",
|
||||
(config_id,),
|
||||
).fetchone()
|
||||
if latest and revision < latest[0]:
|
||||
connection.rollback()
|
||||
raise SourceConfigError("STALE_REVISION")
|
||||
canonical = body.decode("utf-8")
|
||||
if latest and revision == latest[0]:
|
||||
if json.loads(latest[1]) != document:
|
||||
connection.rollback()
|
||||
raise SourceConfigError("REVISION_CONFLICT")
|
||||
connection.rollback()
|
||||
return ApplyResult(config_id, revision, "idempotent", self.get_active(config_id))
|
||||
connection.execute(
|
||||
"INSERT INTO source_snapshots(config_id, revision, effective_at, state, payload) VALUES (?, ?, ?, ?, ?)",
|
||||
(config_id, revision, effective_at, document["rule_set"]["state"], canonical),
|
||||
)
|
||||
if effective_at <= int(self._clock()):
|
||||
connection.execute(
|
||||
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
|
||||
(config_id, revision),
|
||||
)
|
||||
state = "applied"
|
||||
else:
|
||||
state = "scheduled"
|
||||
connection.commit()
|
||||
return ApplyResult(config_id, revision, state, mapped if state == "applied" else self.get_active(config_id))
|
||||
|
||||
def activate_due(self) -> tuple[AppliedConfig, ...]:
|
||||
now = int(self._clock())
|
||||
activated: list[AppliedConfig] = []
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT s.payload FROM source_snapshots s JOIN (SELECT config_id, MAX(revision) revision FROM source_snapshots WHERE effective_at<=? GROUP BY config_id) d ON d.config_id=s.config_id AND d.revision=s.revision",
|
||||
(now,),
|
||||
).fetchall()
|
||||
for (payload,) in rows:
|
||||
document = json.loads(payload)
|
||||
connection.execute(
|
||||
"INSERT INTO source_active(config_id, revision) VALUES (?, ?) ON CONFLICT(config_id) DO UPDATE SET revision=excluded.revision",
|
||||
(document["config_id"], document["revision"]),
|
||||
)
|
||||
activated.append(_map(document))
|
||||
connection.commit()
|
||||
return tuple(activated)
|
||||
|
||||
def get_active(self, config_id: str) -> AppliedConfig | None:
|
||||
with closing(self._connect()) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT s.payload FROM source_active a JOIN source_snapshots s ON s.config_id=a.config_id AND s.revision=a.revision WHERE a.config_id=?",
|
||||
(config_id,),
|
||||
).fetchone()
|
||||
return _map(json.loads(row[0])) if row else None
|
||||
|
||||
|
||||
def _parse_and_validate(body: bytes) -> dict[str, object]:
|
||||
try:
|
||||
document = json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise SourceConfigError("CONFIG_INVALID") from None
|
||||
if not isinstance(document, dict):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
if document.get("schema_version") != VERSION:
|
||||
raise SourceConfigError("UNSUPPORTED_SCHEMA_VERSION")
|
||||
if _contains_secret(document):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
required = {"schema_version", "config_id", "revision", "published_at", "effective_at", "site", "logical_device", "profile", "media", "rule_set", "integrity"}
|
||||
if set(document) - (required | {"extensions"}) or not required <= set(document):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
extensions = document.get("extensions", {})
|
||||
if not isinstance(extensions, dict) or any(
|
||||
not isinstance(namespace, str)
|
||||
or not _EXTENSION_NAMESPACE.fullmatch(namespace)
|
||||
or not isinstance(value, dict)
|
||||
for namespace, value in extensions.items()
|
||||
):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
integrity = document.get("integrity")
|
||||
if not isinstance(integrity, dict) or set(integrity) != {"algorithm", "value"} or integrity.get("algorithm") != "sha256":
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
unsigned = dict(document); unsigned.pop("integrity")
|
||||
digest = hashlib.sha256(json.dumps(unsigned, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode()).hexdigest()
|
||||
if not hmac.compare_digest(digest, str(integrity.get("value", ""))):
|
||||
raise SourceConfigError("CONFIG_INVALID")
|
||||
try:
|
||||
if not _ID.fullmatch(document["config_id"]) or isinstance(document["revision"], bool) or document["revision"] < 1:
|
||||
raise ValueError
|
||||
published, effective = _timestamp(document["published_at"]), _timestamp(document["effective_at"])
|
||||
if effective < published:
|
||||
raise ValueError
|
||||
for field in ("site", "logical_device"):
|
||||
if not isinstance(document[field], dict) or set(document[field]) != {"id"} or not _ID.fullmatch(document[field]["id"]): raise ValueError
|
||||
_validate_profile(document["profile"])
|
||||
media = document["media"]
|
||||
if not isinstance(media, dict) or set(media) != {"ref", "transport"} or media["transport"] != "rtsp" or not isinstance(media["ref"], str) or not media["ref"].startswith("media:") or any(marker in media["ref"] for marker in ("?", "#", "@", "\\", "://")): raise ValueError
|
||||
_validate_rules(document["rule_set"], document["profile"])
|
||||
except (KeyError, TypeError, ValueError, AttributeError):
|
||||
raise SourceConfigError("CONFIG_INVALID") from None
|
||||
return document
|
||||
|
||||
|
||||
def _validate_profile(profile: object) -> None:
|
||||
if not isinstance(profile, dict) or set(profile) != {"id", "width", "height", "encoding", "frame_rate"}: raise ValueError
|
||||
if not _ID.fullmatch(profile["id"]) or profile["encoding"] not in {"H264", "H265", "MJPEG"}: raise ValueError
|
||||
for field in ("width", "height"):
|
||||
if isinstance(profile[field], bool) or not isinstance(profile[field], int) or profile[field] < 1: raise ValueError
|
||||
if isinstance(profile["frame_rate"], bool) or not isinstance(profile["frame_rate"], (int, float)) or profile["frame_rate"] <= 0: raise ValueError
|
||||
|
||||
|
||||
def _validate_rules(rules: object, profile: Mapping[str, object]) -> None:
|
||||
if not isinstance(rules, dict) or set(rules) != {"version", "state", "profile_binding", "areas", "directional_lines"}: raise ValueError
|
||||
if not _ID.fullmatch(rules["version"]) or rules["state"] not in {"active", "disabled", "recalibration_required"}: raise ValueError
|
||||
binding = rules["profile_binding"]
|
||||
if binding != {"profile_id": profile["id"], "width": profile["width"], "height": profile["height"]}: raise ValueError
|
||||
if not isinstance(rules["areas"], list) or not isinstance(rules["directional_lines"], list) or len(rules["areas"]) > 1024 or len(rules["directional_lines"]) > 1024: raise ValueError
|
||||
identifiers: set[str] = set()
|
||||
for area in rules["areas"]:
|
||||
if not isinstance(area, dict) or set(area) != {"id", "version", "kind", "enabled", "points"} or area["kind"] != "danger_area" or not isinstance(area["enabled"], bool) or not 3 <= len(area["points"]) <= 256: raise ValueError
|
||||
_rule_identity(area, identifiers); points = tuple(_point(value) for value in area["points"])
|
||||
polygon = sum(a[0]*b[1]-b[0]*a[1] for a,b in zip(points, points[1:]+points[:1])) / 2
|
||||
if abs(polygon) < 1e-9: raise ValueError
|
||||
for line in rules["directional_lines"]:
|
||||
if not isinstance(line, dict) or set(line) != {"id", "version", "kind", "enabled", "start", "end", "trigger_direction"} or line["kind"] != "directional_line" or not isinstance(line["enabled"], bool) or line["trigger_direction"] not in {"left_to_right", "right_to_left"}: raise ValueError
|
||||
_rule_identity(line, identifiers)
|
||||
if _point(line["start"]) == _point(line["end"]): raise ValueError
|
||||
|
||||
|
||||
def _rule_identity(rule: Mapping[str, object], identifiers: set[str]) -> None:
|
||||
if not isinstance(rule["id"], str) or not _ID.fullmatch(rule["id"]) or rule["id"] in identifiers or isinstance(rule["version"], bool) or not isinstance(rule["version"], int) or rule["version"] < 1: raise ValueError
|
||||
identifiers.add(rule["id"])
|
||||
|
||||
|
||||
def _point(value: object) -> tuple[float, float]:
|
||||
if not isinstance(value, dict) or set(value) != {"x", "y"}: raise ValueError
|
||||
x, y = value["x"], value["y"]
|
||||
if isinstance(x, bool) or isinstance(y, bool) or not isinstance(x, (int,float)) or not isinstance(y,(int,float)) or not 0 <= x <= 1 or not 0 <= y <= 1: raise ValueError
|
||||
return float(x), float(y)
|
||||
|
||||
|
||||
def _map(document: Mapping[str, object]) -> AppliedConfig:
|
||||
profile, rules = document["profile"], document["rule_set"]
|
||||
rule_set = None
|
||||
if rules["state"] == "active":
|
||||
rule_set = RuleSet(
|
||||
version=rules["version"], profile_id=profile["id"], width=profile["width"], height=profile["height"],
|
||||
areas=tuple(AreaDefinition(a["id"], tuple(NormalizedPoint(**p) for p in a["points"])) for a in rules["areas"] if a["enabled"]),
|
||||
directional_lines=tuple(DirectionalLineDefinition(l["id"], NormalizedPoint(**l["start"]), NormalizedPoint(**l["end"]), l["trigger_direction"]) for l in rules["directional_lines"] if l["enabled"]),
|
||||
)
|
||||
return AppliedConfig(document["config_id"], document["revision"], document["logical_device"]["id"], document["media"]["ref"], profile["encoding"], float(profile["frame_rate"]), rules["state"], rule_set)
|
||||
|
||||
|
||||
def _timestamp(value: object) -> float:
|
||||
if not isinstance(value, str) or not value.endswith("Z"): raise ValueError
|
||||
return datetime.fromisoformat(value[:-1] + "+00:00").astimezone(timezone.utc).timestamp()
|
||||
|
||||
|
||||
def _contains_secret(value: object) -> bool:
|
||||
if isinstance(value, dict): return any(_SECRET.search(str(k)) or _contains_secret(v) for k,v in value.items())
|
||||
if isinstance(value, list): return any(_contains_secret(item) for item in value)
|
||||
if isinstance(value, str):
|
||||
split=urlsplit(value)
|
||||
return bool(split.username or split.password or value.startswith("file:") or re.match(r"^[A-Za-z]:[\\/]", value))
|
||||
return False
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Brain-owned durable replay storage; never shared with Sense or Bell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import threading
|
||||
from contextlib import closing
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class SQLiteReplayStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self._path = str(path)
|
||||
self._lock = threading.Lock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS machine_replay (principal TEXT NOT NULL, token_id TEXT NOT NULL, expires_at INTEGER NOT NULL, PRIMARY KEY(principal, token_id))"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self._path, timeout=5, isolation_level=None)
|
||||
connection.execute("PRAGMA busy_timeout=5000")
|
||||
return connection
|
||||
|
||||
def consume(self, principal: str, token_id: str, expires_at: int, now: int) -> bool:
|
||||
if not principal or not token_id or expires_at <= now:
|
||||
return False
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute("DELETE FROM machine_replay WHERE expires_at <= ?", (now,))
|
||||
connection.execute(
|
||||
"INSERT INTO machine_replay(principal, token_id, expires_at) VALUES (?, ?, ?)",
|
||||
(principal, token_id, expires_at),
|
||||
)
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Persistent sequence allocation and safe runtime-status/v1 production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
_LOGICAL_REF = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
|
||||
_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$")
|
||||
_ERROR_CODE = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
|
||||
_RUNTIME_STATES = {"unconfigured", "starting", "running", "degraded", "failed", "stopped"}
|
||||
|
||||
|
||||
class RuntimeStatusPublisher:
|
||||
def __init__(self, state_path: str | Path, brain_instance_ref: str) -> None:
|
||||
self._path, self._instance, self._lock = str(state_path), brain_instance_ref, threading.Lock()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("CREATE TABLE IF NOT EXISTS runtime_sequence(instance_ref TEXT PRIMARY KEY, sequence INTEGER NOT NULL)")
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
return sqlite3.connect(self._path, timeout=5)
|
||||
|
||||
def build(self, *, runtime_state: str, runtime_version: str, started_at: datetime | None, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]], observed_at: datetime | None = None) -> bytes:
|
||||
_validate(runtime_state, runtime_version, self._instance, model_ref, model_version, configurations, health, inputs)
|
||||
with self._lock, closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row=connection.execute("SELECT sequence FROM runtime_sequence WHERE instance_ref=?",(self._instance,)).fetchone();sequence=(row[0]+1) if row else 0
|
||||
connection.execute("INSERT INTO runtime_sequence(instance_ref,sequence) VALUES(?,?) ON CONFLICT(instance_ref) DO UPDATE SET sequence=excluded.sequence",(self._instance,sequence));connection.commit()
|
||||
observed=(observed_at or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
document={"schema_version":"yovision.runtime-status/v1","status_id":str(uuid.uuid4()),"brain_instance_ref":self._instance,"sequence":sequence,"observed_at":_utc(observed),"runtime":{"state":runtime_state,"version":runtime_version,"started_at":_utc(started_at) if started_at else None},"model":{"model_ref":model_ref,"version":model_version},"configurations":list(configurations),"health":dict(health),"inputs":list(inputs)}
|
||||
raw=json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
lowered=raw.lower();
|
||||
for marker in (b"password",b"credential",b"stream_uri",b"cookie",b"jwt",b"file://"):
|
||||
if marker in lowered: raise ValueError("runtime status contains sensitive field")
|
||||
return raw
|
||||
|
||||
|
||||
def _utc(value: datetime) -> str:
|
||||
if value.tzinfo is None: raise ValueError("runtime timestamp must be timezone-aware")
|
||||
return value.astimezone(timezone.utc).isoformat(timespec="seconds").replace("+00:00","Z")
|
||||
|
||||
|
||||
def _validate(runtime_state: str, runtime_version: str, instance: str, model_ref: str, model_version: str, configurations: Sequence[Mapping[str, object]], health: Mapping[str, object], inputs: Sequence[Mapping[str, object]]) -> None:
|
||||
if runtime_state not in _RUNTIME_STATES or not _VERSION.fullmatch(runtime_version) or not _LOGICAL_REF.fullmatch(instance) or not _LOGICAL_REF.fullmatch(model_ref) or not _VERSION.fullmatch(model_version):
|
||||
raise ValueError("invalid runtime identity or version")
|
||||
if len(configurations) > 4096 or len(inputs) > 4096:
|
||||
raise ValueError("runtime status collection too large")
|
||||
seen: set[str] = set()
|
||||
for item in configurations:
|
||||
if set(item) != {"config_id", "apply_state", "applied_revision", "error_code"} or not isinstance(item["config_id"], str) or not _LOGICAL_REF.fullmatch(item["config_id"]) or item["config_id"] in seen:
|
||||
raise ValueError("invalid configuration status")
|
||||
seen.add(item["config_id"]); state=item["apply_state"]; revision=item["applied_revision"]; error=item["error_code"]
|
||||
if state not in {"not_configured","applying","applied","rejected"} or (state=="not_configured" and revision is not None) or (state=="applied" and (isinstance(revision,bool) or not isinstance(revision,int) or revision<1)) or (state=="rejected" and (not isinstance(error,str) or not _ERROR_CODE.fullmatch(error))):
|
||||
raise ValueError("invalid configuration status")
|
||||
if set(health) != {"overall","error_codes","metrics"} or health["overall"] not in {"healthy","degraded","unhealthy"} or not _codes(health["error_codes"],32) or not _metrics(health["metrics"]):
|
||||
raise ValueError("invalid health status")
|
||||
for item in inputs:
|
||||
if set(item) != {"input_ref","state","error_codes","metrics"} or not isinstance(item["input_ref"],str) or not _LOGICAL_REF.fullmatch(item["input_ref"]) or item["state"] not in _RUNTIME_STATES or not _codes(item["error_codes"],16) or not _metrics(item["metrics"]):
|
||||
raise ValueError("invalid input status")
|
||||
|
||||
|
||||
def _codes(value: object, limit: int) -> bool:
|
||||
return isinstance(value,list) and len(value)<=limit and len(set(value))==len(value) and all(isinstance(code,str) and _ERROR_CODE.fullmatch(code) for code in value)
|
||||
|
||||
|
||||
def _metrics(value: object) -> bool:
|
||||
if not isinstance(value,Mapping) or set(value)!={"load_percent","queue_depth","latency_ms"}: return False
|
||||
load,queue,latency=value["load_percent"],value["queue_depth"],value["latency_ms"]
|
||||
return not isinstance(load,bool) and isinstance(load,(int,float)) and 0<=load<=100 and not isinstance(queue,bool) and isinstance(queue,int) and queue>=0 and not isinstance(latency,bool) and isinstance(latency,(int,float)) and latency>=0
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from yovision_brain.integration.machine_identity.token import KeyRecord, Registry, Signer, Verifier
|
||||
from yovision_brain.integration.sense_control.connector import ConnectorResponse, SourceConfigEndpoint, StatusSender
|
||||
from yovision_brain.integration.sense_control.consumer import SourceConfigConsumer, SourceConfigError
|
||||
from yovision_brain.integration.sense_control.replay import SQLiteReplayStore
|
||||
from yovision_brain.integration.sense_control.status import RuntimeStatusPublisher
|
||||
|
||||
|
||||
def source_document(revision: int = 1, *, state: str = "active", effective: int = 0) -> bytes:
|
||||
now=datetime(2026,8,31,tzinfo=timezone.utc)
|
||||
document={"schema_version":"yovision.source-config/v1","config_id":"gate-primary","revision":revision,"published_at":now.isoformat().replace("+00:00","Z"),"effective_at":(now+timedelta(seconds=effective)).isoformat().replace("+00:00","Z"),"site":{"id":"site-east"},"logical_device":{"id":"camera-1"},"profile":{"id":"main","width":1920,"height":1080,"encoding":"H264","frame_rate":25},"media":{"ref":"media:site-east/camera-1/main","transport":"rtsp"},"rule_set":{"version":f"rules-{revision}","state":state,"profile_binding":{"profile_id":"main","width":1920,"height":1080},"areas":[{"id":"danger","version":1,"kind":"danger_area","enabled":True,"points":[{"x":.1,"y":.1},{"x":.8,"y":.1},{"x":.5,"y":.8}]}],"directional_lines":[]}}
|
||||
digest=hashlib.sha256(json.dumps(document,separators=(",",":"),sort_keys=True).encode()).hexdigest();document["integrity"]={"algorithm":"sha256","value":digest}
|
||||
return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
|
||||
|
||||
def with_extension(body: bytes, extensions: object) -> bytes:
|
||||
document=json.loads(body);document["extensions"]=extensions;unsigned=dict(document);unsigned.pop("integrity");document["integrity"]["value"]=hashlib.sha256(json.dumps(unsigned,separators=(",",":"),sort_keys=True).encode()).hexdigest();return json.dumps(document,separators=(",",":"),sort_keys=True).encode()
|
||||
|
||||
|
||||
def identity(tmp_path, now: int):
|
||||
private=Ed25519PrivateKey.generate();signer=Signer("yv:sense:east","sense-key-01",private,clock=lambda:now)
|
||||
registry=Registry([KeyRecord("yv:sense:east","sense-key-01",private.public_key(),"yovision-brain",frozenset({"source-config:write"}))])
|
||||
replay=SQLiteReplayStore(tmp_path/"replay.sqlite")
|
||||
return signer,Verifier(registry,replay,clock=lambda:now)
|
||||
|
||||
|
||||
def test_authenticated_apply_is_idempotent_and_replay_survives_restart(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now);endpoint=SourceConfigEndpoint(consumer,verifier)
|
||||
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
|
||||
result=endpoint.receive("Bearer "+token,body,"corr-request-0001");assert result.state=="applied" and result.config.rules is not None
|
||||
with pytest.raises(ValueError,match="machine_token_replayed"):endpoint.receive("Bearer "+token,body,"corr-request-0001")
|
||||
restarted=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),Verifier(verifier._registry,SQLiteReplayStore(tmp_path/"replay.sqlite"),clock=lambda:now))
|
||||
with pytest.raises(ValueError,match="machine_token_replayed"):restarted.receive("Bearer "+token,body,"corr-request-0002")
|
||||
new_token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body);assert restarted.receive("Bearer "+new_token,body,"corr-request-0003").state=="idempotent"
|
||||
|
||||
|
||||
def test_atomic_replay_accepts_once_under_concurrency(tmp_path):
|
||||
store=SQLiteReplayStore(tmp_path/"atomic.sqlite");results=[]
|
||||
threads=[threading.Thread(target=lambda:results.append(store.consume("yv:sense:east","token-id",200,100))) for _ in range(12)]
|
||||
for thread in threads:thread.start()
|
||||
for thread in threads:thread.join()
|
||||
assert results.count(True)==1
|
||||
|
||||
|
||||
def test_stale_unknown_profile_and_recalibration_are_safe(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
assert consumer.apply(source_document(2)).config.rules is not None
|
||||
with pytest.raises(SourceConfigError,match="STALE_REVISION"):consumer.apply(source_document(1))
|
||||
invalid=json.loads(source_document(3));invalid["profile"]["width"]=1280;invalid["integrity"]["value"]="0"*64
|
||||
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(json.dumps(invalid).encode())
|
||||
unknown=json.loads(source_document(3));unknown["schema_version"]="yovision.source-config/v2"
|
||||
with pytest.raises(SourceConfigError,match="UNSUPPORTED_SCHEMA_VERSION"):consumer.apply(json.dumps(unknown).encode())
|
||||
safe=consumer.apply(source_document(3,state="recalibration_required"));assert safe.config.rule_state=="recalibration_required" and safe.config.rules is None
|
||||
|
||||
|
||||
def test_future_effective_snapshot_activates_atomically_after_restart(tmp_path):
|
||||
base=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());current=[base];path=tmp_path/"state.sqlite";consumer=SourceConfigConsumer(path,clock=lambda:current[0])
|
||||
assert consumer.apply(source_document(1)).state=="applied";scheduled=consumer.apply(source_document(2,effective=60));assert scheduled.state=="scheduled" and scheduled.config.revision==1
|
||||
current[0]+=61;restarted=SourceConfigConsumer(path,clock=lambda:current[0]);activated=restarted.activate_due();assert activated[0].revision==2 and restarted.get_active("gate-primary").revision==2
|
||||
|
||||
|
||||
def test_unknown_valid_extension_namespace_is_ignored(tmp_path):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
result=consumer.apply(with_extension(source_document(),{"vendor.example":{"feature":"safe"}}));assert result.state=="applied" and result.config.revision==1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extensions", [[], {"1invalid":{}}, {"vendor_ok":{}}, {"vendor.example":"not-an-object"}])
|
||||
def test_invalid_extensions_are_rejected(tmp_path, extensions):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());consumer=SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now)
|
||||
with pytest.raises(SourceConfigError,match="CONFIG_INVALID"):consumer.apply(with_extension(source_document(),extensions))
|
||||
|
||||
|
||||
def test_status_sequence_restart_retry_timeout_and_disable(tmp_path):
|
||||
path=tmp_path/"status.sqlite";publisher=RuntimeStatusPublisher(path,"brain-east-01");health={"overall":"healthy","error_codes":[],"metrics":{"load_percent":1.0,"queue_depth":0,"latency_ms":2.0}}
|
||||
one=json.loads(publisher.build(runtime_state="running",runtime_version="1.0.0",started_at=datetime.now(timezone.utc),model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));two=json.loads(RuntimeStatusPublisher(path,"brain-east-01").build(runtime_state="running",runtime_version="1.0.0",started_at=None,model_ref="people-detection",model_version="1",configurations=[],health=health,inputs=[]));assert (one["sequence"],two["sequence"])==(0,1)
|
||||
private=Ed25519PrivateKey.generate();signer=Signer("yv:brain:east","brain-key-01",private,clock=lambda:1_787_000_000);attempts=[]
|
||||
def send(_auth,_body,_corr,timeout):attempts.append(timeout);raise TimeoutError
|
||||
sender=StatusSender(signer,send,max_attempts=3,sleeper=lambda _:None)
|
||||
with pytest.raises(RuntimeError,match="STATUS_DELIVERY_EXHAUSTED"):sender.publish(b"{}","corr-request-0001")
|
||||
assert attempts==[5.0,5.0,5.0]
|
||||
disabled=StatusSender(signer,lambda *_:ConnectorResponse(204,b"","corr-request-0001"),enabled=False)
|
||||
with pytest.raises(RuntimeError,match="CONNECTOR_DISABLED"):disabled.publish(b"{}","corr-request-0001")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_id", ["short", "0123456789abcde\n", "0123456789abcde!", "a"*129])
|
||||
def test_connector_rejects_unsafe_request_ids(tmp_path, request_id):
|
||||
now=int(datetime(2026,8,31,tzinfo=timezone.utc).timestamp());signer,verifier=identity(tmp_path,now);body=source_document();endpoint=SourceConfigEndpoint(SourceConfigConsumer(tmp_path/"state.sqlite",clock=lambda:now),verifier)
|
||||
token=signer.mint("yovision-brain",("source-config:write",),"POST","/machine/v1/source-config",body)
|
||||
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):endpoint.receive("Bearer "+token,body,request_id)
|
||||
status_signer=Signer("yv:brain:east","brain-key-01",Ed25519PrivateKey.generate(),clock=lambda:now)
|
||||
sender=StatusSender(status_signer,lambda *_:ConnectorResponse(204,b"",request_id))
|
||||
with pytest.raises(ValueError,match="INVALID_CORRELATION_ID"):sender.publish(b"{}",request_id)
|
||||
@@ -0,0 +1,118 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
)
|
||||
|
||||
const (
|
||||
sourceConfigPath = "/machine/v1/source-config"
|
||||
runtimeStatusPath = "/machine/v1/runtime-status"
|
||||
)
|
||||
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
|
||||
|
||||
type SendResponse struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
CorrelationID string
|
||||
}
|
||||
type SendFunc func(authorization string, body []byte, correlationID string, timeout time.Duration) (SendResponse, error)
|
||||
|
||||
type ConfigSender struct {
|
||||
Signer mi.Signer
|
||||
Send SendFunc
|
||||
Enabled bool
|
||||
Timeout time.Duration
|
||||
MaxAttempts int
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
func (s ConfigSender) Publish(config SourceConfig, correlationID string) (SendResponse, error) {
|
||||
if !s.Enabled {
|
||||
return SendResponse{}, errors.New("CONNECTOR_DISABLED")
|
||||
}
|
||||
if s.Send == nil || !requestIDPattern.MatchString(correlationID) {
|
||||
return SendResponse{}, errors.New("invalid connector configuration")
|
||||
}
|
||||
if s.Timeout <= 0 {
|
||||
s.Timeout = 5 * time.Second
|
||||
}
|
||||
if s.MaxAttempts == 0 {
|
||||
s.MaxAttempts = 4
|
||||
}
|
||||
if s.MaxAttempts < 1 {
|
||||
return SendResponse{}, errors.New("invalid connector retry policy")
|
||||
}
|
||||
if s.Sleep == nil {
|
||||
s.Sleep = time.Sleep
|
||||
}
|
||||
if err := ValidateSourceConfig(config); err != nil {
|
||||
return SendResponse{}, err
|
||||
}
|
||||
body, err := MarshalSourceConfig(config)
|
||||
if err != nil {
|
||||
return SendResponse{}, err
|
||||
}
|
||||
var last error
|
||||
for attempt := 0; attempt < s.MaxAttempts; attempt++ {
|
||||
token, mintErr := s.Signer.Mint("yovision-brain", []string{"source-config:write"}, "POST", sourceConfigPath, body)
|
||||
if mintErr != nil {
|
||||
return SendResponse{}, mintErr
|
||||
}
|
||||
response, sendErr := s.Send("Bearer "+token, body, correlationID, s.Timeout)
|
||||
if sendErr == nil && response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
return response, nil
|
||||
}
|
||||
if sendErr == nil && response.StatusCode < 500 {
|
||||
return SendResponse{}, fmt.Errorf("source config rejected: %d", response.StatusCode)
|
||||
}
|
||||
if sendErr != nil {
|
||||
last = sendErr
|
||||
} else {
|
||||
last = fmt.Errorf("source config remote status: %d", response.StatusCode)
|
||||
}
|
||||
if attempt+1 < s.MaxAttempts {
|
||||
delay := time.Second << attempt
|
||||
if delay > 30*time.Second {
|
||||
delay = 30 * time.Second
|
||||
}
|
||||
s.Sleep(delay)
|
||||
}
|
||||
}
|
||||
return SendResponse{}, fmt.Errorf("source config delivery exhausted: %w", last)
|
||||
}
|
||||
|
||||
type RuntimeStatusEndpoint struct {
|
||||
Verifier mi.Verifier
|
||||
Store ProjectionStore
|
||||
Enabled bool
|
||||
MaxBodyBytes int
|
||||
}
|
||||
|
||||
func (e RuntimeStatusEndpoint) Receive(authorization string, body []byte, correlationID string, expected map[string]int64) (ProjectionView, error) {
|
||||
if !e.Enabled {
|
||||
return ProjectionView{}, errors.New("CONNECTOR_DISABLED")
|
||||
}
|
||||
if !requestIDPattern.MatchString(correlationID) {
|
||||
return ProjectionView{}, errors.New("INVALID_CORRELATION_ID")
|
||||
}
|
||||
if e.MaxBodyBytes == 0 {
|
||||
e.MaxBodyBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if len(body) > e.MaxBodyBytes {
|
||||
return ProjectionView{}, errors.New("REQUEST_TOO_LARGE")
|
||||
}
|
||||
token, err := mi.BearerToken(authorization)
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
if _, err = e.Verifier.Verify(token, "yovision-sense", "runtime-status:write", "POST", runtimeStatusPath, body); err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
return e.Store.Ingest(body, expected)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func memoryDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&ReplayToken{}, &RuntimeProjection{}, &SourceRevision{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSourceMapperAndRecalibration(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
facts := SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site/camera/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []Point{{.1, .1}, {.8, .1}, {.5, .8}}}}}
|
||||
config, err := MapSourceConfig(facts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = ValidateSourceConfig(config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
facts.Revision = 2
|
||||
facts.NeedsRecalibration = true
|
||||
config, err = MapSourceConfig(facts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if config.RuleSet.State != "recalibration_required" || config.RuleSet.Areas[0].Enabled {
|
||||
t.Fatal("recalibration did not disable rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayAtomicAndRestart(t *testing.T) {
|
||||
db := memoryDB(t, "replay-package")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
|
||||
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
|
||||
body := []byte("{}")
|
||||
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
accepted := 0
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for range 8 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
mu.Lock()
|
||||
accepted++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if accepted != 1 {
|
||||
t.Fatalf("accepted %d", accepted)
|
||||
}
|
||||
v := mi.Verifier{Registry: registry, Replay: GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
t.Fatal("restart replay accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectionStaleRecoveryAndMismatch(t *testing.T) {
|
||||
db := memoryDB(t, "projection-package")
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
store := ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
|
||||
raw := runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 1, now, 2)
|
||||
view, err := store.Ingest(raw, map[string]int64{"gate": 3})
|
||||
if err != nil || !view.RevisionMismatch {
|
||||
t.Fatalf("view %+v err %v", view, err)
|
||||
}
|
||||
now = now.Add(91 * time.Second)
|
||||
view, _ = store.View("brain-east-01")
|
||||
if !view.Offline {
|
||||
t.Fatal("not offline")
|
||||
}
|
||||
raw = runtimeFixture("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 2, now, 3)
|
||||
view, err = store.Ingest(raw, map[string]int64{"gate": 3})
|
||||
if err != nil || !view.Recovered {
|
||||
t.Fatalf("recovery %+v err %v", view, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorRejectsUnsafeRequestIDs(t *testing.T) {
|
||||
for _, value := range []string{"short", "0123456789abcde\n", "0123456789abcde!", strings.Repeat("a", 129)} {
|
||||
sender := ConfigSender{Enabled: true, Send: func(string, []byte, string, time.Duration) (SendResponse, error) {
|
||||
t.Fatal("unsafe request id reached transport")
|
||||
return SendResponse{}, nil
|
||||
}}
|
||||
if _, err := sender.Publish(SourceConfig{}, value); err == nil {
|
||||
t.Fatalf("sender accepted request id %q", value)
|
||||
}
|
||||
endpoint := RuntimeStatusEndpoint{Enabled: true}
|
||||
if _, err := endpoint.Receive("Bearer ignored", nil, value, nil); err == nil || err.Error() != "INVALID_CORRELATION_ID" {
|
||||
t.Fatalf("endpoint accepted request id %q: %v", value, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeFixture(id string, seq int64, observed time.Time, revision int64) []byte {
|
||||
value := map[string]any{"schema_version": RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": "running", "version": "1.0.0", "started_at": observed.Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people", "version": "1"}, "configurations": []any{map[string]any{"config_id": "gate", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 1.0}}, "inputs": []any{}}
|
||||
raw, _ := json.Marshal(value)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var stableID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$`)
|
||||
|
||||
func MapSourceConfig(f SourceFacts) (SourceConfig, error) {
|
||||
if !stableID.MatchString(f.ConfigID) || !stableID.MatchString(f.SiteID) || !stableID.MatchString(f.LogicalDeviceID) || !stableID.MatchString(f.Profile.ID) || f.Revision < 1 || f.Profile.Width < 1 || f.Profile.Height < 1 || f.Profile.FrameRate <= 0 {
|
||||
return SourceConfig{}, errors.New("invalid source configuration facts")
|
||||
}
|
||||
encoding := strings.ToUpper(f.Profile.Encoding)
|
||||
if encoding != "H264" && encoding != "H265" && encoding != "MJPEG" {
|
||||
return SourceConfig{}, errors.New("unsupported profile encoding")
|
||||
}
|
||||
if f.PublishedAt.IsZero() || f.EffectiveAt.Before(f.PublishedAt) {
|
||||
return SourceConfig{}, errors.New("invalid source configuration time")
|
||||
}
|
||||
if !stableID.MatchString(f.RuleSetVersion) {
|
||||
return SourceConfig{}, errors.New("invalid rule set version")
|
||||
}
|
||||
if strings.ContainsAny(f.MediaPath, "?#@\\") || strings.Contains(f.MediaPath, "://") || f.MediaPath == "" {
|
||||
return SourceConfig{}, errors.New("media path must be opaque and credential-free")
|
||||
}
|
||||
if len(f.Areas) > 1024 || len(f.DirectionalLines) > 1024 {
|
||||
return SourceConfig{}, errors.New("too many rules")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, a := range f.Areas {
|
||||
if !stableID.MatchString(a.ID) || seen[a.ID] || a.Version < 1 || a.Kind != "danger_area" || len(a.Points) < 3 || len(a.Points) > 256 || !validPoints(a.Points) || polygonArea(a.Points) == 0 {
|
||||
return SourceConfig{}, errors.New("invalid area rule")
|
||||
}
|
||||
seen[a.ID] = true
|
||||
}
|
||||
for _, l := range f.DirectionalLines {
|
||||
if !stableID.MatchString(l.ID) || seen[l.ID] || l.Version < 1 || l.Kind != "directional_line" || (l.TriggerDirection != "left_to_right" && l.TriggerDirection != "right_to_left") || !validPoints([]Point{l.Start, l.End}) || l.Start == l.End {
|
||||
return SourceConfig{}, errors.New("invalid directional line rule")
|
||||
}
|
||||
seen[l.ID] = true
|
||||
}
|
||||
var out SourceConfig
|
||||
out.SchemaVersion, out.ConfigID, out.Revision = SourceConfigVersion, f.ConfigID, f.Revision
|
||||
out.PublishedAt, out.EffectiveAt = f.PublishedAt.UTC(), f.EffectiveAt.UTC()
|
||||
out.Site.ID, out.LogicalDevice.ID = f.SiteID, f.LogicalDeviceID
|
||||
out.Profile = f.Profile
|
||||
out.Profile.Encoding = encoding
|
||||
out.Media.Ref, out.Media.Transport = "media:"+strings.TrimPrefix(f.MediaPath, "/"), "rtsp"
|
||||
out.RuleSet.Version = f.RuleSetVersion
|
||||
out.RuleSet.State = "active"
|
||||
if f.Disabled {
|
||||
out.RuleSet.State = "disabled"
|
||||
}
|
||||
if f.NeedsRecalibration {
|
||||
out.RuleSet.State = "recalibration_required"
|
||||
}
|
||||
out.RuleSet.ProfileBinding.ProfileID, out.RuleSet.ProfileBinding.Width, out.RuleSet.ProfileBinding.Height = f.Profile.ID, f.Profile.Width, f.Profile.Height
|
||||
out.RuleSet.Areas = append([]AreaRule(nil), f.Areas...)
|
||||
out.RuleSet.DirectionalLines = append([]DirectionalLineRule(nil), f.DirectionalLines...)
|
||||
if out.RuleSet.State != "active" {
|
||||
for i := range out.RuleSet.Areas {
|
||||
out.RuleSet.Areas[i].Enabled = false
|
||||
}
|
||||
for i := range out.RuleSet.DirectionalLines {
|
||||
out.RuleSet.DirectionalLines[i].Enabled = false
|
||||
}
|
||||
}
|
||||
digest, err := sourceDigest(out)
|
||||
if err != nil {
|
||||
return SourceConfig{}, err
|
||||
}
|
||||
out.Integrity.Algorithm, out.Integrity.Value = "sha256", digest
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func MarshalSourceConfig(config SourceConfig) ([]byte, error) { return json.Marshal(config) }
|
||||
|
||||
func sourceDigest(config SourceConfig) (string, error) {
|
||||
raw, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var value map[string]any
|
||||
if err = json.Unmarshal(raw, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
delete(value, "integrity")
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func validPoints(points []Point) bool {
|
||||
for _, p := range points {
|
||||
if p.X < 0 || p.X > 1 || p.Y < 0 || p.Y > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func polygonArea(p []Point) float64 {
|
||||
var a float64
|
||||
for i := range p {
|
||||
n := p[(i+1)%len(p)]
|
||||
a += p[i].X*n.Y - n.X*p[i].Y
|
||||
}
|
||||
if a < 0 {
|
||||
a = -a
|
||||
}
|
||||
return a / 2
|
||||
}
|
||||
|
||||
func ValidateSourceConfig(config SourceConfig) error {
|
||||
if config.SchemaVersion != SourceConfigVersion {
|
||||
return fmt.Errorf("unsupported source config version")
|
||||
}
|
||||
digest, err := sourceDigest(config)
|
||||
if err != nil || config.Integrity.Algorithm != "sha256" || digest != config.Integrity.Value {
|
||||
return errors.New("source config integrity mismatch")
|
||||
}
|
||||
_, err = MapSourceConfig(SourceFacts{ConfigID: config.ConfigID, SiteID: config.Site.ID, LogicalDeviceID: config.LogicalDevice.ID, MediaPath: strings.TrimPrefix(config.Media.Ref, "media:"), Revision: config.Revision, PublishedAt: config.PublishedAt, EffectiveAt: config.EffectiveAt, Profile: config.Profile, RuleSetVersion: config.RuleSet.Version, Disabled: config.RuleSet.State == "disabled", NeedsRecalibration: config.RuleSet.State == "recalibration_required", Areas: config.RuleSet.Areas, DirectionalLines: config.RuleSet.DirectionalLines})
|
||||
return err
|
||||
}
|
||||
|
||||
func UTCNow() time.Time { return time.Now().UTC() }
|
||||
@@ -0,0 +1,122 @@
|
||||
package brain_control
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
SourceConfigVersion = "yovision.source-config/v1"
|
||||
RuntimeStatusVersion = "yovision.runtime-status/v1"
|
||||
)
|
||||
|
||||
type Point struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
FrameRate float64 `json:"frame_rate"`
|
||||
}
|
||||
type AreaRule struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Points []Point `json:"points"`
|
||||
}
|
||||
type DirectionalLineRule struct {
|
||||
ID string `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
Kind string `json:"kind"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Start Point `json:"start"`
|
||||
End Point `json:"end"`
|
||||
TriggerDirection string `json:"trigger_direction"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
ConfigID string `json:"config_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
PublishedAt time.Time `json:"published_at"`
|
||||
EffectiveAt time.Time `json:"effective_at"`
|
||||
Site struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"site"`
|
||||
LogicalDevice struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"logical_device"`
|
||||
Profile Profile `json:"profile"`
|
||||
Media struct {
|
||||
Ref string `json:"ref"`
|
||||
Transport string `json:"transport"`
|
||||
} `json:"media"`
|
||||
RuleSet struct {
|
||||
Version string `json:"version"`
|
||||
State string `json:"state"`
|
||||
ProfileBinding struct {
|
||||
ProfileID string `json:"profile_id"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
} `json:"profile_binding"`
|
||||
Areas []AreaRule `json:"areas"`
|
||||
DirectionalLines []DirectionalLineRule `json:"directional_lines"`
|
||||
} `json:"rule_set"`
|
||||
Integrity struct {
|
||||
Algorithm string `json:"algorithm"`
|
||||
Value string `json:"value"`
|
||||
} `json:"integrity"`
|
||||
}
|
||||
|
||||
// SourceFacts is an explicit, credential-free boundary DTO. Callers map their
|
||||
// GORM entities into it; database models are never serialized as a contract.
|
||||
type SourceFacts struct {
|
||||
ConfigID, SiteID, LogicalDeviceID, MediaRouteID, MediaPath string
|
||||
Revision int64
|
||||
PublishedAt, EffectiveAt time.Time
|
||||
Profile Profile
|
||||
RuleSetVersion string
|
||||
Disabled, NeedsRecalibration bool
|
||||
Areas []AreaRule
|
||||
DirectionalLines []DirectionalLineRule
|
||||
}
|
||||
|
||||
type ReplayToken struct {
|
||||
Principal string `gorm:"size:128;primaryKey"`
|
||||
TokenID string `gorm:"size:96;primaryKey"`
|
||||
ExpiresAt time.Time `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"not null"`
|
||||
}
|
||||
|
||||
func (ReplayToken) TableName() string { return "sense_brain_runtime_replay_tokens" }
|
||||
|
||||
type RuntimeProjection struct {
|
||||
BrainInstanceRef string `gorm:"size:128;primaryKey"`
|
||||
StatusID string `gorm:"size:36;not null;uniqueIndex"`
|
||||
Sequence int64 `gorm:"not null"`
|
||||
ObservedAt time.Time `gorm:"not null;index"`
|
||||
ReceivedAt time.Time `gorm:"not null"`
|
||||
RuntimeState string `gorm:"size:32;not null"`
|
||||
RuntimeVersion string `gorm:"size:64;not null"`
|
||||
ModelRef string `gorm:"size:128;not null"`
|
||||
ModelVersion string `gorm:"size:64;not null"`
|
||||
HealthOverall string `gorm:"size:32;not null"`
|
||||
ExpectedRevisionsJSON string `gorm:"type:jsonb;not null"`
|
||||
ConfigurationsJSON string `gorm:"type:jsonb;not null"`
|
||||
HealthJSON string `gorm:"type:jsonb;not null"`
|
||||
InputsJSON string `gorm:"type:jsonb;not null"`
|
||||
WasOffline bool `gorm:"not null;default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (RuntimeProjection) TableName() string { return "sense_brain_runtime_projections" }
|
||||
|
||||
type SourceRevision struct {
|
||||
ConfigID string `gorm:"size:128;primaryKey"`
|
||||
Revision int64 `gorm:"not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (SourceRevision) TableName() string { return "sense_brain_source_revisions" }
|
||||
@@ -0,0 +1,145 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
type runtimeStatus struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
StatusID string `json:"status_id"`
|
||||
BrainInstanceRef string `json:"brain_instance_ref"`
|
||||
Sequence int64 `json:"sequence"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Runtime struct {
|
||||
State string `json:"state"`
|
||||
Version string `json:"version"`
|
||||
StartedAt *time.Time `json:"started_at"`
|
||||
} `json:"runtime"`
|
||||
Model struct {
|
||||
ModelRef string `json:"model_ref"`
|
||||
Version string `json:"version"`
|
||||
} `json:"model"`
|
||||
Configurations []configurationStatus `json:"configurations"`
|
||||
Health healthStatus `json:"health"`
|
||||
Inputs []inputStatus `json:"inputs"`
|
||||
}
|
||||
type configurationStatus struct {
|
||||
ConfigID string `json:"config_id"`
|
||||
ApplyState string `json:"apply_state"`
|
||||
AppliedRevision *int64 `json:"applied_revision"`
|
||||
ErrorCode *string `json:"error_code"`
|
||||
}
|
||||
type metrics struct {
|
||||
LoadPercent float64 `json:"load_percent"`
|
||||
QueueDepth int64 `json:"queue_depth"`
|
||||
LatencyMS float64 `json:"latency_ms"`
|
||||
}
|
||||
type healthStatus struct {
|
||||
Overall string `json:"overall"`
|
||||
ErrorCodes []string `json:"error_codes"`
|
||||
Metrics metrics `json:"metrics"`
|
||||
}
|
||||
type inputStatus struct {
|
||||
InputRef string `json:"input_ref"`
|
||||
State string `json:"state"`
|
||||
ErrorCodes []string `json:"error_codes"`
|
||||
Metrics metrics `json:"metrics"`
|
||||
}
|
||||
|
||||
var uuid4 = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
var errCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]{2,63}$`)
|
||||
|
||||
func parseRuntimeStatus(raw []byte) (runtimeStatus, error) {
|
||||
var s runtimeStatus
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.DisallowUnknownFields()
|
||||
if err := d.Decode(&s); err != nil {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
if err := d.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
if s.SchemaVersion != RuntimeStatusVersion {
|
||||
return s, errors.New("UNSUPPORTED_SCHEMA_VERSION")
|
||||
}
|
||||
if !uuid4.MatchString(s.StatusID) || !stableID.MatchString(s.BrainInstanceRef) || s.Sequence < 0 || s.ObservedAt.IsZero() || !validState(s.Runtime.State) || s.Runtime.Version == "" || !stableID.MatchString(s.Model.ModelRef) || s.Model.Version == "" || len(s.Configurations) > 4096 || len(s.Inputs) > 4096 || !validHealth(s.Health) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range s.Configurations {
|
||||
if !stableID.MatchString(c.ConfigID) || seen[c.ConfigID] || !validApply(c) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
seen[c.ConfigID] = true
|
||||
}
|
||||
for _, i := range s.Inputs {
|
||||
if !stableID.MatchString(i.InputRef) || !validState(i.State) || !validMetrics(i.Metrics) || !validCodes(i.ErrorCodes, 16) {
|
||||
return s, errors.New("CONFIG_INVALID")
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
func validState(v string) bool {
|
||||
switch v {
|
||||
case "unconfigured", "starting", "running", "degraded", "failed", "stopped":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validApply(c configurationStatus) bool {
|
||||
switch c.ApplyState {
|
||||
case "not_configured":
|
||||
return c.AppliedRevision == nil
|
||||
case "applying":
|
||||
return true
|
||||
case "applied":
|
||||
return c.AppliedRevision != nil && *c.AppliedRevision >= 1
|
||||
case "rejected":
|
||||
return c.ErrorCode != nil && errCode.MatchString(*c.ErrorCode)
|
||||
}
|
||||
return false
|
||||
}
|
||||
func validMetrics(m metrics) bool {
|
||||
return m.LoadPercent >= 0 && m.LoadPercent <= 100 && m.QueueDepth >= 0 && m.LatencyMS >= 0
|
||||
}
|
||||
func validCodes(v []string, n int) bool {
|
||||
if len(v) > n {
|
||||
return false
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, x := range v {
|
||||
if seen[x] || !errCode.MatchString(x) {
|
||||
return false
|
||||
}
|
||||
seen[x] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
func validHealth(h healthStatus) bool {
|
||||
return (h.Overall == "healthy" || h.Overall == "degraded" || h.Overall == "unhealthy") && validCodes(h.ErrorCodes, 32) && validMetrics(h.Metrics)
|
||||
}
|
||||
func validRuntimeTransition(from, to string) bool {
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
switch from {
|
||||
case "unconfigured":
|
||||
return to == "starting" || to == "stopped"
|
||||
case "starting":
|
||||
return to == "running" || to == "degraded" || to == "failed" || to == "stopped"
|
||||
case "running":
|
||||
return to == "degraded" || to == "failed" || to == "stopped"
|
||||
case "degraded":
|
||||
return to == "running" || to == "failed" || to == "stopped"
|
||||
case "failed":
|
||||
return to == "starting" || to == "stopped"
|
||||
case "stopped":
|
||||
return to == "starting"
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package brain_control
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type GORMReplayStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s GORMReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
|
||||
if s.DB == nil || principal == "" || tokenID == "" || !expiresAt.After(now) {
|
||||
return false
|
||||
}
|
||||
return s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("expires_at <= ?", now).Delete(&ReplayToken{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt, CreatedAt: now})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New("replayed")
|
||||
}
|
||||
return nil
|
||||
}) == nil
|
||||
}
|
||||
|
||||
type RevisionStore struct{ DB *gorm.DB }
|
||||
|
||||
func (s RevisionStore) Next(configID string) (int64, error) {
|
||||
if s.DB == nil || !stableID.MatchString(configID) {
|
||||
return 0, errors.New("invalid revision store")
|
||||
}
|
||||
var next int64
|
||||
err := s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var row SourceRevision
|
||||
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_id = ?", configID).First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
row = SourceRevision{ConfigID: configID, Revision: 1}
|
||||
if err = tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
next = 1
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row.Revision++
|
||||
next = row.Revision
|
||||
return tx.Save(&row).Error
|
||||
})
|
||||
return next, err
|
||||
}
|
||||
|
||||
type ProjectionStore struct {
|
||||
DB *gorm.DB
|
||||
Clock func() time.Time
|
||||
StaleAfter time.Duration
|
||||
FutureSkew time.Duration
|
||||
}
|
||||
type ProjectionView struct {
|
||||
Projection RuntimeProjection
|
||||
Offline, Stale, Recovered, RevisionMismatch bool
|
||||
}
|
||||
|
||||
func (s ProjectionStore) Ingest(raw []byte, expected map[string]int64) (ProjectionView, error) {
|
||||
if s.DB == nil {
|
||||
return ProjectionView{}, errors.New("projection database required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Clock != nil {
|
||||
now = s.Clock().UTC()
|
||||
}
|
||||
if s.StaleAfter == 0 {
|
||||
s.StaleAfter = 90 * time.Second
|
||||
}
|
||||
if s.FutureSkew == 0 {
|
||||
s.FutureSkew = 30 * time.Second
|
||||
}
|
||||
status, err := parseRuntimeStatus(raw)
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
if status.ObservedAt.After(now.Add(s.FutureSkew)) {
|
||||
return ProjectionView{}, errors.New("FUTURE_OBSERVATION")
|
||||
}
|
||||
var view ProjectionView
|
||||
err = s.DB.Transaction(func(tx *gorm.DB) error {
|
||||
var old RuntimeProjection
|
||||
find := tx.Where("brain_instance_ref = ?", status.BrainInstanceRef).First(&old).Error
|
||||
if find == nil {
|
||||
if old.StatusID == status.StatusID {
|
||||
view.Projection = old
|
||||
return nil
|
||||
}
|
||||
if status.Sequence <= old.Sequence {
|
||||
return errors.New("OUT_OF_ORDER_STATUS")
|
||||
}
|
||||
if !validRuntimeTransition(old.RuntimeState, status.Runtime.State) {
|
||||
return errors.New("INVALID_STATUS_TRANSITION")
|
||||
}
|
||||
}
|
||||
if find != nil && !errors.Is(find, gorm.ErrRecordNotFound) {
|
||||
return find
|
||||
}
|
||||
expectedJSON, _ := json.Marshal(expected)
|
||||
configs, _ := json.Marshal(status.Configurations)
|
||||
health, _ := json.Marshal(status.Health)
|
||||
inputs, _ := json.Marshal(status.Inputs)
|
||||
p := RuntimeProjection{BrainInstanceRef: status.BrainInstanceRef, StatusID: status.StatusID, Sequence: status.Sequence, ObservedAt: status.ObservedAt, ReceivedAt: now, RuntimeState: status.Runtime.State, RuntimeVersion: status.Runtime.Version, ModelRef: status.Model.ModelRef, ModelVersion: status.Model.Version, HealthOverall: status.Health.Overall, ExpectedRevisionsJSON: string(expectedJSON), ConfigurationsJSON: string(configs), HealthJSON: string(health), InputsJSON: string(inputs), WasOffline: find == nil && now.Sub(old.ObservedAt) > s.StaleAfter}
|
||||
if find == nil {
|
||||
p.CreatedAt = old.CreatedAt
|
||||
view.Recovered = p.WasOffline
|
||||
}
|
||||
if err := tx.Save(&p).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
view.Projection = p
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
view.Stale = now.Sub(view.Projection.ObservedAt) > s.StaleAfter
|
||||
view.Offline = view.Stale
|
||||
applied := make(map[string]*int64, len(status.Configurations))
|
||||
for _, c := range status.Configurations {
|
||||
applied[c.ConfigID] = c.AppliedRevision
|
||||
}
|
||||
for configID, want := range expected {
|
||||
got, ok := applied[configID]
|
||||
if !ok || got == nil || *got != want {
|
||||
view.RevisionMismatch = true
|
||||
}
|
||||
}
|
||||
return view, nil
|
||||
}
|
||||
|
||||
func (s ProjectionStore) View(instance string) (ProjectionView, error) {
|
||||
var p RuntimeProjection
|
||||
if err := s.DB.First(&p, "brain_instance_ref = ?", instance).Error; err != nil {
|
||||
return ProjectionView{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if s.Clock != nil {
|
||||
now = s.Clock().UTC()
|
||||
}
|
||||
stale := s.StaleAfter
|
||||
if stale == 0 {
|
||||
stale = 90 * time.Second
|
||||
}
|
||||
return ProjectionView{Projection: p, Offline: now.Sub(p.ObservedAt) > stale, Stale: now.Sub(p.ObservedAt) > stale}, nil
|
||||
}
|
||||
@@ -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,25 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBrainRuntime)
|
||||
}
|
||||
|
||||
func migrateBrainRuntime(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&brain_control.ReplayToken{}, &brain_control.RuntimeProjection{}, &brain_control.SourceRevision{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestMigrateBrainRuntime(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:brain-runtime-migration?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = migrateBrainRuntime(db, "2026083112000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, model := range []any{&brain_control.ReplayToken{}, &brain_control.RuntimeProjection{}, &brain_control.SourceRevision{}} {
|
||||
if !db.Migrator().HasTable(model) {
|
||||
t.Fatalf("missing table for %T", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package brain_control_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bc "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/brain_control"
|
||||
mi "git.ilapage.cn/ila/yovision/Sense/server/app/sense/integration/machine_identity"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T, name string) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+name+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&bc.ReplayToken{}, &bc.RuntimeProjection{}, &bc.SourceRevision{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestMapperProducesCredentialFreeFrozenContract(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
c, err := bc.MapSourceConfig(bc.SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site-east/camera-1/main", Revision: 1, PublishedAt: now, EffectiveAt: now, Profile: bc.Profile{ID: "main", Width: 1920, Height: 1080, Encoding: "h264", FrameRate: 25}, RuleSetVersion: "rules-1", Areas: []bc.AreaRule{{ID: "danger", Version: 1, Kind: "danger_area", Enabled: true, Points: []bc.Point{{X: .1, Y: .1}, {X: .8, Y: .1}, {X: .5, Y: .8}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = bc.ValidateSourceConfig(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, _ := json.Marshal(c)
|
||||
text := strings.ToLower(string(raw))
|
||||
for _, secret := range []string{"password", "username", "rtsp://", "stream_uri", "credential"} {
|
||||
if strings.Contains(text, secret) {
|
||||
t.Fatalf("leaked %q", secret)
|
||||
}
|
||||
}
|
||||
c2, err := bc.MapSourceConfig(bc.SourceFacts{ConfigID: "gate-primary", SiteID: "site-east", LogicalDeviceID: "camera-1", MediaPath: "site-east/camera-1/main", Revision: 2, PublishedAt: now, EffectiveAt: now, Profile: bc.Profile{ID: "main-v2", Width: 1280, Height: 720, Encoding: "H265", FrameRate: 20}, RuleSetVersion: "rules-2", NeedsRecalibration: true, Areas: []bc.AreaRule{{ID: "danger", Version: 2, Kind: "danger_area", Enabled: true, Points: []bc.Point{{X: .1, Y: .1}, {X: .8, Y: .1}, {X: .5, Y: .8}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c2.RuleSet.State != "recalibration_required" || c2.RuleSet.Areas[0].Enabled {
|
||||
t.Fatal("recalibration must disable rules")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurableReplayIsAtomicAndSurvivesVerifierRestart(t *testing.T) {
|
||||
db := testDB(t, "sense-replay")
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
registry, _ := mi.NewRegistry(mi.KeyRecord{Principal: "yv:brain:east", KeyID: "brain-key-01", PublicKey: pub, Audience: "yovision-sense", Scopes: []string{"runtime-status:write"}, Enabled: true})
|
||||
signer := mi.Signer{Principal: "yv:brain:east", KeyID: "brain-key-01", PrivateKey: priv, Now: func() time.Time { return now }}
|
||||
body := []byte(`{"ok":true}`)
|
||||
token, _ := signer.Mint("yovision-sense", []string{"runtime-status:write"}, "POST", "/machine/v1/runtime-status", body)
|
||||
results := make(chan bool, 8)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
v := mi.Verifier{Registry: registry, Replay: bc.GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
_, err := v.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body)
|
||||
results <- err == nil
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
accepted := 0
|
||||
for ok := range results {
|
||||
if ok {
|
||||
accepted++
|
||||
}
|
||||
}
|
||||
if accepted != 1 {
|
||||
t.Fatalf("accepted=%d", accepted)
|
||||
}
|
||||
v2 := mi.Verifier{Registry: registry, Replay: bc.GORMReplayStore{DB: db}, Now: func() time.Time { return now }}
|
||||
if _, err := v2.Verify(token, "yovision-sense", "runtime-status:write", "POST", "/machine/v1/runtime-status", body); err == nil {
|
||||
t.Fatal("replay accepted after verifier restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevisionStoreConcurrentAndRestart(t *testing.T) {
|
||||
db := testDB(t, "sense-revisions")
|
||||
store := bc.RevisionStore{DB: db}
|
||||
for want := int64(1); want <= 3; want++ {
|
||||
got, err := store.Next("gate-primary")
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("got %d err %v", got, err)
|
||||
}
|
||||
}
|
||||
restarted := bc.RevisionStore{DB: db}
|
||||
got, err := restarted.Next("gate-primary")
|
||||
if err != nil || got != 4 {
|
||||
t.Fatalf("restart got %d err %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProjectionStaleRecoveryMismatchAndOrdering(t *testing.T) {
|
||||
db := testDB(t, "sense-projection")
|
||||
now := time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC)
|
||||
store := bc.ProjectionStore{DB: db, Clock: func() time.Time { return now }, StaleAfter: 90 * time.Second}
|
||||
raw := statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d101", 41, now, "running", 20)
|
||||
view, err := store.Ingest(raw, map[string]int64{"gate-primary": 21})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !view.RevisionMismatch || view.Stale {
|
||||
t.Fatalf("bad initial view %+v", view)
|
||||
}
|
||||
now = now.Add(91 * time.Second)
|
||||
view, err = store.View("brain-east-01")
|
||||
if err != nil || !view.Offline || !view.Stale {
|
||||
t.Fatalf("offline %+v %v", view, err)
|
||||
}
|
||||
raw = statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d102", 42, now, "running", 21)
|
||||
view, err = store.Ingest(raw, map[string]int64{"gate-primary": 21})
|
||||
if err != nil || !view.Recovered || view.RevisionMismatch {
|
||||
t.Fatalf("recovery %+v %v", view, err)
|
||||
}
|
||||
if _, err = store.Ingest(statusJSON("018f4d6a-8d1b-4a25-8b37-9085f9c0d103", 41, now, "running", 21), nil); err == nil || err.Error() != "OUT_OF_ORDER_STATUS" {
|
||||
t.Fatalf("expected ordering rejection: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statusJSON(id string, seq int64, observed time.Time, state string, revision int64) []byte {
|
||||
v := map[string]any{"schema_version": bc.RuntimeStatusVersion, "status_id": id, "brain_instance_ref": "brain-east-01", "sequence": seq, "observed_at": observed.Format(time.RFC3339), "runtime": map[string]any{"state": state, "version": "1.0.0", "started_at": observed.Add(-time.Minute).Format(time.RFC3339)}, "model": map[string]any{"model_ref": "people-detection", "version": "2026.08.1"}, "configurations": []any{map[string]any{"config_id": "gate-primary", "apply_state": "applied", "applied_revision": revision, "error_code": nil}}, "health": map[string]any{"overall": "healthy", "error_codes": []any{}, "metrics": map[string]any{"load_percent": 1.0, "queue_depth": 0, "latency_ms": 2.0}}, "inputs": []any{}}
|
||||
raw, _ := json.Marshal(v)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
module git.ilapage.cn/ila/yovision/Sense/tests/integration/brain_control
|
||||
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
git.ilapage.cn/ila/yovision/Sense/server v0.0.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.49 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
replace git.ilapage.cn/ila/yovision/Sense/server => ../../../server
|
||||
@@ -0,0 +1,12 @@
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 812e822990d8c8e82445bd19ced67aca8c10aba4
|
||||
synchronized_at: 2026-08-31T01:58:55Z
|
||||
wiki_revision: 41c2193b2f1edb37abe1e8994d65d02207549039
|
||||
synchronized_at: 2026-08-31T03:18:12Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -344,3 +344,13 @@ Brain internal candidate / Sense local event
|
||||
|
||||
Brain candidate、Sense candidate/Outbox 与 Bell Event/Receipt/Alert 继续是各自内部模型。当前没有新增可运行的跨端 ingress/relay,不能把冻结 Schema 解释为端到端链路已完成。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 三项目机器身份与安全传输 v1
|
||||
|
||||
工单 #151 已于 2026-08-31 验收并通过 PR #162 合入 `dev`。共享事实源为 `contracts/machine-identity/v1/**` 与 `contracts/transport/v1/**`;Sense、Brain、Bell 分别在自己的 `integration/machine_identity/` 中保留独立适配,不共享用户、JWT、Cookie、Casbin、数据库或业务实现。
|
||||
|
||||
v1 使用 HTTPS 上的 Ed25519 短期请求绑定 JWS。每个部署实例拥有独立 principal、`kid` 和仓库外私钥;消费者使用本地外部公钥注册表,按精确 audience 与最小 scope 授权。令牌绑定 HTTP 方法、规范化路径和正文 SHA-256,有效期最多 300 秒、时钟偏差最多 30 秒,并以 `(principal,jti)` 原子防重放。TLS 最低 1.2,证书链与主机名验证不可关闭。
|
||||
|
||||
Sense/Bell 使用 Go 标准库 Ed25519,Brain 冻结 `cryptography==50.0.1`。固定跨语言向量证明 Go/Python 可互相验签。#151 只提供身份、注册表、传输策略及可注入 replay 接口;#152/#153 才注册业务 endpoint,并必须使用各产品独立的持久原子 replay store验证重启,不能共享数据库。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: bc4a1a7be268028fa85717b71f48f7dd75cc7e52
|
||||
synchronized_at: 2026-08-31T01:59:04Z
|
||||
wiki_revision: 7a91edf3ca35ade3e254937c4a68b53d816a3eea
|
||||
synchronized_at: 2026-08-31T03:18:24Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -262,3 +262,15 @@ synchronized_at: 2026-08-31T01:59:04Z
|
||||
- **Bell 所有权**:Bell 独占内部 Event/Receipt、规则、Alert、ack、close、通知与用户审计;上游不得写入这些状态。
|
||||
- **兼容与回退**:未知主版本终止接收但保留已有事实;破坏性变化发布新主版本。回退停用新生产者版本,不删除 Outbox、Receipt、Event 或审计。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份、权限和重放规则
|
||||
|
||||
- 机器 principal 格式为 `yv:<sense|brain|bell>:<instance>`,每个产品实例独立;不得替代、携带或映射 Sense/Bell 用户身份。
|
||||
- v1 audience 只允许 `yovision-sense`、`yovision-brain`、`yovision-bell`;scope 只允许 `source-config:write`、`runtime-status:write`、`events:ingest`、`evidence:read`,没有通配符。
|
||||
- 机器令牌只能从 Authorization Bearer 读取,不接受管理员密码、浏览器 JWT/Cookie、query token、共享 secret 或其他产品用户身份。
|
||||
- 相同 `(principal,jti)` 只能成功一次;传输重试必须签发新令牌和 jti,但业务幂等键与载荷保持不变。
|
||||
- 私钥只存在于仓库外受操作系统保护的文件或秘密存储;运行配置只引用路径。公钥注册表属于各消费者本地配置,不是共享数据库。
|
||||
- 轮换先登记新 `kid`,最多并存 24 小时,切换后移除旧 key;禁用 principal 或吊销 `kid` 对每次请求即时生效。
|
||||
- 回退只能关闭 connector 并恢复三端独立运行,不得降级为明文、共享管理员身份、共享 JWT 或跳过签名/TLS 验证。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: d11757b202117e028878802e1e8a9ba9df1a8e89
|
||||
synchronized_at: 2026-08-31T01:59:13Z
|
||||
wiki_revision: 771fdd92eb03e9dff7d3ae20b7cb89f1ca288959
|
||||
synchronized_at: 2026-08-31T03:18:34Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -679,3 +679,30 @@ git diff --check
|
||||
|
||||
这些测试只验证冻结契约,不验证 #153 产品 mapper/relay/ingress、#151 机器身份、实际证据存储/授权、网络断线补投或跨项目 E2E。
|
||||
<!-- standard-event-evidence-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份与安全传输 v1 验证
|
||||
|
||||
从仓库根目录执行隔离契约测试:
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File contracts/tests/machine-identity-v1/run.ps1
|
||||
```
|
||||
|
||||
脚本在系统临时目录建立虚拟环境,按固定 `cryptography==50.0.1` 和 `jsonschema==4.25.1` 验证封闭 Schema、Go/Python 固定 Ed25519 向量、请求绑定、Bearer-only、错 audience/scope、过期、重放、轮换、吊销和 TLS policy,结束后删除所属临时目录。
|
||||
|
||||
三端定向验证:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
go test -race ./app/sense/integration/machine_identity
|
||||
|
||||
cd ../../Bell/server
|
||||
go test -race ./app/bell/integration/machine_identity
|
||||
|
||||
cd ../..
|
||||
Brain\.venv\Scripts\python.exe contracts\tests\machine-identity-v1\test_contract.py
|
||||
```
|
||||
|
||||
这些测试只证明 #151 身份和传输基础。真实客户 PKI/网络、现场时钟漂移、业务 endpoint、断网补投以及持久 replay 重启恢复由 #152/#153/#155 验证;不得用进程内 replay store替代生产结论。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: 046624c1b2d7963ef733b4c9687d5dec14771388
|
||||
synchronized_at: 2026-08-27T09:05:32Z
|
||||
wiki_revision: 75bf70f375f58356e968b45139b54fcae391d1e8
|
||||
synchronized_at: 2026-08-31T03:18:53Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -161,3 +161,18 @@ synchronized_at: 2026-08-27T09:05:32Z
|
||||
3. 若同步提示本地镜像有未提交修改,先核对改动归属并停止覆盖。
|
||||
4. Wiki 页面缺失、没有 revision、MCP/API 凭据不可用或映射准备删除/重命名时停止,由工单确认后处理。
|
||||
5. PowerShell 显示乱码时先区分文件编码和控制台输出编码,不默认另起 PowerShell 或使用 `-ExecutionPolicy Bypass`。
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份 v1 排错
|
||||
|
||||
| 现象/错误码 | 常见原因 | 检查 | 安全处理 |
|
||||
|---|---|---|---|
|
||||
| `machine_token_missing` | 未使用 Authorization Bearer 或格式错误 | 检查 connector 配置,不粘贴令牌 | 修复调用方;不改用 Cookie/query token |
|
||||
| `machine_token_invalid` | 签名、kid、请求方法/路径/正文摘要或封闭字段不匹配 | 核对版本、kid 和请求绑定 | 停止重试,修复配置/实现 |
|
||||
| `machine_token_expired` | 时钟偏差或令牌超过 5 分钟 | 检查双方 UTC 时钟 | 校时并签发新令牌,不延长长期 token |
|
||||
| `machine_audience_denied` / `machine_scope_denied` | 目标产品或最小权限不匹配 | 核对公钥注册表和调用方向 | 修正精确授权,不添加通配符 |
|
||||
| `machine_identity_revoked` | principal/key 已禁用或吊销 | 核对当前 kid 与轮换状态 | 切换已批准新 key;不得恢复旧 key绕过吊销 |
|
||||
| `machine_token_replayed` | 同一 jti 被再次使用 | 检查重试是否重新签名 | 新建 jti,保留业务幂等键 |
|
||||
|
||||
排错日志只记录稳定错误码、已认证 principal/kid 和 correlation ID;不得记录令牌、签名、密钥、完整 Authorization header 或秘密路径。无法安全恢复时关闭 connector,三端保持独立运行。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Deployment-and-Operations
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Deployment-and-Operations.-
|
||||
wiki_revision: 0a772b0511044d98430ebd93304faa3dee57183d
|
||||
synchronized_at: 2026-08-31T01:39:35Z
|
||||
wiki_revision: 54d720aa07d2c40684da3fc04378393c7f318a04
|
||||
synchronized_at: 2026-08-31T03:20:15Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# YoVision 部署与运维
|
||||
@@ -142,3 +142,15 @@ Sense\start_sense.bat
|
||||
|
||||
因此现阶段部署仍按 Sense、Brain 各自独立入口进行,不得手工共享数据库、用户 JWT/Cookie、摄像头凭据、文件目录或临时 JSON 字段来提前打通。需要停用或回退时保持两端独立运行,并保留上一已确认的配置与最后已知状态;未知协议主版本必须停止摄取而不是覆盖投影。
|
||||
<!-- sense-brain-contracts-v1:end -->
|
||||
|
||||
<!-- machine-identity-v1:start -->
|
||||
## 机器身份部署与轮换边界
|
||||
|
||||
#151 已冻结机器身份和传输基础,但 #152/#153 尚未注册业务 connector,因此当前不得手工拼接 endpoint 或临时共享凭据提前打通。
|
||||
|
||||
部署时为每个调用实例独立生成 Ed25519 私钥,保存到仓库外受 ACL/秘密存储保护的位置;产品配置只记录私钥路径、principal、kid、目标 audience 和最小 scope。消费者从仓库外公钥注册表读取受信 principal/kid。不得把私钥、完整令牌、Authorization header、管理员密码、浏览器 JWT/Cookie 或 query token写入配置样例、日志、工单和备份。
|
||||
|
||||
传输固定使用 HTTPS,TLS 最低 1.2并验证证书链与主机名。轮换按“先登记新公钥 → 调用方切换新 kid → 验证流量 → 24 小时内移除旧 key”执行;应急吊销直接禁用 principal 或 key,并同时停用相关 connector。回退保持 Sense、Brain、Bell 独立运行,保留 Outbox、Receipt、Event 和最后已知状态。
|
||||
|
||||
#152/#153 必须为各产品注入自己的持久原子 `(principal,jti)` replay store并验证重启;内存 replay store 只用于适配测试或不重启的单进程原语。
|
||||
<!-- machine-identity-v1:end -->
|
||||
|
||||
Reference in New Issue
Block a user