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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user