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 <noreply@anthropic.com>
204 lines
6.1 KiB
Go
204 lines
6.1 KiB
Go
// 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)
|
|
}
|