mirror of
https://github.com/ovh/the-bastion.git
synced 2025-01-10 17:30:51 +08:00
824 lines
30 KiB
PHP
824 lines
30 KiB
PHP
|
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
|
||
|
package OVH::Bastion;
|
||
|
|
||
|
use common::sense;
|
||
|
|
||
|
use File::Temp;
|
||
|
use Fcntl qw{ :mode :DEFAULT };
|
||
|
|
||
|
sub verify_piv {
|
||
|
my %params = @_;
|
||
|
my $key = $params{'key'};
|
||
|
my $keyCertificate = $params{'keyCertificate'};
|
||
|
my $attestationCertificate = $params{'attestationCertificate'};
|
||
|
|
||
|
my $fnret;
|
||
|
$fnret = OVH::Bastion::execute(must_succeed => 1, cmd => ['ovh-yubico-piv-checker', $key, $attestationCertificate, $keyCertificate]);
|
||
|
if (!$fnret || $fnret->value->{'sysret'} != 0) {
|
||
|
return R('KO_INVALID_PIV', "This SSH key failed PIV verification");
|
||
|
}
|
||
|
my $keyPivInfo;
|
||
|
eval {
|
||
|
require JSON;
|
||
|
$keyPivInfo = JSON::decode_json($fnret->value->{'stdout'}->[0]);
|
||
|
};
|
||
|
return R('OK', value => $keyPivInfo); # keyPivInfo can be undef if JSON decode failed, but the key is still a valid one
|
||
|
}
|
||
|
|
||
|
sub get_authorized_keys_from_file {
|
||
|
my %params = @_;
|
||
|
my $file = $params{'file'};
|
||
|
my $includeInvalid = $params{'includeInvalid'}; # also include keys that fail the sanity check
|
||
|
my $includePivDisabled = $params{'includePivDisabled'}; # also include keys that are commented with # NOTPIV
|
||
|
my $fnret;
|
||
|
my @result;
|
||
|
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'file'") if not $file;
|
||
|
|
||
|
if (open(my $fh, '<', $file)) {
|
||
|
my $i = 0;
|
||
|
my $state = 0;
|
||
|
my $pivAttestationCertificate;
|
||
|
my $pivKeyCertificate;
|
||
|
my $info;
|
||
|
while (my $line = <$fh>) {
|
||
|
$i++;
|
||
|
chomp $line;
|
||
|
next if $line =~ /^\s*$/; # ignore empty lines
|
||
|
if ($line =~ /^# INFO: (.+)/) {
|
||
|
$info = $1;
|
||
|
next;
|
||
|
}
|
||
|
elsif ($line eq '# PIV ATTESTATION CERTIFICATE:') {
|
||
|
$state = 1;
|
||
|
next;
|
||
|
}
|
||
|
elsif ($line eq '# PIV KEY CERTIFICATE:') {
|
||
|
$state = 2;
|
||
|
next;
|
||
|
}
|
||
|
elsif ($line =~ /^# (.+)/ && $state == 1) {
|
||
|
|
||
|
# state 1: we're currently reading an attestation cert
|
||
|
$pivAttestationCertificate .= $1 . "\n";
|
||
|
next;
|
||
|
}
|
||
|
elsif ($line =~ /^# (.+)/ && $state == 2) {
|
||
|
|
||
|
# state 2: we're currently reading a key cert
|
||
|
$pivKeyCertificate .= $1 . "\n";
|
||
|
next;
|
||
|
}
|
||
|
$state = 0;
|
||
|
my $pivDisabled = 0;
|
||
|
if ($includePivDisabled && $line =~ /^# NOTPIV (.+)/) {
|
||
|
$line = $1;
|
||
|
$pivDisabled = 1;
|
||
|
}
|
||
|
next if $line =~ /^\s*#/; # ignore comments
|
||
|
$fnret = OVH::Bastion::get_ssh_pub_key_info(pubKey => $line, way => 'ingress');
|
||
|
if (grep { $fnret->err eq $_ } qw{ KO_NOT_A_KEY KO_PRIVATE_KEY ERR_INTERNAL }) {
|
||
|
next unless $includeInvalid;
|
||
|
$fnret->{'value'} = {} if not ref $fnret->{'value'} eq 'HASH';
|
||
|
}
|
||
|
next if ($fnret->err eq 'KO_NOT_A_KEY' && !$fnret->value->{'line'}); # skip empty lines
|
||
|
my $key = $fnret->value;
|
||
|
|
||
|
$key->{'err'} = $fnret->err;
|
||
|
$key->{'index'} = $i;
|
||
|
$key->{'pivAttestationCertificate'} = $pivAttestationCertificate if $pivAttestationCertificate;
|
||
|
$key->{'pivKeyCertificate'} = $pivKeyCertificate if $pivKeyCertificate;
|
||
|
$key->{'info'} = $info if $info;
|
||
|
if ($pivAttestationCertificate && $pivKeyCertificate) {
|
||
|
$fnret = OVH::Bastion::verify_piv(key => $key->{'line'}, attestationCertificate => $pivAttestationCertificate, keyCertificate => $pivKeyCertificate);
|
||
|
$key->{'isPiv'} = ($fnret ? 1 : 0);
|
||
|
$key->{'pivInfo'} = $fnret->value if $fnret;
|
||
|
}
|
||
|
if ($includePivDisabled && $pivDisabled) {
|
||
|
$key->{'pivDisabled'} = 1;
|
||
|
}
|
||
|
push @result, $key;
|
||
|
undef $info;
|
||
|
undef $pivAttestationCertificate;
|
||
|
undef $pivKeyCertificate;
|
||
|
}
|
||
|
close($fh);
|
||
|
}
|
||
|
else {
|
||
|
return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open $file ($!)");
|
||
|
}
|
||
|
return R('OK', value => \@result);
|
||
|
}
|
||
|
|
||
|
sub put_authorized_keys_to_file {
|
||
|
my %params = @_;
|
||
|
my $file = $params{'file'};
|
||
|
my $data = $params{'data'};
|
||
|
my $account = $params{'account'}; # we need it to apply the proper rights
|
||
|
my $fnret;
|
||
|
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'file'") if not $file;
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'data'") if not $data;
|
||
|
if (ref $data ne 'ARRAY') {
|
||
|
return R('ERR_INVALID_PARAMETER', msg => "Argument 'data' should be an array");
|
||
|
}
|
||
|
|
||
|
my $newFile = $file . ".new";
|
||
|
if (open(my $fh, '>', $newFile)) {
|
||
|
foreach my $key (@$data) {
|
||
|
if ($key->{'info'}) {
|
||
|
print $fh "# INFO: " . $key->{'info'} . "\n";
|
||
|
}
|
||
|
if ($key->{'pivAttestationCertificate'}) {
|
||
|
my $toWrite = "PIV ATTESTATION CERTIFICATE:\n";
|
||
|
$toWrite .= $key->{'pivAttestationCertificate'};
|
||
|
$toWrite =~ s/^/# /mg;
|
||
|
chomp $toWrite;
|
||
|
print $fh $toWrite . "\n";
|
||
|
}
|
||
|
if ($key->{'pivKeyCertificate'}) {
|
||
|
my $toWrite = "PIV KEY CERTIFICATE:\n";
|
||
|
$toWrite .= $key->{'pivKeyCertificate'};
|
||
|
$toWrite =~ s/^/# /mg;
|
||
|
chomp $toWrite;
|
||
|
print $fh $toWrite . "\n";
|
||
|
}
|
||
|
print $fh $key->{'line'} . "\n\n";
|
||
|
}
|
||
|
close($fh);
|
||
|
}
|
||
|
else {
|
||
|
return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open $file ($!)");
|
||
|
}
|
||
|
|
||
|
if ($account) {
|
||
|
my (undef, undef, $uid, $gid) = getpwnam($account);
|
||
|
chown $uid, $gid, $newFile;
|
||
|
}
|
||
|
chmod 0644, $newFile;
|
||
|
|
||
|
if (!rename $file, $file . '.backup-' . time() . '-' . $$) {
|
||
|
return R('ERR_RENAME_FAILED', msg => "Couldn't rename old authorized keys file, aborting");
|
||
|
}
|
||
|
if (!rename $newFile, $file) {
|
||
|
return R('ERR_RENAME_FAILED', msg => "Couldn't replace authorized keys file, account left in a locked-out state!");
|
||
|
}
|
||
|
return R('OK');
|
||
|
}
|
||
|
|
||
|
sub get_ssh_pub_key_info {
|
||
|
my %params = @_;
|
||
|
my $pubKey = $params{'pubKey'};
|
||
|
my $file = $params{'file'};
|
||
|
my $noexec = $params{'noexec'};
|
||
|
my $way = $params{'way'};
|
||
|
my $fnret;
|
||
|
|
||
|
if (not $way) {
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing argument way in get_ssh_pub_key_info");
|
||
|
}
|
||
|
if ($way ne 'ingress' && $way ne 'egress') {
|
||
|
return R('ERR_INVALID_PARAMETER', msg => "Expected ingress or egress for argument way in get_ssh_pub_key_info");
|
||
|
}
|
||
|
$way = ucfirst($way);
|
||
|
|
||
|
$pubKey =~ s/[\r\n]//g;
|
||
|
|
||
|
if ($file) {
|
||
|
if (open(my $fh, '<', $file)) {
|
||
|
$pubKey = <$fh>;
|
||
|
close($fh);
|
||
|
}
|
||
|
else {
|
||
|
return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open specified file ($!)");
|
||
|
}
|
||
|
}
|
||
|
|
||
|
# some little sanity check
|
||
|
if ($pubKey =~ /PRIVATE KEY/) {
|
||
|
|
||
|
# n00b check
|
||
|
return R('KO_PRIVATE_KEY');
|
||
|
}
|
||
|
|
||
|
if ($pubKey !~ m{^\s*((\S+)\s+)?(ssh-dss|ssh-rsa|ecdsa-sha\d+-nistp\d+|ssh-ed\d+)\s+([a-zA-Z0-9/=+]+)(\s+(.{1,128}))?$}
|
||
|
|| length($pubKey) > 3000)
|
||
|
{
|
||
|
return R('KO_NOT_A_KEY', value => {line => $pubKey});
|
||
|
}
|
||
|
my ($prefix, $typecode, $base64, $comment) = ($2, $3, $4, $6);
|
||
|
my $line = "$typecode $base64";
|
||
|
$prefix = '' if not defined $prefix;
|
||
|
$line .= " " . $comment if $comment;
|
||
|
$line = $prefix . " " . $line if $prefix;
|
||
|
my @fromList;
|
||
|
if ($prefix =~ /^from=["']([^ "']+)/) {
|
||
|
@fromList = split /,/, $1;
|
||
|
}
|
||
|
my %return = (
|
||
|
prefix => $prefix,
|
||
|
typecode => $typecode,
|
||
|
base64 => $base64,
|
||
|
comment => $comment,
|
||
|
line => $line,
|
||
|
fromList => \@fromList,
|
||
|
);
|
||
|
|
||
|
# put that in a tempfile for ssh-keygen inspection
|
||
|
if (not $noexec) {
|
||
|
my $fh = File::Temp->new(UNLINK => 1);
|
||
|
my $filename = $fh->filename;
|
||
|
print {$fh} $typecode . " " . $base64;
|
||
|
close($fh);
|
||
|
$fnret = OVH::Bastion::execute(cmd => ['ssh-keygen', '-l', '-f', $filename]);
|
||
|
if ($fnret->is_err || not $fnret->value || ($fnret->value->{'sysret'} != 0 && $fnret->value->{'sysret'} != 1)) {
|
||
|
|
||
|
# sysret == 1 means ssh-keygen didn't recognize this key, handled below.
|
||
|
return R('ERR_SSH_KEYGEN_FAILED', msg => "Couldn't read the fingerprint of $filename (" . $fnret->msg . ")");
|
||
|
}
|
||
|
my $sshkeygen;
|
||
|
if ($fnret->err eq 'OK') {
|
||
|
$sshkeygen = $fnret->value->{'stdout'}->[0];
|
||
|
chomp $sshkeygen;
|
||
|
}
|
||
|
|
||
|
=cut
|
||
|
2048 01:c0:37:5e:b4:bf:00:b6:ef:d3:65:a7:5c:60:b1:81 john@doe (RSA)
|
||
|
521 af:84:cd:70:34:64:ca:51:b2:17:1a:85:3b:53:2e:52 john@doe (ECDSA)
|
||
|
1024 c0:4d:f7:bf:55:1f:95:59:be:7e:50:47:e4:81:c3:6a john@doe (DSA)
|
||
|
256 SHA256:Yggd7VRRbbivxkdVwrdt0HpqKNylMK91nNIU+RxndTI john@doe (ED25519)
|
||
|
=cut
|
||
|
|
||
|
if (defined $sshkeygen and $sshkeygen =~ /^(\d+)\s+(\S+)\s+(.+)\s+\(([A-Z0-9]+)\)$/) {
|
||
|
my ($size, $fingerprint, $comment2, $family) = ($1, $2, $3, $4);
|
||
|
$return{'size'} = $size + 0;
|
||
|
$return{'fingerprint'} = $fingerprint;
|
||
|
$return{'family'} = $family;
|
||
|
my @blacklistfiles = qw{ DSA-1024 DSA-2048 RSA-1024 RSA-2048 RSA-4096 };
|
||
|
if (grep { "$family-$size" eq $_ } @blacklistfiles) {
|
||
|
|
||
|
# check for vulnkeys
|
||
|
my $file = '/usr/share/ssh/blacklist.' . $family . '-' . $size;
|
||
|
if (-r $file && open(my $fh_blacklist, '<', $file)) {
|
||
|
my $shortfp = $fingerprint;
|
||
|
$shortfp =~ s/://g;
|
||
|
$shortfp =~ s/^.{12}//;
|
||
|
|
||
|
#print "looking for shortfingerprint=$shortfp...\n";
|
||
|
while (<$fh_blacklist>) {
|
||
|
/^\Q$shortfp\E$/ or next;
|
||
|
close($fh_blacklist);
|
||
|
return R('KO_VULNERABLE_KEY', value => \%return);
|
||
|
}
|
||
|
close($fh_blacklist);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
# check allowed algos and key size
|
||
|
my $allowedSshAlgorithms = OVH::Bastion::config("allowed${way}SshAlgorithms");
|
||
|
my $minimumRsaKeySize = OVH::Bastion::config("minimum${way}RsaKeySize");
|
||
|
if ($allowedSshAlgorithms && not grep { lc($return{'family'}) eq $_ } @{$allowedSshAlgorithms->value}) {
|
||
|
return R('KO_FORBIDDEN_ALGORITHM', value => \%return);
|
||
|
}
|
||
|
if ($minimumRsaKeySize && lc($return{'family'}) eq 'rsa' && $minimumRsaKeySize->value > $return{'size'}) {
|
||
|
return R('KO_KEY_SIZE_TOO_SMALL', value => \%return);
|
||
|
}
|
||
|
return R('OK', value => \%return);
|
||
|
}
|
||
|
else {
|
||
|
return R('KO_NOT_A_KEY', value => \%return);
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
# noexec is set, caller doesn't want us to call ssh-keygen
|
||
|
return R('OK', value => \%return);
|
||
|
}
|
||
|
return R('ERR_INTERNAL', value => \%return);
|
||
|
}
|
||
|
|
||
|
sub is_valid_public_key {
|
||
|
my %params = @_;
|
||
|
my $pubKey = $params{'pubKey'};
|
||
|
my $noexec = $params{'noexec'}; # don't run ssh-keygen in get_ssh_pub_key_info
|
||
|
my $way = $params{'way'};
|
||
|
|
||
|
my $fnret = R('KO_NOT_A_KEY', msg => "This is not a key", silent => 1);
|
||
|
|
||
|
if (defined $pubKey) {
|
||
|
$pubKey =~ tr/\r\n//d;
|
||
|
$fnret = OVH::Bastion::get_ssh_pub_key_info(pubKey => $pubKey, noexec => $noexec, way => $way);
|
||
|
}
|
||
|
|
||
|
if ($fnret->err eq 'KO_PRIVATE_KEY') {
|
||
|
|
||
|
# n00b check
|
||
|
$fnret->{'msg'} = <<EOS;
|
||
|
HOLY SH*T, did you just paste your PRIVATE KEY?!! You have no idea what you are doing, do you? No matter what, don't do that ever again.
|
||
|
Hint: in 'private key', the most important word is 'private', which means, well, you know, NOT PUBLIC.
|
||
|
EOS
|
||
|
}
|
||
|
elsif ($fnret->err eq 'KO_NOT_A_KEY') {
|
||
|
$fnret->{'msg'} = <<EOS;
|
||
|
This doesn't look like an SSH public key, accepted formats are RSA (>= 2048 bits)
|
||
|
and if supported by the OS, ECDSA and Ed25519.
|
||
|
EOS
|
||
|
}
|
||
|
elsif ($fnret->err eq 'KO_VULNERABLE_KEY') {
|
||
|
$fnret->{'msg'} = <<EOS;
|
||
|
This key is COMPROMISED due to tue Debian OpenSSL 2008 debacle (aka CVE-2008-0166).
|
||
|
***********************************************
|
||
|
DO NOT USE THIS KEY ANYWHERE, IT IS VULNERABLE!
|
||
|
***********************************************
|
||
|
EOS
|
||
|
}
|
||
|
elsif ($fnret->err eq 'KO_KEY_SIZE_TOO_SMALL') {
|
||
|
$fnret->{'msg'} = "This is too small. And sorry, but, yes, size DOES matter. Please re-generate a bigger key.";
|
||
|
}
|
||
|
elsif ($fnret->value && $fnret->value->{'family'} eq 'DSA') {
|
||
|
$fnret->{'msg'} = "Wait, DSA key ? Seriously ? Hello, 90's are over ! Please re-generate a bigger key.";
|
||
|
}
|
||
|
elsif ($fnret->err eq 'KO_FORBIDDEN_ALGORITHM') {
|
||
|
$fnret->{'msg'} = "This key generation algorithm has been disabled on this bastion, please use another one.";
|
||
|
}
|
||
|
elsif (not $fnret) {
|
||
|
$fnret->{'msg'} = "Unknown error (" . $fnret->msg . "), please report to your sysadmin.";
|
||
|
}
|
||
|
else {
|
||
|
if (not grep { $fnret->value->{'family'} eq $_ } qw{ RSA ECDSA ED25519 }) {
|
||
|
$fnret->{'err'} = 'ERR_UNKNOWN_TYPE';
|
||
|
$fnret->{'msg'} = "Unknown family type (" . $fnret->value->{'family'} . "), please report to your sysadmin.";
|
||
|
}
|
||
|
elsif (not $fnret->value->{'base64'}) {
|
||
|
$fnret->{'err'} = 'ERR_NOT_DECODED';
|
||
|
$fnret->{'msg'} = "Unknown error parsing your key, please report to your sysadmin.";
|
||
|
}
|
||
|
else {
|
||
|
# ok :)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $fnret;
|
||
|
}
|
||
|
|
||
|
sub get_from_for_user_key {
|
||
|
my %params = @_;
|
||
|
my $userProvidedIpList = $params{'userProvidedIpList'} || []; # arrayref
|
||
|
my $key = $params{'key'};
|
||
|
|
||
|
my $ingressKeysFrom = OVH::Bastion::config('ingressKeysFrom');
|
||
|
my $ingressKeysFromAllowOverride = OVH::Bastion::config('ingressKeysFromAllowOverride');
|
||
|
|
||
|
if (not $ingressKeysFrom or not $ingressKeysFromAllowOverride) {
|
||
|
return R('ERR_CANNOT_LOAD_CONFIGURATION');
|
||
|
}
|
||
|
|
||
|
my @ipList = @{$ingressKeysFrom->value};
|
||
|
|
||
|
if ($ingressKeysFromAllowOverride->value and scalar @$userProvidedIpList) {
|
||
|
@ipList = @$userProvidedIpList;
|
||
|
}
|
||
|
|
||
|
my @ipListVerified = grep { OVH::Bastion::is_valid_ip(ip => $_, allowPrefixes => 1) } @ipList;
|
||
|
|
||
|
my $from = '';
|
||
|
if (@ipListVerified) {
|
||
|
$from = sprintf('from="%s"', join(',', @ipListVerified));
|
||
|
}
|
||
|
|
||
|
# if we have a $key, modify it accordingly
|
||
|
if ($key) {
|
||
|
$key->{'prefix'} = $from;
|
||
|
$key->{'line'} = ($from ? $from . " " : "") . $key->{'typecode'} . " " . $key->{'base64'};
|
||
|
$key->{'line'} .= " " . $key->{'comment'} if $key->{'comment'};
|
||
|
$key->{'fromList'} = \@ipListVerified;
|
||
|
}
|
||
|
|
||
|
return R('OK', value => {from => $from, ipList => \@ipListVerified, key => $key});
|
||
|
}
|
||
|
|
||
|
sub generate_ssh_key {
|
||
|
my %params = @_;
|
||
|
my $uid = $params{'uid'}; # optional, uid to chmod key to, only if i'm root
|
||
|
my $gid = $params{'gid'}; # optional, gid to chmod key to, only if i'm root
|
||
|
my $folder = $params{'folder'}; # required, folder to put key into
|
||
|
my $prefix = $params{'prefix'}; # required, prefix of the key name
|
||
|
my $name = $params{'name'}; # optional, in key comment
|
||
|
my $algo = $params{'algo'}; # required, -t ssh-keygen param
|
||
|
my $size = $params{'size'}; # required, -b ssh-keygen param
|
||
|
my $passphrase = $params{'passphrase'}; # optional, passphrase to encrypt key with
|
||
|
my $group_readable = $params{'group_readable'}; # optional, need g+r on privkey
|
||
|
my $fnret;
|
||
|
|
||
|
if (my @missingParameters = grep { not defined $params{$_} } qw{ folder prefix algo size }) {
|
||
|
local $" = ', ';
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing params @missingParameters on generate_ssh_key() call");
|
||
|
}
|
||
|
|
||
|
if (!-d $folder) {
|
||
|
return R('ERR_DIRECTORY_NOT_FOUND', msg => "Specified directory not found ($folder)");
|
||
|
}
|
||
|
|
||
|
if (!-w $folder) {
|
||
|
return R('ERR_DIRECTORY_NOT_WRITABLE', msg => "Specified directory can't be written to ($folder)");
|
||
|
}
|
||
|
|
||
|
if ($prefix !~ /^[A-Za-z0-9_.-]{1,64}$/) {
|
||
|
return R('ERR_INVALID_PARAMETER', msg => "Specified prefix is invalid ($prefix)");
|
||
|
}
|
||
|
|
||
|
if ((defined $uid or defined $gid) and $< != 0) {
|
||
|
return R('ERR_INVALID_PARAMETER', msg => "Can't specify uid or gid when not root");
|
||
|
}
|
||
|
|
||
|
$fnret = OVH::Bastion::is_allowed_algo_and_size(algo => $algo, size => $size, way => 'egress');
|
||
|
$fnret or return $fnret;
|
||
|
|
||
|
# Forge key
|
||
|
$passphrase = '' if not $passphrase;
|
||
|
$size = '' if $algo eq 'ed25519';
|
||
|
$name ||= $prefix;
|
||
|
my $sshKeyName = $folder . '/id_' . $algo . $size . '_' . $prefix . '.' . time();
|
||
|
|
||
|
if (-e $sshKeyName) {
|
||
|
HEXIT('ERR_KEY_ALREADY_EXISTS', msg => "Can't forge key, generated name already exists");
|
||
|
}
|
||
|
|
||
|
my $bastionName = OVH::Bastion::config('bastionName');
|
||
|
if (!$bastionName) {
|
||
|
return R('ERR_CANNOT_LOAD_CONFIGURATION');
|
||
|
}
|
||
|
$bastionName = $bastionName->value;
|
||
|
|
||
|
my @command = ('ssh-keygen');
|
||
|
push @command, '-t', $algo;
|
||
|
push @command, '-b', $size if $size;
|
||
|
push @command, '-N', $passphrase;
|
||
|
push @command, '-f', $sshKeyName;
|
||
|
push @command, '-C', "$name\@$bastionName:" . time();
|
||
|
|
||
|
$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1);
|
||
|
$fnret->err eq 'OK' or return R('ERR_SSH_KEYGEN_FAILED', msg => "Error while generating group key (" . $fnret->msg . ")");
|
||
|
|
||
|
my %files = (
|
||
|
$sshKeyName => ($group_readable ? 0440 : 0400),
|
||
|
$sshKeyName . '.pub' => 0444,
|
||
|
);
|
||
|
while (my ($file, $chmod) = each(%files)) {
|
||
|
if (not -e $file) {
|
||
|
return R('ERR_SSH_KEYGEN_FAILED', msg => "Couldn't find generated key ($file)");
|
||
|
}
|
||
|
chown $uid, -1, $file if defined $uid;
|
||
|
chown -1, $gid, $file if defined $gid;
|
||
|
chmod $chmod, $file;
|
||
|
}
|
||
|
return R('OK', value => {file => $sshKeyName});
|
||
|
}
|
||
|
|
||
|
# return the list of bastion's ips
|
||
|
sub get_bastion_ips {
|
||
|
my %params = @_;
|
||
|
my $fnret;
|
||
|
|
||
|
my $egressKeysFrom = OVH::Bastion::config('egressKeysFrom');
|
||
|
if (!$egressKeysFrom) {
|
||
|
return R('ERR_CANNOT_LOAD_CONFIGURATION');
|
||
|
}
|
||
|
$egressKeysFrom = $egressKeysFrom->value;
|
||
|
|
||
|
my @ips;
|
||
|
if (not $egressKeysFrom or @$egressKeysFrom == 0) {
|
||
|
$fnret = OVH::Bastion::execute(cmd => ['hostname', '--all-ip-addresses']);
|
||
|
$fnret or return R('ERR_HOSTNAME_FAILED', msg => "Couldn't determine bastion IP addresses, please fix the config");
|
||
|
@ips = split(/\s+/, join(' ', @{$fnret->value->{'stdout'} || []}));
|
||
|
}
|
||
|
else {
|
||
|
@ips = @$egressKeysFrom;
|
||
|
}
|
||
|
|
||
|
my @checkedIps = grep { OVH::Bastion::is_valid_ip(ip => $_, allowPrefixes => 1) } @ips;
|
||
|
|
||
|
return R('OK', value => \@checkedIps);
|
||
|
}
|
||
|
|
||
|
my $_cache_get_supported_ssh_algorithms_list_runtime = undef;
|
||
|
|
||
|
sub get_supported_ssh_algorithms_list {
|
||
|
my %params = @_;
|
||
|
my $way = $params{'way'}; # ingress or egress
|
||
|
|
||
|
if (not $way) {
|
||
|
return R('ERR_MISSING_PARAMETER', msg => 'Missing required argument way in get_supported_ssh_algorithms_list');
|
||
|
}
|
||
|
if ($way ne 'ingress' && $way ne 'egress') {
|
||
|
return R('ERR_INVALID_PARAMETER', msg => 'Expected way argument of ingress or egress in get_supported_ssh_algorithms_list');
|
||
|
}
|
||
|
$way = ucfirst($way);
|
||
|
|
||
|
# first, filter by config
|
||
|
my $fnret = OVH::Bastion::config("allowed${way}SshAlgorithms");
|
||
|
$fnret or return $fnret;
|
||
|
my @allowedList = @{$fnret->value};
|
||
|
|
||
|
# other vary, detect this by running openssh client -V
|
||
|
my @supportedList;
|
||
|
if (ref $_cache_get_supported_ssh_algorithms_list_runtime eq 'ARRAY') {
|
||
|
@supportedList = @{$_cache_get_supported_ssh_algorithms_list_runtime};
|
||
|
}
|
||
|
else {
|
||
|
push @supportedList, 'rsa'; # rsa is always supported
|
||
|
$fnret = OVH::Bastion::execute(cmd => [qw{ ssh -V }]);
|
||
|
if ($fnret) {
|
||
|
foreach (@{$fnret->value->{'stdout'} || []}, @{$fnret->value->{'stderr'} || []}) {
|
||
|
if (/OpenSSH_(\d+\.\d+)/) {
|
||
|
my $version = $1;
|
||
|
push @supportedList, 'ecdsa' if ($version gt "5.7");
|
||
|
push @supportedList, 'ed25519' if ($version gt "6.5");
|
||
|
$_cache_get_supported_ssh_algorithms_list_runtime = \@supportedList;
|
||
|
last;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
# then, take the union of both
|
||
|
my @list;
|
||
|
foreach my $algo (@supportedList) {
|
||
|
push @list, $algo if grep { $_ eq $algo } @allowedList;
|
||
|
}
|
||
|
return R('OK', value => \@list);
|
||
|
}
|
||
|
|
||
|
sub is_allowed_algo_and_size {
|
||
|
my %params = @_;
|
||
|
my $algo = lc($params{'algo'});
|
||
|
my $size = $params{'size'};
|
||
|
my $way = $params{'way'};
|
||
|
my $fnret;
|
||
|
|
||
|
if (not $algo) {
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'algo'");
|
||
|
}
|
||
|
|
||
|
$fnret = OVH::Bastion::get_supported_ssh_algorithms_list(way => $way);
|
||
|
$fnret or return $fnret;
|
||
|
my @supportedList = @{$fnret->value};
|
||
|
|
||
|
if (not grep { $algo eq $_ } @supportedList) {
|
||
|
return R('KO_NOT_SUPPORTED', msg => "The algorithm '$algo' is not supported (or disabled) for $way on this bastion");
|
||
|
}
|
||
|
|
||
|
if ($algo eq 'rsa') {
|
||
|
$way = ucfirst($way);
|
||
|
$fnret = OVH::Bastion::config("minimum${way}RsaKeySize");
|
||
|
$fnret or return $fnret;
|
||
|
if ($size < $fnret->value) {
|
||
|
return R('KO_KEY_SIZE_TOO_SMALL', msg => "For the selected algorithm, minimum configured key size for $way by policy is " . $fnret->value . " bits");
|
||
|
}
|
||
|
}
|
||
|
elsif ($algo eq 'ecdsa') {
|
||
|
if (not grep { $size eq $_ } qw{ 256 384 521 }) {
|
||
|
return R('KO_KEY_SIZE_INVALID', msg => "For the selected algorithm, valid key sizes are 256, 384, 521");
|
||
|
}
|
||
|
}
|
||
|
elsif ($algo eq 'ed25519' and $size and $size ne 256) {
|
||
|
return R('KO_KEY_SIZE_INVALID', msg => "For the selected algorithm, key size must be 256");
|
||
|
}
|
||
|
return R('OK');
|
||
|
}
|
||
|
|
||
|
sub is_valid_fingerprint {
|
||
|
my %params = @_;
|
||
|
my $fingerprint = $params{'fingerprint'};
|
||
|
|
||
|
if (not $fingerprint) {
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'fingerprint'");
|
||
|
}
|
||
|
elsif ($fingerprint =~ /^(([0-9a-f]{2}:){15}[0-9a-f]{2}$)/i) {
|
||
|
return R('OK', value => {type => 'md5', fingerprint => lc($1)});
|
||
|
}
|
||
|
elsif ($fingerprint =~ /^(SHA256:[\-\/a-z0-9+=]{43})$/i) {
|
||
|
return R('OK', value => {type => 'sha256', fingerprint => $1});
|
||
|
}
|
||
|
return R('ERR_INVALID_PARAMETER',
|
||
|
msg => "Specified fingerprint is invalid, expected a key fingerprint of the form 12:34:56:78:9a:bc:de:f0:12:34:56:78:9a:bc:de:f0 or SHA256:base64fingerprint");
|
||
|
}
|
||
|
|
||
|
sub print_public_key {
|
||
|
my %params = @_;
|
||
|
my $key = $params{'key'};
|
||
|
my $id = $params{'id'};
|
||
|
my $err = $params{'err'} || 'OK';
|
||
|
|
||
|
require Term::ANSIColor;
|
||
|
|
||
|
my $line = $key->{'line'};
|
||
|
if ($key->{'base64'}) {
|
||
|
$line = sprintf("%s%s %s %s", $key->{'prefix'} ? $key->{'prefix'} . ' ' : '', $key->{'typecode'}, $key->{'base64'}, $key->{'comment'});
|
||
|
}
|
||
|
|
||
|
if ($key->{'info'}) {
|
||
|
osh_info(Term::ANSIColor::colored("info: " . $key->{'info'}, 'cyan'));
|
||
|
}
|
||
|
if ($key->{'isPiv'}) {
|
||
|
osh_info(
|
||
|
Term::ANSIColor::colored(
|
||
|
"PIV: "
|
||
|
. "TouchPolicy="
|
||
|
. $key->{'pivInfo'}{'Yubikey'}{'TouchPolicy'}
|
||
|
. ", PinPolicy="
|
||
|
. $key->{'pivInfo'}{'Yubikey'}{'PinPolicy'}
|
||
|
. ", SerialNo="
|
||
|
. $key->{'pivInfo'}{'Yubikey'}{'SerialNumber'}
|
||
|
. ", Firmware="
|
||
|
. $key->{'pivInfo'}{'Yubikey'}{'FirmwareVersion'},
|
||
|
'magenta'
|
||
|
)
|
||
|
);
|
||
|
}
|
||
|
osh_info(
|
||
|
sprintf(
|
||
|
"%s%s (%s-%d) [%s]%s",
|
||
|
Term::ANSIColor::colored('fingerprint: ', 'green'),
|
||
|
$key->{'fingerprint'} || 'INVALID_FINGERPRINT',
|
||
|
$key->{'family'} || 'INVALID_FAMILY',
|
||
|
$key->{'size'},
|
||
|
defined $id ? "ID = $id" : POSIX::strftime("%Y/%m/%d", localtime($key->{'mtime'})),
|
||
|
$err eq 'OK' ? '' : ' ***<<' . $err . '>>***',
|
||
|
)
|
||
|
);
|
||
|
osh_info(Term::ANSIColor::colored('keyline', 'red') . ' follows, please copy the *whole* line:');
|
||
|
print($line. "\n");
|
||
|
osh_info(' ');
|
||
|
}
|
||
|
|
||
|
sub account_ssh_config_get {
|
||
|
my %params = @_;
|
||
|
my $account = $params{'account'};
|
||
|
|
||
|
my $fnret;
|
||
|
|
||
|
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
|
||
|
$fnret or return $fnret;
|
||
|
|
||
|
$account = $fnret->value->{'account'};
|
||
|
my $dir = $fnret->value->{'dir'};
|
||
|
|
||
|
# read file content. If it doesn't exist, not a problem
|
||
|
my $sshconfig_data;
|
||
|
if (open(my $sshconfig_fd, '<', "$dir/.ssh/config")) {
|
||
|
local $/ = undef;
|
||
|
$sshconfig_data = <$sshconfig_fd>;
|
||
|
close($sshconfig_fd);
|
||
|
|
||
|
# ensure we don't have any Host or Match directive.
|
||
|
# If we do, bail out: the file has been modified manually by someone
|
||
|
if ($sshconfig_data =~ /^\s*(Host|Match)\s/mi) {
|
||
|
return R('ERR_FILE_LOCALLY_MODIFIED',
|
||
|
msg => "The ssh configuration of this account has been modified manually. As we can't guarantee modifying it won't cause adverse effects, modification aborted.");
|
||
|
}
|
||
|
|
||
|
# remove empty lines & comments
|
||
|
my @lines = grep { /./ && !/^\s*#/ } split("\n", $sshconfig_data);
|
||
|
|
||
|
# lowercase all keys
|
||
|
my %keys = map { m/^(\S+)\s+(.+)$/ ? (lc($1) => $2) : () } @lines;
|
||
|
|
||
|
return R('OK_EMPTY') if !%keys;
|
||
|
return R('OK', value => \%keys);
|
||
|
}
|
||
|
|
||
|
return R($! =~ /permission|denied/i ? 'ERR_ACCESS_DENIED' : 'OK_EMPTY');
|
||
|
}
|
||
|
|
||
|
sub account_ssh_config_set {
|
||
|
my %params = @_;
|
||
|
my $account = $params{'account'};
|
||
|
my $key = $params{'key'};
|
||
|
my $value = $params{'value'}; # if undef, remove $key
|
||
|
|
||
|
my $fnret;
|
||
|
|
||
|
if (not defined $key) {
|
||
|
return R('ERR_MISSING_PARAMETER', value => "Expected 'key' parameter");
|
||
|
}
|
||
|
$key = lc($key);
|
||
|
|
||
|
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
|
||
|
$fnret or return $fnret;
|
||
|
|
||
|
$account = $fnret->value->{'account'};
|
||
|
my $dir = $fnret->value->{'dir'};
|
||
|
|
||
|
# read file content
|
||
|
$fnret = OVH::Bastion::account_ssh_config_get(account => $account);
|
||
|
$fnret or return $fnret;
|
||
|
my %keys = %{$fnret->value()};
|
||
|
|
||
|
# remove key if it already exists
|
||
|
delete $keys{$key};
|
||
|
|
||
|
# add new key+value
|
||
|
$keys{$key} = $value if defined $value;
|
||
|
|
||
|
# write modified file. to avoid symlink attacks, remove it then reopen it with sysopen()
|
||
|
unlink("$dir/.ssh/config");
|
||
|
if (sysopen(my $sshconfig_fd, "$dir/.ssh/config", O_RDWR | O_CREAT | O_EXCL)) {
|
||
|
foreach my $keyWrite (sort keys %keys) {
|
||
|
print $sshconfig_fd $keyWrite . " " . $keys{$keyWrite} . "\n";
|
||
|
}
|
||
|
close($sshconfig_fd);
|
||
|
}
|
||
|
else {
|
||
|
return R('ERR_CANNOT_OPEN_FILE', msg => "Couldn't open ssh config file for write: $!");
|
||
|
}
|
||
|
|
||
|
# ensure file is readable by everyone (and mainly the account itself)
|
||
|
if (!chmod 0644, "$dir/.ssh/config") {
|
||
|
return R('ERR_CANNOT_CHMOD', msg => "Couldn't ensure the ssh config file perms are correct");
|
||
|
}
|
||
|
|
||
|
return R('OK');
|
||
|
}
|
||
|
|
||
|
# action=enable: will comment all non-PIV keys from the account's authorized_keys2,
|
||
|
# by calling get_authorized_keys_from_file() with the includePivDisabled param at 1,
|
||
|
# so we also get the commented PIV keys if we already have some, then we comment all
|
||
|
# non-PIV keys, and put them back with put_authorized_keys_to_file()
|
||
|
#
|
||
|
# action=disable: will uncomment all non-PIV keys from the account's authorized_keys2,
|
||
|
# by calling get_authorized_keys_from_file() with the includePivDisabled param at 1,
|
||
|
# so we also get the commented PIV keys, then we uncomment them all and put them back
|
||
|
# with put_authorized_keys_to_file()
|
||
|
sub ssh_ingress_keys_piv_apply {
|
||
|
my %params = @_;
|
||
|
my $account = $params{'account'};
|
||
|
my $action = $params{'action'};
|
||
|
|
||
|
my $fnret;
|
||
|
|
||
|
if (not $action) {
|
||
|
return R('ERR_MISSING_PARAMETER', msg => "Argument 'action' is required");
|
||
|
}
|
||
|
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
|
||
|
$fnret or return $fnret;
|
||
|
|
||
|
$account = $fnret->value->{'account'};
|
||
|
my $dir = $fnret->value->{'dir'};
|
||
|
|
||
|
$fnret = OVH::Bastion::get_authorized_keys_from_file(account => $account, file => "$dir/.ssh/authorized_keys2", includePivDisabled => 1);
|
||
|
$fnret or return $fnret;
|
||
|
|
||
|
my $keys = $fnret->value();
|
||
|
|
||
|
my @keysToWrite;
|
||
|
if ($action eq 'disable') {
|
||
|
|
||
|
# uncomment all commented PIV keys
|
||
|
foreach my $key (@$keys) {
|
||
|
if ($key->{'line'} =~ /^# NOTPIV (.+)/) {
|
||
|
$key->{'line'} = $1;
|
||
|
}
|
||
|
push @keysToWrite, $key;
|
||
|
}
|
||
|
}
|
||
|
elsif ($action eq 'enable') {
|
||
|
|
||
|
# comment all non-PIV and non-verified PIV keys
|
||
|
foreach my $key (@$keys) {
|
||
|
my $line = $key->{'line'};
|
||
|
|
||
|
# remove any commented PIV marker for the verify_piv()
|
||
|
$line =~ s/^# NOTPIV //;
|
||
|
if ($key->{'pivAttestationCertificate'} && $key->{'pivKeyCertificate'}) {
|
||
|
$fnret = OVH::Bastion::verify_piv(key => $line, attestationCertificate => $key->{'pivAttestationCertificate'}, keyCertificate => $key->{'pivKeyCertificate'});
|
||
|
if (!$fnret) {
|
||
|
|
||
|
# PIV verify failed prepend with # NOTPIV
|
||
|
$key->{'line'} = "# NOTPIV " . $line;
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
# no certificates => not PIV, prepend with # NOTPIV
|
||
|
$key->{'line'} = "# NOTPIV " . $line;
|
||
|
}
|
||
|
push @keysToWrite, $key;
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
return R('ERR_INVALID_PARAMETER', msg => "Argument 'action' must be either 'enable' or 'disable'");
|
||
|
}
|
||
|
|
||
|
$fnret = OVH::Bastion::put_authorized_keys_to_file(account => $account, file => "$dir/.ssh/authorized_keys2", data => \@keysToWrite);
|
||
|
$fnret or return $fnret;
|
||
|
|
||
|
OVH::Bastion::syslogFormatted(
|
||
|
severity => 'info',
|
||
|
type => 'account',
|
||
|
fields => [['action', 'ingress-piv-apply'], ['account', $account], ['policy', $action], ['comment', 'auto-reaper'],]
|
||
|
);
|
||
|
|
||
|
return R('OK');
|
||
|
}
|
||
|
|
||
|
1;
|