133 lines
5.5 KiB
Go
133 lines
5.5 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"`
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
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))
|
|
}
|