package main import ( "context" "encoding/json" "errors" "sync" "testing" "time" "github.com/novox/mesh-host/internal/identity" "github.com/novox/mesh-host/internal/link" ) // Issue 786 (novox/hq ADR 0372): the engine states its own sealing key to the vault's node-key at start, retries // while the vault does not answer, and states it again when the key changes; the same key is not stated twice. type keyVault struct { mu sync.Mutex stated []string fail int verbs []string // keyless is how many answers carry no vault key (a vault before ADR 0547 clause 9's, or not yet started); key the // vault's key the others carry. keyless int key string } func (v *keyVault) ask(verb string, args any) (json.RawMessage, error) { v.mu.Lock() defer v.mu.Unlock() v.verbs = append(v.verbs, verb) if v.fail > 0 { v.fail-- return nil, errors.New("the vault does not answer") } raw, _ := json.Marshal(args) var a struct { Key string `json:"key"` } _ = json.Unmarshal(raw, &a) v.stated = append(v.stated, a.Key) if v.key == "" || v.keyless > 0 { v.keyless-- return json.RawMessage(`{"recorded":true}`), nil } answer, _ := json.Marshal(map[string]any{"recorded": true, "vault_key": v.key}) return answer, nil } func (v *keyVault) said() []string { v.mu.Lock() defer v.mu.Unlock() return append([]string(nil), v.stated...) } func waitFor(t *testing.T, what string, ok func() bool) { t.Helper() for i := 0; i < 400; i++ { if ok() { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("timed out: %s", what) } func TestTheEngineStatesItsSealingKeyToTheVaultAtStartRetriesAndRestatesAChange(t *testing.T) { first, _ := identity.GenerateSealingKey() second, _ := identity.GenerateSealingKey() var mu sync.Mutex current := first load := func() (identity.SealingKey, error) { mu.Lock() defer mu.Unlock() return current, nil } vault := &keyVault{fail: 2} var lines []string var lmu sync.Mutex say := func(l string) { lmu.Lock(); lines = append(lines, l); lmu.Unlock() } ctx, cancel := context.WithCancel(context.Background()) defer cancel() var hmu sync.Mutex var answers []string heard := func(a json.RawMessage) { hmu.Lock(); answers = append(answers, string(a)); hmu.Unlock() } go keepNodeKeyStated(ctx, load, vault.ask, say, time.Millisecond, 5*time.Millisecond, &keyHearing{heard: heard}) waitFor(t, "the first key stated after two refusals", func() bool { return len(vault.said()) == 1 }) // Its answer is handed on, so the vault's own key in it is heard (ADR 0547 clause 9); refusals hand nothing. waitFor(t, "the answer heard", func() bool { hmu.Lock(); defer hmu.Unlock(); return len(answers) == 1 }) if vault.said()[0] != first.Public { t.Errorf("stated %q, want the engine's own key %q", vault.said()[0], first.Public) } vault.mu.Lock() verbs := append([]string(nil), vault.verbs...) vault.mu.Unlock() for _, v := range verbs { if v != "node-key" { t.Errorf("asked %s", v) } } // The same key again is not stated again. time.Sleep(60 * time.Millisecond) if n := len(vault.said()); n != 1 { t.Errorf("the same key was stated %d times", n) } mu.Lock() current = second mu.Unlock() waitFor(t, "the changed key stated", func() bool { return len(vault.said()) == 2 }) if vault.said()[1] != second.Public { t.Errorf("stated %q after the change, want %q", vault.said()[1], second.Public) } lmu.Lock() defer lmu.Unlock() if len(lines) == 0 { t.Error("the engine said nothing") } asksAgainUntilItHearsTheVaultsKey(t) } // ADR 0547 clause 9: the vault's key is heard though the engine's own key never changes. While none is held the key // is stated again at each look, so a vault delivered after the engine (or answering no key yet) is heard; once one is // held, again only at the slow interval, so a changed vault key is heard; never more. func asksAgainUntilItHearsTheVaultsKey(t *testing.T) { sealing, _ := identity.GenerateSealingKey() load := func() (identity.SealingKey, error) { return sealing, nil } pem := func(c string) string { return "-----BEGIN PUBLIC KEY-----\n" + c + "\n-----END PUBLIC KEY-----\n" } for _, c := range []struct { name string keyless int every time.Duration change bool wantAtLeast int wantAtMost int }{ {name: "a vault with no key yet is asked again until it answers one, then not", keyless: 3, wantAtLeast: 4, wantAtMost: 4}, {name: "a held key is asked again at the slow interval, and a changed one heard", every: 20 * time.Millisecond, change: true, wantAtLeast: 3, wantAtMost: 1 << 20}, } { t.Run(c.name, func(t *testing.T) { vault := &keyVault{keyless: c.keyless, key: pem("AAAA")} h := &heardKey{} ctx, cancel := context.WithCancel(context.Background()) defer cancel() go keepNodeKeyStated(ctx, load, vault.ask, func(string) {}, time.Millisecond, 5*time.Millisecond, &keyHearing{heard: h.heard, held: func() bool { return h.get() != "" }, look: time.Millisecond, every: c.every}) waitFor(t, "the vault's key heard", func() bool { return h.get() == pem("AAAA") }) if c.change { vault.mu.Lock() vault.key = pem("BBBB") vault.mu.Unlock() waitFor(t, "the changed vault key heard", func() bool { return h.get() == pem("BBBB") }) } time.Sleep(60 * time.Millisecond) n := len(vault.said()) if n < c.wantAtLeast || n > c.wantAtMost { t.Errorf("node-key asked %d times, want %d to %d", n, c.wantAtLeast, c.wantAtMost) } }) } } // The statement a host makes names this contract (issue 786), one above the vault's box. And the vault's key its // answer gives is kept, told to the follower once when first heard and again only when changed; an answer without one // (an older vault) or with no public key changes nothing (ADR 0547 clause 9). func TestTheNodeKeyContractIsThirteen(t *testing.T) { if link.NodeKeyContract != 13 { t.Errorf("NodeKeyContract %d", link.NodeKeyContract) } h := &heardKey{} kicks := 0 h.following(func() { kicks++ }) h.heard(json.RawMessage(`{"recorded":true}`)) h.heard(json.RawMessage(`{"recorded":true,"vault_key":"not a key"}`)) if h.get() != "" || kicks != 0 { t.Fatalf("an answer with no key was kept: %q, %d kicks", h.get(), kicks) } pem := "-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n" answer, _ := json.Marshal(map[string]any{"recorded": true, "vault_key": pem}) h.heard(answer) h.heard(answer) if h.get() != pem || kicks != 1 { t.Fatalf("key %q, %d kicks, want the key and one", h.get(), kicks) } h.following(func() { kicks += 10 }) if kicks != 11 { t.Fatalf("a follower named after the key was heard was not told: %d", kicks) } }