57/F1: The sealed box moves into the SDK, in Go and TypeScript (issue 650) #26
+1
-1
@@ -16,4 +16,4 @@ 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.
|
||||
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.
|
||||
|
||||
+203
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+121
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+2
-1
@@ -12,7 +12,8 @@
|
||||
"./events": "./dist/events/index.js",
|
||||
"./primitives": "./dist/primitives/index.js",
|
||||
"./stdio": "./dist/stdio/index.js",
|
||||
"./state": "./dist/state/index.js"
|
||||
"./state": "./dist/state/index.js",
|
||||
"./seal": "./dist/seal/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user