carlos / platform Public

Add recipient-bound private maintenance envelopes

Paul Campbell pushed by paul@keymail.dev f22d861a0c10c97f9d3e21adc656bed0878c593b parent 6a81dac
4 files changed, +468 −0
  • internal/maintenance/envelope.go +196 −0
  • internal/maintenance/envelope_test.go +235 −0
  • internal/maintenance/testdata/envelope-vector.json +19 −0
  • internal/maintenance/testdata/generate-vector.mjs +18 −0

CI passed — run details

diff --git a/internal/maintenance/envelope.go b/internal/maintenance/envelope.go
new file mode 100644
index 000000000..acc6fd709
--- /dev/null
+++ b/internal/maintenance/envelope.go
@@ -0,0 +1,196 @@
+package maintenance
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdh"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/hex"
+ "errors"
+ "io"
+
+ "golang.org/x/crypto/hkdf"
+)
+
+const (
+ EnvelopeVersion uint32 = 1
+ // MaxEnvelopePlaintext bounds the crypto allocation. Request/result parsers
+ // impose their smaller operation-specific limits before execution.
+ MaxEnvelopePlaintext = 1 << 20
+ envelopePrefix = "CME1"
+ ephemeralBytes = 65
+ nonceBytes = 12
+ tagBytes = 16
+ envelopeInfo = "carlos-maintenance-v1"
+)
+
+type Direction string
+
+const (
+ DirectionInput Direction = "input"
+ DirectionResult Direction = "result"
+ DirectionEnvironment Direction = "environment"
+)
+
+var ErrEnvelope = errors.New("maintenance: invalid private envelope")
+
+// Binding authenticates identifiers already validated by the request handler.
+// It does not establish ownership or authorize an operation.
+type Binding struct {
+ Version uint32
+ Direction Direction
+ Account string
+ App string
+ Host string
+ RequestID string
+ AttemptID string
+ Recipient string
+}
+
+// RecipientFingerprint names the P-256 key that can open the envelope.
+func RecipientFingerprint(pub *ecdh.PublicKey) (string, error) {
+ if pub == nil || pub.Curve() != ecdh.P256() {
+ return "", ErrEnvelope
+ }
+ sum := sha256.Sum256(pub.Bytes())
+ return hex.EncodeToString(sum[:]), nil
+}
+
+func appendField(dst []byte, field []byte) []byte {
+ dst = binary.BigEndian.AppendUint32(dst, uint32(len(field)))
+ return append(dst, field...)
+}
+func bindingAAD(b Binding, pub *ecdh.PublicKey) ([]byte, error) {
+ fingerprint, err := RecipientFingerprint(pub)
+ if err != nil || b.Version != EnvelopeVersion || b.Recipient != fingerprint {
+ return nil, ErrEnvelope
+ }
+ if b.Direction != DirectionInput && b.Direction != DirectionResult && b.Direction != DirectionEnvironment {
+ return nil, ErrEnvelope
+ }
+ fields := []struct {
+ s string
+ max int
+ }{{b.Account, 64}, {b.App, 64}, {b.Host, 253}, {b.RequestID, 64}, {b.AttemptID, 64}}
+ for _, f := range fields {
+ if len(f.s) == 0 || len(f.s) > f.max {
+ return nil, ErrEnvelope
+ }
+ for i := range len(f.s) {
+ if f.s[i] < 0x21 || f.s[i] > 0x7e {
+ return nil, ErrEnvelope
+ }
+ }
+ }
+ aad := appendField(nil, []byte(envelopePrefix))
+ aad = appendField(aad, binary.BigEndian.AppendUint32(nil, b.Version))
+ aad = appendField(aad, []byte(b.Direction))
+ for _, f := range fields {
+ aad = appendField(aad, []byte(f.s))
+ }
+ return appendField(aad, []byte(b.Recipient)), nil
+}
+func envelopeGCM(shared []byte) (cipher.AEAD, error) {
+ key := make([]byte, 32)
+ if _, err := io.ReadFull(hkdf.New(sha256.New, shared, nil, []byte(envelopeInfo)), key); err != nil {
+ return nil, ErrEnvelope
+ }
+ block, err := aes.NewCipher(key)
+ clear(key)
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ return gcm, nil
+}
+
+// Seal encrypts private input, output or an environment snapshot for one
+// recipient and one immutable operation binding.
+func Seal(pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) {
+ if len(plaintext) > MaxEnvelopePlaintext {
+ return "", ErrEnvelope
+ }
+ if _, err := bindingAAD(b, pub); err != nil {
+ return "", ErrEnvelope
+ }
+ eph, err := ecdh.P256().GenerateKey(rand.Reader)
+ if err != nil {
+ return "", ErrEnvelope
+ }
+ nonce := make([]byte, nonceBytes)
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return "", ErrEnvelope
+ }
+ return sealWith(eph, nonce, pub, b, plaintext)
+}
+
+// sealWith permits deterministic vectors without replacing production entropy.
+func sealWith(eph *ecdh.PrivateKey, nonce []byte, pub *ecdh.PublicKey, b Binding, plaintext []byte) (string, error) {
+ if eph == nil || eph.Curve() != ecdh.P256() || len(nonce) != nonceBytes || len(plaintext) > MaxEnvelopePlaintext {
+ return "", ErrEnvelope
+ }
+ aad, err := bindingAAD(b, pub)
+ if err != nil {
+ return "", ErrEnvelope
+ }
+ shared, err := eph.ECDH(pub)
+ if err != nil {
+ return "", ErrEnvelope
+ }
+ defer clear(shared)
+ gcm, err := envelopeGCM(shared)
+ if err != nil {
+ return "", ErrEnvelope
+ }
+ raw := make([]byte, 0, len(envelopePrefix)+ephemeralBytes+nonceBytes+len(plaintext)+tagBytes)
+ raw = append(raw, []byte(envelopePrefix)...)
+ raw = append(raw, eph.PublicKey().Bytes()...)
+ raw = append(raw, nonce...)
+ raw = gcm.Seal(raw, nonce, plaintext, aad)
+ return base64.StdEncoding.EncodeToString(raw), nil
+}
+
+// Open refuses changed bindings and noncanonical or oversized envelopes before
+// returning plaintext. No error includes caller input or decrypted material.
+func Open(priv *ecdh.PrivateKey, b Binding, envelope string) ([]byte, error) {
+ const overhead = len(envelopePrefix) + ephemeralBytes + nonceBytes + tagBytes
+ if priv == nil || priv.Curve() != ecdh.P256() || len(envelope) > base64.StdEncoding.EncodedLen(overhead+MaxEnvelopePlaintext) {
+ return nil, ErrEnvelope
+ }
+ aad, err := bindingAAD(b, priv.PublicKey())
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ raw, err := base64.StdEncoding.Strict().DecodeString(envelope)
+ if err != nil || len(raw) < overhead || len(raw) > overhead+MaxEnvelopePlaintext || base64.StdEncoding.EncodeToString(raw) != envelope {
+ return nil, ErrEnvelope
+ }
+ if string(raw[:len(envelopePrefix)]) != envelopePrefix {
+ return nil, ErrEnvelope
+ }
+ pointEnd := len(envelopePrefix) + ephemeralBytes
+ eph, err := ecdh.P256().NewPublicKey(raw[len(envelopePrefix):pointEnd])
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ shared, err := priv.ECDH(eph)
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ defer clear(shared)
+ gcm, err := envelopeGCM(shared)
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ plaintext, err := gcm.Open(nil, raw[pointEnd:pointEnd+nonceBytes], raw[pointEnd+nonceBytes:], aad)
+ if err != nil {
+ return nil, ErrEnvelope
+ }
+ return plaintext, nil
+}
diff --git a/internal/maintenance/envelope_test.go b/internal/maintenance/envelope_test.go
new file mode 100644
index 000000000..0b52517d1
--- /dev/null
+++ b/internal/maintenance/envelope_test.go
@@ -0,0 +1,235 @@
+package maintenance
+
+import (
+ "bytes"
+ "crypto/ecdh"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+)
+
+type vector struct {
+ RecipientPrivate string `json:"recipient_private_hex"`
+ EphemeralPrivate string `json:"ephemeral_private_hex"`
+ Nonce string `json:"nonce_hex"`
+ Plaintext string `json:"plaintext"`
+ Binding Binding `json:"binding"`
+ AAD string `json:"aad_hex"`
+ Envelope string `json:"envelope"`
+}
+
+func fixture(t *testing.T) (vector, *ecdh.PrivateKey, *ecdh.PrivateKey, []byte) {
+ t.Helper()
+ raw, err := os.ReadFile("testdata/envelope-vector.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var v vector
+ if err := json.Unmarshal(raw, &v); err != nil {
+ t.Fatal(err)
+ }
+ decode := func(s string) []byte {
+ b, err := hex.DecodeString(s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+ }
+ key := func(s string) *ecdh.PrivateKey {
+ k, err := ecdh.P256().NewPrivateKey(decode(s))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return k
+ }
+ return v, key(v.RecipientPrivate), key(v.EphemeralPrivate), decode(v.Nonce)
+}
+func refused(t *testing.T, k *ecdh.PrivateKey, b Binding, envelope string) {
+ t.Helper()
+ got, err := Open(k, b, envelope)
+ if err != ErrEnvelope || got != nil {
+ t.Fatal("invalid envelope returned plaintext or a non-fixed error")
+ }
+}
+func TestEnvelopeIndependentVector(t *testing.T) {
+ v, k, eph, nonce := fixture(t)
+ aad, err := bindingAAD(v.Binding, k.PublicKey())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hex.EncodeToString(aad) != v.AAD {
+ t.Fatal("authenticated field encoding differs from independent vector")
+ }
+ got, err := sealWith(eph, nonce, k.PublicKey(), v.Binding, []byte(v.Plaintext))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != v.Envelope {
+ t.Fatal("ciphertext differs from independent Node/OpenSSL vector")
+ }
+ plain, err := Open(k, v.Binding, v.Envelope)
+ if err != nil || !bytes.Equal(plain, []byte(v.Plaintext)) {
+ t.Fatal("independent vector did not decrypt")
+ }
+}
+func TestEnvelopeEveryBindingField(t *testing.T) {
+ v, k, _, _ := fixture(t)
+ mutations := map[string]func(*Binding){
+ "version": func(b *Binding) { b.Version++ },
+ "direction": func(b *Binding) { b.Direction = DirectionResult },
+ "account": func(b *Binding) { b.Account = "other" },
+ "app": func(b *Binding) { b.App = "docs" },
+ "host": func(b *Binding) { b.Host = "other.home.example" },
+ "request": func(b *Binding) { b.RequestID = "request-0002" },
+ "attempt": func(b *Binding) { b.AttemptID = "attempt-0002" },
+ "recipient": func(b *Binding) { b.Recipient = strings.Repeat("0", 64) },
+ }
+ for name, mutate := range mutations {
+ t.Run(name, func(t *testing.T) { b := v.Binding; mutate(&b); refused(t, k, b, v.Envelope) })
+ }
+ otherRaw := make([]byte, 32)
+ otherRaw[31] = 3
+ other, err := ecdh.P256().NewPrivateKey(otherRaw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := v.Binding
+ b.Recipient, _ = RecipientFingerprint(other.PublicKey())
+ refused(t, other, b, v.Envelope)
+}
+func TestEnvelopeLengthPrefixesPreventFieldConcatenation(t *testing.T) {
+ v, k, _, _ := fixture(t)
+ b := v.Binding
+ b.Account = "ab"
+ b.App = "c"
+ sealed, err := Seal(k.PublicKey(), b, []byte(v.Plaintext))
+ if err != nil {
+ t.Fatal(err)
+ }
+ b.Account = "a"
+ b.App = "bc"
+ refused(t, k, b, sealed)
+}
+func TestEnvelopeDirectionsAndFreshness(t *testing.T) {
+ v, k, _, _ := fixture(t)
+ for _, direction := range []Direction{DirectionInput, DirectionResult, DirectionEnvironment} {
+ t.Run(string(direction), func(t *testing.T) {
+ b := v.Binding
+ b.Direction = direction
+ first, err := Seal(k.PublicKey(), b, []byte(v.Plaintext))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := Seal(k.PublicKey(), b, []byte(v.Plaintext))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first == second {
+ t.Fatal("production sealing reused its random envelope")
+ }
+ plain, err := Open(k, b, first)
+ if err != nil || string(plain) != v.Plaintext {
+ t.Fatal("round trip failed")
+ }
+ for _, other := range []Direction{DirectionInput, DirectionResult, DirectionEnvironment} {
+ if other != direction {
+ changed := b
+ changed.Direction = other
+ refused(t, k, changed, first)
+ }
+ }
+ })
+ }
+}
+func TestEnvelopeRejectsEveryTamperedByteAndNoncanonicalInput(t *testing.T) {
+ v, k, _, _ := fixture(t)
+ raw, err := base64.StdEncoding.DecodeString(v.Envelope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for i := range raw {
+ changed := bytes.Clone(raw)
+ changed[i] ^= 1
+ refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(changed))
+ }
+ for _, bad := range []string{"", v.Envelope + "\n", " " + v.Envelope, v.Envelope[:len(v.Envelope)-1], v.Envelope + "A", base64.RawStdEncoding.EncodeToString(raw), strings.Repeat("A", base64.StdEncoding.EncodedLen(MaxEnvelopePlaintext+1024))} {
+ refused(t, k, v.Binding, bad)
+ }
+ for i := 0; i < len(raw); i++ {
+ refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(raw[:i]))
+ }
+}
+func TestEnvelopeLimitsAndInvalidConfiguration(t *testing.T) {
+ v, k, eph, nonce := fixture(t)
+ for _, size := range []int{0, MaxEnvelopePlaintext} {
+ plain := bytes.Repeat([]byte{0x42}, size)
+ sealed, err := Seal(k.PublicKey(), v.Binding, plain)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, err := Open(k, v.Binding, sealed)
+ if err != nil || !bytes.Equal(got, plain) {
+ t.Fatal("boundary payload failed")
+ }
+ }
+ if got, err := Seal(k.PublicKey(), v.Binding, make([]byte, MaxEnvelopePlaintext+1)); err != ErrEnvelope || got != "" {
+ t.Fatal("oversized plaintext accepted")
+ }
+ invalid := []Binding{v.Binding, v.Binding, v.Binding, v.Binding, v.Binding, v.Binding, v.Binding, v.Binding}
+ invalid[0].Account = ""
+ invalid[1].App = "space app"
+ invalid[2].Host = strings.Repeat("a", 254)
+ invalid[3].RequestID = "request\x00suffix"
+ invalid[4].AttemptID = strings.Repeat("a", 65)
+ invalid[5].Direction = "unknown"
+ invalid[6].Version = 0
+ invalid[7].Recipient = ""
+ for _, b := range invalid {
+ if got, err := Seal(k.PublicKey(), b, []byte(v.Plaintext)); err != ErrEnvelope || got != "" {
+ t.Fatal("invalid binding accepted")
+ }
+ refused(t, k, b, v.Envelope)
+ }
+ if _, err := Seal(nil, v.Binding, nil); err != ErrEnvelope {
+ t.Fatal("nil recipient accepted")
+ }
+ refused(t, nil, v.Binding, v.Envelope)
+ x, err := ecdh.X25519().NewPrivateKey(bytes.Repeat([]byte{1}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := RecipientFingerprint(x.PublicKey()); err != ErrEnvelope {
+ t.Fatal("wrong curve accepted")
+ }
+ refused(t, x, v.Binding, v.Envelope)
+ for _, n := range [][]byte{nil, nonce[:len(nonce)-1], append(bytes.Clone(nonce), 0)} {
+ if _, err := sealWith(eph, n, k.PublicKey(), v.Binding, nil); err != ErrEnvelope {
+ t.Fatal("invalid nonce accepted")
+ }
+ }
+}
+func TestEnvelopeCannotCrossAppconfigPurpose(t *testing.T) {
+ v, k, _, _ := fixture(t)
+ old, err := appconfig.Seal(k.PublicKey(), []byte(v.Plaintext))
+ if err != nil {
+ t.Fatal(err)
+ }
+ refused(t, k, v.Binding, old)
+ oldRaw, err := base64.StdEncoding.DecodeString(old)
+ if err != nil {
+ t.Fatal(err)
+ }
+ refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(append([]byte(envelopePrefix), oldRaw...)))
+ newRaw, err := base64.StdEncoding.DecodeString(v.Envelope)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, err := appconfig.Open(k, base64.StdEncoding.EncodeToString(newRaw[len(envelopePrefix):])); err == nil || got != nil {
+ t.Fatal("maintenance ciphertext opened as appconfig secret")
+ }
+}
diff --git a/internal/maintenance/testdata/envelope-vector.json b/internal/maintenance/testdata/envelope-vector.json
new file mode 100644
index 000000000..40c351d2d
--- /dev/null
+++ b/internal/maintenance/testdata/envelope-vector.json
@@ -0,0 +1,19 @@
+{
+ "provenance": "Synthetic keys; independently generated with Node/OpenSSL P-256/ECDH/HKDF-SHA256/AES-GCM, not Go output.",
+ "recipient_private_hex": "0000000000000000000000000000000000000000000000000000000000000001",
+ "ephemeral_private_hex": "0000000000000000000000000000000000000000000000000000000000000002",
+ "nonce_hex": "000102030405060708090a0b",
+ "plaintext": "maintenance-secret-canary",
+ "binding": {
+ "Version": 1,
+ "Direction": "input",
+ "Account": "bds",
+ "App": "home",
+ "Host": "test.home.example",
+ "RequestID": "request-0001",
+ "AttemptID": "attempt-0001",
+ "Recipient": "698bea63dc44a344663ff1429aea10842df27b6b991ef25866b2c6c02cdcc5be"
+ },
+ "aad_hex": "00000004434d4531000000040000000100000005696e7075740000000362647300000004686f6d6500000011746573742e686f6d652e6578616d706c650000000c726571756573742d303030310000000c617474656d70742d303030310000004036393862656136336463343461333434363633666631343239616561313038343264663237623662393931656632353836366232633663303263646363356265",
+ "envelope": "Q01FMQR88nsYjQNPfopSOAMEtRrDwIlp4nfyGzWmC0j8R2aZeAd3VRDbjtBAKT2axp90MNu6fa3mPOmCKZ4Et50ieHPRAAECAwQFBgcICQoLkA+1IVYfe119BqOQy8nZ8jD0XjMWA3GManMmnX+QFzg/akaop7LVZGo="
+}
diff --git a/internal/maintenance/testdata/generate-vector.mjs b/internal/maintenance/testdata/generate-vector.mjs
new file mode 100644
index 000000000..f0b6e22b1
--- /dev/null
+++ b/internal/maintenance/testdata/generate-vector.mjs
@@ -0,0 +1,18 @@
+import {createECDH, createHash, hkdfSync, createCipheriv} from 'node:crypto';
+import {writeFileSync} from 'node:fs';
+// Fixed synthetic keys; this vector is independent of the Go implementation.
+const scalar = n => { const b=Buffer.alloc(32); b[31]=n; return b; };
+const recipient=createECDH('prime256v1'); recipient.setPrivateKey(scalar(1));
+const ephemeral=createECDH('prime256v1'); ephemeral.setPrivateKey(scalar(2));
+const binding={Version:1,Direction:'input',Account:'bds',App:'home',Host:'test.home.example',RequestID:'request-0001',AttemptID:'attempt-0001',Recipient:createHash('sha256').update(recipient.getPublicKey()).digest('hex')};
+const length = n => {const b=Buffer.alloc(4);b.writeUInt32BE(n);return b;};
+const fields=[Buffer.from('CME1'),length(1),Buffer.from('input'),...['Account','App','Host','RequestID','AttemptID','Recipient'].map(k=>Buffer.from(binding[k]))];
+const aad=Buffer.concat(fields.flatMap(f=>[length(f.length),f]));
+const shared=ephemeral.computeSecret(recipient.getPublicKey());
+const key=Buffer.from(hkdfSync('sha256',shared,Buffer.alloc(0),Buffer.from('carlos-maintenance-v1'),32));
+const nonce=Buffer.from(Array.from({length:12},(_,i)=>i));const plaintext=Buffer.from('maintenance-secret-canary');
+const cipher=createCipheriv('aes-256-gcm',key,nonce);cipher.setAAD(aad);
+const ciphertext=Buffer.concat([cipher.update(plaintext),cipher.final(),cipher.getAuthTag()]);
+const wire=Buffer.concat([Buffer.from('CME1'),ephemeral.getPublicKey(),nonce,ciphertext]);
+const vector={provenance:'Synthetic keys; independently generated with Node/OpenSSL P-256/ECDH/HKDF-SHA256/AES-GCM, not Go output.',recipient_private_hex:scalar(1).toString('hex'),ephemeral_private_hex:scalar(2).toString('hex'),nonce_hex:nonce.toString('hex'),plaintext:plaintext.toString(),binding,aad_hex:aad.toString('hex'),envelope:wire.toString('base64')};
+writeFileSync(new URL('./envelope-vector.json',import.meta.url),JSON.stringify(vector,null,2)+'\n');