Skip to content

Commit 50f5c8a

Browse files
committed
Fixes #12 - multiple signing keys in metadata
1 parent 519b3ac commit 50f5c8a

13 files changed

+515
-58
lines changed

lib/Net/SAML2/Binding/Redirect.pm

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,15 @@ that the encoding should be in the standard uppercase (%2F not %2f)
9393
Specifies that the IdP response sent to the HTTP-Redirect is double encoded.
9494
The double encoding requires it to be decoded prior to processing.
9595
96+
=item B<debug>
97+
98+
Output extra debugging information
99+
96100
=back
97101
98102
=cut
99103

100-
has 'cert' => (isa => 'Str', is => 'ro', required => 1);
104+
has 'cert' => (isa => 'ArrayRef[Str]', is => 'ro', required => 0, predicate => 'has_cert');
101105
has 'url' => (isa => Uri, is => 'ro', required => 0, coerce => 1, predicate => 'has_url');
102106
has 'key' => (isa => 'Str', is => 'ro', required => 0, predicate => 'has_key');
103107

@@ -129,6 +133,12 @@ has 'sls_double_encoded_response' => (
129133
default => 0
130134
);
131135

136+
has debug => (
137+
is => 'ro',
138+
isa => 'Bool',
139+
required => 0,
140+
);
141+
132142
=for Pod::Coverage BUILD
133143
134144
=cut
@@ -140,9 +150,32 @@ sub BUILD {
140150
croak("Need to have an URL specified") unless $self->has_url;
141151
croak("Need to have a key specified") unless $self->has_key;
142152
}
153+
if ($self->param eq 'SAMLResponse') {
154+
croak("Need to have a cert specified") unless $self->has_cert;
155+
}
143156
# other params don't need to have these per-se
144157
}
145158

159+
# BUILDARGS
160+
161+
# Earlier versions expected the cert to be a string. However, metadata
162+
# can include multiple signing certificates so the $idp->cert is now
163+
# expected to be an arrayref to the certificates. To avoid breaking existing
164+
# applications this changes the the cert to an arrayref if it is not
165+
# already an array ref.
166+
167+
around BUILDARGS => sub {
168+
my $orig = shift;
169+
my $self = shift;
170+
171+
my %params = @_;
172+
if ($params{cert} && ref($params{cert}) ne 'ARRAY') {
173+
$params{cert} = [$params{cert}];
174+
}
175+
176+
return $self->$orig(%params);
177+
};
178+
146179
=head2 sign( $request, $relaystate )
147180
148181
Signs the given request, and returns the URL to which the user's
@@ -185,6 +218,37 @@ sub sign {
185218
return $u->as_string;
186219
}
187220

221+
sub _verified {
222+
my ($self, $sigalg, $signed, $sig) = @_;
223+
224+
foreach my $crt (@{$self->cert}) {
225+
my $cert = Crypt::OpenSSL::X509->new_from_string($crt);
226+
my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($cert->pubkey);
227+
228+
if ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256') {
229+
$rsa_pub->use_sha256_hash;
230+
} elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha224') {
231+
$rsa_pub->use_sha224_hash;
232+
} elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384') {
233+
$rsa_pub->use_sha384_hash;
234+
} elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512') {
235+
$rsa_pub->use_sha512_hash;
236+
} elsif ($sigalg eq 'http://www.w3.org/2000/09/xmldsig#rsa-sha1') {
237+
$rsa_pub->use_sha1_hash;
238+
} else {
239+
warn "Unsupported Signature Algorithim: $sigalg" if ($self->debug);
240+
}
241+
242+
if ($rsa_pub->verify($signed, $sig)) {
243+
return 1;
244+
}
245+
246+
warn "Unable to verify with " . $cert->subject if ($self->debug);
247+
}
248+
249+
die "bad sig";
250+
}
251+
188252
=head2 verify( $url )
189253
190254
Decode a Redirect binding URL.
@@ -200,9 +264,6 @@ sub verify {
200264
# verify the response
201265
my $sigalg = $u->query_param('SigAlg');
202266

203-
my $cert = Crypt::OpenSSL::X509->new_from_string($self->cert);
204-
my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($cert->pubkey);
205-
206267
my $signed;
207268
my $saml_request;
208269
my $sig = $u->query_param_delete('Signature');
@@ -242,21 +303,7 @@ sub verify {
242303

243304
$sig = decode_base64($sig);
244305

245-
if ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256') {
246-
$rsa_pub->use_sha256_hash;
247-
} elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha224') {
248-
$rsa_pub->use_sha224_hash;
249-
} elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384') {
250-
$rsa_pub->use_sha384_hash;
251-
} elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512') {
252-
$rsa_pub->use_sha512_hash;
253-
} elsif ($sigalg eq 'http://www.w3.org/2000/09/xmldsig#rsa-sha1') {
254-
$rsa_pub->use_sha1_hash;
255-
} else {
256-
die "Unsupported Signature Algorithim: $sigalg";
257-
}
258-
259-
die "bad sig" unless $rsa_pub->verify($signed, $sig);
306+
$self->_verified($sigalg, $signed, $sig);
260307

261308
# unpack the SAML request
262309
my $deflated = decode_base64($saml_request);

lib/Net/SAML2/IdP.pm

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ has 'cacert' => (isa => 'Maybe[Str]', is => 'ro', required => 1);
6666
has 'sso_urls' => (isa => 'HashRef[Str]', is => 'ro', required => 1);
6767
has 'slo_urls' => (isa => 'Maybe[HashRef[Str]]', is => 'ro');
6868
has 'art_urls' => (isa => 'Maybe[HashRef[Str]]', is => 'ro');
69-
has 'certs' => (isa => 'HashRef[Str]', is => 'ro', required => 1);
69+
has 'certs' => (isa => 'HashRef[ArrayRef[Str]]', is => 'ro', required => 1);
7070
has 'sls_force_lcase_url_encoding' => (isa => 'Bool', is => 'ro', required => 0);
7171
has 'sls_double_encoded_response' => (isa => 'Bool', is => 'ro', required => 0);
7272

@@ -180,10 +180,15 @@ sub new_from_xml {
180180
}
181181
}
182182

183+
my @certs = ();
184+
183185
for my $key (
184186
$xpath->findnodes('//md:EntityDescriptor/md:IDPSSODescriptor/md:KeyDescriptor'))
185187
{
186-
my $use = $key->getAttribute('use') || 'signing';
188+
my @uses;
189+
push (@uses, $key->getAttribute('use') || 'signing');
190+
push (@uses, 'encryption') if !$key->getAttribute('use');
191+
187192

188193
$key->setNamespace('http://www.w3.org/2000/09/xmldsig#', 'ds');
189194

@@ -204,17 +209,20 @@ sub new_from_xml {
204209
$text = join "\n", @lines;
205210

206211
# form a PEM certificate
207-
$data->{Cert}->{$use}
208-
= sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n",
209-
$text);
212+
for my $use (@uses) {
213+
my $pem->{$use}
214+
= sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n",
215+
$text);
216+
push (@certs, $pem);
217+
}
210218
}
211219

212220
my $self = $class->new(
213221
entityid => $xpath->findvalue('//md:EntityDescriptor/@entityID'),
214222
sso_urls => $data->{SSO},
215223
slo_urls => $data->{SLO} || {},
216224
art_urls => $data->{Art} || {},
217-
certs => $data->{Cert},
225+
certs => \@certs,
218226
cacert => $args{cacert},
219227
sls_force_lcase_url_encoding => $args{sls_force_lcase_url_encoding},
220228
sls_double_encoded_response => $args{sls_double_encoded_response},
@@ -229,28 +237,50 @@ sub new_from_xml {
229237
return $self;
230238
}
231239

232-
=head2 BUILD ( hashref of the parameters passed to the constructor )
233-
234-
Called after the object is created to validate the IdP using the cacert
235-
236-
=cut
237-
238-
sub BUILD {
239-
my($self) = @_;
240+
# BUILDARGS ( hashref of the parameters passed to the constructor )
241+
#
242+
# Called after the object is created to validate the IdP using the cacert
243+
#
244+
245+
around BUILDARGS => sub {
246+
my $orig = shift;
247+
my $self = shift;
248+
249+
my %params = @_;
250+
251+
if ($params{cacert}) {
252+
my $ca = Crypt::OpenSSL::Verify->new($params{cacert}, { strict_certs => 0, });
253+
254+
my $verified = 0;
255+
my %errors;
256+
my %certs;
257+
258+
for my $pem (@{ $params{certs} }) {
259+
for my $use (keys %{$pem}) {
260+
my @tmpcrt;
261+
my $cert = Crypt::OpenSSL::X509->new_from_string($pem->{$use});
262+
## BUGBUG this is failing for valid things ...
263+
eval { $ca->verify($cert) };
264+
if ($@) {
265+
$errors{$cert->fingerprint_sha256} = $@;
266+
next;
267+
}
268+
$verified = 1;
269+
push @tmpcrt, $pem->{$use};
270+
271+
$certs{$use} = \@tmpcrt;
272+
}
273+
}
240274

241-
if ($self->cacert) {
242-
my $ca = Crypt::OpenSSL::Verify->new($self->cacert, { strict_certs => 0, });
275+
$params{certs} = \%certs;
243276

244-
for my $use (keys %{$self->certs}) {
245-
my $cert = Crypt::OpenSSL::X509->new_from_string($self->certs->{$use});
246-
## BUGBUG this is failing for valid things ...
247-
eval { $ca->verify($cert) };
248-
if ($@) {
249-
warn "Can't verify IdP '$use' cert: $@\n";
250-
}
277+
if (!$verified) {
278+
warn "Can't verify IdP signing cert: ", %errors, "\n";
251279
}
252280
}
253-
}
281+
282+
return $self->$orig(%params);
283+
};
254284

255285
=head2 sso_url( $binding )
256286
@@ -290,7 +320,13 @@ sub art_url {
290320

291321
=head2 cert( $use )
292322
293-
Returns the IdP's certificate for the given use (e.g. C<signing>).
323+
Returns the IdP's certificates for the given use (e.g. C<signing>).
324+
325+
IdP's are generated from the metadata it is possible for multiple certificates
326+
to be contained in the metadata and therefore possible for them to be there to
327+
be multiple verified certs in $self->certs. At this point any certs in the IdP
328+
have been verified and are valid for the specified use. All certs are of type
329+
$use are returned.
294330
295331
=cut
296332

@@ -310,6 +346,7 @@ sub binding {
310346
my($self, $name) = @_;
311347

312348
my $bindings = {
349+
post => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
313350
redirect => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
314351
soap => 'urn:oasis:names:tc:SAML:2.0:bindings:SOAP',
315352
};

t/01-create-idp.t

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,11 @@ is(
9595
'Has correct art_url'
9696
);
9797

98-
looks_like_a_cert($idp->cert('signing'));
98+
foreach my $use (keys %{$idp->certs}) {
99+
for my $cert (@{$idp->cert($use)}) {
100+
looks_like_a_cert($cert);
101+
}
102+
};
99103

100104
is(
101105
$idp->entityid,
@@ -152,7 +156,11 @@ is(
152156
'Has correct art_url'
153157
);
154158

155-
looks_like_a_cert($idp->cert('signing'), 'Looks like signing certificate');
159+
foreach my $use (keys %{$idp->certs}) {
160+
for my $cert (@{$idp->cert($use)}) {
161+
looks_like_a_cert($cert);
162+
}
163+
};
156164

157165
is(
158166
$idp->entityid,
@@ -275,7 +283,11 @@ XML
275283
'Has correct art_url'
276284
);
277285

278-
looks_like_a_cert($idp->cert('signing'), 'Looks like signing certificate');
286+
foreach my $use (keys %{$idp->certs}) {
287+
for my $cert (@{$idp->cert($use)}) {
288+
looks_like_a_cert($cert);
289+
}
290+
};
279291

280292
is(
281293
$idp->entityid,

t/05-soap-binding.t

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,13 @@ is(
2424
'SLO url is correct'
2525
);
2626

27-
my $idp_cert = $idp->cert('signing');
28-
looks_like_a_cert($idp_cert);
27+
my $idp_cert;
28+
foreach my $use (keys %{$idp->certs}) {
29+
for my $cert (@{$idp->cert($use)}) {
30+
$idp_cert = $cert;
31+
looks_like_a_cert($cert);
32+
}
33+
};
2934

3035
my $nameid = 'user-to-log-out';
3136
my $session = 'session-to-log-out';

t/06-redirect-binding.t

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ my $idp = Net::SAML2::IdP->new_from_xml(
1414
xml => $metadata,
1515
cacert => 't/cacert.pem'
1616
);
17-
1817
isa_ok($idp, "Net::SAML2::IdP");
1918

2019
my $sso_url = $idp->sso_url($idp->binding('redirect'));
@@ -55,18 +54,30 @@ is($relaystate, 'http://return/url', "Relay state shows correct uri");
5554
lives_ok(
5655
sub {
5756
my $binding = Net::SAML2::Binding::Redirect->new(
58-
cert => $sp->cert,
57+
cert => $idp->cert('signing'),
5958
param => 'SAMLResponse',
6059
);
6160
isa_ok($binding, "Net::SAML2::Binding::Redirect");
6261
},
6362
"We can create a binding redirect without key/url for verification purposes"
6463
);
6564

65+
lives_ok(
66+
sub {
67+
my $binding = Net::SAML2::Binding::Redirect->new(
68+
param => 'SAMLRequest',
69+
url => 'https://foo.example.com',
70+
key => $sp->key,
71+
);
72+
isa_ok($binding, "Net::SAML2::Binding::Redirect");
73+
},
74+
"We do not need a cert to sign a SAMLRequest"
75+
);
76+
6677
throws_ok(
6778
sub {
6879
Net::SAML2::Binding::Redirect->new(
69-
cert => $sp->cert,
80+
cert => $idp->cert('signing'),
7081
key => $sp->key,
7182
);
7283
},
@@ -77,7 +88,6 @@ throws_ok(
7788
throws_ok(
7889
sub {
7990
Net::SAML2::Binding::Redirect->new(
80-
cert => $sp->cert,
8191
url => 'https://foo.example.com',
8292
);
8393
},

t/11-more-metadata.t

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,12 @@ is(
3030
'Found SSO artifact binding'
3131
);
3232

33-
looks_like_a_cert($idp->cert('signing'));
33+
foreach my $use (keys %{$idp->certs}) {
34+
for my $cert (@{$idp->cert($use)}) {
35+
looks_like_a_cert($cert);
36+
}
37+
};
38+
3439

3540
is(
3641
$idp->entityid,

0 commit comments

Comments
 (0)