@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +1169 -175
  3. package/bin/7h3.ts +22 -1
  4. package/docs/assets/banner-github.png +0 -0
  5. package/docs/assets/banner.svg +123 -0
  6. package/package.json +55 -13
  7. package/sdk/browser/package.json +1 -1
  8. package/sdk/go/cbor.go +551 -0
  9. package/sdk/go/cbor_test.go +232 -0
  10. package/sdk/go/encryption.go +280 -0
  11. package/sdk/go/encryption_test.go +318 -0
  12. package/sdk/go/go.mod +5 -1
  13. package/sdk/go/go.sum +4 -0
  14. package/sdk/go/replay.go +121 -0
  15. package/sdk/go/replay_test.go +149 -0
  16. package/sdk/pq/package-lock.json +1358 -0
  17. package/sdk/pq/package.json +42 -0
  18. package/sdk/pq/src/index.test.ts +143 -0
  19. package/sdk/pq/src/index.ts +166 -0
  20. package/sdk/pq/tsconfig.json +14 -0
  21. package/sdk/pq/vitest.config.ts +7 -0
  22. package/sdk/python/protocol_7h3/encryption.py +252 -0
  23. package/sdk/python/protocol_7h3/pq.py +244 -0
  24. package/sdk/python/protocol_7h3/replay.py +98 -0
  25. package/sdk/python/pyproject.toml +1 -1
  26. package/sdk/python/tests/test_encryption.py +206 -0
  27. package/sdk/rust/Cargo.lock +1 -1
  28. package/sdk/rust/Cargo.toml +1 -1
  29. package/sdk/threshold/index.d.ts +68 -0
  30. package/sdk/threshold/index.d.ts.map +1 -0
  31. package/sdk/threshold/index.js +254 -0
  32. package/sdk/threshold/package-lock.json +1361 -0
  33. package/sdk/threshold/package.json +39 -0
  34. package/sdk/threshold/src/index.d.ts +68 -0
  35. package/sdk/threshold/src/index.d.ts.map +1 -0
  36. package/sdk/threshold/src/index.js +254 -0
  37. package/sdk/threshold/src/index.test.ts +238 -0
  38. package/sdk/threshold/src/index.ts +355 -0
  39. package/sdk/threshold/tsconfig.json +19 -0
  40. package/sdk/threshold/vitest.config.ts +12 -0
  41. package/src/capability.test.ts +504 -0
  42. package/src/capability.ts +380 -0
  43. package/src/cborCodec.test.ts +263 -0
  44. package/src/cborCodec.ts +339 -0
  45. package/src/encryption.test.ts +206 -0
  46. package/src/encryption.ts +245 -0
  47. package/src/envelopeCbor.ts +140 -0
  48. package/src/gateway.ts +75 -0
  49. package/src/httpBinding.ts +37 -11
  50. package/src/index.ts +7 -0
  51. package/src/otel.ts +136 -0
  52. package/src/protocol.d.ts +67 -0
  53. package/src/protocol.d.ts.map +1 -0
  54. package/src/protocol.js +294 -0
  55. package/src/protocol.ts +1 -0
  56. package/src/replayStores.test.ts +133 -1
  57. package/src/replayStores.ts +136 -3
  58. package/src/stream.test.ts +254 -0
  59. package/src/stream.ts +417 -0
  60. package/src/telemetry.test.ts +251 -0
  61. package/src/telemetry.ts +299 -0
  62. package/src/wsBinding.ts +100 -0
  63. 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
@@ -1,3 +1,7 @@
1
1
  module github.com/IceMasterT/7h3-protocol/sdk/go
2
2
 
3
- go 1.21
3
+ go 1.25.0
4
+
5
+ require golang.org/x/crypto v0.53.0
6
+
7
+ require golang.org/x/sys v0.46.0 // indirect
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=
@@ -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
+ }