306 lines
9.2 KiB
Go
306 lines
9.2 KiB
Go
package authsnapshot
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
SnapshotVersion = 1
|
|
MaxTTL = 5 * time.Minute
|
|
clockSkew = 30 * time.Second
|
|
signatureDomain = "nekonest-cloud/relay-authorization-snapshot/v1\n"
|
|
)
|
|
|
|
type Device struct {
|
|
DeviceID string `json:"device_id"`
|
|
CredentialHash string `json:"credential_hash"`
|
|
IdentityFingerprint string `json:"identity_fingerprint"`
|
|
Ed25519Public string `json:"ed25519_public"`
|
|
X25519Public string `json:"x25519_public"`
|
|
Name string `json:"name"`
|
|
OS string `json:"os"`
|
|
}
|
|
|
|
type Phone struct {
|
|
PhoneID string `json:"phone_id"`
|
|
CredentialHash string `json:"credential_hash"`
|
|
IdentityFingerprint string `json:"identity_fingerprint"`
|
|
Ed25519Public string `json:"ed25519_public"`
|
|
X25519Public string `json:"x25519_public"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type Payload struct {
|
|
SnapshotVersion int `json:"snapshot_version"`
|
|
TenantID string `json:"tenant_id"`
|
|
TenantStatus string `json:"tenant_status"`
|
|
HomeRegion string `json:"home_region"`
|
|
RelayNodeID string `json:"relay_node_id"`
|
|
PlacementGeneration int64 `json:"placement_generation"`
|
|
AuthorizationRevision int64 `json:"authorization_revision"`
|
|
Devices []Device `json:"devices"`
|
|
Phones []Phone `json:"phones,omitempty"`
|
|
IssuedAt string `json:"issued_at"`
|
|
ExpiresAt string `json:"expires_at"`
|
|
}
|
|
|
|
type Signed struct {
|
|
Algorithm string `json:"algorithm"`
|
|
KID string `json:"kid"`
|
|
Payload Payload `json:"payload"`
|
|
Signature string `json:"signature"`
|
|
}
|
|
|
|
type Key struct {
|
|
PublicKey ed25519.PublicKey
|
|
NotBefore time.Time
|
|
RetainUntil time.Time
|
|
}
|
|
|
|
type Keyring map[string]Key
|
|
|
|
func parseTime(value string) (time.Time, error) {
|
|
parsed, err := time.Parse(time.RFC3339Nano, value)
|
|
if err != nil {
|
|
return time.Time{}, fmt.Errorf("invalid snapshot time: %w", err)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func validPrefixedHex(value, prefix string, hexLength int) bool {
|
|
if !strings.HasPrefix(value, prefix) || len(value) != len(prefix)+hexLength {
|
|
return false
|
|
}
|
|
for _, char := range strings.TrimPrefix(value, prefix) {
|
|
if !strings.ContainsRune("0123456789abcdef", char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validDigest(value string) bool {
|
|
if len(value) != 64 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if !strings.ContainsRune("0123456789abcdef", char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validPrefixedID(value, prefix string, maxSuffix int) bool {
|
|
if !strings.HasPrefix(value, prefix) || len(value) <= len(prefix) || len(value) > len(prefix)+maxSuffix {
|
|
return false
|
|
}
|
|
for _, char := range strings.TrimPrefix(value, prefix) {
|
|
if !(char >= 'A' && char <= 'Z') && !(char >= 'a' && char <= 'z') &&
|
|
!(char >= '0' && char <= '9') && !strings.ContainsRune("._:-", char) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validBase64URLKey(value string) bool {
|
|
if len(value) != 43 {
|
|
return false
|
|
}
|
|
for _, char := range value {
|
|
if !(char >= 'A' && char <= 'Z') && !(char >= 'a' && char <= 'z') &&
|
|
!(char >= '0' && char <= '9') && char != '_' && char != '-' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validatePayload(payload Payload, now time.Time) (time.Time, time.Time, error) {
|
|
if payload.SnapshotVersion != SnapshotVersion || !validPrefixedHex(payload.TenantID, "tenant_", 32) {
|
|
return time.Time{}, time.Time{}, errors.New("invalid snapshot identity")
|
|
}
|
|
if !strings.HasPrefix(payload.RelayNodeID, "node_") || len(payload.RelayNodeID) > 101 {
|
|
return time.Time{}, time.Time{}, errors.New("invalid relay node")
|
|
}
|
|
if payload.TenantStatus != "active" || payload.PlacementGeneration < 1 || payload.AuthorizationRevision < 0 {
|
|
return time.Time{}, time.Time{}, errors.New("snapshot does not authorize an active placement")
|
|
}
|
|
issuedAt, err := parseTime(payload.IssuedAt)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, err
|
|
}
|
|
expiresAt, err := parseTime(payload.ExpiresAt)
|
|
if err != nil {
|
|
return time.Time{}, time.Time{}, err
|
|
}
|
|
if !expiresAt.After(issuedAt) || expiresAt.Sub(issuedAt) > MaxTTL {
|
|
return time.Time{}, time.Time{}, errors.New("invalid snapshot lifetime")
|
|
}
|
|
if now.Before(issuedAt.Add(-clockSkew)) || !now.Before(expiresAt) {
|
|
return time.Time{}, time.Time{}, errors.New("snapshot is not current")
|
|
}
|
|
previous := ""
|
|
for _, device := range payload.Devices {
|
|
if !validPrefixedHex(device.DeviceID, "host_", 32) || !validDigest(device.CredentialHash) {
|
|
return time.Time{}, time.Time{}, errors.New("invalid snapshot device")
|
|
}
|
|
if strings.TrimSpace(device.Name) == "" || len(device.Name) > 48 ||
|
|
(device.OS != "windows" && device.OS != "linux") ||
|
|
!validBase64URLKey(device.Ed25519Public) || !validBase64URLKey(device.X25519Public) ||
|
|
!validDigest(device.IdentityFingerprint) {
|
|
return time.Time{}, time.Time{}, errors.New("incomplete snapshot device identity")
|
|
}
|
|
if previous != "" && device.DeviceID <= previous {
|
|
return time.Time{}, time.Time{}, errors.New("snapshot devices are not strictly ordered")
|
|
}
|
|
previous = device.DeviceID
|
|
}
|
|
previous = ""
|
|
for _, phone := range payload.Phones {
|
|
if !validPrefixedID(phone.PhoneID, "phone_", 120) || !validDigest(phone.CredentialHash) ||
|
|
strings.TrimSpace(phone.Name) == "" || len(phone.Name) > 48 ||
|
|
!validBase64URLKey(phone.Ed25519Public) || !validBase64URLKey(phone.X25519Public) ||
|
|
!validDigest(phone.IdentityFingerprint) {
|
|
return time.Time{}, time.Time{}, errors.New("invalid snapshot phone")
|
|
}
|
|
if previous != "" && phone.PhoneID <= previous {
|
|
return time.Time{}, time.Time{}, errors.New("snapshot phones are not strictly ordered")
|
|
}
|
|
previous = phone.PhoneID
|
|
}
|
|
return issuedAt, expiresAt, nil
|
|
}
|
|
|
|
func canonicalJSON(value any) ([]byte, error) {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
|
decoder.UseNumber()
|
|
var decoded any
|
|
if err := decoder.Decode(&decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
var output bytes.Buffer
|
|
if err := writeCanonical(&output, decoded); err != nil {
|
|
return nil, err
|
|
}
|
|
return output.Bytes(), nil
|
|
}
|
|
|
|
func writeCanonical(output *bytes.Buffer, value any) error {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
output.WriteString("null")
|
|
case bool:
|
|
output.WriteString(strconv.FormatBool(typed))
|
|
case string:
|
|
encoded, _ := json.Marshal(typed)
|
|
output.Write(encoded)
|
|
case json.Number:
|
|
if _, err := strconv.ParseInt(string(typed), 10, 64); err != nil {
|
|
return fmt.Errorf("snapshot contains unsupported number %q", typed)
|
|
}
|
|
output.WriteString(string(typed))
|
|
case []any:
|
|
output.WriteByte('[')
|
|
for index, item := range typed {
|
|
if index > 0 {
|
|
output.WriteByte(',')
|
|
}
|
|
if err := writeCanonical(output, item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
output.WriteByte(']')
|
|
case map[string]any:
|
|
keys := make([]string, 0, len(typed))
|
|
for key := range typed {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
output.WriteByte('{')
|
|
for index, key := range keys {
|
|
if index > 0 {
|
|
output.WriteByte(',')
|
|
}
|
|
encoded, _ := json.Marshal(key)
|
|
output.Write(encoded)
|
|
output.WriteByte(':')
|
|
if err := writeCanonical(output, typed[key]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
output.WriteByte('}')
|
|
default:
|
|
return fmt.Errorf("unsupported snapshot JSON value %T", value)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func SigningBytes(payload Payload) ([]byte, error) {
|
|
canonical, err := canonicalJSON(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append([]byte(signatureDomain), canonical...), nil
|
|
}
|
|
|
|
func Verify(snapshot Signed, keyring Keyring, now time.Time) (Payload, error) {
|
|
if snapshot.Algorithm != "Ed25519" || snapshot.KID == "" {
|
|
return Payload{}, errors.New("unsupported snapshot signature")
|
|
}
|
|
issuedAt, expiresAt, err := validatePayload(snapshot.Payload, now)
|
|
if err != nil {
|
|
return Payload{}, err
|
|
}
|
|
key, ok := keyring[snapshot.KID]
|
|
if !ok || len(key.PublicKey) != ed25519.PublicKeySize {
|
|
return Payload{}, errors.New("snapshot signing key is not pinned")
|
|
}
|
|
if issuedAt.Before(key.NotBefore) || key.RetainUntil.Before(expiresAt) {
|
|
return Payload{}, errors.New("snapshot signing key lifetime is insufficient")
|
|
}
|
|
signature, err := base64.RawURLEncoding.DecodeString(snapshot.Signature)
|
|
if err != nil || len(signature) != ed25519.SignatureSize {
|
|
return Payload{}, errors.New("invalid snapshot signature encoding")
|
|
}
|
|
message, err := SigningBytes(snapshot.Payload)
|
|
if err != nil {
|
|
return Payload{}, err
|
|
}
|
|
if !ed25519.Verify(key.PublicKey, message, signature) {
|
|
return Payload{}, errors.New("snapshot signature mismatch")
|
|
}
|
|
return snapshot.Payload, nil
|
|
}
|
|
|
|
func PublicKeyFromJWK(raw json.RawMessage) (ed25519.PublicKey, string, error) {
|
|
var jwk struct {
|
|
KTY string `json:"kty"`
|
|
CRV string `json:"crv"`
|
|
X string `json:"x"`
|
|
KID string `json:"kid"`
|
|
}
|
|
if err := json.Unmarshal(raw, &jwk); err != nil || jwk.KTY != "OKP" || jwk.CRV != "Ed25519" {
|
|
return nil, "", errors.New("invalid Ed25519 JWK")
|
|
}
|
|
key, err := base64.RawURLEncoding.DecodeString(jwk.X)
|
|
if err != nil || len(key) != ed25519.PublicKeySize {
|
|
return nil, "", errors.New("invalid Ed25519 JWK key")
|
|
}
|
|
return ed25519.PublicKey(key), jwk.KID, nil
|
|
}
|