mesh-vault and mesh-node-enroller clients: the signing, the node's two logins and the own-secret drift; the token and the redeem proof (ADR 0634 §4; C-10; issue 1966)
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
package meshnodeenrollerclient
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultTokenLife is a token's life when issue names none; MostTokenLife the longest it may name (ADR 0587 §4).
|
||||
DefaultTokenLife = time.Hour
|
||||
MostTokenLife = 24 * time.Hour
|
||||
|
||||
// UserPrefix is the bus user a joining machine dials as, `enrol.<node>` (ADR 0587 §4).
|
||||
UserPrefix = "enrol."
|
||||
)
|
||||
|
||||
// User is the bus user a joining machine dials as.
|
||||
func User(node string) string { return UserPrefix + node }
|
||||
|
||||
// IssuedID is the id of the enrolment-issued event of node's enrolment issued at issued:
|
||||
// `enrolment-issued.<node>.<issue time in Unix nanoseconds>`, what enrolment-expired names.
|
||||
func IssuedID(node string, issued time.Time) string {
|
||||
return "enrolment-issued." + node + "." + strconv.FormatInt(issued.UnixNano(), 10)
|
||||
}
|
||||
|
||||
// TokenLife is the life an issue's `expires` asks for: a duration, one hour when empty, at most MostTokenLife.
|
||||
func TokenLife(expires string) (time.Duration, error) {
|
||||
if strings.TrimSpace(expires) == "" {
|
||||
return DefaultTokenLife, nil
|
||||
}
|
||||
d, err := time.ParseDuration(strings.TrimSpace(expires))
|
||||
if err != nil || d <= 0 || d > MostTokenLife {
|
||||
return 0, fmt.Errorf("meshnodeenrollerclient: a token lasts a duration up to %s, such as 1h, not %q", MostTokenLife, expires)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package meshnodeenrollerclient
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestATokenLifeAndTheRedeemProofAreAsTheHolderMakesThem(t *testing.T) {
|
||||
for life, ok := range map[string]bool{"": true, "1h": true, "24h": true, "25h": false, "0s": false, "-1h": false, "soon": false} {
|
||||
if _, err := TokenLife(life); (err == nil) != ok {
|
||||
t.Errorf("TokenLife(%q) = %v, want ok %v", life, err, ok)
|
||||
}
|
||||
}
|
||||
if d, _ := TokenLife(""); d != DefaultTokenLife {
|
||||
t.Errorf("an empty life is %s", d)
|
||||
}
|
||||
if got := IssuedID("g14", time.Unix(0, 42)); got != "enrolment-issued.g14.42" {
|
||||
t.Errorf("IssuedID = %s", got)
|
||||
}
|
||||
if User("g14") != "enrol.g14" {
|
||||
t.Error("the joining machine dials as enrol.<node>")
|
||||
}
|
||||
r := Redeem{Node: "g14", PublicKey: "p", OverlayKey: "o", SealingKey: "s", ServingKey: "v"}
|
||||
if string(RedeemProof("g14", "p", "o", "s", "v")) != "novox-mesh-redeem v1\x00g14\x00p\x00o\x00s\x00v" || r.KeysDigest() != KeysDigest("p", "o", "s", "v") {
|
||||
t.Error("the proof bytes or the keys' digest changed")
|
||||
}
|
||||
if got := (RedeemAnswer{Node: "g14", Signature: "x"}).SignedBytes(); string(got) == "" || string(got) == "x" {
|
||||
t.Errorf("SignedBytes = %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshnodeenrollerclient
|
||||
|
||||
go 1.26.0
|
||||
@@ -0,0 +1,65 @@
|
||||
package meshnodeenrollerclient
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// **Redeem** (§6): what a joining node-engine sends, and what the holder answers. Shared by the holder and the engine,
|
||||
// which live in two repositories.
|
||||
|
||||
// Redeem is the redeem verb's call: the node, the public halves of its keys and its identity key's signature over
|
||||
// RedeemProof. No token: the bus's login check verified it.
|
||||
type Redeem struct {
|
||||
Node string `json:"node"`
|
||||
PublicKey string `json:"public_key"`
|
||||
OverlayKey string `json:"overlay_key,omitempty"`
|
||||
SealingKey string `json:"sealing_key"`
|
||||
ServingKey string `json:"serving_key,omitempty"`
|
||||
Profile map[string]any `json:"profile,omitempty"`
|
||||
Tunnel json.RawMessage `json:"tunnel,omitempty"`
|
||||
Proof string `json:"proof"`
|
||||
}
|
||||
|
||||
// RedeemProof is the bytes a node signs with its identity key when it redeems: its name and every public key it
|
||||
// presents, so a proof cannot be moved to another node or other keys. The holder builds the same bytes.
|
||||
func RedeemProof(node, public, overlay, sealing, serving string) []byte {
|
||||
return []byte("novox-mesh-redeem v1\x00" + node + "\x00" + public + "\x00" + overlay + "\x00" + sealing + "\x00" + serving)
|
||||
}
|
||||
|
||||
// KeysDigest is the digest of the public keys a redeem presents, `sha256:<hex>`: what the key at redeeming and the
|
||||
// events carry, the one hash an event carries (§1).
|
||||
func KeysDigest(public, overlay, sealing, serving string) string {
|
||||
sum := sha256.Sum256([]byte(strings.Join([]string{public, overlay, sealing, serving}, "\x00")))
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// KeysDigest is r's keys' digest.
|
||||
func (r Redeem) KeysDigest() string {
|
||||
return KeysDigest(r.PublicKey, r.OverlayKey, r.SealingKey, r.ServingKey)
|
||||
}
|
||||
|
||||
// ProofBytes is r's proof decoded from base64.
|
||||
func (r Redeem) ProofBytes() ([]byte, error) { return base64.StdEncoding.DecodeString(r.Proof) }
|
||||
|
||||
// RedeemAnswer is what redeem answers (§6): the node, the login it dials as next, its account sealed to the sealing key it
|
||||
// presented (base64), its certificate as PEM, the vault's public signing half (base64), and the vault's signature over
|
||||
// SignedBytes under the context of KindEnrolment. The engine refuses an answer not signed by the key its token carries.
|
||||
type RedeemAnswer struct {
|
||||
Node string `json:"node"`
|
||||
Login string `json:"login"`
|
||||
Account string `json:"account"`
|
||||
Certificate string `json:"certificate"`
|
||||
Signer string `json:"signer"`
|
||||
Signature string `json:"signature,omitempty"`
|
||||
}
|
||||
|
||||
// SignedBytes is what the vault signs of a: a without its signature, as JSON.
|
||||
func (a RedeemAnswer) SignedBytes() []byte {
|
||||
a.Signature = ""
|
||||
raw, _ := json.Marshal(a)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package meshnodeenrollerclient
|
||||
|
||||
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).
|
||||
//
|
||||
// Shared 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 vault'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("meshnodeenrollerclient: 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) }
|
||||
@@ -0,0 +1,53 @@
|
||||
package meshnodeenrollerclient
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package meshvaultclient
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
)
|
||||
|
||||
func TestSigningNodeLoginsAndOwnDrift(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(nil)
|
||||
k := NewSignerKey(pub, time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC))
|
||||
if got, err := SignerPublicKey(k); err != nil || !got.Equal(pub) {
|
||||
t.Fatalf("the key round trips: %v", err)
|
||||
}
|
||||
k.Fingerprint = "sha256:00"
|
||||
if _, err := SignerPublicKey(k); err == nil {
|
||||
t.Error("a key with another fingerprint was taken")
|
||||
}
|
||||
ctx, err := SignContext(KindEnrolment)
|
||||
if err != nil || ctx != "novox enrolment v1\n" {
|
||||
t.Fatalf("SignContext = %q, %v", ctx, err)
|
||||
}
|
||||
if _, err := SignContext("other"); err == nil {
|
||||
t.Error("an unknown kind has a context")
|
||||
}
|
||||
sig := SignMessage(priv, ctx, []byte("m"))
|
||||
if s, err := VerifyBy([]Signer{{Name: SignerVault, Key: pub}}, ctx, []byte("m"), sig); err != nil || s.Name != SignerVault {
|
||||
t.Errorf("VerifyBy = %v, %v", s, err)
|
||||
}
|
||||
other, _ := SignContext(KindAuthority)
|
||||
if _, err := VerifyBy([]Signer{{Name: SignerVault, Key: pub}}, other, []byte("m"), sig); err == nil {
|
||||
t.Error("a signature made for one kind verified as another's")
|
||||
}
|
||||
|
||||
// A node's two logins: the first, the second, a rotation to the other only, a stage only with a rotation.
|
||||
since := "2026-11-02T01:00:00Z"
|
||||
for name, c := range map[string]struct {
|
||||
l seat.NodeLoginsByNodeValue
|
||||
ok bool
|
||||
}{
|
||||
"first login": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Since: since}, true},
|
||||
"rotating to other": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationBegun, Since: since}, true},
|
||||
"rotating to itself": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun, Since: since}, false},
|
||||
"stage without rotate": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Stage: RotationReady, Since: since}, false},
|
||||
"another's login": {seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.ace", Since: since}, false},
|
||||
} {
|
||||
if err := CheckNodeLogin(c.l); (err == nil) != c.ok {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if raw, err := EncodeNodeLogin(seat.NodeLoginsByNodeValue{Node: "g14", Login: "node.g14", Since: since}); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if back, err := DecodeNodeLogin(raw); err != nil || Rotates(back) {
|
||||
t.Errorf("round trip: %+v %v", back, err)
|
||||
}
|
||||
if _, err := DecodeNodeLogin([]byte(`{"v":9,"node":"g14","login":"node.g14","since":"` + since + `"}`)); err == nil {
|
||||
t.Error("version 9 was taken")
|
||||
}
|
||||
if n, ok := LoginNode("node.g14--b"); !ok || n != "g14" {
|
||||
t.Errorf("LoginNode = %q %v", n, ok)
|
||||
}
|
||||
|
||||
masked := []ModuleBodies{{Module: "nats", Bodies: []Body{{ID: "nats.needs-jwt", Composed: "x " + MaskedAnotherValue}, {ID: "nats.conf", Running: MaskedAnotherValue}}}}
|
||||
if own := OwnValuesDiffer(masked); len(own) != 1 || own[0] != (OwnValueDiffers{Module: "nats", Name: "jwt"}) || ValuesDiffer(masked) != 2 {
|
||||
t.Errorf("OwnValuesDiffer = %v, ValuesDiffer = %d", own, ValuesDiffer(masked))
|
||||
}
|
||||
if !strings.Contains(MaskedAnotherValue, "another value") {
|
||||
t.Error("the mask word changed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
module git.novox.be/novox/mesh-contracts/identity-and-access/go/clients/meshvaultclient
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault v0.0.0-20261105000000-000000000000
|
||||
git.novox.be/novox/mesh-sdk/go v0.0.0-20261105000000-000000000000
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
package meshvaultclient
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
)
|
||||
|
||||
// **A node's two logins** (novox/hq ADR 0583): a node's bus account rotates between the login `node.<n>` and
|
||||
// `node.<n>--b`. The layout of the state `node-logins` is the seat's contract's; this is the logic every reader of it
|
||||
// shares.
|
||||
const (
|
||||
// The stages of a rotation (ADR 0583 §2-§4).
|
||||
RotationBegun = "begun"
|
||||
RotationReady = "ready"
|
||||
RotationConfirmed = "confirmed"
|
||||
|
||||
nodeLoginPrefix = "node."
|
||||
// SecondLoginSuffix marks a node's second login, as a module's account rotates over `--b` (ADR 0114).
|
||||
SecondLoginSuffix = "--b"
|
||||
|
||||
// Version is the version of every value of the seat's states.
|
||||
Version = 1
|
||||
)
|
||||
|
||||
// ErrUnknownVersion is what a decode returns for a value whose version is not Version.
|
||||
var ErrUnknownVersion = errors.New("meshvaultclient: unknown value version")
|
||||
|
||||
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
|
||||
|
||||
func checkName(kind, name string) error {
|
||||
if name == "" || !nameOK.MatchString(name) {
|
||||
return fmt.Errorf("meshvaultclient: the %s name %q is empty or holds a dot, a wildcard, whitespace or a character a key refuses", kind, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FirstLogin is a node's first login, `node.<n>`; SecondLogin its other.
|
||||
func FirstLogin(node string) string { return nodeLoginPrefix + node }
|
||||
func SecondLogin(node string) string { return nodeLoginPrefix + node + SecondLoginSuffix }
|
||||
|
||||
// OtherLogin is the login a node rotates to from login: the second from the first, the first from the second.
|
||||
func OtherLogin(node, login string) string {
|
||||
if login == SecondLogin(node) {
|
||||
return FirstLogin(node)
|
||||
}
|
||||
return SecondLogin(node)
|
||||
}
|
||||
|
||||
// LoginNode is the node a node login names, either of its two, and whether login is one.
|
||||
func LoginNode(login string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(login, nodeLoginPrefix)
|
||||
rest = strings.TrimSuffix(rest, SecondLoginSuffix)
|
||||
return rest, ok && checkName("node", rest) == nil
|
||||
}
|
||||
|
||||
// Rotates says the node is rotating: a login rotating is named.
|
||||
func Rotates(l seat.NodeLoginsByNodeValue) bool { return l.Rotating != "" }
|
||||
|
||||
// CheckNodeLogin refuses a login that is not one of the node's two, a rotation to anything but the other, and a stage
|
||||
// without a rotation or an unknown one.
|
||||
func CheckNodeLogin(l seat.NodeLoginsByNodeValue) error {
|
||||
if err := checkName("node", l.Node); err != nil {
|
||||
return err
|
||||
}
|
||||
if l.Login != FirstLogin(l.Node) && l.Login != SecondLogin(l.Node) {
|
||||
return fmt.Errorf("meshvaultclient: the node %s logs in as %q, neither of its two logins", l.Node, l.Login)
|
||||
}
|
||||
for _, act := range l.Acts {
|
||||
if strings.TrimSpace(act) == "" {
|
||||
return fmt.Errorf("meshvaultclient: the node %s names an empty hand act among its rotations' warrants", l.Node)
|
||||
}
|
||||
}
|
||||
if !Rotates(l) {
|
||||
if l.Stage != "" || l.ListRevision != 0 {
|
||||
return fmt.Errorf("meshvaultclient: the node %s rotates to nothing and names stage %q", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if l.Rotating != OtherLogin(l.Node, l.Login) {
|
||||
return fmt.Errorf("meshvaultclient: the node %s rotates from %s to %q, not its other login", l.Node, l.Login, l.Rotating)
|
||||
}
|
||||
switch l.Stage {
|
||||
case RotationBegun, RotationReady, RotationConfirmed:
|
||||
default:
|
||||
return fmt.Errorf("meshvaultclient: the node %s's rotation is at stage %q, not begun, ready or confirmed", l.Node, l.Stage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeNodeLogin is l as JSON at Version; it refuses what DecodeNodeLogin refuses.
|
||||
func EncodeNodeLogin(l seat.NodeLoginsByNodeValue) ([]byte, error) {
|
||||
if err := CheckNodeLogin(l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.V = Version
|
||||
return json.Marshal(l)
|
||||
}
|
||||
|
||||
// DecodeNodeLogin reads a key of `node-logins`; it refuses an unknown version (ErrUnknownVersion) and what
|
||||
// CheckNodeLogin refuses.
|
||||
func DecodeNodeLogin(data []byte) (seat.NodeLoginsByNodeValue, error) {
|
||||
var probe struct {
|
||||
V *int `json:"v"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return seat.NodeLoginsByNodeValue{}, fmt.Errorf("meshvaultclient: value is not JSON: %w", err)
|
||||
}
|
||||
if probe.V == nil || *probe.V != Version {
|
||||
return seat.NodeLoginsByNodeValue{}, fmt.Errorf("%w: this reader reads %d", ErrUnknownVersion, Version)
|
||||
}
|
||||
l, err := seat.DecodeNodeLoginsByNodeValue(data)
|
||||
if err != nil {
|
||||
return l, fmt.Errorf("meshvaultclient: value does not fit its shape: %w", err)
|
||||
}
|
||||
if err := CheckNodeLogin(l); err != nil {
|
||||
return seat.NodeLoginsByNodeValue{}, err
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package meshvaultclient
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The own secrets a node's shadow answer finds the vault holds another value for than the node runs (issue 1126, cause
|
||||
// C4): the vault's `move-in` never replaces a value it holds, so a value made anew later runs while the vault keeps the
|
||||
// first one it was handed. The node opens both seals with its own key and masks the value as MaskedAnotherValue; this
|
||||
// reads which own secrets those are, for the node-engine (which hands the vault what runs) and for the vault's own
|
||||
// drift tool, so both read the answer one way.
|
||||
|
||||
// MaskedAnotherValue is the word a shown body masks a value with when the node opened both sides with its key and they
|
||||
// are two values.
|
||||
const MaskedAnotherValue = "<masked: another value>"
|
||||
|
||||
// OwnValueDiffers is one own secret of a module whose value composed (the vault's) is another than the one that runs.
|
||||
type OwnValueDiffers struct {
|
||||
Module string `json:"module"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Body is one differing resource of a module as a node's answer shows it: the resource's id and both sides, masked.
|
||||
type Body struct {
|
||||
ID string
|
||||
Composed string
|
||||
Running string
|
||||
}
|
||||
|
||||
// ModuleBodies is the shown bodies of one module whose composition differs.
|
||||
type ModuleBodies struct {
|
||||
Module string
|
||||
Bodies []Body
|
||||
}
|
||||
|
||||
var ownNeedPrefix = catalogue.NeedID("")
|
||||
|
||||
// OwnNeedName is the name of module's own secret whose file a resource id names, and whether it names one. On a node a
|
||||
// resource's id carries its module's name (`only-office.needs-jwt`); the bare form a declaration composes
|
||||
// (`needs-jwt`, catalogue.NeedID) is read too. An id of another module's is not this module's.
|
||||
func OwnNeedName(module, id string) (string, bool) {
|
||||
if module != "" {
|
||||
id = strings.TrimPrefix(id, module+".")
|
||||
}
|
||||
name, own := strings.CutPrefix(id, ownNeedPrefix)
|
||||
return name, own && name != "" && !strings.Contains(name, ".")
|
||||
}
|
||||
|
||||
// OwnValuesDiffer is every own secret whose shown bodies say it holds another value composed than running, sorted by
|
||||
// module and name, each once. A body the answer did not show is not read.
|
||||
func OwnValuesDiffer(differs []ModuleBodies) []OwnValueDiffers {
|
||||
seen := map[OwnValueDiffers]bool{}
|
||||
var out []OwnValueDiffers
|
||||
for _, d := range differs {
|
||||
for _, b := range d.Bodies {
|
||||
name, own := OwnNeedName(d.Module, b.ID)
|
||||
if !own || !anotherValue(b) {
|
||||
continue
|
||||
}
|
||||
o := OwnValueDiffers{Module: d.Module, Name: name}
|
||||
if !seen[o] {
|
||||
seen[o] = true
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Module != out[j].Module {
|
||||
return out[i].Module < out[j].Module
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ValuesDiffer is how many shown bodies hold a value masked MaskedAnotherValue, own secrets and every other resource
|
||||
// alike.
|
||||
func ValuesDiffer(differs []ModuleBodies) int {
|
||||
n := 0
|
||||
for _, d := range differs {
|
||||
for _, b := range d.Bodies {
|
||||
if anotherValue(b) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func anotherValue(b Body) bool {
|
||||
return strings.Contains(b.Composed, MaskedAnotherValue) || strings.Contains(b.Running, MaskedAnotherValue)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package meshvaultclient
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
seat "git.novox.be/novox/mesh-contracts/identity-and-access/go/seats/meshvault"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// **The vault signs** (novox/hq ADR 0550 §2; design 47's L3 item d). The vault makes an ed25519 signing key and an x509
|
||||
// authority once, keeps both as its own secrets with their operator copies, and publishes their public halves in its
|
||||
// state `signers`, which only the vault writes and every node principal and composing part reads. Its verb `sign` signs a
|
||||
// node's certificate for that node's own node-engine, from bus state alone (the name from the node's placement, the
|
||||
// serving key from its record), and says `signed` naming the caller, the kind, the node and the digest, never the key.
|
||||
//
|
||||
// A node trusts the vault's key through the bus grants alone (the lead's design limit, 2026-10-28): only the vault may
|
||||
// write `signers`, the same trust the wants and the placement rest on. A node trusts the vault's key alone since the
|
||||
// controller ended (ADR 0590). What a node trusts now it states in its record, the node record's trusts.
|
||||
|
||||
const (
|
||||
// The verb sign, its answer and the event signed are the vault's contract's (mesh-contracts meshvault: VerbSign,
|
||||
// SignAnswer, HolderEventSigned, HolderSubjectSigned, SignedHolderEvent; novox/hq ADR 0627 §1).
|
||||
|
||||
// KindNodeCertificate is a node's certificate for its internal name, asked by that node's node-engine alone.
|
||||
KindNodeCertificate = "node-certificate"
|
||||
// KindEnrolment is a redeem answer (RedeemAnswer), signed under its own context for the seat mesh-node-enroller alone (ADR 0587
|
||||
// §3, §6).
|
||||
KindEnrolment = "enrolment"
|
||||
// KindAuthority is the vault's own authority certificate, which the vault signs for itself.
|
||||
KindAuthority = "authority"
|
||||
|
||||
// SignerVault names the signer a node trusts.
|
||||
SignerVault = "vault"
|
||||
|
||||
// CertificateLife is how long a certificate the vault signs for a node lasts: ten years, renewed by asking again,
|
||||
// never by a clock running out unseen.
|
||||
CertificateLife = 10 * 365 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// SignKinds are the kinds `sign` knows.
|
||||
var SignKinds = []string{KindNodeCertificate, KindEnrolment, KindAuthority}
|
||||
|
||||
// SignContext is the line a message of kind is signed after, so a signature made for one kind never verifies as
|
||||
// another's (as the composition's and the asks' contexts are kept apart).
|
||||
func SignContext(kind string) (string, error) {
|
||||
if !slices.Contains(SignKinds, kind) {
|
||||
return "", fmt.Errorf("meshvaultclient: %q is not a kind the vault signs (%s)", kind, strings.Join(SignKinds, ", "))
|
||||
}
|
||||
return "novox " + kind + " v1\n", nil
|
||||
}
|
||||
|
||||
// SignerFingerprint is an ed25519 public key's fingerprint, `sha256:<hex>` of its 32 bytes.
|
||||
func SignerFingerprint(key ed25519.PublicKey) string {
|
||||
sum := sha256.Sum256(key)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// AuthorityFingerprint is an authority certificate's fingerprint, `sha256:<hex>` of its DER; refused when certificate is
|
||||
// not one PEM certificate.
|
||||
func AuthorityFingerprint(certificate string) (string, error) {
|
||||
c, err := ParseCertificate(certificate)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(c.Raw)
|
||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// ParseCertificate is the first certificate of a PEM text.
|
||||
func ParseCertificate(text string) (*x509.Certificate, error) {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(text)))
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return nil, errors.New("meshvaultclient: not a PEM certificate")
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
|
||||
// NewSignerKey is the value of `key` for pub.
|
||||
func NewSignerKey(pub ed25519.PublicKey, made time.Time) seat.SignersByKeyValue {
|
||||
return seat.SignersByKeyValue{V: 1, Key: base64.StdEncoding.EncodeToString(pub), Fingerprint: SignerFingerprint(pub), Made: made.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
// PublicKey is k's key, checked: an ed25519 public key whose fingerprint is the one k names.
|
||||
func SignerPublicKey(k seat.SignersByKeyValue) (ed25519.PublicKey, error) {
|
||||
raw, err := base64.StdEncoding.DecodeString(k.Key)
|
||||
if err != nil || len(raw) != ed25519.PublicKeySize {
|
||||
return nil, errors.New("meshvaultclient: the signers' key is not an ed25519 public key in base64")
|
||||
}
|
||||
if fp := SignerFingerprint(raw); fp != k.Fingerprint {
|
||||
return nil, fmt.Errorf("meshvaultclient: the signers' key has the fingerprint %s, and names %s", fp, k.Fingerprint)
|
||||
}
|
||||
return ed25519.PublicKey(raw), nil
|
||||
}
|
||||
|
||||
// DecodeSignerKey reads the key `key` of `signers`, refused when it does not check.
|
||||
func DecodeSignerKey(raw []byte) (seat.SignersByKeyValue, error) {
|
||||
k, err := seat.DecodeSignersByKeyValue(raw)
|
||||
if err != nil {
|
||||
return k, fmt.Errorf("meshvaultclient: signers %s: %w", "key", err)
|
||||
}
|
||||
if _, err := SignerPublicKey(k); err != nil {
|
||||
return seat.SignersByKeyValue{}, err
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// DecodeSignerAuthority reads the key `authority` of `signers`: one authority certificate (a CA that signs leaves
|
||||
// alone) whose fingerprint is the one named.
|
||||
func DecodeSignerAuthority(raw []byte) (seat.SignersByAuthorityValue, error) {
|
||||
a, err := seat.DecodeSignersByAuthorityValue(raw)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("meshvaultclient: signers %s: %w", "authority", err)
|
||||
}
|
||||
c, err := ParseCertificate(a.Certificate)
|
||||
if err != nil {
|
||||
return seat.SignersByAuthorityValue{}, err
|
||||
}
|
||||
if !c.IsCA {
|
||||
return seat.SignersByAuthorityValue{}, errors.New("meshvaultclient: the signers' authority is not an authority certificate")
|
||||
}
|
||||
if fp, _ := AuthorityFingerprint(a.Certificate); fp != a.Fingerprint {
|
||||
return seat.SignersByAuthorityValue{}, fmt.Errorf("meshvaultclient: the signers' authority has the fingerprint %s, and names %s", fp, a.Fingerprint)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Signer is one key a node trusts, by who holds it (SignerVault, or the operator's).
|
||||
type Signer struct {
|
||||
Name string
|
||||
Key ed25519.PublicKey
|
||||
}
|
||||
|
||||
// Fingerprint is the signer's key's fingerprint.
|
||||
func (s Signer) Fingerprint() string { return SignerFingerprint(s.Key) }
|
||||
|
||||
// VerifyBy is the trusted signer whose key verifies sig over context and msg: refused when no trusted key does, saying
|
||||
// so, and when a key is not an ed25519 key. A signature made under another context never verifies.
|
||||
func VerifyBy(trusted []Signer, context string, msg, sig []byte) (Signer, error) {
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return Signer{}, errors.New("meshvaultclient: not an ed25519 signature")
|
||||
}
|
||||
message := append([]byte(context), msg...)
|
||||
for _, s := range trusted {
|
||||
if len(s.Key) != ed25519.PublicKeySize {
|
||||
continue
|
||||
}
|
||||
if ed25519.Verify(s.Key, message, sig) {
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(trusted))
|
||||
for _, s := range trusted {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
return Signer{}, fmt.Errorf("meshvaultclient: the signature verifies against no trusted key (%s)", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// SignMessage is sig over context and msg by key: what the vault's `sign` does for a kind whose bytes are given.
|
||||
func SignMessage(key ed25519.PrivateKey, context string, msg []byte) []byte {
|
||||
return ed25519.Sign(key, append([]byte(context), msg...))
|
||||
}
|
||||
|
||||
// DigestOf is the digest a `signed` names: `sha256:<hex>` of the bytes signed.
|
||||
func DigestOf(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// NodeCertificateTemplate is the certificate the vault signs for node's internal name, as the controller's authority
|
||||
// shaped it (identity.Certify): the name in the subject and the subject alternative names, server and client use, the
|
||||
// serial derived from the name and the key so the same ask gives the same certificate, and its life from notBefore.
|
||||
func NodeCertificateTemplate(name string, servingKey ed25519.PublicKey, notBefore time.Time) *x509.Certificate {
|
||||
sum := sha256.Sum256(append([]byte(name+"\n"), servingKey...))
|
||||
return &x509.Certificate{
|
||||
SerialNumber: new(big.Int).SetBytes(sum[:16]),
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
DNSNames: []string{name},
|
||||
NotBefore: notBefore.UTC().Truncate(time.Second),
|
||||
NotAfter: notBefore.UTC().Truncate(time.Second).Add(CertificateLife),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorityTemplate is the vault's authority certificate for its key, made once: an authority that signs leaves and no
|
||||
// other authority, as the controller's.
|
||||
func AuthorityTemplate(key ed25519.PublicKey, made time.Time) *x509.Certificate {
|
||||
sum := sha256.Sum256(append([]byte("authority\n"), key...))
|
||||
return &x509.Certificate{
|
||||
SerialNumber: new(big.Int).SetBytes(sum[:16]),
|
||||
Subject: pkix.Name{CommonName: "the mesh's vault"},
|
||||
NotBefore: made.UTC().Truncate(time.Second),
|
||||
NotAfter: made.UTC().Truncate(time.Second).Add(2 * CertificateLife),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
MaxPathLen: 0,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckNodeCertificate says why certificate is not a certificate of name for servingKey by one of the authorities, or
|
||||
// nil: a node uses it to tell whether its record holds a certificate from an authority it trusts, and another node to
|
||||
// check one the vault signed (ADR 0550 §2's live check).
|
||||
func CheckNodeCertificate(certificate string, authorities []string, name string, servingKey ed25519.PublicKey, at time.Time) error {
|
||||
c, err := ParseCertificate(certificate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
for _, a := range authorities {
|
||||
pool.AppendCertsFromPEM([]byte(a)) // a bundle is every certificate it holds
|
||||
}
|
||||
if _, err := c.Verify(x509.VerifyOptions{Roots: pool, DNSName: name, CurrentTime: at,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}}); err != nil {
|
||||
return fmt.Errorf("meshvaultclient: the certificate of %s is not one a trusted authority signed for it: %w", name, err)
|
||||
}
|
||||
held, ok := c.PublicKey.(ed25519.PublicKey)
|
||||
if !ok || !held.Equal(servingKey) {
|
||||
return fmt.Errorf("meshvaultclient: the certificate of %s binds another key than the node serves with", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuthorityBundle is the authorities given as one PEM text, each certificate once, in the order given, blanks left out:
|
||||
// what a module asking for the mesh's authority is given once the vault signs beside the controller (ADR 0550 §2). An
|
||||
// authority given is split into its certificates, so a bundle given again (the one a node kept from its last
|
||||
// composition) adds nothing twice; a text that holds no PEM block is taken whole.
|
||||
func AuthorityBundle(authorities ...string) string {
|
||||
var out []string
|
||||
for _, a := range authorities {
|
||||
for _, piece := range pemPieces(a) {
|
||||
if !slices.Contains(out, piece) {
|
||||
out = append(out, piece)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(out, "\n") + "\n"
|
||||
}
|
||||
|
||||
// pemPieces is each PEM block of text re-encoded, or the trimmed text whole when it holds none.
|
||||
func pemPieces(text string) []string {
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
rest := []byte(text)
|
||||
for {
|
||||
block, more := pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
out = append(out, strings.TrimSpace(string(pem.EncodeToMemory(block))))
|
||||
rest = more
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{text}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Trusts is what a node states it trusts now (the node record's trusts): `<who> key <fingerprint>` for each signer and
|
||||
// `<who> authority <fingerprint>` for each authority certificate, by who holds it, each once, sorted. The gate before the
|
||||
// lease write reads `vault key <fingerprint>` in every node's record.
|
||||
func Trusts(signers []Signer, authorities map[string]string) []string {
|
||||
var out []string
|
||||
for _, s := range signers {
|
||||
if len(s.Key) == ed25519.PublicKeySize {
|
||||
out = append(out, s.Name+" key "+s.Fingerprint())
|
||||
}
|
||||
}
|
||||
for who, a := range authorities {
|
||||
if fp, err := AuthorityFingerprint(a); err == nil {
|
||||
out = append(out, who+" authority "+fp)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return slices.Compact(out)
|
||||
}
|
||||
|
||||
// TrustsKey says whether trusts names the key with fingerprint fp held by who.
|
||||
func TrustsKey(trusts []string, who, fp string) bool { return slices.Contains(trusts, who+" key "+fp) }
|
||||
Reference in New Issue
Block a user