mesh-host enrol redeems through the seat mesh-enrolment and refuses an answer the token's key did not sign
The engine dials as enrol.<node> on the pinned certificate, calls redeem with the public halves of its keys and a proof over them (the SDK's RedeemProof, which binds the node and the keys and never the token's secret), asks again while the holder says to, and verifies the vault's answer against the key its token carries before it keeps anything: an answer signed by another key, naming another signer or node, or altered is refused and nothing is kept. A signed one is kept (membership, login, keys), then the node dials as the login it was given, reads its key of node-logins, and calls confirm; nothing is applied until its placement names a module. When the bus does not serve the seat to the token (the controller still holds its lease) the controller's enrolment subject is used as before. The token's shape is mesh-sdk's now: identity keeps its names as aliases. Tests: one added, paid by merging three identity token tests into one (net two fewer). (issue 774, 774/S64) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -235,7 +235,9 @@ func TestTheWireFormatIsExactlyTheseFieldNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestACompleteTokenParses(t *testing.T) {
|
||||
// A complete token parses, a pasted one tolerates whitespace, and one of another version is refused (three tests merged; the
|
||||
// token's shape is mesh-sdk's since ADR 0587 §3).
|
||||
func TestACompleteTokenParsesAPastedOneToleratesWhitespaceAndAnotherVersionIsRefused(t *testing.T) {
|
||||
got, err := ParseToken(completeToken(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -243,12 +245,12 @@ func TestACompleteTokenParses(t *testing.T) {
|
||||
if got.Broker != "192.0.2.10:5671" || len(got.SignerKey()) != ed25519.PublicKeySize {
|
||||
t.Errorf("parsed %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPastedTokenTolerantOfWhitespace(t *testing.T) {
|
||||
if _, err := ParseToken(" " + completeToken(t) + "\n"); err != nil {
|
||||
t.Errorf("a pasted token was refused: %v", err)
|
||||
}
|
||||
if _, err := ParseToken(encodeToken(t, `{"v":99,"broker":"b","fingerprint":"f","secret":"s"}`)); err == nil {
|
||||
t.Fatal("a token from an unknown version was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnIncompleteTokenIsRefusedWholeAndSaysWhatIsMissing(t *testing.T) {
|
||||
@@ -272,12 +274,6 @@ func TestAnIncompleteTokenIsRefusedWholeAndSaysWhatIsMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestATokenFromAnotherVersionIsRefused(t *testing.T) {
|
||||
if _, err := ParseToken(encodeToken(t, `{"v":99,"broker":"b","fingerprint":"f","secret":"s"}`)); err == nil {
|
||||
t.Fatal("a token from an unknown version was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGarbageIsRefused(t *testing.T) {
|
||||
for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8"} {
|
||||
if _, err := ParseToken(bad); err == nil {
|
||||
|
||||
+10
-107
@@ -1,112 +1,15 @@
|
||||
package identity
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
import "git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
// 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"`
|
||||
// Token is what a person carries to a machine that is joining (novox/hq ADR 0004): where the broker is, what certificate to
|
||||
// expect there, whose signature to believe afterwards, and a one-time right to join. Its shape and its reading are mesh-sdk's
|
||||
// (delivery.Token), which the vault that makes it, mesh-cli that opens it and this engine that parses it share (novox/hq ADR
|
||||
// 0587 §3, issue 774); these are the names this package has always had for them.
|
||||
type Token = delivery.Token
|
||||
|
||||
// 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"`
|
||||
// TokenTunnel is the joining machine's side of its first tunnel (delivery.TokenTunnel).
|
||||
type TokenTunnel = delivery.TokenTunnel
|
||||
|
||||
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 controller 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) }
|
||||
// ParseToken reads a token a person pasted (delivery.ParseToken).
|
||||
func ParseToken(encoded string) (Token, error) { return delivery.ParseToken(encoded) }
|
||||
|
||||
Reference in New Issue
Block a user