Files
mesh-host/internal/identity/token.go
T
jschoubben b9fc3afc14 A machine makes its tunnel key first and joins through the tunnel
nox-mesh-host key makes the tunnel key, or reads the one made, and
prints its public half for the token to be issued for. enrol with a
token that carries a tunnel takes that key, refuses another, writes
mesh0 with the hub as its one peer and starts it, then reaches the bus
over it (novox/hq ADR 0169). Tokens without a tunnel enrol as before.
2026-10-02 18:02:49 +02:00

113 lines
4.5 KiB
Go

package identity
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
// Token is what a person carries to a machine that is joining.
//
// novox/hq ADR 0004 — four things: where the broker is, what certificate to expect there, whose
// signature to believe afterwards, and a one-time right to join.
//
// THIS IS A WIRE FORMAT SHARED WITH THE CONTROL PLANE, which writes it. The two definitions are
// separate on purpose — the host requires nothing present and does not import the control plane —
// so they are held together by a test on each side asserting the exact field names rather than by
// a shared type. If a field is renamed here and not there, that test fails on both sides.
type Token struct {
Version int `json:"v"`
// Node is what the mesh calls this machine, and it arrives here because the node cannot work
// it out. The broker account it must authenticate as is named after it, so it has to be known
// before the mesh can say anything — and without it enrolment is a connection refused with an
// empty username, which names nothing.
Node string `json:"node,omitempty"`
Broker string `json:"broker,omitempty"`
Fingerprint string `json:"fingerprint,omitempty"`
Signer []byte `json:"signer,omitempty"`
Secret string `json:"secret"`
// Adopted says this node joins adopted (novox/hq ADR 0100). The host checks it speaks the
// firewall found here before enrolling, because an adopted node keeps that firewall in force.
// Absent for a converged node.
Adopted bool `json:"adopted,omitempty"`
// Tunnel is this machine's first tunnel, when the token was issued for the key it made with
// `key` (novox/hq ADR 0169): its own address and the hub to reach. It brings the tunnel up from
// this alone and reaches the bus over it, so the bus never has to face the internet.
Tunnel *TokenTunnel `json:"tunnel,omitempty"`
}
// TokenTunnel is the joining machine's side of its first tunnel. Field names are the wire format
// the control plane writes.
type TokenTunnel struct {
Key string `json:"key"`
Address string `json:"address"`
Range string `json:"range"`
HubKey string `json:"hub_key"`
HubEndpoint string `json:"hub_endpoint"`
}
// ParseToken reads a token a person pasted.
//
// Every refusal here says *this is not a token* rather than *this is the wrong token*. The
// difference matters once there is a mesh: a host must tell "this is not from the mesh I joined"
// apart from "this is malformed" (novox/hq ADR 0004), and the first is a signature check later,
// not a parse failure here.
func ParseToken(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", t.Version)
}
var missing []string
if strings.TrimSpace(t.Broker) == "" {
missing = append(missing, "the broker's address")
}
if strings.TrimSpace(t.Fingerprint) == "" {
missing = append(missing, "the broker certificate's fingerprint")
}
if len(t.Signer) != ed25519.PublicKeySize {
missing = append(missing, "the control plane's signing key")
}
if strings.TrimSpace(t.Secret) == "" {
missing = append(missing, "the one-time secret")
}
if tt := t.Tunnel; tt != nil {
for _, part := range []struct{ value, says string }{
{tt.Key, "the tunnel key it was issued for"}, {tt.Address, "this machine's address"},
{tt.Range, "the private network's range"}, {tt.HubKey, "the hub's tunnel key"},
{tt.HubEndpoint, "where the hub's tunnel is dialled"},
} {
if strings.TrimSpace(part.value) == "" {
missing = append(missing, part.says)
}
}
}
if len(missing) > 0 {
// Refused whole rather than used partially. A token missing the fingerprint would have
// this node connect to whatever answers at that address, and one missing the signing key
// would leave it unable to tell a declaration from a forgery — so an incomplete token is
// not a reduced capability, it is an unsafe one.
return Token{}, fmt.Errorf(
"this token is missing %s, so it cannot be used to join anything",
strings.Join(missing, ", "))
}
return t, nil
}
// SignerKey is the control plane's public signing key, as a key.
func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) }