From 009dc3cca0ad835cf10fec6b58c7436bd66315b0 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 31 Aug 2026 10:53:51 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E4=B8=89=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=9C=BA=E5=99=A8=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E4=BC=A0=E8=BE=93=20v1=20(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/machine_identity/registry.go | 68 ++++ .../integration/machine_identity/token.go | 363 ++++++++++++++++++ .../machine_identity/token_test.go | 219 +++++++++++ .../integration/machine_identity/transport.go | 39 ++ Brain/pyproject.toml | 2 +- .../integration/machine_identity/__init__.py | 37 ++ .../integration/machine_identity/token.py | 336 ++++++++++++++++ .../integration/machine_identity/transport.py | 37 ++ .../integration/machine_identity/registry.go | 68 ++++ .../integration/machine_identity/token.go | 363 ++++++++++++++++++ .../machine_identity/token_test.go | 219 +++++++++++ .../integration/machine_identity/transport.go | 39 ++ contracts/machine-identity/v1/README.md | 62 +++ contracts/machine-identity/v1/errors.md | 14 + .../v1/machine-token.schema.json | 28 ++ .../v1/principal-registry.schema.json | 38 ++ .../cross-language-vector.json | 11 + .../machine-identity-v1/requirements.txt | 2 + contracts/tests/machine-identity-v1/run.ps1 | 33 ++ .../machine-identity-v1/test_contract.py | 181 +++++++++ contracts/transport/v1/README.md | 12 + .../transport/v1/transport-policy.schema.json | 18 + 22 files changed, 2188 insertions(+), 1 deletion(-) create mode 100644 Bell/server/app/bell/integration/machine_identity/registry.go create mode 100644 Bell/server/app/bell/integration/machine_identity/token.go create mode 100644 Bell/server/app/bell/integration/machine_identity/token_test.go create mode 100644 Bell/server/app/bell/integration/machine_identity/transport.go create mode 100644 Brain/src/yovision_brain/integration/machine_identity/__init__.py create mode 100644 Brain/src/yovision_brain/integration/machine_identity/token.py create mode 100644 Brain/src/yovision_brain/integration/machine_identity/transport.py create mode 100644 Sense/server/app/sense/integration/machine_identity/registry.go create mode 100644 Sense/server/app/sense/integration/machine_identity/token.go create mode 100644 Sense/server/app/sense/integration/machine_identity/token_test.go create mode 100644 Sense/server/app/sense/integration/machine_identity/transport.go create mode 100644 contracts/machine-identity/v1/README.md create mode 100644 contracts/machine-identity/v1/errors.md create mode 100644 contracts/machine-identity/v1/machine-token.schema.json create mode 100644 contracts/machine-identity/v1/principal-registry.schema.json create mode 100644 contracts/tests/machine-identity-v1/cross-language-vector.json create mode 100644 contracts/tests/machine-identity-v1/requirements.txt create mode 100644 contracts/tests/machine-identity-v1/run.ps1 create mode 100644 contracts/tests/machine-identity-v1/test_contract.py create mode 100644 contracts/transport/v1/README.md create mode 100644 contracts/transport/v1/transport-policy.schema.json diff --git a/Bell/server/app/bell/integration/machine_identity/registry.go b/Bell/server/app/bell/integration/machine_identity/registry.go new file mode 100644 index 0000000..8a73745 --- /dev/null +++ b/Bell/server/app/bell/integration/machine_identity/registry.go @@ -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...) +} diff --git a/Bell/server/app/bell/integration/machine_identity/token.go b/Bell/server/app/bell/integration/machine_identity/token.go new file mode 100644 index 0000000..16fb1fb --- /dev/null +++ b/Bell/server/app/bell/integration/machine_identity/token.go @@ -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) +} diff --git a/Bell/server/app/bell/integration/machine_identity/token_test.go b/Bell/server/app/bell/integration/machine_identity/token_test.go new file mode 100644 index 0000000..f8f3dfd --- /dev/null +++ b/Bell/server/app/bell/integration/machine_identity/token_test.go @@ -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") + } +} diff --git a/Bell/server/app/bell/integration/machine_identity/transport.go b/Bell/server/app/bell/integration/machine_identity/transport.go new file mode 100644 index 0000000..494051a --- /dev/null +++ b/Bell/server/app/bell/integration/machine_identity/transport.go @@ -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 +} diff --git a/Brain/pyproject.toml b/Brain/pyproject.toml index ac87efd..db02661 100644 --- a/Brain/pyproject.toml +++ b/Brain/pyproject.toml @@ -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 diff --git a/Brain/src/yovision_brain/integration/machine_identity/__init__.py b/Brain/src/yovision_brain/integration/machine_identity/__init__.py new file mode 100644 index 0000000..524da8e --- /dev/null +++ b/Brain/src/yovision_brain/integration/machine_identity/__init__.py @@ -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", +] diff --git a/Brain/src/yovision_brain/integration/machine_identity/token.py b/Brain/src/yovision_brain/integration/machine_identity/token.py new file mode 100644 index 0000000..9b9b963 --- /dev/null +++ b/Brain/src/yovision_brain/integration/machine_identity/token.py @@ -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 diff --git a/Brain/src/yovision_brain/integration/machine_identity/transport.py b/Brain/src/yovision_brain/integration/machine_identity/transport.py new file mode 100644 index 0000000..c40e6e7 --- /dev/null +++ b/Brain/src/yovision_brain/integration/machine_identity/transport.py @@ -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 diff --git a/Sense/server/app/sense/integration/machine_identity/registry.go b/Sense/server/app/sense/integration/machine_identity/registry.go new file mode 100644 index 0000000..8a73745 --- /dev/null +++ b/Sense/server/app/sense/integration/machine_identity/registry.go @@ -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...) +} diff --git a/Sense/server/app/sense/integration/machine_identity/token.go b/Sense/server/app/sense/integration/machine_identity/token.go new file mode 100644 index 0000000..16fb1fb --- /dev/null +++ b/Sense/server/app/sense/integration/machine_identity/token.go @@ -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) +} diff --git a/Sense/server/app/sense/integration/machine_identity/token_test.go b/Sense/server/app/sense/integration/machine_identity/token_test.go new file mode 100644 index 0000000..f8f3dfd --- /dev/null +++ b/Sense/server/app/sense/integration/machine_identity/token_test.go @@ -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") + } +} diff --git a/Sense/server/app/sense/integration/machine_identity/transport.go b/Sense/server/app/sense/integration/machine_identity/transport.go new file mode 100644 index 0000000..494051a --- /dev/null +++ b/Sense/server/app/sense/integration/machine_identity/transport.go @@ -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 +} diff --git a/contracts/machine-identity/v1/README.md b/contracts/machine-identity/v1/README.md new file mode 100644 index 0000000..50b0e12 --- /dev/null +++ b/contracts/machine-identity/v1/README.md @@ -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 `. 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. diff --git a/contracts/machine-identity/v1/errors.md b/contracts/machine-identity/v1/errors.md new file mode 100644 index 0000000..9d8d5cc --- /dev/null +++ b/contracts/machine-identity/v1/errors.md @@ -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. diff --git a/contracts/machine-identity/v1/machine-token.schema.json b/contracts/machine-identity/v1/machine-token.schema.json new file mode 100644 index 0000000..c71e428 --- /dev/null +++ b/contracts/machine-identity/v1/machine-token.schema.json @@ -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}$"} + } +} diff --git a/contracts/machine-identity/v1/principal-registry.schema.json b/contracts/machine-identity/v1/principal-registry.schema.json new file mode 100644 index 0000000..cec2adc --- /dev/null +++ b/contracts/machine-identity/v1/principal-registry.schema.json @@ -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"]}} + } + } + } + } + } + } + } +} diff --git a/contracts/tests/machine-identity-v1/cross-language-vector.json b/contracts/tests/machine-identity-v1/cross-language-vector.json new file mode 100644 index 0000000..c3c67b6 --- /dev/null +++ b/contracts/tests/machine-identity-v1/cross-language-vector.json @@ -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=" +} diff --git a/contracts/tests/machine-identity-v1/requirements.txt b/contracts/tests/machine-identity-v1/requirements.txt new file mode 100644 index 0000000..ef3dd47 --- /dev/null +++ b/contracts/tests/machine-identity-v1/requirements.txt @@ -0,0 +1,2 @@ +cryptography==50.0.1 +jsonschema==4.25.1 diff --git a/contracts/tests/machine-identity-v1/run.ps1 b/contracts/tests/machine-identity-v1/run.ps1 new file mode 100644 index 0000000..2a6a70d --- /dev/null +++ b/contracts/tests/machine-identity-v1/run.ps1 @@ -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 + } +} diff --git a/contracts/tests/machine-identity-v1/test_contract.py b/contracts/tests/machine-identity-v1/test_contract.py new file mode 100644 index 0000000..ac8ed31 --- /dev/null +++ b/contracts/tests/machine-identity-v1/test_contract.py @@ -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) diff --git a/contracts/transport/v1/README.md b/contracts/transport/v1/README.md new file mode 100644 index 0000000..1a2438d --- /dev/null +++ b/contracts/transport/v1/README.md @@ -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. diff --git a/contracts/transport/v1/transport-policy.schema.json b/contracts/transport/v1/transport-policy.schema.json new file mode 100644 index 0000000..46772b0 --- /dev/null +++ b/contracts/transport/v1/transport-policy.schema.json @@ -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} + } +}