Files
mesh-controller/internal/token/token.go
T
jschoubben ec78fafc82 A token can be issued for a machine's tunnel key, and it joins through the tunnel
token issue --overlay-key records the key the machine made, binds the
token to it, gives the machine its address and makes it a peer of the
hub, pushing the hub before the token is shown. The token carries the
hub's tunnel and the bus at its address on the private network, and
enrolment refuses any other key (novox/hq ADR 0169). Tokens without a
key enrol as before until the bus is closed. Also a token verb.
2026-10-02 18:02:48 +02:00

166 lines
7.1 KiB
Go

// Package token is the thing a person carries to a machine that is joining.
//
// novox/hq ADR 0004: it carries four things, and it is the only thing a joining node needs —
// where the broker is, what certificate to expect there, whose signature to believe afterwards,
// and a one-time right to join.
//
// Its authenticity comes from the channel it travelled, not from anything the node can check
// afterwards: trust on first use, with the first use moved out of band. Which makes the token
// security-critical, because it carries the pin. Tampering with it substitutes the mesh — still
// better than the alternative, where there is nothing to tamper with and a node trusts the first
// answer it gets.
package token
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// Token is everything a joining node needs, assembled by whatever holds all of it.
//
// Two contexts contribute: `inventory` owns the node record and mints the secret, `identity` owns
// the signing key. Neither reads the other's store — the process holding both grants asks each
// for its part (novox/hq ADR 0008).
type Token struct {
Version int `json:"v"`
// Node is what the mesh calls this machine.
//
// Not a secret and not the node's to choose — the record was created before the token was
// issued, and the broker account the node must authenticate as is named after it. So the node
// has to know it *before* the mesh can tell it anything, which is why it travels here.
//
// It was not here at first, and enrolment then needed a separate flag while its own help said
// the token was the only thing required. The failure that produced was a connection refused
// with an empty username, which says nothing about the cause.
Node string `json:"node,omitempty"`
// Broker is an address and not a name. There is no resolution before joining, which is why
// this is the one place in the mesh where an address is carried deliberately.
Broker string `json:"broker,omitempty"`
// Fingerprint is the broker certificate's, checked before anything is sent.
Fingerprint string `json:"fingerprint,omitempty"`
// Signer is the control plane's public signing key: whose declarations to believe.
Signer []byte `json:"signer,omitempty"`
// Secret is the one-time right to join. Useless once used, useless after it expires.
Secret string `json:"secret"`
// Adopted says the node joins adopted (novox/hq ADR 0100): the host checks, before enrolling,
// that it speaks the firewall found on the machine, because an adopted node keeps that firewall
// in force. Absent for a converged node, so a converged token is byte for byte what it was.
Adopted bool `json:"adopted,omitempty"`
// Tunnel is the one peer a joining machine needs, when the token was issued for its tunnel key
// (novox/hq ADR 0169). The machine brings its tunnel up from this alone and reaches the bus over
// it, at an address on the private network — so the bus is never open to the internet. Absent
// on a token issued without a key, which then reads byte for byte as before.
Tunnel *Tunnel `json:"tunnel,omitempty"`
}
// Tunnel is the joining machine's side of its first tunnel: its own address and the hub to reach.
type Tunnel struct {
// Key is the public half of the key the machine made itself, which this token was issued for.
// The private half never left the machine (novox/hq ADR 0004).
Key string `json:"key"`
// Address is the machine's own address on the private network, with its prefix.
Address string `json:"address"`
// Range is the private network, routed through the hub until the machine is told more.
Range string `json:"range"`
// HubKey and HubEndpoint are the hub's tunnel key and where it is dialled.
HubKey string `json:"hub_key"`
HubEndpoint string `json:"hub_endpoint"`
}
// Missing names the parts that are not filled in.
//
// Returned as a list rather than a bool, because "this token cannot be used" is not an answer
// anybody can act on and "it has no broker address" is. Everything here is required: a token
// missing the fingerprint cannot verify what it connects to, and one missing the signer makes
// the control plane's authority transitive through the broker — which ADR 0004 rejects, because
// a compromised broker could then forge declarations, and that is the whole machine.
func (t Token) Missing() []string {
var missing []string
if strings.TrimSpace(t.Node) == "" {
missing = append(missing, "the node's name — the broker account is named after it")
}
if strings.TrimSpace(t.Broker) == "" {
missing = append(missing, "the broker's address — there is nowhere to connect to")
}
if strings.TrimSpace(t.Fingerprint) == "" {
missing = append(missing,
"the broker certificate's fingerprint — nothing to check the connection against")
}
if len(t.Signer) != ed25519.PublicKeySize {
missing = append(missing,
"the control plane's signing key — declarations could not be told from forgeries")
}
if strings.TrimSpace(t.Secret) == "" {
missing = append(missing, "the one-time secret — nothing to present")
}
if t.Tunnel != nil {
for _, part := range []struct{ value, says string }{
{t.Tunnel.Key, "the machine's own tunnel key — the hub would not know it"},
{t.Tunnel.Address, "the machine's address on the private network"},
{t.Tunnel.Range, "the private network's range — nothing to route through the hub"},
{t.Tunnel.HubKey, "the hub's tunnel key — nothing to dial"},
{t.Tunnel.HubEndpoint, "where the hub's tunnel is dialled"},
} {
if strings.TrimSpace(part.value) == "" {
missing = append(missing, part.says)
}
}
}
return missing
}
// Complete reports whether this token could actually be used to join.
func (t Token) Complete() bool { return len(t.Missing()) == 0 }
// Encode renders the token as one line a person can carry.
//
// Base64 of JSON: self-describing, so a token from an older control plane says what it is rather
// than being misread by a newer one; and one line, because it is copied by hand between a
// terminal and a machine.
func (t Token) Encode() (string, error) {
t.Version = 1
raw, err := json.Marshal(t)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
// Decode reads a token a person pasted.
func Decode(encoded string) (Token, error) {
raw, err := base64.RawURLEncoding.DecodeString(strings.TrimSpace(encoded))
if err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
var t Token
if err := json.Unmarshal(raw, &t); err != nil {
return Token{}, fmt.Errorf("this is not a token: %w", err)
}
if t.Version != 1 {
return Token{}, fmt.Errorf(
"this token says it is version %d, and this host understands version 1. It was made "+
"by a different control plane than the one this was built against", t.Version)
}
return t, nil
}
// base64Decode and encodeBase64 exist for the tests, which construct a token by hand to prove a
// version this build does not understand is refused. Kept beside the encoding they mirror.
func base64Decode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(strings.TrimSpace(s))
}
func encodeBase64(s string) string {
return base64.RawURLEncoding.EncodeToString([]byte(s))
}