package Spreadsheet::ParseXLSX::Decryptor;

use strict;
use warnings;

our $VERSION = '0.32'; # VERSION

# ABSTRACT: helper class to open password protected files

use Crypt::Mode::CBC;
use Crypt::Mode::ECB;
use Digest::SHA ();
use Encode ();
use File::Temp ();
use MIME::Base64 ();
use OLE::Storage_Lite;

use Spreadsheet::ParseXLSX::Decryptor::Standard;
use Spreadsheet::ParseXLSX::Decryptor::Agile;

sub open {
  my $class = shift;

  my ($filename, $password) = @_;

  $password = $password || 'VelvetSweatshop';

  my ($infoFH, $packageFH) = $class->_getCompoundData($filename, ['EncryptionInfo', 'EncryptedPackage']);

  return unless $infoFH;

  my $buffer;
  $infoFH->read($buffer, 8);
  my ($majorVers, $minorVers) = unpack('s<s<', $buffer);

  my $xlsx;
  if ($majorVers == 4 && $minorVers == 4) {
    $xlsx = $class->_agileDecryption($infoFH, $packageFH, $password);
  } else {
    $xlsx = $class->_standardDecryption($infoFH, $packageFH, $password);
  }

  return $xlsx;
}

sub _getCompoundData {
  my $class = shift;
  my ($filename, $names) = @_;

  my @files;

  my $storage = OLE::Storage_Lite->new($filename);

  foreach my $name (@{$names}) {
    my @data = $storage->getPpsSearch([OLE::Storage_Lite::Asc2Ucs($name)], 1, 1);
    if ($#data < 0) {
      push @files, undef;
    } else {
      my $fh = File::Temp->new;
      binmode($fh);
      $fh->write($data[0]->{Data});
      $fh->seek(0, 0);
      push @files, $fh;
    }
  }

  return @files;
}

sub _standardDecryption {
  my $class = shift;
  my ($infoFH, $packageFH, $password) = @_;

  my $buffer;
  my $n = $infoFH->read($buffer, 24);

  my ($encryptionHeaderSize, undef, undef, $algID, $algIDHash, $keyBits) = unpack('L<*', $buffer);

  $infoFH->seek($encryptionHeaderSize - 0x14, IO::File::SEEK_CUR);

  $infoFH->read($buffer, 4);

  my $saltSize = unpack('L<', $buffer);

  my ($salt, $encryptedVerifier, $verifierHashSize, $encryptedVerifierHash);

  $infoFH->read($salt, 16);
  $infoFH->read($encryptedVerifier, 16);

  $infoFH->read($buffer, 4);
  $verifierHashSize = unpack('L<', $buffer);

  $infoFH->read($encryptedVerifierHash, 32);
  $infoFH->close();

  my ($cipherAlgorithm, $hashAlgorithm);

  if ($algID == 0x0000660E || $algID == 0x0000660F || $algID == 0x0000660E) {
    $cipherAlgorithm = 'AES';
  } else {
    die sprintf('Unsupported encryption algorithm: 0x%.8x', $algID);
  }

  if ($algIDHash == 0x00008004) {
    $hashAlgorithm = 'SHA-1';
  } else {
    die sprintf('Unsupported hash algorithm: 0x%.8x', $algIDHash);
  }

  my $decryptor = Spreadsheet::ParseXLSX::Decryptor::Standard->new({
      cipherAlgorithm => $cipherAlgorithm,
      cipherChaining => 'ECB',
      hashAlgorithm => $hashAlgorithm,
      salt => $salt,
      password => $password,
      keyBits => $keyBits,
      spinCount => 50000
    }
  );

  $decryptor->verifyPassword($encryptedVerifier, $encryptedVerifierHash);

  my $fh = File::Temp->new;
  binmode($fh);

  my $inbuf;
  $packageFH->read($inbuf, 8);
  my $fileSize = unpack('L<', $inbuf);

  $decryptor->decryptFile($packageFH, $fh, 1024, $fileSize);

  $fh->seek(0, 0);

  return $fh;
}

sub _agileDecryption {
  my $class = shift;
  my ($infoFH, $packageFH, $password) = @_;

  my $xml = XML::Twig->new;
  $xml->parse($infoFH);

  my ($info) = $xml->find_nodes('//encryption/keyEncryptors/keyEncryptor/p:encryptedKey');

  my $encryptedVerifierHashInput = MIME::Base64::decode($info->att('encryptedVerifierHashInput'));
  my $encryptedVerifierHashValue = MIME::Base64::decode($info->att('encryptedVerifierHashValue'));
  my $encryptedKeyValue = MIME::Base64::decode($info->att('encryptedKeyValue'));
  my $hashSize = 0 + $info->att('hashSize');

  my $keyDecryptor = Spreadsheet::ParseXLSX::Decryptor::Agile->new({
      cipherAlgorithm => $info->att('cipherAlgorithm'),
      cipherChaining => $info->att('cipherChaining'),
      hashAlgorithm => $info->att('hashAlgorithm'),
      salt => MIME::Base64::decode($info->att('saltValue')),
      password => $password,
      keyBits => 0 + $info->att('keyBits'),
      spinCount => 0 + $info->att('spinCount'),
      blockSize => 0 + $info->att('blockSize')
    }
  );

  $keyDecryptor->verifyPassword($encryptedVerifierHashInput, $encryptedVerifierHashValue, $hashSize);

  my $key = $keyDecryptor->decrypt($encryptedKeyValue, "\x14\x6e\x0b\xe7\xab\xac\xd0\xd6");

  ($info) = $xml->find_nodes('//encryption/keyData');

  my $fileDecryptor = Spreadsheet::ParseXLSX::Decryptor::Agile->new({
      cipherAlgorithm => $info->att('cipherAlgorithm'),
      cipherChaining => $info->att('cipherChaining'),
      hashAlgorithm => $info->att('hashAlgorithm'),
      salt => MIME::Base64::decode($info->att('saltValue')),
      password => $password,
      keyBits => 0 + $info->att('keyBits'),
      blockSize => 0 + $info->att('blockSize')
    }
  );

  my $fh = File::Temp->new;
  binmode($fh);

  my $inbuf;
  $packageFH->read($inbuf, 8);
  my $fileSize = unpack('L<', $inbuf);

  $fileDecryptor->decryptFile($packageFH, $fh, 4096, $key, $fileSize);

  $fh->seek(0, 0);

  return $fh;
}

sub new {
  my $class = shift;
  my ($args) = @_;

  my $self = {%$args};
  $self->{keyLength} = $self->{keyBits} / 8;

  if ($self->{hashAlgorithm} eq 'SHA512') {
    $self->{hashProc} = \&Digest::SHA::sha512;
  } elsif (($self->{hashAlgorithm} eq 'SHA-1') || ($self->{hashAlgorithm} eq 'SHA1')) {
    $self->{hashProc} = \&Digest::SHA::sha1;
  } elsif ($self->{hashAlgorithm} eq 'SHA256') {
    $self->{hashProc} = \&Digest::SHA::sha256;
  } else {
    die "Unsupported hash algorithm: $self->{hashAlgorithm}";
  }

  return bless $self, $class;
}


1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Spreadsheet::ParseXLSX::Decryptor - helper class to open password protected files

=head1 VERSION

version 0.32

=for Pod::Coverage   new
  open

=head1 AUTHOR

Jesse Luehrs <doy@tozt.net>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2024 by Jesse Luehrs.

This is free software, licensed under:

  The MIT (X11) License

=cut
