Refactor RDP proxy handling and update related tests
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user