231 lines
9.4 KiB
Go
231 lines
9.4 KiB
Go
package main
|
|
|
|
// The vault's own calls (issue 723): it asks the desk's prompt and an issuing provider's tool itself, in its own
|
|
// name, under the bus account its own runtime runs on. That the bus lets that account alone make these calls, and
|
|
// nothing else, is mesh-controller's test on a real server (internal/broker vault_calls_live_test.go); here, what
|
|
// the vault sends and takes back on those calls carries keys and sealed values only.
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"golang.org/x/crypto/nacl/box"
|
|
)
|
|
|
|
// vaultsAccount is the vault's bus user as its own runtime names it to a tool it calls.
|
|
const vaultsAccount = "novox.mesh-vault"
|
|
|
|
// aMesh stands in for the bus the vault's runtime calls on: each call is answered by the desk's prompt or a
|
|
// provider's tool, named by the caller the vault's own account is, and everything that crosses is kept.
|
|
type aMesh struct {
|
|
mu sync.Mutex
|
|
desk func(caller string, args map[string]any) (map[string]any, error)
|
|
tools map[string]func(caller string, args map[string]any) (map[string]any, error)
|
|
keys []string
|
|
crossed []string
|
|
}
|
|
|
|
func (m *aMesh) ask(key string, body any) (json.RawMessage, error) {
|
|
m.mu.Lock()
|
|
m.keys = append(m.keys, key)
|
|
m.mu.Unlock()
|
|
raw, _ := json.Marshal(body)
|
|
var args map[string]any
|
|
_ = json.Unmarshal(raw, &args)
|
|
var answer map[string]any
|
|
var err error
|
|
switch {
|
|
case strings.HasPrefix(key, "seat:node-launcher.secret@") && m.desk != nil:
|
|
answer, err = m.desk(vaultsAccount, args)
|
|
case m.tools[key] != nil:
|
|
answer, err = m.tools[key](vaultsAccount, args)
|
|
default:
|
|
err = errors.New("503 no responders")
|
|
}
|
|
out, _ := json.Marshal(answer)
|
|
m.mu.Lock()
|
|
m.crossed = append(m.crossed, string(raw), string(out))
|
|
m.mu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (m *aMesh) said() string {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return strings.Join(m.crossed, "\n")
|
|
}
|
|
|
|
// aDesk is the desk's prompt (rofi's node-launcher.secret): it answers what the operator types, sealed to the key
|
|
// it is given, and keeps what it was asked.
|
|
func aDesk(typed string, asked *[]map[string]any) func(string, map[string]any) (map[string]any, error) {
|
|
return func(caller string, args map[string]any) (map[string]any, error) {
|
|
*asked = append(*asked, args)
|
|
switch typed {
|
|
case "":
|
|
return map[string]any{"cancelled": true}, nil
|
|
case "\x00timeout":
|
|
return map[string]any{"cancelled": true, "timed_out": true}, nil
|
|
}
|
|
key, _ := args["seal_to"].(string)
|
|
raw, err := base64.StdEncoding.DecodeString(key)
|
|
if err != nil || len(raw) != 32 {
|
|
return nil, errors.New("seal_to is not a sealing key")
|
|
}
|
|
var to [32]byte
|
|
copy(to[:], raw)
|
|
sealed, err := box.SealAnonymous(nil, []byte(typed+"\n"), &to, rand.Reader)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"sealed": base64.StdEncoding.EncodeToString(sealed)}, nil
|
|
}
|
|
}
|
|
|
|
// The vault asks the desk itself: recorded first, the prompt named by the vault's call and naming the asker, what
|
|
// is typed opened here alone; what crossed is a key and a sealed box.
|
|
func TestTheVaultAsksTheDesksPromptItselfAndOnlyASealedValueCrosses(t *testing.T) {
|
|
v := newVault(t, AllowList{"anchor.node-provisioner": {"own/anchor/telegram/telegram-token"}})
|
|
var asked []map[string]any
|
|
mesh := &aMesh{desk: aDesk(typedAtTheDesk, &asked)}
|
|
v.Ask = mesh.ask
|
|
|
|
got, err := v.AskAtDesk(ControllerCaller, telegramAsk("g14/claude-code, via the console"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Outcome != "given" || got.Generation != 1 || got.AskedBy != "g14/claude-code" ||
|
|
got.Secret != "own/anchor/telegram/telegram-token" {
|
|
t.Errorf("answered %+v", got)
|
|
}
|
|
if len(mesh.keys) != 1 || mesh.keys[0] != "seat:node-launcher.secret@laptop" {
|
|
t.Fatalf("the vault asked %v", mesh.keys)
|
|
}
|
|
p := asked[0]
|
|
if p["asked_by"] != "g14/claude-code" || p["module"] != "telegram" || p["secret"] != "telegram-token" ||
|
|
p["node"] != "anchor" || p["seal_to"] == "" || p["timeout_seconds"] != float64(DeskPromptFor) {
|
|
t.Errorf("the prompt was asked %v", p)
|
|
}
|
|
if v := handedTo(t, v, "anchor.node-provisioner", "own/anchor/telegram/telegram-token"); v != typedAtTheDesk {
|
|
t.Errorf("the vault holds %q", v)
|
|
}
|
|
rec, _, _ := v.Store.Read("own/anchor/telegram/telegram-token")
|
|
if rec.Origin != OriginTyped || rec.ChangedBy != "g14/claude-code" || rec.ChangedVia != ControllerCaller {
|
|
t.Errorf("the record: origin %q by %q via %q", rec.Origin, rec.ChangedBy, rec.ChangedVia)
|
|
}
|
|
asks, _ := v.Store.Asks(10)
|
|
if len(asks) != 2 || asks[1].Event != AskOpened || asks[0].Outcome != "given" {
|
|
t.Errorf("the record of asks: %+v", asks)
|
|
}
|
|
if strings.Contains(mesh.said(), typedAtTheDesk) || strings.Contains(everything(t, v, got, asks), typedAtTheDesk) {
|
|
t.Fatal("the typed value crossed the bus, or is in something the vault said or wrote, in the clear")
|
|
}
|
|
|
|
// Dismissed and timed out end the ask so, and keep nothing.
|
|
for typed, outcome := range map[string]string{"": "dismissed", "\x00timeout": "timed-out"} {
|
|
w := newVault(t, nil)
|
|
var a []map[string]any
|
|
w.Ask = (&aMesh{desk: aDesk(typed, &a)}).ask
|
|
got, err := w.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code"))
|
|
if err != nil || got.Outcome != outcome {
|
|
t.Errorf("%s: %+v %v", outcome, got, err)
|
|
}
|
|
if asks, _ := w.Store.Asks(1); asks[0].Outcome != outcome {
|
|
t.Errorf("%s recorded %+v", outcome, asks[0])
|
|
}
|
|
if _, held, _ := w.Store.Read("own/anchor/telegram/telegram-token"); held {
|
|
t.Errorf("%s kept a value", outcome)
|
|
}
|
|
}
|
|
// Answered empty: the operator's answer, said so, nothing kept.
|
|
e := newVault(t, nil)
|
|
var ea []map[string]any
|
|
e.Ask = (&aMesh{desk: aDesk(" ", &ea)}).ask
|
|
if got, err := e.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")); err != nil || got.Outcome != "empty" {
|
|
t.Errorf("empty: %+v %v", got, err)
|
|
}
|
|
// The desk cannot be asked: refused, recorded so.
|
|
r := newVault(t, nil)
|
|
r.Ask = (&aMesh{}).ask
|
|
if _, err := r.AskAtDesk(ControllerCaller, telegramAsk("g14.claude-code")); err == nil {
|
|
t.Error("an ask nobody answered was said to be done")
|
|
}
|
|
if asks, _ := r.Store.Asks(1); asks[0].Outcome != "refused" {
|
|
t.Errorf("an unanswered desk recorded %+v", asks[0])
|
|
}
|
|
// Only the controller asks the vault to ask: anybody else is refused before any prompt.
|
|
n := newVault(t, nil)
|
|
var na []map[string]any
|
|
n.Ask = (&aMesh{desk: aDesk(typedAtTheDesk, &na)}).ask
|
|
for _, caller := range []string{"g14.node-tools", "g14.claude-code", "unproven", ""} {
|
|
if _, err := n.AskAtDesk(caller, telegramAsk("x")); err == nil {
|
|
t.Errorf("%q had the vault ask a desk", caller)
|
|
}
|
|
}
|
|
if len(na) != 0 {
|
|
t.Fatal("a desk was asked for a caller that is not the controller")
|
|
}
|
|
}
|
|
|
|
// The vault asks an issuing provider's tool itself: by its name on the provider's machine, with a key made for the
|
|
// call; the tool answers the vault's account; the value opens here; what crossed is a key and a sealed box.
|
|
func TestTheVaultAsksTheIssuingToolItselfAndOnlyASealedValueCrosses(t *testing.T) {
|
|
v := newVault(t, AllowList{"anchor.node-provisioner": {"provision/anchor/plex-api"}})
|
|
software := "plex-token-from-the-vendor"
|
|
mesh := &aMesh{tools: map[string]func(string, map[string]any) (map[string]any, error){
|
|
"plex.plex_issued_credential@anchor": standInProvider(&software)}}
|
|
v.Ask = mesh.ask
|
|
got, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Secret != "provision/anchor/plex-api" || got.Generation != 1 || got.Unchanged {
|
|
t.Errorf("answered %+v", got)
|
|
}
|
|
if got := handedTo(t, v, "anchor.node-provisioner", "provision/anchor/plex-api"); got != software {
|
|
t.Errorf("the vault holds %q", got)
|
|
}
|
|
if strings.Contains(mesh.said(), software) || strings.Contains(everything(t, v, got), software) {
|
|
t.Fatal("the issued value crossed the bus, or is in something the vault said or wrote, in the clear")
|
|
}
|
|
if !strings.Contains(mesh.said(), `"provision":"plex-api"`) || !strings.Contains(mesh.said(), `"seal_to"`) {
|
|
t.Errorf("the tool was asked %s", mesh.said())
|
|
}
|
|
// The same value again writes nothing.
|
|
again, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential")
|
|
if err != nil || !again.Unchanged || again.Generation != 1 {
|
|
t.Errorf("again: %+v %v", again, err)
|
|
}
|
|
// Only the controller asks the vault to take it; a tool's name that is not one is refused; a tool that is not
|
|
// answered, or answers no sealed value, keeps nothing.
|
|
for _, caller := range []string{"anchor.node-tools", "g14.claude-code", "unproven", ""} {
|
|
if _, err := v.TakeIssued(caller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil {
|
|
t.Errorf("%q had the vault ask a provider", caller)
|
|
}
|
|
}
|
|
for _, bad := range []string{"", "plex.other", "x@y", "a b"} {
|
|
if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", bad); err == nil {
|
|
t.Errorf("the tool %q was asked", bad)
|
|
}
|
|
}
|
|
if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_status"); err == nil {
|
|
t.Error("a tool nobody answers gave a value")
|
|
}
|
|
mesh.tools["plex.plex_issued_credential@anchor"] = func(string, map[string]any) (map[string]any, error) {
|
|
return map[string]any{"value": "in-the-clear"}, nil
|
|
}
|
|
if _, err := v.TakeIssued(ControllerCaller, "plex-api", "anchor", "plex", "plex_issued_credential"); err == nil {
|
|
t.Error("a tool answering no sealed value gave a value")
|
|
}
|
|
if len(v.pending) != 0 {
|
|
t.Errorf("%d calls' keys are still kept", len(v.pending))
|
|
}
|
|
}
|