Skip to content

Commit

Permalink
Add basic support for ssh authentication
Browse files Browse the repository at this point in the history
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/.
  • Loading branch information
kraih committed Oct 26, 2023
1 parent 4c81994 commit 7c1d4e0
Show file tree
Hide file tree
Showing 7 changed files with 92 additions and 17 deletions.
5 changes: 5 additions & 0 deletions cavil.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions lib/Cavil.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
48 changes: 34 additions & 14 deletions lib/Cavil/OBS.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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};
Expand All @@ -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);
Expand All @@ -68,43 +71,41 @@ 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
return $linfo unless $match && !$linfo->{match};
}
}
$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;
Expand All @@ -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'));
Expand Down
21 changes: 20 additions & 1 deletion lib/Cavil/Util.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
7 changes: 7 additions & 0 deletions t/ssh/cavil-test.key
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAQ1ktyOCFDMUIV9GfaZio8NNPT09mHcG0Wpx3bo7xwzAAAAJBnE+yjZxPs
owAAAAtzc2gtZWQyNTUxOQAAACAQ1ktyOCFDMUIV9GfaZio8NNPT09mHcG0Wpx3bo7xwzA
AAAEAnJpCOHj1O0O8oCygQJ6pjDT+827VkQXq98zApns/VYRDWS3I4IUMxQhX0Z9pmKjw0
09PT2YdwbRanHdujvHDMAAAACmNhdmlsQHRlc3QBAgM=
-----END OPENSSH PRIVATE KEY-----
1 change: 1 addition & 0 deletions t/ssh/cavil-test.key.pub
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBDWS3I4IUMxQhX0Z9pmKjw009PT2YdwbRanHdujvHDM cavil@test
21 changes: 19 additions & 2 deletions t/util.t
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;

0 comments on commit 7c1d4e0

Please sign in to comment.