diff --git a/go/clients/nodeengineclient/handed.go b/go/clients/nodeengineclient/handed.go new file mode 100644 index 0000000..b8c7490 --- /dev/null +++ b/go/clients/nodeengineclient/handed.go @@ -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:` 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 +} diff --git a/go/clients/nodeengineclient/handed_test.go b/go/clients/nodeengineclient/handed_test.go new file mode 100644 index 0000000..e3cf50b --- /dev/null +++ b/go/clients/nodeengineclient/handed_test.go @@ -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") + } +}