Authen-NZRealMe
view release on metacpan or search on metacpan
lib/Authen/NZRealMe/XMLSig.pm view on Meta::CPAN
messaging.
=cut
use Carp qw(croak);
use Digest::SHA qw(sha1 sha1_base64 sha256);
use MIME::Base64 qw(encode_base64 decode_base64);
use Authen::NZRealMe::CommonURIs qw(URI NS_PAIR);
require XML::LibXML;
require XML::LibXML::XPathContext;
require XML::Generator;
require Crypt::OpenSSL::RSA;
require Crypt::OpenSSL::X509;
my(%transforms_by_name, %transforms_by_uri);
__PACKAGE__->register_transform_method($_, URI($_)) foreach (qw(
c14n
c14n_wc
c14n11
c14n11_wc
ec14n
ec14n_wc
sha1
sha256
env_sig
));
my(%sig_alg_by_name, %sig_alg_by_uri);
__PACKAGE__->register_signature_methods($_, URI($_)) foreach (qw(
rsa_sha1
rsa_sha256
));
use constant WITH_COMMENTS => 1;
use constant WITHOUT_COMMENTS => 0;
sub new {
my $class = shift;
my $self = bless {
reference_transforms => [ 'env_sig', 'ec14n' ],
reference_digest_method => 'sha1',
c14n_method => 'ec14n',
signature_algorithm => 'rsa_sha1',
include_x509_cert => 0,
@_
}, $class;
return $self;
}
sub id_attr { shift->{id_attr}; }
sub reference_transforms { shift->{reference_transforms}; }
sub reference_digest_method { shift->{reference_digest_method}; }
sub c14n_method { shift->{c14n_method}; }
sub signature_algorithm { shift->{signature_algorithm}; }
sub include_x509_cert { shift->{include_x509_cert}; }
sub _signed_fragment_paths { @{ shift->{signed_fragment_paths} }; }
sub sign {
my($self, $xml, $target_id, %options) = @_;
my $return_signature_xml = delete $options{return_signature_xml};
my $refs = $options{references} // [ { ref_id => $target_id } ];
my $ns_map = delete($options{namespaces}) // [];
my $xc = $self->_xcdom_from_xml($xml, @$ns_map);
my $doc = $xc->getContextNode();
my $sig_xml = $self->_make_sig_xml($xc, %options, references => $refs);
# Just return the XML of the signature block if that's what the caller wants
return $sig_xml if $return_signature_xml;
# Otherwise, add sig fragment to source doc as first child of first ref
my $sig_frag = $self->_xml_to_dom($sig_xml);
my $ref_id_0 = $refs->[0]->{ref_id};
my $target = $self->_find_element_by_uri_reference($xc, $ref_id_0);
if($target->hasChildNodes()) {
$target->insertBefore($sig_frag, $target->firstChild);
}
else {
$target->appendChild($sig_frag);
}
return $doc->toString;
}
sub _xml_to_dom {
my($self, $xml) = @_;
my $parser = XML::LibXML->new();
my $doc = $parser->parse_string($xml);
return $doc->documentElement;
}
sub verify {
my $self = shift;
my $xml = shift or croak "Need XML to verify";
my $selector = shift // '//ds:Signature';
my @namespaces = @_;
# Verifying an enveloped signature performs destructive operations on the
# DOM, so we need a new DOM for each <Signature> block.
my $sig_count = do {
my $xc = $self->_xcdom_from_xml($xml, @namespaces);
my @sigs = $xc->findnodes($selector);
scalar(@sigs);
};
my @signed_fragment_paths;
eval {
for(my $i = 0; $i < $sig_count; $i++) {
my $xc = $self->_xcdom_from_xml($xml, @namespaces);
my($sig_node) = ($xc->findnodes($selector))[$i];
die "No signature block match for selector: '$selector'"
unless $sig_node;
my $sig_block = $self->_parse_signature_block($xc, $sig_node);
my @frags = $self->_verify_one_signature_block($xc, $sig_block);
push @signed_fragment_paths, @frags;
}
1;
} or do {
my $message = $@ =~ s/\n+\z//r;
croak "Signature verification failed. $message";
};
croak "XML document contains no signatures" unless @signed_fragment_paths;
$self->{signed_fragment_paths} = \@signed_fragment_paths;
return 1;
}
sub _verify_one_signature_block {
my($self, $xc, $sig_block) = @_;
my(@signed_fragment_paths);
# Confirm that the signature is valid for the <SignedInfo> block
my $input = [ $xc, $sig_block->{sig_info_node}];
my $sig_info_plaintext = $self->_apply_transform($sig_block->{c14n}, $input);
$self->_verify_signature(
$sig_block->{signature_algorithm},
$sig_info_plaintext,
$sig_block->{signature_value}
) or die "SignedInfo block signature does not match\n";
# Confirm the digest value for each reference
my $references = $sig_block->{references};
die "Signature block contains no references\n" unless @$references;
foreach my $ref ( @$references ) {
my $fragment = [ $xc, $ref->{xml_node} ];
my $transforms = $ref->{transforms};
foreach my $transform ( @$transforms ) {
$fragment = $self->_apply_transform($transform, $fragment);
}
my $digest = $self->_apply_transform($ref->{digest_method}, $fragment);
if($digest ne $ref->{digest_value}) {
die "Digest of signed element '$ref->{ref_id}' "
. "differs from that given in reference block\n"
. "Expected: '$ref->{digest_value}'\n"
. "Calculated: '$digest'\n ";
}
push @signed_fragment_paths, $ref->{xml_fragment_path};
}
return @signed_fragment_paths;
}
sub _parse_signature_block {
my($self, $xc, $sig) = @_;
my $sig_as_text = $sig->toString;
my $block = {};
my($sig_info) = $xc->findnodes(q{./ds:SignedInfo}, $sig)
or die "Can't verify a signature without a 'SignedInfo' element";
$block->{sig_info_node} = $sig_info;
my($c14n_node) = $xc->findnodes(q{./ds:CanonicalizationMethod}, $sig_info)
or die "Can't find CanonicalizationMethod in: '$sig_as_text'";
my $c14n_method = $c14n_node->{Algorithm}
or die "CanonicalizationMethod element lacks Algorithm attribute in: '$sig_as_text'";
$block->{c14n} = $self->_find_transform($c14n_method);
my($sigm_node) = $xc->findnodes(q{./ds:SignatureMethod}, $sig_info)
or die "Can't find SignatureMethod in: '$sig_as_text'";
my $sig_alg = $sigm_node->{Algorithm}
or die "SignatureMethod element lacks Algorithm attribute in: '$sig_as_text'";
$block->{signature_algorithm} = $self->_find_sig_alg($sig_alg);
$block->{references} = [
map { $self->_parse_signature_reference($xc, $_); }
$xc->findnodes(q{.//ds:Reference}, $sig)
];
my($sig_value) = $xc->findvalue(q{./ds:SignatureValue}, $sig)
or die "Can't find SignatureValue in: '$sig_as_text'";
$sig_value =~ s/\s+//g;
$block->{signature_value} = $sig_value;
return $block;
}
sub _parse_signature_reference {
my($self, $xc, $ref_node) = @_;
my $ref_as_text = $ref_node->toString;
my $ref_data = {};
my $ref_uri = $xc->findvalue('./@URI', $ref_node)
or die "Reference element is missing the URI attribute";
$ref_uri =~ s{^#}{};
$ref_data->{ref_id} = $ref_uri;
my $target_node = $self->_find_element_by_uri_reference($xc, $ref_uri);
$ref_data->{xml_node} = $target_node;
$ref_data->{xml_fragment_path} = $self->_node_to_clarkian_path($target_node);
$ref_data->{transforms} = [
map {
my $trans_node = $_;
my $trans_as_text = $trans_node->toString;
my $algorithm = $trans_node->{Algorithm}
or die "Transform element lacks Algorithm attribute in: '$trans_as_text'";
my $transform = $self->_find_transform($algorithm);
if($xc->findnodes('./*')) {
$transform->{args} = $trans_node->toStringEC14N();
}
$transform;
} $xc->findnodes(q{./ds:Transforms/ds:Transform}, $ref_node)
];
my($digest_node) = $xc->findnodes(q{./ds:DigestMethod}, $ref_node)
or die "Can't find DigestMethod in: '$ref_as_text'";
my $digest_method = $digest_node->{Algorithm}
or die "DigestMethod element lacks Algorithm attribute in: '$ref_as_text'";
$ref_data->{digest_method} = $self->_find_transform($digest_method);
my($digest) = map { $_->to_literal } $xc->findnodes('./ds:DigestValue', $ref_node);
$ref_data->{digest_value} = $digest if $digest;
return $ref_data;
}
sub _find_element_by_uri_reference {
my($self, $xc, $ref_uri) = @_;
my @elem;
if(my $id_attr = $self->id_attr) {
@elem = $xc->findnodes("//*[\@${id_attr}='${ref_uri}']")
or croak "Can't find element with ${id_attr}='${ref_uri}'";
if(@elem != 1) {
croak "Reference URI \@${id_attr}='$ref_uri' is ambiguous";
}
}
else {
my @attr = $xc->findnodes("//*/\@*[.='${ref_uri}']")
or croak "Can't find element with ID='${ref_uri}'";
if(@attr > 1) {
@attr = grep { lc( $_->localName() ) eq 'id' } @attr;
if(@attr != 1) {
croak "Reference URI '$ref_uri' is ambiguous";
}
}
@elem = map { $_->ownerElement() } @attr;
}
return $elem[0];
}
sub _node_to_clarkian_path {
my($self, $node) = @_;
my $node_path = $node->nodePath();
my %frag_ns;
do {
if(my $prefix = $node->prefix) {
$frag_ns{$prefix} = $node->namespaceURI;
}
$node = $node->parentNode();
} while($node);
$node_path =~ s{([\w-]+):}{
my $prefix = $1;
my $uri = $frag_ns{$prefix};
"{$uri}";
}ge;
return $node_path;
}
sub _make_sig_xml {
my($self, $xc, %opt) = @_;
my $sig = {};
my $ref_specs = $opt{references} // [];
die "Can't make a signature without references" unless @$ref_specs;
my @references = map {
$_->{digest_method} //= $opt{reference_digest_method} if $opt{reference_digest_method};
$_->{transforms} //= $opt{reference_transforms} if $opt{reference_transforms};
$self->_make_reference($xc, $_);
} @$ref_specs;
$sig->{references} = \@references;
$sig->{c14n} = $self->_find_transform(
$opt{c14n} // $self->c14n_method()
);
if(my $ns_list = $opt{c14n_namespaces}) {
$sig->{c14n}->{namespaces} = $ns_list;
}
$sig->{signature_algorithm} = $self->_find_sig_alg(
$opt{signature_algorithm} // $self->signature_algorithm()
);
return $self->_sig_as_xml($sig);
}
sub _sig_as_xml {
my($self, $sig) = @_;
my $ns_ds = [ dsig => URI('ds') ];
my $x = XML::Generator->new(':strict', pretty => 2);
my @ref_blocks = map {
my @transforms = map {
$self->_transform_as_xml($x, 'Transform', $ns_ds, $_);
} @{ $_->{transforms} };
$x->Reference($ns_ds, { URI => '#' . $_->{ref_id} },
$x->Transforms($ns_ds,
@transforms,
),
$x->DigestMethod($ns_ds, { Algorithm => $_->{digest_method}->{uri} }),
$x->DigestValue($ns_ds, $_->{digest_value}),
),
} @{ $sig->{references} };
my $c14n = $sig->{c14n};
my $sig_alg = $sig->{signature_algorithm};
my @key_info;
if($self->include_x509_cert) {
my $cert_text = $self->pub_cert_text()
or die "Need pub_cert_file or pub_cert_text for include_x509_cert";
$cert_text =~ s{\A\s*-+\s*BEGIN CERTIFICATE\s*-+\s*}{};
$cert_text =~ s{\s*-+\s*END CERTIFICATE\s*-+\s*}{};
$cert_text =~ s{^\s+}{}mg;
@key_info = (
$x->KeyInfo($ns_ds,
$x->X509Data($ns_ds,
$x->X509Certificate($ns_ds,
$cert_text . "\n"
)
)
)
);
}
my $sig_xml = $x->Signature($ns_ds,
$x->SignedInfo($ns_ds,
$self->_transform_as_xml($x, 'CanonicalizationMethod', $ns_ds, $c14n),
$x->SignatureMethod($ns_ds, { Algorithm => $sig_alg->{uri} }),
@ref_blocks,
),
$x->SignatureValue($ns_ds),
@key_info,
) . '';
my $xc = $self->_xcdom_from_xml($sig_xml, @$ns_ds);
my $doc = $xc->getContextNode();
my($fragment) = [ $xc, $xc->findnodes('/ds:Signature/ds:SignedInfo') ];
my $plaintext = $self->_apply_transform($sig->{c14n}, $fragment);
my $sig_text = "\n" . $self->_create_signature(
$sig->{signature_algorithm},
$plaintext,
);
my($sig_node) = $xc->findnodes('//dsig:SignatureValue')
or die "Failed to find SignatureValue in generated signature XML";
$sig_node->addChild( $doc->ownerDocument->createTextNode($sig_text) );
# Serialising, parsing and reserialising simplifies ns attr and empty tags
return $self->_xml_to_dom( $doc->toStringEC14N() )->toString();
}
sub _transform_as_xml {
my($self, $x, $tag_name, $ns_ds, $trans) = @_;
my @content;
if(my $ns_list = $trans->{namespaces}) {
my $prefixes = join ' ', @$ns_list;
my $ec_ns = [ 'ec' => URI('ec14n') ];
push @content, $x->InclusiveNamespaces($ec_ns, { PrefixList => $prefixes });
}
my $xml = $x->$tag_name($ns_ds, { Algorithm => $trans->{uri} }, @content);
return $xml;
}
sub _make_reference {
my($self, $xc, $spec) = @_;
if((ref($spec) || '') ne 'HASH') {
die "references must be specified as hashrefs";
}
my $ref = {};
my $ref_uri = $ref->{ref_id} = $spec->{ref_id}
// die "need a 'ref_id' to create a reference";
my $target_node = $self->_find_element_by_uri_reference($xc, $ref_uri);
$ref->{xml_node} = $target_node;
my $fragment = [$xc, $target_node];
my @transforms = map {
$self->_find_transform($_)
} @{ $spec->{transforms} // $self->reference_transforms() };
if(my $ns_list = $spec->{namespaces}) {
if($transforms[-1]->{uri} ne URI('ec14n')) {
$transforms[-1] = $self->_find_transform('ec14n');
}
$transforms[-1]->{namespaces} = $ns_list;
}
$ref->{transforms} = \@transforms;
foreach my $transform ( @transforms ) {
$fragment = $self->_apply_transform($transform, $fragment);
}
my $digest_method = $ref->{digest_method} = $self->_find_transform(
$spec->{digest_method} // $self->reference_digest_method()
);
$ref->{digest_value} = $self->_apply_transform($digest_method, $fragment);
return $ref;
}
sub find_verified_element {
my($self, $xc, $xpath) = @_;
my($node) = $xc->findnodes($xpath);
croak "No element matches: '$xpath'" unless $node;
# Check if the matching node, or one of its ancestors is in one of
# the signed fragments which were verified earlier.
my @vfrags = $self->_find_signed_fragment_nodes($xc);
my $n = $node;
do {
foreach my $v (@vfrags) {
return $node if $v->isEqual($n);
}
$n = $n->parentNode;
} while ($n);
croak "Element matching '$xpath' is not in a signed fragment";
return $node;
}
sub _find_signed_fragment_nodes {
my($self, $xc) = @_;
my @paths = $self->_signed_fragment_paths;
my %prefix;
my $i = 1;
foreach my $uri ("@paths" =~ m/{(.*?)}/g) {
my $prefix = $prefix{$uri} //= sprintf('_XSig-%02u', $i++);
s/{$uri}/$prefix:/g foreach @paths;
}
while(my($uri, $pfx) = each %prefix) {
$xc->registerNs($pfx => $uri);
}
my @nodes = map { $xc->findnodes($_) } @paths;
return @nodes;
}
sub ignore_bad_signatures { # Called if skip_signature_check is enabled
shift->{signed_fragment_paths} = [ '/' ];
}
sub create_detached_signature {
my($self, $plaintext, $eol) = @_;
$eol //= "\n";
my $algorithm = $self->_find_sig_alg($self->signature_algorithm);
my $b64_sig = $self->_create_signature($algorithm, $plaintext);
$b64_sig =~ s/\s+/$eol/g;
return $b64_sig;
}
sub verify_detached_signature {
my($self, $plaintext, $b64_sig) = @_;
my $algorithm = $self->_find_sig_alg($self->signature_algorithm);
return $self->_verify_signature($algorithm, $plaintext, $b64_sig);
}
sub key_text {
my($self) = @_;
return $self->{key_text} if $self->{key_text};
my $path = $self->{key_file}
or croak "signing key must be set with 'key_file' or 'key_text'";
$self->{key_text} = $self->_slurp_file($path);
return $self->{key_text};
}
sub pub_key_text {
my($self) = @_;
return $self->{pub_key_text} if $self->{pub_key_text};
my $cert_text = $self->pub_cert_text();
my $x509 = Crypt::OpenSSL::X509->new_from_string($cert_text);
$self->{pub_key_text} = $x509->pubkey();
return $self->{pub_key_text};
}
sub pub_cert_text {
my($self) = @_;
return $self->{pub_cert_text} if $self->{pub_cert_text};
my $path = $self->{pub_cert_file}
or croak "signing cert must be set with 'pub_cert_file' or 'pub_cert_text'";
$self->{pub_cert_text} = $self->_slurp_file($path);
return $self->{pub_cert_text};
}
sub _slurp_file {
my($self, $path) = @_;
local($/) = undef;
open my $fh, '<', $path or die "open($path): $!";
my $text = <$fh>;
return $text;
}
##############################################################################
# Methods for applying transforms
#
# A transform method takes a parameter '$input' which must either be a DOM
# fragment or a string. Some of the transform methods also accept a second
# parameter '$args' which is a hashref defining the parameters of the transform
# in more detail.
#
# In the case of a DOM fragment, we also need the XPathContext object to
# facilitate the use of namespaces in queries. Therefore the input parameter
# will be a reference to an array of two elements: the context object, followed
# by the DOM fragment node:
#
# [ $xc, $node ]
#
# String input will be a simple scalar. The _input_as_context_dom() helper
# method can be used to turn a string into a DOM fragment/context pair.
#
# The return value from the transform method will either be a DOM fragment
# /context pair or a string - depending on the type of transform.
#
# The process for calling these methods is:
#
# 1. Use $self->_find_transform($name_or_uri) to get a $transform hashref
# describing the transform.
# 2. Optionally plug some extra parameters into the $transform hashref.
# 3. Call $self->_apply_transform($transform, $input)
#
sub register_transform_method {
my($class, $name, $uri) = @_;
my $transform = {
name => $name,
uri => $uri,
method => '_apply_transform_' . $name,
};
$transforms_by_name{$name} = $transform;
$transforms_by_uri{$uri} = $transform;
}
sub _find_transform {
my($self, $identifier) = @_;
my $transform = $transforms_by_name{$identifier}
// $transforms_by_uri{$identifier}
or die "Unknown transform: '$identifier'";
return { %$transform };
}
sub _apply_transform {
my($self, $transform, $input) = @_;
my $method = $transform->{method} or die "transform does not include method";
die "Unimplemented transformation method: '$method'" unless $self->can($method);
return $self->$method($input, $transform);
}
sub _xcdom_from_xml {
my($self, $xml, @namespaces) = @_;
my $parser = XML::LibXML->new();
my $doc = $parser->parse_string($xml);
my $xc = XML::LibXML::XPathContext->new($doc->documentElement);
$xc->registerNs( NS_PAIR('ds') );
while(@namespaces) {
my $prefix = shift @namespaces;
my $uri = shift @namespaces;
$xc->registerNs($prefix, $uri);
}
return $xc;
}
lib/Authen/NZRealMe/XMLSig.pm view on Meta::CPAN
Takes an XML document and an optional element ID value and returns a string of
XML with a digital signature added. The XML document can be provided either as
a string or as an XML::LibXML DOM object.
Named options can be provided to customise the transforms and algorithms used
when generating the signature block. In particular, the C<references> option
can be used to supply a list of multiple references. In which case, a value
of C<undef> should be provided for the C<$target_id> parameter:
my $refs = [
{ ref_uri => $first_uri_value },
{ ref_uri => $second_uri_value },
];
$signer->sign($xml, undef, references => $ref);
Each reference can include a list of namespace prefixes to be included in the
canonicalisation transform.
=head2 create_detached_signature( $plaintext, $eol )
Takes a plaintext string, calculates a signature using the private key (and
optionally the signarture algorithm) passed to the constructor and returns a
base64-encoded string. The C<$eol> parameter can be used to specify the
line-ending character used in the base64 encoding process (default: \n).
=head2 verify_detached_signature( $plaintext, $base64_sig )
Takes a plaintext string, and a base64-encoded signature. Verifies the
signature using the public key or certificate supplied to the constructor.
Returns true if the signature is valid, and false otherwise.
=head2 verify( $xml, $selector_xpath, @namespaces )
Takes an XML string (or DOM object); searches for signature elements; verifies
the provided signature and message digest for each; and returns true on success.
The caller would then typically use C<find_verified_element()> to ensure that
subsequent queries target element which were covered by a verified signature.
The C<$selector_xpath> can be used to identify which C<< <Signature> >> element
should be checked. This is particularly useful with documents containing
multiple signatures where each was creaated using a different key (since the
API only provides for a single cert/public key). If not provided, a default
selector of C<'//ds:Signature'> will be used.
If provided, the value for C<$selector_xpath> may use 'ds' as a namespace
prefix for digital signature elements. If any other namespaces are required,
the following arguments are assumed to be C<< prefix => uri >> pairs. For
example this code might be used to verify signatures in the SOAP envelope while
ignoring signatures in the payload withing the SOAP body:
my $selector = '//ds:Signature[not(ancestor::soap12:Body)]';
$verifier->verify($xml, $selector, NS_PAIR('soap12'));
If the provided document does not contain any signatures which match the
selector, or if an invalid signature is found, an exception will be thrown.
=head2 find_verified_element( $xc, $xpath )
This method is a wrapper around the standard L<XML::LibXML> C<findnodes()>
method, which also confirms that the matching node is within one of the signed
fragments which were identified by the earlier call to the C<verify()> method.
The caller must provide an L<XML::LibXML::XPathContext> object with registered
URIs for all namespace prefixes required by the supplied XPath expression.
=head2 ignore_bad_signatures( )
Calling this method after C<verify()> will tag the root element as a verified
fragment. This is used in cases where signature verification failed (perhaps
because the other party has just replaced their signing key) but you wish to
proceed with calling C<find_verified_element()> anyway.
=head2 key_text( )
Returns the private key text which will be used to initialise the
L<Crypt::OpenSSL::RSA> object used for generating signatures.
=head2 pub_key_text( )
Returns the public key text used to initialise the L<Crypt::OpenSSL::RSA>
object used for verifing signatures.
=head2 pub_cert_text( )
If the public key is being extracted from an X509 certificate, this method is
used to retrieve the text which defines the certificate.
=head2 register_transform_method( $name, $uri )
Used internally to register methods for implementing transformation algorithms
so that they can be looked by by URI. May be called by a subclass to add
support for additional algorithms.
=head2 register_signature_methods( $name, $uri )
Used internally to register methods for implementing creation and verification
of signatures using specific algorithms so that they can be looked by by URI.
May be called by a subclass to add support for additional algorithms.
=head1 SUPPORTED SIGNATURE ALGORITHMS
=head3 rsa_sha1
=head3 rsa_sha256
=head1 SEE ALSO
See L<Authen::NZRealMe> for documentation index.
=head1 LICENSE AND COPYRIGHT
Copyright (c) 2010-2022 Enrolment Services, New Zealand Electoral Commission
Written by Grant McLean E<lt>grant@catalyst.net.nzE<gt>
This program is free software; you can redistribute it and/or modify it
under the terms of either: the GNU General Public License as published
by the Free Software Foundation; or the Artistic License.
See http://dev.perl.org/licenses/ for more information.
=cut
( run in 2.126 seconds using v1.01-cache-2.11-cpan-364913b4093 )