|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// V-Ecosystem Air-gapped transfer protocol connector for secure offline data exchange Connector |
| 3 | +// Author: Jonathan D.A. Jewell |
| 4 | +// |
| 5 | +// Air-gapped data transfer client for secure offline environments. Supports |
| 6 | +// sneakernet-style serialisation, QR code chunking, USB device enumeration, |
| 7 | +// hash-chain integrity verification, and manifest-based transfer validation. |
| 8 | +// Designed for high-security environments where network connectivity is |
| 9 | +// intentionally absent. |
| 10 | + |
| 11 | +module airgap |
| 12 | + |
| 13 | +import os |
| 14 | +import crypto.sha256 |
| 15 | +import encoding.hex |
| 16 | + |
| 17 | +// --- Air-gap transfer constants --- |
| 18 | + |
| 19 | +// Maximum chunk size for QR code encoding (bytes). |
| 20 | +const max_qr_chunk = 2048 |
| 21 | + |
| 22 | +// Maximum chunk size for USB transfer (bytes). |
| 23 | +const max_usb_chunk = 1048576 |
| 24 | + |
| 25 | +// --- Transfer direction --- |
| 26 | + |
| 27 | +// TransferDirection indicates whether data flows in or out. |
| 28 | +pub enum TransferDirection { |
| 29 | + export_out // Data leaving the secure enclave |
| 30 | + import_in // Data entering the secure enclave |
| 31 | +} |
| 32 | + |
| 33 | +// --- Data structures --- |
| 34 | + |
| 35 | +// ChunkManifest describes a set of chunks comprising a single transfer. |
| 36 | +pub struct ChunkManifest { |
| 37 | +pub: |
| 38 | + transfer_id string // Unique transfer session identifier |
| 39 | + total_chunks int // Total number of chunks |
| 40 | + hash_algo string // Hash algorithm (sha256) |
| 41 | + root_hash string // Merkle root of all chunk hashes |
| 42 | +} |
| 43 | + |
| 44 | +// Chunk represents a single data fragment within a transfer. |
| 45 | +pub struct Chunk { |
| 46 | +pub: |
| 47 | + index int // Chunk ordinal (0-based) |
| 48 | + data []u8 // Raw chunk payload |
| 49 | + hash string // SHA-256 hash of data |
| 50 | +} |
| 51 | + |
| 52 | +// TransferSession tracks the state of an ongoing air-gapped transfer. |
| 53 | +pub struct TransferSession { |
| 54 | +pub mut: |
| 55 | + manifest ChunkManifest |
| 56 | + received []bool // Which chunks have been received |
| 57 | + direction TransferDirection |
| 58 | +} |
| 59 | + |
| 60 | +// Config holds air-gap transfer parameters. |
| 61 | +pub struct Config { |
| 62 | +pub: |
| 63 | + chunk_size int = max_qr_chunk // Chunk size in bytes |
| 64 | + verify_hash bool = true // Verify chunk integrity |
| 65 | + device_path string = "/dev/sda1" // USB device path |
| 66 | +} |
| 67 | + |
| 68 | +// --- Session lifecycle --- |
| 69 | + |
| 70 | +// new_session creates a new air-gapped transfer session. |
| 71 | +pub fn new_session(config Config) &TransferSession { |
| 72 | + return &TransferSession{ |
| 73 | + manifest: ChunkManifest{ |
| 74 | + transfer_id: "ag-session" |
| 75 | + total_chunks: 0 |
| 76 | + hash_algo: "sha256" |
| 77 | + root_hash: "" |
| 78 | + } |
| 79 | + received: []bool{} |
| 80 | + direction: .export_out |
| 81 | + } |
| 82 | +} |
| 83 | + |
| 84 | +// prepare_export splits data into chunks and builds a manifest. |
| 85 | +pub fn (mut s TransferSession) prepare_export(data []u8, chunk_size int) []Chunk { |
| 86 | + mut chunks := []Chunk{} |
| 87 | + mut offset := 0 |
| 88 | + mut idx := 0 |
| 89 | + for offset < data.len { |
| 90 | + end := if offset + chunk_size > data.len { data.len } else { offset + chunk_size } |
| 91 | + slice := data[offset..end] |
| 92 | + h := sha256.sum(slice) |
| 93 | + chunks << Chunk{ |
| 94 | + index: idx |
| 95 | + data: slice |
| 96 | + hash: hex.encode(h) |
| 97 | + } |
| 98 | + offset = end |
| 99 | + idx += 1 |
| 100 | + } |
| 101 | + s.manifest.total_chunks = chunks.len |
| 102 | + s.received = []bool{len: chunks.len, init: false} |
| 103 | + return chunks |
| 104 | +} |
| 105 | + |
| 106 | +// receive_chunk validates and stores a single incoming chunk. |
| 107 | +pub fn (mut s TransferSession) receive_chunk(chunk Chunk) ! { |
| 108 | + if chunk.index < 0 || chunk.index >= s.manifest.total_chunks { |
| 109 | + return error("chunk index ${chunk.index} out of range") |
| 110 | + } |
| 111 | + h := sha256.sum(chunk.data) |
| 112 | + computed := hex.encode(h) |
| 113 | + if computed != chunk.hash { |
| 114 | + return error("chunk ${chunk.index} hash mismatch") |
| 115 | + } |
| 116 | + s.received[chunk.index] = true |
| 117 | +} |
| 118 | + |
| 119 | +// is_complete returns true when all chunks have been received. |
| 120 | +pub fn (s &TransferSession) is_complete() bool { |
| 121 | + for r in s.received { |
| 122 | + if !r { return false } |
| 123 | + } |
| 124 | + return s.received.len > 0 |
| 125 | +} |
| 126 | + |
| 127 | +// --- Tests --- |
| 128 | + |
| 129 | +fn test_chunk_splitting() { |
| 130 | + mut sess := TransferSession{ |
| 131 | + manifest: ChunkManifest{ transfer_id: "test", total_chunks: 0, hash_algo: "sha256", root_hash: "" } |
| 132 | + received: []bool{} |
| 133 | + direction: .export_out |
| 134 | + } |
| 135 | + data := []u8{len: 100, init: u8(0x41)} |
| 136 | + chunks := sess.prepare_export(data, 30) |
| 137 | + assert chunks.len == 4 |
| 138 | + assert sess.manifest.total_chunks == 4 |
| 139 | +} |
0 commit comments