From 7c1d4e006a0d76e82907ac8872a58dc12bb977ca Mon Sep 17 00:00:00 2001 From: Sebastian Riedel Date: Tue, 24 Oct 2023 13:36:55 +0200 Subject: [PATCH] Add basic support for ssh authentication This is the IBS specific implementation of ssh authentication as described in the blog post https://www.suse.com/c/multi-factor-authentication-on-suses-build-service/. --- cavil.conf | 5 +++++ lib/Cavil.pm | 6 +++++ lib/Cavil/OBS.pm | 48 ++++++++++++++++++++++++++++------------ lib/Cavil/Util.pm | 21 +++++++++++++++++- t/ssh/cavil-test.key | 7 ++++++ t/ssh/cavil-test.key.pub | 1 + t/util.t | 21 ++++++++++++++++-- 7 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 t/ssh/cavil-test.key create mode 100644 t/ssh/cavil-test.key.pub diff --git a/cavil.conf b/cavil.conf index d7ed177399..a25c34a6d0 100644 --- a/cavil.conf +++ b/cavil.conf @@ -13,6 +13,11 @@ well_known_url => 'https://id.opensuse.org/openidc/.well-known/openid-configuration' }, + obs => { + user => 'NAME', + ssh_key => 'SSH_PRIVATE_KEY_PATH' + }, + tokens => [], pg => 'postgresql://sri@/legaldb-local', diff --git a/lib/Cavil.pm b/lib/Cavil.pm index 724a94b136..99df4fcd62 100644 --- a/lib/Cavil.pm +++ b/lib/Cavil.pm @@ -58,6 +58,12 @@ sub startup ($self) { $ENV{MOJO_TMPDIR} = $config->{tmp_dir} if $config->{tmp_dir}; $self->max_request_size(262144000); + # Optional OBS credentials + if (my $obs = $config->{obs}) { + $self->obs->user($obs->{user}) if $obs->{user}; + $self->obs->ssh_key($obs->{ssh_key}) if $obs->{ssh_key}; + } + # Short logs for systemd $self->log->short(1) if $self->mode eq 'production'; diff --git a/lib/Cavil/OBS.pm b/lib/Cavil/OBS.pm index 47424a552d..7e4a94b6b6 100644 --- a/lib/Cavil/OBS.pm +++ b/lib/Cavil/OBS.pm @@ -16,13 +16,16 @@ package Cavil::OBS; use Mojo::Base -base, -signatures; -use Carp 'croak'; +use Carp qw(croak); use Digest::MD5; -use Mojo::File 'path'; +use Mojo::File qw(path); use Mojo::UserAgent; use Mojo::URL; +use Cavil::Util qw(obs_ssh_auth); -has ua => sub { +has ssh_hosts => sub { ['api.suse.de'] }; +has ssh_key => sub { die 'Missing ssh key' }; +has ua => sub { my $ua = Mojo::UserAgent->new(inactivity_timeout => 600); $ua->on( start => sub ($ua, $tx) { @@ -36,15 +39,15 @@ has ua => sub { ); return $ua; }; +has user => sub { die 'Missing ssh user' }; sub download_source ($self, $api, $project, $pkg, $dir, $options = {}) { $dir = path($dir)->make_path; - my $ua = $self->ua; # List files my $url = _url($api, 'public', 'source', $project, $pkg)->query(expand => 1); $url->query([rev => $options->{rev}]) if defined $options->{rev}; - my $res = $ua->get($url)->result; + my $res = $self->_get($url); croak "$url: " . $res->code unless $res->is_success; my $dom = $res->dom; my $srcmd5 = $dom->at('directory')->{srcmd5}; @@ -58,7 +61,7 @@ sub download_source ($self, $api, $project, $pkg, $dir, $options = {}) { my $url = _url($api, 'public', 'source', $project, $pkg, $file->{name}); $url->query([expand => 1, rev => $srcmd5]); - my $res = $ua->get($url)->result; + my $res = $self->_get($url); croak "$url: " . $res->code unless $res->is_success; my $target = $dir->child($file->{name}); $res->content->asset->move_to($target); @@ -68,35 +71,33 @@ sub download_source ($self, $api, $project, $pkg, $dir, $options = {}) { } sub package_info ($self, $api, $project, $pkg, $options = {}) { - my $ua = $self->ua; - my $url = _url($api, 'public', 'source', $project, $pkg)->query(view => 'info'); $url->query([rev => $options->{rev}]) if defined $options->{rev}; - my $res = $ua->get($url)->result; + my $res = $self->_get($url); croak "$url: " . $res->code unless $res->is_success; my $source = $res->dom->at('sourceinfo'); my $info = {srcmd5 => $source->{srcmd5}, verifymd5 => $source->{verifymd5}, package => $pkg}; # Find the deepest link - my $linfo = _find_link_target($ua, $api, $project, $pkg, $options->{rev} || $source->{srcmd5}); + my $linfo = $self->_find_link_target($api, $project, $pkg, $options->{rev} || $source->{srcmd5}); $info->{package} = $linfo->{package} if $linfo; return $info; } -sub _find_link_target ($ua, $api, $project, $pkg, $lrev) { +sub _find_link_target ($self, $api, $project, $pkg, $lrev) { my $url = _url($api, 'public', 'source', $project, $pkg); my $query = {expand => 1}; $query->{rev} = $lrev if defined $lrev; $url->query($query); - my $res = $ua->get($url)->result; + my $res = $self->_get($url); return undef unless $res->is_success; # Check if we're on track my $match = grep { $_->{name} eq "$pkg.spec" } $res->dom->find('entry')->map('attr')->each; if (my $link = $res->dom->at('linkinfo')) { - my $linfo = _find_link_target($ua, $api, $link->{project}, $link->{package}, $link->{srcmd5}); + my $linfo = $self->_find_link_target($api, $link->{project}, $link->{package}, $link->{srcmd5}); if ($linfo) { # If the sub package has no matching spec file, we drop it @@ -104,7 +105,7 @@ sub _find_link_target ($ua, $api, $project, $pkg, $lrev) { } } $url = _url($api, 'public', 'source', $project, $pkg, '_meta'); - $res = $ua->get($url)->result; + $res = $self->_get($url); # This is severe as we already checked the sources croak "$url: " . $res->code unless $res->is_success; @@ -114,6 +115,25 @@ sub _find_link_target ($ua, $api, $project, $pkg, $lrev) { return {%linfo, package => $rn->text}; } +sub _get ($self, $url) { + my $ua = $self->ua; + + # "api.suse.de" does not have public API endpoints + my $host = $url->host; + my $path = $url->path; + my $hosts = $self->ssh_hosts; + shift @{$path->parts} if (grep { $host eq $_ } @$hosts) && $path->parts->[0] eq 'public'; + + my $tx = $ua->get($url); + + # "api.suse.de" needs ssh authentication + my $res = $tx->res; + $tx = $ua->get($url, {Authorization => obs_ssh_auth($res->headers->www_authenticate, $self->user, $self->ssh_key)}) + if $res->code == 401; + + return $tx->result; +} + sub _md5 ($file) { my $md5 = Digest::MD5->new; $md5->addfile(path($file)->open('r')); diff --git a/lib/Cavil/Util.pm b/lib/Cavil/Util.pm index 1d1d2cb4f5..ddf18426ef 100644 --- a/lib/Cavil/Util.pm +++ b/lib/Cavil/Util.pm @@ -26,7 +26,10 @@ use Spooky::Patterns::XS; use Text::Glob 'glob_to_regex'; $Text::Glob::strict_wildcard_slash = 0; -our @EXPORT_OK = qw(buckets slurp_and_decode load_ignored_files lines_context paginate parse_exclude_file read_lines); +our @EXPORT_OK = ( + qw(buckets slurp_and_decode load_ignored_files lines_context obs_ssh_auth paginate parse_exclude_file), + qw(read_lines ssh_sign) +); my $MAX_FILE_SIZE = 30000; @@ -103,6 +106,16 @@ sub load_ignored_files ($db) { return \%ignored_file_res; } +sub obs_ssh_auth ($challenge, $user, $key) { + die "Unexpected OBS challenge: $challenge" unless $challenge =~ /realm="([^"]+)".*headers="\(created\)"/; + my $realm = $1; + + my $now = time; + my $signature = ssh_sign($key, $realm, "(created): $now"); + + return qq{Signature keyId="$user",algorithm="ssh",signature="$signature",headers="(created)",created="$now"}; +} + sub paginate ($results, $options) { my $total = @$results ? $results->[0]{total} : 0; delete $_->{total} for @$results; @@ -146,4 +159,10 @@ sub read_lines ($path, $start_line, $end_line) { return $text; } +sub ssh_sign ($key, $realm, $value) { + + # One-liner copied from https://www.suse.com/c/multi-factor-authentication-on-suses-build-service/ + return qx/ssh-keygen -Y sign -f "$key" -q -n "$realm" < <(echo -n "$value") | tail -n +2 | head -n -1 | tr -d "\n"/; +} + 1; diff --git a/t/ssh/cavil-test.key b/t/ssh/cavil-test.key new file mode 100644 index 0000000000..cb099452b6 --- /dev/null +++ b/t/ssh/cavil-test.key @@ -0,0 +1,7 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACAQ1ktyOCFDMUIV9GfaZio8NNPT09mHcG0Wpx3bo7xwzAAAAJBnE+yjZxPs +owAAAAtzc2gtZWQyNTUxOQAAACAQ1ktyOCFDMUIV9GfaZio8NNPT09mHcG0Wpx3bo7xwzA +AAAEAnJpCOHj1O0O8oCygQJ6pjDT+827VkQXq98zApns/VYRDWS3I4IUMxQhX0Z9pmKjw0 +09PT2YdwbRanHdujvHDMAAAACmNhdmlsQHRlc3QBAgM= +-----END OPENSSH PRIVATE KEY----- diff --git a/t/ssh/cavil-test.key.pub b/t/ssh/cavil-test.key.pub new file mode 100644 index 0000000000..ad44bb9838 --- /dev/null +++ b/t/ssh/cavil-test.key.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBDWS3I4IUMxQhX0Z9pmKjw009PT2YdwbRanHdujvHDM cavil@test diff --git a/t/util.t b/t/util.t index 0f03ef2a35..1d9fbdafb6 100644 --- a/t/util.t +++ b/t/util.t @@ -16,9 +16,11 @@ use Mojo::Base -strict; use Test::More; -use Mojo::File; +use Mojo::File qw(curfile); use Mojo::JSON qw(decode_json); -use Cavil::Util qw(buckets lines_context parse_exclude_file); +use Cavil::Util qw(buckets lines_context obs_ssh_auth parse_exclude_file ssh_sign); + +my $PRIVATE_KEY = curfile->dirname->child('ssh', 'cavil-test.key')->to_string; subtest 'buckets' => sub { is_deeply buckets([1 .. 10], 3), [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10]], 'right buckets'; @@ -47,4 +49,19 @@ subtest 'parse_exclude_file' => sub { is_deeply parse_exclude_file('t/exclude-files/empty.exclude', 'whatever'), []; }; +subtest 'ssh_sign' => sub { + my $signature = ssh_sign($PRIVATE_KEY, 'realm', 'message'); + like $signature, qr/^[-A-Za-z0-9+\/]+={0,3}$/, 'valid Base64 encoded signature'; + isnt ssh_sign($PRIVATE_KEY, 'realm2', 'message'), $signature, 'different signature'; + isnt ssh_sign($PRIVATE_KEY, 'realm', 'message2'), $signature, 'different signature'; + is ssh_sign($PRIVATE_KEY, 'realm', 'message'), $signature, 'identical signature'; +}; + +subtest 'obs_ssh_auth' => sub { + my $auth_header + = obs_ssh_auth('Signature realm="Use your developer account",headers="(created)"', 'user', $PRIVATE_KEY); + like $auth_header, + qr/^Signature keyId="user",algorithm="ssh",signature="[-A-Za-z0-9+\/]+={0,3}",headers="\(created\)",created="\d+"$/; +}; + done_testing;