The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Convert::SSH2;

use 5.010;
use strict;
use warnings;

use Moo;
use MIME::Base64 qw(decode_base64);
use File::Slurp qw(read_file write_file);
use Carp qw(confess);
use Try::Tiny;
use Class::Load qw(load_class);
use Math::BigInt try => 'GMP';

=head1 NAME

Convert::SSH2 - Convert SSH2 RSA keys to other formats

=head1 VERSION

Version 0.01

=cut

our $VERSION = '0.01';

=head1 SYNOPSIS

    use 5.010;
    use Convert::SSH2;

    my $converter = Convert::SSH2->new('~/.ssh/id_rsa.pub');
    # Automatically calls parse()

    # Use default PKCS#1 format
    say $converter->format_output();

    $converter->write('/my/pub/key.pem');


=head1 PURPOSE

This library converts SSH2 style RSA public keys to other representations like PKCS#1.
This is useful if you want to use these public keys with other Perl cryptography 
libraries like L<Crypt::RSA> or L<Crypt::OpenSSL::RSA>.

=head1 ATTRIBUTES

=over

=item key

Required. Read-only.  The key material.  Attempts to be DWIMish. If this is a file path,
it will be used to load the file contents into memory.  If it's a buffer, it will use
the buffer contents.

=back

=cut

has 'key' => (
    is => 'ro',
    required => 1,
);

=over

=item format

Read-only. The output format. Current supports:

=over

=item * pkcs1

This format looks like

  -----BEGIN RSA PUBLIC KEY-----
  ...
  -----END RSA PUBLIC KEY-----

=item * pkcs8

This format looks like

  -----BEGIN PUBLIC KEY-----
  ...
  -----END PUBLIC KEY-----

=back

You can add your own format by implementing a L<Convert::SSH2::Format::Base> module.

=back

=cut

has 'format' => (
    is => 'ro',
    isa => sub {
        my $n = shift;
        confess "$n is not a supported format." unless 
            grep { $n eq $_ } qw(
                pkcs1
                pkcs8
            );
    },
    default => sub { 'pkcs1' },
);

has '_buffer' => (
    is => 'rw',
);

has '_output' => (
    is => 'rw',
    predicate => '_has_output',
);

has '_e' => (
    is => 'rw',
);

has '_n' => (
    is => 'rw',
);

=head1 METHODS 

Generally, errors are fatal.  Use L<Try::Tiny> if you want more graceful error handling.

=over

=item new()

Constructor. Takes any of the attributes as arguments.  You may optionally call new
with either a buffer or a path, and the class will assume that it is the C<key>
material.

The object automatically attempts to parse C<key> data after instantiation.

=back

=cut 

# Support single caller argument
around BUILDARGS => sub {
    my $orig = shift;
    my $class = shift;

    if ( @_ == 1 ) {
        unshift @_, "key";
    }

    $class->$orig(@_);
};

sub BUILD {
    my $self = shift;

    my $buf;
    unless ( $self->key =~ /\n/ ) {
        if ( -e $self->key ) {
            $buf = read_file($self->key, { binmode => ':raw' });
        }
        else {
            $buf = $self->key;
        }
    }
    else {
        $buf = $self->key;
    }

    $buf =~ s/\n//g;
    $self->_buffer( (split / /, $buf)[1] );

    $self->parse();
}

=over 

=item parse()

This method takes the Base64 encoded portion of the SSH key, decodes it, and then converts the
data inside of it into three components: the id string ('ssh-rsa'), the public exponent ('e'),
and the modulus ('n'). By default it looks for the Base64 data inside the instantiated object, 
but you can optionally pass in a Base64 string.

It uses L<Math::BigInt> to hold large integers such as 'n' or 'e'. If you don't have 
C<libgmp> installed, it will fall back to pure perl automatically, but there will be a speed 
penalty.

Returns a true value on success.

=back

=cut

sub parse {
    my $self = shift;
    my $b64 = shift || $self->_buffer;

    confess "I don't have a buffer!" unless $b64;

    my $blob = decode_base64($b64) or confess "Couldn't Base64 decode buffer"; 

    my @parts;
    my $len = length($blob);
    my $pos = 0;

    while ( $pos < $len ) {
        # There's probably a clever way to do this, but this works ok.
        my $dlen = hex( unpack "H*", substr($blob, $pos, 4) );
        $pos += 4;
        push @parts, substr($blob, $pos, $dlen);
        $pos += $dlen;
    }

    # ok $parts[0] should be a string, $parts[1] the exponent 'e', and $parts[2] the modulus 'n'

    confess "Invalid key type" unless unpack "A*", $parts[0] eq 'ssh-rsa';

    my $e;
    if ( length($parts[1]) <= 4 ) {
        $e = hex( unpack "H*", $parts[1] );
    }
    else {
        $e = Math::BigInt->new( ("0x" . unpack "H*", $parts[1]) );
    }

    my $n = Math::BigInt->new( ("0x" . unpack "H*", $parts[2]) );

    $self->_e( $e );
    $self->_n( $n );

    return 1;
}

=over

=item format_output()

Using a subclass of L<Convert::SSH2::Format::Base>, generate a representation of the SSH2 key.

Returns a formatted string.

=back

=cut

sub format_output {
    my $self = shift;
    my $format = "Convert::SSH2::Format::" . uc($self->format);

    try {
        load_class $format;
    }
    catch {
        confess "Couldn't load formatter $format: $_";
    };

    my $fmt = $format->new(
            e => $self->_e,
            n => $self->_n,
    );

    my $str = $fmt->generate();

    $self->_output( $str );

    return $str;
}

=over

=item write()

Convenience method to write a formatted key representation to a file.

Expects a pathname.  Automatically calls C<format_output()> if necessary.
If the output format has been generated already, it uses a cached version.

Returns a true value on success.

=back

=cut

sub write {
    my $self = shift;
    my $path = shift;

    confess "I don't have a path" unless $path;

    if ( -e $path ) {
        confess "$path seems to exist already?";
    }

    $self->format_output() unless $self->_has_output;

    write_file( $path, { noclobber => 1, binmode => ":raw" }, $self->_output );
}

=head1 AUTHOR

Mark Allen, C<< <mrallen1 at yahoo.com> >>

=head1 BUGS

Please report any bugs or feature requests to C<bug-convert-ssh2 at rt.cpan.org>, or through
the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Convert-SSH2>.  I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

=head1 SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc Convert::SSH2

You can also look for information at:

=over 4

=item * RT: CPAN's request tracker (report bugs here)

L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Convert-SSH2>

=item * AnnoCPAN: Annotated CPAN documentation

L<http://annocpan.org/dist/Convert-SSH2>

=item * CPAN Ratings

L<http://cpanratings.perl.org/d/Convert-SSH2>

=item * MetaCPAN

L<https://metacpan.org/dist/Convert-SSH2>

=item * Git Hub

L<https://github.com/mrallen1/Convert-SSH2>

=back

=head1 SEE ALSO

L<Convert::SSH2::Format::Base>, L<Convert::SSH2::Format::PKCS1>

L<Converting OpenSSH public keys|http://blog.oddbit.com/2011/05/converting-openssh-public-keys.html>

=head1 ACKNOWLEDGEMENTS

Mark Cavage

=head1 LICENSE AND COPYRIGHT

Copyright 2012 Mark Allen.

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

1; # End of Convert::SSH2