the-bastion/lib/perl/OVH/Bastion/ssh.inc
Stéphane Lesimple fde20136ef
Initial commit
2020-10-20 14:30:27 +00:00

823 lines
30 KiB
Perl

# 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;