ADR 0589 §6: an address key gains the issue's ask and time, written when the ask is raised. The key is written at the reservation, before the ask, so both are empty then and both set from the ask; one without the other is refused, as is no node. EncodeEnrolmentAddress takes node, ask and issued. EnrolmentIssuedID builds the enrolment-issued event's id, enrolment-issued.<node>.<issue time in Unix nanoseconds>, as the holder names it today. Tested as rows of TestNodeRecord; no new test function (1438 unit tests). (issue 774, review round 1b) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
214 lines
10 KiB
Go
214 lines
10 KiB
Go
package delivery
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
)
|
|
|
|
// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field
|
|
// check (ADR 0201 §13) refuses such a field in a state every node reads.
|
|
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
|
|
|
|
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
|
|
// neither it nor the placement file names a field like a secret.
|
|
func TestNodeRecord(t *testing.T) {
|
|
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
|
|
Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
|
|
NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"}}
|
|
raw, err := EncodeNodeRecord(hub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := DecodeNodeRecord(raw)
|
|
if err != nil || !SameNodeRecord(back, hub) || back.V != Version {
|
|
t.Fatalf("round trip: %+v, %v", back, err)
|
|
}
|
|
// It rides in the node's own have key: a have that differs only in its record is another value.
|
|
with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version}
|
|
if raw, err := EncodeHaveNode(with); err != nil {
|
|
t.Fatal(err)
|
|
} else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) {
|
|
t.Fatalf("the have's record: %+v, %v", got, err)
|
|
}
|
|
changed := hub
|
|
changed.Contract = 4
|
|
if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) {
|
|
t.Error("a changed record reads as the same have")
|
|
}
|
|
|
|
for name, r := range map[string]NodeRecord{
|
|
"empty capability": {Node: "ace", Capabilities: []string{""}},
|
|
"node that is not a name": {Node: "a.b"},
|
|
} {
|
|
if _, err := EncodeNodeRecord(r); err == nil {
|
|
t.Errorf("%s: taken", name)
|
|
}
|
|
}
|
|
|
|
// Every JSON field name of the record and the types it carries, walked: none reads as a secret.
|
|
var walk func(reflect.Type, string)
|
|
walk = func(ty reflect.Type, at string) {
|
|
for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice {
|
|
ty = ty.Elem()
|
|
}
|
|
if ty.Kind() != reflect.Struct {
|
|
return
|
|
}
|
|
for i := 0; i < ty.NumField(); i++ {
|
|
f := ty.Field(i)
|
|
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
|
|
if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) {
|
|
t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name)
|
|
}
|
|
walk(f.Type, at+"."+f.Name)
|
|
}
|
|
}
|
|
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
|
|
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
|
|
// grants part reads them (ADR 0587 §2).
|
|
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
|
|
// The tunnel key given at issue, at every stage.
|
|
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
|
|
for _, e := range []Enrolment{
|
|
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
|
|
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
|
|
} {
|
|
raw, err := EncodeEnrolment(e)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := DecodeEnrolment(raw)
|
|
e.V = Version
|
|
if err != nil || !reflect.DeepEqual(back, e) {
|
|
t.Errorf("enrolment round trip: %+v, %v", back, err)
|
|
}
|
|
}
|
|
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
|
|
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
|
|
t.Fatal(err)
|
|
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
|
|
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
|
|
}
|
|
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
|
|
t.Fatal(err)
|
|
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
|
|
t.Errorf("address at the ask round trip: %+v, %v", back, err)
|
|
}
|
|
for name, raw := range map[string]string{
|
|
"no node": `{"v":1}`,
|
|
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
|
|
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
|
|
} {
|
|
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
|
|
t.Errorf("an address with %s: taken", name)
|
|
}
|
|
}
|
|
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
|
|
t.Errorf("the issued event's id: %q", id)
|
|
}
|
|
for name, e := range map[string]Enrolment{
|
|
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
|
|
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
|
|
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
|
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
|
|
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
|
|
Expires: issued.Add(time.Hour)},
|
|
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
|
|
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
|
|
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
|
|
Expires: issued.Add(time.Hour)},
|
|
} {
|
|
if _, err := EncodeEnrolment(e); err == nil {
|
|
t.Errorf("enrolment with %s: taken", name)
|
|
}
|
|
}
|
|
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
|
|
t.Errorf("address key: %q %v", key, ok)
|
|
}
|
|
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
|
|
t.Errorf("node key: %q %v", node, ok)
|
|
}
|
|
for _, life := range []string{"", "1h", "24h"} {
|
|
if _, err := TokenLife(life); err != nil {
|
|
t.Errorf("a life of %q: %v", life, err)
|
|
}
|
|
}
|
|
for _, life := range []string{"25h", "-1h", "a day"} {
|
|
if _, err := TokenLife(life); err == nil {
|
|
t.Errorf("a life of %q: taken", life)
|
|
}
|
|
}
|
|
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
|
|
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
|
|
for _, l := range []NodeLogin{
|
|
{Node: "g14", Login: "node.g14", Since: issued},
|
|
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
|
|
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
|
|
} {
|
|
raw, err := EncodeNodeLogin(l)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := DecodeNodeLogin(raw)
|
|
l.V = Version
|
|
if err != nil || !reflect.DeepEqual(back, l) {
|
|
t.Errorf("node login round trip: %+v, %v", back, err)
|
|
}
|
|
}
|
|
// The hand acts that warranted its rotations, as the vault records them.
|
|
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
|
|
!strings.Contains(string(raw), `"acts":["act-1"]`) {
|
|
t.Errorf("a login with its acts: %s, %v", raw, err)
|
|
}
|
|
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
|
|
t.Errorf("an empty rotating: %+v, %v", l, err)
|
|
}
|
|
for name, l := range map[string]NodeLogin{
|
|
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
|
|
"another node's login": {Node: "g14", Login: "node.ace"},
|
|
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
|
|
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
|
|
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
|
|
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
|
|
} {
|
|
if _, err := EncodeNodeLogin(l); err == nil {
|
|
t.Errorf("node login with %s: taken", name)
|
|
}
|
|
}
|
|
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
|
|
t.Errorf("the second login's node: %q %v", node, ok)
|
|
}
|
|
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
|
|
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
|
|
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
|
|
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
|
|
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
|
|
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
|
|
t.Errorf("an expiry: %v", err)
|
|
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
|
|
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
|
|
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
|
|
}
|
|
for name, raw := range map[string]string{
|
|
"no issued event": `{"v":1,"node":"lab"}`,
|
|
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
|
|
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
|
|
} {
|
|
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
|
|
t.Errorf("an expiry with %s: taken", name)
|
|
}
|
|
}
|
|
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
|
|
walk(reflect.TypeOf(Placement{}), "Placement")
|
|
}
|