~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
306 lines · 9.1 KB Raw History
package trust

import (
	"context"
	"errors"
	"slices"
	"strings"
	"time"

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

const (
	KindClaim       = "claim"
	KindAssociation = "association"
	KindBskyPost    = "bskyPost"
	KindDelvePost   = "delvePost"

	DefaultPostTemplate = "associate {did}"
)

var DefaultKinds = []string{KindClaim, KindAssociation}

type Association struct {
	Kinds           []string
	PostTemplate    string
	MaxDepth        int
	RequireContract bool
}

type associationKey struct {
	claim           bool
	association     bool
	bskyPost        bool
	delvePost       bool
	template        string
	requireContract bool
}

func (a Association) key() associationKey {
	kinds := a.Kinds
	if len(kinds) == 0 {
		kinds = DefaultKinds
	}
	template := a.PostTemplate
	if strings.TrimSpace(template) == "" {
		template = DefaultPostTemplate
	}
	return associationKey{
		claim:           slices.Contains(kinds, KindClaim),
		association:     slices.Contains(kinds, KindAssociation),
		bskyPost:        slices.Contains(kinds, KindBskyPost),
		delvePost:       slices.Contains(kinds, KindDelvePost),
		template:        template,
		requireContract: a.RequireContract,
	}
}

func (k associationKey) allows(kind string) bool {
	switch kind {
	case KindClaim:
		return k.claim
	case KindAssociation:
		return k.association
	case KindBskyPost:
		return k.bskyPost
	case KindDelvePost:
		return k.delvePost
	}
	return false
}

func KindOfCollection(collection string) string {
	switch collection {
	case BadgeBlueKeysNSID:
		return KindClaim
	case AssociationNSID:
		return KindAssociation
	case BskyPostNSID:
		return KindBskyPost
	case DelvePostNSID:
		return KindDelvePost
	}
	return ""
}

func collectionOfKind(kind string) string {
	switch kind {
	case KindClaim:
		return BadgeBlueKeysNSID
	case KindAssociation:
		return AssociationNSID
	case KindBskyPost:
		return BskyPostNSID
	case KindDelvePost:
		return DelvePostNSID
	}
	return ""
}

func Matches(kind string, value map[string]any, authority, subject, service, template string) bool {
	if value == nil || authority == "" || subject == "" || authority == subject {
		return false
	}
	if recordType, _ := value["$type"].(string); recordType != collectionOfKind(kind) {
		return false
	}
	switch kind {
	case KindClaim:
		held, ok := claimOf(value)
		return ok && held.operator == authority && held.subject == subject && associationService(held.service, service)
	case KindAssociation:
		recordSubject, _ := value["subject"].(string)
		recordService, _ := value["service"].(string)
		return recordSubject == subject && associationService(recordService, service)
	case KindBskyPost, KindDelvePost:
		return postMatches(value, subject, template)
	}
	return false
}

func postMatches(value map[string]any, subject, template string) bool {
	if strings.TrimSpace(template) == "" {
		template = DefaultPostTemplate
	}
	text, _ := value["text"].(string)
	if strings.TrimSpace(text) != strings.TrimSpace(strings.ReplaceAll(template, "{did}", subject)) {
		return false
	}
	mentions := 0
	matched := false
	facets, _ := value["facets"].([]any)
	for _, facet := range facets {
		entry, _ := facet.(map[string]any)
		features, _ := entry["features"].([]any)
		for _, feature := range features {
			mention, _ := feature.(map[string]any)
			if featureType, _ := mention["$type"].(string); featureType != MentionFacetType {
				continue
			}
			mentions++
			did, _ := mention["did"].(string)
			matched = did == subject
		}
	}
	return mentions == 1 && matched
}

func strongRefOf(value any) (*atproto.StrongRef, bool) {
	fields, ok := value.(map[string]any)
	if !ok {
		return nil, false
	}
	uri, _ := fields["uri"].(string)
	cid, _ := fields["cid"].(string)
	if !strings.HasPrefix(uri, "at://") || cid == "" {
		return nil, false
	}
	return &atproto.StrongRef{URI: uri, CID: cid}, true
}

func (r *Resolver) pinnedRecord(ctx context.Context, ref atproto.StrongRef) (map[string]any, string, error) {
	record, err := r.client.GetRecordAt(ctx, ref)
	if errors.Is(err, atproto.ErrRecordNotFound) {
		return nil, "the record no longer resolves", nil
	}
	if err != nil {
		return nil, "", err
	}
	if record.CID != ref.CID {
		return nil, "the record cid changed", nil
	}
	return record.Value, "", nil
}

func (r *Resolver) deny(reason string, want claim, meta map[string]any) (bool, error) {
	fields := map[string]any{"did": want.subject, "operator": want.operator, "service": want.service, "reason": reason}
	for key, value := range meta {
		fields[key] = value
	}
	r.log("info", "association denied", fields)
	return false, nil
}

func (r *Resolver) proofHeld(ctx context.Context, want claim, proof atproto.StrongRef, assoc associationKey) (bool, error) {
	repo, collection, _ := atproto.SplitURI(proof.URI)
	kind := KindOfCollection(collection)
	meta := map[string]any{"proof": proof.URI, "cid": proof.CID}
	switch {
	case repo != want.operator:
		return r.deny("the proof is not in the authority repo", want, meta)
	case kind == "" || !assoc.allows(kind):
		return r.deny("the proof kind is not accepted", want, meta)
	}
	value, reason, err := r.pinnedRecord(ctx, proof)
	if err != nil || reason != "" {
		if err != nil {
			return false, err
		}
		return r.deny("proof: "+reason, want, meta)
	}
	if !Matches(kind, value, want.operator, want.subject, want.service, assoc.template) {
		return r.deny("the proof does not match", want, meta)
	}
	if kind == KindBskyPost || kind == KindDelvePost {
		if assoc.requireContract {
			return r.deny("a post carries no contract", want, meta)
		}
		return true, nil
	}
	return r.scopeHeld(ctx, value, want, assoc)
}

func (r *Resolver) associationHeld(ctx context.Context, want claim, assoc associationKey) (bool, error) {
	value, err := r.getRecord(ctx, want.operator, AssociationNSID, ClaimRKey(want.service, want.subject))
	if err != nil || value == nil {
		return false, err
	}
	if !Matches(KindAssociation, value, want.operator, want.subject, want.service, assoc.template) {
		return r.deny("the association record does not match", want, nil)
	}
	return r.scopeHeld(ctx, value, want, assoc)
}

func (r *Resolver) scopeHeld(ctx context.Context, value map[string]any, want claim, assoc associationKey) (bool, error) {
	rawContract, scoped := value["contract"]
	rawExpires, expiring := value["expiresAt"]
	expiresAt, _ := rawExpires.(string)
	switch {
	case scoped && expiresAt == "":
		return r.deny("a contract-scoped association has no expiresAt", want, nil)
	case assoc.requireContract && !scoped:
		return r.deny("the verifier requires a contract-scoped association", want, nil)
	}
	if expiring {
		deadline, err := time.Parse(time.RFC3339Nano, expiresAt)
		if err != nil {
			return r.deny("the expiresAt is not a datetime", want, map[string]any{"expiresAt": expiresAt})
		}
		if !r.now().Before(deadline) {
			return r.deny("the association expired", want, map[string]any{"expiresAt": expiresAt})
		}
	}
	if !scoped {
		return true, nil
	}
	contract, ok := strongRefOf(rawContract)
	if !ok {
		return r.deny("the contract is not a strongRef", want, nil)
	}
	return r.contractHeld(ctx, *contract, want)
}

func (r *Resolver) contractHeld(ctx context.Context, contract atproto.StrongRef, want claim) (bool, error) {
	meta := map[string]any{"contract": contract.URI, "cid": contract.CID}
	repo, collection, _ := atproto.SplitURI(contract.URI)
	if repo != want.operator || collection != market.AcceptNSID {
		return r.deny("the contract is not a market.accept of the authority", want, meta)
	}
	accept, reason, err := r.pinnedRecord(ctx, contract)
	if err != nil || reason != "" {
		if err != nil {
			return false, err
		}
		return r.deny("contract: "+reason, want, meta)
	}
	bid, ok := strongRefOf(accept["bid"])
	if !ok {
		return r.deny("the contract names no bid", want, meta)
	}
	bidder := atproto.RepoFromURI(bid.URI)
	if !strings.HasPrefix(bidder, "did:") {
		return r.deny("the contract's bid is not in a DID repo", want, meta)
	}
	receiptURI := "at://" + bidder + "/" + market.ReceiptNSID + "/" + market.ReceiptRKey(contract.URI, contract.CID)
	receipt, err := r.client.GetRecordAt(ctx, atproto.StrongRef{URI: receiptURI})
	if errors.Is(err, atproto.ErrRecordNotFound) {
		return r.deny("the contract has no receipt", want, meta)
	}
	if err != nil {
		return false, err
	}
	if receipt.URI != receiptURI {
		return r.deny("the receipt uri is not canonical", want, map[string]any{"contract": contract.URI, "receipt": receipt.URI})
	}
	if answered, ok := strongRefOf(receipt.Value["accept"]); !ok || *answered != contract {
		return r.deny("the receipt does not answer the contract", want, meta)
	}
	released, err := r.getRecord(ctx, want.operator, market.ComputeEventsVMDeleteNSID, market.ReleasePayloadRKey(receiptURI))
	if err != nil {
		return false, err
	}
	if released != nil {
		return r.deny("the contract was released", want, meta)
	}
	terminationRKey, _ := market.EventRKeys(market.TerminationKey(receiptURI))
	terminated, err := r.getRecord(ctx, bidder, market.ComputeEventsVMTerminatedNSID, terminationRKey)
	if err != nil {
		return false, err
	}
	if terminated != nil {
		return r.deny("the contract was terminated", want, meta)
	}
	return true, nil
}