package Crypt::MultiKey::Coffer;
our $VERSION= '0.001'; our $VERSION = '0.000_002'; # TRIAL VERSION
# ABSTRACT: Encrypted container that can be unlocked with various combinations of keys

use v5.12;
use warnings;
use Carp;
use version;
use Scalar::Util qw/ blessed looks_like_number /;
use MIME::Base64 qw/ encode_base64 decode_base64 /;
use Crypt::SecretBuffer qw/ secret span memcmp HEX BASE64 ISO8859_1 /;
use Crypt::SecretBuffer::PEM 0.020;
use Crypt::MultiKey;
use Crypt::MultiKey::LockMechanism;
use constant {
   DICT_CONTENT_TYPE => 'application/crypt-multikey-coffer-dict',
};
our @CARP_NOT= qw( Crypt::MultiKey );

sub _isa_pem_obj { blessed($_[0]) && $_[0]->isa('Crypt::SecretBuffer::PEM') }
sub _isa_secret { blessed($_[0]) && $_[0]->isa('Crypt::SecretBuffer') }
sub _isa_secret_span { blessed($_[0]) && $_[0]->isa('Crypt::SecretBuffer::Span') }
sub _coerce_secret {
   my $val= shift;
   return $val if _isa_secret($val) || _isa_secret_span($val);
   croak "Expected a Crypt::SecretBuffer, Crypt::SecretBuffer::Span, or something that can stringify to bytes"
      if ref $val && !blessed($val);
   return secret($val);
}


sub path { @_ > 1? shift->_set_path(@_) : $_[0]{path} }
sub _set_path { $_[0]{path}= $_[1]; $_[0] }

sub bundled_keys { @_ > 1? shift->_set_bundled_keys(@_) : $_[0]{bundled_keys} }
sub _set_bundled_keys {
   my ($self, $val)= @_;
   # coerce to either 1, 0, or 'public'
   $val= !$val? 0
       : $val eq 'public'? 'public'
       : 1;
   $_[0]{bundled_keys}= $val;
}


sub lock_mechanism { $_[0]{lock_mechanism} //= Crypt::MultiKey::LockMechanism->new }
sub _set_primary_skey { shift->lock_mechanism->_set_primary_skey(@_) }
sub locks { shift->lock_mechanism->locks(@_) }
sub _set_lock_mechanism {
   my ($self, $val)= @_;
   blessed($val) && $val->can('primary_skey')
      or croak "Expected instance of Crypt::MultiKey::LockMechanism";
   $_[0]{lock_mechanism}= $_[1]
}
sub add_access { shift->lock_mechanism->add_access(@_) }
sub insert_keys { shift->lock_mechanism->insert_keys(@_) }
sub unlock {
   my $self= shift;
   $self->lock_mechanism->unlock(@_);
   # If 'authentication' is set, it means we need to validate the MAC of the
   # PEM file headers, which couldn't be done until we know the primary_skey.
   $self->authenticate if defined $self->authentication;
   $self;
}

sub interactive_unlock {
   my ($self, %options)= @_;
   require Crypt::MultiKey::InteractiveUnlock;
   my $iu= Crypt::MultiKey::InteractiveUnlock->new(target => $self, %options);
   $iu->run;
}

sub locked {
   my $self= shift;
   $self->lock_mechanism->locked;
}


sub user_meta { @_ > 1? shift->_set_user_meta(@_) : ($_[0]{user_meta} ||= {}) }
sub _set_user_meta { $_[0]{user_meta}= $_[1]; $_[0] }

sub name { @_ > 1? shift->_set_name(@_) : $_[0]->user_meta->{name} }
sub _set_name { $_[0]->user_meta->{name}= $_[1]; $_[0] }


sub cipher_data { @_ > 1? shift->_set_cipher_data(@_) : $_[0]{cipher_data} }
sub _set_cipher_data { $_[0]{cipher_data}= $_[1]; $_[0] }

sub has_ciphertext { defined $_[0]{cipher_data} && defined $_[0]{cipher_data}{ciphertext} }


sub content_type { @_ > 1? shift->_set_content_type(@_) : $_[0]{content_type} }
sub _set_content_type { $_[0]{content_type}= $_[1]; $_[0] }
sub is_dict { (shift->content_type // '') eq DICT_CONTENT_TYPE }

sub content {
   my $self= shift;
   return $self->_set_content($_[0])
      if @_;
   $self->decrypt
      if !$self->has_content && $self->has_ciphertext;
   $self->_pack_content_dict
      if ref($self->{content}) eq 'ARRAY';
   $self->{content}
}

sub has_content { defined $_[0]{content} }
sub initialized { $_[0]->has_content || $_[0]->has_ciphertext }
sub content_changed { $_[0]{content_changed}= $_[1] if @_ > 1; !!$_[0]{content_changed} }

sub _set_content {
   my ($self, $val)= @_;
   my $dict;
   if (!defined $val) {
      $self->{content}= undef;
   } elsif (ref($val) eq 'ARRAY' || ref($val) eq 'HASH') {
      # Make copies of the user-supplied keys and values, for safety at the expense
      # of some performance.  Note that _unpack_content_dict does *not* make copies
      # since the user is unlikely to have access to the underlying buffer.
      # Maybe I should add a 'readonly' flag to SecretBuffer to enable some optimizations...
      if (ref($val) eq 'HASH') {
         $val= [ map secret($_), %$val ];
      } else {
         croak "Expected even-length arrayref containing alternating name/value"
            if @$val & 1;
         $val= [ map secret($_), @$val ];
      }
      $self->{content}= $val;
      $self->{content_type}= DICT_CONTENT_TYPE;
   } else {
      $self->{content}= _coerce_secret($val);
   }
   delete $self->{_dict_name_cache};
   $self->content_changed(1);
   $self;
}

sub _unpack_content_dict {
   my $self= shift;
   my $span= span($self->content);
   # parse_lenprefixed(-1) attempts to consume all bytes of the span as a list of variable-int
   # length followed by that many bytes.  It fails and returns an empty list if the final
   # lenprefixed string does not end exactly at the end of the span.
   my @kv= $span->parse_lenprefixed(-1);
   croak "Failed to parse Key/Value pairs: ".$span->last_error
      if !@kv && $span->len; # empty content is not an error
   croak "Failed to parse Key/Value pairs: odd number of strings"
      if @kv & 1;
   $self->{content}= \@kv; # these spans are still referencing the original ->{content} buffer.
   delete $self->{_dict_name_cache};
}

sub _pack_content_dict {
   my $self= shift;
   my $buf= secret;
   $buf->append_lenprefixed(@{ $self->{content} });
   $self->{content}= $buf;
}

sub _content_dict {
   my $self= shift;
   croak "content_type is not ".DICT_CONTENT_TYPE
      unless $self->is_dict;
   $self->decrypt
      if !$self->has_content && $self->has_ciphertext;
   $self->_unpack_content_dict
      if ref($self->{content}) ne 'ARRAY' && defined $self->{content};
   $self->{content} //= [];
   $self->{content}= []
      unless ref($self->{content}) eq 'ARRAY';
   $self->{content};
}


sub authentication { @_ > 1? shift->_set_authentication(@_) : $_[0]{authentication} }
sub _set_authentication {
   my ($self, $val)= @_;
   ref $val eq 'ARRAY' && @$val == 3
      or croak 'Expected arrayref of [$canonical_message_bytes, $algorithm, $mac]';
   $self->{authentication}= $val;
   $self;
}


# Make sure attributes (or methods) are applied in the following order:
our %_attr_pri= (
   user_meta => -2,
   content_type => -1,
   # everything else defaults to 0
);

sub new {
   my $class= shift;
   my %attrs= @_ == 1? %{$_[0]} : @_;
   my $self= bless {}, $class;
   # Hook for subclasses to process attributes
   $self->_init(\%attrs) if $self->can('_init');
   # Every remaining attribute must have a writable accessor
   for (sort { ($_attr_pri{$a}||0) <=> ($_attr_pri{$b}||0) } keys %attrs) {
      croak "Unknown or internal attribute '$_'"
         if substr($_,0,1) eq '_';
      my $setter= "_set_$_";
      $self->$setter($attrs{$_});
   }
   return $self;
}

sub load {
   my ($class_or_self, $path_or_data_or_pem, %options)= @_;
   # If called as a method on an object, default to loading the ->path attribute
   $path_or_data_or_pem //= $class_or_self->path
      if ref $class_or_self;
   
   # There should be exactly one COFFER pem block and any number of ENCRYPTED PRIVATE KEY or
   # PUBLIC KEY blocks.
   my ($coffer_pem, @pkey_pem);
   for (Crypt::MultiKey::_extract_pems_from_something($path_or_data_or_pem, \%options)) {
      if ($_->label eq 'CRYPT MULTIKEY COFFER') {
         croak "More than one CRYPT MULTIKEY COFFER pem block in file"
            if defined $coffer_pem;
         $coffer_pem= $_;
      }
      elsif ($options{bundled_keys} and (
         $_->label eq 'ENCRYPTED PRIVATE KEY'
         || $_->label eq 'PUBLIC KEY'
      )) {
         push @pkey_pem, $_;
      }
      else {
         carp "Ignoring PEM block ".$_->label;
      }
   }
   croak "No CRYPT MULTIKEY COFFER pem block in file"
      unless defined $coffer_pem;
   my $attrs= $class_or_self->_attrs_from_pem($coffer_pem, $options{path});
   $attrs->{path}= $options{path};
   $attrs->{bundled_keys}= $options{bundled_keys};
   my $self;
   if (ref $class_or_self) {
      # Called on existing object. Replace attributes.
      $self= $class_or_self;
      %$self= ();
      # Hook for subclasses to process attributes
      $self->_init($attrs) if $self->can('_init');
      # Every remaining attribute must have a writable accessor
      $self->$_($attrs->{$_})
         for sort { ($_attr_pri{$a}||0) <=> ($_attr_pri{$b}||0) } keys %$attrs;
   } else {
      # If called as a class method, return a new object
      $self= $class_or_self->new($attrs);
   }
   my @pkeys;
   for my $pk_pem (@pkey_pem) {
      # If a key can't be loaded, it shouldn't be a fatal error, because the coffer could
      # still be unlockable by other keys, or copies of the keys saved elsewhere.
      # But, it's a big enough problem to warrant writing stderr.
      eval { push @pkeys, Crypt::MultiKey::PKey->load($pk_pem) }
         or carp "Warning: failed to load PKey found alongside Coffer: $@";
   }
   $self->insert_keys(@pkeys) if @pkeys;
   return $self;
}


sub get {
   my ($self, $key)= @_;
   my (undef, $idx)= $self->_dict_find_name_idx($key);
   return undef unless defined $idx;
   $self->_content_dict->[$idx+1];
}


sub set {
   my ($self, $key, $val)= @_;
   if (!defined $self->content_type && !$self->has_ciphertext && !$self->has_content) {
      # initialize KV storage, which sets content_type.
      $self->content([]);
   }
   my $dict= $self->_content_dict;
   my (undef, $idx)= $self->_dict_find_name_idx($key);
   if (defined $val) {
      # make copies of key and/or val so that the caller doesn't have to worry about
      # keeping them constant.
      if (defined $idx) {
         $dict->[$idx+1]= secret($val);
      } else {
         push @$dict, secret($key), secret($val);
         delete $self->{_dict_name_cache};
      }
   } elsif (defined $idx) {
      splice(@$dict, $idx, 2);
      delete $self->{_dict_name_cache};
   }
   $self->content_changed(1);
   $self;
}

sub _dict_name_cache {
   my $self= shift;
   my $dict= $self->_content_dict;
   my $cache= $self->{_dict_name_cache};
   return $cache if $cache;
   $cache= [];
   for (my $i= 0; $i < @$dict; $i += 2) {
      push @$cache, [ $dict->[$i], $i ];
   }
   @$cache= sort { memcmp($a->[0], $b->[0]) } @$cache;
   $self->{_dict_name_cache}= $cache;
}

# Returns nearest index within cache, and index of matching element within @_content_dict
sub _dict_find_name_idx {
   my ($self, $name)= @_;
   my $cache= $self->_dict_name_cache;
   $name= _coerce_secret($name);
   my ($lo, $hi)= (0, scalar @$cache);
   while ($lo < $hi) {
      my $mid= int(($lo + $hi) / 2);
      if (memcmp($cache->[$mid][0], $name) < 0) {
         $lo= $mid + 1;
      } else {
         $hi= $mid;
      }
   }
   return ($lo, undef) if $lo >= @$cache || memcmp($cache->[$lo][0], $name) != 0;
   ($lo, $cache->[$lo][1]);
}


sub list_names {
   my ($self, $prefix)= @_;
   my $dict= $self->_content_dict;
   my $cache= $self->_dict_name_cache;
   return () unless @$cache;
   my @ret;
   my $prefix_len= defined $prefix && ($prefix= _coerce_secret($prefix))->len;
   if ($prefix_len) {
      my ($i)= $self->_dict_find_name_idx($prefix);
      for (; $i < @$cache; $i++) {
         my $name_prefix= span($cache->[$i][0], 0, $prefix_len);
         last if memcmp($name_prefix, $prefix) != 0;
         push @ret, span($cache->[$i][0]);
      }
   }
   else {
      @ret= map span($_->[0]), @$cache;
   }
   @ret;
}


sub list_names_plaintext {
   my ($self, $prefix)= @_;
   return map { my $plain; $_->copy_to($plain); $plain } $self->list_names($prefix);
}


sub export {
   my ($self)= @_;
   # Make sure there is a way to unlock it!
   croak "Can't save Coffer when no locks are defined!  (you would lose your data)"
      unless @{ $self->locks };
   # No need to encrypt if the ciphertext exists and the content is not changed
   $self->encrypt if $self->content_changed || !$self->has_ciphertext;
   # Build PEM object and serialize to buffer
   my $buf= $self->_export_pem->serialize;
   if ($self->bundled_keys) {
      my $method= $self->bundled_keys eq 'public'? 'export_pem_openssl_public_key' : 'export_pem';
      my %keys;
      my $n_missing= 0;
      for (@{ $self->locks }) {
         for (@{ $_->{tumblers} }) {
            if (defined $_->{key}) {
               $keys{$_->{key}->fingerprint}= $_->{key};
            } else {
               ++$n_missing;
            }
         }
      }
      carp "Exporting 'bundled_keys' but $n_missing tumblers lack a PKey object"
         if $n_missing;
      for my $fp (sort keys %keys) { # export in a stable order
         my $k= $keys{$fp};
         my $pem= !defined($k->protection_scheme)
            ? $k->export_pem_openssl_public_key
            : $k->$method;
         $buf->append("\n", $pem->serialize); # ensure -----BEGIN is the start of a text line
      }
   }
   return $buf;
}

sub save {
   my ($self, $path)= @_;
   $path //= $self->path // croak "No path set";
   $self->export->save_file($path, "rename");
   $self->path($path) unless defined $self->path;
   $self;
}


sub authenticate {
   my ($self, $croak)= @_;
   my $auth= $self->authentication;
   unless (defined $auth) {
      croak 'No authentication attribute available' if $croak;
      return 0;
   }
   unless (defined $self->lock_mechanism->primary_skey) {
      croak 'No primary_skey available for authentication' if $croak;
      return 0;
   }
   unless ($auth->[1] eq 'HMAC-SHA256') {
      croak 'Only HMAC-SHA256 is supported' if $croak;
      return 0;
   }
   my $mac= Crypt::MultiKey::hmac_sha256($self->lock_mechanism->hmac_skey, $auth->[0]);
   unless ($mac->memcmp($auth->[2]) == 0) {
      croak 'Header MAC failed; PEM headers have been modified since Coffer file was written.'
         if $croak;
      return 0;
   }
   return 1;
}


sub lock {
   my $self= shift;
   # No need to encrypt if the ciphertext exists and the content is not changed
   $self->encrypt if $self->content_changed || !$self->has_ciphertext;
   # deletes primary_skey
   $self->lock_mechanism->lock;
   # Delete all secrets
   delete @{$self}{qw( content _dict_name_cache content_changed )};
   $self;
}


sub encrypt {
   my $self= shift;
   # preconditions: the content must be defined
   croak "Content is not ".($self->has_ciphertext? 'decrypted' : 'defined')
      unless $self->has_content;
   # If possible, use a fresh AES key.  We can only replace it when all locks have
   # public keys inserted.
   my ($complete, $incomplete)= $self->lock_mechanism->insert_keys;
   unless (@$incomplete) {
      $self->lock_mechanism->generate_primary_skey;
      # Throw away any old ciphertext
      delete $self->{cipher_data}{ciphertext} if $self->{cipher_data};
      # Any authentication field is invalid now.
      delete $self->{authentication};
   }
   # Preserve some encryption parameters from the previous encryption.
   my %cipher_data;
   my $prev_cipher= $self->{cipher_data};
   @cipher_data{'cipher','pad'}= @{$prev_cipher}{'cipher','pad'}
      if defined $prev_cipher;
   # Main encryption routine
   $cipher_data{ciphertext}= Crypt::MultiKey::symmetric_encrypt(
      \%cipher_data, $self->lock_mechanism->cipher_skey, $self->content
   );
   $self->{cipher_data}= \%cipher_data;
   $self->content_changed(0);
   $self;
}


sub decrypt {
   my $self= shift;
   # preconditions: the cipher_data must have ciphertext and aes_key must be loaded
   croak "Coffer is locked"
      if $self->locked;
   croak "No ciphertext defined"
      unless $self->has_ciphertext;
   $self->{content}= Crypt::MultiKey::symmetric_decrypt(
      $self->cipher_data, $self->lock_mechanism->cipher_skey, $self->cipher_data->{ciphertext}
   );
   $self;
}

our %_importable_attributes= map +($_ => 1),
   qw( lock_mechanism user_meta cipher_data content_type );
# Extract Coffer attributes from a Crypt::SecretBuffer::PEM object
sub _attrs_from_pem {
   my ($self, $pem, $path)= @_;
   my $header_kv= $pem->header_kv;

   # The first two headers must be version and writer_version.
   croak "Headers must start with version and writer_version"
      unless @$header_kv > 4 && $header_kv->[0] eq 'version' && $header_kv->[2] eq 'writer_version';
   my $min_version= version->parse($header_kv->[1]);
   carp "'$path' requires version $min_version of Crypt::MultiKey::Coffer"
      ." but this is only version ".__PACKAGE__->VERSION
      if $min_version > __PACKAGE__->VERSION;
   # Even if version problems, try to proceed

   # The final header must be "pem_header_authentication".  We use that to ensure that nothing
   # has been altered since it was written.  All the headers before that get combined into the
   # canonical text that we compare to the MAC.
   croak "Headers must end with pem_header_authentication"
      unless @$header_kv > 2 && $header_kv->[-2] eq 'pem_header_authentication';
   # The PEM parser removes any leading or trailing whitespace on attributes and values, but we
   # also ensured that no leading or trailing whitespace existed when we wrote the file.
   # Reconstructing the text negates any whitespace damage that may have occurred to the file.
   my $header_text= '';
   for (0..$#$header_kv-2) {
      $header_text .= $header_kv->[$_] . ($_ & 1? "\n" : ": ");
   }
   my ($mac_algo,$mac_base64)= split ':', $header_kv->[-1], 2;
   # ...but the MAC can't be validated until we decrypt the coffer.

   my $attrs= Crypt::MultiKey::_inflate_pem_header_kv(@{$header_kv}[4..($#$header_kv-2)]);
   $attrs->{lock_mechanism}= Crypt::MultiKey::LockMechanism->new->_import_attrs($attrs);
   # PEM object provides a Span with encoding=BASE64.  Need to decode that to bytes.
   my $ciphertext= $pem->content;
   if (_isa_secret_span($ciphertext) && $ciphertext->encoding == BASE64) {
      my $buf= secret;
      $ciphertext->copy_to($buf, encoding => ISO8859_1);
      $ciphertext= $buf;
   }
   $attrs->{cipher_data}{ciphertext}= $ciphertext;

   # validate all attributes to make sure constructor doesn't call methods
   if (my @unauth= grep !$_importable_attributes{$_}, keys %$attrs) {
      carp "The following PEM headers cannot be imported: ".join(', ', sort @unauth);
      delete @$attrs{@unauth};
   }

   $attrs->{authentication}= [ $header_text, $mac_algo, decode_base64($mac_base64) ];
   return $attrs;
}

sub _export_pem {
   my $self= shift;
   my %cipher_data_export= %{ $self->cipher_data };
   my $ciphertext= delete $cipher_data_export{ciphertext};
   my @header_kv= Crypt::MultiKey::_flatten_to_pem_header_kv(
      version        => '0.000',
      writer_version => __PACKAGE__->VERSION,
      user_meta      => $self->user_meta,
      %{ $self->lock_mechanism->_export_attrs },
      (content_type   => $self->content_type)x!!(defined $self->content_type),
      cipher_data    => \%cipher_data_export,
   );
   # Build the canonical PEM header text so we can validate it with a MAC.
   my $header_text= '';
   for (0..$#header_kv) {
      $header_text .= $header_kv[$_] . ($_ & 1? "\n" : ": ");
   }
   my $header_mac= 'HMAC-SHA256:';
   Crypt::MultiKey::hmac_sha256($self->lock_mechanism->hmac_skey, $header_text)
      ->span->append_to($header_mac, encoding => BASE64);
   push @header_kv, 'pem_header_authentication' => $header_mac;
   return Crypt::SecretBuffer::PEM->new(
      label     => 'CRYPT MULTIKEY COFFER',
      header_kv => \@header_kv,
      content   => $ciphertext,
   );
}

# Avoid depending on namespace::clean
delete @Crypt::MultiKey::Coffer::{qw(
   carp croak confess
   blessed looks_like_number
   encode_base64 decode_base64
   secret span HEX BASE64 ISO8859_1
)};

__END__

=pod

=encoding UTF-8

=head1 NAME

Crypt::MultiKey::Coffer - Encrypted container that can be unlocked with various combinations of keys

=head1 SYNOPSIS

  # Coffer is locked/unlocked using public/private keys
  my ($key1, $key2, $key3)= map Crypt::MultiKey::PKey->generate('x25519'), 1..3;
  
  # initial state of coffer is not locked, and unsaved
  my $coffer= Crypt::MultiKey::Coffer->new(
    path => './mydata.coffer',
    content => $secret_buffer,
  );
  $coffer->add_access($key1);        # now coffer can be unlocked by key1
  $coffer->add_access($key2, $key3); # now coffer can be unlocked by key2+key3
  $coffer->save;                     # write encrypted PEM to file 'mydata.coffer'
  $coffer->lock;                     # now coffer cannot be read until unlocked
  $coffer->unlock($key2,$key3);      # content decrypted from ciphertext
  $secret= $coffer->content;         # access secret data
  
  # Coffer can be used in key/value mode.
  # (multiple named secrets get concatenated and encrypted as one secret)
  my $coffer= Crypt::MultiKey::Coffer->new(
    path => './mydata.coffer',
  );
  $coffer->set("secret1", $secret1);
  $coffer->set("secret2", $secret2);
  $coffer->add_access($key2);
  $coffer->save;
  $coffer->lock;
  $coffer->unlock($key2);
  $secret2= $coffer->get("secret2");

=head1 DESCRIPTION

=head1 CONSTRUCTORS

=head2 new

  $coffer= Crypt::MultiKey::Coffer->new(%attributes);

Construct a new Coffer.  The attributes are applied to the object as method calls.

=head2 load

  # as a constructor
  $coffer= Crypt::MultiKey::Coffer->load($source, %options);
  # as a method
  $coffer->load($source, %options);
  # $source may be:
  #   $file_path
  #   \$buffer
  #   Crypt::SecretBuffer
  #   Crypt::SecretBuffer::Span
  #   Crypt::SecretBuffer::PEM
  # options:
  #   path         => $file_path   # value for 'path' attribute when using a buffer
  #   bundled_keys => $bool        # whether to process PKey PEM blocks found in buffer

Load a Coffer from a file or buffer or L<PEM object|Crypt::SecretBuffer::PEM>.  This does not
decrypt the data.  See L</unlock>.

When loading from a file or buffer, the PEM encoding of the Coffer may be followed by PEM
encodings of the PKey objects.  If you request C<bundled_keys>, they will be inflated to
L<PKey objects|Crypt::MultiKey::PKey> and passed to L</insert_keys>.  This will also
initialize the L</bundled_keys> attribute of the created object.

Neither 'path' nor 'bundled_keys' attributes are serialized in the Coffer PEM, for security
reasons.  They must be specified / requested by the caller.

=head1 ATTRIBUTES

=head2 path

Filesystem path from which to load and save the Coffer.

=head2 bundled_keys

  $coffer->bundled_keys(1);        # $coffer->save will also export referenced PKeys
  $coffer->bundled_keys('public'); # coffer PEM will have OpenSSL 'PUBLIC KEY' PEM appended

The L</locks> attribute references the keys that can unlock it by the key fingerprint.  The keys
can be saved separately to be loaded by the application and added with L</insert_keys>, or they
can be appended to the Coffer to be loaded automatically when loading the Coffer, to keep
everything together in one place.  When this option is set to a true value, the L</export>
method will write out the Coffer PEM block followed by the PEM serializations of each of the
L<PKey objects|Crypt::MultiKey::PKey/export>.  (They serialize as either public-only or
encrypted-private PEM blocks with PKey metadata included.  Obviously it would defeat the purpose
of the Coffer to serialize unencrypted private keys to the same file)

You can set this option to C<'public'> to write only the public key in the standard OpenSSL
format without any of the PKey metadata.  Having the full public key present allows a new Coffer
to be written that is decryptable by all the same PKeys as the current one, while not giving any
advantage to an attacker by showing them the PKey metadata.

=head2 lock_mechanism

This object handles the details of locking and unlocking, and lets Coffer and Vault share code.
The implementation could be configurable in the future, but currently only the default of
L<Crypt::MultiKey::LockMechanism> is supported.

Several methods are directly delegated to this object:

=over

=item L<locks|Crypt::MultiKey::LockMechanism/locks>

=item L<add_access|Crypt::MultiKey::LockMechanism/add_access>

=item L<insert_keys|Crypt::MultiKey::LockMechanism/insert_keys>

=item L<unlock|Crypt::MultiKey::LockMechanism/unlock>

=back

=head2 locked

True if the Coffer has been initialized with locks but the primary secret key is not currently
available.  A new, uninitialized Coffer is not considered locked.

=head2 user_meta

An arbitrary hashref of name/value strings that will be added to the exported PEM as headers
of the form C<< user_meta.$name = $value >>.  Note that headers are B<plaintext>.
If you wish to store secret user metadata it needs to be part of L</content>, which can be
accomplished conveniently using L</get> and L</set>.

Because PEM has no escaping system, the names and values may not contain control characters or
begin or end with space characters.  The names also may not contain '.' or be purely numeric,
because these are used for encoding the structure of the data.

Warning: the authenticity of C<user_meta> does not get checked until you have
L<unlocked|/unlock> the coffer.  Never trust C<user_meta> on a locked Coffer unless the file
was stored securely.

=head2 name

A shortcut for C<< ->user_meta->{name} >>.  This helps encourage you to at least provide a label
for the file indicating its purpose or contents.  This defaults to the basename of the L</path>.

=head2 cipher_data

If the coffer has been encrypted/locked, this attribute holds a hashref including the ciphertext
and other parameters describing how it was encrypted.  In a newly-initialized Coffer this will
be C<undef>.

=over

=item has_ciphertext

True if the cipher_data is defined and contains a ciphertext string

=back

=head2 content_type

Specify the MIME type of the L</content> attribute.  The special value
C<< application/crypt-multikey-coffer-dict >> enables the L</get> and L</set> methods to use the
C<content> as a key/value dictionary.  This is stored unencrypted in the headers of the Coffer,
so you may wish to omit it for non-dictionary types if the content type would leak information.

=over

=item is_dict

Accessor to test whether the content_type indicates a dictionary encoding.
Currently only C<< application/crypt-multikey-coffer-dict >> is supported.

=back

=head2 content

This attribute is an unencrypted L<Crypt::SecretBuffer> of the secret data of the Coffer.
If it isn't initialized and an encrypted copy exists (L</has_ciphertext> is true), reading this
attribute will attempt to decrypt it, and fail if the Coffer is still locked.  If there is no
encrypted copy (such as when a Coffer object is first created) reading this attribute just
returns C<undef>.

Writing this attribute will invalidate the C<cipher_data> attribute, forcing it to be
re-encrypted when you call L</save>.  Beware that if you make changes to the SecretBuffer object
directly, the Coffer object will not be aware of those changes and the changes may be lost if
the Coffer doesn't know they need re-encrypted.  Set C<< $coffer->content_changed(1) >> if you
need to flag the content as having changed.

If you are using the Coffer for name/value dictionary storage, use the L</get> and L</set>
methods instead of accessing this attribute.  In dictionary mode, accessing this attribute will
trigger a serialization of the data.

=over

=item has_content

True if the C<content> attribute or hidden dictionary storage are defined, meaning that either
the Coffer is decrypted or has been initialized to a new value.  Maybe unintuitively, it returns
false for a not-locked coffer where the content hasn't been lazy-decrypted yet.

=item initialized

True if C<has_content> or C<has_ciphertext>, meaning that content has been added to this Coffer.

=back

=head2 content_changed

True if you have used accessors to alter your C<content> or hidden dictionary storage.  If you
modify the content SecretBuffer yourself, you should set this attribute to true so that the
Coffer knows it needs to re-encrypt the content.

=head2 authentication

When loaded from an external source (currently just PEM files), this attribute gets initialized
to an arrayref of the canonical message (PEM headers) and the HMAC-SHA256 of that text.
This will be verified during L</unlock> to ensure that the headers were not altered, throwing
an exception if they don't match.  Beware that until unlocked, you have no guarantee that the
headers weren't altered by an attacker.  For an example attack, consider what happens if you
load a Coffer file, assign new content without unlocking the old content, and then re-encrypt
using the same public keys from the previous locks.  An attacker could inject a bogus lock
using a key they control, and then your re-encrypted Coffer file would be readable by them!
Always L</unlock> a Coffer before trusting any attribute of the object.

This attribute will be C<undef> if the Coffer was not loaded from an external source.
The check during L</unlock> is only performed if this attribute is defined.

=head1 METHODS

=head2 interactive_unlock

  $bool= $coffer->interactive_unlock(%options);

Shortcut for

  my $iu= Crypt::MultiKey::InteractiveUnlock->new(target => $coffer, %options);
  $iu->run;

=head2 get

  $secret= $coffer->get($name);

When using dictionary mode (based on L</content_type> / L</is_dict> flag), this method can be
used to retrieve a secret by name.  It dies if the content_type is not a supported dictionary
type.  In Coffer's dictionary mode, a name that exists in the collection always has a defined
value, so checking defined-ness of the return value is equivalent to checking 'exists' on a
perl hashref.

If the content is not yet decrypted, it will try decrypting it and fail if the Coffer is
L</locked>.

B<Do not modify> the buffer referenced by the returned C<$secret> Span object.

=head2 set

  $coffer->set($name, $secret);

When using dictionary mode (based on L</content_type> / L</is_dict> flag), this method can be
used to store a secret by name.  Using this method when no content or ciphertext are defined
will initialize the content_type to C<< application/crypt-multikey-coffer-dict >> and the hidden
dictionary storage.  If content exists but is not yet decrypted, this will try decrypting it and
fail if the Coffer is L</locked>.

If C<$secret> is not defined, it deletes C<$name> from the dictionary.  There is no way to
store a state of "exists but undefined".

=head2 list_names

  @names= $coffer->list_names;
  @names= $coffer->list_names($prefix);

When using dictionary mode (based on L</content_type> / L</is_dict> flag), this method returns a
list of the available names.  You can optionally filter them by a prefix.  The names are
returned as L<Span objects|Crypt::SecretBuffer::Span>.  C<Do not modify> the buffer underlying
the name Spans.  See also L</list_names_plaintext>.

=head2 list_names_plaintext

Same as L</list_names>, but returns plain strings instead of Span objects.

=head2 export

  $buf= $coffer->export;

Serialize the Coffer to a buffer, in PEM format.

=head2 save

  $coffer->save;           # saves to ->path
  $coffer->save($path);    # save to specific path, and initialize path attribute

Save changes to disk.  If you specify the C<$path> and the path attribute is not already set,
this initializes it.  This writes a new file and then renames it into place to ensure it doesn't
corrupt the existing file.

=head2 authenticate

  $bool= $coffer->authenticate;
  $coffer->authenticate(1); # automatic croak

Validate the L</authentication> attribute, returning boolean.  This can only be called after
L<unlocking|/unlock> the Coffer.  Pass a true value to have it croak on failure instead of
returning false.

=head2 lock

Delete the L</primary_skey> attribute from the L</lock_mechanism>, and any attributes holding
unencrypted secrets.

=head2 encrypt

  $coffer->encrypt;

Regenerate the L</cipher_data> attribute from L</content> (or hidden dictionary storage).
The C<content> attribute or hidden dictionary storage must be initialized.
This will use a fresh AES key if all lock tumblers have public keys present.

This is called automatically during L</save> if the Coffer is aware that the L</cipher_data>
is not current.

=head2 decrypt

  $coffer->decrypt;

Regenerate the C<content> attribute from the C<cipher_data> attribute.  Returns C<$coffer> for
chaining.  The L</cipher_data> attribute must be initialized and the correct primary secret key
must be loaded in the L</lock_mechanism>.

This is called automatically when accessing an uninitialized C<content> in either scalar or
dictionary mode if the
Coffer is not locked.

=head1 FILE FORMAT

A Coffer is encoded in PEM format, with headers that describe the contents of the coffer and
which keys can unlock it.

  -----BEGIN CRYPT MULTIKEY COFFER-----
  version: 0.001
  writer_version: 0.001
  user_meta.name: Example
  locks.0.cipher: AES-256-GCM
  locks.0.ciphertext: base64==
  locks.0.tumblers.0.ephemeral_pubkey: base64==
  locks.0.tumblers.0.key_fingerprint: SHA256:base64==
  locks.1.cipher: AES-256-GCM
  locks.1.ciphertext: base64==
  locks.1.tumblers.0.ephemeral_pubkey: base64==
  locks.1.tumblers.0.key_fingerprint: SHA256:base64==
  locks.1.tumblers.1.ephemeral_pubkey: base64==
  locks.1.tumblers.1.key_fingerprint: SHA256:base64==
  content_type: text/plain
  cipher_data.cipher: AES-256-GCM
  pem_header_authentication: HMAC-SHA256:base64==
  
  base64==
  -----END CRYPT MULTIKEY COFFER-----

The content is either binary data of your choice, or a key/value format written by this module
which is just a series of length-delimited strings.  The content is encrypted with AES-256 and
written as base64 as the body of the PEM file.  The AES-GCM key that encrypted the content is
encrypted in one or more "lock" entries and the AES encryption key for each access is
derived from the combined key material from the "tumblers".  A "tumbler" is a set of parameters
that can be combined with the private half of a public/private key to generate AES-key material.

=head1 VERSION

version 0.000_002

=head1 AUTHOR

Michael Conrad <mike@nrdvana.net>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Michael Conrad.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
