Skip to content

Commit 69b9068

Browse files
committed
feat: allow validation of PIV certificate against a CA
Signed-off-by: Jonah Zürcher <[email protected]>
1 parent 53ee50f commit 69b9068

File tree

4 files changed

+226
-74
lines changed

4 files changed

+226
-74
lines changed

bin/plugin/open/selfAddIngressKey

Lines changed: 178 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ my $remainingOptions = OVH::Bastion::Plugin::begin(
1414
header => "add a new public key to your account",
1515
options => {
1616
"pubKey|public-key=s" => \my $pubKey, # 'pubKey' is a deprecated name, keep it to not break scripts or people
17+
"certificate=s" => \my $certificate,
1718
"piv" => \my $pivExplicit,
1819
},
1920
helptext => <<'EOF',
@@ -25,7 +26,10 @@ Usage: --osh SCRIPT_NAME [--public-key '"ssh key text"'] [--piv]
2526
If this option is not specified, you'll be prompted interactively for your public SSH key. Note that you
2627
can also pass it through STDIN directly. If the policy of this bastion allows it, you may prefix the key
2728
with a 'from="IP1,IP2,..."' snippet, a la authorized_keys. However the policy might force a configured
28-
'from' prefix that will override yours, or be used if you don't specify it yourself.
29+
'from' prefix that will override yours, or be used if you don't specify it yourself. If the PIV validation
30+
requires validation against a CA, this parameter will be ignored.
31+
--certificate KEY Your certificate in PEM format, if the PIV validation requires validation against a CA. If this
32+
parameter is not specified, you'll be prompted interactively for your certificate in PEM format.
2933
--piv Add a public SSH key from a PIV-compatible hardware token, along with its attestation certificate and key
3034
certificate, both in PEM format. If you specified --public-key, then the attestation and key certificate are
3135
expected on STDIN only, otherwise the public SSH key, the attestation and key certificate are expected on STDIN.
@@ -60,58 +64,194 @@ if (!OVH::Bastion::has_piv_helper()) {
6064
}
6165
}
6266

63-
if (not defined $pubKey) {
64-
osh_info "Please paste the SSH key you want to add.";
65-
OVH::Bastion::print_accepted_key_algorithms(way => "ingress");
66-
osh_info "\nPlease ensure your private key is encrypted using a proper passphrase.";
67+
my $pivValidationCAPath = OVH::Bastion::config('pivValidationCA')->value;
6768

68-
if (OVH::Bastion::config('ingressKeysFromAllowOverride')->value) {
69+
if (($pivExplicit || $pivEffectivePolicyEnabled) && $pivValidationCAPath ne "") {
70+
handle_piv_with_ca();
71+
}
72+
else {
73+
handle_without_ca();
74+
}
75+
76+
sub handle_without_ca {
77+
if (not defined $pubKey) {
78+
osh_info "Please paste the SSH key you want to add.";
79+
OVH::Bastion::print_accepted_key_algorithms(way => "ingress");
80+
osh_info "\nPlease ensure your private key is encrypted using a proper passphrase.";
81+
82+
if (OVH::Bastion::config('ingressKeysFromAllowOverride')->value) {
83+
osh_info
84+
'You can prepend your key with a from="IP1,IP2,..." as this bastion policy allows ingress keys "from" override by users';
85+
}
86+
else {
87+
osh_info
88+
'Any from="IP1,IP2,..." you include will be ignored, as this bastion policy refuses ingress keys "from" override by users';
89+
}
90+
91+
$pubKey = <STDIN>;
92+
93+
# trim spaces
94+
$pubKey =~ s{^\s+|\s+$}{}g;
95+
}
96+
97+
$fnret = OVH::Bastion::is_valid_public_key(pubKey => $pubKey, way => 'ingress');
98+
if (!$fnret) {
99+
100+
# maybe we decoded the key but for some reason we don't want/can't add it
101+
# in that case, return the data of the key in the same format as when this
102+
# call works (see last line with osh_ok)
103+
$fnret->{'value'} = {key => $fnret->value} if $fnret->value;
104+
osh_exit $fnret;
105+
}
106+
my $key = $fnret->value;
107+
108+
my $allowedKeyFile = $HOME . '/' . OVH::Bastion::AK_FILE;
109+
if (checkExistKey($key->{'base64'})) {
110+
osh_exit R('KO_DUPLICATE_KEY', msg => "This public key already exists on your account!",
111+
value => {key => $key});
112+
}
113+
114+
if ($pivEffectivePolicyEnabled) {
115+
osh_info "Your are required to add only SSH keys from PIV-compatible hardware tokens, by policy.";
116+
}
117+
elsif ($pivExplicit) {
118+
osh_info "You have requested to add a PIV-enabled SSH key.";
119+
}
120+
121+
# we have a valid key, now handle PIV if needed
122+
if ($pivExplicit || $pivEffectivePolicyEnabled) {
123+
($key->{'pivAttestationCertificate'}, $key->{'pivKeyCertificate'}) = get_attestation_material();
124+
125+
$fnret = OVH::Bastion::verify_piv(
126+
key => $key->{'line'},
127+
attestationCertificate => $key->{'pivAttestationCertificate'},
128+
keyCertificate => $key->{'pivKeyCertificate'}
129+
);
130+
$key->{'isPiv'} = ($fnret ? 1 : 0);
131+
$key->{'pivInfo'} = $fnret->value if $fnret;
132+
133+
if (!$key->{'isPiv'}) {
134+
osh_exit R('ERR_PIV_VALIDATION_FAILED',
135+
msg => "Those certificates didn't successfully validate the provided PIV key, aborting!");
136+
}
137+
}
138+
139+
$fnret = OVH::Bastion::get_from_for_user_key(userProvidedIpList => $key->{'fromList'}, key => $key);
140+
$fnret or osh_exit $fnret;
141+
142+
$key->{'info'} = sprintf("ADDED_BY=%s USING=%s UNIQID=%s TIMESTAMP=%s DATETIME=%s VERSION=%s",
143+
$self, $scriptName, $ENV{'UNIQID'}, time(), DateTime->now(), $OVH::Bastion::VERSION);
144+
145+
$fnret = OVH::Bastion::add_key_to_authorized_keys_file(file => $allowedKeyFile, key => $key);
146+
$fnret or osh_exit $fnret;
147+
148+
osh_info " ";
149+
osh_info "Public key successfully added:";
150+
OVH::Bastion::print_public_key(key => $key, nokeyline => 1);
151+
152+
if (ref $key->{'fromList'} eq 'ARRAY' && @{$key->{'fromList'}}) {
153+
osh_info "You will only be able to connect from: " . join(', ', @{$key->{'fromList'}});
154+
}
155+
156+
$key->{'from_list'} = delete $key->{'fromList'}; # for json display
157+
osh_ok {connect_only_from => $key->{'from_list'}, key => $key};
158+
}
159+
160+
sub handle_piv_with_ca {
161+
my $pivUserCertificate;
162+
if (not defined $certificate) {
163+
osh_info "Please paste the certificate in PEM format to validate your PIV key against the CA.";
69164
osh_info
70-
'You can prepend your key with a from="IP1,IP2,..." as this bastion policy allows ingress keys "from" override by users';
165+
"This snippet should start with '-----BEGIN CERTIFICATE-----' and end with '-----END CERTIFICATE-----':";
166+
osh_info " ";
167+
$fnret = readPEMFromSTDIN();
168+
$fnret or osh_exit $fnret;
169+
$pivUserCertificate = $fnret->value;
170+
osh_info " ";
71171
}
72172
else {
73-
osh_info
74-
'Any from="IP1,IP2,..." you include will be ignored, as this bastion policy refuses ingress keys "from" override by users';
173+
$pivUserCertificate = $certificate;
75174
}
76175

77-
$pubKey = <STDIN>;
176+
# this option will only be used if PIV is required, so we don't check any policies here.
177+
my ($pivAttestationCertificate, $pivKeyCertificate) = get_attestation_material();
78178

79-
# trim spaces
80-
$pubKey =~ s{^\s+|\s+$}{}g;
81-
}
179+
$fnret = OVH::Bastion::verify_piv(
180+
userCertificate => $pivUserCertificate,
181+
attestationCertificate => $pivAttestationCertificate,
182+
keyCertificate => $pivKeyCertificate,
183+
caCertificatePath => $pivValidationCAPath
184+
);
185+
if (!$fnret) {
186+
osh_exit R('ERR_PIV_VALIDATION_FAILED',
187+
msg => "Those certificates didn't successfully validate the provided PIV key against the CA, aborting!");
188+
}
189+
my $pivInfo = $fnret->value;
190+
my $pubKey = $pivInfo->{'SSHKey'}->{'PublicKey'};
82191

83-
$fnret = OVH::Bastion::is_valid_public_key(pubKey => $pubKey, way => 'ingress');
84-
if (!$fnret) {
192+
$fnret = OVH::Bastion::is_valid_public_key(pubKey => $pubKey, way => 'ingress');
193+
if (!$fnret) {
85194

86-
# maybe we decoded the key but for some reason we don't want/can't add it
87-
# in that case, return the data of the key in the same format as when this
88-
# call works (see last line with osh_ok)
89-
$fnret->{'value'} = {key => $fnret->value} if $fnret->value;
90-
osh_exit $fnret;
91-
}
92-
my $key = $fnret->value;
195+
# maybe we decoded the key but for some reason we don't want/can't add it
196+
# in that case, return the data of the key in the same format as when this
197+
# call works (see last line with osh_ok)
198+
$fnret->{'value'} = {key => $fnret->value} if $fnret->value;
199+
osh_exit $fnret;
200+
}
201+
my $key = $fnret->value;
93202

94-
my $allowedKeyFile = $HOME . '/' . OVH::Bastion::AK_FILE;
95-
if (checkExistKey($key->{'base64'})) {
96-
osh_exit R('KO_DUPLICATE_KEY', msg => "This public key already exists on your account!", value => {key => $key});
97-
}
203+
my $allowedKeyFile = $HOME . '/' . OVH::Bastion::AK_FILE;
204+
if (checkExistKey($key->{'base64'})) {
205+
osh_exit R('KO_DUPLICATE_KEY', msg => "This public key already exists on your account!",
206+
value => {key => $key});
207+
}
98208

99-
# we have a valid key, now handle PIV if needed
209+
$key->{'isPiv'} = 1;
210+
$key->{'pivAttestationCertificate'} = $pivAttestationCertificate;
211+
$key->{'pivKeyCertificate'} = $pivKeyCertificate;
212+
$key->{'pivInfo'} = $pivInfo;
100213

101-
if ($pivEffectivePolicyEnabled) {
102-
osh_info "Your are required to add only SSH keys from PIV-compatible hardware tokens, by policy.";
103-
}
104-
elsif ($pivExplicit) {
105-
osh_info "You have requested to add a PIV-enabled SSH key.";
214+
# since the ssh pubkey is generated from the PIV cert, we can't read the FROM list from the pubkey.
215+
# instead we ask the user to provide it, if the policy allows it.
216+
if (OVH::Bastion::config('ingressKeysFromAllowOverride')->value) {
217+
osh_info
218+
'You can specify a comma-separated list of IPs or CIDRs you will be allowed to connect from (empty means any).';
219+
osh_info 'Example: 192.168.0.0/24,192.168.1.0/24';
220+
221+
# reading fromList from stdin
222+
my $fromList = <STDIN>;
223+
$fromList =~ s{^\s+|\s+$}{}g;
224+
$key->{'fromList'} = [split(/\s*,\s*/, $fromList)] if $fromList ne '';
225+
}
226+
227+
$fnret = OVH::Bastion::get_from_for_user_key(userProvidedIpList => $key->{'fromList'}, key => $key);
228+
$fnret or osh_exit $fnret;
229+
230+
$key->{'info'} = sprintf("ADDED_BY=%s USING=%s UNIQID=%s TIMESTAMP=%s DATETIME=%s VERSION=%s",
231+
$self, $scriptName, $ENV{'UNIQID'}, time(), DateTime->now(), $OVH::Bastion::VERSION);
232+
233+
$fnret = OVH::Bastion::add_key_to_authorized_keys_file(file => $allowedKeyFile, key => $key);
234+
$fnret or osh_exit $fnret;
235+
236+
osh_info " ";
237+
osh_info "Public key successfully added:";
238+
OVH::Bastion::print_public_key(key => $key, nokeyline => 1);
239+
240+
if (ref $key->{'fromList'} eq 'ARRAY' && @{$key->{'fromList'}}) {
241+
osh_info "You will only be able to connect from: " . join(', ', @{$key->{'fromList'}});
242+
}
243+
244+
$key->{'from_list'} = delete $key->{'fromList'}; # for json display
245+
osh_ok {connect_only_from => $key->{'from_list'}, key => $key};
106246
}
107247

108-
if ($pivExplicit || $pivEffectivePolicyEnabled) {
248+
sub get_attestation_material {
109249
osh_info "Please paste the PIV attestation certificate of your hardware key in PEM format.";
110250
osh_info "This snippet should start with '-----BEGIN CERTIFICATE-----' and end with '-----END CERTIFICATE-----':";
111251
osh_info " ";
112252
$fnret = readPEMFromSTDIN();
113253
$fnret or osh_exit $fnret;
114-
$key->{'pivAttestationCertificate'} = $fnret->value;
254+
my $pivAttestationCertificate = $fnret->value;
115255

116256
osh_info " ";
117257
osh_info "Thanks, now please paste the PIV key certificate of your generated key in PEM format.";
@@ -120,47 +260,16 @@ if ($pivExplicit || $pivEffectivePolicyEnabled) {
120260
osh_info " ";
121261
$fnret = readPEMFromSTDIN();
122262
$fnret or osh_exit $fnret;
123-
$key->{'pivKeyCertificate'} = $fnret->value;
263+
my $pivKeyCertificate = $fnret->value;
124264
osh_info " ";
125265

126-
$fnret = OVH::Bastion::verify_piv(
127-
key => $key->{'line'},
128-
attestationCertificate => $key->{'pivAttestationCertificate'},
129-
keyCertificate => $key->{'pivKeyCertificate'}
130-
);
131-
$key->{'isPiv'} = ($fnret ? 1 : 0);
132-
$key->{'pivInfo'} = $fnret->value if $fnret;
133-
134-
if (!$key->{'isPiv'}) {
135-
osh_exit R('ERR_PIV_VALIDATION_FAILED',
136-
msg => "Those certificates didn't successfully validate the provided PIV key, aborting!");
137-
}
138-
}
139-
140-
# end of PIV handling
141-
142-
$fnret = OVH::Bastion::get_from_for_user_key(userProvidedIpList => $key->{'fromList'}, key => $key);
143-
$fnret or osh_exit $fnret;
144-
145-
$key->{'info'} = sprintf("ADDED_BY=%s USING=%s UNIQID=%s TIMESTAMP=%s DATETIME=%s VERSION=%s",
146-
$self, $scriptName, $ENV{'UNIQID'}, time(), DateTime->now(), $OVH::Bastion::VERSION);
147-
148-
$fnret = OVH::Bastion::add_key_to_authorized_keys_file(file => $allowedKeyFile, key => $key);
149-
$fnret or osh_exit $fnret;
150-
151-
osh_info " ";
152-
osh_info "Public key successfully added:";
153-
OVH::Bastion::print_public_key(key => $key, nokeyline => 1);
154-
155-
if (ref $key->{'fromList'} eq 'ARRAY' && @{$key->{'fromList'}}) {
156-
osh_info "You will only be able to connect from: " . join(', ', @{$key->{'fromList'}});
266+
return ($pivAttestationCertificate, $pivKeyCertificate);
157267
}
158268

159269
sub checkExistKey {
160-
161270
# only pass the base64 part of the key here (returned by get_ssh_pub_key_info->{'base64'})
162-
my $pubKeyB64 = shift;
163-
271+
my $pubKeyB64 = shift;
272+
my $allowedKeyFile = $HOME . '/' . OVH::Bastion::AK_FILE;
164273
open(my $fh_keys, '<', $allowedKeyFile) || die("can't read the $allowedKeyFile file!\n");
165274
while (my $currentLine = <$fh_keys>) {
166275
chomp $currentLine;
@@ -218,6 +327,3 @@ sub readPEMFromSTDIN {
218327
}
219328
return R('ERR_INTERNAL'); # unreachable
220329
}
221-
222-
$key->{'from_list'} = delete $key->{'fromList'}; # for json display
223-
osh_ok {connect_only_from => $key->{'from_list'}, key => $key};

etc/bastion/bastion.conf.dist

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,11 @@
401401
# DEFAULT: false
402402
"ingressRequirePIV": false,
403403
#
404+
# pivValidationCA (string)
405+
# DESC: Path to a trusted certificate authority, which will be used to validate the PIV certificate. Users will have to paste their certificate in PEM format instead of SSH, when enrolling a new ingress key.
406+
# DEFAULT: ""
407+
"pivValidationCA": "",
408+
#
404409
# accountMFAPolicy (string)
405410
# DESC: Set a MFA policy for the bastion accounts, the supported values are:
406411
#

lib/perl/OVH/Bastion/configuration.inc

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ sub load_configuration {
163163
{name => 'fanciness', default => 'full', validre => qr/^((none|boomer)|(basic|millenial)|(full|genz))$/},
164164
{name => 'accountExternalValidationProgram', default => '', validre => qr'^([a-zA-Z0-9/$_.-]*)$', emptyok => 1},
165165
{name => 'ttyrecStealthStdoutPattern', default => '', validre => qr'^(.{0,4096})$', emptyok => 1},
166+
{name => 'pivValidationCA', default => '', validre => qr'^([a-zA-Z0-9/$_.-]*)$', emptyok => 1},
166167
)
167168
{
168169
if (!$C->{$o->{'name'}} && !$o->{'emptyok'}) {
@@ -617,6 +618,22 @@ sub load_configuration {
617618
}
618619
delete $unknownkeys{'ingressToEgressRules'};
619620

621+
# ... validate pivValidationCA file exists if specified
622+
if ($C->{'pivValidationCA'} && $C->{'pivValidationCA'} ne '') {
623+
if (!-f $C->{'pivValidationCA'}) {
624+
push @errors,
625+
"Configuration error: PIV validation CA file '"
626+
. $C->{'pivValidationCA'}
627+
. "' does not exist or is not a regular file";
628+
$C->{'pivValidationCA'} = '';
629+
}
630+
elsif (!-r $C->{'pivValidationCA'}) {
631+
push @errors,
632+
"Configuration error: PIV validation CA file '" . $C->{'pivValidationCA'} . "' is not readable";
633+
$C->{'pivValidationCA'} = '';
634+
}
635+
}
636+
620637
# ... normalize fanciness
621638
$C->{'fanciness'} = 'none' if $C->{'fanciness'} eq 'boomer';
622639
$C->{'fanciness'} = 'basic' if $C->{'fanciness'} eq 'millenial';

lib/perl/OVH/Bastion/ssh.inc

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,35 @@ sub verify_piv {
2727
my $key = $params{'key'};
2828
my $keyCertificate = $params{'keyCertificate'};
2929
my $attestationCertificate = $params{'attestationCertificate'};
30+
my $userCertificate = $params{'userCertificate'};
31+
my $caCertificatePath = $params{'caCertificatePath'};
3032

3133
my $fnret;
34+
my @cmd;
35+
36+
# Determine which verification mode to use based on provided parameters
37+
if ($userCertificate && $caCertificatePath) {
38+
# CA validation mode - use user certificate and CA
39+
@cmd = (
40+
'yubico-piv-checker', '-cert', $userCertificate, '-attestation',
41+
$attestationCertificate, '-key-cert', $keyCertificate, '-ca',
42+
$caCertificatePath
43+
);
44+
}
45+
elsif ($key) {
46+
# Standard PIV mode - use SSH key directly
47+
@cmd = ('yubico-piv-checker', $key, $attestationCertificate, $keyCertificate);
48+
}
49+
else {
50+
return R('ERR_MISSING_PARAMETER',
51+
msg => "Either 'key' or both 'userCertificate' and 'caCertificate' must be provided");
52+
}
53+
54+
print "verify_piv: executing command: " . join(' ', @cmd) . "\n";
55+
3256
$fnret = OVH::Bastion::execute(
3357
must_succeed => 1,
34-
cmd => ['yubico-piv-checker', $key, $attestationCertificate, $keyCertificate]
58+
cmd => \@cmd
3559
);
3660
if (!$fnret || $fnret->value->{'sysret'} != 0) {
3761
return R('KO_INVALID_PIV', "This SSH key failed PIV verification");
@@ -41,7 +65,7 @@ sub verify_piv {
4165
require JSON;
4266
$keyPivInfo = JSON::decode_json($fnret->value->{'stdout'}->[0]);
4367
};
44-
return R('OK', value => $keyPivInfo); # keyPivInfo can be undef if JSON decode failed, but the key is still a valid one
68+
return R('OK', value => $keyPivInfo);
4569
}
4670

4771
sub get_authorized_keys_from_file {

0 commit comments

Comments
 (0)