File Coverage

blib/lib/Net/OpenID/Association.pm
Criterion Covered Total %
statement 16 18 88.8
branch n/a
condition n/a
subroutine 6 6 100.0
pod n/a
total 22 24 91.6


line stmt bran cond sub pod time code
1 6     6   28 use strict;
  6         11  
  6         138  
2 6     6   30 use Carp ();
  6         9  
  6         291  
3              
4             ############################################################################
5             package Net::OpenID::Association;
6             {
7             $Net::OpenID::Association::VERSION = '1.16';
8             }
9             use fields (
10 6         32 'server', # author-identity identity provider endpoint
11             'secret', # the secret for this association
12             'handle', # the 255-character-max ASCII printable handle (33-126)
13             'expiry', # unixtime, adjusted, of when this association expires
14             'type', # association type
15 6     6   27 );
  6         10  
16              
17 6     6   6225 use Storable ();
  6         21010  
  6         138  
18 6     6   38 use Digest::SHA ();
  6         12  
  6         107  
19 6     6   4947 use Net::OpenID::Common;
  0            
  0            
20             use URI::Escape qw(uri_escape);
21              
22             ################################################################
23             # Association and Session Types
24              
25             # session type hash
26             # name - by which session type appears in URI parameters (required)
27             # len - number of bytes in digest (undef => accommodates any length)
28             # fn - DH hash function (undef => secret passed in the clear)
29             # https - must use encrypted connection (boolean)
30             #
31             my %_session_types = ();
32             # {versionkey}{name} -> session type
33             # {NO}{versionkey} -> no-encryption stype for this version
34             # {MAX}{versionkey} -> strongest encryption stype for this version
35              
36             # association type hash
37             # name - by which assoc. type appears in URI parameters (required)
38             # len - number of bytes in digest (required)
39             # macfn - MAC hash function (required)
40             #
41             my %_assoc_types = ();
42             # {versionkey}{name} -> association type
43             # {MAX}{versionkey} -> strongest encryption atype for this version
44              
45             my %_assoc_macfn = ();
46             # {name} -> hmac function
47             # ... since association types in the cache are only listed by name
48             # and don't say what version they're from. Which should not matter
49             # as long as the macfn associated with a given association type
50             # name does not change in future versions.
51              
52             # (floating point version numbers scare me)
53             # (also version key can stay the same if the
54             # set of hash functions available does not change)
55             # ('NO' and 'MAX' should never be used as version keys)
56             sub _version_key_from_numeric {
57             my ($numeric_protocol_version) = @_;
58             return $numeric_protocol_version < 2 ? 'v1' : 'v2';
59             }
60             # can SESSION_TYPE be used with ASSOC_TYPE?
61             sub _compatible_stype_atype {
62             my ($s_type, $a_type) = @_;
63             return !$s_type->{len} || $s_type->{len} == $a_type->{len};
64             }
65              
66             {
67             # Define the no-encryption session types.
68             # In version 1.1/1.0, the no-encryption session type
69             # is the default and never explicitly specified
70             $_session_types{$_->[0]}{$_->[1]}
71             = $_session_types{NO}{$_->[0]}
72             = {
73             name => $_->[1],
74             https => 1,
75             }
76             foreach ([v1 => ''], [v2 => 'no-encryption']);
77              
78             # Define SHA-based session and association types
79             my %_sha_fns =
80             (
81             SHA1 => { minv => 'v1', # first version group in which this appears
82             v1max => 1, # best encryption for v1
83             len => 20, # number of bytes in digest
84             fn => \&Digest::SHA::sha1,
85             macfn => \&Digest::SHA::hmac_sha1, },
86             SHA256 => { minv => 'v2',
87             v2max => 1, # best encryption for v2
88             len => 32,
89             fn => \&Digest::SHA::sha256,
90             macfn => \&Digest::SHA::hmac_sha256, },
91             # doubtless there will be more...
92             );
93             foreach my $SHAX (keys %_sha_fns) {
94             my $s = $_sha_fns{$SHAX};
95             my $a_type = { name => "HMAC-${SHAX}", map {$_,$s->{$_}} qw(len macfn) };
96             my $s_type = { name => "DH-${SHAX}", map {$_,$s->{$_}} qw(len fn) };
97             my $seen_minv = 0;
98             foreach my $v (qw(v1 v2)) {
99             $seen_minv = 1 if $v eq $s->{minv};
100             next unless $seen_minv;
101             $_assoc_types{$v}{$a_type->{name}} = $a_type;
102             $_session_types{$v}{$s_type->{name}} = $s_type;
103             if ($s->{"${v}max"}) {
104             $_assoc_types{MAX}{$v} = $a_type;
105             $_session_types{MAX}{$v} = $s_type;
106             }
107             }
108             $_assoc_macfn{$a_type->{name}} = $a_type->{macfn};
109             }
110             }
111             ################################################################
112              
113             sub new {
114             my Net::OpenID::Association $self = shift;
115             $self = fields::new( $self ) unless ref $self;
116             my %opts = @_;
117             for my $f (qw( server secret handle expiry type )) {
118             $self->{$f} = delete $opts{$f};
119             }
120             Carp::croak("unknown options: " . join(", ", keys %opts)) if %opts;
121             return $self;
122             }
123              
124             sub handle {
125             my $self = shift;
126             die if @_;
127             $self->{'handle'};
128             }
129              
130             sub secret {
131             my $self = shift;
132             die if @_;
133             $self->{'secret'};
134             }
135              
136             sub type {
137             my $self = shift;
138             die if @_;
139             $self->{'type'};
140             }
141              
142             sub generate_signature {
143             my Net::OpenID::Association $self = shift;
144             my $string = shift;
145             return OpenID::util::b64($_assoc_macfn{$self->type}->($string, $self->secret));
146             }
147              
148             sub server {
149             my Net::OpenID::Association $self = shift;
150             Carp::croak("Too many parameters") if @_;
151             return $self->{server};
152             }
153              
154             sub expired {
155             my Net::OpenID::Association $self = shift;
156             return time() > $self->{'expiry'};
157             }
158              
159             sub usable {
160             my Net::OpenID::Association $self = shift;
161             return 0 unless $self->{'handle'} =~ /^[\x21-\x7e]{1,255}$/;
162             return 0 unless $self->{'expiry'} =~ /^\d+$/;
163             return 0 unless $self->{'secret'};
164             return 0 if $self->expired;
165             return 1;
166             }
167              
168              
169             # server_assoc(CSR, SERVER, FORCE_REASSOCIATE, OPTIONS...)
170             #
171             # Return an association for SERVER (provider), whether already
172             # cached and not yet expired, or freshly negotiated.
173             # Return undef if no local storage/cache is available
174             # or negotiation fails for whatever reason,
175             # in which case the caller goes into dumb consumer mode.
176             # FORCE_REASSOCIATE true => ignore the cache
177             # OPTIONS... are passed to new_server_assoc()
178             #
179             sub server_assoc {
180             my ($csr, $server, $force_reassociate, @opts) = @_;
181              
182             # closure to return undef (dumb consumer mode) and log why
183             my $dumb = sub {
184             $csr->_debug("server_assoc: dumb mode: $_[0]");
185             return undef;
186             };
187              
188             my $cache = $csr->cache;
189             return $dumb->("no_cache") unless $cache;
190              
191             unless ($force_reassociate) {
192             # try first from cached association handle
193             if (my $handle = $cache->get("shandle:$server")) {
194             my $assoc = handle_assoc($csr, $server, $handle);
195              
196             if ($assoc && $assoc->usable) {
197             $csr->_debug("Found association from cache (handle=$handle)");
198             return $assoc;
199             }
200             }
201             }
202              
203             # make a new association
204             my ($assoc, $err, $retry) = new_server_assoc($csr, $server, @opts);
205             return $dumb->($err)
206             if $err;
207             ($assoc, $err) = new_server_assoc($csr, $server, @opts, %$retry)
208             if $retry;
209             return $dumb->($err || 'second_retry')
210             unless $assoc;
211              
212             my $ahandle = $assoc->handle;
213             $cache->set("hassoc:$server:$ahandle", Storable::freeze({%$assoc}));
214             $cache->set("shandle:$server", $ahandle);
215              
216             # now we test that the cache object given to us actually works. if it
217             # doesn't, it'll also fail later, making the verify fail, so let's
218             # go into stateless (dumb mode) earlier if we can detect this.
219             $cache->get("shandle:$server")
220             or return $dumb->("cache_broken");
221              
222             return $assoc;
223             }
224              
225             # new_server_assoc(CSR, SERVER, OPTIONS...)
226             #
227             # Attempts to negotiate a fresh association from C<$server> (provider)
228             # with session and association types determined by OPTIONS...
229             # (accepts protocol_version and all assoc_options from Consumer,
230             # however max_encrypt and session_no_encrypt_https are ignored
231             # if assoc_type and session_type are passed directly as hashes)
232             # Returns
233             # ($association) on success
234             # (undef, $error_message) on unrecoverable failure
235             # (undef, undef, {retry...}) if identity provider suggested
236             # alternate session/assoc types in an error response
237             #
238             sub new_server_assoc {
239             my ($csr, $server, %opts) = @_;
240             my $server_is_https = lc($server) =~ m/^https:/;
241             my $protocol_version = delete $opts{protocol_version} || 1;
242             my $version_key = _version_key_from_numeric($protocol_version);
243             my $allow_eavesdropping = (delete $opts{allow_eavesdropping} || 0) && $protocol_version < 2;
244              
245             my $a_maxencrypt = delete $opts{max_encrypt} || 0;
246             my $s_noencrypt = delete $opts{session_no_encrypt_https} && $server_is_https;
247              
248             my $s_type = delete $opts{session_type} || "DH-SHA1";
249             unless (ref $s_type) {
250             if ($s_noencrypt) {
251             $s_type = $_session_types{NO}{$version_key};
252             }
253             elsif ($a_maxencrypt) {
254             $s_type = $_session_types{MAX}{$version_key};
255             }
256             }
257              
258             my $a_type = delete $opts{assoc_type} || "HMAC-SHA1";
259             unless (ref $a_type) {
260             $a_type = $_assoc_types{MAX}{$version_key}
261             if $a_maxencrypt;
262             }
263              
264             Carp::croak("unknown options: " . join(", ", keys %opts)) if %opts;
265              
266             $a_type = $_assoc_types{$version_key}{$a_type} unless ref $a_type;
267             Carp::croak("unknown association type") unless $a_type;
268              
269             $s_type = $_session_types{$version_key}{$s_type} unless ref $s_type;
270             Carp::croak("unknown session type") unless $s_type;
271              
272             my $error = sub { return (undef, $_[0].($_[1]?" ($_[1])":'')); };
273              
274             return $error->("incompatible_session_type")
275             unless _compatible_stype_atype($s_type, $a_type);
276              
277             return $error->("https_required")
278             if $s_type->{https} && !$server_is_https && !$allow_eavesdropping;
279              
280             my %post = ( "openid.mode" => "associate" );
281             $post{'openid.ns'} = OpenID::util::version_2_namespace()
282             if $protocol_version == 2;
283             $post{'openid.assoc_type'} = $a_type->{name};
284             $post{'openid.session_type'} = $s_type->{name} if $s_type->{name};
285              
286             my $dh;
287             if ($s_type->{fn}) {
288             $dh = OpenID::util::get_dh();
289             $post{'openid.dh_consumer_public'} = OpenID::util::int2arg($dh->pub_key);
290             }
291              
292             my $req = HTTP::Request->new(POST => $server);
293             $req->header("Content-Type" => "application/x-www-form-urlencoded");
294             $req->content(join("&", map { "$_=" . uri_escape($post{$_}) } keys %post));
295              
296             $csr->_debug("Associate mode request: " . $req->content);
297              
298             my $ua = $csr->ua;
299             my $res = $ua->request($req);
300              
301             return $error->("http_no_response") unless $res;
302              
303             my $recv_time = time();
304             my $content = $res->content;
305             my %args = OpenID::util::parse_keyvalue($content);
306             $csr->_debug("Response to associate mode: [$content] parsed = " . join(",", %args));
307              
308             my $r_a_type = $_assoc_types{$version_key}{$args{'assoc_type'}};
309             my $r_s_type = $_session_types{$version_key}{$args{'session_type'}||''};
310              
311             unless ($res->is_success) {
312             # direct error
313             return $error->("http_failure_no_associate")
314             if ($protocol_version < 2);
315             return $error->("http_direct_error")
316             unless $args{'error_code'} eq 'unsupported_type';
317             return (undef,undef,{assoc_type => $r_a_type, session_type => $r_s_type})
318             if $r_a_type && $r_s_type && ($r_a_type != $a_type || $r_s_type != $s_type);
319             return $error->("unsupported_type");
320             }
321             return $error->("unknown_assoc_type",$args{'assoc_type'})
322             unless $r_a_type;
323             return $error->("unknown_session_type",$args{'session_type'})
324             unless $r_s_type;
325             return $error->("wrong_assoc_type",$r_a_type->{name})
326             unless $a_type == $r_a_type;
327             return $error->("wrong_session_type",$r_s_type->{name})
328             unless $s_type == $r_s_type || ($protocol_version < 2);
329              
330             # protocol version 1.1
331             my $expires_in = $args{'expires_in'};
332              
333             # protocol version 1.0 (DEPRECATED)
334             if (! $expires_in) {
335             if (my $issued = OpenID::util::w3c_to_time($args{'issued'})) {
336             my $expiry = OpenID::util::w3c_to_time($args{'expiry'});
337             my $replace_after = OpenID::util::w3c_to_time($args{'replace_after'});
338              
339             # seconds ahead (positive) or behind (negative) the provider is
340             $expires_in = ($replace_after || $expiry) - $issued;
341             }
342             }
343              
344             # between 1 second and 2 years
345             return $error->("bogus_expires_in")
346             unless $expires_in > 0 && $expires_in < 63072000;
347              
348             my $ahandle = $args{'assoc_handle'};
349              
350             my $secret;
351             unless ($r_s_type->{fn}) {
352             $secret = OpenID::util::d64($args{'mac_key'});
353             }
354             else {
355             my $server_pub = OpenID::util::arg2int($args{'dh_server_public'});
356             my $dh_sec = $dh->compute_secret($server_pub);
357             $secret = OpenID::util::d64($args{'enc_mac_key'})
358             ^ $r_s_type->{fn}->(OpenID::util::int2bytes($dh_sec));
359             }
360             return $error->("bad_secret_length")
361             if $r_s_type->{len} && length($secret) != $r_s_type->{len};
362              
363             my %assoc = (
364             handle => $ahandle,
365             server => $server,
366             secret => $secret,
367             type => $r_a_type->{name},
368             expiry => $recv_time + $expires_in,
369             );
370              
371             return Net::OpenID::Association->new( %assoc );
372             }
373              
374             # returns association, or undef if it can't be found
375             sub handle_assoc {
376             my ($csr, $server, $handle) = @_;
377              
378             # closure to return undef (dumb consumer mode) and log why
379             my $dumb = sub {
380             $csr->_debug("handle_assoc: dumb mode: $_[0]");
381             return undef;
382             };
383              
384             return $dumb->("no_handle") unless $handle;
385              
386             my $cache = $csr->cache;
387             return $dumb->("no_cache") unless $cache;
388              
389             my $frozen = $cache->get("hassoc:$server:$handle");
390             return $dumb->("not_in_cache") unless $frozen;
391              
392             my $param = eval { Storable::thaw($frozen) };
393             return $dumb->("not_a_hashref") unless ref $param eq "HASH";
394              
395             return Net::OpenID::Association->new( %$param );
396             }
397              
398             sub invalidate_handle {
399             my ($csr, $server, $handle) = @_;
400             my $cache = $csr->cache
401             or return;
402             $cache->set("hassoc:$server:$handle", "");
403             }
404              
405             1;
406              
407             __END__