carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// internal/appconfig/crypto_test.go
2package appconfig
3
4import (
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.
15func 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
24var 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.
32func 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
67func 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
93func 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".
133func 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.
168func 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.
219func 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