node-engine client: the adoption handing's two ends (HandAccounts, OpenHanded, HandedAccount, KeyFingerprint, IsBcrypt) over the seat's AccountsHanded (B5f; ADR 0634 §4; issue 1966)

Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
2026-11-05 15:54:21 +01:00
parent bb2140c236
commit d8d319e3ff
2 changed files with 123 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
package nodeengineclient
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
seat "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// The adoption handing of novox/hq ADR 0547 clause 9 (issue 1945): the node-engine of the node holding mesh-broker
// hands the bcrypt hashes of the bus accounts, in the bus module's have, sealed to the vault's own key (the seat's
// AccountsHanded), so that of every reader of the have state the vault alone can open them. These are the two ends of
// that box, which the node-engine and the vault both hold.
// HandedAccount is one user of an AccountsHanded box, opened: the bus user and the bcrypt hash the loaded list carries.
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[:])
}
// 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) (seat.AccountsHanded, error) {
if strings.TrimSpace(vaultPEM) == "" {
return seat.AccountsHanded{}, errors.New("node-engine: no vault key to seal the handed accounts to")
}
body, err := json.Marshal(accounts)
if err != nil {
return seat.AccountsHanded{}, err
}
box, err := seal.Seal(string(body), vaultPEM)
if err != nil {
return seat.AccountsHanded{}, fmt.Errorf("node-engine: the handed accounts could not be sealed to the vault's key: %w", err)
}
return seat.AccountsHanded{From: from, To: KeyFingerprint(vaultPEM), Users: int64(len(accounts)),
Box: seat.SealedBox{Ct: box.Ct, Eph: box.Eph, Iv: box.IV, Tag: box.Tag, V: int64(box.V)}, 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 seat.AccountsHanded, publicPEM, privatePEM string) ([]HandedAccount, error) {
if h.To != KeyFingerprint(publicPEM) {
return nil, fmt.Errorf("node-engine: the handing is sealed to the key %s, not to this one (%s)", h.To, KeyFingerprint(publicPEM))
}
plain, err := seal.Open(seal.Box{V: int(h.Box.V), Eph: h.Box.Eph, IV: h.Box.Iv, Tag: h.Box.Tag, Ct: h.Box.Ct}, privatePEM)
if err != nil {
return nil, fmt.Errorf("node-engine: the handing does not open: %w", err)
}
var out []HandedAccount
if err := json.Unmarshal([]byte(plain), &out); err != nil {
return nil, fmt.Errorf("node-engine: the handing holds no accounts: %w", err)
}
if int64(len(out)) != h.Users {
return nil, fmt.Errorf("node-engine: the handing says %d users and holds %d", h.Users, len(out))
}
return out, nil
}
@@ -0,0 +1,47 @@
package nodeengineclient
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// A handing is sealed to the vault's key and opened with it alone; another key, a wrong count and a plain value are refused.
func TestAHandingOpensWithTheVaultsKeyAlone(t *testing.T) {
vault, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
other, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
hash := "$2a$10$" + strings.Repeat("a", 53)
accounts := []HandedAccount{{User: "a", Hash: hash}, {User: "b", Hash: hash}}
h, err := HandAccounts(accounts, vault.PublicKey, "sha256:list", time.Date(2026, 11, 5, 10, 0, 0, 0, time.FixedZone("x", 3600)))
if err != nil {
t.Fatal(err)
}
if h.Users != 2 || h.To != KeyFingerprint(vault.PublicKey) || h.At.Location() != time.UTC {
t.Errorf("handing = %+v", h)
}
got, err := OpenHanded(h, vault.PublicKey, vault.PrivateKey)
if err != nil || len(got) != 2 || got[1].User != "b" {
t.Errorf("opened %v, %v", got, err)
}
if _, err := OpenHanded(h, other.PublicKey, other.PrivateKey); err == nil {
t.Error("a handing sealed to the vault opened for another key")
}
h.Users = 3
if _, err := OpenHanded(h, vault.PublicKey, vault.PrivateKey); err == nil {
t.Error("a handing that says 3 users and holds 2 was opened")
}
if !IsBcrypt(hash) || IsBcrypt("plain") {
t.Error("IsBcrypt")
}
if _, err := HandAccounts(accounts, " ", "x", time.Now()); err == nil {
t.Error("sealed to no key")
}
}