NAME

Crypt::MultiKey - Toolkit for encrypting data that can be unlocked by multiple key combinations

SYNOPSIS

use Crypt::MultiKey qw( pkey load_pkey coffer vault );
use Crypt::SecretBuffer qw( secret );

# Encrypt data with your public key
my $pubkey= load_pkey('~/.ssh/id_rsa.pub');
my $encrypted= $pubkey->encrypt("secret data");

# Load your private key
my $privkey= load_pkey('~/.ssh/id_rsa');

# If your private key is itself encrypted, prompt for password
if ($privkey->private_encrypted) {
  my $password= secret;
  $password->append_console_line(prompt => 'password: ')
    or die "require password";
  $privkey->decrypt_private($password);
}

# use private key to decrypt data
my $secret= $privkey->decrypt($encrypted);

# Store multiple data strings in a Coffer, locked with a variety of keys
my $coffer= coffer(content_dict => {
  secret1 => "REDACTED",
  secret2 => "REDACTED",
});
$coffer->add_access($privkey);      # now $privkey can unlock
my $key2= pkey(generate => 'x25519');
my $key3= pkey(generate => 'secp256k1');
$coffer->add_access($key2, $key3);  # now $key2+$key3 can unlock
$coffer->save('coffer.pem');

# Open a coffer
$coffer= coffer('coffer.pem');
$coffer->unlock($privkey);
# or $coffer->unlock($key2, $key3);

# Values stored in a coffer dictionary are SecretBuffers
$coffer->get('secret1')->unmask_to(sub { say $_[0] });

DESCRIPTION

This module collection is an implementation of a "key wrapping scheme" (such as done by age(1) or libsodium) packaged as an object resembling a password safe (Coffer) and also as an object representing encrypted block storage (Vault, similar to Linux LUKS) and comes with a handy PKey object that can load a variety of existing public/private key formats and use a variety of methods to protect them, such as ::PKey::FIDO2 for using hardware authenticators as passwords.

Since there are so many "secrets" and "keys" involved in this system, I'm using the following metaphor to help disambiguate them:

PKey

The PKey objects are wrappers around a public/private key system, currently implemented with OpenSSL's EVP_PKEY. A PKey can either be a full public/private key, a public key missing the private half, or a public key with an encrypted private half. Subclasses of Crypt::MultiKey::PKey implement different ways of recovering the private half of the key; for example the Crypt::MultiKey::PKey::SSHAgentSignature can use a signature from an SSH agent as a password to decrypt the private half of the PKey, and the Crypt::MultiKey::PKey::FIDO2 uses a challenge/response from a FIDO2 hardware authenticator (e.g. YubiKey) as a password to unlock the private half.

PKey objects can be loaded from OpenSSL public key PEM files, OpenSSL private key PEM files, OpenSSH public keys, and OpenSSH private keys, and in limited cases even encrypted OpenSSH private keys. Newly-generated keys are saved as OpenSSL PEM format with additional headers to hold the attributes of the object.

Coffer

A Coffer object is a container that implements a key-wrapping scheme where a master "file key" is encrypted with one or more "Locks" and each "Lock" can require one or more PKeys to unlock it. A symmetric AES key is derived from the file key and used to encrypt the data payload. Coffers are also stored in PEM format, with PEM headers that describe which keys can unlock the Coffer, and a MAC to guard against tampering.

The coffer file may also contain any number of PKey PEM blocks if you wish to keep all the coffer's keys (with private-half encrypted) bundled in the same file to ensure they don't get lost.

Because a Coffer is always locked using public/private key pairs, the coffer can be re-encrypted at any time without needing the private halves available.

Vault

A Vault object is a container just like a Coffer but designed for compatibility with Linux's dm-crypt implementation. The Vault can be unlocked and then an offset can be bound to a loopback device, and then initialize dm-crypt so that the rest of the file can be read/written directly by a Linux Device Mapper block device. In this case, Crypt::MultiKey is really just acting as a substitute for LUKS.

Motivation

The use case this module was designed to solve was to allow encrypted volumes on a server to be unlocked with more than one method:

  • A key server, using SSL certs for authentication

  • An SSH private key from the Agent of a logged-in user

  • A YubiKey

  • A very secure password stored offline

Every time the server boots, it needs the encrypted volumes unlocked. If it is online, it contacts the central key server and the key server is able to send a response that allows the activation of that key. If the key server is offline or unavailable, an admin with a SSH key can connect and forward their SSH agent to unlock the volumes. If an admin can't connect remotely or can't be reached, someone on-site with a physically secure YubiKey can retrieve and plug in the key and then udev rules automatically trigger to unlock the encrypted volumes. If all else fails, someone can go to the safe deposit box and get the sheet of paper where the secret is written and read it to someone over the phone. Further, any or all of these unlock methods can be added or removed without needing to have all the secrets present.

This module collection facilitates all of that.

FUNCTIONS

All functions can be exported, or called by the full package name. They cannot be called as class methods.

pkey

With an odd number of arguments, this is a shortcut for Crypt::MultiKey::PKey->load(@_). Otherwise, it is a shortcut for Crypt::MultiKey::PKey->new(@_).

new_pkey

Shortcut for Crypt::MultiKey::PKey->new(@_).

load_pkey

Shortcut for Crypt::MultiKey::PKey->load(@_).

coffer

With an odd number of arguments, this is a shortcut for Crypt::MultiKey::Coffer->load(@_). Otherwise, it is a shortcut for Crypt::MultiKey::Coffer->new(@_).

new_coffer

Shortcut for Crypt::MultiKey::Coffer->new(@_).

load_coffer

Shortcut for Crypt::MultiKey::Coffer->load(@_).

vault

With an odd number of arguments, this is a shortcut for Crypt::MultiKey::Vault->load(@_). Otherwise, it is a shortcut for Crypt::MultiKey::Vault->new(@_).

new_vault

Shortcut for Crypt::MultiKey::Vault->new(@_).

load_vault

Shortcut for Crypt::MultiKey::Vault->load(@_).

For example, load_vault('/path/to/existing.vault') loads an existing Vault, while new_vault(path => '/path/to/new.vault') constructs a new unsaved Vault.

hkdf

my %params;
$secret_buffer= hkdf(\%params, $secret_key_material);
# %params:
#   size           - number of bytes to generate
#   cipher         - substitute for 'size'; name of a cipher with known key length
#   kdf_info       - namespace for key derivation
#   kdf_salt       - salt bytes, will be generated if not provided

This runs OpenSSL's EVP_PKEY_HKDF with EVP_sha256, supplying kdf_info and kdf_salt and storing the output into a new SecretBuffer object. If kdf_salt was not provided in %params, it will receive a randomly generated value, which you then need to save. You can request "no salt" by setting kdf_salt to an empty string.

sha256

$secret_buffer= sha256(@strings);

Feed one or more strings (which may be SecretBuffers or Spans) into SHA256 and return the result as a Crypt::SecretBuffer. The buffer contains raw bytes, not hex or base64.

hmac_sha256

$secret_buffer= hmac_sha256($mac_key, @strings);

Feed a key and one or more strings (which may be SecretBuffers or Spans) into HMAC-SHA256 and return the result as a Crypt::SecretBuffer. The buffer contains raw bytes, not hex or base64.

symmetric_encrypt

my %params;
my $ciphertext= symmetric_encrypt(\%params, $aes_key, $secret);
# or
symmetric_encrypt(\%params, $aes_key, $secret, $ciphertext_out);
# %params:
#   cipher      - 'AES-256-GCM' or 'AES-256-XTS'; will be assigned if unset
#   pad         - optionally harden the secret with a prefix of random bytes
#                 and pad to specified length.
#   auth_data   - optional Additional Authenticated Data (AAD) for AES-GCM
#                 to include in the validation tag of the ciphertext.
#                 In other words, cause decryption to fail if it isn't given
#                 identical 'auth_data'.  The failure will be indistinguishable
#                 from an incorrect $aes_key.
#   sector_size - for XTS, specify the encryption size
#   sector_idx  - for XTS, specify the sector number of the first sector

This performs encryption using a cipher (AES-256-GCM or AES-256-XTS) and optional padding to obscure the length of the secret. The ciphertext is returned, or written into $ciphertext_out if supplied (which may be a byte scalar or Crypt::SecretBuffer). You must preserve (or reconstruct) %params in order to decrypt the ciphertext with symmetric_decrypt.

The $aes_key should be a SecretBuffer object and must be the correct length for the cipher. Use "hkdf" to get a key the correct length.

If you use auth_data, you should not serialize that alongside the other parameters, and instead reconstruct the auth_data before decryption.

For XTS encryption, the secret's length must be a multiple of the sector_size. Each sector gets encrypted individually, using the sector number as an initialization vector. Unless you are encrypting from sector 0, you need to specify sector_idx.

This function can encrypt in-place (passing the same SecretBuffer for $secret and ciphertext_out) so long as there is sufficient spare capacity in the buffer for the extra GCM suffix (30 bytes) and you don't enable 'pad'.

symmetric_decrypt

my %params= ...;         # previous encryption parameter hashref
my $ciphertext= ...;     # previous ciphertext bytes
my $secret= symmetric_decrypt(\%params, $aes_key, $ciphertext);
# or
symmetric_decrypt(\%params, $aes_key, $ciphertext, $secret_out);

This decrypts the previous result of symmetric_encrypt. If using AES-GCM, it will also verify whether the $aes_key is the correct key, and croak on failure.

lazy_load

$class= lazy_load($class);

Given a class name, perform 'require' on that class name if and only if it is in the permitted set of "lazy_loadable".

lazy_loadable

Convenient accessor for %Crypt::MultiKey::lazy_loadable. Returns a global hashref of the names of classes which are safe to load on demand; i.e. from tainted input requesting a class be used to process that input. This hashref is writeable so that other modules may add more names to the list.

SEE ALSO

age

"age is a simple, modern and secure file encryption tool, format, and Go library."

This tool can encrypt a secret using one or more public keys such that it can then be decrypted using any of the corresponding private keys.

libsodium

"Sodium is an easy-to-use software library that provides a wide range of cryptographic operations including encryption, decryption, digital signatures, and secure password hashing."

libsodium provides a "box" primitive that is a key-wrapping scheme similar to Coffer.

There are Perl bindings at Crypt::Sodium.

VERSION

version 0.000_001

AUTHOR

Michael Conrad <mike@nrdvana.net>

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.