#!/usr/bin/env perl

# Standalone decryptor for sneakernet transport files.
#
# sneakernet optionally encrypts every dataset stream it writes to the transport
# drive with `openssl enc -aes-256-cbc` (see buildEncryptionPipeline/
# buildDecryptionPipeline in sneakernet/sneakernet), using a random per-file IV
# stored hex-encoded in a companion "<file>.IV" sidecar. This script reverses
# that on its own - no YAML config, drive-mounting, or dataset-mapping code
# involved - for disaster recovery when only the transport drive (or a copy of
# it) and the key are available.
#
# sneakernet itself keeps the key as an inline 64-character hex string in its
# config (transport.encryptionKey). This script instead takes a raw 32-byte
# binary key file, e.g. generated with:
#   openssl rand 32 > key.bin

use strict;
use warnings;
use Getopt::Long qw(GetOptions);
use File::Basename qw(basename);
use Pod::Usage qw(pod2usage);

use constant IVLENGTH => 16;   # bytes; matches sneakernet's transport IVs

my %opt = ( pathsub => '.' );

GetOptions(
   'key=s'     => \$opt{key},
   'input=s'   => \$opt{input},
   'output=s'  => \$opt{output},
   'receive=s' => \$opt{receive},
   'pathsub=s' => \$opt{pathsub},
   'dry-run'   => \$opt{dryrun},
   'verbose+'  => \$opt{verbose},
   'help'      => sub { pod2usage(1) },
) or pod2usage(2);

pod2usage("--key is required")   unless $opt{key};
pod2usage("--input is required") unless $opt{input};
pod2usage("Specify exactly one of --output DIR or --receive DATASET-PREFIX")
   unless ( $opt{output} xor $opt{receive} );

my $hexKey = readKeyAsHex($opt{key});
my @files  = -d $opt{input} ? @{ listEncryptedFiles($opt{input}) } : ($opt{input});
die "No encrypted files found under '$opt{input}'\n" unless @files;

my ($ok, $failed) = (0, 0);
for my $file (sort @files) {
   processFile($file, $hexKey, \%opt) ? $ok++ : $failed++;
}
print "Done: $ok succeeded, $failed failed\n";
exit( $failed ? 1 : 0 );

## Read a raw binary key file and return it hex-encoded, as required by
## `openssl enc -K`. Dies if the file can't be read or isn't exactly 32 bytes
## (AES-256 requires a 256-bit key).
sub readKeyAsHex {
   my ($keyFile) = @_;
   open my $fh, '<:raw', $keyFile or die "Can't read key file '$keyFile': $!\n";
   local $/;
   my $raw = <$fh>;
   close $fh;
   die "Key file '$keyFile' is " . length($raw) . " bytes; AES-256 requires exactly 32\n"
      unless defined($raw) && length($raw) == 32;
   return unpack('H*', $raw);
}

## Return an arrayref of encrypted file paths in $dir, excluding the .IV sidecars.
sub listEncryptedFiles {
   my ($dir) = @_;
   opendir my $dh, $dir or die "Can't read directory '$dir': $!\n";
   my @files = grep { -f $_ && $_ !~ /\.IV$/ }
               map  { "$dir/$_" }
               grep { $_ ne '.' && $_ ne '..' } readdir $dh;
   closedir $dh;
   return \@files;
}

## Read and validate the "$file.IV" sidecar, returning the IV as a hex string.
## Dies (caught by the caller) on anything that would make the file undecryptable.
sub readIV {
   my ($file) = @_;
   my $ivFile = "$file.IV";
   open my $fh, '<', $ivFile or die "Can't read IV file '$ivFile': $!\n";
   my $iv = <$fh>;
   close $fh;
   die "IV file '$ivFile' is empty\n" unless defined $iv;
   chomp $iv;
   die "IV file '$ivFile' is not " . (IVLENGTH * 2) . " hex characters as expected\n"
      if $iv =~ /[^0-9a-fA-F]/ || length($iv) != IVLENGTH * 2;
   return $iv;
}

## Single-quote a string for safe use in a shell command (same convention as
## shellQuote() in sneakernet/sneakernet).
sub shellQuote {
   my ($s) = @_;
   $s = '' unless defined $s;
   $s =~ s/'/'\\''/g;
   return "'$s'";
}

## Reverse sneakernet's dirnameToFileName(): "pool.data.child" -> "pool/data/child",
## using the configured path-substitution character (default '.').
sub fileNameToDatasetPath {
   my ($baseName, $substitution) = @_;
   return join( '/', split( /\Q$substitution\E/, $baseName ) );
}

## Decrypt (and decompress, if named *.xz) one transport file, then either
## write the plaintext stream to $opt->{output} or pipe it into
## `zfs receive -F` under $opt->{receive}. Returns 1 on success, 0 on failure
## (warns with the reason and lets the caller continue with the next file).
sub processFile {
   my ($file, $hexKey, $opt) = @_;

   my $iv = eval { readIV($file) };
   if ($@) { warn $@; return 0; }

   my $baseName     = basename($file);
   my $isCompressed = $baseName =~ s/\.xz$//;

   my $cmd = "openssl enc -aes-256-cbc -d -K $hexKey -iv $iv -in " . shellQuote($file);
   $cmd .= " | xz -dc" if $isCompressed;

   if ( $opt->{receive} ) {
      my $dataset  = $opt->{receive} . '/' . fileNameToDatasetPath($baseName, $opt->{pathsub});
      my $pipeline = "$cmd | zfs receive -F " . shellQuote($dataset) . " 2>&1";
      if ( $opt->{dryrun} ) {
         print "Would run: $pipeline\n";
         return 1;
      }
      print "Receiving '$file' -> $dataset\n" if $opt->{verbose};
      my $out = `$pipeline`;
      if ( $? != 0 ) {
         warn "Failed to receive '$file' into '$dataset':\n$out";
         return 0;
      }
      return 1;
   } else {
      my $outFile  = "$opt->{output}/$baseName";
      if ( $opt->{dryrun} ) {
         print "Would write: $file -> $outFile\n";
         return 1;
      }
      print "Decrypting '$file' -> $outFile\n" if $opt->{verbose};
      my $pipeline = "$cmd > " . shellQuote($outFile);
      system( '/bin/sh', '-c', $pipeline );
      if ( $? != 0 ) {
         warn "Failed to decrypt '$file' (exit code " . ($? >> 8) . ")\n";
         unlink $outFile;
         return 0;
      }
      return 1;
   }
}

__END__

=head1 NAME

decryptTransport.pl - standalone decryptor for sneakernet transport files

=head1 SYNOPSIS

 decryptTransport.pl --key key.bin --input /mnt/transport/datasets --output /tmp/restored

 decryptTransport.pl --key key.bin --input /mnt/transport/datasets --receive backup

 decryptTransport.pl --key key.bin --input nextcloud.xz --output /tmp/restored

Options:

  --key FILE       Raw binary key file, exactly 32 bytes (AES-256). Required.
                    Generate with: openssl rand 32 > key.bin
  --input PATH     A single encrypted file, or a directory of them (e.g. the
                    transport drive's "datasets" directory). Required.
  --output DIR     Write decrypted (and decompressed) plaintext streams here,
                    one per input file. Exactly one of --output/--receive
                    is required.
  --receive PREFIX Pipe each decrypted stream directly into
                    "zfs receive -F PREFIX/<dataset>", reconstructing the
                    dataset name from the filename.
  --pathsub CHAR   Path-substitution character used when the files were
                    written (matches sneakernet's transport.pathSubstitution).
                    Default: '.'
  --dry-run        Show what would be done without decrypting/receiving anything.
  --verbose        Print progress as each file is processed.
  --help           Show this message.

=head1 DESCRIPTION

sneakernet optionally encrypts every dataset stream it writes to the transport
drive with C<openssl enc -aes-256-cbc>, using a random per-file initialization
vector stored alongside it in a C<< <file>.IV >> sidecar (hex-encoded). This
script reverses that independently of sneakernet's own config, drive-mounting,
and dataset-management code, for use when the transport drive (or a copy of
it) and the key are all that's available - for example, if sneakernet itself
is not installed or configured on the machine doing the recovery.

Files compressed by sneakernet (transport.compression) carry a ".xz" suffix
before their ".IV" sidecar's name; this script detects that suffix and
decompresses after decrypting, same as sneakernet's own receive path.

=head1 NOTES

=over 4

=item * This does not touch GELI whole-disk encryption on the target server;
that is unlocked with the native C<geli attach> command and a key file, not
anything custom to sneakernet.

=item * C<--receive> does not auto-create parent datasets or destroy an
existing target dataset that already has snapshots (unlike sneakernet's own
C<target.allowFullOverwrite> path) - a failed receive is reported and left for
the operator to resolve deliberately.

=back

=cut
