| 1 | // internal/appconfig/crypto_test.go |
| 2 | package appconfig |
| 3 | |
| 4 | import ( |
| 5 | "bytes" |
| 6 | "crypto/ecdh" |
| 7 | "encoding/base64" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | ) |
| 11 | |
| 12 | // fixedScalar returns a 32-byte P-256 scalar for deterministic test keys — |
| 13 | // never used outside tests. Byte 0 is pinned to 0x01 to keep it a valid |
| 14 | // scalar well under the P-256 order regardless of the fill byte. |
| 15 | func fixedScalar(b byte) []byte { |
| 16 | s := make([]byte, 32) |
| 17 | for i := range s { |
| 18 | s[i] = b |
| 19 | } |
| 20 | s[0] = 0x01 |
| 21 | return s |
| 22 | } |
| 23 | |
| 24 | var testBinding = Binding{Account: "bab", App: "keymail"} |
| 25 | |
| 26 | // TestSealOpenGoldenVector pins the wire format (ephPub(65) || nonce(12) || |
| 27 | // ciphertext, base64) against a fixed ephemeral key and nonce so a future |
| 28 | // change to field order, sizes, or the HKDF info string breaks loudly |
| 29 | // instead of silently producing envelopes older code can't read. The |
| 30 | // expected value was computed by running this exact algorithm once, not |
| 31 | // invented — see the plan's Task 2 for how. |
| 32 | func TestSealOpenGoldenVector(t *testing.T) { |
| 33 | recipientPriv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x11)) |
| 34 | if err != nil { |
| 35 | t.Fatalf("recipient key: %v", err) |
| 36 | } |
| 37 | ephPriv, err := ecdh.P256().NewPrivateKey(fixedScalar(0x22)) |
| 38 | if err != nil { |
| 39 | t.Fatalf("ephemeral key: %v", err) |
| 40 | } |
| 41 | nonce := make([]byte, 12) |
| 42 | for i := range nonce { |
| 43 | nonce[i] = byte(i + 1) |
| 44 | } |
| 45 | plaintext := []byte("s3kr1t-value") |
| 46 | |
| 47 | const wantEnvelope = "BKtNPVwwBQiJVQNbG6mW7bJMPFZfpNRwU1bRNeKQcV29Pzvpof1ONhvRxqXEJnk/4+qdPPRg1qWtCl0sgEsTaUgBAgMEBQYHCAkKCwwPFyVKJUipEHkmgC9Wpeogyu3CLH/32CzrKb0y" |
| 48 | |
| 49 | got, err := sealWith(ephPriv, nonce, recipientPriv.PublicKey(), "", hkdfInfo, nil, plaintext) |
| 50 | if err != nil { |
| 51 | t.Fatalf("sealWith: %v", err) |
| 52 | } |
| 53 | if got != wantEnvelope { |
| 54 | t.Fatalf("envelope drifted from the pinned wire format:\n got: %s\n want: %s", got, wantEnvelope) |
| 55 | } |
| 56 | // SealUnbound is the same v1 shape; and a v1 envelope — every one in a |
| 57 | // production bucket today — opens under ANY binding (see Open). |
| 58 | opened, err := Open(recipientPriv, Binding{Account: "zzz", App: "other"}, got) |
| 59 | if err != nil { |
| 60 | t.Fatalf("Open: %v", err) |
| 61 | } |
| 62 | if !bytes.Equal(opened, plaintext) { |
| 63 | t.Fatalf("Open = %q, want %q", opened, plaintext) |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestSealOpenRoundTripRandomKeys(t *testing.T) { |
| 68 | priv, pub, err := GenerateKey() |
| 69 | if err != nil { |
| 70 | t.Fatalf("GenerateKey: %v", err) |
| 71 | } |
| 72 | privKey, err := ParsePrivateKey(priv, "test") |
| 73 | if err != nil { |
| 74 | t.Fatalf("ParsePrivateKey: %v", err) |
| 75 | } |
| 76 | pubKey, err := ParsePublicKey(pub, "test") |
| 77 | if err != nil { |
| 78 | t.Fatalf("ParsePublicKey: %v", err) |
| 79 | } |
| 80 | envelope, err := SealBound(pubKey, testBinding, []byte("hello")) |
| 81 | if err != nil { |
| 82 | t.Fatalf("Seal: %v", err) |
| 83 | } |
| 84 | got, err := Open(privKey, testBinding, envelope) |
| 85 | if err != nil { |
| 86 | t.Fatalf("Open: %v", err) |
| 87 | } |
| 88 | if string(got) != "hello" { |
| 89 | t.Fatalf("Open = %q, want hello", got) |
| 90 | } |
| 91 | } |
| 92 | |
| 93 | func TestOpenRejectsTamperedCiphertext(t *testing.T) { |
| 94 | priv, pub, err := GenerateKey() |
| 95 | if err != nil { |
| 96 | t.Fatalf("GenerateKey: %v", err) |
| 97 | } |
| 98 | privKey, err := ParsePrivateKey(priv, "test") |
| 99 | if err != nil { |
| 100 | t.Fatalf("ParsePrivateKey: %v", err) |
| 101 | } |
| 102 | pubKey, err := ParsePublicKey(pub, "test") |
| 103 | if err != nil { |
| 104 | t.Fatalf("ParsePublicKey: %v", err) |
| 105 | } |
| 106 | envelope, err := SealBound(pubKey, testBinding, []byte("hello")) |
| 107 | if err != nil { |
| 108 | t.Fatalf("Seal: %v", err) |
| 109 | } |
| 110 | raw, err := base64.StdEncoding.DecodeString(envelope) |
| 111 | if err != nil { |
| 112 | t.Fatalf("decode: %v", err) |
| 113 | } |
| 114 | raw[len(raw)-1] ^= 0xFF // flip a ciphertext byte |
| 115 | tampered := base64.StdEncoding.EncodeToString(raw) |
| 116 | if _, err := Open(privKey, testBinding, tampered); err == nil { |
| 117 | t.Fatal("Open accepted tampered ciphertext, want an error") |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | // TestCheckEnvelopeAcceptsSealed pins the property the console door relies |
| 122 | // on: anything Seal produces passes the structural check. If these two ever |
| 123 | // diverge, the door starts refusing envelopes the box could have opened. |
| 124 | // |
| 125 | // Empty plaintext is the ONE deliberate exception, and it is the next test |
| 126 | // rather than a case here. It is not a structural refusal — the envelope is |
| 127 | // perfectly well formed and the box could open it and find "" — it is a |
| 128 | // refusal of the VALUE, added because an empty secret is nearly always a |
| 129 | // substitution that produced nothing (discussion #16). Keeping the two |
| 130 | // tests apart keeps that distinction where the next person will see it: the |
| 131 | // invariant this test protects is still "Seal's output is structurally |
| 132 | // valid", not "the door takes everything". |
| 133 | func TestCheckEnvelopeAcceptsSealed(t *testing.T) { |
| 134 | _, pub, err := GenerateKey() |
| 135 | if err != nil { |
| 136 | t.Fatalf("GenerateKey: %v", err) |
| 137 | } |
| 138 | pubKey, err := ParsePublicKey(pub, "test") |
| 139 | if err != nil { |
| 140 | t.Fatalf("ParsePublicKey: %v", err) |
| 141 | } |
| 142 | for _, plaintext := range [][]byte{[]byte("s3kr1t"), []byte("x")} { |
| 143 | envelope, err := SealBound(pubKey, testBinding, plaintext) |
| 144 | if err != nil { |
| 145 | t.Fatalf("Seal(%q): %v", plaintext, err) |
| 146 | } |
| 147 | if err := CheckEnvelope(envelope); err != nil { |
| 148 | t.Fatalf("CheckEnvelope rejected a Seal envelope for plaintext %q: %v", plaintext, err) |
| 149 | } |
| 150 | unbound, err := SealUnbound(pubKey, plaintext) |
| 151 | if err != nil { |
| 152 | t.Fatalf("SealUnbound(%q): %v", plaintext, err) |
| 153 | } |
| 154 | if err := CheckEnvelope(unbound); err != nil { |
| 155 | t.Fatalf("CheckEnvelope rejected a SealUnbound envelope for plaintext %q: %v", plaintext, err) |
| 156 | } |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | // TestCheckEnvelopeRefusesEmptyValue pins the length-read the refusal rests |
| 161 | // on, because it is the whole mechanism and it is not obvious: AES-GCM is |
| 162 | // length-preserving, so an envelope of EXACTLY ephLen+nonceLen+16 sealed an |
| 163 | // empty plaintext and one byte longer did not. That is the only fact about a |
| 164 | // secret's value this side of the seal can know without a key, and if it |
| 165 | // ever stopped being true this refusal would start rejecting real |
| 166 | // one-character secrets — hence the boundary is asserted from both sides, |
| 167 | // against real Seal output rather than against a hand-built buffer. |
| 168 | func TestCheckEnvelopeRefusesEmptyValue(t *testing.T) { |
| 169 | _, pub, err := GenerateKey() |
| 170 | if err != nil { |
| 171 | t.Fatalf("GenerateKey: %v", err) |
| 172 | } |
| 173 | pubKey, err := ParsePublicKey(pub, "test") |
| 174 | if err != nil { |
| 175 | t.Fatalf("ParsePublicKey: %v", err) |
| 176 | } |
| 177 | empty, err := SealBound(pubKey, testBinding, []byte{}) |
| 178 | if err != nil { |
| 179 | t.Fatalf("Seal(empty): %v", err) |
| 180 | } |
| 181 | unboundEmpty, err := SealUnbound(pubKey, []byte{}) |
| 182 | if err != nil { |
| 183 | t.Fatalf("SealUnbound(empty): %v", err) |
| 184 | } |
| 185 | if err := CheckEnvelope(unboundEmpty); err == nil || !strings.Contains(err.Error(), "empty value") { |
| 186 | t.Fatalf("CheckEnvelope on an unbound empty value: %v", err) |
| 187 | } |
| 188 | err = CheckEnvelope(empty) |
| 189 | if err == nil { |
| 190 | t.Fatal("CheckEnvelope accepted an envelope sealing an empty value") |
| 191 | } |
| 192 | if !strings.Contains(err.Error(), "empty value") { |
| 193 | t.Fatalf("error %q does not name the defect (want it to mention %q)", err, "empty value") |
| 194 | } |
| 195 | // The refusal points somewhere useful rather than just saying no: the |
| 196 | // two things an operator might actually have meant. |
| 197 | for _, want := range []string{"secrets unset", "env set"} { |
| 198 | if !strings.Contains(err.Error(), want) { |
| 199 | t.Fatalf("error %q does not offer %q", err, want) |
| 200 | } |
| 201 | } |
| 202 | // One byte over the floor is a real secret and must survive — the |
| 203 | // shortest value this check must never eat. |
| 204 | oneByte, err := SealBound(pubKey, testBinding, []byte("x")) |
| 205 | if err != nil { |
| 206 | t.Fatalf("Seal(x): %v", err) |
| 207 | } |
| 208 | if err := CheckEnvelope(oneByte); err != nil { |
| 209 | t.Fatalf("CheckEnvelope rejected a one-byte secret: %v", err) |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | // TestCheckEnvelopeRefusals walks the structural defects the door has to |
| 214 | // catch at SET time, and asserts on the one thing every refusal must have in |
| 215 | // common besides being an error: it names the DEFECT and never echoes the |
| 216 | // envelope. An envelope is ciphertext an operator may have pasted from |
| 217 | // anywhere; a refusal that quotes it back puts it into console logs, audit |
| 218 | // rows and HTTP responses for nothing. |
| 219 | func TestCheckEnvelopeRefusals(t *testing.T) { |
| 220 | // 65 bytes that are not a P-256 point (0xAA is not the 0x04 |
| 221 | // uncompressed-point tag), padded past the minimum length so the length |
| 222 | // check cannot be what refuses it. |
| 223 | notAPoint := make([]byte, ephLen+nonceLen+16) |
| 224 | for i := range notAPoint { |
| 225 | notAPoint[i] = 0xAA |
| 226 | } |
| 227 | cases := []struct { |
| 228 | name string |
| 229 | envelope string |
| 230 | want string // substring naming the defect |
| 231 | }{ |
| 232 | {"empty", "", "too short"}, |
| 233 | {"not base64", "!!!not base64!!!", "base64"}, |
| 234 | {"too short", base64.StdEncoding.EncodeToString(make([]byte, ephLen+nonceLen+15)), "too short"}, |
| 235 | {"ephemeral key is not a point", base64.StdEncoding.EncodeToString(notAPoint), "ephemeral key"}, |
| 236 | } |
| 237 | for _, tc := range cases { |
| 238 | t.Run(tc.name, func(t *testing.T) { |
| 239 | err := CheckEnvelope(tc.envelope) |
| 240 | if err == nil { |
| 241 | t.Fatalf("CheckEnvelope accepted %s", tc.name) |
| 242 | } |
| 243 | if !strings.Contains(err.Error(), tc.want) { |
| 244 | t.Fatalf("error %q does not name the defect (want it to mention %q)", err, tc.want) |
| 245 | } |
| 246 | if tc.envelope != "" && strings.Contains(err.Error(), tc.envelope) { |
| 247 | t.Fatalf("error echoes the envelope back: %q", err) |
| 248 | } |
| 249 | }) |
| 250 | } |
| 251 | } |
| 252 | |