● Notice //GitHub (@shehzade) is temporarily suspended pending an automated false-positive review. All tools are hosted on this Code Mirror and available on request (contact); GitHub links will return once reinstated.
PUBLIC MIRRORGo v1.0

Multi-protocol network egress assessment and DLP verification framework featuring automated L4 sweeps, corporate proxy traversal, and covert protocol smuggling across 20+ channels.

$ git clone https://shehzade.io/repos/contraband.git Copy
Source ZIP (1.93 MB)
Code 174 Commits 1
contraband / pkg / common / models.go
← Back to directory
517 lines · 21.1 KB
Raw
1package common
2
3import (
4 "crypto/rand"
5 "encoding/binary"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "strings"
11 "time"
12)
13
14// SessionToken represents a cryptographically random 16-byte session identifier.
15type SessionToken [16]byte
16
17// NewSessionToken generates a cryptographically secure random 16-byte session token.
18func NewSessionToken() (SessionToken, error) {
19 var token SessionToken
20 if _, err := rand.Read(token[:]); err != nil {
21 return SessionToken{}, fmt.Errorf("failed to generate random session token: %w", err)
22 }
23 return token, nil
24}
25
26// String returns the 32-character lowercase hexadecimal representation of the token.
27func (t SessionToken) String() string {
28 return hex.EncodeToString(t[:])
29}
30
31// ParseSessionToken parses a 32-character hexadecimal or 36-character UUID string.
32func ParseSessionToken(s string) (SessionToken, error) {
33 clean := strings.ReplaceAll(s, "-", "")
34 if len(clean) != 32 {
35 return SessionToken{}, fmt.Errorf("invalid session token length %d (expected 32 hex chars)", len(clean))
36 }
37 b, err := hex.DecodeString(clean)
38 if err != nil {
39 return SessionToken{}, fmt.Errorf("invalid session token hex encoding: %w", err)
40 }
41 var token SessionToken
42 copy(token[:], b)
43 return token, nil
44}
45
46// MarshalText serializes the token as a lowercase hex string for JSON and YAML.
47func (t SessionToken) MarshalText() ([]byte, error) {
48 return []byte(t.String()), nil
49}
50
51// UnmarshalText deserializes a hex string into the 16-byte token.
52func (t *SessionToken) UnmarshalText(data []byte) error {
53 tok, err := ParseSessionToken(string(data))
54 if err != nil {
55 return err
56 }
57 *t = tok
58 return nil
59}
60
61// IsZero checks if the token is all zero bytes.
62func (t SessionToken) IsZero() bool {
63 return t == SessionToken{}
64}
65
66// Verdict categorizes the outcome of an egress or data smuggling probe.
67type Verdict string
68
69const (
70 VerdictBlockedL4 Verdict = "BLOCKED_L4" // Firewall drop/reject (network level)
71 VerdictBlockedL7 Verdict = "BLOCKED_L7" // Proxy block / invalid handshake / DPI RST
72 VerdictBlockedDLP Verdict = "BLOCKED_DLP" // Connection succeeded, but payload intercepted
73 VerdictSuccess Verdict = "EXFIL_SUCCESS" // Payload arrived intact at server with valid SHA-256
74)
75
76// IsValid validates whether the verdict matches one of the 4 defined constants.
77func (v Verdict) IsValid() bool {
78 switch v {
79 case VerdictBlockedL4, VerdictBlockedL7, VerdictBlockedDLP, VerdictSuccess:
80 return true
81 default:
82 return false
83 }
84}
85
86// IsSuccess returns true if the probe achieved successful data exfiltration.
87func (v Verdict) IsSuccess() bool {
88 return v == VerdictSuccess
89}
90
91// IsBlocked returns true if any layer (L4, L7, or DLP) blocked the transmission.
92func (v Verdict) IsBlocked() bool {
93 return v != VerdictSuccess
94}
95
96// Description provides a human-readable interpretation of the verdict.
97func (v Verdict) Description() string {
98 switch v {
99 case VerdictBlockedL4:
100 return "Firewall dropped or rejected network connection (L4)"
101 case VerdictBlockedL7:
102 return "Layer 7 inspection, proxy, or DPI blocked transmission"
103 case VerdictBlockedDLP:
104 return "Connection succeeded, but DLP payload was intercepted"
105 case VerdictSuccess:
106 return "Egress payload successfully exfiltrated and verified intact"
107 default:
108 return "Unknown verdict"
109 }
110}
111
112// ForensicTimestamps records microsecond-precision timing metrics across the probe lifecycle.
113type ForensicTimestamps struct {
114 StartTCP time.Time `json:"start_tcp" yaml:"start_tcp"`
115 ConnectedTCP time.Time `json:"connected_tcp" yaml:"connected_tcp"`
116 HandshakeTLS time.Time `json:"handshake_tls" yaml:"handshake_tls"`
117 FirstByteSent time.Time `json:"first_byte_sent" yaml:"first_byte_sent"`
118 AckReceived time.Time `json:"ack_received" yaml:"ack_received"`
119 TeardownDone time.Time `json:"teardown_done" yaml:"teardown_done"`
120 TCPDuration time.Duration `json:"tcp_duration_ms" yaml:"tcp_duration_ms"`
121 TLSDuration time.Duration `json:"tls_duration_ms" yaml:"tls_duration_ms"`
122 TransferDuration time.Duration `json:"transfer_duration_ms" yaml:"transfer_duration_ms"`
123 TotalDuration time.Duration `json:"total_duration_ms" yaml:"total_duration_ms"`
124}
125
126// ComputeDurations populates duration fields from timestamp delta calculations.
127func (f *ForensicTimestamps) ComputeDurations() {
128 if !f.ConnectedTCP.IsZero() && !f.StartTCP.IsZero() {
129 f.TCPDuration = f.ConnectedTCP.Sub(f.StartTCP)
130 }
131 if !f.HandshakeTLS.IsZero() && !f.ConnectedTCP.IsZero() {
132 f.TLSDuration = f.HandshakeTLS.Sub(f.ConnectedTCP)
133 }
134 if !f.AckReceived.IsZero() && !f.FirstByteSent.IsZero() {
135 f.TransferDuration = f.AckReceived.Sub(f.FirstByteSent)
136 }
137 if !f.TeardownDone.IsZero() && !f.StartTCP.IsZero() {
138 f.TotalDuration = f.TeardownDone.Sub(f.StartTCP)
139 } else if !f.AckReceived.IsZero() && !f.StartTCP.IsZero() {
140 f.TotalDuration = f.AckReceived.Sub(f.StartTCP)
141 }
142}
143
144// forensicTimestampsJSON marshals durations as floating-point milliseconds in JSON.
145type forensicTimestampsJSON struct {
146 StartTCP time.Time `json:"start_tcp"`
147 ConnectedTCP time.Time `json:"connected_tcp"`
148 HandshakeTLS time.Time `json:"handshake_tls"`
149 FirstByteSent time.Time `json:"first_byte_sent"`
150 AckReceived time.Time `json:"ack_received"`
151 TeardownDone time.Time `json:"teardown_done"`
152 TCPDurationMS float64 `json:"tcp_duration_ms"`
153 TLSDurationMS float64 `json:"tls_duration_ms"`
154 TransferDurationMS float64 `json:"transfer_duration_ms"`
155 TotalDurationMS float64 `json:"total_duration_ms"`
156}
157
158func (f ForensicTimestamps) MarshalJSON() ([]byte, error) {
159 aux := forensicTimestampsJSON{
160 StartTCP: f.StartTCP,
161 ConnectedTCP: f.ConnectedTCP,
162 HandshakeTLS: f.HandshakeTLS,
163 FirstByteSent: f.FirstByteSent,
164 AckReceived: f.AckReceived,
165 TeardownDone: f.TeardownDone,
166 TCPDurationMS: float64(f.TCPDuration.Microseconds()) / 1000.0,
167 TLSDurationMS: float64(f.TLSDuration.Microseconds()) / 1000.0,
168 TransferDurationMS: float64(f.TransferDuration.Microseconds()) / 1000.0,
169 TotalDurationMS: float64(f.TotalDuration.Microseconds()) / 1000.0,
170 }
171 return json.Marshal(aux)
172}
173
174func (f *ForensicTimestamps) UnmarshalJSON(data []byte) error {
175 var aux forensicTimestampsJSON
176 if err := json.Unmarshal(data, &aux); err != nil {
177 return err
178 }
179 f.StartTCP = aux.StartTCP
180 f.ConnectedTCP = aux.ConnectedTCP
181 f.HandshakeTLS = aux.HandshakeTLS
182 f.FirstByteSent = aux.FirstByteSent
183 f.AckReceived = aux.AckReceived
184 f.TeardownDone = aux.TeardownDone
185 f.TCPDuration = time.Duration(aux.TCPDurationMS * float64(time.Millisecond))
186 f.TLSDuration = time.Duration(aux.TLSDurationMS * float64(time.Millisecond))
187 f.TransferDuration = time.Duration(aux.TransferDurationMS * float64(time.Millisecond))
188 f.TotalDuration = time.Duration(aux.TotalDurationMS * float64(time.Millisecond))
189 return nil
190}
191
192// GenerateUUIDv4 generates an RFC 4122 compliant UUID v4 string without external dependencies.
193func GenerateUUIDv4() (string, error) {
194 var b [16]byte
195 if _, err := rand.Read(b[:]); err != nil {
196 return "", fmt.Errorf("failed to generate random bytes for UUID: %w", err)
197 }
198 b[6] = (b[6] & 0x0f) | 0x40 // Version 4
199 b[8] = (b[8] & 0x3f) | 0x80 // Variant RFC 4122
200 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
201 b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
202}
203
204// ValidateNetNTLMv2Hash validates NetNTLMv2 hash format (Hashcat mode 5600):
205// username::domain:challenge:HMAC:blob
206func ValidateNetNTLMv2Hash(h string) bool {
207 parts := strings.Split(h, ":")
208 if len(parts) != 6 {
209 return false
210 }
211 // Format is: username::domain:challenge:HMAC:blob
212 // parts[0] = username (must not be empty)
213 // parts[1] = "" (the empty field between ::)
214 // parts[2] = domain (may be empty for local accounts)
215 // parts[3] = 16-hex challenge (8 bytes)
216 // parts[4] = 32-hex HMAC / NTProofStr (16 bytes)
217 // parts[5] = variable-length hex blob (even length, non-empty)
218 if parts[0] == "" || parts[1] != "" {
219 return false
220 }
221 if len(parts[3]) != 16 {
222 return false
223 }
224 if _, err := hex.DecodeString(parts[3]); err != nil {
225 return false
226 }
227 if len(parts[4]) != 32 {
228 return false
229 }
230 if _, err := hex.DecodeString(parts[4]); err != nil {
231 return false
232 }
233 if len(parts[5]) == 0 || len(parts[5])%2 != 0 {
234 return false
235 }
236 if _, err := hex.DecodeString(parts[5]); err != nil {
237 return false
238 }
239 return true
240}
241
242// ProbeResult records the comprehensive outcome and forensic evidence of an individual probe.
243type ProbeResult struct {
244 ID string `json:"id" yaml:"id"`
245 Timestamp time.Time `json:"timestamp" yaml:"timestamp"`
246 Timestamps ForensicTimestamps `json:"timestamps" yaml:"timestamps"`
247 Stage string `json:"stage" yaml:"stage"`
248 Protocol string `json:"protocol" yaml:"protocol"`
249 TargetPort int `json:"target_port" yaml:"target_port"`
250 TargetHost string `json:"target_host" yaml:"target_host"`
251 LocalAddr string `json:"local_addr" yaml:"local_addr"`
252 ProxyHop string `json:"proxy_hop,omitempty" yaml:"proxy_hop,omitempty"`
253 TLSProfile string `json:"tls_profile,omitempty" yaml:"tls_profile,omitempty"`
254 JA3String string `json:"ja3_string,omitempty" yaml:"ja3_string,omitempty"`
255 JA4String string `json:"ja4_string,omitempty" yaml:"ja4_string,omitempty"`
256 PayloadType string `json:"payload_type,omitempty" yaml:"payload_type,omitempty"`
257 CarrierType string `json:"carrier_type,omitempty" yaml:"carrier_type,omitempty"`
258 TargetVolume int64 `json:"target_volume_bytes" yaml:"target_volume_bytes"`
259 BytesSent int64 `json:"bytes_sent" yaml:"bytes_sent"`
260 BytesRecv int64 `json:"bytes_recv" yaml:"bytes_recv"`
261 ThroughputBps float64 `json:"throughput_kbps" yaml:"throughput_kbps"`
262 PayloadHash string `json:"payload_hash,omitempty" yaml:"payload_hash,omitempty"`
263 WireHash string `json:"wire_hash,omitempty" yaml:"wire_hash,omitempty"`
264 Verdict Verdict `json:"verdict" yaml:"verdict"`
265 CapturedHash string `json:"captured_hash,omitempty" yaml:"captured_hash,omitempty"`
266 ErrorMessage string `json:"error_message,omitempty" yaml:"error_message,omitempty"`
267 RawEvidence string `json:"raw_evidence,omitempty" yaml:"raw_evidence,omitempty"`
268}
269
270// NewProbeResult initializes a ProbeResult with an auto-generated UUID v4 and UTC timestamp.
271func NewProbeResult(stage, protocol, targetHost string, targetPort int) (ProbeResult, error) {
272 id, err := GenerateUUIDv4()
273 if err != nil {
274 return ProbeResult{}, err
275 }
276 now := time.Now().UTC()
277 return ProbeResult{
278 ID: id,
279 Timestamp: now,
280 Timestamps: ForensicTimestamps{StartTCP: now},
281 Stage: stage,
282 Protocol: protocol,
283 TargetHost: targetHost,
284 TargetPort: targetPort,
285 }, nil
286}
287
288// Validate checks the structural integrity of the ProbeResult.
289func (p ProbeResult) Validate() error {
290 if p.ID == "" {
291 return errors.New("probe result missing ID")
292 }
293 if p.TargetPort < 1 || p.TargetPort > 65535 {
294 return fmt.Errorf("invalid target port %d (must be 1-65535)", p.TargetPort)
295 }
296 if !p.Verdict.IsValid() {
297 return fmt.Errorf("invalid verdict '%s'", p.Verdict)
298 }
299 if p.Timestamp.IsZero() {
300 return errors.New("probe result missing timestamp")
301 }
302 if p.CapturedHash != "" && !ValidateNetNTLMv2Hash(p.CapturedHash) {
303 return fmt.Errorf("malformed NetNTLMv2 hash format: %s", p.CapturedHash)
304 }
305 return nil
306}
307
308// ChunkRecord tracks reception of an individual stream chunk on the server.
309type ChunkRecord struct {
310 Sequence uint32 `json:"sequence" yaml:"sequence"`
311 Total uint32 `json:"total" yaml:"total"`
312 Length uint32 `json:"length" yaml:"length"`
313 ReceivedAt time.Time `json:"received_at" yaml:"received_at"`
314 Hash string `json:"hash,omitempty" yaml:"hash,omitempty"`
315}
316
317// Session models the server-side lifecycle and reassembly state of an exfiltration stream.
318type Session struct {
319 SessionID string `json:"session_id" yaml:"session_id"`
320 Token SessionToken `json:"token,omitempty" yaml:"token,omitempty"`
321 ClientIP string `json:"client_ip" yaml:"client_ip"`
322 ClientHost string `json:"client_host,omitempty" yaml:"client_host,omitempty"`
323 NetworkZone string `json:"network_zone,omitempty" yaml:"network_zone,omitempty"`
324 Protocol string `json:"protocol" yaml:"protocol"`
325 Port int `json:"port" yaml:"port"`
326 StartTime time.Time `json:"start_time" yaml:"start_time"`
327 LastActive time.Time `json:"last_active" yaml:"last_active"`
328 TotalBytes int64 `json:"total_bytes" yaml:"total_bytes"`
329 ExpectedBytes int64 `json:"expected_bytes" yaml:"expected_bytes"`
330 TotalChunks int `json:"total_chunks" yaml:"total_chunks"`
331 ReceivedChunks int `json:"received_chunks" yaml:"received_chunks"`
332 PayloadHash string `json:"payload_hash" yaml:"payload_hash"`
333 ExpectedHash string `json:"expected_hash" yaml:"expected_hash"`
334 Status string `json:"status" yaml:"status"` // active, completed, corrupted, failed, timed_out
335 Verdict Verdict `json:"verdict" yaml:"verdict"`
336 Chunks map[uint32]ChunkRecord `json:"chunks,omitempty" yaml:"chunks,omitempty"`
337}
338
339// NewSession initializes a new server session tracker.
340func NewSession(token SessionToken, clientIP, protocol string, port int) *Session {
341 now := time.Now().UTC()
342 return &Session{
343 SessionID: token.String(),
344 Token: token,
345 ClientIP: clientIP,
346 Protocol: protocol,
347 Port: port,
348 StartTime: now,
349 LastActive: now,
350 Status: "active",
351 Chunks: make(map[uint32]ChunkRecord),
352 }
353}
354
355// IsComplete returns true if all expected chunks have arrived.
356func (s *Session) IsComplete() bool {
357 return s.TotalChunks > 0 && s.ReceivedChunks >= s.TotalChunks
358}
359
360// ChunkHeader represents the standardized 32-byte binary chunk frame header.
361type ChunkHeader struct {
362 Magic uint32 // 0x43545242 ("CTRB")
363 Token SessionToken // 16-byte session identifier
364 Sequence uint32 // Chunk sequence number (1-based)
365 TotalChunks uint32 // Total chunk count
366 PayloadLen uint32 // Byte length of chunk payload
367}
368
369// MarshalBinary serializes the 32-byte chunk header to big-endian wire format.
370func (h ChunkHeader) MarshalBinary() ([]byte, error) {
371 buf := make([]byte, ChunkHeaderSize)
372 binary.BigEndian.PutUint32(buf[0:4], h.Magic)
373 copy(buf[4:20], h.Token[:])
374 binary.BigEndian.PutUint32(buf[20:24], h.Sequence)
375 binary.BigEndian.PutUint32(buf[24:28], h.TotalChunks)
376 binary.BigEndian.PutUint32(buf[28:32], h.PayloadLen)
377 return buf, nil
378}
379
380// UnmarshalBinary deserializes the 32-byte chunk header from big-endian wire format.
381func (h *ChunkHeader) UnmarshalBinary(data []byte) error {
382 if len(data) < ChunkHeaderSize {
383 return fmt.Errorf("chunk header too short: got %d bytes, expected %d", len(data), ChunkHeaderSize)
384 }
385 h.Magic = binary.BigEndian.Uint32(data[0:4])
386 if h.Magic != MagicHeader {
387 return fmt.Errorf("invalid chunk header magic: 0x%08x (expected 0x%08x)", h.Magic, MagicHeader)
388 }
389 copy(h.Token[:], data[4:20])
390 h.Sequence = binary.BigEndian.Uint32(data[20:24])
391 h.TotalChunks = binary.BigEndian.Uint32(data[24:28])
392 h.PayloadLen = binary.BigEndian.Uint32(data[28:32])
393 return nil
394}
395
396// CapturedCredential models credentials harvested by server listeners (SMB, SFTP, FTP).
397type CapturedCredential struct {
398 Protocol string `json:"protocol" yaml:"protocol"`
399 Timestamp time.Time `json:"timestamp" yaml:"timestamp"`
400 ClientIP string `json:"client_ip" yaml:"client_ip"`
401 Username string `json:"username" yaml:"username"`
402 Domain string `json:"domain,omitempty" yaml:"domain,omitempty"`
403 HashType string `json:"hash_type" yaml:"hash_type"` // NetNTLMv2, plaintext, ssh_key
404 Hash string `json:"hash" yaml:"hash"` // Hashcat format or captured credential
405}
406
407// Finding models a security deficiency identified during egress testing for SARIF/reporting.
408type Finding struct {
409 RuleID string `json:"rule_id" yaml:"rule_id"`
410 Level string `json:"level" yaml:"level"` // info, low, medium, high, critical
411 Message string `json:"message" yaml:"message"`
412 Port int `json:"port" yaml:"port"`
413 Protocol string `json:"protocol" yaml:"protocol"`
414 EEIContribution float64 `json:"eei_contribution" yaml:"eei_contribution"`
415 Remediation string `json:"remediation" yaml:"remediation"`
416}
417
418// BannerReflection models the authenticated JSON reflection banner emitted by server listeners.
419type BannerReflection struct {
420 Status string `json:"status"` // "OK"
421 RemoteIP string `json:"remote_ip"` // Observed client IP
422 Port int `json:"port"` // Target port
423 Proto string `json:"proto"` // "TCP" or "UDP"
424 Session string `json:"session"` // Session ID string
425}
426
427// TelemetryEvent models real-time server-side streaming events for SSE/telemetry.
428type TelemetryEvent struct {
429 Timestamp time.Time `json:"timestamp"`
430 Type string `json:"type"` // probe_start, chunk_received, credential_captured, error
431 Message string `json:"message"`
432 Data *ProbeResult `json:"data,omitempty"`
433}
434
435// VerificationACK models the cryptographically signed and hash-validated acknowledgement
436// returned by contraband server listeners upon chunk receipt or stream completion.
437type VerificationACK struct {
438 Status string `json:"status" yaml:"status"`
439 SessionID string `json:"session_id" yaml:"session_id"`
440 TotalBytes int64 `json:"total_bytes,omitempty" yaml:"total_bytes,omitempty"`
441 ReceivedChunks int `json:"received_chunks,omitempty" yaml:"received_chunks,omitempty"`
442 TotalChunks int `json:"total_chunks,omitempty" yaml:"total_chunks,omitempty"`
443 Sequence uint32 `json:"sequence,omitempty" yaml:"sequence,omitempty"`
444 PayloadHash string `json:"payload_hash,omitempty" yaml:"payload_hash,omitempty"`
445 Verdict Verdict `json:"verdict" yaml:"verdict"`
446 ErrorMessage string `json:"error_message,omitempty" yaml:"error_message,omitempty"`
447}
448
449// Verify evaluates the server verification ACK against client expectations.
450// It returns VerdictSuccess and nil error only if:
451// 1. Status == "OK"
452// 2. Verdict is VerdictSuccess (or non-empty valid success and not IN_PROGRESS)
453// 3. Expected chunk counts match (if provided)
454// 4. Rolling payload SHA-256 matches expectedHash (if provided)
455//
456// In case of DLP interception (hash mismatch, explicit BLOCKED_DLP verdict),
457// it returns VerdictBlockedDLP and a descriptive error.
458// In case of L7 blocking (status ERROR, proxy block, chunk missing),
459// it returns VerdictBlockedL7 and an error.
460func (ack *VerificationACK) Verify(expectedHash string, totalChunks int) (Verdict, error) {
461 if ack == nil {
462 return VerdictBlockedL7, errors.New("nil verification ACK received from server")
463 }
464
465 // 1. Check for server error status or explicit DLP block
466 if strings.EqualFold(ack.Status, "ERROR") {
467 errMsg := ack.ErrorMessage
468 if errMsg == "" {
469 errMsg = "server returned error status"
470 }
471 if ack.Verdict == VerdictBlockedDLP || strings.Contains(strings.ToLower(errMsg), "hash mismatch") || strings.Contains(strings.ToLower(errMsg), "dlp") {
472 return VerdictBlockedDLP, fmt.Errorf("server reported DLP interception: %s", errMsg)
473 }
474 return VerdictBlockedL7, fmt.Errorf("server error: %s", errMsg)
475 }
476
477 if ack.Verdict == VerdictBlockedDLP {
478 return VerdictBlockedDLP, fmt.Errorf("server verdict BLOCKED_DLP: %s", ack.ErrorMessage)
479 }
480
481 // 2. Check for in-progress or incomplete stream
482 if string(ack.Verdict) == "IN_PROGRESS" || ack.Verdict == "" {
483 if totalChunks > 0 && ack.ReceivedChunks > 0 && ack.ReceivedChunks < totalChunks {
484 return VerdictBlockedL7, fmt.Errorf("incomplete exfiltration stream: received %d/%d chunks", ack.ReceivedChunks, totalChunks)
485 }
486 if ack.Verdict == "IN_PROGRESS" {
487 return VerdictBlockedL7, fmt.Errorf("stream incomplete on server (verdict IN_PROGRESS)")
488 }
489 }
490
491 // 3. Check chunk accounting if totalChunks specified
492 if totalChunks > 0 {
493 if ack.TotalChunks > 0 && ack.TotalChunks != totalChunks {
494 return VerdictBlockedL7, fmt.Errorf("chunk total mismatch: server total %d, client total %d", ack.TotalChunks, totalChunks)
495 }
496 if ack.ReceivedChunks > 0 && ack.ReceivedChunks != totalChunks {
497 return VerdictBlockedL7, fmt.Errorf("chunk reception mismatch: server received %d, client sent %d", ack.ReceivedChunks, totalChunks)
498 }
499 }
500
501 // 4. Cryptographic Non-Repudiation: verify rolling SHA-256 checksum
502 if expectedHash != "" {
503 if ack.PayloadHash == "" {
504 return VerdictBlockedDLP, fmt.Errorf("missing server payload hash: expected %s", expectedHash)
505 }
506 if !strings.EqualFold(ack.PayloadHash, expectedHash) {
507 return VerdictBlockedDLP, fmt.Errorf("cryptographic payload SHA-256 mismatch (possible DLP tampering): server=%s client=%s", ack.PayloadHash, expectedHash)
508 }
509 }
510
511 // 5. Success confirmation
512 if ack.Verdict != "" && ack.Verdict != VerdictSuccess {
513 return ack.Verdict, fmt.Errorf("unexpected server verdict: %s", ack.Verdict)
514 }
515
516 return VerdictSuccess, nil
517}