a73x

internal/joinblob/joinblob.go

Ref:   Size: 3.4 KiB   History

// Package joinblob encodes and decodes the single-paste enrollment token
// ("join blob") an agent uses to enroll: it carries the server's HTTP base URL,
// its QUIC address, a one-shot enrollment token, and the server's TLS cert
// fingerprint for out-of-band pinning. Dependency-free leaf shared by the
// server (encode) and the agent (decode).
package joinblob

import (
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net"
	"net/url"
	"regexp"
	"strings"
)

// Prefix marks a join blob: human-recognizable and greppable.
const Prefix = "eitri_join_"

// version is the current blob format version.
const version = 1

// sha256Hex matches exactly 64 lowercase hex characters. names.IsSHA256Hex is
// the same check, but joinblob is a wire-plane leaf (arch R9) and may import no
// other internal package — so this one stays its own copy on purpose.
var sha256Hex = regexp.MustCompile(`^[a-f0-9]{64}$`)

// Fields is the decoded content of a join blob.
type Fields struct {
	HTTPURL  string // server HTTP base URL, including scheme (e.g. http://host:8080)
	QUICAddr string // server QUIC address, host:port
	Token    string // one-shot enrollment token
	CertFP   string // server cert SHA-256 fingerprint (64 lowercase hex)
}

// wire is the JSON inside the base64url body. Terse keys keep the paste short.
type wire struct {
	V int    `json:"v"`
	H string `json:"h"`
	Q string `json:"q"`
	T string `json:"t"`
	F string `json:"f"`
}

// Encode validates its inputs and returns a prefixed join blob.
func Encode(httpURL, quicAddr, token, certFP string) (string, error) {
	if err := validate(httpURL, quicAddr, token, certFP); err != nil {
		return "", err
	}
	raw, err := json.Marshal(wire{V: version, H: httpURL, Q: quicAddr, T: token, F: certFP})
	if err != nil {
		return "", fmt.Errorf("marshal join blob: %w", err)
	}
	return Prefix + base64.RawURLEncoding.EncodeToString(raw), nil
}

// Decode parses and validates a join blob. Surrounding whitespace/newlines
// (common from copy-paste) are trimmed first.
func Decode(blob string) (Fields, error) {
	rest, ok := strings.CutPrefix(strings.TrimSpace(blob), Prefix)
	if !ok {
		return Fields{}, fmt.Errorf("not a join blob: missing %q prefix", Prefix)
	}
	raw, err := base64.RawURLEncoding.DecodeString(rest)
	if err != nil {
		return Fields{}, fmt.Errorf("join blob is not valid base64url: %w", err)
	}
	var w wire
	if err := json.Unmarshal(raw, &w); err != nil {
		return Fields{}, fmt.Errorf("join blob JSON malformed: %w", err)
	}
	if w.V != version {
		return Fields{}, fmt.Errorf("unsupported join blob version %d (want %d)", w.V, version)
	}
	if err := validate(w.H, w.Q, w.T, w.F); err != nil {
		return Fields{}, err
	}
	return Fields{HTTPURL: w.H, QUICAddr: w.Q, Token: w.T, CertFP: w.F}, nil
}

// validate enforces field rules with a distinct error per field.
func validate(httpURL, quicAddr, token, certFP string) error {
	u, err := url.Parse(httpURL)
	if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || u.Path != "" {
		return fmt.Errorf("http url %q must be an absolute http(s) URL with no path (no trailing slash)", httpURL)
	}
	if _, _, err := net.SplitHostPort(quicAddr); err != nil {
		return fmt.Errorf("quic addr %q must be host:port", quicAddr)
	}
	if token == "" {
		return fmt.Errorf("enrollment token is empty")
	}
	if !sha256Hex.MatchString(certFP) {
		return fmt.Errorf("cert fingerprint must be 64 lowercase hex chars")
	}
	return nil
}