Merge branch 'feat-maintenance-envelope' into main
4 files changed,
+468
−0
internal/maintenance/envelope.go+196 −0internal/maintenance/envelope_test.go+235 −0internal/maintenance/testdata/envelope-vector.json+19 −0internal/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'); |