Refactor RDP proxy handling and update related tests

This commit is contained in:
2026-05-17 20:38:35 +03:00
parent 8e9402580f
commit d551e57fd5
172 changed files with 22117 additions and 2509 deletions
@@ -16,6 +16,8 @@ import (
const (
AuthoritySchemaVersion = "rap.cluster_authority.v1"
SignatureSchemaVersion = "rap.cluster_authority.signature.v1"
QuorumSchemaVersion = "rap.cluster_authority.quorum.v1"
QuorumEnvelopeVersion = "rap.cluster_authority.quorum_envelope.v1"
AlgorithmEd25519 = "ed25519"
)
@@ -39,6 +41,34 @@ type Signature struct {
SignedAt time.Time `json:"signed_at"`
}
type QuorumMember struct {
NodeID string `json:"node_id,omitempty"`
Role string `json:"role,omitempty"`
PublicKey string `json:"public_key"`
PublicKeyFingerprint string `json:"public_key_fingerprint"`
Scopes []string `json:"scopes,omitempty"`
}
type QuorumDescriptor struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
Epoch string `json:"epoch"`
Threshold int `json:"threshold"`
Members []QuorumMember `json:"members"`
}
type QuorumEnvelope struct {
SchemaVersion string `json:"schema_version"`
ClusterID string `json:"cluster_id"`
Epoch string `json:"epoch"`
Threshold int `json:"threshold"`
PayloadSHA256 string `json:"payload_sha256"`
QuorumSHA256 string `json:"quorum_sha256"`
Signatures []Signature `json:"signatures"`
AllowedScopes []string `json:"allowed_scopes,omitempty"`
DecisionReason string `json:"decision_reason,omitempty"`
}
func GenerateKeyPair() (KeyPair, error) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
@@ -128,6 +158,82 @@ func VerifyRaw(publicKeyB64 string, payload json.RawMessage, signature Signature
return nil
}
func VerifyQuorumRaw(descriptor QuorumDescriptor, payload json.RawMessage, envelope QuorumEnvelope, requiredScope string) error {
if descriptor.SchemaVersion != QuorumSchemaVersion {
return fmt.Errorf("%w: quorum schema_version must be %s", ErrInvalidSignature, QuorumSchemaVersion)
}
if envelope.SchemaVersion != QuorumEnvelopeVersion {
return fmt.Errorf("%w: quorum envelope schema_version must be %s", ErrInvalidSignature, QuorumEnvelopeVersion)
}
if strings.TrimSpace(descriptor.ClusterID) == "" || descriptor.ClusterID != envelope.ClusterID {
return fmt.Errorf("%w: quorum cluster mismatch", ErrInvalidSignature)
}
if strings.TrimSpace(descriptor.Epoch) == "" || descriptor.Epoch != envelope.Epoch {
return fmt.Errorf("%w: quorum epoch mismatch", ErrInvalidSignature)
}
threshold := descriptor.Threshold
if envelope.Threshold > threshold {
threshold = envelope.Threshold
}
if threshold <= 0 || threshold > len(descriptor.Members) {
return fmt.Errorf("%w: invalid quorum threshold", ErrInvalidSignature)
}
payloadHash, err := HashRaw(payload)
if err != nil {
return err
}
if envelope.PayloadSHA256 != payloadHash {
return fmt.Errorf("%w: quorum payload hash mismatch", ErrInvalidSignature)
}
descriptorHash, err := QuorumDescriptorHash(descriptor)
if err != nil {
return err
}
if envelope.QuorumSHA256 != descriptorHash {
return fmt.Errorf("%w: quorum descriptor hash mismatch", ErrInvalidSignature)
}
members := map[string]QuorumMember{}
for _, member := range descriptor.Members {
fingerprint := strings.TrimSpace(member.PublicKeyFingerprint)
if fingerprint == "" {
publicKey, err := DecodePublicKey(member.PublicKey)
if err != nil {
return err
}
fingerprint = Fingerprint(publicKey)
}
if _, exists := members[fingerprint]; exists {
return fmt.Errorf("%w: duplicate quorum member", ErrInvalidSignature)
}
member.PublicKeyFingerprint = fingerprint
members[fingerprint] = member
}
seen := map[string]bool{}
valid := 0
for _, signature := range envelope.Signatures {
fingerprint := strings.TrimSpace(signature.KeyFingerprint)
if seen[fingerprint] {
continue
}
member, ok := members[fingerprint]
if !ok {
return fmt.Errorf("%w: quorum signer is not a member", ErrInvalidSignature)
}
if requiredScope != "" && !memberAllowsScope(member, requiredScope) {
return fmt.Errorf("%w: quorum signer scope mismatch", ErrInvalidSignature)
}
if err := VerifyRaw(member.PublicKey, payload, signature); err != nil {
return err
}
seen[fingerprint] = true
valid++
}
if valid < threshold {
return fmt.Errorf("%w: quorum threshold not met", ErrInvalidSignature)
}
return nil
}
func CanonicalJSON(raw json.RawMessage) ([]byte, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%w: empty payload", ErrInvalidPayload)
@@ -152,6 +258,28 @@ func HashRaw(raw json.RawMessage) (string, error) {
return hex.EncodeToString(sum[:]), nil
}
func QuorumDescriptorHash(descriptor QuorumDescriptor) (string, error) {
raw, err := json.Marshal(descriptor)
if err != nil {
return "", err
}
return HashRaw(raw)
}
func memberAllowsScope(member QuorumMember, requiredScope string) bool {
requiredScope = strings.TrimSpace(requiredScope)
if requiredScope == "" {
return true
}
for _, scope := range member.Scopes {
scope = strings.TrimSpace(scope)
if scope == "*" || scope == requiredScope {
return true
}
}
return false
}
func DecodePublicKey(value string) (ed25519.PublicKey, error) {
decoded, err := decodeBase64(strings.TrimSpace(value))
if err != nil {
@@ -3,6 +3,7 @@ package clusterauth
import (
"encoding/json"
"errors"
"fmt"
"testing"
"time"
)
@@ -42,3 +43,85 @@ func TestVerifyRawRejectsTamperedPayload(t *testing.T) {
t.Fatalf("err = %v, want ErrInvalidSignature", err)
}
}
func TestVerifyQuorumRawAcceptsThreshold(t *testing.T) {
payload := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"rap.node_update_plan_authority.v1","action":"update"}`)
descriptor, keys := testQuorum(t, 3, 2)
payloadHash, err := HashRaw(payload)
if err != nil {
t.Fatalf("HashRaw: %v", err)
}
quorumHash, err := QuorumDescriptorHash(descriptor)
if err != nil {
t.Fatalf("QuorumDescriptorHash: %v", err)
}
signatureA, err := SignRaw(keys[0].PrivateKeyB64, payload, time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("SignRaw A: %v", err)
}
signatureB, err := SignRaw(keys[1].PrivateKeyB64, payload, time.Date(2026, 5, 16, 12, 0, 1, 0, time.UTC))
if err != nil {
t.Fatalf("SignRaw B: %v", err)
}
envelope := QuorumEnvelope{
SchemaVersion: QuorumEnvelopeVersion,
ClusterID: "cluster-1",
Epoch: "epoch-1",
Threshold: 2,
PayloadSHA256: payloadHash,
QuorumSHA256: quorumHash,
Signatures: []Signature{signatureA, signatureB},
}
if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); err != nil {
t.Fatalf("VerifyQuorumRaw: %v", err)
}
}
func TestVerifyQuorumRawRejectsBelowThreshold(t *testing.T) {
payload := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"rap.node_update_plan_authority.v1","action":"update"}`)
descriptor, keys := testQuorum(t, 3, 2)
payloadHash, _ := HashRaw(payload)
quorumHash, _ := QuorumDescriptorHash(descriptor)
signature, err := SignRaw(keys[0].PrivateKeyB64, payload, time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("SignRaw: %v", err)
}
envelope := QuorumEnvelope{
SchemaVersion: QuorumEnvelopeVersion,
ClusterID: "cluster-1",
Epoch: "epoch-1",
Threshold: 2,
PayloadSHA256: payloadHash,
QuorumSHA256: quorumHash,
Signatures: []Signature{signature},
}
if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); !errors.Is(err, ErrInvalidSignature) {
t.Fatalf("err = %v, want ErrInvalidSignature", err)
}
}
func testQuorum(t *testing.T, count int, threshold int) (QuorumDescriptor, []KeyPair) {
t.Helper()
descriptor := QuorumDescriptor{
SchemaVersion: QuorumSchemaVersion,
ClusterID: "cluster-1",
Epoch: "epoch-1",
Threshold: threshold,
}
keys := make([]KeyPair, 0, count)
for i := 0; i < count; i++ {
keyPair, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair: %v", err)
}
descriptor.Members = append(descriptor.Members, QuorumMember{
NodeID: fmt.Sprintf("authority-%d", i+1),
Role: "update-authority",
PublicKey: keyPair.PublicKeyB64,
PublicKeyFingerprint: keyPair.Fingerprint,
Scopes: []string{"update-authority"},
})
keys = append(keys, keyPair)
}
return descriptor, keys
}