#!/usr/bin/env perl use strict; use warnings; use Getopt::Long; use Crypt::Cipher::AES; use IO::File; # checkSneakernetFile - verify an encrypted ZFS send stream produced by sneakernet. # # sneakernet encrypts each dataset stream with: # openssl enc -aes-256-cbc -K -iv # and stores the (hex) IV in a companion ".IV" sidecar. This tool: # Stage 1: decrypts just the leading block(s) in pure Perl and checks for the # ZFS send-stream magic (DMU_BACKUP_MAGIC) at the start of the plaintext. # Stage 2: streams the whole file through openssl + `zfs receive -nF` (a dry run, # -n makes no changes) to confirm the entire stream is valid. # ZFS send-stream magic: DMU_BACKUP_MAGIC = 0x2F5bacbac (64-bit). # It is the drr_magic field of the leading DRR_BEGIN record, which sits at byte # offset 8 (after drr_type and drr_payloadlen), NOT at offset 0. Rather than # assume an exact offset, we search the decrypted prefix for the 8-byte pattern # in either endianness (the sender's byte order is what the receiver detects). # Big-endian bytes: 00 00 00 02 F5 BA CB AC # Little-endian bytes: AC CB BA F5 02 00 00 00 my $MAGIC_BE = pack('C8', 0x00, 0x00, 0x00, 0x02, 0xF5, 0xBA, 0xCB, 0xAC); my $MAGIC_LE = pack('C8', 0xAC, 0xCB, 0xBA, 0xF5, 0x02, 0x00, 0x00, 0x00); use constant BLOCK => 16; # AES block size in bytes # Config / CLI my $help = 0; my $enc_file; my $key_hex; my $iv_hex; my $zfs_target = 'storage/backup/files_share'; my $header_len = 64 * 1024; # how many plaintext bytes to inspect in stage 1 my $debug = 0; GetOptions( 'file=s' => \$enc_file, 'key=s' => \$key_hex, 'iv=s' => \$iv_hex, 'target=s' => \$zfs_target, 'header=i' => \$header_len, 'debug!' => \$debug, 'help|h' => \$help, ) or usage(); usage() if $help; defined $enc_file or usage("missing --file"); defined $key_hex or usage("missing --key"); # IV: default to the companion ".IV" sidecar that sneakernet writes. unless (defined $iv_hex) { my $iv_file = "$enc_file.IV"; my $ifh = IO::File->new($iv_file, 'r') or usage("missing --iv and cannot read companion IV file '$iv_file': $!"); $iv_hex = <$ifh>; close $ifh; defined $iv_hex or usage("companion IV file '$iv_file' is empty"); print "Using IV from $iv_file\n" if $debug; } # Hex key/iv -> raw bytes sub hex_to_raw { my ($h) = @_; $h =~ s/\s+//g; die "hex string must contain only hex digits\n" if $h =~ /[^0-9a-fA-F]/; die "hex string must have even length\n" if length($h) % 2; return pack('H*', $h); } my $key = hex_to_raw($key_hex); my $iv = hex_to_raw($iv_hex); die "IV must be @{[BLOCK]} bytes (".(BLOCK*2)." hex chars), got ".length($iv)." bytes\n" unless length($iv) == BLOCK; die "key must be 16, 24, or 32 bytes (AES-128/192/256), got ".length($key)." bytes\n" unless length($key) == 16 || length($key) == 24 || length($key) == 32; # --- Stage 1: decrypt a prefix and check the ZFS magic ----------------------- # For AES-CBC, each plaintext block depends only on its own ciphertext block and # the previous ciphertext block (or the IV), so we can decrypt a leading prefix # without reading the whole file. Read ceil(header_len / BLOCK) blocks. my $needed_blocks = int(($header_len + BLOCK - 1) / BLOCK); my $cipher_bytes_to_read = $needed_blocks * BLOCK; my $fh = IO::File->new($enc_file, 'r') or die "Cannot open '$enc_file': $!\n"; binmode $fh; my $cipherbuf = ''; my $read_total = 0; while ($read_total < $cipher_bytes_to_read) { my $buf; my $n = $fh->read($buf, $cipher_bytes_to_read - $read_total); last unless $n; # 0 = EOF, undef = error $cipherbuf .= $buf; $read_total += $n; } close $fh; if ($read_total < $cipher_bytes_to_read) { warn "Note: file shorter than requested prefix ($read_total bytes read)\n" if $debug; } die "Ciphertext too small to decrypt header (need at least @{[BLOCK]} bytes)\n" if length($cipherbuf) < BLOCK; # Manual AES-CBC decrypt of whole blocks: ECB-decrypt each block, XOR with the # previous ciphertext block (the IV for the first). No padding is involved here # because we only ever look at leading blocks, never the final padded block. my $aes = Crypt::Cipher::AES->new($key); my $plain = ''; my $prev = $iv; for (my $pos = 0; $pos + BLOCK <= length($cipherbuf); $pos += BLOCK) { my $cblock = substr($cipherbuf, $pos, BLOCK); $plain .= $aes->decrypt($cblock) ^ $prev; $prev = $cblock; } $plain = substr($plain, 0, $header_len) if length($plain) > $header_len; # Search the decrypted prefix for the ZFS send-stream magic (see note above). my $magic_at = index($plain, $MAGIC_BE); $magic_at = index($plain, $MAGIC_LE) if $magic_at < 0; if ($magic_at >= 0) { print "HEADER CHECK: OK - found ZFS send-stream magic at offset $magic_at of decrypted data\n"; } else { print "HEADER CHECK: FAILED - ZFS magic not found (wrong key/IV or not a ZFS stream)\n"; if ($debug) { printf "DEBUG: first 16 plaintext bytes: %s\n", join(' ', map { sprintf '%02x', $_ } unpack('C16', $plain)); } exit 3; } # --- Stage 2: full-stream dry run through openssl | zfs receive -nF ----------- print "FULL-STREAM CHECK: streaming entire ciphertext through 'zfs receive -nF' ...\n"; my $openssl_cipher = length($key) == 16 ? 'aes-128-cbc' : length($key) == 24 ? 'aes-192-cbc' : 'aes-256-cbc'; # NOTE: -K/-iv place the key on the command line, so they are briefly visible in # `ps` to other local users -- this matches how sneakernet itself invokes openssl. my $open_cmd = "openssl enc -$openssl_cipher -d -K $key_hex -iv $iv_hex -in " . sh_quote($enc_file); my $zfs_cmd = "zfs receive -nF " . sh_quote($zfs_target); my $pipeline = "$open_cmd 2>&1 | $zfs_cmd 2>&1"; print "DEBUG: pipeline: $pipeline\n" if $debug; # Run the shell pipeline and stream its combined output. Both stages already # redirect stderr into the pipe (2>&1), so a single read handle suffices and # there is no risk of an open3-style stdout/stderr deadlock. The shell reports # the exit status of the last command (zfs receive), which is what we want. open(my $pipe, '-|', '/bin/sh', '-c', $pipeline) or die "Cannot run verification pipeline: $!\n"; while (defined(my $line = <$pipe>)) { print $line; } close $pipe; my $rc = $? >> 8; if ($rc == 0) { print "FULL-STREAM CHECK: OK - 'zfs receive -nF' accepted the stream (valid)\n"; exit 0; } else { print "FULL-STREAM CHECK: FAILED - pipeline exit code $rc\n"; exit 4; } # Single-quote a string for safe use in /bin/sh. sub sh_quote { my ($s) = @_; $s =~ s/'/'\\''/g; return "'$s'"; } sub usage { my ($msg) = @_; print "Error: $msg\n\n" if $msg; print <<"USAGE"; Usage: $0 --file --key [--iv ] [--target pool/dataset] [--header bytes] [--debug] Verifies an encrypted ZFS send stream produced by sneakernet. --file Path to the encrypted transport file. --key Transport encryption key as hex (transport.encryptionKey). --iv IV as hex. Optional: defaults to the companion ".IV" sidecar. --target ZFS dataset for the 'zfs receive -nF' dry run (default: $zfs_target). --header Plaintext bytes to inspect in the fast header check (default: $header_len). --debug Print the pipeline and extra diagnostics. Examples: $0 --file /mnt/sneakernet/datasets/storage.files_share --key 0123abcd... $0 --file enc.bin --key 0123abcd... --iv 89ab... --target storage/backup/files_share Exit codes: 0 = header OK and full-stream dry-run OK 1 = usage error 3 = header check failed (no ZFS magic) 4 = full-stream dry-run failed USAGE exit($msg ? 1 : 0); }