Files
NODEDC_MISSION_CORE/apps/node-agent/internal/node/pairing.go
T

235 lines
7.1 KiB
Go

package node
import (
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
type Invitation struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
ExpiresAt int64 `json:"expires_at"`
SecretHash string `json:"secret_hash,omitempty"`
}
type CoreBinding struct {
BindingID string `json:"binding_id"`
CoreID string `json:"core_id"`
CoreName string `json:"core_name"`
Endpoint string `json:"endpoint"`
CAPEM string `json:"ca_pem"`
ClientPEM string `json:"client_pem"`
Receipt string `json:"receipt,omitempty"`
OfferHash string `json:"offer_hash,omitempty"`
ExpiresAt int64 `json:"expires_at"`
}
type PairState struct {
Schema string `json:"schema"`
Phase string `json:"phase"`
Invitation *Invitation `json:"invitation,omitempty"`
Binding *CoreBinding `json:"binding,omitempty"`
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
Sensors *Sensors
DeviceEnrollment *DeviceEnrollment
mu sync.Mutex
path string
store *Store
state PairState
now func() time.Time
inventory func() Inventory
version string
lastSeen int64
connection string
listenError string
failureWindow int64
failures int
clients map[string]*http.Client
}
func OpenPairing(store *Store, dir, version string, inventory func() Inventory) (*Pairing, error) {
p := &Pairing{store: store, path: filepath.Join(dir, "core-binding.json"), now: time.Now, inventory: inventory, version: version, connection: "offline", clients: make(map[string]*http.Client), state: PairState{Schema: PairSchema, Phase: "unpaired"}}
data, e := os.ReadFile(p.path)
if os.IsNotExist(e) {
return p, nil
}
if e != nil {
return nil, e
}
info, e := os.Lstat(p.path)
if e != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || len(data) > 65536 {
return nil, errors.New("invalid Core binding permissions or size")
}
if json.Unmarshal(data, &p.state) != nil || p.state.Schema != PairSchema {
return nil, errors.New("invalid Core binding; recovery required")
}
switch p.state.Phase {
case "unpaired", "inviting", "pending", "paired", "revoked":
default:
return nil, errors.New("unknown Core binding state")
}
if (p.state.Phase == "pending" || p.state.Phase == "paired") && p.state.Binding == nil {
return nil, errors.New("incomplete Core binding")
}
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil {
return nil, errors.New("incomplete invitation")
}
if (p.state.Phase == "paired" || p.state.Phase == "revoked") && p.state.Invitation != nil {
next := p.state
next.Invitation = nil
if e := p.save(next); e != nil {
return nil, e
}
}
return p, nil
}
func savePrivateJSON(path string, value any) error {
data, e := json.Marshal(value)
if e != nil {
return e
}
f, e := os.CreateTemp(filepath.Dir(path), ".binding-*")
if e != nil {
return e
}
defer os.Remove(f.Name())
if _, e = f.Write(data); e != nil {
f.Close()
return e
}
if e = f.Sync(); e != nil {
f.Close()
return e
}
if e = f.Close(); e != nil {
return e
}
if e = os.Rename(f.Name(), path); e != nil {
return e
}
dir, e := os.Open(filepath.Dir(path))
if e != nil {
return e
}
defer dir.Close()
return dir.Sync()
}
func (p *Pairing) save(next PairState) error {
if e := savePrivateJSON(p.path, next); e != nil {
return e
}
p.state = next
return nil
}
func digest(value string) string { h := sha256.Sum256([]byte(value)); return hex.EncodeToString(h[:]) }
func (p *Pairing) expire() error {
if (p.state.Phase == "inviting" && p.state.Invitation.ExpiresAt <= p.now().Unix()) || (p.state.Phase == "pending" && p.state.Binding.ExpiresAt <= p.now().Unix()) {
return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: p.state.Revocations})
}
return nil
}
func (p *Pairing) status() map[string]any {
p.mu.Lock()
defer p.mu.Unlock()
_ = p.expire()
id, _ := p.store.Public()
out := map[string]any{"phase": p.state.Phase, "node_id": id, "connection": p.connection, "last_seen": p.lastSeen, "notice": p.listenError, "addresses": p.addresses(), "pending_revocations": len(p.state.Revocations)}
if i := p.state.Invitation; i != nil {
out["invitation"] = map[string]any{"id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt}
}
if b := p.state.Binding; b != nil {
out["binding"] = map[string]any{"binding_id": b.BindingID, "core_id": b.CoreID, "core_name": b.CoreName, "endpoint": b.Endpoint}
}
return out
}
func (p *Pairing) addresses() []string {
result := []string{}
for _, network := range p.inventory().Networks {
if !network.Up {
continue
}
for _, alias := range network.Addresses {
address := alias
for i, c := range address {
if c == '/' {
address = address[:i]
break
}
}
if PrivateAddress(address) {
result = append(result, address)
}
}
}
return result
}
func (p *Pairing) invite(address string) (map[string]any, error) {
p.mu.Lock()
defer p.mu.Unlock()
if e := p.expire(); e != nil {
return nil, e
}
if p.state.Phase == "paired" || p.state.Phase == "pending" {
return nil, errors.New("Сначала отмените текущую привязку")
}
found := false
for _, a := range p.addresses() {
if a == address {
found = true
}
}
if !found {
return nil, errors.New("Выберите доступный частный адрес этого БК")
}
secret := token()
i := &Invitation{ID: token(), Endpoint: "https://" + address + ":" + PairPort, ExpiresAt: p.now().Add(10 * time.Minute).Unix(), SecretHash: digest(secret)}
if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil {
return nil, e
}
p.connection = "offline"
p.lastSeen = 0
id, _ := p.store.Public()
code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret})
return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil
}
func (p *Pairing) cancel() error {
p.mu.Lock()
defer p.mu.Unlock()
revocations := append([]CoreBinding(nil), p.state.Revocations...)
if p.state.Binding != nil {
if len(revocations) >= 8 {
return errors.New("Дождитесь доставки предыдущих отзывов доверия")
}
revocations = append(revocations, *p.state.Binding)
}
p.connection = "offline"
p.listenError = ""
p.lastSeen = 0
return p.save(PairState{Schema: PairSchema, Phase: "unpaired", Revocations: revocations})
}
func (p *Pairing) checkInvitation(id, secret string) bool {
now := p.now().Unix()
if now-p.failureWindow >= 60 {
p.failureWindow = now
p.failures = 0
}
if p.failures >= 32 {
return false
}
i := p.state.Invitation
if i == nil || i.ID != id || i.ExpiresAt <= now || subtle.ConstantTimeCompare([]byte(i.SecretHash), []byte(digest(secret))) != 1 {
p.failures++
return false
}
return true
}