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
}