From 1b36bcdb68d9da2b97cc23503a60b3eaef607e89 Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:45:51 +0000 Subject: [PATCH 1/3] Move the sealed box into the SDK, so the vault and providers need not copy it (issue 650) go/seal and its TypeScript twin @novox/mesh-sdk/seal are the ADR 0206 box the licence manager kept to itself. Fixtures made by the licence manager's own seal.go prove both byte for byte, in both directions. Co-Authored-By: Claude Opus 5.5 --- go/README.md | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/go/README.md b/go/README.md index 76ffd0f..311c8dd 100644 --- a/go/README.md +++ b/go/README.md @@ -1,19 +1 @@ -# mesh-sdk for Go - -`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime -names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a -function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `.` is the seat's implementation. -`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). Standard library only. - -## Installing - -``` -GOPRIVATE=git.novox.be go get git.novox.be/novox/mesh-sdk/go@latest -``` - -Releases are tagged `go/vX.Y.Z` in this repository, the subdirectory form Go's module system expects, -at the same version as the TypeScript package. - -What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol -`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one -answer. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. +PLACEHOLDER \ No newline at end of file -- 2.54.0 From adfb9c84b50400472411268af7d09e5d14ebe11d Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:47:21 +0000 Subject: [PATCH 2/3] Move the sealed box into the SDK, so the vault and providers need not copy it (issue 650) go/seal and its TypeScript twin @novox/mesh-sdk/seal are the ADR 0206 box the licence manager kept to itself. Fixtures made by the licence manager's own seal.go prove both byte for byte, in both directions. (Replaces the placeholder README the branch's first commit carried by mistake.) Co-Authored-By: Claude Opus 5.5 --- go/README.md | 20 +- go/seal/seal.go | 203 ++++++++++++++++++ go/seal/seal_test.go | 156 ++++++++++++++ .../testdata/sealed-by-licence-manager.json | 121 +++++++++++ go/seal/testdata/sealed-by-typescript.json | 12 ++ package.json | 30 +-- 6 files changed, 512 insertions(+), 30 deletions(-) create mode 100644 go/seal/seal.go create mode 100644 go/seal/seal_test.go create mode 100644 go/seal/testdata/sealed-by-licence-manager.json create mode 100644 go/seal/testdata/sealed-by-typescript.json diff --git a/go/README.md b/go/README.md index 311c8dd..e6903ea 100644 --- a/go/README.md +++ b/go/README.md @@ -1 +1,19 @@ -PLACEHOLDER \ No newline at end of file +# mesh-sdk for Go + +`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime +names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a +function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `.` is the seat's implementation. +`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). Standard library only. + +## Installing + +``` +GOPRIVATE=git.novox.be go get git.novox.be/novox/mesh-sdk/go@latest +``` + +Releases are tagged `go/vX.Y.Z` in this repository, the subdirectory form Go's module system expects, +at the same version as the TypeScript package. + +What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol +`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one +answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. diff --git a/go/seal/seal.go b/go/seal/seal.go new file mode 100644 index 0000000..0aeb8ca --- /dev/null +++ b/go/seal/seal.go @@ -0,0 +1,203 @@ +// Package seal is the mesh's sealed box (novox/hq ADR 0183, ADR 0206): a value sealed to one recipient's +// public key, which only that recipient's private key opens. X25519 for the agreement, HKDF-SHA256 for the +// key, AES-256-GCM for the box — the box the licence manager made and opened before it lived here, byte +// for byte, and the one the SDK's TypeScript `seal` makes and opens. +// +// A box is `{ v: 1, eph, iv, tag, ct }`, every field standard base64; `eph` is the one-time public key as +// SPKI DER. The key is derived with the salt eph-DER ‖ the recipient's raw public key and the info +// "novox-mesh sealed box v1", so a box cannot be re-addressed to another key. +// +// Keys travel as PEM: the public key as SPKI ("PUBLIC KEY"), the private key as PKCS #8 ("PRIVATE KEY"). +// Standard library only. +package seal + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" +) + +// Box is a value sealed to one recipient, as it travels in JSON. +type Box struct { + V int `json:"v"` + Eph string `json:"eph"` + IV string `json:"iv"` + Tag string `json:"tag"` + Ct string `json:"ct"` +} + +// KeyPair is a recipient's keypair as the two PEM strings it is kept and sent as. +type KeyPair struct { + PublicKey string `json:"publicKey"` + PrivateKey string `json:"privateKey"` +} + +// Version is the only box version Open opens. +const Version = 1 + +const info = "novox-mesh sealed box v1" + +// GenerateKeyPair makes a fresh X25519 keypair, PEM-encoded. +func GenerateKeyPair() (KeyPair, error) { + priv, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return KeyPair{}, err + } + pubDER, err := x509.MarshalPKIXPublicKey(priv.PublicKey()) + if err != nil { + return KeyPair{}, err + } + privDER, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return KeyPair{}, err + } + return KeyPair{ + PublicKey: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})), + PrivateKey: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})), + }, nil +} + +// Seal seals plaintext to the recipient's PEM public key, with a fresh one-time key and iv. +func Seal(plaintext, recipientPEM string) (Box, error) { + eph, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return Box{}, err + } + iv := make([]byte, 12) + if _, err := rand.Read(iv); err != nil { + return Box{}, err + } + return sealWith(plaintext, recipientPEM, eph, iv) +} + +// sealWith is Seal with its two sources of randomness given, for the byte-for-byte tests. +func sealWith(plaintext, recipientPEM string, eph *ecdh.PrivateKey, iv []byte) (Box, error) { + recipient, err := publicFromPEM(recipientPEM) + if err != nil { + return Box{}, err + } + secret, err := eph.ECDH(recipient) + if err != nil { + return Box{}, err + } + ephDER, err := x509.MarshalPKIXPublicKey(eph.PublicKey()) + if err != nil { + return Box{}, err + } + gcm, err := newGCM(boxKey(secret, ephDER, recipient.Bytes())) + if err != nil { + return Box{}, err + } + out := gcm.Seal(nil, iv, []byte(plaintext), nil) + ct, tag := out[:len(out)-gcm.Overhead()], out[len(out)-gcm.Overhead():] + b64 := base64.StdEncoding.EncodeToString + return Box{V: Version, Eph: b64(ephDER), IV: b64(iv), Tag: b64(tag), Ct: b64(ct)}, nil +} + +// Open opens a box with the recipient's PEM private key. It fails for a box sealed to another key, one +// changed in any field, or one of another version. +func Open(box Box, privatePEM string) (string, error) { + if box.V != Version { + return "", fmt.Errorf("a sealed box of version %d, not %d", box.V, Version) + } + priv, err := privateFromPEM(privatePEM) + if err != nil { + return "", err + } + d := base64.StdEncoding.DecodeString + ephDER, err := d(box.Eph) + if err != nil { + return "", fmt.Errorf("the box's eph: %w", err) + } + ephKey, err := x509.ParsePKIXPublicKey(ephDER) + if err != nil { + return "", fmt.Errorf("the box's eph: %w", err) + } + eph, ok := ephKey.(*ecdh.PublicKey) + if !ok || eph.Curve() != ecdh.X25519() { + return "", errors.New("the box's eph is not an X25519 key") + } + secret, err := priv.ECDH(eph) + if err != nil { + return "", err + } + iv, err1 := d(box.IV) + tag, err2 := d(box.Tag) + ct, err3 := d(box.Ct) + if err := errors.Join(err1, err2, err3); err != nil { + return "", fmt.Errorf("the box's fields: %w", err) + } + gcm, err := newGCM(boxKey(secret, ephDER, priv.PublicKey().Bytes())) + if err != nil { + return "", err + } + if len(iv) != gcm.NonceSize() || len(tag) != gcm.Overhead() { + return "", errors.New("the box does not open with this key") + } + plain, err := gcm.Open(nil, iv, append(append([]byte{}, ct...), tag...), nil) + if err != nil { + return "", errors.New("the box does not open with this key") + } + return string(plain), nil +} + +func publicFromPEM(p string) (*ecdh.PublicKey, error) { + block, _ := pem.Decode([]byte(p)) + if block == nil { + return nil, errors.New("not a PEM public key") + } + k, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, err + } + pub, ok := k.(*ecdh.PublicKey) + if !ok || pub.Curve() != ecdh.X25519() { + return nil, errors.New("not an X25519 public key") + } + return pub, nil +} + +func privateFromPEM(p string) (*ecdh.PrivateKey, error) { + block, _ := pem.Decode([]byte(p)) + if block == nil { + return nil, errors.New("not a PEM private key") + } + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + priv, ok := k.(*ecdh.PrivateKey) + if !ok || priv.Curve() != ecdh.X25519() { + return nil, errors.New("not an X25519 private key") + } + return priv, nil +} + +// boxKey is HKDF-SHA256 (RFC 5869) for one 32-byte block: written out because crypto/hkdf needs Go 1.24 +// and this module promises 1.22; the vectors prove it equals the licence manager's hkdf.Key. +func boxKey(secret, ephDER, recipientRaw []byte) []byte { + salt := append(append([]byte{}, ephDER...), recipientRaw...) + extract := hmac.New(sha256.New, salt) + extract.Write(secret) + prk := extract.Sum(nil) + expand := hmac.New(sha256.New, prk) + expand.Write([]byte(info)) + expand.Write([]byte{1}) + return expand.Sum(nil) +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} diff --git a/go/seal/seal_test.go b/go/seal/seal_test.go new file mode 100644 index 0000000..2a2786e --- /dev/null +++ b/go/seal/seal_test.go @@ -0,0 +1,156 @@ +package seal + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "os" + "testing" +) + +type fixture struct { + Recipient KeyPair `json:"recipient"` + Sealed []struct { + Plaintext string `json:"plaintext"` + Box Box `json:"box"` + } `json:"sealed"` + Vectors []struct { + Plaintext string `json:"plaintext"` + EphPrivate string `json:"ephPrivate"` + IV string `json:"iv"` + Box Box `json:"box"` + } `json:"vectors"` +} + +func readFixture(t *testing.T) fixture { + t.Helper() + raw, err := os.ReadFile("testdata/sealed-by-licence-manager.json") + if err != nil { + t.Fatal(err) + } + var f fixture + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if len(f.Sealed) == 0 || len(f.Vectors) == 0 { + t.Fatal("the fixture holds no boxes") + } + return f +} + +// Every box the licence manager's own Seal made opens here to its plaintext. +func TestABoxTheLicenceManagerSealedOpens(t *testing.T) { + f := readFixture(t) + for _, s := range f.Sealed { + got, err := Open(s.Box, f.Recipient.PrivateKey) + if err != nil || got != s.Plaintext { + t.Fatalf("opened %q, %v; want %q", got, err, s.Plaintext) + } + } +} + +// Given the same one-time key and iv, this package seals byte for byte what the licence manager sealed: +// the reverse direction, so whatever opens the licence manager's boxes opens these. +func TestSealIsByteForByteTheLicenceManagersBox(t *testing.T) { + f := readFixture(t) + for _, v := range f.Vectors { + d := base64.StdEncoding.DecodeString + ephRaw, err1 := d(v.EphPrivate) + iv, err2 := d(v.IV) + if err1 != nil || err2 != nil { + t.Fatal(err1, err2) + } + eph, err := ecdh.X25519().NewPrivateKey(ephRaw) + if err != nil { + t.Fatal(err) + } + got, err := sealWith(v.Plaintext, f.Recipient.PublicKey, eph, iv) + if err != nil { + t.Fatal(err) + } + if got != v.Box { + t.Fatalf("sealed %+v\nthe licence manager sealed %+v", got, v.Box) + } + if plain, err := Open(got, f.Recipient.PrivateKey); err != nil || plain != v.Plaintext { + t.Fatalf("reopened %q, %v", plain, err) + } + } +} + +// A box the agent module's TypeScript sealed (the fixture the licence manager keeps) opens here too. +func TestABoxSealedInTypeScriptOpens(t *testing.T) { + raw, err := os.ReadFile("testdata/sealed-by-typescript.json") + if err != nil { + t.Fatal(err) + } + var f struct { + PrivateKey string `json:"privateKey"` + Box Box `json:"box"` + Plaintext string `json:"plaintext"` + } + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatal(err) + } + if got, err := Open(f.Box, f.PrivateKey); err != nil || got != f.Plaintext { + t.Fatalf("opened %q, %v", got, err) + } +} + +// What Seal makes opens with its recipient's key and no other, and a fresh box differs every time. +func TestABoxOpensOnlyForItsRecipient(t *testing.T) { + a, err := GenerateKeyPair() + if err != nil { + t.Fatal(err) + } + b, _ := GenerateKeyPair() + box, err := Seal("a token", a.PublicKey) + if err != nil { + t.Fatal(err) + } + if got, err := Open(box, a.PrivateKey); err != nil || got != "a token" { + t.Fatalf("opened %q, %v", got, err) + } + if _, err := Open(box, b.PrivateKey); err == nil { + t.Fatal("a box opened for another key") + } + again, _ := Seal("a token", a.PublicKey) + if again.Eph == box.Eph || again.IV == box.IV { + t.Fatal("two boxes share a one-time key or iv") + } +} + +// A box changed in any field, or of another version, does not open. +func TestATamperedBoxDoesNotOpen(t *testing.T) { + f := readFixture(t) + good := f.Sealed[1].Box + flip := func(s string) string { + b, _ := base64.StdEncoding.DecodeString(s) + b[len(b)-1] ^= 1 + return base64.StdEncoding.EncodeToString(b) + } + for name, box := range map[string]Box{ + "ct": {V: 1, Eph: good.Eph, IV: good.IV, Tag: good.Tag, Ct: flip(good.Ct)}, + "tag": {V: 1, Eph: good.Eph, IV: good.IV, Tag: flip(good.Tag), Ct: good.Ct}, + "iv": {V: 1, Eph: good.Eph, IV: flip(good.IV), Tag: good.Tag, Ct: good.Ct}, + "eph": {V: 1, Eph: flip(good.Eph), IV: good.IV, Tag: good.Tag, Ct: good.Ct}, + "version": {V: 2, Eph: good.Eph, IV: good.IV, Tag: good.Tag, Ct: good.Ct}, + } { + if _, err := Open(box, f.Recipient.PrivateKey); err == nil { + t.Fatalf("a box with a changed %s opened", name) + } + } +} + +// A key that is not X25519 PEM is refused, not misread. +func TestAKeyOfAnotherKindIsRefused(t *testing.T) { + if _, err := Seal("x", "not a key"); err == nil { + t.Fatal("sealed to text") + } + f := readFixture(t) + if _, err := Open(f.Sealed[0].Box, f.Recipient.PublicKey); err == nil { + t.Fatal("opened with a public key") + } + if _, err := Seal("x", f.Recipient.PrivateKey); err == nil { + t.Fatal("sealed to a private key") + } +} diff --git a/go/seal/testdata/sealed-by-licence-manager.json b/go/seal/testdata/sealed-by-licence-manager.json new file mode 100644 index 0000000..b85ea5a --- /dev/null +++ b/go/seal/testdata/sealed-by-licence-manager.json @@ -0,0 +1,121 @@ +{ + "about": "Made by the claude-licence-manager's seal.go (mesh-catalog feat/57-config-and-provisioning at 8aa37e8), unchanged but for its two sources of randomness, which the vectors fix. The recipient is a test key derived from a fixed label; it guards nothing.", + "recipient": { + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEAkZXnghyfA1BR5rWtvBLmjdZMCbC99xxF3WKHe1E+3XM=\n-----END PUBLIC KEY-----\n", + "privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIHjV954xXPa72FyGUw5PsWCbsHrliVp/7xoAgG9fGs7q\n-----END PRIVATE KEY-----\n" + }, + "sealed": [ + { + "plaintext": "", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAwN2gt32hv+9VyFh0oQ8oetPBHsZ9dt80DzwPwcB0bHg=", + "iv": "0xHCegxP6o48EGLq", + "tag": "VYQZm/lBZB0YMPUqYVPKaQ==", + "ct": "" + } + }, + { + "plaintext": "a grant sealed by the licence manager", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEA54v96LEb8SwT2uJZCa90/2jcLvEbK/BZpbMS6TxAAW0=", + "iv": "xm6Jg4oOnRpRpDGK", + "tag": "cmbOzIV63Nv0lPfpTgP2kg==", + "ct": "InWe8PZ8hkd7F9QaHujjhdiBECKzLAK22d2aIEXwBhtxIowc/Q==" + } + }, + { + "plaintext": "sk-ant-api03-test-0123456789abcdefghij\n", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAIXdum2FYLzvLDj/Of2253hH8Lu0szU4tLxJzfXTahDs=", + "iv": "KMPNa60C/Qjhtp0Z", + "tag": "PPa82ZdFyWz9kbLSSGkl3g==", + "ct": "Phu3ZsxIWGlBWbYOGzPopncEbc0VhTAloxzeHs4KDBMFZU9Pet2V" + } + }, + { + "plaintext": "ünïcødé ✓ and a NUL \u0000 inside", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAo1WaRyrAJFM6If/cpFgdpZjsC6XebuJtVSugbI+m8RU=", + "iv": "H9+WvM6ZYbCYJj6D", + "tag": "K5p986nSpeItYK1m4bcVPQ==", + "ct": "eyZs3uVeZguNbqSmrMc1rWgxhW3nP0GABH6Ek06/8UuBqw==" + } + }, + { + "plaintext": "a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; ", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAzjZdTMu08I0Z+FWXSr9f++EADfqg3qr2FVt+3Q/hxm0=", + "iv": "lTG2VwPA5SwHbZE/", + "tag": "Il6ICxoZgZr9dOgeIZOPrg==", + "ct": "ZWQ8m1nxrqq0oIGRoeNlUu9/JpTMrxlncTCL5cDqsJrx9NjS0nZX292JJ5p0+BYSVLUN+yGUuBOnH860ywvKK7XhPOc0Yo2nq7X2O3OYn9Za4y+HK6dVDM8zhsS8P3Di/MJCwS+AnremVCaj2k3xHL3oc331RBZZ5w2JfgSX3ouwl2+VbIPyJVSDsxnLLoHsiIR7nB0GNCe0fynhVkoIEwC8IXLnL6m3rjjN3YCQDmVP2UoftniLAypd5ioc9GCtpvUL6ktjs82JkvEtFbHtc+YzIjP6ZnjTj/bvbRHkIqN4MDiu+fvGOr0jtGWO24ecGC+6KtrKGOaEYHDqOLaK3c+UcrDsFYC+cuKCdCNhs5UNhYYylVXE648KFa488eFk" + } + } + ], + "vectors": [ + { + "plaintext": "", + "ephPrivate": "W8iT0SrRDnazANHYsXHC72sM1wzx+/Y8vowREeGGz/A=", + "iv": "DD7WV6Y8hLGvaG0b", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAdCCqlFhxbKBpv1JClXceH43AqEvcaM6SGfyXx05UJUs=", + "iv": "DD7WV6Y8hLGvaG0b", + "tag": "ctb+ZQjx8FyynvT88rfwkQ==", + "ct": "" + } + }, + { + "plaintext": "a grant sealed by the licence manager", + "ephPrivate": "lzIbkYmIbkKTYDL4dZiAFUFv2h+GWBiqlQfnsVD2An8=", + "iv": "xz2FIBXNM3scOAJb", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAS3L49p4KrGMo/NmHZq8osoPbk2ZCQWLoNIKnpB4T5hg=", + "iv": "xz2FIBXNM3scOAJb", + "tag": "7dC8bYqzsJ/17EC7y6lhCA==", + "ct": "FEMfh4HMvMwqx4QE4YMMR3d3g5rNuxa/+u6u+i/aRLed3q8kBw==" + } + }, + { + "plaintext": "sk-ant-api03-test-0123456789abcdefghij\n", + "ephPrivate": "tAjRQTQHk6nNJLblzdslDbWmyYm8cE6/G4ipXHR0zlE=", + "iv": "BPF1JDQ5mDNhKKr3", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEA1GianODxSXUHYMdUmZLcQztp5ry2/xtwUSeHZWrGFAo=", + "iv": "BPF1JDQ5mDNhKKr3", + "tag": "uiIIFD7LgPmLV3Y6uleSzg==", + "ct": "+maa0GDDOkcyzD0chFQ4a/0NvBrvl23NVqcGv1BEKgmMUenf9HHd" + } + }, + { + "plaintext": "ünïcødé ✓ and a NUL \u0000 inside", + "ephPrivate": "QBYUxuk4jwiWKOPEWeE7v6WxY8BDmZZCDwlY1WJ1PSA=", + "iv": "u76JO8baqokfnjcK", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAePakOoCmmXLtnN3EykbpqfdbfXpkg04F2djxnJWH220=", + "iv": "u76JO8baqokfnjcK", + "tag": "sfBPRnwhfP/p7vlsUx+e9w==", + "ct": "B9yc0FbdKYDtDeX1jmKa+DBP8fPN1O9SbeYBDn9b3+Bq2Q==" + } + }, + { + "plaintext": "a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; a longer value, past one AES block; ", + "ephPrivate": "2VYOyzqLrb+zWUhVpHA9aV3vMAyRWlNPEq2ASf60F9M=", + "iv": "0Gv6ejq07LwbM8cY", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEARSOBczrJ6X1MkxT7Mrd1ryDLmXAMBgYIgpfwBjHz1GA=", + "iv": "0Gv6ejq07LwbM8cY", + "tag": "Vm+E0myXgdMwA6knJDg/+w==", + "ct": "tJwZpUXeQYOu2PG1g9AwIV8RhKKDorUHWQSygEus3xHOpuTwh3+D4j2htIerMB/3AKToQYABeZMyEAOzhL2x+Cg06WlUKrggZjvdX5W7X813KQBgzg0YnnCZTKTYlviDWe1E9TZ3aAauhlcgpBaQXUh/akkDiqeq2Ua5ruY6IGH5HgqWWPYJo3b4zkbArhvdP2HJX9Pn1mQa0BtGMinyutyD0am5vZuemdKGYS1Ao00M81sMXYc2CBy9K48Ofw8GapcmYNHsE0s6NSgNZumnVaR8TQRy/oqEdL3Q6z31hAZMzTdS0ckXg1NCgcgXVuzwNov/D4320E3DxFS3v8Z5p74ZkqAmo/1JcCNJwwQyRCWk/4iJFjP1OKEavOGo+bcp" + } + } + ] +} diff --git a/go/seal/testdata/sealed-by-typescript.json b/go/seal/testdata/sealed-by-typescript.json new file mode 100644 index 0000000..ba811d7 --- /dev/null +++ b/go/seal/testdata/sealed-by-typescript.json @@ -0,0 +1,12 @@ +{ + "privateKey": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VuBCIEIAi2NK/bN+p7cqYUwv/kz72TgLdmJUfOHCDZTrMpQzpS\n-----END PRIVATE KEY-----\n", + "publicKey": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VuAyEARYvD/w+9ah0KWS9T9pd6Ea6CBymUE48vEVk983QPKyY=\n-----END PUBLIC KEY-----\n", + "box": { + "v": 1, + "eph": "MCowBQYDK2VuAyEAIYlSGrJvF8qSjR1aTFWbEQM/NFbZXrarglN0aRLZZ0E=", + "iv": "ABqT/hMukn6+xpjh", + "tag": "POzmsWTPHSn9xLMMJJ9Akg==", + "ct": "m2u0kuQ0PwrsGelM99Z5aBw6FCd6lFISUbwiK5TzzDw4ZrGtsHEvxytoIeFC" + }, + "plaintext": "a grant sealed by the TypeScript agent module" +} \ No newline at end of file diff --git a/package.json b/package.json index cd796d0..e30efcb 100644 --- a/package.json +++ b/package.json @@ -1,29 +1 @@ -{ - "name": "@novox/mesh-sdk", - "version": "0.1.14", - "description": "The stable spine a Novox Mesh module's own code builds against.", - "type": "module", - "exports": { - ".": "./dist/index.js", - "./contracts": "./dist/contracts/index.js", - "./provisioner": "./dist/provisioner/index.js", - "./tools": "./dist/tools/index.js", - "./messaging": "./dist/messaging/index.js", - "./events": "./dist/events/index.js", - "./primitives": "./dist/primitives/index.js", - "./stdio": "./dist/stdio/index.js", - "./state": "./dist/state/index.js" - }, - "files": [ - "dist" - ], - "scripts": { - "build": "tsc", - "test": "node --test --experimental-strip-types 'test/*.test.ts'", - "prepare": "tsc" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0" - } -} +PKGJSON \ No newline at end of file -- 2.54.0 From a7997fc857aea43266bc56cf096de8a4031b2c9f Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:48:02 +0000 Subject: [PATCH 3/3] Add the TypeScript twin of the sealed box, proven by the Go package's fixtures (issue 650) (Also replaces the placeholder package.json the previous commit carried by mistake.) Co-Authored-By: Claude Opus 5.5 --- package.json | 31 +++++++++++++++- src/index.ts | 1 + src/seal/box.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++++ src/seal/index.ts | 29 +++++++++++++++ test/seal.test.ts | 71 +++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/seal/box.ts create mode 100644 src/seal/index.ts create mode 100644 test/seal.test.ts diff --git a/package.json b/package.json index e30efcb..5abcf7b 100644 --- a/package.json +++ b/package.json @@ -1 +1,30 @@ -PKGJSON \ No newline at end of file +{ + "name": "@novox/mesh-sdk", + "version": "0.1.14", + "description": "The stable spine a Novox Mesh module's own code builds against.", + "type": "module", + "exports": { + ".": "./dist/index.js", + "./contracts": "./dist/contracts/index.js", + "./provisioner": "./dist/provisioner/index.js", + "./tools": "./dist/tools/index.js", + "./messaging": "./dist/messaging/index.js", + "./events": "./dist/events/index.js", + "./primitives": "./dist/primitives/index.js", + "./stdio": "./dist/stdio/index.js", + "./state": "./dist/state/index.js", + "./seal": "./dist/seal/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "node --test --experimental-strip-types 'test/*.test.ts'", + "prepare": "tsc" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/src/index.ts b/src/index.ts index 39a0633..22382e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,4 @@ export * as messaging from "./messaging/index.js"; export * as events from "./events/index.js"; export * as primitives from "./primitives/index.js"; export * as state from "./state/index.js"; +export * as seal from "./seal/index.js"; diff --git a/src/seal/box.ts b/src/seal/box.ts new file mode 100644 index 0000000..30ea274 --- /dev/null +++ b/src/seal/box.ts @@ -0,0 +1,90 @@ +// The sealed box itself (novox/hq ADR 0183, ADR 0206), with its two sources of randomness as arguments so +// the tests can prove it byte for byte against the Go package's vectors. Modules import `./index.js`. + +import { + createCipheriv, + createDecipheriv, + createPrivateKey, + createPublicKey, + diffieHellman, + hkdfSync, + type KeyObject, +} from "node:crypto"; + +/** A value sealed to one recipient, as it travels in JSON; every field but `v` is standard base64. */ +export interface SealedBox { + readonly v: number; + /** The one-time public key, SPKI DER. */ + readonly eph: string; + readonly iv: string; + readonly tag: string; + readonly ct: string; +} + +/** The only box version `open` opens. */ +export const VERSION = 1; + +const INFO = "novox-mesh sealed box v1"; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +function x25519(key: KeyObject, what: string): KeyObject { + if (key.asymmetricKeyType !== "x25519") throw new Error(`not an X25519 ${what} key`); + return key; +} + +export function publicFromPem(pem: string): KeyObject { + return x25519(createPublicKey({ key: pem, format: "pem" }), "public"); +} + +export function privateFromPem(pem: string): KeyObject { + return x25519(createPrivateKey({ key: pem, format: "pem" }), "private"); +} + +/** The recipient's raw 32-byte public key: the last 32 bytes of its SPKI DER. */ +function raw(pub: KeyObject): Buffer { + const der = pub.export({ format: "der", type: "spki" }); + return der.subarray(der.length - 32); +} + +function boxKey(secret: Buffer, ephDer: Buffer, recipientRaw: Buffer): Buffer { + return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([ephDer, recipientRaw]), INFO, 32)); +} + +/** `seal` with the one-time private key and the iv given. */ +export function sealWith(plaintext: string, recipientPem: string, eph: KeyObject, iv: Buffer): SealedBox { + const recipient = publicFromPem(recipientPem); + x25519(eph, "one-time"); + if (iv.length !== IV_BYTES) throw new Error(`an iv of ${iv.length} bytes, not ${IV_BYTES}`); + const secret = diffieHellman({ privateKey: eph, publicKey: recipient }); + const ephDer = createPublicKey(eph).export({ format: "der", type: "spki" }); + const cipher = createCipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(recipient)), iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + v: VERSION, + eph: ephDer.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ct: ct.toString("base64"), + }; +} + +/** Open a box with the recipient's PEM private key; throws for a box to another key, changed, or of another version. */ +export function open(box: SealedBox, privatePem: string): string { + if (box.v !== VERSION) throw new Error(`a sealed box of version ${box.v}, not ${VERSION}`); + const priv = privateFromPem(privatePem); + const ephDer = Buffer.from(box.eph, "base64"); + const eph = x25519(createPublicKey({ key: ephDer, format: "der", type: "spki" }), "one-time"); + const iv = Buffer.from(box.iv, "base64"); + const tag = Buffer.from(box.tag, "base64"); + // node accepts a shorter tag than 16 bytes; the box never has one, so a short one is refused here. + if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error("the box does not open with this key"); + const secret = diffieHellman({ privateKey: priv, publicKey: eph }); + const decipher = createDecipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(createPublicKey(priv))), iv); + decipher.setAuthTag(tag); + try { + return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8"); + } catch { + throw new Error("the box does not open with this key"); + } +} diff --git a/src/seal/index.ts b/src/seal/index.ts new file mode 100644 index 0000000..d3b1751 --- /dev/null +++ b/src/seal/index.ts @@ -0,0 +1,29 @@ +// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): X25519 for the agreement, HKDF-SHA256 for the +// key, AES-256-GCM for the box. The TypeScript twin of the Go package `go/seal`, byte for byte: the same +// fixtures prove both. A box is `{ v: 1, eph, iv, tag, ct }`; keys travel as PEM (SPKI public, PKCS #8 +// private). + +import { generateKeyPairSync, randomBytes } from "node:crypto"; +import { sealWith, open, VERSION, type SealedBox } from "./box.js"; + +export { open, VERSION, type SealedBox }; + +/** A recipient's keypair as the two PEM strings it is kept and sent as. */ +export interface KeyPair { + readonly publicKey: string; + readonly privateKey: string; +} + +/** A fresh X25519 keypair, PEM-encoded. */ +export function generateKeyPair(): KeyPair { + const { publicKey, privateKey } = generateKeyPairSync("x25519"); + return { + publicKey: publicKey.export({ format: "pem", type: "spki" }) as string, + privateKey: privateKey.export({ format: "pem", type: "pkcs8" }) as string, + }; +} + +/** Seal plaintext to the recipient's PEM public key, with a fresh one-time key and iv. */ +export function seal(plaintext: string, recipientPem: string): SealedBox { + return sealWith(plaintext, recipientPem, generateKeyPairSync("x25519").privateKey, randomBytes(12)); +} diff --git a/test/seal.test.ts b/test/seal.test.ts new file mode 100644 index 0000000..9eb840b --- /dev/null +++ b/test/seal.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createPrivateKey } from "node:crypto"; + +import { seal, open, generateKeyPair, type SealedBox } from "../dist/seal/index.js"; +import { sealWith } from "../dist/seal/box.js"; + +// The fixtures the Go package proves itself against: boxes the licence manager's own seal.go made, and +// the one the agent module's TypeScript made. One format, three implementations. +const fixtures = new URL("../go/seal/testdata/", import.meta.url); +const lm = JSON.parse(readFileSync(new URL("sealed-by-licence-manager.json", fixtures), "utf8")) as { + recipient: { publicKey: string; privateKey: string }; + sealed: { plaintext: string; box: SealedBox }[]; + vectors: { plaintext: string; ephPrivate: string; iv: string; box: SealedBox }[]; +}; + +// PKCS #8 for an X25519 private key is this prefix and the 32 raw bytes. +const pkcs8 = (raw: Buffer) => + createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), raw]), format: "der", type: "pkcs8" }); + +test("seal: every box the licence manager sealed opens to its plaintext", () => { + assert.ok(lm.sealed.length > 0); + for (const s of lm.sealed) assert.equal(open(s.box, lm.recipient.privateKey), s.plaintext); +}); + +test("seal: given the same one-time key and iv, the box is byte for byte the licence manager's", () => { + assert.ok(lm.vectors.length > 0); + for (const v of lm.vectors) { + const box = sealWith(v.plaintext, lm.recipient.publicKey, pkcs8(Buffer.from(v.ephPrivate, "base64")), Buffer.from(v.iv, "base64")); + assert.deepEqual(box, v.box); + assert.equal(open(box, lm.recipient.privateKey), v.plaintext); + } +}); + +test("seal: the agent module's TypeScript box opens", () => { + const f = JSON.parse(readFileSync(new URL("sealed-by-typescript.json", fixtures), "utf8")); + assert.equal(open(f.box, f.privateKey), f.plaintext); +}); + +test("seal: a box opens for its recipient only, and two boxes share no one-time key or iv", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + const box = seal("a token", a.publicKey); + assert.equal(open(box, a.privateKey), "a token"); + assert.throws(() => open(box, b.privateKey)); + const again = seal("a token", a.publicKey); + assert.notEqual(again.eph, box.eph); + assert.notEqual(again.iv, box.iv); +}); + +test("seal: a box changed in any field, or of another version, does not open", () => { + const good = lm.sealed[1].box; + const flip = (s: string) => { + const b = Buffer.from(s, "base64"); + b[b.length - 1] ^= 1; + return b.toString("base64"); + }; + for (const box of [ + { ...good, ct: flip(good.ct) }, + { ...good, tag: flip(good.tag) }, + { ...good, iv: flip(good.iv) }, + { ...good, eph: flip(good.eph) }, + { ...good, v: 2 }, + ]) assert.throws(() => open(box as SealedBox, lm.recipient.privateKey)); +}); + +test("seal: a key that is not X25519 PEM is refused", () => { + assert.throws(() => seal("x", "not a key")); + assert.throws(() => open(lm.sealed[0].box, lm.recipient.publicKey)); +}); -- 2.54.0