| 1 | package maintenance |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "crypto/ecdh" |
| 6 | "encoding/base64" |
| 7 | "encoding/hex" |
| 8 | "encoding/json" |
| 9 | "os" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/appconfig" |
| 14 | ) |
| 15 | |
| 16 | type vector struct { |
| 17 | RecipientPrivate string `json:"recipient_private_hex"` |
| 18 | EphemeralPrivate string `json:"ephemeral_private_hex"` |
| 19 | Nonce string `json:"nonce_hex"` |
| 20 | Plaintext string `json:"plaintext"` |
| 21 | Binding Binding `json:"binding"` |
| 22 | AAD string `json:"aad_hex"` |
| 23 | Envelope string `json:"envelope"` |
| 24 | } |
| 25 | |
| 26 | func fixture(t *testing.T) (vector, *ecdh.PrivateKey, *ecdh.PrivateKey, []byte) { |
| 27 | t.Helper() |
| 28 | raw, err := os.ReadFile("testdata/envelope-vector.json") |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | var v vector |
| 33 | if err := json.Unmarshal(raw, &v); err != nil { |
| 34 | t.Fatal(err) |
| 35 | } |
| 36 | decode := func(s string) []byte { |
| 37 | b, err := hex.DecodeString(s) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | return b |
| 42 | } |
| 43 | key := func(s string) *ecdh.PrivateKey { |
| 44 | k, err := ecdh.P256().NewPrivateKey(decode(s)) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | return k |
| 49 | } |
| 50 | return v, key(v.RecipientPrivate), key(v.EphemeralPrivate), decode(v.Nonce) |
| 51 | } |
| 52 | func refused(t *testing.T, k *ecdh.PrivateKey, b Binding, envelope string) { |
| 53 | t.Helper() |
| 54 | got, err := Open(k, b, envelope) |
| 55 | if err != ErrEnvelope || got != nil { |
| 56 | t.Fatal("invalid envelope returned plaintext or a non-fixed error") |
| 57 | } |
| 58 | } |
| 59 | func TestEnvelopeIndependentVector(t *testing.T) { |
| 60 | v, k, eph, nonce := fixture(t) |
| 61 | aad, err := bindingAAD(v.Binding, k.PublicKey()) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | if hex.EncodeToString(aad) != v.AAD { |
| 66 | t.Fatal("authenticated field encoding differs from independent vector") |
| 67 | } |
| 68 | got, err := sealWith(eph, nonce, k.PublicKey(), v.Binding, []byte(v.Plaintext)) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | if got != v.Envelope { |
| 73 | t.Fatal("ciphertext differs from independent Node/OpenSSL vector") |
| 74 | } |
| 75 | plain, err := Open(k, v.Binding, v.Envelope) |
| 76 | if err != nil || !bytes.Equal(plain, []byte(v.Plaintext)) { |
| 77 | t.Fatal("independent vector did not decrypt") |
| 78 | } |
| 79 | } |
| 80 | func TestEnvelopeEveryBindingField(t *testing.T) { |
| 81 | v, k, _, _ := fixture(t) |
| 82 | mutations := map[string]func(*Binding){ |
| 83 | "version": func(b *Binding) { b.Version++ }, |
| 84 | "direction": func(b *Binding) { b.Direction = DirectionResult }, |
| 85 | "account": func(b *Binding) { b.Account = "other" }, |
| 86 | "app": func(b *Binding) { b.App = "docs" }, |
| 87 | "host": func(b *Binding) { b.Host = "other.home.example" }, |
| 88 | "request": func(b *Binding) { b.RequestID = "request-0002" }, |
| 89 | "attempt": func(b *Binding) { b.AttemptID = "attempt-0002" }, |
| 90 | "recipient": func(b *Binding) { b.Recipient = strings.Repeat("0", 64) }, |
| 91 | } |
| 92 | for name, mutate := range mutations { |
| 93 | t.Run(name, func(t *testing.T) { b := v.Binding; mutate(&b); refused(t, k, b, v.Envelope) }) |
| 94 | } |
| 95 | otherRaw := make([]byte, 32) |
| 96 | otherRaw[31] = 3 |
| 97 | other, err := ecdh.P256().NewPrivateKey(otherRaw) |
| 98 | if err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | b := v.Binding |
| 102 | b.Recipient, _ = RecipientFingerprint(other.PublicKey()) |
| 103 | refused(t, other, b, v.Envelope) |
| 104 | } |
| 105 | func TestEnvelopeLengthPrefixesPreventFieldConcatenation(t *testing.T) { |
| 106 | v, k, _, _ := fixture(t) |
| 107 | b := v.Binding |
| 108 | b.Account = "ab" |
| 109 | b.App = "c" |
| 110 | sealed, err := Seal(k.PublicKey(), b, []byte(v.Plaintext)) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | b.Account = "a" |
| 115 | b.App = "bc" |
| 116 | refused(t, k, b, sealed) |
| 117 | } |
| 118 | func TestEnvelopeDirectionsAndFreshness(t *testing.T) { |
| 119 | v, k, _, _ := fixture(t) |
| 120 | for _, direction := range []Direction{DirectionInput, DirectionResult, DirectionEnvironment} { |
| 121 | t.Run(string(direction), func(t *testing.T) { |
| 122 | b := v.Binding |
| 123 | b.Direction = direction |
| 124 | first, err := Seal(k.PublicKey(), b, []byte(v.Plaintext)) |
| 125 | if err != nil { |
| 126 | t.Fatal(err) |
| 127 | } |
| 128 | second, err := Seal(k.PublicKey(), b, []byte(v.Plaintext)) |
| 129 | if err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | if first == second { |
| 133 | t.Fatal("production sealing reused its random envelope") |
| 134 | } |
| 135 | plain, err := Open(k, b, first) |
| 136 | if err != nil || string(plain) != v.Plaintext { |
| 137 | t.Fatal("round trip failed") |
| 138 | } |
| 139 | for _, other := range []Direction{DirectionInput, DirectionResult, DirectionEnvironment} { |
| 140 | if other != direction { |
| 141 | changed := b |
| 142 | changed.Direction = other |
| 143 | refused(t, k, changed, first) |
| 144 | } |
| 145 | } |
| 146 | }) |
| 147 | } |
| 148 | } |
| 149 | func TestEnvelopeRejectsEveryTamperedByteAndNoncanonicalInput(t *testing.T) { |
| 150 | v, k, _, _ := fixture(t) |
| 151 | raw, err := base64.StdEncoding.DecodeString(v.Envelope) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | for i := range raw { |
| 156 | changed := bytes.Clone(raw) |
| 157 | changed[i] ^= 1 |
| 158 | refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(changed)) |
| 159 | } |
| 160 | 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))} { |
| 161 | refused(t, k, v.Binding, bad) |
| 162 | } |
| 163 | for i := 0; i < len(raw); i++ { |
| 164 | refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(raw[:i])) |
| 165 | } |
| 166 | } |
| 167 | func TestEnvelopeLimitsAndInvalidConfiguration(t *testing.T) { |
| 168 | v, k, eph, nonce := fixture(t) |
| 169 | for _, size := range []int{0, MaxEnvelopePlaintext} { |
| 170 | plain := bytes.Repeat([]byte{0x42}, size) |
| 171 | sealed, err := Seal(k.PublicKey(), v.Binding, plain) |
| 172 | if err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | got, err := Open(k, v.Binding, sealed) |
| 176 | if err != nil || !bytes.Equal(got, plain) { |
| 177 | t.Fatal("boundary payload failed") |
| 178 | } |
| 179 | } |
| 180 | if got, err := Seal(k.PublicKey(), v.Binding, make([]byte, MaxEnvelopePlaintext+1)); err != ErrEnvelope || got != "" { |
| 181 | t.Fatal("oversized plaintext accepted") |
| 182 | } |
| 183 | invalid := []Binding{v.Binding, v.Binding, v.Binding, v.Binding, v.Binding, v.Binding, v.Binding, v.Binding} |
| 184 | invalid[0].Account = "" |
| 185 | invalid[1].App = "space app" |
| 186 | invalid[2].Host = strings.Repeat("a", 254) |
| 187 | invalid[3].RequestID = "request\x00suffix" |
| 188 | invalid[4].AttemptID = strings.Repeat("a", 65) |
| 189 | invalid[5].Direction = "unknown" |
| 190 | invalid[6].Version = 0 |
| 191 | invalid[7].Recipient = "" |
| 192 | for _, b := range invalid { |
| 193 | if got, err := Seal(k.PublicKey(), b, []byte(v.Plaintext)); err != ErrEnvelope || got != "" { |
| 194 | t.Fatal("invalid binding accepted") |
| 195 | } |
| 196 | refused(t, k, b, v.Envelope) |
| 197 | } |
| 198 | if _, err := Seal(nil, v.Binding, nil); err != ErrEnvelope { |
| 199 | t.Fatal("nil recipient accepted") |
| 200 | } |
| 201 | refused(t, nil, v.Binding, v.Envelope) |
| 202 | x, err := ecdh.X25519().NewPrivateKey(bytes.Repeat([]byte{1}, 32)) |
| 203 | if err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | if _, err := RecipientFingerprint(x.PublicKey()); err != ErrEnvelope { |
| 207 | t.Fatal("wrong curve accepted") |
| 208 | } |
| 209 | refused(t, x, v.Binding, v.Envelope) |
| 210 | for _, n := range [][]byte{nil, nonce[:len(nonce)-1], append(bytes.Clone(nonce), 0)} { |
| 211 | if _, err := sealWith(eph, n, k.PublicKey(), v.Binding, nil); err != ErrEnvelope { |
| 212 | t.Fatal("invalid nonce accepted") |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | func TestEnvelopeCannotCrossAppconfigPurpose(t *testing.T) { |
| 217 | v, k, _, _ := fixture(t) |
| 218 | appBinding := appconfig.Binding{Account: v.Binding.Account, App: v.Binding.App} |
| 219 | unbound, err := appconfig.SealUnbound(k.PublicKey(), []byte(v.Plaintext)) |
| 220 | if err != nil { |
| 221 | t.Fatal(err) |
| 222 | } |
| 223 | bound, err := appconfig.SealBound(k.PublicKey(), appBinding, []byte(v.Plaintext)) |
| 224 | if err != nil { |
| 225 | t.Fatal(err) |
| 226 | } |
| 227 | for _, old := range []string{unbound, bound} { |
| 228 | refused(t, k, v.Binding, old) |
| 229 | oldRaw, err := base64.StdEncoding.DecodeString(old) |
| 230 | if err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | refused(t, k, v.Binding, base64.StdEncoding.EncodeToString(append([]byte(envelopePrefix), oldRaw...))) |
| 234 | } |
| 235 | newRaw, err := base64.StdEncoding.DecodeString(v.Envelope) |
| 236 | if err != nil { |
| 237 | t.Fatal(err) |
| 238 | } |
| 239 | if got, err := appconfig.Open(k, appBinding, base64.StdEncoding.EncodeToString(newRaw[len(envelopePrefix):])); err == nil || got != nil { |
| 240 | t.Fatal("maintenance ciphertext opened as appconfig secret") |
| 241 | } |
| 242 | } |
| 243 | |