File Coverage

blib/lib/Parse/SSH2/PublicKey.pm
Criterion Covered Total %
statement 97 124 78.2
branch 9 26 34.6
condition 3 10 30.0
subroutine 15 15 100.0
pod 6 6 100.0
total 130 181 71.8


line stmt bran cond sub pod time code
1             package Parse::SSH2::PublicKey;
2              
3 8     8   27285 use strict;
  8         21  
  8         296  
4 8     8   48 use warnings;
  8         15  
  8         319  
5 8     8   7176 use autodie qw/open close/;
  8         148557  
  8         57  
6 8     8   13781 use Moo;
  8         147147  
  8         53  
7 8     8   25908 use MIME::Base64;
  8         6490  
  8         696  
8 8     8   57 use Carp qw/confess/;
  8         19  
  8         423  
9 8     8   47 no warnings qw/substr uninitialized/;
  8         16  
  8         24884  
10              
11             our $VERSION = 0.01;
12              
13             =head1 NAME
14              
15             Parse::SSH2::PublicKey - Parse SSH2 public keys in either SECSH or OpenSSH format.
16              
17             =head1 VERSION
18              
19             Version 0.01
20              
21             =cut
22              
23             =head1 PURPOSE
24              
25             Different implementations of SSH (OpenSSH, SSH Tectia, PuTTY, etc) use different key formats. For example, for public key authentication, OpenSSH will accept an authorized_keys file that holds all keys, whereas the ssh.com proprietary implementation wants an authorized_keys/ *directory* with a file for each key!
26              
27             This module was created to assist sysadmins in converting from one SSH implementation to another.
28              
29             =head1 SYNOPSIS
30              
31             use Parse::SSH2::PublicKey;
32              
33             my $auth_key = "$ENV{HOME}/.ssh/authorized_keys";
34             my @keys = Parse::SSH2::PublicKey->parse_file($auth_key);
35              
36             for my $k ( @keys ) {
37             print $k->secsh();
38             # or ->openssh()
39             }
40              
41             ...
42              
43             my $dir = "$ENV{HOME}/.ssh2/authorized_keys/";
44             my @files = glob("$dir/*pub");
45             my @keys = map { Parse::SSH2::PublicKey->parse_file($_) } @files;
46              
47             for my $k ( @keys ) {
48             print $k->openssh();
49             }
50              
51             =cut
52              
53             has key => (
54             is => 'ro',
55             isa => sub {},
56             default => sub { '' },
57             );
58              
59             has type => (
60             is => 'ro',
61             isa => sub {
62             my $t = shift;
63             confess "type must be 'public' or 'private'"
64             unless grep { $t eq $_ } qw (public private);
65             },
66             default => sub { '' },
67             );
68              
69             has encryption => (
70             is => 'ro',
71             isa => sub {
72             my $enc = shift;
73             confess "must be 'ssh-rsa' or 'ssh-dss'"
74             unless grep { $enc eq $_ } qw/ssh-rsa ssh-dss/;
75             },
76             default => sub { '' },
77             );
78              
79             has headers => (
80             is => 'ro',
81             isa => sub { die "'headers' attribute must be a hashref." unless (ref $_[0] eq 'HASH'); },
82             default => sub { return {} },
83             );
84              
85             has header_order => (
86             is => 'ro',
87             isa => sub { die "'header_order' attribute must be an arrayref." unless (ref $_[0] eq 'ARRAY'); },
88             default => sub { return [] },
89             );
90              
91             =head1 METHODS
92              
93             =head2 new()
94              
95             Creates an Parse::SSH2::PublicKey object. Not intended to be used directly.
96             Instead, this is called internally by parse(),
97             which returns an array of objects.
98              
99             =head2 parse()
100              
101             Accepts a block of text and parses out SSH2 public keys in both OpenSSH and SECSH format.
102             Returns an *array* of Parse::SSH2::PublicKey objects. Class method to be used instead of new().
103              
104             =cut
105              
106             sub parse {
107 20     20 1 5460 my $class = shift;
108 20         38 my $data = shift;
109              
110 20         28 my @objs;
111              
112 20         75 while ( length($data) > 0 ) {
113              
114 28         35 my $entire_key;
115              
116             # OpenSSH format -- all on one line.
117 28 100       582 if ( $data =~ m%((ssh-rsa|ssh-dss)\ ([A-Z0-9a-z/+=]+)\s*([^\n]*))%gsmx ) {
    100          
    50          
118 12         33 $entire_key = $1;
119              
120             # TODO: pull encryption from base64 key data, not here... just to be safe.
121 12         27 my $encryption = $2;
122 12         28 my $key = $3;
123 12         26 my $comment = $4;
124 12         20 my $type = 'public';
125              
126 12         19 my ($headers, $header_order);
127 12 50       34 if ( defined $comment ) {
128 12         27 push @$header_order, 'Comment';
129 12         33 $headers->{ 'Comment' } = $comment;
130             }
131              
132 12         270 push @objs, $class->new( key => $key,
133             #comment => $headers->{Comment} || '',
134             type => $type,
135             #subject => '',
136             header_order => $header_order,
137             headers => $headers,
138             encryption => $encryption );
139              
140             }
141              
142             # SECSH pubkey format
143             elsif ( $data =~ m/(----\ BEGIN\ SSH2\ PUBLIC\ KEY\ ----(?:\n|\r|\f)
144             (.*?)(?:\n|\r|\f)
145             ----\ END\ SSH2\ PUBLIC\ KEY\ ----)/gsmx ) {
146 11         32 $entire_key = $1;
147 11         22 my $type = 'public';
148 11         35 my $keydata = $2;
149              
150 11         38 my ($key, $header_order, $headers) = _extract_secsh_key_headers( $keydata );
151              
152             # ==================================================================
153             # TODO: this needs to be factored out into a separate subroutine
154             # which decodes ALL the base64 key data (modulus and exponent also)
155 11         117 my $octets = decode_base64( $key );
156 11         59 my $dlen = unpack("N4", substr($octets,0,4));
157 11         44 my $encryption = unpack("A" . $dlen, substr($octets, 4, $dlen));
158             # ==================================================================
159              
160 11         204 push @objs, $class->new( key => $key,
161             #comment => $headers->{Comment} || '',
162             type => $type,
163             #subject => $headers->{Subject} || '',
164             header_order => $header_order,
165             headers => $headers,
166             encryption => $encryption );
167             }
168              
169             # note: OpenSSH private keys are parsed & removed from $data,
170             # but objects are not created
171             elsif ( $data =~ m/(-+BEGIN\ (DSA|RSA)\ PRIVATE\ KEY-+(?:\n|\r|\f)
172             (.*?)(?:\n|\r|\f)
173             -+END\ (DSA|RSA)\ PRIVATE\ KEY-+)/gsmx ) {
174 0         0 $entire_key = $1;
175 0         0 my $encryption = $2;
176 0         0 my $keydata = $3;
177 0         0 my $type = 'private';
178              
179 0         0 my ($key, $header_order, $headers) = _extract_secsh_key_headers( $keydata );
180              
181 0 0       0 $encryption = ($encryption eq 'RSA') ? 'ssh-rsa' :
    0          
182             ($encryption eq 'DSA') ? 'ssh-dss' : '';
183             # Because the regex match requires RSA or DSA for this value,
184             # $encryption should never get set to the empty string here.
185             }
186             else {
187             # no keys found and/or invalid key data
188 5         11 last;
189             }
190              
191 23         2002 $data =~ s/\Q$entire_key\E(?:\n|\f|\r)?//gsmx;
192             }
193              
194 20         92 return @objs;
195             }
196              
197             =head2 parse_file()
198              
199             Convenience method which opens a file and calls C on the contents.
200              
201             =cut
202              
203             sub parse_file {
204 6     6 1 10532 my $class = shift;
205 6         11 my $infile = shift;
206              
207 6         30 open (my $in , '<', $infile);
208             # now handled by autodie
209              
210 6         7417 my $data = do { local $/; <$in> };
  6         24  
  6         160  
211 6         23 close $in;
212 6         1975 return $class->parse( $data );
213             }
214              
215             =head2 secsh()
216              
217             Returns an SSH public key in SECSH format (as specified in RFC4716).
218             Preserves headers and the order of headers.
219              
220             See L.
221              
222             =cut
223              
224             sub secsh {
225 1     1 1 546 my $self = shift;
226              
227 1         1 my $str;
228 1 50       8 if ( $self->type eq 'public' ) {
    0          
229 1         1 $str = "---- BEGIN SSH2 PUBLIC KEY ----\n";
230 1         3 my @headers = @{$self->header_order()};
  1         5  
231 1 50       3 if ( scalar(@headers) ) {
232 1         2 for my $h ( @headers ) {
233 2         13 $str .= join("\\\n", split(/\n/, _chop_long_string(
234             $h . ': ' . $self->headers->{$h}, 70 ))) . "\n";
235             }
236             }
237 1         5 $str .= _chop_long_string( $self->key, 70 ) . "\n";
238 1         3 $str .= "---- END SSH2 PUBLIC KEY ----\n";
239             }
240              
241             # TODO: remove support for private keys...
242             elsif ( $self->type eq 'private' ) {
243 0         0 $str = "---- BEGIN SSH2 ENCRYPTED PRIVATE KEY ----\n";
244              
245             # Not sure if 'Proc-Type' and 'DEK-Info' are valid headers
246             # for Tectia private keys...
247              
248 0         0 my @headers = @{$self->header_order()};
  0         0  
249 0   0     0 @headers = grep { !/Proc-Type/ && !/DEK-Info/ } @headers;
  0         0  
250 0 0       0 if ( scalar(@headers) ) {
251 0         0 for my $h ( @headers ) {
252 0         0 $str .= join("\\\n", split(/\n/, _chop_long_string(
253             $h . ': ' . $self->headers->{$h}, 70 ))) . "\n";
254             }
255             }
256              
257 0         0 $str .= _chop_long_string( $self->key, 70 ) . "\n";
258 0         0 $str .= "---- END SSH2 ENCRYPTED PRIVATE KEY ----\n";
259             }
260              
261 1         4 return $str;
262             }
263              
264              
265             =head2 openssh()
266              
267             Returns an SSH public key in OpenSSH format. Preserves 'comment' field
268             parsed from either SECSH or OpenSSH.
269              
270             =cut
271              
272             sub openssh {
273 1     1 1 2 my $self = shift;
274              
275 1         2 my $str;
276              
277 1 50       6 if ( $self->type eq 'public' ) {
    0          
278 1         9 $str = $self->encryption . ' ' .
279             $self->key . ' ' .
280             $self->comment . "\n";
281             }
282              
283             # TODO: remove support for private keys...
284             elsif ( $self->type eq 'private' ) {
285 0         0 $str = "-----BEGIN " . $self->encryption . " PRIVATE KEY-----\n";
286              
287             # Not sure if 'Comment' and 'Subject' are valid headers
288             # for OpenSSH private keys...
289              
290 0         0 my @headers = @{$self->header_order()};
  0         0  
291 0   0     0 @headers = grep { !/Comment/ && !/Subject/ } @headers;
  0         0  
292 0 0       0 if ( scalar(@headers) ) {
293 0         0 for my $h ( @headers ) {
294 0         0 $str .= join("\\\n", split(/\n/, _chop_long_string(
295             $h . ': ' . $self->headers->{$h}, 64 ))) . "\n";
296             }
297 0         0 $str .= "\n";
298             }
299 0         0 $str .= _chop_long_string( $self->key, 64 ) . "\n";
300 0         0 $str .= "-----END " . $self->encryption . " PRIVATE KEY-----\n";
301             }
302              
303 1         5 return $str;
304             }
305              
306             =head2 comment()
307              
308             Convenience method for $k->headers->{Comment}. Returns the Comment header value or the empty string.
309              
310             =cut
311              
312             sub comment {
313 14     14 1 9602 my $self = shift;
314 14   100     152 return $self->headers->{Comment} || '';
315             }
316              
317             =head2 subject()
318              
319             Convenience method for $k->headers->{Subject}. Returns the Subject header value or the empty string.
320              
321             =cut
322              
323             sub subject {
324 3     3 1 992 my $self = shift;
325 3   50     22 return $self->headers->{Subject} || '';
326             }
327              
328             =head1 ATTRIBUTES
329              
330             =head2 encryption
331              
332             Either 'ssh-rsa' or 'ssh-dss', for RSA and DSA keys, respectively.
333              
334             =head2 header_order
335              
336             Order of headers parsed from SECSH-format keys. See also
337             L.
338              
339             =head2 headers
340              
341             Hashref containing headers parsed from SECSH-format keys.
342             See also L.
343              
344             =head2 key
345              
346             The actual base64-encoded key data.
347              
348             =head2 type
349              
350             Either 'public' or 'private', but private keys aren't currently
351             supported. Obsolete. (Or perhaps ahead of it's time.)
352              
353             =cut
354              
355             # internal method, not intended for use outside this module
356             # Breaks long string into chunks of MAXLEN length,
357             # separated by "\n"
358             sub _chop_long_string {
359 3     3   3 my $string = shift;
360 3         4 my $maxlen = shift;
361              
362 3         4 my @lines;
363 3         1 my $index = 0;
364 3         10 while ( my $line = substr($string, $index, $maxlen) ) {
365 9         10 push @lines, $line;
366 9         18 $index += $maxlen;
367             }
368 3         17 return join("\n", @lines);
369             }
370              
371              
372             # internal method, not intended for use outside this module
373             sub _extract_secsh_key_headers {
374 11     11   20 my $data = shift;
375 11         15 my %headers;
376             my @header_order;
377              
378             # Match all up to a "\n" not prefixed with a '\' char
379             # -- a "\\\n" sequence should be ignored/slurped.
380             # This regex uses negative look-behind.
381 11         117 while ( $data =~ m/^((?:\w|-)+):\ (.*?)(?
382             {
383 12         27 my $header_tag = $1;
384 12         25 my $header_val = $2;
385              
386             # Don't change \\\n to '' here, because we need this
387             # to match the header for stripping it from the key
388             # data below.
389 12         33 $headers{ $header_tag } = $header_val;
390 12         108 push @header_order, $header_tag;
391             }
392              
393 11         40 for my $h ( keys %headers ) {
394             # strip headers from main key data,
395             # now that they have been saved in %headers
396 12         390 $data =~ s/\Q$h: $headers{$h}\E(?:\n|\f|\r)//gsm;
397             }
398              
399             # NOW strip the '\\\n' from the header values
400 11         71 $_ =~ s/\\(\n|\f|\r)//g for values %headers;
401              
402 11         193 (my $key = $data) =~ s/\n|\f|\r//g;
403              
404 11         104 return ($key, \@header_order, \%headers);
405             }
406              
407              
408             1;
409              
410             __END__