From c98952da8a9b3bae419851a68d71189608199ebc Mon Sep 17 00:00:00 2001 From: Ed J Date: Tue, 2 Jun 2020 23:34:36 +0100 Subject: [PATCH] first cut --- .gitignore | 33 ++++++ .travis.yml | 33 ++++++ Changes | 2 + MANIFEST | 9 ++ MANIFEST.SKIP | 13 +++ Makefile.PL | 58 ++++++++++ README.md | 108 ++++++++++++++++++ lib/Crypt/RFC8188.pm | 245 +++++++++++++++++++++++++++++++++++++++++ t/00-report-prereqs.t | 187 +++++++++++++++++++++++++++++++ t/ece.t | 249 ++++++++++++++++++++++++++++++++++++++++++ xt/manifest.t | 12 ++ xt/pod.t | 15 +++ 12 files changed, 964 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 Changes create mode 100644 MANIFEST create mode 100644 MANIFEST.SKIP create mode 100644 Makefile.PL create mode 100644 README.md create mode 100644 lib/Crypt/RFC8188.pm create mode 100644 t/00-report-prereqs.t create mode 100644 t/ece.t create mode 100644 xt/manifest.t create mode 100644 xt/pod.t diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43bf7ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +/.build/ +/_build/ +/Build +/Build.bat +/blib +/Makefile +/pm_to_blib + +/carton.lock +/.carton/ +/local/ + +nytprof.out +nytprof/ + +cover_db/ + +*.bak +*.old +*~ +*.swp +*.o +*.obj + +!LICENSE + +/_build_params + +MYMETA.* + +/Crypt-RFC8188-* + +/MANIFEST.bak diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..84232c8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,33 @@ +language: perl +jobs: + include: + - perl: "5.10.0" + - perl: "5.26" + env: AUTHOR_TESTING=1 RELEASE_TESTING=1 + # separate from author testing else cover_db blows up xt/manifest.t + - perl: "5.26" + env: COVERAGE=1 +before_install: + - git clone git://github.com/travis-perl/helpers ~/travis-perl-helpers + - source ~/travis-perl-helpers/init + - build-perl + - local-lib cache + - perl -V + - if [ "$AUTHOR_TESTING" = 1 ]; then prove -l xt; fi + - build-dist + - cd $BUILD_DIR # $BUILD_DIR is set by the build-dist command +install: + - cpan-install --deps # installs prereqs, including recommends + - cpan-install --coverage # installs coverage prereqs, if enabled +before_script: + - coverage-setup +script: + - prove -l -j$(test-jobs) $(test-files) # parallel testing +after_success: + - coverage-report +notifications: + irc: + channels: + - "irc.perl.org#graphql-perl" + on_failure: always + skip_join: true diff --git a/Changes b/Changes new file mode 100644 index 0000000..7e5dd6a --- /dev/null +++ b/Changes @@ -0,0 +1,2 @@ +0.01 2020-06-02 +- first version diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..b6604c2 --- /dev/null +++ b/MANIFEST @@ -0,0 +1,9 @@ +Changes +lib/Crypt/RFC8188.pm +Makefile.PL +MANIFEST This list of files +README.md +t/00-report-prereqs.t +t/ece.t +xt/manifest.t +xt/pod.t diff --git a/MANIFEST.SKIP b/MANIFEST.SKIP new file mode 100644 index 0000000..711f41e --- /dev/null +++ b/MANIFEST.SKIP @@ -0,0 +1,13 @@ +MANIFEST.SKIP +^cover_db\/ +MANIFEST.bak +.git\/ +^.gitignore +^.travis.yml +blib\/ +MYMETA\..* +^Crypt-RFC8188-\d.* +^Makefile$ +^Makefile.bak$ +pm_to_blib +\.swp$ diff --git a/Makefile.PL b/Makefile.PL new file mode 100644 index 0000000..6d62f14 --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,58 @@ +use strict; +use warnings; +use ExtUtils::MakeMaker; + +WriteMakefile( + NAME => 'Crypt::RFC8188', + AUTHOR => q{Ed J }, + VERSION_FROM => 'lib/Crypt/RFC8188.pm', + ABSTRACT_FROM => 'lib/Crypt/RFC8188.pm', + LICENSE => 'artistic_2', + MIN_PERL_VERSION => '5.010000', # pack "*>" + CONFIGURE_REQUIRES => { + 'ExtUtils::MakeMaker' => '7.10', + }, + TEST_REQUIRES => { + 'Test::More' => '0.98', + }, + PREREQ_PM => { + 'CryptX' => '0.068', + 'Exporter' => '5.57', + 'MIME::Base64' => '3.11', + 'Math::BigInt' => '1.999804', # from_bytes + }, + clean => { FILES => 'Crypt-RFC8188-*' }, + META_MERGE => { + "meta-spec" => { version => 2 }, + dynamic_config => 0, + resources => { + x_IRC => 'irc://irc.perl.org/#graphql-perl', + repository => { + type => 'git', + url => 'git@github.com:mohawk2/Crypt-RFC8188.git', + web => 'https://github.com/mohawk2/Crypt-RFC8188', + }, + bugtracker => { + web => 'https://github.com/mohawk2/Crypt-RFC8188/issues', + }, + license => [ 'http://dev.perl.org/licenses/' ], + }, + prereqs => { + develop => { + requires => { + 'Test::Pod' => '1.22', + 'Pod::Markdown' => 0, + }, + }, + }, + }, +); + +sub MY::postamble { + <\$\@ +EOF +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9ca713 --- /dev/null +++ b/README.md @@ -0,0 +1,108 @@ +# NAME + +Crypt::RFC8188 - Implement RFC 8188 HTTP Encrypted Content Encoding + +# PROJECT STATUS + +| 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-RFC8188?branch=master) + +# 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`. + +# FUNCTIONS + +Exportable (not by default) functions: + +## ece\_encrypt\_aes128gcm + +Arguments: + +### $plaintext + +The plain text. + +### $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. + +### $key + +A secret key to be exchanged by other means. + +### $private\_key + +The private key of a [Crypt::PK::ECC](https://metacpan.org/pod/Crypt::PK::ECC) Prime 256 ECDSA key. + +### $dh + +If the private key above is provided, this is the recipient's public +key of an Prime 256 ECDSA key. + +### $auth\_secret + +An authentication secret. + +### $keyid + +If provided, the ID of a key to be looked up by other means. + +### $rs + +The record size for encrypted blocks. Must be at least 18, which would +be very inefficient as the overhead is 17 bytes. Defaults to 4096. + +## ece\_decrypt\_aes128gcm + +### $ciphertext + +The plain text. + +### $key + +### $private\_key + +### $dh + +### $auth\_secret + +All as above. `$salt`, `$keyid`, `$rs` are not given since they are +encoded in the ciphertext. + +# SEE ALSO + +[https://github.com/web-push-libs/encrypted-content-encoding](https://github.com/web-push-libs/encrypted-content-encoding) + +RFC 8188 - Encrypted Content-Encoding for HTTP (using `aes128gcm`). + +# AUTHOR + +Ed J, `` + +# LICENSE + +Copyright (C) Ed J + +This library is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. diff --git a/lib/Crypt/RFC8188.pm b/lib/Crypt/RFC8188.pm new file mode 100644 index 0000000..c4879f9 --- /dev/null +++ b/lib/Crypt/RFC8188.pm @@ -0,0 +1,245 @@ +package Crypt::RFC8188; +use strict; +use warnings; +use MIME::Base64 qw(encode_base64url decode_base64url); +use Crypt::PK::ECC; +use Math::BigInt; +use Encode qw(encode decode); +use Crypt::KeyDerivation qw(hkdf hkdf_extract hkdf_expand); +use Crypt::AuthEnc::GCM qw(gcm_encrypt_authenticate gcm_decrypt_verify); +use Exporter qw(import); +use Crypt::PRNG qw(random_bytes); + +our $VERSION = "0.01"; +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; + my $tag = substr $bit, -16; + my $data = gcm_decrypt_verify 'AES', $key_, $iv, '', $ciphertext, $tag; + die "Decryption error\n" unless defined $data; + my $last = ($i + $chunk_size) >= $end; + $data =~ s/\x00*$//; + die "all zero record plaintext\n" if !length $data; + die "record delimiter != 1\n" if !$last and $data !~ s/\x01$//; + die "last record delimiter != 2\n" if $last and $data !~ s/\x02$//; + $result .= $data; + $counter++; + } + $result; +} + +=encoding utf-8 + +=head1 NAME + +Crypt::RFC8188 - Implement RFC 8188 HTTP Encrypted Content Encoding + +=begin markdown + +# PROJECT STATUS + +| 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-RFC8188?branch=master) + +=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 (Advanced Encryption Standard +128-bit Galois/Counter Mode) encryption, not the previous draft standards +envisaged for Web Push. It implements neither C nor C. + +=head1 FUNCTIONS + +Exportable (not by default) functions: + +=head2 ece_encrypt_aes128gcm + +Arguments: + +=head3 $plaintext + +The plain text. + +=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 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 + +An authentication secret. + +=head3 $keyid + +If provided, the ID of a key to be looked up by other means. + +=head3 $rs + +The record size for encrypted blocks. Must be at least 18, which would +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 + +L + +RFC 8188 - Encrypted Content-Encoding for HTTP (using C). + +=head1 AUTHOR + +Ed J, C<< >> + +=head1 LICENSE + +Copyright (C) Ed J + +This library is free software; you can redistribute it and/or modify +it under the same terms as Perl itself. + +=cut + +1; diff --git a/t/00-report-prereqs.t b/t/00-report-prereqs.t new file mode 100644 index 0000000..904b32f --- /dev/null +++ b/t/00-report-prereqs.t @@ -0,0 +1,187 @@ +#!perl + +use strict; +use warnings; + +# This test was generated by Dist::Zilla::Plugin::Test::ReportPrereqs 0.020 +# THEN modified with more info by Ed J for PDL project + +use Test::More tests => 1; + +use ExtUtils::MakeMaker; +use File::Spec; + +# from $version::LAX +my $lax_version_re = + qr/(?: undef | (?: (?:[0-9]+) (?: \. | (?:\.[0-9]+) (?:_[0-9]+)? )? + | + (?:\.[0-9]+) (?:_[0-9]+)? + ) | (?: + v (?:[0-9]+) (?: (?:\.[0-9]+)+ (?:_[0-9]+)? )? + | + (?:[0-9]+)? (?:\.[0-9]+){2,} (?:_[0-9]+)? + ) + )/x; + +# hide optional CPAN::Meta modules from prereq scanner +# and check if they are available +my $cpan_meta = "CPAN::Meta"; +my $cpan_meta_pre = "CPAN::Meta::Prereqs"; +my $HAS_CPAN_META = eval "require $cpan_meta; $cpan_meta->VERSION('2.120900')" && eval "require $cpan_meta_pre"; ## no critic + +# Verify requirements? +my $DO_VERIFY_PREREQS = 1; + +sub _max { + my $max = shift; + $max = ( $_ > $max ) ? $_ : $max for @_; + return $max; +} + +sub _merge_prereqs { + my ($collector, $prereqs) = @_; + + # CPAN::Meta::Prereqs object + if (ref $collector eq $cpan_meta_pre) { + return $collector->with_merged_prereqs( + CPAN::Meta::Prereqs->new( $prereqs ) + ); + } + + # Raw hashrefs + for my $phase ( keys %$prereqs ) { + for my $type ( keys %{ $prereqs->{$phase} } ) { + for my $module ( keys %{ $prereqs->{$phase}{$type} } ) { + $collector->{$phase}{$type}{$module} = $prereqs->{$phase}{$type}{$module}; + } + } + } + + return $collector; +} + +my @include = qw( + +); + +my @exclude = qw( + +); + +# Add static prereqs to the included modules list +my $static_prereqs = do 't/00-report-prereqs.dd'; + +# Merge all prereqs (either with ::Prereqs or a hashref) +my $full_prereqs = _merge_prereqs( + ( $HAS_CPAN_META ? $cpan_meta_pre->new : {} ), + $static_prereqs +); + +# Add dynamic prereqs to the included modules list (if we can) +my ($source) = grep { -f } 'MYMETA.json', 'MYMETA.yml'; +if ( $source && $HAS_CPAN_META ) { + if ( my $meta = eval { CPAN::Meta->load_file($source) } ) { + $full_prereqs = _merge_prereqs($full_prereqs, $meta->prereqs); + } +} +else { + $source = 'static metadata'; +} + +my @full_reports; +my @dep_errors; +my $req_hash = $HAS_CPAN_META ? $full_prereqs->as_string_hash : $full_prereqs; + +# Add static includes into a fake section +for my $mod (@include) { + $req_hash->{other}{modules}{$mod} = 0; +} + +for my $phase ( qw(configure build test runtime develop other) ) { + next unless $req_hash->{$phase}; + next if ($phase eq 'develop' and not $ENV{AUTHOR_TESTING}); + + for my $type ( qw(requires recommends suggests conflicts modules) ) { + next unless $req_hash->{$phase}{$type}; + + my $title = ucfirst($phase).' '.ucfirst($type); + my @reports = [qw/Module Want Have Where Howbig/]; + + for my $mod ( sort keys %{ $req_hash->{$phase}{$type} } ) { + next if $mod eq 'perl'; + next if grep { $_ eq $mod } @exclude; + + my $file = $mod; + $file =~ s{::}{/}g; + $file .= ".pm"; + my ($prefix) = grep { -e File::Spec->catfile($_, $file) } @INC; + + my $want = $req_hash->{$phase}{$type}{$mod}; + $want = "undef" unless defined $want; + $want = "any" if !$want && $want == 0; + + my $req_string = $want eq 'any' ? 'any version required' : "version '$want' required"; + + if ($prefix) { + my $filename = File::Spec->catfile($prefix, $file); + my $have = MM->parse_version( $filename ); + $have = "undef" unless defined $have; + push @reports, [$mod, $want, $have, $prefix, (-s $filename)]; + + if ( $DO_VERIFY_PREREQS && $HAS_CPAN_META && $type eq 'requires' ) { + if ( $have !~ /\A$lax_version_re\z/ ) { + push @dep_errors, "$mod version '$have' cannot be parsed ($req_string)"; + } + elsif ( ! $full_prereqs->requirements_for( $phase, $type )->accepts_module( $mod => $have ) ) { + push @dep_errors, "$mod version '$have' is not in required range '$want'"; + } + } + } + else { + push @reports, [$mod, $want, "missing", '', 0]; + + if ( $DO_VERIFY_PREREQS && $type eq 'requires' ) { + push @dep_errors, "$mod is not installed ($req_string)"; + } + } + } + + if ( @reports ) { + push @full_reports, "=== $title ===\n\n"; + + my $ml = _max( map { length $_->[0] } @reports ); + my $wl = _max( map { length $_->[1] } @reports ); + my $hl = _max( map { length $_->[2] } @reports ); + my $ll = _max( map { length $_->[3] } @reports ); # location + my $sl = _max( map { length $_->[4] } @reports ); # size + + if ($type eq 'modules') { + splice @reports, 1, 0, ["-" x $ml, "", "-" x $hl, "-" x $ll, "-" x $sl]; + push @full_reports, map { sprintf(" %*s %*s\n", -$ml, $_->[0], $hl, $_->[2]) } @reports; + } + else { + splice @reports, 1, 0, ["-" x $ml, "-" x $wl, "-" x $hl, "-" x $ll, "-" x $sl]; + push @full_reports, map { sprintf(" %*s %*s %*s %*s %*s\n", -$ml, $_->[0], $wl, $_->[1], $hl, $_->[2], -$ll, $_->[3], $sl, $_->[4]) } @reports; + } + + push @full_reports, "\n"; + } + } +} + +if ( @full_reports ) { + diag "\nVersions for all modules listed in $source (including optional ones):\n\n", @full_reports; +} + +if ( @dep_errors ) { + diag join("\n", + "\n*** WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING ***\n", + "The following REQUIRED prerequisites were not satisfied:\n", + @dep_errors, + "\n" + ); +} + +pass; + +# vim: ts=4 sts=4 sw=4 et: diff --git a/t/ece.t b/t/ece.t new file mode 100644 index 0000000..fb658b0 --- /dev/null +++ b/t/ece.t @@ -0,0 +1,249 @@ +use strict; +use warnings; +use Test::More; +use MIME::Base64 qw(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"]], + [["decrypt", "qtIFfTNTt_83veQq4dUP2g==", "ZMcOZKclVRRR8gjfuqC5cg==", undef, undef, undef], ["qYWpkVCDVZW7l_LpBS9afg==", "Brc0TQQMob40Dyw1"]], + [["decrypt", "dKdaWSgXpZBv0uPeMtIWjQ==", "uAyCGPbsBtMkaE3RpqY-IQ==", undef, undef, undef], ["dUm1w62FlUX_TAsgbNindw==", "SoclDc9_KtBmYEZF"]], + [["encrypt", "Aq8ZmNVGKYJrAiD4LQITew==", "VydnrnVNbVzSh8cUK_fHgQ==", undef, undef, undef], ["tuU6Z1adaAeRM4PjP8xaYA==", "tqO89snEcEr88dV7"]], + [["encrypt", "nkaMAXr7KZ8kA_VmLSMjBQ==", "bgOhe7mh7D-VM6h8o78_4g==", undef, undef, undef, undef], ["DV_4-biUdWnyNnTAK7TgFA==", "2uILAcmqv8WBn9Ms"]], + [["decrypt", "qtIFfTNTt_83veQq4dUP2g==", "ZMcOZKclVRRR8gjfuqC5cg==", undef, undef, undef, ""], ["qYWpkVCDVZW7l_LpBS9afg==", "Brc0TQQMob40Dyw1"]], +); +subtest 'derive_key' => sub { + for my $case (@DK_CASES) { + my ($in, $out) = @$case; + my @args = ($in->[0], map decode_base64url($_), @$in[1..5]); + my ($got_key, $got_nonce) = derive_key(@args); + is $got_key, decode_base64url($out->[0]), 'right key'; + is $got_nonce, decode_base64url($out->[1]), 'right nonce'; + } +}; + +# my ($m_key, $m_input, $m_header) = test_init(); +sub test_init { + my $m_key = random_bytes(16); + my $m_input = random_bytes(5); + # This header is specific to the padding tests, but can be used + # 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", + ) }; + like $@, qr/all zero/; + eval { ece_decrypt_aes128gcm( + $m_header . + "\xb9\xc7\xb9ev\x0b\xf0\x9eB\xb1\x08C8u" . + "\xa3\x06\xc9x\x06\n\xfc|}\xe9R\x85\x91" . + "\x8bX\x02`\xf3" . + "E8z(\xe5%f/H\xc1\xc32\x04\xb1\x95\xb5N\x9ep\xd4\x0e<\xf3" . + "\xef\x0cg\x1b\xe0\x14I~\xdc", + "d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r", + ) }; + like $@, qr/record delimiter != 1/; + eval { ece_decrypt_aes128gcm( + $m_header . + "\xba\xc7\xb9ev\x0b\xf0\x9eB\xb1\x08Ji" . + "\xe4P\x1b\x8dI\xdb\xc6y#MG\xc2W\x16", + "d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r", + ) }; + like $@, qr/last record delimiter != 2/; + eval { ece_decrypt_aes128gcm( + $m_header . + "\xbb\xc6\xb1\x1dF:~\x0f\x07+\xbe\xaaD" . + "\xe0\xd6.K\xe5\xf9]%\xe3\x86q\xe0}", + "d\xc7\x0ed\xa7%U\x14Q\xf2\x08\xdf\xba\xa0\xb9r", + ) }; + like $@, qr/Decryption error/; +}; + +sub maybe_decode_base64url { defined($_[0]) ? decode_base64url $_[0] : undef } + +# generated from encrypt_data.json from the JavaScript library, then: +# perl -Mojo -e 'print r j f(shift)->slurp' file >file.pl +my @CASES = ( + { + "encrypted" => "hwaB6ajPR3BbJ_EtJ7DPGwAAEAAALeErM5xhsiAHm4Kqh_SuUT8naH0b1dgCaukr-9b7FRfYEBCadps", + "input" => "wXe3vEnuHqhdGgrwaaT1j2PLt1aK", + "params" => { + "decrypt" => { + "key" => "0MLhZq8sewP4P2h18tlS2A", + "salt" => "hwaB6ajPR3BbJ_EtJ7DPGw" + }, + "encrypt" => { + "key" => "0MLhZq8sewP4P2h18tlS2A", + "salt" => "hwaB6ajPR3BbJ_EtJ7DPGw" + } + }, + "test" => "useExplicitKey aes128gcm" + }, + { + "encrypted" => "sj2q-yxtvnEKrtNyfo-lPwAAEAAAPpHyEJGNkL9xmHAxwv_eieKYQWk", + "input" => "pHFj", + "params" => { + "decrypt" => { + "authSecret" => "GCIe1dcp-nfsQw5nFoVzmw", + "key" => "297VgT05oFIZfyasTP_B7w", + "salt" => "sj2q-yxtvnEKrtNyfo-lPw" + }, + "encrypt" => { + "authSecret" => "GCIe1dcp-nfsQw5nFoVzmw", + "key" => "297VgT05oFIZfyasTP_B7w", + "salt" => "sj2q-yxtvnEKrtNyfo-lPw" + } + }, + "test" => "authenticationSecret aes128gcm" + }, + { + "encrypted" => "rNEm6--7fMS1FuTr8btW3AAAEAAAnwgL-gYZKP4cme0fyuMKIISSZEBw8e44aiSVlycIOO9-2HOgcuKuLGJf4f4r7mOcP0aJgOLTbfxQYuZAaJlVAbZc5q23vPKzOzxf2VuKgYvdwfjESSA", + "input" => "olO7J2DXC6DjHuhke8jmBckEFVheWN22Ib0en7B85t9orab9Lhb0_sifeMcEHBxl4O8xfP_FJlJ5A0FCAvqbzZW4e-qd", + "params" => { + "decrypt" => { + "key" => "ZkBfrd75r93uxCpocaMhoA", + "salt" => "rNEm6--7fMS1FuTr8btW3A" + }, + "encrypt" => { + "key" => "ZkBfrd75r93uxCpocaMhoA", + "salt" => "rNEm6--7fMS1FuTr8btW3A" + } + }, + "test" => "exactlyOneRecord aes128gcm" + }, + { + "encrypted" => "phSedT69xhtlKvR3lfkMKQAAAGFBBCp3NKi1owBzC8i3Sgkw15WJTuXkhjlcVdv4S0alC0W8VfNhE8DWxlzwXsImQUpM0zxNWotxRbDXt1yAfiP03d0Q4o4LCPfJr9aJAn9eKE7G_681R7-yoDEHilLcfs_OXATkjCpl99aTApG0dFBudoF9PHQftfLcZo-l8H7rA5frvbFvxj09RngrgnrqrPn4Vahmhg1Jn--fYOf02nW8zw", + "input" => "n9_vFNekfRIXbmXRjb_1SL0XQWPoJSvmYvtb_g6a90qRdRdhmbDIHeg8B19iCbm732X5s_1VOGWBFivjFCmWQkWcE2_uq_MGPU00SgaS", + "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 +AwEHoUQDQgAEKnc0qLWjAHMLyLdKCTDXlYlO5eSGOVxV2/hLRqULRbxV82ETwNbG +XPBewiZBSkzTPE1ai3FFsNe3XIB+I/Td3Q== +-----END EC PRIVATE KEY----- +EOF + }, + "params" => { + "decrypt" => { + "authSecret" => "dYwViyw3w5oIIVNpHBddAQ", + "salt" => "phSedT69xhtlKvR3lfkMKQ" + }, + "encrypt" => { + "authSecret" => "dYwViyw3w5oIIVNpHBddAQ", + "dh" => "BBmwvEW96UcLVYMgUlFygM0xAd0qDhJvUYDQFl37T1jgceu3xHN9MQKw3y-KZpmbvvzE6VWZLtG7sotU26ru4Ww", + "rs" => 97, + "salt" => "phSedT69xhtlKvR3lfkMKQ" + } + }, + "test" => "useDH aes128gcm" + }, +); +subtest 'test encryption/decryption' => sub { + for my $case (@CASES) { + my ($input, $encrypted) = map decode_base64url($_), @$case{qw(input encrypted)}; + my %mode2keys; + if (my $keys = $case->{keys}) { + $mode2keys{$_}{private_key} = Crypt::PK::ECC->new( + \$keys->{$_} + ) for qw(encrypt decrypt); + } else { + $mode2keys{$_}{key} = decode_base64url $case->{params}{$_}{key} + for qw(encrypt decrypt); + } + my %mode2auth_secret; + $mode2auth_secret{$_} = maybe_decode_base64url $case->{params}{$_}{authSecret} + for qw(encrypt decrypt); + my %mode2dh; + $mode2dh{$_} = maybe_decode_base64url $case->{params}{$_}{dh} + for qw(encrypt decrypt); + my $got_encrypted = ece_encrypt_aes128gcm( + $input, + decode_base64url($case->{params}{encrypt}{salt}), + $mode2keys{encrypt}{key}, + $mode2keys{encrypt}{private_key}, + $mode2dh{encrypt}, + $mode2auth_secret{encrypt}, + $case->{keyid}, + $case->{params}{encrypt}{rs} || 4096, + ); + 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 $got_input = ece_decrypt_aes128gcm( + $encrypted, + $mode2keys{decrypt}{key}, + $mode2keys{decrypt}{private_key}, + $mode2dh{decrypt}, + $mode2auth_secret{decrypt}, + ); + is $got_input, $input, "$case->{test} decrypted right" or eval { + require Text::Diff; + diag Text::Diff::diff(\join('', map "$_\n", split //, $input), \join('', map "$_\n", split //, $got_input)); + }; + } +}; + +sub gen_key { Crypt::PK::ECC->new->generate_key('prime256v1') } + +done_testing; diff --git a/xt/manifest.t b/xt/manifest.t new file mode 100644 index 0000000..a817ebc --- /dev/null +++ b/xt/manifest.t @@ -0,0 +1,12 @@ +use strict; +use warnings; +use Test::More; +use ExtUtils::Manifest; + +unless ( $ENV{RELEASE_TESTING} ) { + plan( skip_all => "Author tests not required for installation" ); +} +plan tests => 2; + +is_deeply [ ExtUtils::Manifest::manicheck() ], [], 'missing'; +is_deeply [ ExtUtils::Manifest::filecheck() ], [], 'extra'; diff --git a/xt/pod.t b/xt/pod.t new file mode 100644 index 0000000..33039b7 --- /dev/null +++ b/xt/pod.t @@ -0,0 +1,15 @@ +#!perl -T +use strict; +use warnings; +use Test::More; + +unless ( $ENV{RELEASE_TESTING} ) { + plan( skip_all => "Author tests not required for installation" ); +} + +# Ensure a recent version of Test::Pod +my $min_tp = 1.22; +eval "use Test::Pod $min_tp"; +plan skip_all => "Test::Pod $min_tp required for testing POD" if $@; + +all_pod_files_ok();