Crypt-RFC8188

 view release on metacpan or  search on metacpan

README.md  view on Meta::CPAN

| OS      |  Build status |
|:-------:|--------------:|
| Linux   | [![Build Status](https://travis-ci.org/mohawk2/Crypt-RFC8188.svg?branch=master)](https://travis-ci.org/mohawk2/Crypt-RFC8188) |

[![CPAN version](https://badge.fury.io/pl/Crypt-RFC8188.svg)](https://metacpan.org/pod/Crypt-RFC8188) [![Coverage Status](https://coveralls.io/repos/github/mohawk2/Crypt-RFC8188/badge.svg?branch=master)](https://coveralls.io/github/mohawk2/Crypt-RFC8...

# SYNOPSIS

    use Crypt::RFC8188 qw(ece_encrypt_aes128gcm ece_decrypt_aes128gcm);
    my $ciphertext = ece_encrypt_aes128gcm(
      $plaintext, $salt, $key, $private_key, $dh, $auth_secret, $keyid, $rs,
    );
    my $plaintext = ece_decrypt_aes128gcm(
      # no salt, keyid, rs as encoded in header
      $ciphertext, $key, $private_key, $dh, $auth_secret,
    );

# DESCRIPTION

This module implements RFC 8188, the HTTP Encrypted Content Encoding
standard. Among other things, this is used by Web Push (RFC 8291).

It implements only the `aes128gcm` (Advanced Encryption Standard
128-bit Galois/Counter Mode) encryption, not the previous draft standards
envisaged for Web Push. It implements neither `aesgcm` nor `aesgcm128`.

lib/Crypt/RFC8188.pm  view on Meta::CPAN

use Exporter qw(import);
use Crypt::PRNG qw(random_bytes);

our $VERSION = "0.04";
our @EXPORT_OK = qw(ece_encrypt_aes128gcm ece_decrypt_aes128gcm derive_key);

my $MAX_RECORD_SIZE = (2 ** 31) - 1;

# $dh will always be public key data - decode_base64url if necessary
sub derive_key {
  my ($mode, $salt, $key, $private_key, $dh, $auth_secret) = @_;
  die "Salt must be 16 octets\n" unless $salt and length $salt == 16;
  my ($context, $secret) = ("");
  if ($dh) {
    die "DH requires a private_key\n" unless $private_key;
    my $pubkey = Crypt::PK::ECC->new->import_key_raw($dh, 'P-256'); 
    my $encoded = $private_key->export_key_raw('public');
    my ($sender_pub_key, $receiver_pub_key) = ($mode eq "encrypt")
      ? ($encoded, $dh) : ($dh, $encoded);
    $context = "WebPush: info\x00" . $receiver_pub_key . $sender_pub_key;
    $secret = $private_key->shared_secret($pubkey);
  } else {
    $secret = $key;
  }
  die "Unable to determine the secret\n" unless $secret;
  my $keyinfo = "Content-Encoding: aes128gcm\x00";
  my $nonceinfo = "Content-Encoding: nonce\x00";
  # Only mix the authentication secret when using DH for aes128gcm
  $auth_secret = undef if !$dh;
  if ($auth_secret) {
    $secret = hkdf $secret, $auth_secret, 'SHA256', 32, $context;
  }
  (
    hkdf($secret, $salt, 'SHA256', 16, $keyinfo),
    hkdf($secret, $salt, 'SHA256', 12, $nonceinfo),
  );
}

sub ece_encrypt_aes128gcm {
  my (
    $content, $salt, $key, $private_key, $dh, $auth_secret, $keyid, $rs,
  ) = @_;
  $salt ||= random_bytes(16);
  $rs ||= 4096;
  die "Too much content\n" if $rs > $MAX_RECORD_SIZE;
  my ($key_, $nonce_) = derive_key(
    'encrypt', $salt, $key, $private_key, $dh, $auth_secret,
  );
  my $overhead = 17;
  die "Record size too small\n" if $rs <= $overhead;
  my $end = length $content;
  my $chunk_size = $rs - $overhead;
  my $result = "";
  my $counter = 0;
  my $nonce_bigint = Math::BigInt->from_bytes($nonce_);
  # the extra one on the loop ensures that we produce a padding only
  # record if the data length is an exact multiple of the chunk size
  for (my $i = 0; $i <= $end; $i += $chunk_size) {
    my $iv = ($nonce_bigint ^ $counter)->as_bytes;
    my ($data, $tag) = gcm_encrypt_authenticate 'AES', $key_, $iv, '',
      substr($content, $i, $chunk_size) .
        ((($i + $chunk_size) >= $end) ? "\x02" : "\x01")
      ;
    $result .= $data . $tag;
    $counter++;
  }
  if (!$keyid and $private_key) {
    $keyid = $private_key->export_key_raw('public');
  } else {
    $keyid = encode('UTF-8', $keyid || '', Encode::FB_CROAK | Encode::LEAVE_SRC);
  }
  die "keyid is too long\n" if length($keyid) > 255;
  $salt . pack('L> C', $rs, length $keyid) . $keyid . $result;
}

sub ece_decrypt_aes128gcm {
  my (
    # no salt, keyid, rs as encoded in header
    $content, $key, $private_key, $dh, $auth_secret,
  ) = @_;
  my $id_len = unpack 'C', substr $content, 20, 1;
  my $salt = substr $content, 0, 16;
  my $rs = unpack 'L>', substr $content, 16, 4;
  my $overhead = 17;
  die "Record size too small\n" if $rs <= $overhead;
  my $keyid = substr $content, 21, $id_len;
  $content = substr $content, 21 + $id_len;
  if ($private_key and !$dh) {
    $dh = $keyid;
  } else {
    $keyid = decode('UTF-8', $keyid || '', Encode::FB_CROAK | Encode::LEAVE_SRC);
  }
  my ($key_, $nonce_) = derive_key(
    'decrypt', $salt, $key, $private_key, $dh, $auth_secret,
  );
  my $chunk_size = $rs;
  my $result = "";
  my $counter = 0;
  my $end = length $content;
  my $nonce_bigint = Math::BigInt->from_bytes($nonce_);
  for (my $i = 0; $i < $end; $i += $chunk_size) {
    my $iv = ($nonce_bigint ^ $counter)->as_bytes;
    my $bit = substr $content, $i, $chunk_size;
    my $ciphertext = substr $bit, 0, length($bit) - 16;

lib/Crypt/RFC8188.pm  view on Meta::CPAN

| Linux   | [![Build Status](https://travis-ci.org/mohawk2/Crypt-RFC8188.svg?branch=master)](https://travis-ci.org/mohawk2/Crypt-RFC8188) |

[![CPAN version](https://badge.fury.io/pl/Crypt-RFC8188.svg)](https://metacpan.org/pod/Crypt-RFC8188) [![Coverage Status](https://coveralls.io/repos/github/mohawk2/Crypt-RFC8188/badge.svg?branch=master)](https://coveralls.io/github/mohawk2/Crypt-RFC8...

=end markdown

=head1 SYNOPSIS

  use Crypt::RFC8188 qw(ece_encrypt_aes128gcm ece_decrypt_aes128gcm);
  my $ciphertext = ece_encrypt_aes128gcm(
    $plaintext, $salt, $key, $private_key, $dh, $auth_secret, $keyid, $rs,
  );
  my $plaintext = ece_decrypt_aes128gcm(
    # no salt, keyid, rs as encoded in header
    $ciphertext, $key, $private_key, $dh, $auth_secret,
  );

=head1 DESCRIPTION

This module implements RFC 8188, the HTTP Encrypted Content Encoding
standard. Among other things, this is used by Web Push (RFC 8291).

It implements only the C<aes128gcm> (Advanced Encryption Standard
128-bit Galois/Counter Mode) encryption, not the previous draft standards
envisaged for Web Push. It implements neither C<aesgcm> nor C<aesgcm128>.

lib/Crypt/RFC8188.pm  view on Meta::CPAN


=head3 $salt

A randomly-generated 16-octet sequence. If not provided, one will be
generated. This is still useful as the salt is included in the ciphertext.

=head3 $key

A secret key to be exchanged by other means.

=head3 $private_key

The private key of a L<Crypt::PK::ECC> Prime 256 ECDSA key.

=head3 $dh

If the private key above is provided, this is the recipient's public
key of an Prime 256 ECDSA key.

=head3 $auth_secret

lib/Crypt/RFC8188.pm  view on Meta::CPAN

be very inefficient as the overhead is 17 bytes. Defaults to 4096.

=head2 ece_decrypt_aes128gcm

=head3 $ciphertext

The plain text.

=head3 $key

=head3 $private_key

=head3 $dh

=head3 $auth_secret

All as above. C<$salt>, C<$keyid>, C<$rs> are not given since they are
encoded in the ciphertext.

=head1 SEE ALSO

t/ece.t  view on Meta::CPAN

use Test::More;
use MIME::Base64 qw(encode_base64url decode_base64url);
use Crypt::PK::ECC;
use Crypt::PRNG qw(random_bytes random_bytes_b64u);
use Crypt::RFC8188 qw(ece_encrypt_aes128gcm ece_decrypt_aes128gcm derive_key);

# modified port of github.com/web-push-libs/encrypted-content-encoding/python tests

my @DK_EXCEPTION_CASES = (
  [ [ 1 ], qr/must be 16 octets/ ],
  [ [ 2, 3 ], qr/DH requires a private_key/ ],
  [ [ 2, 3, 4 ], qr/Unable to determine the secret/ ],
);
subtest 'derive_key exceptions' => sub {
  for my $case (@DK_EXCEPTION_CASES) {
    my $private_key = gen_key();
    my @args = (
      'encrypt',
      random_bytes(16),
      random_bytes(16),
      $private_key,
      $private_key->export_key_raw('public'),
    );
    $args[$_] = undef for @{ $case->[0] };
    eval { derive_key(@args) };
    like $@, $case->[1];
  }
};

my @DK_CASES = (
  [["decrypt", "qtIFfTNTt_83veQq4dUP2g==", "ZMcOZKclVRRR8gjfuqC5cg==", undef, undef, undef], ["qYWpkVCDVZW7l_LpBS9afg==", "Brc0TQQMob40Dyw1"]],
  [["decrypt", "qtIFfTNTt_83veQq4dUP2g==", "ZMcOZKclVRRR8gjfuqC5cg==", undef, undef, undef], ["qYWpkVCDVZW7l_LpBS9afg==", "Brc0TQQMob40Dyw1"]],

t/ece.t  view on Meta::CPAN

  # elsewhere
  my $m_header = "\xaa\xd2\x05}3S\xb7\xff7\xbd\xe4*\xe1\xd5\x0f\xda";
  $m_header .= pack('L>', 32) . "\0";
  ($m_key, $m_input, $m_header);
}

subtest 'encrypt exceptions' => sub {
  my ($m_key, $m_input, $m_header) = test_init();
  eval { ece_encrypt_aes128gcm($m_input, undef, $m_key, (undef) x 4, 1) };
  like $@, qr/too small/;
#$content, $salt, $key, $private_key, $dh, $auth_secret, $keyid, $rs,
  eval { ece_encrypt_aes128gcm(
    $m_input, undef, $m_key, (undef) x 3,
    random_bytes_b64u(192), # 256 bytes
  ) };
  like $@, qr/keyid is too long/;
};

subtest 'decrypt exceptions' => sub {
  my ($m_key, $m_input, $m_header) = test_init();
#$content, $key, $private_key, $dh, $auth_secret,
  eval { ece_decrypt_aes128gcm(
    ('x' x 16) . pack('L> C', 2, 0) . $m_input,
    $m_key,
  ) };
  like $@, qr/too small/;
  eval { ece_decrypt_aes128gcm(
    $m_header .
      "\xbb\xc7\xb9ev\x0b\xf0f+\x93\xf4" .
      "\xe5\xd6\x94\xb7e\xf0\xcd\x15\x9b(\x01\xa5",
    "d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r",

t/ece.t  view on Meta::CPAN

  {
    encrypted => "rNEm6--7fMS1FuTr8btW3AAAEAAAnwgL-gYZKP4cme0fyuMKIISSZEBw8e44aiSVlycIOO9-2HOgcuKuLGJf4f4r7mOcP0aJgOLTbfxQYuZAaJlVAbZc5q23vPKzOzxf2VuKgYvdwfjESSA",
    input => "olO7J2DXC6DjHuhke8jmBckEFVheWN22Ib0en7B85t9orab9Lhb0_sifeMcEHBxl4O8xfP_FJlJ5A0FCAvqbzZW4e-qd",
    key => "ZkBfrd75r93uxCpocaMhoA",
    salt => "rNEm6--7fMS1FuTr8btW3A",
    test => "exactlyOneRecord aes128gcm",
  },
  {
    encrypted => "phSedT69xhtlKvR3lfkMKQAAAGFBBCp3NKi1owBzC8i3Sgkw15WJTuXkhjlcVdv4S0alC0W8VfNhE8DWxlzwXsImQUpM0zxNWotxRbDXt1yAfiP03d0Q4o4LCPfJr9aJAn9eKE7G_681R7-yoDEHilLcfs_OXATkjCpl99aTApG0dFBudoF9PHQftfLcZo-l8H7rA5frvbFvxj09RngrgnrqrPn4Vahmhg1Jn--f...
    input => "n9_vFNekfRIXbmXRjb_1SL0XQWPoJSvmYvtb_g6a90qRdRdhmbDIHeg8B19iCbm732X5s_1VOGWBFivjFCmWQkWcE2_uq_MGPU00SgaS",
    private_keys => {
      decrypt => <<'EOF',
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJnfq/XwOS2/jEBfeL+Pg1zVxwHmrm0mJn77uMlAc8dFoAoGCCqGSM49
AwEHoUQDQgAEGbC8Rb3pRwtVgyBSUXKAzTEB3SoOEm9RgNAWXftPWOBx67fEc30x
ArDfL4pmmZu+/MTpVZku0buyi1Tbqu7hbA==
-----END EC PRIVATE KEY-----
EOF
      encrypt => <<'EOF',
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIJLIfrqKwtDj7SyyrQUwB0ynXFqoN0hzibDDFQOlFb2soAoGCCqGSM49

t/ece.t  view on Meta::CPAN

        dh => "BBmwvEW96UcLVYMgUlFygM0xAd0qDhJvUYDQFl37T1jgceu3xHN9MQKw3y-KZpmbvvzE6VWZLtG7sotU26ru4Ww",
        rs => 97,
      },
    },
    test => "useDH aes128gcm",
  },
);
subtest 'test encryption/decryption' => sub {
  for my $case (@CASES) {
    my ($input, $encrypted) = map decode_base64url($_), @$case{qw(input encrypted)};
    my %mode2private_key;
    if (my $keys = $case->{private_keys}) {
      $mode2private_key{$_} = Crypt::PK::ECC->new(
        \$keys->{$_}
      ) for qw(encrypt decrypt);
    }
    my $got_encrypted = eval { ece_encrypt_aes128gcm(
      $input,
      decode_base64url($case->{salt}),
      maybe_decode_base64url($case->{key}),
      $mode2private_key{encrypt},
      maybe_decode_base64url($case->{params}{encrypt}{dh}),
      maybe_decode_base64url($case->{authSecret}),
      $case->{keyid},
      $case->{params}{encrypt}{rs} || 4096,
    ) };
    is $@, '';
    is $got_encrypted, $encrypted, "$case->{test} encrypted right" or eval {
      require Text::Diff;
      diag Text::Diff::diff(\join('', map "$_\n", split //, $encrypted), \join('', map "$_\n", split //, $got_encrypted));
    };
    my @decrypt_args = (
      $encrypted,
      maybe_decode_base64url($case->{key}),
      $mode2private_key{decrypt},
      maybe_decode_base64url($case->{params}{decrypt}{dh}),
      maybe_decode_base64url($case->{authSecret}),
    );
    my $got_input = eval { ece_decrypt_aes128gcm(@decrypt_args) };
    is $@, '';
    is $got_input, $input, "$case->{test} decrypted right" or eval {
      diag explain [ map +(ref() ? $_ : maybe_encode_base64url $_), @decrypt_args ];
      require Text::Diff;
      diag Text::Diff::diff(\join('', map "$_\n", split //, $input), \join('', map "$_\n", split //, $got_input));
    };



( run in 0.262 second using v1.01-cache-2.11-cpan-4d50c553e7e )