Initial project snapshot
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
package clusterauth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthoritySchemaVersion = "rap.cluster_authority.v1"
|
||||
SignatureSchemaVersion = "rap.cluster_authority.signature.v1"
|
||||
AlgorithmEd25519 = "ed25519"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidKey = errors.New("invalid cluster authority key")
|
||||
ErrInvalidSignature = errors.New("invalid cluster authority signature")
|
||||
ErrInvalidPayload = errors.New("invalid cluster authority payload")
|
||||
)
|
||||
|
||||
type KeyPair struct {
|
||||
PublicKeyB64 string
|
||||
PrivateKeyB64 string
|
||||
Fingerprint string
|
||||
}
|
||||
|
||||
type Signature struct {
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
Signature string `json:"signature"`
|
||||
SignedAt time.Time `json:"signed_at"`
|
||||
}
|
||||
|
||||
func GenerateKeyPair() (KeyPair, error) {
|
||||
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return KeyPair{}, err
|
||||
}
|
||||
fingerprint := Fingerprint(publicKey)
|
||||
return KeyPair{
|
||||
PublicKeyB64: base64.StdEncoding.EncodeToString(publicKey),
|
||||
PrivateKeyB64: base64.StdEncoding.EncodeToString(privateKey),
|
||||
Fingerprint: fingerprint,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Fingerprint(publicKey ed25519.PublicKey) string {
|
||||
sum := sha256.Sum256(publicKey)
|
||||
return "rap-ca-ed25519-" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func FingerprintFromBase64(publicKeyB64 string) (string, error) {
|
||||
publicKey, err := DecodePublicKey(publicKeyB64)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Fingerprint(publicKey), nil
|
||||
}
|
||||
|
||||
func SignRaw(privateKeyB64 string, payload json.RawMessage, signedAt time.Time) (Signature, error) {
|
||||
privateKey, err := DecodePrivateKey(privateKeyB64)
|
||||
if err != nil {
|
||||
return Signature{}, err
|
||||
}
|
||||
canonical, err := CanonicalJSON(payload)
|
||||
if err != nil {
|
||||
return Signature{}, err
|
||||
}
|
||||
publicKey, ok := privateKey.Public().(ed25519.PublicKey)
|
||||
if !ok {
|
||||
return Signature{}, ErrInvalidKey
|
||||
}
|
||||
signature := ed25519.Sign(privateKey, canonical)
|
||||
return Signature{
|
||||
SchemaVersion: SignatureSchemaVersion,
|
||||
Algorithm: AlgorithmEd25519,
|
||||
KeyFingerprint: Fingerprint(publicKey),
|
||||
Signature: base64.StdEncoding.EncodeToString(signature),
|
||||
SignedAt: signedAt.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func SignPayload(privateKeyB64 string, payload any, signedAt time.Time) (json.RawMessage, Signature, error) {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, Signature{}, fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
signature, err := SignRaw(privateKeyB64, raw, signedAt)
|
||||
if err != nil {
|
||||
return nil, Signature{}, err
|
||||
}
|
||||
return json.RawMessage(raw), signature, nil
|
||||
}
|
||||
|
||||
func VerifyRaw(publicKeyB64 string, payload json.RawMessage, signature Signature) error {
|
||||
if signature.SchemaVersion != SignatureSchemaVersion {
|
||||
return fmt.Errorf("%w: schema_version must be %s", ErrInvalidSignature, SignatureSchemaVersion)
|
||||
}
|
||||
if signature.Algorithm != AlgorithmEd25519 {
|
||||
return fmt.Errorf("%w: algorithm must be %s", ErrInvalidSignature, AlgorithmEd25519)
|
||||
}
|
||||
publicKey, err := DecodePublicKey(publicKeyB64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if signature.KeyFingerprint != Fingerprint(publicKey) {
|
||||
return fmt.Errorf("%w: key fingerprint mismatch", ErrInvalidSignature)
|
||||
}
|
||||
canonical, err := CanonicalJSON(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decodedSignature, err := decodeBase64(strings.TrimSpace(signature.Signature))
|
||||
if err != nil || len(decodedSignature) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("%w: signature must be base64 ed25519 signature", ErrInvalidSignature)
|
||||
}
|
||||
if !ed25519.Verify(publicKey, canonical, decodedSignature) {
|
||||
return ErrInvalidSignature
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func CanonicalJSON(raw json.RawMessage) ([]byte, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty payload", ErrInvalidPayload)
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid json: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
canonical, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: canonical json: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
func HashRaw(raw json.RawMessage) (string, error) {
|
||||
canonical, err := CanonicalJSON(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(canonical)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func DecodePublicKey(value string) (ed25519.PublicKey, error) {
|
||||
decoded, err := decodeBase64(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: public key must be base64 encoded", ErrInvalidKey)
|
||||
}
|
||||
if len(decoded) != ed25519.PublicKeySize {
|
||||
return nil, fmt.Errorf("%w: public key must decode to %d bytes", ErrInvalidKey, ed25519.PublicKeySize)
|
||||
}
|
||||
return ed25519.PublicKey(decoded), nil
|
||||
}
|
||||
|
||||
func DecodePrivateKey(value string) (ed25519.PrivateKey, error) {
|
||||
decoded, err := decodeBase64(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: private key must be base64 encoded", ErrInvalidKey)
|
||||
}
|
||||
if len(decoded) != ed25519.PrivateKeySize {
|
||||
return nil, fmt.Errorf("%w: private key must decode to %d bytes", ErrInvalidKey, ed25519.PrivateKeySize)
|
||||
}
|
||||
return ed25519.PrivateKey(decoded), nil
|
||||
}
|
||||
|
||||
func decodeBase64(value string) ([]byte, error) {
|
||||
if value == "" {
|
||||
return nil, errors.New("empty base64 value")
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err == nil {
|
||||
return decoded, nil
|
||||
}
|
||||
return base64.RawStdEncoding.DecodeString(value)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package clusterauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignAndVerifyRawPayload(t *testing.T) {
|
||||
keys, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
payload := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"test.v1","value":1}`)
|
||||
|
||||
signature, err := SignRaw(keys.PrivateKeyB64, payload, time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("SignRaw: %v", err)
|
||||
}
|
||||
if signature.KeyFingerprint != keys.Fingerprint {
|
||||
t.Fatalf("fingerprint = %q, want %q", signature.KeyFingerprint, keys.Fingerprint)
|
||||
}
|
||||
if err := VerifyRaw(keys.PublicKeyB64, payload, signature); err != nil {
|
||||
t.Fatalf("VerifyRaw: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRawRejectsTamperedPayload(t *testing.T) {
|
||||
keys, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair: %v", err)
|
||||
}
|
||||
payload := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"test.v1","value":1}`)
|
||||
signature, err := SignRaw(keys.PrivateKeyB64, payload, time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("SignRaw: %v", err)
|
||||
}
|
||||
|
||||
tampered := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"test.v1","value":2}`)
|
||||
if err := VerifyRaw(keys.PublicKeyB64, tampered, signature); !errors.Is(err, ErrInvalidSignature) {
|
||||
t.Fatalf("err = %v, want ErrInvalidSignature", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user