@7h3/protocol 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/README.md +1169 -175
- package/bin/7h3.ts +22 -1
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/package.json +55 -13
- package/sdk/browser/package.json +1 -1
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +5 -1
- package/sdk/go/go.sum +4 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/pyproject.toml +1 -1
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/rust/Cargo.lock +1 -1
- package/sdk/rust/Cargo.toml +1 -1
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/gateway.ts +75 -0
- package/src/httpBinding.ts +37 -11
- package/src/index.ts +7 -0
- package/src/otel.ts +136 -0
- package/src/protocol.d.ts +67 -0
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.ts +1 -0
- package/src/replayStores.test.ts +133 -1
- package/src/replayStores.ts +136 -3
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/wsBinding.ts +100 -0
- package/vitest.config.ts +11 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
package protocol7h3_test
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/base64"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"strings"
|
|
7
|
+
"testing"
|
|
8
|
+
"time"
|
|
9
|
+
|
|
10
|
+
protocol7h3 "github.com/IceMasterT/7h3-protocol/sdk/go"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
// Helpers
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
func makeEnvelopeGo(t *testing.T, body protocol7h3.ProtocolBody) protocol7h3.ProtocolEnvelope {
|
|
18
|
+
t.Helper()
|
|
19
|
+
nowMs := time.Now().UnixMilli()
|
|
20
|
+
return protocol7h3.ProtocolEnvelope{
|
|
21
|
+
Header: protocol7h3.ProtocolHeader{
|
|
22
|
+
Version: "7h3/0.1",
|
|
23
|
+
MessageID: "msg-go-test",
|
|
24
|
+
TimestampMs: nowMs,
|
|
25
|
+
TTLMs: 60_000,
|
|
26
|
+
Sender: "agent-alice",
|
|
27
|
+
Recipient: "agent-bob",
|
|
28
|
+
Nonce: "test-nonce-go",
|
|
29
|
+
},
|
|
30
|
+
Body: body,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Test 1: GenerateX25519KeyPair returns 32-byte base64url keys (~43 chars, no padding)
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
func TestGenerateX25519KeyPair(t *testing.T) {
|
|
39
|
+
pub, priv, err := protocol7h3.GenerateX25519KeyPair()
|
|
40
|
+
if err != nil {
|
|
41
|
+
t.Fatalf("GenerateX25519KeyPair error: %v", err)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Should not contain base64 padding
|
|
45
|
+
if strings.Contains(pub, "=") {
|
|
46
|
+
t.Errorf("public key has padding: %s", pub)
|
|
47
|
+
}
|
|
48
|
+
if strings.Contains(priv, "=") {
|
|
49
|
+
t.Errorf("private key has padding: %s", priv)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Should be 43 chars (base64url of 32 bytes without padding)
|
|
53
|
+
if len(pub) != 43 {
|
|
54
|
+
t.Errorf("public key length = %d, want 43", len(pub))
|
|
55
|
+
}
|
|
56
|
+
if len(priv) != 43 {
|
|
57
|
+
t.Errorf("private key length = %d, want 43", len(priv))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Decoded must be exactly 32 bytes
|
|
61
|
+
pubBytes, err := base64.RawURLEncoding.DecodeString(pub)
|
|
62
|
+
if err != nil {
|
|
63
|
+
t.Fatalf("decode public key: %v", err)
|
|
64
|
+
}
|
|
65
|
+
if len(pubBytes) != 32 {
|
|
66
|
+
t.Errorf("public key decoded length = %d, want 32", len(pubBytes))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
privBytes, err := base64.RawURLEncoding.DecodeString(priv)
|
|
70
|
+
if err != nil {
|
|
71
|
+
t.Fatalf("decode private key: %v", err)
|
|
72
|
+
}
|
|
73
|
+
if len(privBytes) != 32 {
|
|
74
|
+
t.Errorf("private key decoded length = %d, want 32", len(privBytes))
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Test 2: SealEnvelope + OpenEnvelope round-trip recovers original body exactly
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
func TestSealOpenEnvelopeRoundTrip(t *testing.T) {
|
|
83
|
+
recipientPub, recipientPriv, err := protocol7h3.GenerateX25519KeyPair()
|
|
84
|
+
if err != nil {
|
|
85
|
+
t.Fatalf("GenerateX25519KeyPair: %v", err)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
senderPub, senderPriv, err := protocol7h3.GenerateKeypair()
|
|
89
|
+
if err != nil {
|
|
90
|
+
t.Fatalf("GenerateKeypair: %v", err)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
originalBody := protocol7h3.ProtocolBody{
|
|
94
|
+
Intent: "TASK",
|
|
95
|
+
Content: "Hello encrypted world!",
|
|
96
|
+
Capability: "some-cap",
|
|
97
|
+
CorrelationID: "corr-123",
|
|
98
|
+
}
|
|
99
|
+
envelope := makeEnvelopeGo(t, originalBody)
|
|
100
|
+
|
|
101
|
+
sealed, err := protocol7h3.SealEnvelope(envelope, recipientPub, senderPriv)
|
|
102
|
+
if err != nil {
|
|
103
|
+
t.Fatalf("SealEnvelope: %v", err)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_, body, err := protocol7h3.OpenEnvelope(sealed, recipientPriv, senderPub)
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatalf("OpenEnvelope: %v", err)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if body.Intent != originalBody.Intent {
|
|
112
|
+
t.Errorf("intent = %q, want %q", body.Intent, originalBody.Intent)
|
|
113
|
+
}
|
|
114
|
+
if body.Content != originalBody.Content {
|
|
115
|
+
t.Errorf("content = %q, want %q", body.Content, originalBody.Content)
|
|
116
|
+
}
|
|
117
|
+
if body.Capability != originalBody.Capability {
|
|
118
|
+
t.Errorf("capability = %q, want %q", body.Capability, originalBody.Capability)
|
|
119
|
+
}
|
|
120
|
+
if body.CorrelationID != originalBody.CorrelationID {
|
|
121
|
+
t.Errorf("correlationId = %q, want %q", body.CorrelationID, originalBody.CorrelationID)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Test 3: OpenEnvelope fails with wrong recipient key
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
func TestOpenEnvelopeWrongRecipientKey(t *testing.T) {
|
|
130
|
+
recipientPub, _, err := protocol7h3.GenerateX25519KeyPair()
|
|
131
|
+
if err != nil {
|
|
132
|
+
t.Fatalf("GenerateX25519KeyPair: %v", err)
|
|
133
|
+
}
|
|
134
|
+
_, wrongPriv, err := protocol7h3.GenerateX25519KeyPair()
|
|
135
|
+
if err != nil {
|
|
136
|
+
t.Fatalf("GenerateX25519KeyPair wrong: %v", err)
|
|
137
|
+
}
|
|
138
|
+
senderPub, senderPriv, err := protocol7h3.GenerateKeypair()
|
|
139
|
+
if err != nil {
|
|
140
|
+
t.Fatalf("GenerateKeypair: %v", err)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
envelope := makeEnvelopeGo(t, protocol7h3.ProtocolBody{Intent: "PING", Content: "secret"})
|
|
144
|
+
sealed, err := protocol7h3.SealEnvelope(envelope, recipientPub, senderPriv)
|
|
145
|
+
if err != nil {
|
|
146
|
+
t.Fatalf("SealEnvelope: %v", err)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Wrong recipient private key — we need to test decryptBody path directly.
|
|
150
|
+
// To bypass signature check (which uses senderPub correctly), we test OpenEnvelope
|
|
151
|
+
// with wrong key but correct signature — the AEAD tag will fail.
|
|
152
|
+
// However, OpenEnvelope verifies Ed25519 first using senderPub (which matches),
|
|
153
|
+
// so signature passes, and then decryption fails with wrong key.
|
|
154
|
+
_, _, err = protocol7h3.OpenEnvelope(sealed, wrongPriv, senderPub)
|
|
155
|
+
if err == nil {
|
|
156
|
+
t.Error("expected error with wrong recipient key, got nil")
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Test 4: OpenEnvelope fails if envelope signature tampered
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
func TestOpenEnvelopeTamperedSignature(t *testing.T) {
|
|
165
|
+
recipientPub, recipientPriv, err := protocol7h3.GenerateX25519KeyPair()
|
|
166
|
+
if err != nil {
|
|
167
|
+
t.Fatalf("GenerateX25519KeyPair: %v", err)
|
|
168
|
+
}
|
|
169
|
+
senderPub, senderPriv, err := protocol7h3.GenerateKeypair()
|
|
170
|
+
if err != nil {
|
|
171
|
+
t.Fatalf("GenerateKeypair: %v", err)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
envelope := makeEnvelopeGo(t, protocol7h3.ProtocolBody{Intent: "PING", Content: "secret"})
|
|
175
|
+
sealed, err := protocol7h3.SealEnvelope(envelope, recipientPub, senderPriv)
|
|
176
|
+
if err != nil {
|
|
177
|
+
t.Fatalf("SealEnvelope: %v", err)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Tamper with signature
|
|
181
|
+
tamperedSig := *sealed.Signature
|
|
182
|
+
tamperedSig.Value = strings.Repeat("A", 86)
|
|
183
|
+
sealed.Signature = &tamperedSig
|
|
184
|
+
|
|
185
|
+
_, _, err = protocol7h3.OpenEnvelope(sealed, recipientPriv, senderPub)
|
|
186
|
+
if err == nil {
|
|
187
|
+
t.Error("expected error with tampered signature, got nil")
|
|
188
|
+
}
|
|
189
|
+
if !strings.Contains(err.Error(), "signature") {
|
|
190
|
+
t.Errorf("expected signature error, got: %v", err)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Test 5: OpenEnvelope fails if ciphertext tampered (AEAD auth tag fails)
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
func TestOpenEnvelopeTamperedCiphertext(t *testing.T) {
|
|
199
|
+
recipientPub, recipientPriv, err := protocol7h3.GenerateX25519KeyPair()
|
|
200
|
+
if err != nil {
|
|
201
|
+
t.Fatalf("GenerateX25519KeyPair: %v", err)
|
|
202
|
+
}
|
|
203
|
+
senderPub, senderPriv, err := protocol7h3.GenerateKeypair()
|
|
204
|
+
if err != nil {
|
|
205
|
+
t.Fatalf("GenerateKeypair: %v", err)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
envelope := makeEnvelopeGo(t, protocol7h3.ProtocolBody{Intent: "PING", Content: "secret"})
|
|
209
|
+
sealed, err := protocol7h3.SealEnvelope(envelope, recipientPub, senderPriv)
|
|
210
|
+
if err != nil {
|
|
211
|
+
t.Fatalf("SealEnvelope: %v", err)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Decode the encrypted payload, flip a bit in ciphertext
|
|
215
|
+
payloadJSON, err := base64.RawURLEncoding.DecodeString(sealed.Body.Content)
|
|
216
|
+
if err != nil {
|
|
217
|
+
t.Fatalf("decode content: %v", err)
|
|
218
|
+
}
|
|
219
|
+
var payload map[string]string
|
|
220
|
+
if err = json.Unmarshal(payloadJSON, &payload); err != nil {
|
|
221
|
+
t.Fatalf("unmarshal payload: %v", err)
|
|
222
|
+
}
|
|
223
|
+
ctBytes, err := base64.RawURLEncoding.DecodeString(payload["ciphertext"])
|
|
224
|
+
if err != nil {
|
|
225
|
+
t.Fatalf("decode ciphertext: %v", err)
|
|
226
|
+
}
|
|
227
|
+
ctBytes[0] ^= 0xFF
|
|
228
|
+
payload["ciphertext"] = base64.RawURLEncoding.EncodeToString(ctBytes)
|
|
229
|
+
|
|
230
|
+
tamperedPayloadJSON, _ := json.Marshal(payload)
|
|
231
|
+
tamperedContent := base64.RawURLEncoding.EncodeToString(tamperedPayloadJSON)
|
|
232
|
+
|
|
233
|
+
// We need to test decryptBody path, bypassing the signature check by calling
|
|
234
|
+
// a sealed envelope that we signed correctly but with tampered content.
|
|
235
|
+
// Since we can't re-sign (that would require a new seal), test via a helper approach:
|
|
236
|
+
// Create a new sealed envelope with the tampered content re-signed.
|
|
237
|
+
tamperedEnv := sealed
|
|
238
|
+
tamperedEnv.Body.Content = tamperedContent
|
|
239
|
+
// Re-sign it so sig check passes but AEAD fails
|
|
240
|
+
reSealed, err := protocol7h3.SignEnvelopeEd25519(protocol7h3.ProtocolEnvelope{
|
|
241
|
+
Header: tamperedEnv.Header,
|
|
242
|
+
Body: tamperedEnv.Body,
|
|
243
|
+
}, senderPriv)
|
|
244
|
+
if err != nil {
|
|
245
|
+
t.Fatalf("re-sign tampered envelope: %v", err)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
_, _, err = protocol7h3.OpenEnvelope(reSealed, recipientPriv, senderPub)
|
|
249
|
+
if err == nil {
|
|
250
|
+
t.Error("expected error with tampered ciphertext, got nil")
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Test 6: Two SealEnvelope calls on same body produce different ciphertexts
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
func TestSealEnvelopeEphemeralRandomness(t *testing.T) {
|
|
259
|
+
recipientPub, _, err := protocol7h3.GenerateX25519KeyPair()
|
|
260
|
+
if err != nil {
|
|
261
|
+
t.Fatalf("GenerateX25519KeyPair: %v", err)
|
|
262
|
+
}
|
|
263
|
+
_, senderPriv, err := protocol7h3.GenerateKeypair()
|
|
264
|
+
if err != nil {
|
|
265
|
+
t.Fatalf("GenerateKeypair: %v", err)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
body := protocol7h3.ProtocolBody{Intent: "PING", Content: "same content"}
|
|
269
|
+
envelope1 := makeEnvelopeGo(t, body)
|
|
270
|
+
envelope2 := makeEnvelopeGo(t, body)
|
|
271
|
+
|
|
272
|
+
sealed1, err := protocol7h3.SealEnvelope(envelope1, recipientPub, senderPriv)
|
|
273
|
+
if err != nil {
|
|
274
|
+
t.Fatalf("SealEnvelope 1: %v", err)
|
|
275
|
+
}
|
|
276
|
+
sealed2, err := protocol7h3.SealEnvelope(envelope2, recipientPub, senderPriv)
|
|
277
|
+
if err != nil {
|
|
278
|
+
t.Fatalf("SealEnvelope 2: %v", err)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if sealed1.Body.Content == sealed2.Body.Content {
|
|
282
|
+
t.Error("two seal calls produced identical ciphertexts — ephemeral key randomness failure")
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Test 7: Encrypted content is opaque (does not contain original body.content)
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
func TestEncryptedContentIsOpaque(t *testing.T) {
|
|
291
|
+
recipientPub, _, err := protocol7h3.GenerateX25519KeyPair()
|
|
292
|
+
if err != nil {
|
|
293
|
+
t.Fatalf("GenerateX25519KeyPair: %v", err)
|
|
294
|
+
}
|
|
295
|
+
_, senderPriv, err := protocol7h3.GenerateKeypair()
|
|
296
|
+
if err != nil {
|
|
297
|
+
t.Fatalf("GenerateKeypair: %v", err)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
originalContent := "super-secret-data-12345"
|
|
301
|
+
envelope := makeEnvelopeGo(t, protocol7h3.ProtocolBody{Intent: "TASK", Content: originalContent})
|
|
302
|
+
|
|
303
|
+
sealed, err := protocol7h3.SealEnvelope(envelope, recipientPub, senderPriv)
|
|
304
|
+
if err != nil {
|
|
305
|
+
t.Fatalf("SealEnvelope: %v", err)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// The raw encrypted content should not contain the original plaintext
|
|
309
|
+
if strings.Contains(sealed.Body.Content, originalContent) {
|
|
310
|
+
t.Error("encrypted content contains original plaintext (raw base64url)")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Decoded JSON of encrypted payload should also not contain it
|
|
314
|
+
payloadJSON, _ := base64.RawURLEncoding.DecodeString(sealed.Body.Content)
|
|
315
|
+
if strings.Contains(string(payloadJSON), originalContent) {
|
|
316
|
+
t.Error("encrypted payload JSON contains original plaintext")
|
|
317
|
+
}
|
|
318
|
+
}
|
package/sdk/go/go.mod
CHANGED
package/sdk/go/go.sum
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
|
2
|
+
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
|
3
|
+
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
|
4
|
+
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
package/sdk/go/replay.go
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
package protocol7h3
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"sync"
|
|
6
|
+
"time"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// ReplayStore interface
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
// ReplayStore provides atomic nonce deduplication for replay protection.
|
|
14
|
+
//
|
|
15
|
+
// Check atomically marks a nonce as seen and reports whether it was already
|
|
16
|
+
// present:
|
|
17
|
+
// - (false, nil) — nonce is fresh (first time seen)
|
|
18
|
+
// - (true, nil) — nonce is a replay (already seen)
|
|
19
|
+
// - (_, err) — store error; caller should fail closed
|
|
20
|
+
type ReplayStore interface {
|
|
21
|
+
Check(ctx context.Context, key string, ttlMs int64) (bool, error)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// InMemoryReplayStore
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
type inMemoryEntry struct {
|
|
29
|
+
expiresAt time.Time
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// InMemoryReplayStore is a stdlib-only, process-local replay store backed by
|
|
33
|
+
// sync.Map with time-based expiry. It does NOT provide cross-instance replay
|
|
34
|
+
// protection — use RedisReplayStore for multi-instance deployments.
|
|
35
|
+
type InMemoryReplayStore struct {
|
|
36
|
+
entries sync.Map
|
|
37
|
+
now func() time.Time
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// NewInMemoryReplayStore returns a new in-process replay store.
|
|
41
|
+
func NewInMemoryReplayStore() *InMemoryReplayStore {
|
|
42
|
+
return &InMemoryReplayStore{now: time.Now}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check returns (false, nil) when the nonce is fresh and (true, nil) when it
|
|
46
|
+
// is a replay. Expired entries are evicted lazily on access.
|
|
47
|
+
func (s *InMemoryReplayStore) Check(ctx context.Context, key string, ttlMs int64) (bool, error) {
|
|
48
|
+
ttl := time.Duration(ttlMs) * time.Millisecond
|
|
49
|
+
if ttl <= 0 {
|
|
50
|
+
ttl = time.Millisecond
|
|
51
|
+
}
|
|
52
|
+
now := s.now()
|
|
53
|
+
expiresAt := now.Add(ttl)
|
|
54
|
+
|
|
55
|
+
actual, loaded := s.entries.LoadOrStore(key, &inMemoryEntry{expiresAt: expiresAt})
|
|
56
|
+
if !loaded {
|
|
57
|
+
// Key was freshly stored — this is the first time we've seen it.
|
|
58
|
+
return false, nil
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Key already existed — check whether the stored entry is still live.
|
|
62
|
+
entry := actual.(*inMemoryEntry)
|
|
63
|
+
if now.After(entry.expiresAt) {
|
|
64
|
+
// Expired: treat as fresh and refresh the expiry.
|
|
65
|
+
s.entries.Store(key, &inMemoryEntry{expiresAt: expiresAt})
|
|
66
|
+
return false, nil
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Entry is live — this is a replay.
|
|
70
|
+
return true, nil
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// RedisReplayStore — injection-based, zero external imports
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
// SetNXFunc is the injection point for Redis SET NX PX semantics.
|
|
78
|
+
//
|
|
79
|
+
// Implementations should:
|
|
80
|
+
// - Return (true, nil) when the key was newly set (fresh).
|
|
81
|
+
// - Return (false, nil) when the key already existed (replay blocked by NX).
|
|
82
|
+
// - Return (_, err) on connection or protocol errors.
|
|
83
|
+
//
|
|
84
|
+
// Example adapter using go-redis:
|
|
85
|
+
//
|
|
86
|
+
// func(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
|
87
|
+
// return rdb.SetNX(ctx, key, "1", ttl).Result()
|
|
88
|
+
// }
|
|
89
|
+
type SetNXFunc func(ctx context.Context, key string, ttl time.Duration) (bool, error)
|
|
90
|
+
|
|
91
|
+
// RedisReplayStore delegates SET NX PX to an injected function, keeping this
|
|
92
|
+
// package free of any Redis client dependency.
|
|
93
|
+
type RedisReplayStore struct {
|
|
94
|
+
prefix string
|
|
95
|
+
setNX SetNXFunc
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// NewRedisReplayStore creates a RedisReplayStore.
|
|
99
|
+
//
|
|
100
|
+
// prefix is prepended to every nonce key (e.g. "7h3:nonce:").
|
|
101
|
+
// setNX is the injection function described by SetNXFunc.
|
|
102
|
+
func NewRedisReplayStore(prefix string, setNX SetNXFunc) *RedisReplayStore {
|
|
103
|
+
return &RedisReplayStore{prefix: prefix, setNX: setNX}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check returns (false, nil) when the nonce is fresh, (true, nil) when it is
|
|
107
|
+
// a replay, or (false, err) on store error.
|
|
108
|
+
func (r *RedisReplayStore) Check(ctx context.Context, key string, ttlMs int64) (bool, error) {
|
|
109
|
+
ttl := time.Duration(ttlMs) * time.Millisecond
|
|
110
|
+
if ttl <= 0 {
|
|
111
|
+
ttl = time.Millisecond
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
wasSet, err := r.setNX(ctx, r.prefix+key, ttl)
|
|
115
|
+
if err != nil {
|
|
116
|
+
return false, err
|
|
117
|
+
}
|
|
118
|
+
// wasSet=true → key newly set → fresh → not a replay
|
|
119
|
+
// wasSet=false → key existed → replay
|
|
120
|
+
return !wasSet, nil
|
|
121
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package protocol7h3
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"sync"
|
|
6
|
+
"testing"
|
|
7
|
+
"time"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
func TestInMemoryReplayStore_Fresh(t *testing.T) {
|
|
11
|
+
store := NewInMemoryReplayStore()
|
|
12
|
+
ctx := context.Background()
|
|
13
|
+
|
|
14
|
+
replay, err := store.Check(ctx, "nonce-1", 30_000)
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatalf("unexpected error: %v", err)
|
|
17
|
+
}
|
|
18
|
+
if replay {
|
|
19
|
+
t.Error("expected fresh (false) on first call, got true")
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestInMemoryReplayStore_Replay(t *testing.T) {
|
|
24
|
+
store := NewInMemoryReplayStore()
|
|
25
|
+
ctx := context.Background()
|
|
26
|
+
|
|
27
|
+
if _, err := store.Check(ctx, "nonce-2", 30_000); err != nil {
|
|
28
|
+
t.Fatalf("unexpected error on first call: %v", err)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
replay, err := store.Check(ctx, "nonce-2", 30_000)
|
|
32
|
+
if err != nil {
|
|
33
|
+
t.Fatalf("unexpected error on second call: %v", err)
|
|
34
|
+
}
|
|
35
|
+
if !replay {
|
|
36
|
+
t.Error("expected replay (true) on second call with same nonce, got false")
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestInMemoryReplayStore_ExpiredEntryTreatedAsFresh(t *testing.T) {
|
|
41
|
+
var mu sync.Mutex
|
|
42
|
+
now := time.Now()
|
|
43
|
+
store := &InMemoryReplayStore{
|
|
44
|
+
now: func() time.Time {
|
|
45
|
+
mu.Lock()
|
|
46
|
+
defer mu.Unlock()
|
|
47
|
+
return now
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
ctx := context.Background()
|
|
51
|
+
|
|
52
|
+
// First call: set nonce with 100ms TTL
|
|
53
|
+
replay, err := store.Check(ctx, "nonce-exp", 100)
|
|
54
|
+
if err != nil || replay {
|
|
55
|
+
t.Fatalf("expected fresh on first call; replay=%v err=%v", replay, err)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Advance past TTL
|
|
59
|
+
mu.Lock()
|
|
60
|
+
now = now.Add(200 * time.Millisecond)
|
|
61
|
+
mu.Unlock()
|
|
62
|
+
|
|
63
|
+
// Second call: expired entry should be treated as fresh
|
|
64
|
+
replay, err = store.Check(ctx, "nonce-exp", 100)
|
|
65
|
+
if err != nil {
|
|
66
|
+
t.Fatalf("unexpected error: %v", err)
|
|
67
|
+
}
|
|
68
|
+
if replay {
|
|
69
|
+
t.Error("expected fresh (false) after TTL expiry, got true")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestInMemoryReplayStore_DistinctNonces(t *testing.T) {
|
|
74
|
+
store := NewInMemoryReplayStore()
|
|
75
|
+
ctx := context.Background()
|
|
76
|
+
|
|
77
|
+
for _, nonce := range []string{"a", "b", "c"} {
|
|
78
|
+
replay, err := store.Check(ctx, nonce, 60_000)
|
|
79
|
+
if err != nil || replay {
|
|
80
|
+
t.Errorf("nonce %q: expected fresh; replay=%v err=%v", nonce, replay, err)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
func TestRedisReplayStore_Fresh(t *testing.T) {
|
|
86
|
+
// setNX returns true = key was newly set = fresh
|
|
87
|
+
setNX := func(_ context.Context, _ string, _ time.Duration) (bool, error) {
|
|
88
|
+
return true, nil
|
|
89
|
+
}
|
|
90
|
+
store := NewRedisReplayStore("7h3:nonce:", setNX)
|
|
91
|
+
replay, err := store.Check(context.Background(), "nonce-r1", 30_000)
|
|
92
|
+
if err != nil {
|
|
93
|
+
t.Fatalf("unexpected error: %v", err)
|
|
94
|
+
}
|
|
95
|
+
if replay {
|
|
96
|
+
t.Error("expected fresh (false) when setNX returns true")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestRedisReplayStore_Replay(t *testing.T) {
|
|
101
|
+
// setNX returns false = key already existed = replay
|
|
102
|
+
setNX := func(_ context.Context, _ string, _ time.Duration) (bool, error) {
|
|
103
|
+
return false, nil
|
|
104
|
+
}
|
|
105
|
+
store := NewRedisReplayStore("7h3:nonce:", setNX)
|
|
106
|
+
replay, err := store.Check(context.Background(), "nonce-r2", 30_000)
|
|
107
|
+
if err != nil {
|
|
108
|
+
t.Fatalf("unexpected error: %v", err)
|
|
109
|
+
}
|
|
110
|
+
if !replay {
|
|
111
|
+
t.Error("expected replay (true) when setNX returns false")
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
func TestRedisReplayStore_KeyPrefix(t *testing.T) {
|
|
116
|
+
var capturedKey string
|
|
117
|
+
setNX := func(_ context.Context, key string, _ time.Duration) (bool, error) {
|
|
118
|
+
capturedKey = key
|
|
119
|
+
return true, nil
|
|
120
|
+
}
|
|
121
|
+
store := NewRedisReplayStore("custom:prefix:", setNX)
|
|
122
|
+
_, _ = store.Check(context.Background(), "my-nonce", 5000)
|
|
123
|
+
|
|
124
|
+
if capturedKey != "custom:prefix:my-nonce" {
|
|
125
|
+
t.Errorf("expected key 'custom:prefix:my-nonce', got %q", capturedKey)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
func TestRedisReplayStore_TTLClamped(t *testing.T) {
|
|
130
|
+
var capturedTTL time.Duration
|
|
131
|
+
setNX := func(_ context.Context, _ string, ttl time.Duration) (bool, error) {
|
|
132
|
+
capturedTTL = ttl
|
|
133
|
+
return true, nil
|
|
134
|
+
}
|
|
135
|
+
store := NewRedisReplayStore("p:", setNX)
|
|
136
|
+
_, _ = store.Check(context.Background(), "n", 0)
|
|
137
|
+
|
|
138
|
+
if capturedTTL < time.Millisecond {
|
|
139
|
+
t.Errorf("TTL should be clamped to at least 1ms, got %v", capturedTTL)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func TestRedisReplayStore_ImplementsInterface(t *testing.T) {
|
|
144
|
+
setNX := func(_ context.Context, _ string, _ time.Duration) (bool, error) {
|
|
145
|
+
return true, nil
|
|
146
|
+
}
|
|
147
|
+
var _ ReplayStore = NewRedisReplayStore("p:", setNX)
|
|
148
|
+
var _ ReplayStore = NewInMemoryReplayStore()
|
|
149
|
+
}
|