~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
561 lines · 18.0 KB Raw History
package guestidentity

import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"regexp"
	"slices"
	"sort"
	"strings"
	"time"

	"github.com/bluesky-social/indigo/atproto/syntax"
	apierrors "k8s.io/apimachinery/pkg/api/errors"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/market"
)

const MaxVMHostKeys = 8

var (
	guestNameRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)

	hostKeyTypeRE = regexp.MustCompile(`^[a-z0-9][a-z0-9.@-]*$`)

	hostKeyBlobRE = regexp.MustCompile(`^[A-Za-z0-9+/]+={0,2}$`)

	networkTagRE = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$`)

	dtlsFingerprintRE = regexp.MustCompile(`^sha-256 [0-9A-F]{2}(:[0-9A-F]{2}){31}$`)
)

var (
	ErrUnknownGuest = errors.New("computevm: no live ComputeVM holds this guest")

	ErrUnknownAccept = errors.New("computevm: the accept is not this guest's")

	ErrReportRefused = errors.New("computevm: the guest report was refused")
)

type VMStore interface {
	Get(ctx context.Context, name string) (*v1alpha1.ComputeVM, error)

	RecordGuest(ctx context.Context, name string, report v1alpha1.ComputeVMGuestReport) error
}

func GuestReportOf(o *v1alpha1.ComputeVM) *v1alpha1.ComputeVMGuestReport {
	if o == nil || o.Status.Market == nil {
		return nil
	}
	return o.Status.Market.Guest
}

func Ended(o *v1alpha1.ComputeVM) bool {
	if o == nil {
		return false
	}
	return o.Status.Phase == v1alpha1.ComputeVMPhaseLost || o.Status.Phase == v1alpha1.ComputeVMPhaseFailed
}

func AcceptURIOf(o *v1alpha1.ComputeVM) string {
	switch {
	case o == nil:
		return ""
	case o.Spec.Accept != nil && o.Spec.Accept.URI != "":
		return o.Spec.Accept.URI
	case o.Spec.Contract != nil:
		return o.Spec.Contract.Accept.URI
	}
	return ""
}

func AcceptedAtOf(o *v1alpha1.ComputeVM) time.Time {
	if o == nil {
		return time.Time{}
	}
	if c := o.Spec.Contract; c != nil && c.AcceptedAt != "" {
		if at, err := time.Parse(time.RFC3339, c.AcceptedAt); err == nil {
			return at
		}
	}
	return o.CreationTimestamp.Time
}

func Releasing(o *v1alpha1.ComputeVM) bool {
	if o == nil {
		return false
	}
	return o.DeletionTimestamp != nil || o.Status.Phase == v1alpha1.ComputeVMPhaseReleasing
}

type guestRecord struct {
	store VMStore

	log *slog.Logger

	now func() time.Time
}

func (g *guestRecord) Live(ctx context.Context, name string) (*v1alpha1.ComputeVM, error) {
	if g == nil || g.store == nil {
		return nil, errors.New("computevm: the guest recorder has no ComputeVM store")
	}
	if len(name) > 201 || !guestNameRE.MatchString(name) {
		return nil, ErrUnknownGuest
	}
	o, err := g.store.Get(ctx, name)
	if apierrors.IsNotFound(err) {
		return nil, ErrUnknownGuest
	}
	if err != nil {
		return nil, err
	}
	if o == nil {
		return nil, ErrUnknownGuest
	}
	if Ended(o) || Releasing(o) {
		return nil, ErrUnknownGuest
	}
	return o, nil
}

func (g *guestRecord) OnNetwork(ctx context.Context, name string, report OnNetworkReport) error {
	acceptURI, acceptCID := strings.TrimSpace(report.AcceptURI), strings.TrimSpace(report.AcceptCID)
	address, tag := strings.TrimSpace(report.Address), strings.TrimSpace(report.Tag)
	createdAt := report.CreatedAt
	switch {
	case acceptURI == "" || acceptCID == "":
		return fmt.Errorf("%w: the report names no accept", ErrReportRefused)
	case len(acceptURI) > 2048 || len(acceptCID) > 128:
		return fmt.Errorf("%w: the accept ref is too long", ErrReportRefused)
	case address == "" || len(address) > 4096:
		return fmt.Errorf("%w: the report names no address", ErrReportRefused)
	case len(createdAt) > 64:
		return fmt.Errorf("%w: the report's createdAt is too long", ErrReportRefused)
	case tag != "" && !networkTagRE.MatchString(tag):
		return fmt.Errorf("%w: the tag %q is not a DNS label of at most 32 characters", ErrReportRefused, tag)
	}
	if err := checkNetworkKind(strings.TrimSpace(report.Kind), tag, address); err != nil {
		return err
	}
	o, err := g.Live(ctx, name)
	if err != nil {
		return err
	}
	if c := o.Spec.Contract; c != nil && c.Accept.URI != "" && (c.Accept.URI != acceptURI || c.Accept.CID != acceptCID) {
		return ErrUnknownAccept
	}
	held := GuestReportOf(o)
	reportedAt := strings.TrimSpace(createdAt)
	if reportedAt == "" {
		reportedAt = g.now().UTC().Format(time.RFC3339)
	}
	next := v1alpha1.ComputeVMGuestReport{}
	if held != nil {
		next = *held.DeepCopy()
	}
	if tag == "" {
		if held != nil && held.Address == address && held.ReportedAt != "" {
			return nil
		}
		next.Address, next.ReportedAt = address, reportedAt
	} else {
		i := slices.IndexFunc(next.Networks, func(n v1alpha1.ComputeVMGuestNetwork) bool { return n.Tag == tag })
		switch {
		case i >= 0 && next.Networks[i].Address == address:
			return nil
		case i >= 0:
			next.Networks[i] = v1alpha1.ComputeVMGuestNetwork{Tag: tag, Address: address, ReportedAt: reportedAt}
		default:
			next.Networks = append(next.Networks, v1alpha1.ComputeVMGuestNetwork{Tag: tag, Address: address, ReportedAt: reportedAt})
		}
	}
	if err := g.store.RecordGuest(ctx, name, next); err != nil {
		if apierrors.IsNotFound(err) {
			return ErrUnknownGuest
		}
		return err
	}
	g.log.Info("the guest's on-network address is recorded", "computevm", name, "tag", tag, "address", address, "reportedAt", reportedAt)
	return nil
}

func checkNetworkKind(kind, tag, address string) error {
	if kind == "" {
		kind = market.OnNetworkKindIroh
		if tag != "" {
			kind = market.OnNetworkKindHTTPS
		}
	}
	switch kind {
	case market.OnNetworkKindIroh:
		if tag != "" {
			return fmt.Errorf("%w: an iroh address carries no tag", ErrReportRefused)
		}
	case market.OnNetworkKindHTTPS:
		if tag == "" || len(address) > 2048 || !strings.HasPrefix(address, "https://") {
			return fmt.Errorf("%w: an https network is tagged and its address is an https URL of at most 2048 bytes", ErrReportRefused)
		}
	case market.OnNetworkKindWebRTC:
		if tag == "" || !dtlsFingerprintRE.MatchString(address) {
			return fmt.Errorf("%w: a webrtc network is tagged and its address is a DTLS fingerprint \"sha-256 AB:CD:...\"", ErrReportRefused)
		}
	default:
		return fmt.Errorf("%w: the network kind %q is not iroh, https or webrtc", ErrReportRefused, kind)
	}
	return nil
}

func NetworkKind(tag, address string) string {
	switch {
	case tag == "":
		return market.OnNetworkKindIroh
	case dtlsFingerprintRE.MatchString(address):
		return market.OnNetworkKindWebRTC
	default:
		return market.OnNetworkKindHTTPS
	}
}

func (g *guestRecord) Accept(ctx context.Context, name, acceptURI, acceptCID string) error {
	o, err := g.Live(ctx, name)
	if err != nil {
		return err
	}
	c := o.Spec.Contract
	if c == nil || c.Accept.URI == "" || c.Accept.URI != acceptURI || c.Accept.CID != acceptCID {
		return ErrUnknownAccept
	}
	if c.Receipt.URI == "" || c.Receipt.CID == "" {
		return fmt.Errorf("computevm: the ComputeVM %s holds no receipt yet", name)
	}
	return nil
}

func (g *guestRecord) HostKeys(ctx context.Context, name string, keys []v1alpha1.ComputeVMHostKey) error {
	o, err := g.Live(ctx, name)
	if err != nil {
		return err
	}
	checked, err := CheckHostKeys(keys)
	if err != nil {
		return fmt.Errorf("%w: %v", ErrReportRefused, err)
	}
	if err := RequireEd25519(checked); err != nil {
		return fmt.Errorf("%w: %v", ErrReportRefused, err)
	}
	held := GuestReportOf(o)
	if held != nil && len(held.HostKeys) > 0 {
		if SameHostKeys(held.HostKeys, checked) {
			return nil
		}
		return fmt.Errorf("%w: host keys are already reported for this guest", ErrReportRefused)
	}
	next := v1alpha1.ComputeVMGuestReport{HostKeys: checked}
	if held != nil {
		next.Address, next.ReportedAt = held.Address, held.ReportedAt
		next.Networks = append([]v1alpha1.ComputeVMGuestNetwork(nil), held.Networks...)
	}
	if err := g.store.RecordGuest(ctx, name, next); err != nil {
		if apierrors.IsNotFound(err) {
			return ErrUnknownGuest
		}
		return err
	}
	g.log.Info("the guest's bound host keys are recorded", "computevm", name, "types", keyTypes(checked))
	return nil
}

func ParseHostKey(line string) (v1alpha1.ComputeVMHostKey, error) {
	fields := strings.Fields(line)
	if len(fields) < 2 {
		return v1alpha1.ComputeVMHostKey{}, errors.New("computevm: the host key is not an authorized_keys line")
	}
	keys, err := CheckHostKeys([]v1alpha1.ComputeVMHostKey{{Type: fields[0], PublicKey: fields[1]}})
	if err != nil {
		return v1alpha1.ComputeVMHostKey{}, err
	}
	return keys[0], nil
}

func CheckHostKeys(keys []v1alpha1.ComputeVMHostKey) ([]v1alpha1.ComputeVMHostKey, error) {
	if len(keys) == 0 {
		return nil, errors.New("computevm: no host keys")
	}
	if len(keys) > MaxVMHostKeys {
		return nil, fmt.Errorf("computevm: more than %d host keys", MaxVMHostKeys)
	}
	out := make([]v1alpha1.ComputeVMHostKey, 0, len(keys))
	types := map[string]bool{}
	for _, key := range keys {
		if len(key.Type) > 64 || !hostKeyTypeRE.MatchString(key.Type) {
			return nil, fmt.Errorf("computevm: host key type %q is malformed", key.Type)
		}
		if len(key.PublicKey) < 16 || len(key.PublicKey) > 4096 || !hostKeyBlobRE.MatchString(key.PublicKey) {
			return nil, fmt.Errorf("computevm: the %s public key is not a base64 key blob", key.Type)
		}
		blobType, err := keyBlobType(key.PublicKey)
		if err != nil {
			return nil, err
		}
		if blobType != key.Type {
			return nil, fmt.Errorf("computevm: the %s key blob names type %s", key.Type, blobType)
		}
		if types[key.Type] {
			return nil, fmt.Errorf("computevm: %s is reported twice", key.Type)
		}
		types[key.Type] = true
		fingerprint, err := keyFingerprint(key.PublicKey)
		if err != nil {
			return nil, err
		}
		if key.Fingerprint != "" && key.Fingerprint != fingerprint {
			return nil, fmt.Errorf("computevm: the %s fingerprint does not match its key", key.Type)
		}
		out = append(out, v1alpha1.ComputeVMHostKey{Type: key.Type, PublicKey: key.PublicKey, Fingerprint: fingerprint})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Type < out[j].Type })
	return out, nil
}

func RequireEd25519(keys []v1alpha1.ComputeVMHostKey) error {
	for _, key := range keys {
		if key.Type == "ssh-ed25519" {
			return nil
		}
	}
	return errors.New("computevm: the report carries no ssh-ed25519 key")
}

func SameHostKeys(a, b []v1alpha1.ComputeVMHostKey) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i].Type != b[i].Type || a[i].PublicKey != b[i].PublicKey {
			return false
		}
	}
	return true
}

func keyFingerprint(publicKey string) (string, error) {
	blob, err := decodeKeyBlob(publicKey)
	if err != nil {
		return "", err
	}
	sum := sha256.Sum256(blob)
	return "SHA256:" + base64.RawStdEncoding.EncodeToString(sum[:]), nil
}

func keyBlobType(publicKey string) (string, error) {
	blob, err := decodeKeyBlob(publicKey)
	if err != nil {
		return "", err
	}
	if len(blob) < 4 {
		return "", errors.New("computevm: the key blob is shorter than its type")
	}
	length := int(binary.BigEndian.Uint32(blob[:4]))
	if length == 0 || length > 64 || len(blob) < 4+length {
		return "", errors.New("computevm: the key blob has no length-prefixed type")
	}
	return string(blob[4 : 4+length]), nil
}

func decodeKeyBlob(publicKey string) ([]byte, error) {
	blob, err := base64.StdEncoding.DecodeString(publicKey)
	if err != nil {
		blob, err = base64.RawStdEncoding.DecodeString(strings.TrimRight(publicKey, "="))
	}
	if err != nil {
		return nil, errors.New("computevm: the public key is not base64")
	}
	return blob, nil
}

func keyTypes(keys []v1alpha1.ComputeVMHostKey) []string {
	out := make([]string, len(keys))
	for i := range keys {
		out[i] = keys[i].Type
	}
	return out
}

const (
	MaxGuestEventTag = 32

	MaxGuestKeyKind = 32

	MaxGuestKey = 256

	MaxGuestSignalKind = 16

	MaxGuestCiphertext = 16384

	MaxGuestEventBytes = 24 << 10

	MaxGuestSecretsEventBytes = market.MaxGuestSecretsCiphertext + 4<<10

	MaxGuestSecretsVia = 16

	ageArmorHeader = "-----BEGIN AGE ENCRYPTED FILE-----"
)

func CheckGuestEvent(record map[string]any) (string, error) {
	nsid, _ := record["$type"].(string)
	raw, err := json.Marshal(record)
	limit := MaxGuestEventBytes
	if nsid == market.ComputeEventsGuestSecretsNSID {
		limit = MaxGuestSecretsEventBytes
	}
	switch {
	case err != nil:
		return "", fmt.Errorf("%w: the guest event is not JSON: %v", ErrReportRefused, err)
	case len(raw) > limit:
		return "", fmt.Errorf("%w: the guest event is larger than %d bytes", ErrReportRefused, limit)
	}
	switch nsid {
	case market.ComputeEventsGuestPublicKeyNSID:
		r, err := market.ParseComputeEventsGuestPublicKey(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if err := checkGuestEventFields(r.Tag, r.Kind, MaxGuestKeyKind, r.CreatedAt); err != nil {
			return "", err
		}
		if r.Key == "" || len(r.Key) > MaxGuestKey || !printableASCII(r.Key) {
			return "", fmt.Errorf("%w: key is one printable token of at most %d bytes", ErrReportRefused, MaxGuestKey)
		}
	case market.ComputeEventsGuestSignalWebRTCNSID:
		r, err := market.ParseComputeEventsGuestSignalWebRTC(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if err := checkGuestEventFields(r.Tag, r.Kind, MaxGuestSignalKind, r.CreatedAt); err != nil {
			return "", err
		}
		if len(r.Ciphertext) > MaxGuestCiphertext || !strings.HasPrefix(strings.TrimSpace(r.Ciphertext), ageArmorHeader) {
			return "", fmt.Errorf("%w: ciphertext is age armored ciphertext of at most %d bytes", ErrReportRefused, MaxGuestCiphertext)
		}
	case market.ComputeEventsGuestSecretsNSID:
		r, err := market.ParseComputeEventsGuestSecrets(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
		if len(r.Ciphertext) > market.MaxGuestSecretsCiphertext || !strings.HasPrefix(strings.TrimSpace(r.Ciphertext), ageArmorHeader) {
			return "", fmt.Errorf("%w: ciphertext is age armored ciphertext of at most %d bytes", ErrReportRefused, market.MaxGuestSecretsCiphertext)
		}
	case market.ComputeEventsGuestSecretsRequestedNSID:
		r, err := market.ParseComputeEventsGuestSecretsRequested(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
	case market.ComputeEventsGuestRequesterDIDNSID:
		r, err := market.ParseComputeEventsGuestRequesterDID(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
		if err := market.ValidGuestRequesterDID(r.DID, ""); err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
	case market.ComputeEventsGuestR2StartNSID:
		r, err := market.ParseComputeEventsGuestR2Start(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
		if err := market.ValidGuestRequesterName(r.Name); err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
	case market.ComputeEventsGuestR2ReleaseNSID:
		r, err := market.ParseComputeEventsGuestR2Release(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
	case market.ComputeEventsGuestR2ReleasedNSID:
		r, err := market.ParseComputeEventsGuestR2Released(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
		if len(r.Message) > 512 || !printableASCII(r.Message) && r.Message != "" {
			return "", fmt.Errorf("%w: message is printable ASCII of at most 512 bytes", ErrReportRefused)
		}
	case market.ComputeEventsGuestSecretsUpdatedNSID:
		r, err := market.ParseComputeEventsGuestSecretsUpdated(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
	case market.ComputeEventsGuestAgentStartNSID:
		r, err := market.ParseComputeEventsGuestAgentStart(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
	case market.ComputeEventsGuestSecretsAppliedNSID:
		r, err := market.ParseComputeEventsGuestSecretsApplied(record)
		if err != nil {
			return "", fmt.Errorf("%w: %v", ErrReportRefused, err)
		}
		if _, err := syntax.ParseDatetime(r.CreatedAt); err != nil {
			return "", fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
		}
		if r.Via == "" || len(r.Via) > MaxGuestSecretsVia || !printableASCII(r.Via) || r.Count < 0 {
			return "", fmt.Errorf("%w: via is printable ASCII of 1 to %d bytes and count is not negative", ErrReportRefused, MaxGuestSecretsVia)
		}
	default:
		return "", fmt.Errorf("%w: %q is not a guest event", ErrReportRefused, nsid)
	}
	return nsid, nil
}

func checkGuestEventFields(tag, kind string, maxKind int, createdAt string) error {
	switch {
	case tag == "" || len(tag) > MaxGuestEventTag || !printableASCII(tag):
		return fmt.Errorf("%w: tag is printable ASCII of 1 to %d bytes", ErrReportRefused, MaxGuestEventTag)
	case kind == "" || len(kind) > maxKind || !printableASCII(kind):
		return fmt.Errorf("%w: kind is printable ASCII of 1 to %d bytes", ErrReportRefused, maxKind)
	}
	if _, err := syntax.ParseDatetime(createdAt); err != nil {
		return fmt.Errorf("%w: createdAt is not an atproto datetime", ErrReportRefused)
	}
	return nil
}

func printableASCII(value string) bool {
	for i := 0; i < len(value); i++ {
		if value[i] < 0x21 || value[i] > 0x7e {
			return false
		}
	}
	return true
}