diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 8fccfaa..cea8059 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -34,9 +34,12 @@ // **The release 4 layouts** (issues 1356 to 1359; ADR 0453 clauses 4, 6 and 16): the new fingerprint form (fingerprint2.go), // the indexer's manifests by digest and the new fields of its modules and candidates (manifests.go, source.go), the // builder's build keys (builder.go) and the checker's verdicts and kept verdicts (checker.go). A manifests value is the -// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. +// one exception to the size above: a manifest is up to about 21 KB, whole, byte for byte. The bus module's have on the +// node holding mesh-broker is the other: its user list's handing carries a sealed box of every loaded user's hash, some +// kilobytes (handed.go). // -// Standard library only, and this module's own asks and declaration packages (a composition holds declaration resources). +// Standard library only, and this module's own asks, declaration and seal packages (a composition holds declaration +// resources; the adoption handing is a sealed box, handed.go). package delivery import ( @@ -352,6 +355,9 @@ type UserListWritten struct { Users int `json:"users"` At time.Time `json:"at"` Error string `json:"error,omitempty"` + // Handed is the hashes of the users of the list on the machine, sealed to the vault, for its adoption of the + // accounts it holds no value for (ADR 0547 clause 9, handed.go); nil while the vault's key is not known. + Handed *AccountsHanded `json:"handed,omitempty"` } // HaveNode is a node's own facts, written under HaveNodeKey. Each field is the node-engine's own JSON, carried whole: @@ -502,7 +508,8 @@ func SameHave(a, b Have) bool { } x, y := *ua, *ub x.At, y.At = time.Time{}, time.Time{} - return x == y && ua.At.Equal(ub.At) + x.Handed, y.Handed = nil, nil + return x == y && ua.At.Equal(ub.At) && sameHanded(ua.Handed, ub.Handed) } // SameHaveNode says whether two node facts are the same value, comparing each carried JSON without its spacing. diff --git a/go/delivery/delivery_test.go b/go/delivery/delivery_test.go index a0aba8d..3ed5072 100644 --- a/go/delivery/delivery_test.go +++ b/go/delivery/delivery_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/seal" ) // 1131/AC1: the have key forms and parses back; bad names are refused, naming them. @@ -167,6 +169,47 @@ func TestRoundTrip(t *testing.T) { if !reflect.DeepEqual(out, in) || !out.UserList.At.Equal(at) { t.Fatalf("got %+v want %+v", out, in) } + // And with the hashes handed to the vault (ADR 0547 clause 9): sealed to its key, opened by it alone, every + // listed user with a bcrypt hash once, and refused to another key. + hash := "$2a$11$" + strings.Repeat("a", 53) + list := []byte("{ user: \"controller\", password: \"" + hash + "\", permissions: {\n" + + "{ user: \"node.ace\", password: \"" + hash + "\", permissions: {\n" + + "{ user: \"controller\", password: \"" + hash + "\" }\n{ user: \"nkey\", password: \"plain\" }\n") + accounts := HandedAccountsOf(list) + if len(accounts) != 2 || accounts[0].User != "controller" || accounts[1].User != "node.ace" || accounts[1].Hash != hash { + t.Fatalf("handed accounts: %+v", accounts) + } + vault, err := seal.GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + other, _ := seal.GenerateKeyPair() + handed, err := HandAccounts(accounts, vault.PublicKey, "sha256:ab", at) + if err != nil { + t.Fatal(err) + } + in.UserList.Handed = &handed + b, err = EncodeHave(in) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), "node.ace") || strings.Contains(string(b), hash) { + t.Fatalf("a user or a hash is in the clear: %s", b) + } + out, err = DecodeHave(b) + if err != nil || !SameHave(out, in) { + t.Fatalf("handed round trip: %v %+v", err, out.UserList) + } + opened, err := OpenHanded(*out.UserList.Handed, vault.PublicKey, vault.PrivateKey) + if err != nil || !reflect.DeepEqual(opened, accounts) { + t.Fatalf("opened %+v, %v", opened, err) + } + if _, err := OpenHanded(*out.UserList.Handed, other.PublicKey, other.PrivateKey); err == nil { + t.Fatal("a handing opened for another key") + } + if _, err := HandAccounts(accounts, "", "sha256:ab", at); err == nil { + t.Fatal("handed with no vault key") + } }) t.Run("have node", func(t *testing.T) { in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)} @@ -306,6 +349,20 @@ func TestSame(t *testing.T) { if SameHave(ua, ub) { t.Error("different user lists same") } + // A handing is compared by what it says: one said and one not differ, as do two to other keys. + ub.UserList.Revision = 1 + ua.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at} + if SameHave(ua, ub) { + t.Error("a handing said and one not are the same") + } + ub.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at.In(time.Local)} + if !SameHave(ua, ub) { + t.Error("equal handings differ") + } + ub.UserList.Handed.To = "sha256:j" + if SameHave(ua, ub) { + t.Error("handings to other keys same") + } n := HaveNode{V: 1, Busy: json.RawMessage(`true`)} if !SameHaveNode(n, HaveNode{V: 1, Busy: json.RawMessage(`true`)}) || SameHaveNode(n, HaveNode{V: 1}) { t.Error("have node") diff --git a/go/delivery/handed.go b/go/delivery/handed.go new file mode 100644 index 0000000..af0bc3c --- /dev/null +++ b/go/delivery/handed.go @@ -0,0 +1,125 @@ +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:` (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:` 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) +}