penguin/homekey-go

homekey-go

internal/provision/store.go

raw ยท 7357 bytes

// SPDX-License-Identifier: Apache-2.0
package provision

import (
	"bytes"
	"crypto/elliptic"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
)

type Endpoint struct {
	PublicKey     string `json:"public_key"`
	KeyType       byte   `json:"key_type"`
	KeyState      byte   `json:"key_state"`
	EnrolledAt    int64  `json:"enrolled_at"`
	PersistentKey string `json:"persistent_key,omitempty"` // populated by future NFC authentication
}
type Issuer struct {
	PublicKey string              `json:"public_key"`
	Endpoints map[string]Endpoint `json:"endpoints"`
}
type Credentials struct {
	ReaderPrivateKey string            `json:"reader_private_key,omitempty"`
	ReaderIdentifier string            `json:"reader_identifier,omitempty"`
	Issuers          map[string]Issuer `json:"issuers"`
}
type Data struct {
	Version int               `json:"version"`
	HAP     map[string][]byte `json:"hap"`
	HomeKey Credentials       `json:"homekey"`
}

// Store implements hap.Store. Pairings and Home Key credentials are committed
// together, so unpairing revokes the corresponding issuer in the same update.
// One process must own a store; the CLI takes an OS file lock before opening it.
type Store struct {
	mu   sync.Mutex
	path string
	data Data
}

func Open(dir string) (*Store, error) {
	if err := os.MkdirAll(dir, 0700); err != nil {
		return nil, err
	}
	if err := os.Chmod(dir, 0700); err != nil {
		return nil, err
	}
	s := &Store{path: filepath.Join(dir, "state.json"), data: Data{Version: 1, HAP: map[string][]byte{}, HomeKey: Credentials{Issuers: map[string]Issuer{}}}}
	b, err := os.ReadFile(s.path)
	if errors.Is(err, os.ErrNotExist) {
		return s, nil
	}
	if err != nil {
		return nil, err
	}
	d := json.NewDecoder(bytes.NewReader(b))
	d.DisallowUnknownFields()
	if err := d.Decode(&s.data); err != nil {
		return nil, fmt.Errorf("invalid state file; refusing to reset: %w", err)
	}
	var extra any
	if err := d.Decode(&extra); err != io.EOF {
		return nil, fmt.Errorf("trailing content in state file")
	}
	if s.data.Version != 1 || s.data.HAP == nil || s.data.HomeKey.Issuers == nil {
		return nil, fmt.Errorf("invalid state schema")
	}
	if err := validateCredentials(s.data.HomeKey); err != nil {
		return nil, err
	}
	if err := reconcile(&s.data); err != nil {
		return nil, err
	}
	if err := os.Chmod(s.path, 0600); err != nil {
		return nil, err
	}
	return s, nil
}

func keyID(b []byte) string {
	h := sha256.New()
	h.Write([]byte("key-identifier"))
	h.Write(b)
	return hex.EncodeToString(h.Sum(nil)[:8])
}

func reconcile(d *Data) error {
	current := make(map[string]Issuer)
	for name, raw := range d.HAP {
		if !strings.HasSuffix(name, ".pairing") {
			continue
		}
		var p struct {
			Name       string
			PublicKey  []byte
			Permission byte
		}
		if err := json.Unmarshal(raw, &p); err != nil || len(p.PublicKey) != 32 || p.Name == "" {
			return fmt.Errorf("invalid stored HAP pairing")
		}
		id := keyID(p.PublicKey)
		issuer, ok := d.HomeKey.Issuers[id]
		if !ok {
			issuer = Issuer{PublicKey: hex.EncodeToString(p.PublicKey), Endpoints: map[string]Endpoint{}}
		}
		current[id] = issuer
	}
	if len(current) > Capacity {
		return fmt.Errorf("issuer capacity exceeded")
	}
	d.HomeKey.Issuers = current
	if len(current) == 0 {
		d.HomeKey.ReaderPrivateKey = ""
		d.HomeKey.ReaderIdentifier = ""
	}
	return nil
}

func validateCredentials(c Credentials) error {
	if c.ReaderPrivateKey != "" {
		p, e := hex.DecodeString(c.ReaderPrivateKey)
		if e != nil || !validScalar(p) {
			return fmt.Errorf("invalid saved reader key")
		}
		id, e := hex.DecodeString(c.ReaderIdentifier)
		if e != nil || len(id) != 8 {
			return fmt.Errorf("invalid saved reader identifier")
		}
	} else if c.ReaderIdentifier != "" {
		return fmt.Errorf("reader identifier without key")
	}
	for id, i := range c.Issuers {
		p, e := hex.DecodeString(i.PublicKey)
		if e != nil || len(p) != 32 || id != keyID(p) || i.Endpoints == nil {
			return fmt.Errorf("invalid saved issuer")
		}
		for eid, ep := range i.Endpoints {
			p, e := hex.DecodeString(ep.PublicKey)
			if e != nil || len(p) != 65 {
				return fmt.Errorf("invalid saved endpoint")
			}
			x, _ := elliptic.Unmarshal(elliptic.P256(), p)
			if x == nil || eid != endpointID(p) || ep.KeyType != 2 || ep.KeyState > 1 {
				return fmt.Errorf("invalid saved endpoint")
			}
			if ep.PersistentKey != "" {
				k, e := hex.DecodeString(ep.PersistentKey)
				if e != nil || len(k) != 32 {
					return fmt.Errorf("invalid saved persistent key")
				}
			}
		}
	}
	return nil
}

func (s *Store) update(fn func(*Data) error) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	// Clone before mutation. Failed validation or persistence leaves live state intact.
	old, err := json.Marshal(s.data)
	if err != nil {
		return err
	}
	var next Data
	if err = json.Unmarshal(old, &next); err != nil {
		return err
	}
	if err = fn(&next); err != nil {
		return err
	}
	b, err := json.Marshal(next)
	if err != nil {
		return err
	}
	if bytes.Equal(b, old) {
		return nil
	}
	if err = atomicWrite(s.path, b); err != nil {
		return err
	}
	s.data = next
	return nil
}

func atomicWrite(path string, b []byte) error {
	f, err := os.CreateTemp(filepath.Dir(path), ".state-*")
	if err != nil {
		return err
	}
	defer os.Remove(f.Name())
	if _, err = f.Write(b); err != nil {
		f.Close()
		return err
	}
	if err = f.Sync(); err != nil {
		f.Close()
		return err
	}
	if err = f.Close(); err != nil {
		return err
	}
	if err = os.Rename(f.Name(), path); err != nil {
		return err
	}
	// Best effort directory fsync: rename is the commit point. Reporting a later
	// failure as rollback would leave memory inconsistent with the committed file.
	if dir, e := os.Open(filepath.Dir(path)); e == nil {
		_ = dir.Sync()
		_ = dir.Close()
	}
	return nil
}
func (s *Store) Set(key string, value []byte) error {
	return s.update(func(d *Data) error {
		d.HAP[key] = append([]byte(nil), value...)
		if strings.HasSuffix(key, ".pairing") {
			return reconcile(d)
		}
		return nil
	})
}
func (s *Store) Get(key string) ([]byte, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	v, ok := s.data.HAP[key]
	if !ok {
		return nil, os.ErrNotExist
	}
	return append([]byte(nil), v...), nil
}
func (s *Store) Delete(key string) error {
	return s.update(func(d *Data) error {
		if _, ok := d.HAP[key]; !ok {
			return os.ErrNotExist
		}
		delete(d.HAP, key)
		if strings.HasSuffix(key, ".pairing") {
			return reconcile(d)
		}
		return nil
	})
}
func (s *Store) KeysWithSuffix(suffix string) ([]string, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	var keys []string
	for key := range s.data.HAP {
		if strings.HasSuffix(key, suffix) {
			keys = append(keys, key)
		}
	}
	sort.Strings(keys)
	return keys, nil
}

type Summary struct {
	PairedControllers int  `json:"paired_controllers"`
	ReaderProvisioned bool `json:"reader_provisioned"`
	Issuers           int  `json:"issuers"`
	Endpoints         int  `json:"endpoints"`
}

func (s *Store) Summary() Summary {
	s.mu.Lock()
	defer s.mu.Unlock()
	v := Summary{ReaderProvisioned: s.data.HomeKey.ReaderPrivateKey != "", Issuers: len(s.data.HomeKey.Issuers)}
	for k := range s.data.HAP {
		if strings.HasSuffix(k, ".pairing") {
			v.PairedControllers++
		}
	}
	for _, i := range s.data.HomeKey.Issuers {
		v.Endpoints += len(i.Endpoints)
	}
	return v
}