Files
mesh-sdk/go/delivery/handed.go
T
jschoubben 1747899419 delivery: the bus node hands the loaded list's hashes to the vault, sealed, in the bus module's have (ADR 0547 clause 9, issue 1945)
The vault holds no value for the controller's own account, the node accounts and the enrolled devices, so it cannot
hash them; the one part that has their hashes is the node-engine of the bus node, in the list the bus loaded. The have
of the bus module now carries that list's hashes sealed to the vault's key (UserListWritten.Handed), which every reader
of the have bucket sees as a count, a list digest and a key fingerprint, and only the vault opens. HandedAccountsOf
reads them from a composed list, HandAccounts seals, OpenHanded opens and refuses a box sealed to another key.
2026-10-26 11:28:38 +01:00

126 lines
5.2 KiB
Go

package delivery
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the vault adopts, once and before `take-over`, the
// bcrypt hashes of the bus accounts it holds no value for (the controller's own, the node accounts, the enrolled
// devices) from the list the bus has loaded. The node-engine of the node holding mesh-broker is the one part that has
// that list, so it hands the hashes in the bus module's have, beside what it says of the list it wrote: sealed to the
// vault's own key, which it learns from the vault's answer to `node-key`, so that of every reader of the have bucket
// the vault alone can open them. The have says in the clear only how many users it hands, from which list and to which
// key; the users' names stay sealed, as the list itself is (ADR 0547 clause 7: it names every account).
//
// It hands every user of the loaded list; the vault takes from it only the users it holds no value for, and never
// replaces a hash it made from a value it holds, so the node-engine never needs to read the vault's state (which the
// grants part alone is to read, clause 9).
// AccountsHanded is the hashes of the loaded list's users, sealed to the vault, in UserListWritten.
type AccountsHanded struct {
// From is the digest of the list on the machine the hashes are taken from, `sha256:<hex>` (UserListWritten.Digest).
From string `json:"from"`
// To is the fingerprint of the vault's key the box is sealed to (KeyFingerprint).
To string `json:"to"`
// Users is how many users the box holds.
Users int `json:"users"`
// Box is the JSON of []HandedAccount, sealed to the vault's key (ADR 0206).
Box seal.Box `json:"box"`
At time.Time `json:"at"`
}
// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list
// carries for it.
type HandedAccount struct {
User string `json:"user"`
Hash string `json:"hash"`
}
// KeyFingerprint is a public key's fingerprint as a handing names it: `sha256:<hex>` of its PEM, without the space
// around it.
func KeyFingerprint(publicPEM string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(publicPEM)))
return "sha256:" + hex.EncodeToString(sum[:])
}
// listedAccount is one user of a bus user list as the composer writes it (buscompose), with its password's hash.
var listedAccount = regexp.MustCompile(`\{\s*user:\s*"([^"]+)",\s*password:\s*"([^"]+)"`)
// HandedAccountsOf is every user of a bus user list that carries a bcrypt hash, in the list's order. A user listed
// twice is handed once, as the list first names it.
func HandedAccountsOf(list []byte) []HandedAccount {
var out []HandedAccount
seen := map[string]bool{}
for _, m := range listedAccount.FindAllSubmatch(list, -1) {
user, hash := string(m[1]), string(m[2])
if seen[user] || !IsBcrypt(hash) {
continue
}
seen[user] = true
out = append(out, HandedAccount{User: user, Hash: hash})
}
return out
}
// bcryptHash is the form of a bcrypt hash: version, cost, and 53 characters of salt and digest.
var bcryptHash = regexp.MustCompile(`^\$2[abxy]?\$[0-9]{2}\$[./A-Za-z0-9]{53}$`)
// IsBcrypt says whether s has the form of a bcrypt hash.
func IsBcrypt(s string) bool { return bcryptHash.MatchString(s) }
// HandAccounts seals the accounts to the vault's public key (SPKI PEM) as the handing of the list whose digest is from.
func HandAccounts(accounts []HandedAccount, vaultPEM, from string, at time.Time) (AccountsHanded, error) {
if strings.TrimSpace(vaultPEM) == "" {
return AccountsHanded{}, errors.New("delivery: no vault key to seal the handed accounts to")
}
body, err := json.Marshal(accounts)
if err != nil {
return AccountsHanded{}, err
}
box, err := seal.Seal(string(body), vaultPEM)
if err != nil {
return AccountsHanded{}, fmt.Errorf("delivery: the handed accounts could not be sealed to the vault's key: %w", err)
}
return AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: len(accounts), Box: box, At: at.UTC()}, nil
}
// OpenHanded opens a handing with the vault's private key (PEM), refusing one sealed to another key than publicPEM's
// and a box that does not hold as many users as it says.
func OpenHanded(h AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) {
if h.To != KeyFingerprint(publicPEM) {
return nil, fmt.Errorf("delivery: the handing is sealed to the key %s, not to this one (%s)", h.To,
KeyFingerprint(publicPEM))
}
plain, err := seal.Open(h.Box, privatePEM)
if err != nil {
return nil, fmt.Errorf("delivery: the handing does not open: %w", err)
}
var out []HandedAccount
if err := json.Unmarshal([]byte(plain), &out); err != nil {
return nil, fmt.Errorf("delivery: the handing holds no accounts: %w", err)
}
if len(out) != h.Users {
return nil, fmt.Errorf("delivery: the handing says %d users and holds %d", h.Users, len(out))
}
return out, nil
}
// sameHanded says whether two handings say the same: the same list, key, count and box, whatever zone their time is in.
func sameHanded(a, b *AccountsHanded) bool {
if a == nil || b == nil {
return a == b
}
x, y := *a, *b
x.At, y.At = time.Time{}, time.Time{}
return x == y && a.At.Equal(b.At)
}