266 lines
8.5 KiB
Go
266 lines
8.5 KiB
Go
// Package identity persists the Windows Node identity without plaintext secrets.
|
|
package identity
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/netip"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
|
|
"remlink/internal/config"
|
|
"remlink/internal/model"
|
|
)
|
|
|
|
const diskVersion = 1
|
|
|
|
// Protector is implemented by the Windows DPAPI adapter.
|
|
type Protector interface {
|
|
Protect([]byte) ([]byte, error)
|
|
Unprotect([]byte) ([]byte, error)
|
|
}
|
|
|
|
// Identity is the durable Node identity used by Bootstrap and reconciliation.
|
|
type Identity struct {
|
|
NodeID string
|
|
NodeType model.NodeType
|
|
NodeName string
|
|
PrivateKey wgtypes.Key
|
|
NodeToken string
|
|
ServerURL string
|
|
ConfigVersion uint64
|
|
OwnedRoutes []string
|
|
}
|
|
|
|
// PublicKey derives the shareable WireGuard key from the protected private key.
|
|
func (i Identity) PublicKey() string { return i.PrivateKey.PublicKey().String() }
|
|
|
|
// New generates a UUID and independent WireGuard key pair.
|
|
func New(nodeType model.NodeType, nodeName, serverURL string) (Identity, error) {
|
|
privateKey, err := wgtypes.GeneratePrivateKey()
|
|
if err != nil {
|
|
return Identity{}, fmt.Errorf("generate Node WireGuard key: %w", err)
|
|
}
|
|
identity := Identity{
|
|
NodeID: uuid.NewString(), NodeType: nodeType, NodeName: strings.TrimSpace(nodeName),
|
|
PrivateKey: privateKey, ServerURL: strings.TrimRight(strings.TrimSpace(serverURL), "/"),
|
|
}
|
|
if err := identity.Validate(false); err != nil {
|
|
return Identity{}, err
|
|
}
|
|
return identity, nil
|
|
}
|
|
|
|
// Validate checks persistent identity invariants. requireToken is true after enrollment.
|
|
func (i Identity) Validate(requireToken bool) error {
|
|
if _, err := uuid.Parse(i.NodeID); err != nil {
|
|
return fmt.Errorf("Node ID must be a UUID: %w", err)
|
|
}
|
|
if !i.NodeType.Valid() {
|
|
return fmt.Errorf("invalid Node type %q", i.NodeType)
|
|
}
|
|
if i.NodeName == "" || len(i.NodeName) > 128 {
|
|
return errors.New("Node name must contain 1 to 128 bytes")
|
|
}
|
|
if err := config.ValidateServerURL(i.ServerURL); err != nil {
|
|
return fmt.Errorf("invalid Server URL: %w", err)
|
|
}
|
|
if i.PrivateKey == (wgtypes.Key{}) {
|
|
return errors.New("WireGuard private key must not be zero")
|
|
}
|
|
if requireToken && i.NodeToken == "" {
|
|
return errors.New("Node Token is required after registration")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Store saves one role-specific identity JSON file.
|
|
type Store struct {
|
|
path string
|
|
protector Protector
|
|
}
|
|
|
|
func NewStore(path string, protector Protector) (*Store, error) {
|
|
if strings.TrimSpace(path) == "" || protector == nil {
|
|
return nil, errors.New("identity path and Protector are required")
|
|
}
|
|
return &Store{path: path, protector: protector}, nil
|
|
}
|
|
|
|
// Path returns the exact identity artifact location.
|
|
func (s *Store) Path() string { return s.path }
|
|
|
|
// LoadOwnedRoutes implements RouteManager's persistence boundary.
|
|
func (s *Store) LoadOwnedRoutes() ([]netip.Prefix, error) {
|
|
current, err := s.Load()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
routes := make([]netip.Prefix, 0, len(current.OwnedRoutes))
|
|
for _, raw := range current.OwnedRoutes {
|
|
prefix, err := netip.ParsePrefix(raw)
|
|
if err != nil || !prefix.Addr().Is4() || prefix != prefix.Masked() {
|
|
return nil, fmt.Errorf("invalid owned Remote route %q", raw)
|
|
}
|
|
routes = append(routes, prefix)
|
|
}
|
|
return routes, nil
|
|
}
|
|
|
|
// SaveOwnedRoutes atomically updates RouteManager ownership metadata.
|
|
func (s *Store) SaveOwnedRoutes(routes []netip.Prefix) error {
|
|
current, err := s.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
current.OwnedRoutes = make([]string, 0, len(routes))
|
|
for _, prefix := range routes {
|
|
if !prefix.Addr().Is4() || prefix != prefix.Masked() {
|
|
return fmt.Errorf("owned Remote route must be canonical IPv4: %s", prefix)
|
|
}
|
|
current.OwnedRoutes = append(current.OwnedRoutes, prefix.String())
|
|
}
|
|
return s.Save(current)
|
|
}
|
|
|
|
// Load decrypts and validates a persisted identity.
|
|
func (s *Store) Load() (Identity, error) {
|
|
raw, err := os.ReadFile(s.path)
|
|
if err != nil {
|
|
return Identity{}, fmt.Errorf("read Node identity: %w", err)
|
|
}
|
|
var stored diskIdentity
|
|
decoder := json.NewDecoder(strings.NewReader(string(raw)))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&stored); err != nil {
|
|
return Identity{}, fmt.Errorf("decode Node identity: %w", err)
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
|
return Identity{}, errors.New("Node identity must contain one JSON object")
|
|
}
|
|
if stored.Version != diskVersion {
|
|
return Identity{}, fmt.Errorf("unsupported Node identity version %d", stored.Version)
|
|
}
|
|
privateKeyRaw, err := s.unprotect(stored.ProtectedPrivateKey)
|
|
if err != nil {
|
|
return Identity{}, fmt.Errorf("decrypt WireGuard private key: %w", err)
|
|
}
|
|
privateKey, err := wgtypes.ParseKey(string(privateKeyRaw))
|
|
clear(privateKeyRaw)
|
|
if err != nil {
|
|
return Identity{}, fmt.Errorf("parse decrypted WireGuard private key: %w", err)
|
|
}
|
|
nodeToken := ""
|
|
if stored.ProtectedNodeToken != "" {
|
|
nodeTokenRaw, err := s.unprotect(stored.ProtectedNodeToken)
|
|
if err != nil {
|
|
return Identity{}, fmt.Errorf("decrypt Node Token: %w", err)
|
|
}
|
|
nodeToken = string(nodeTokenRaw)
|
|
clear(nodeTokenRaw)
|
|
}
|
|
identity := Identity{
|
|
NodeID: stored.NodeID, NodeType: stored.NodeType, NodeName: stored.NodeName,
|
|
PrivateKey: privateKey, NodeToken: nodeToken, ServerURL: stored.ServerURL,
|
|
ConfigVersion: stored.ConfigVersion, OwnedRoutes: append([]string(nil), stored.OwnedRoutes...),
|
|
}
|
|
if err := identity.Validate(false); err != nil {
|
|
return Identity{}, fmt.Errorf("validate Node identity: %w", err)
|
|
}
|
|
return identity, nil
|
|
}
|
|
|
|
// Save protects all secrets and atomically replaces the identity artifact.
|
|
func (s *Store) Save(identity Identity) error {
|
|
if err := identity.Validate(false); err != nil {
|
|
return err
|
|
}
|
|
privateKeyRaw := []byte(identity.PrivateKey.String())
|
|
protectedPrivateKey, err := s.protect(privateKeyRaw)
|
|
clear(privateKeyRaw)
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt WireGuard private key: %w", err)
|
|
}
|
|
protectedNodeToken := ""
|
|
if identity.NodeToken != "" {
|
|
protectedNodeToken, err = s.protect([]byte(identity.NodeToken))
|
|
if err != nil {
|
|
return fmt.Errorf("encrypt Node Token: %w", err)
|
|
}
|
|
}
|
|
stored := diskIdentity{
|
|
Version: diskVersion, NodeID: identity.NodeID, NodeType: identity.NodeType,
|
|
NodeName: identity.NodeName, ProtectedPrivateKey: protectedPrivateKey,
|
|
ProtectedNodeToken: protectedNodeToken, ServerURL: identity.ServerURL,
|
|
ConfigVersion: identity.ConfigVersion, OwnedRoutes: identity.OwnedRoutes,
|
|
}
|
|
encoded, err := json.MarshalIndent(stored, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode Node identity: %w", err)
|
|
}
|
|
encoded = append(encoded, '\n')
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return fmt.Errorf("create Node identity directory: %w", err)
|
|
}
|
|
temporary, err := os.CreateTemp(filepath.Dir(s.path), ".identity-*.tmp")
|
|
if err != nil {
|
|
return fmt.Errorf("create temporary Node identity: %w", err)
|
|
}
|
|
temporaryPath := temporary.Name()
|
|
defer os.Remove(temporaryPath)
|
|
if err := temporary.Chmod(0o600); err != nil {
|
|
temporary.Close()
|
|
return fmt.Errorf("restrict temporary Node identity: %w", err)
|
|
}
|
|
if _, err := temporary.Write(encoded); err != nil {
|
|
temporary.Close()
|
|
return fmt.Errorf("write temporary Node identity: %w", err)
|
|
}
|
|
if err := temporary.Sync(); err != nil {
|
|
temporary.Close()
|
|
return fmt.Errorf("flush temporary Node identity: %w", err)
|
|
}
|
|
if err := temporary.Close(); err != nil {
|
|
return fmt.Errorf("close temporary Node identity: %w", err)
|
|
}
|
|
if err := replaceFile(temporaryPath, s.path); err != nil {
|
|
return fmt.Errorf("replace Node identity: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) protect(value []byte) (string, error) {
|
|
protected, err := s.protector.Protect(value)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(protected), nil
|
|
}
|
|
|
|
func (s *Store) unprotect(value string) ([]byte, error) {
|
|
protected, err := base64.StdEncoding.DecodeString(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.protector.Unprotect(protected)
|
|
}
|
|
|
|
type diskIdentity struct {
|
|
Version int `json:"version"`
|
|
NodeID string `json:"node_id"`
|
|
NodeType model.NodeType `json:"node_type"`
|
|
NodeName string `json:"node_name"`
|
|
ProtectedPrivateKey string `json:"protected_wg_private_key"`
|
|
ProtectedNodeToken string `json:"protected_node_token,omitempty"`
|
|
ServerURL string `json:"server_url"`
|
|
ConfigVersion uint64 `json:"config_version"`
|
|
OwnedRoutes []string `json:"owned_routes,omitempty"`
|
|
}
|