feat: establish NekoNest Cloud control and relay
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package authsnapshot
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func signedFixture(t *testing.T, now time.Time) (Signed, Keyring) {
|
||||
t.Helper()
|
||||
public, private, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encodedKey := base64.RawURLEncoding.EncodeToString(make([]byte, 32))
|
||||
payload := Payload{
|
||||
SnapshotVersion: 1,
|
||||
TenantID: "tenant_0123456789abcdef0123456789abcdef",
|
||||
TenantStatus: "active",
|
||||
HomeRegion: "cn-east",
|
||||
RelayNodeID: "node_east_1",
|
||||
PlacementGeneration: 2,
|
||||
AuthorizationRevision: 7,
|
||||
Devices: []Device{{
|
||||
DeviceID: "host_0123456789abcdef0123456789abcdef",
|
||||
CredentialHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
IdentityFingerprint: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||||
Ed25519Public: encodedKey,
|
||||
X25519Public: encodedKey,
|
||||
Name: "Laptop",
|
||||
OS: "windows",
|
||||
}},
|
||||
Phones: []Phone{{
|
||||
PhoneID: "phone_0123456789abcdef",
|
||||
CredentialHash: "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
||||
IdentityFingerprint: "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
|
||||
Ed25519Public: encodedKey,
|
||||
X25519Public: encodedKey,
|
||||
Name: "Phone",
|
||||
}},
|
||||
IssuedAt: now.Format(time.RFC3339Nano),
|
||||
ExpiresAt: now.Add(MaxTTL).Format(time.RFC3339Nano),
|
||||
}
|
||||
message, err := SigningBytes(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Signed{
|
||||
Algorithm: "Ed25519",
|
||||
KID: "key-1",
|
||||
Payload: payload,
|
||||
Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, message)),
|
||||
}, Keyring{"key-1": {PublicKey: public, NotBefore: now.Add(-time.Hour), RetainUntil: now.Add(MaxTTL)}}
|
||||
}
|
||||
|
||||
func TestVerifyAcceptsPinnedCurrentSnapshot(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
|
||||
snapshot, keys := signedFixture(t, now)
|
||||
payload, err := Verify(snapshot, keys, now)
|
||||
if err != nil || payload.TenantID != snapshot.Payload.TenantID {
|
||||
t.Fatalf("payload=%#v err=%v", payload, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamperingExpiryAndInsufficientKeyOverlap(t *testing.T) {
|
||||
now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC)
|
||||
snapshot, keys := signedFixture(t, now)
|
||||
tampered := snapshot
|
||||
tampered.Payload.AuthorizationRevision++
|
||||
if _, err := Verify(tampered, keys, now); err == nil {
|
||||
t.Fatal("tampered snapshot accepted")
|
||||
}
|
||||
if _, err := Verify(snapshot, keys, now.Add(MaxTTL)); err == nil {
|
||||
t.Fatal("expired snapshot accepted")
|
||||
}
|
||||
key := keys["key-1"]
|
||||
key.RetainUntil = now.Add(MaxTTL - time.Second)
|
||||
keys["key-1"] = key
|
||||
if _, err := Verify(snapshot, keys, now); err == nil {
|
||||
t.Fatal("key without full TTL overlap accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user