~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
274 lines · 7.8 KB Raw History
package sshtransport

import (
	"cmp"
	"crypto/sha256"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"regexp"
	"strconv"
	"strings"
	"time"

	"github.com/johnandersen777/socialweb-computer-kcp/internal/gatewayhttp"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/httpjson"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
)

const (
	KeysDefaultMaxBodyBytes = int64(8 * 1024)

	KeysDefaultMaxKeys = 100

	MaxKeyNameLength = 128
)

var (
	keyAlgorithmPattern = regexp.MustCompile(`^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256|ecdsa-sha2-nistp384|ecdsa-sha2-nistp521|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)$`)

	keyBase64Pattern = regexp.MustCompile(`^[A-Za-z0-9+/]+=*$`)

	rkeyPattern = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
)

const tidAlphabet = "234567abcdefghijklmnopqrstuvwxyz"

type Authenticator interface {
	Authenticate(r *http.Request) (*gatewayhttp.WebCaller, error)
}

type AgentProvider interface {
	AgentFor(caller *gatewayhttp.WebCaller) gatewayhttp.WebAccountAgent
}

type KeysOptions struct {
	Agents AgentProvider

	Auth Authenticator

	MaxBodyBytes int64

	MaxKeys int

	Log *slog.Logger
}

type Keys struct {
	opts    KeysOptions
	maxBody int64
	maxKeys int
	log     *slog.Logger
}

func NewKeys(opts KeysOptions) (*Keys, error) {
	if opts.Agents == nil {
		return nil, fmt.Errorf("sshtransport: keys API has no account agent provider")
	}
	if opts.Auth == nil {
		return nil, fmt.Errorf("sshtransport: keys API has no authenticator")
	}
	opts.MaxBodyBytes = opt.Positive(opts.MaxBodyBytes, KeysDefaultMaxBodyBytes)
	opts.MaxKeys = opt.Positive(opts.MaxKeys, KeysDefaultMaxKeys)
	if opts.Log == nil {
		opts.Log = slog.Default()
	}
	return &Keys{opts: opts, maxBody: opts.MaxBodyBytes, maxKeys: opts.MaxKeys, log: opts.Log}, nil
}

func (k *Keys) Register(mux *http.ServeMux) {
	mux.HandleFunc("GET /keys", k.list)
	mux.HandleFunc("POST /keys", k.create)
	mux.HandleFunc("DELETE /keys/{rkey}", k.remove)
}

func (k *Keys) Handler() http.Handler {
	mux := http.NewServeMux()
	k.Register(mux)
	return mux
}

func (k *Keys) caller(w http.ResponseWriter, r *http.Request) (*gatewayhttp.WebCaller, bool) {
	who, err := k.opts.Auth.Authenticate(r)
	if err != nil {
		k.log.Error("keys_identify_failed", "err", err)
		httpjson.Write(w, http.StatusServiceUnavailable, map[string]any{"error": "the vault is not answering"})
		return nil, false
	}
	if who == nil {
		httpjson.Write(w, http.StatusUnauthorized, map[string]any{"error": "Unauthorized", "message": "sign in again"})
		return nil, false
	}
	return who, true
}

type keyView struct {
	Rkey  string `json:"rkey"`
	Name  string `json:"name"`
	KeyID string `json:"keyId"`
}

func (k *Keys) list(w http.ResponseWriter, r *http.Request) {
	who, ok := k.caller(w, r)
	if !ok {
		return
	}
	found, err := k.opts.Agents.AgentFor(who).ListRecords(r.Context(), who.DID, BadgeBlueKeysNSID, k.maxKeys)
	if err != nil {
		k.failed(w, who.DID, "list keys", err)
		return
	}
	keys := make([]keyView, 0, len(found))
	for _, record := range found {
		challenge, _ := record.Value["challenge"].(string)
		service, _ := record.Value["service"].(string)
		if challenge != who.DID || service != RequesterAssociateService {
			continue
		}
		rkey := record.URI
		if slash := strings.LastIndex(rkey, "/"); slash >= 0 {
			rkey = rkey[slash+1:]
		}
		name, _ := record.Value["name"].(string)
		name = cmp.Or(name, "unnamed")
		keys = append(keys, keyView{Rkey: rkey, Name: name, KeyID: stringOf(record.Value["keyId"])})
	}
	w.Header().Set("Cache-Control", "no-store")
	httpjson.Write(w, http.StatusOK, map[string]any{"keys": keys})
}

type parsedPublicKey struct {
	algo    string
	key     string
	comment string
}

func parsePublicKey(text string) (parsedPublicKey, bool) {
	parts := strings.Fields(strings.TrimSpace(text))
	if len(parts) < 2 {
		return parsedPublicKey{}, false
	}
	if !keyAlgorithmPattern.MatchString(parts[0]) {
		return parsedPublicKey{}, false
	}
	if !keyBase64Pattern.MatchString(parts[1]) {
		return parsedPublicKey{}, false
	}
	return parsedPublicKey{algo: parts[0], key: parts[1], comment: strings.Join(parts[2:], " ")}, true
}

func (k *Keys) create(w http.ResponseWriter, r *http.Request) {
	who, ok := k.caller(w, r)
	if !ok {
		return
	}
	if declared, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64); err == nil && declared > k.maxBody {
		httpjson.Write(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "body too large"})
		return
	}
	raw, err := io.ReadAll(io.LimitReader(r.Body, k.maxBody+1))
	if err != nil || int64(len(raw)) > k.maxBody {
		httpjson.Write(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "body too large"})
		return
	}
	var body struct {
		PublicKey any `json:"publicKey"`
		Name      any `json:"name"`
	}
	_ = json.Unmarshal(raw, &body)
	line, _ := body.PublicKey.(string)
	parsed, valid := parsePublicKey(line)
	if !valid {
		httpjson.Write(w, http.StatusBadRequest, map[string]any{"error": "that does not look like an OpenSSH public key"})
		return
	}
	name := parsed.comment
	if provided, ok := body.Name.(string); ok && strings.TrimSpace(provided) != "" {
		name = strings.TrimSpace(provided)
	}
	name = cmp.Or(name, "key")
	if runes := []rune(name); len(runes) > MaxKeyNameLength {
		name = string(runes[:MaxKeyNameLength])
	}
	rkey := keyRkey(who.DID, parsed)
	record := map[string]any{
		"$type":     BadgeBlueKeysNSID,
		"keyId":     parsed.algo + " " + parsed.key,
		"name":      name,
		"challenge": who.DID,
		"service":   RequesterAssociateService,
		"createdAt": time.Now().UTC().Format(time.RFC3339Nano),
	}
	agent := k.opts.Agents.AgentFor(who)
	if _, err := agent.CreateRecord(r.Context(), who.DID, BadgeBlueKeysNSID, rkey, record); err != nil {
		if !recordAlreadyExists(err) {
			k.failed(w, who.DID, "register the key", err)
			return
		}
		writes := []gatewayhttp.Write{{Action: "update", Collection: BadgeBlueKeysNSID, Rkey: rkey, Record: record}}
		if err := agent.ApplyWrites(r.Context(), who.DID, writes); err != nil {
			k.failed(w, who.DID, "register the key", err)
			return
		}
	}
	k.log.Info("keys_registered", "did", who.DID, "rkey", rkey)
	httpjson.Write(w, http.StatusOK, map[string]any{"rkey": rkey})
}

func (k *Keys) remove(w http.ResponseWriter, r *http.Request) {
	who, ok := k.caller(w, r)
	if !ok {
		return
	}
	rkey := r.PathValue("rkey")
	if len(rkey) > 512 || !rkeyPattern.MatchString(rkey) {
		httpjson.Write(w, http.StatusBadRequest, map[string]any{"error": "invalid key id"})
		return
	}
	writes := []gatewayhttp.Write{{Action: "delete", Collection: BadgeBlueKeysNSID, Rkey: rkey}}
	if err := k.opts.Agents.AgentFor(who).ApplyWrites(r.Context(), who.DID, writes); err != nil {
		k.failed(w, who.DID, "remove the key", err)
		return
	}
	k.log.Info("keys_removed", "did", who.DID, "rkey", rkey)
	httpjson.Write(w, http.StatusOK, map[string]any{"rkey": rkey})
}

func (k *Keys) failed(w http.ResponseWriter, did, action string, err error) {
	k.log.Error("keys_failed", "did", did, "action", action, "err", err)
	httpjson.Write(w, http.StatusBadGateway, map[string]any{"error": "could not " + action + ": " + err.Error()})
}

func keyRkey(did string, parsed parsedPublicKey) string {
	digest := sha256.Sum256([]byte("sshtransport/badge-blue-key/v1\x00" + did + "\x00" + parsed.algo + " " + parsed.key))
	value := uint64(0)
	for _, b := range digest[:8] {
		value = value<<8 | uint64(b)
	}
	out := make([]byte, 13)
	for i := len(out) - 1; i >= 0; i-- {
		out[i] = tidAlphabet[value&31]
		value >>= 5
	}
	return string(out)
}

func recordAlreadyExists(err error) bool {
	text := strings.ToLower(err.Error())
	return strings.Contains(text, "recordalreadyexists") || strings.Contains(text, "record already exists")
}

func stringOf(value any) string {
	switch v := value.(type) {
	case string:
		return v
	case nil:
		return ""
	case json.Number:
		return v.String()
	default:
		return fmt.Sprint(v)
	}
}