diff --git a/go/delivery/gate_test.go b/go/delivery/gate_test.go index bd4e7bc..0927506 100644 --- a/go/delivery/gate_test.go +++ b/go/delivery/gate_test.go @@ -356,17 +356,16 @@ func TestGateSame(t *testing.T) { // 1201/AC6 } } -func TestGateBuckets(t *testing.T) { +// today's keys: names of letters, digits, - / and _ key exactly as before the escape. +func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2 + // And the buckets and their history are the decided layout (once TestGateBuckets; merged to pay for issue 774's + // token test). if MergesBucket != "mesh-delivery-gate_merges" || AsksBucket != "mesh-delivery-gate_asks" || OwedBucket != "mesh-delivery-gate_owed" || AnswersBucket != "mesh-delivery-teller_answers" || MergesHistory != 10 || AsksHistory != 5 || OwedHistory != 5 || AnswersHistory != 5 || TurnsBucket != "mesh-delivery-gate_turns" || TurnsHistory != 5 { t.Error("a bucket or history differs from the decided layout") } -} - -// today's keys: names of letters, digits, - / and _ key exactly as before the escape. -func TestKeysOfPlainNamesAreUnchanged(t *testing.T) { // 1323/AC2 hh, _ := HeadsHash(gateHeads()) for _, name := range []string{"feat/x", "fix/1323-escape", "feat/delivery_source-keys", "main", "A/B_c-9"} { if k, err := MergesKey(name); err != nil || k != name { diff --git a/go/delivery/token.go b/go/delivery/token.go new file mode 100644 index 0000000..9a376da --- /dev/null +++ b/go/delivery/token.go @@ -0,0 +1,118 @@ +package delivery + +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): where the bus is, what certificate +// to expect there, whose signature to believe afterwards, and a one-time right to join; and, when it was issued for the +// machine's tunnel key, the machine's first tunnel (ADR 0169). +// +// Moved here from the node-engine (mesh-host internal/identity, which keeps an alias) so the vault, which makes the +// token from the lease write on (novox/hq ADR 0587 §3, issue 774), mesh-cli, which opens and prints it, and the +// node-engine, which parses it, read one shape. The field names are the wire format the controller's `token issue` +// writes, unchanged, so a token of either issuer reads the same; the tunnel stays in it (the lead's settlement of +// ADR 0587 §3, 2026-11-02). +type Token struct { + Version int `json:"v"` + + // Node is what the mesh calls this machine: the bus user it dials as is named after it, so it must be known before + // the mesh can say anything. + 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); 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 (novox/hq ADR 0169): its own + // address and the hub to reach, so it reaches the bus over the private network and the bus never faces the internet. + Tunnel *TokenTunnel `json:"tunnel,omitempty"` +} + +// TokenTunnel is the joining machine's side of its first tunnel, in the field names 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 says *this is not a token* rather than *this is the wrong +// token*: telling "not from the mesh I joined" apart from "malformed" is a signature check later (ADR 0004). A token +// missing a part is refused whole, naming every part, never used partially: without the fingerprint a node connects +// to whatever answers, without the signing key it cannot tell a declaration from a forgery. +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) + } + if missing := t.Missing(); len(missing) > 0 { + return Token{}, fmt.Errorf( + "this token is missing %s, so it cannot be used to join anything", + strings.Join(missing, ", ")) + } + return t, nil +} + +// Missing names every part of t a joining machine needs and t lacks. +func (t Token) Missing() []string { + 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) + } + } + } + return missing +} + +// Encode is t as one line a person carries: base64 (URL alphabet, no padding) of its JSON at version 1, as the +// controller's `token issue` writes it. Refused when a part is missing. +func (t Token) Encode() (string, error) { + t.Version = 1 + if missing := t.Missing(); len(missing) > 0 { + return "", fmt.Errorf("delivery: the token is missing %s", strings.Join(missing, ", ")) + } + raw, err := json.Marshal(t) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// SignerKey is the signing key the token carries, as a key: the controller's before the lease write, the vault's after. +func (t Token) SignerKey() ed25519.PublicKey { return ed25519.PublicKey(t.Signer) } diff --git a/go/delivery/token_test.go b/go/delivery/token_test.go new file mode 100644 index 0000000..5e7d73d --- /dev/null +++ b/go/delivery/token_test.go @@ -0,0 +1,53 @@ +package delivery + +import ( + "encoding/hex" + "reflect" + "strings" + "testing" +) + +// controllerToken is a token made by the controller's own `token issue` code: mesh-control origin/main 5c281ad9, +// internal/token Token.Encode, run on 2026-11-02 for node anchor, adopted, with a first tunnel, its signer the ed25519 +// key of the seed 00 01 .. 1f. Copied, never written by hand. +const controllerToken = "eyJ2IjoxLCJub2RlIjoiYW5jaG9yIiwiYnJva2VyIjoiMTkyLjAuMi4xMDo0MjIyIiwiZmluZ2VycHJpbnQiOiJzaGEyNTY6YWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYmFiYWJhYiIsInNpZ25lciI6IkE2RUh2L1BPRUw0ZGNOMFk1MHZBbVdmazFqQ2JwUTFmSGR5R1pCSlZNYmc9Iiwic2VjcmV0IjoiYS1vbmUtdGltZS1zZWNyZXQiLCJhZG9wdGVkIjp0cnVlLCJ0dW5uZWwiOnsia2V5IjoiZEdobExXMWhZMmhwYm1WekxXdGxlUT09IiwiYWRkcmVzcyI6IjEwLjAuMC43LzI0IiwicmFuZ2UiOiIxMC4wLjAuMC8yNCIsImh1Yl9rZXkiOiJkR2hsTFdoMVluTXRhMlY1IiwiaHViX2VuZHBvaW50IjoiMTk4LjUxLjEwMC4xOjUxODIwIn19" + +// 774/S36/AC1: a token the controller made reads, with the moved code, field for field as it was written; encoding it +// again gives the same line; a part missing is refused naming it; garbage and another version are not tokens. +func TestATokenTheControllerMadeReads(t *testing.T) { + signer, _ := hex.DecodeString("03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8") + want := Token{Version: 1, Node: "anchor", Broker: "192.0.2.10:4222", + Fingerprint: "sha256:" + strings.Repeat("ab", 32), Signer: signer, Secret: "a-one-time-secret", Adopted: true, + Tunnel: &TokenTunnel{Key: "dGhlLW1hY2hpbmVzLWtleQ==", Address: "10.0.0.7/24", Range: "10.0.0.0/24", + HubKey: "dGhlLWh1YnMta2V5", HubEndpoint: "198.51.100.1:51820"}} + got, err := ParseToken(" " + controllerToken + "\n") + if err != nil || !reflect.DeepEqual(got, want) || len(got.SignerKey()) != 32 { + t.Fatalf("parsed %+v, %v", got, err) + } + if again, err := got.Encode(); err != nil || again != controllerToken { + t.Errorf("encoded again: %q, %v", again, err) + } + for _, c := range []struct { + cut func(*Token) + expect string + }{ + {func(t *Token) { t.Broker = "" }, "broker's address"}, + {func(t *Token) { t.Fingerprint = "" }, "fingerprint"}, + {func(t *Token) { t.Signer = t.Signer[:5] }, "signing key"}, + {func(t *Token) { t.Secret = "" }, "one-time secret"}, + {func(t *Token) { t.Tunnel = &TokenTunnel{Key: "k"} }, "where the hub's tunnel is dialled"}, + } { + cut := want + cut.Tunnel = &TokenTunnel{} + *cut.Tunnel = *want.Tunnel + c.cut(&cut) + if _, err := cut.Encode(); err == nil || !strings.Contains(err.Error(), c.expect) { + t.Errorf("a token without %s: %v", c.expect, err) + } + } + for _, bad := range []string{"", "!!!not base64!!!", "aGVsbG8", "eyJ2Ijo5OX0"} { + if _, err := ParseToken(bad); err == nil { + t.Errorf("%q parsed as a token", bad) + } + } +}