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)
|
|
}
|