sources/Google.Solutions.IapDesktop.Extensions.Session/Protocol/Ssh/MetadataAuthorizedPublicKeyProcessor.cs (375 lines of code) (raw):
//
// Copyright 2020 Google LLC
//
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//
using Google.Apis.Compute.v1.Data;
using Google.Solutions.Apis;
using Google.Solutions.Apis.Auth;
using Google.Solutions.Apis.Compute;
using Google.Solutions.Apis.Crm;
using Google.Solutions.Apis.Diagnostics;
using Google.Solutions.Apis.Locator;
using Google.Solutions.Common.Diagnostics;
using Google.Solutions.Common.Linq;
using Google.Solutions.Common.Util;
using Google.Solutions.IapDesktop.Application;
using Google.Solutions.Ssh.Cryptography;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.IapDesktop.Extensions.Session.Protocol.Ssh
{
public abstract class MetadataAuthorizedPublicKeyProcessor
{
public const string EnableOsLoginFlag = "enable-oslogin";
public const string EnableOsLoginWithSecurityKeyFlag = "enable-oslogin-sk";
public const string BlockProjectSshKeysFlag = "block-project-ssh-keys";
public abstract bool IsOsLoginEnabled { get; }
public abstract bool IsOsLoginWithSecurityKeyEnabled { get; }
public abstract bool AreProjectSshKeysBlocked { get; }
internal static void AddPublicKeyToMetadata(
Metadata metadata,
MetadataAuthorizedPublicKey newKey)
{
//
// Merge new key into existing keyset, and take
// the opportunity to purge expired keys.
//
var newKeySet = MetadataAuthorizedPublicKeySet.FromMetadata(metadata)
.RemoveExpiredKeys()
.Add(newKey);
metadata.Add(MetadataAuthorizedPublicKeySet.MetadataKey, newKeySet.ToString());
}
internal static void RemovePublicKeyFromMetadata(
Metadata metadata,
MetadataAuthorizedPublicKey key)
{
//
// Merge new key into existing keyset, and take
// the opportunity to purge expired keys.
//
var newKeySet = MetadataAuthorizedPublicKeySet
.FromMetadata(metadata)
.Remove(key);
metadata.Add(MetadataAuthorizedPublicKeySet.MetadataKey, newKeySet.ToString());
}
protected async Task ModifyMetadataAndHandleErrorsAsync(
Func<CancellationToken, Task> modifyMetadata,
CancellationToken token)
{
try
{
await modifyMetadata(token).ConfigureAwait(false);
}
catch (GoogleApiException e) when (e.Error == null || e.Error.Code == 403)
{
ApplicationTraceSource.Log.TraceVerbose(
"Setting request payload metadata failed with 403: {0} ({1})",
e.Message,
e.Error?.Errors.EnsureNotNull().Select(er => er.Reason).FirstOrDefault());
//
// Setting metadata failed due to lack of permissions. Note that
// the Error object is not always populated, hence the OR filter.
//
throw new SshKeyPushFailedException(
"You do not have sufficient permissions to publish an SSH key. " +
"You need the 'Service Account User' and " +
"'Compute Instance Admin' roles (or equivalent custom roles) " +
"to perform this action.",
HelpTopics.ManagingMetadataAuthorizedKeys);
}
catch (GoogleApiException e) when (e.IsBadRequestCausedByServiceAccountAccessDenied())
{
ApplicationTraceSource.Log.TraceVerbose(
"Setting request payload metadata failed with 400: {0} ({1})",
e.Message,
e.Error?.Errors.EnsureNotNull().Select(er => er.Reason).FirstOrDefault());
//
// This error happens if the user has the necessary
// permissions on the VM, but lacks ActAs permission on
// the associated service account.
//
throw new SshKeyPushFailedException(
"You do not have sufficient permissions to publish an SSH key. " +
"Because this VM instance uses a service account, you also need the " +
"'Service Account User' role.",
HelpTopics.ManagingMetadataAuthorizedKeys);
}
}
public abstract IEnumerable<MetadataAuthorizedPublicKey> ListAuthorizedKeys(
KeyAuthorizationMethods allowedMethods);
//---------------------------------------------------------------------
// Publics.
//---------------------------------------------------------------------
public static async Task<InstanceMetadataAuthorizedPublicKeyProcessor> ForInstance(
IComputeEngineClient computeClient,
IResourceManagerClient resourceManagerAdapter,
InstanceLocator instance,
CancellationToken token)
{
Precondition.ExpectNotNull(computeClient, nameof(computeClient));
Precondition.ExpectNotNull(resourceManagerAdapter, nameof(resourceManagerAdapter));
Precondition.ExpectNotNull(instance, nameof(instance));
//
// Query metadata for instance and project in parallel.
//
var instanceDetailsTask = computeClient
.GetInstanceAsync(
instance,
token)
.ConfigureAwait(false);
var projectDetailsTask = computeClient
.GetProjectAsync(
instance.Project,
token)
.ConfigureAwait(false);
return new InstanceMetadataAuthorizedPublicKeyProcessor(
computeClient,
resourceManagerAdapter,
instance,
await instanceDetailsTask,
await projectDetailsTask);
}
public static async Task<ProjectMetadataAuthorizedPublicKeyProcessor> ForProject(
IComputeEngineClient computeClient,
ProjectLocator project,
CancellationToken token)
{
Precondition.ExpectNotNull(computeClient, nameof(computeClient));
Precondition.ExpectNotNull(project, nameof(project));
var projectDetails = await computeClient
.GetProjectAsync(project, token)
.ConfigureAwait(false);
return new ProjectMetadataAuthorizedPublicKeyProcessor(
computeClient,
projectDetails);
}
}
public class ProjectMetadataAuthorizedPublicKeyProcessor : MetadataAuthorizedPublicKeyProcessor
{
private readonly IComputeEngineClient computeClient;
private readonly Project projectDetails;
public override bool IsOsLoginEnabled
=> this.projectDetails.GetFlag(EnableOsLoginFlag) == true;
public override bool IsOsLoginWithSecurityKeyEnabled
=> this.projectDetails.GetFlag(EnableOsLoginWithSecurityKeyFlag) == true;
public override bool AreProjectSshKeysBlocked
=> this.projectDetails.GetFlag(BlockProjectSshKeysFlag) == true;
internal ProjectMetadataAuthorizedPublicKeyProcessor(
IComputeEngineClient computeClient,
Project projectDetails)
{
this.computeClient = computeClient;
this.projectDetails = projectDetails;
}
public override IEnumerable<MetadataAuthorizedPublicKey> ListAuthorizedKeys(
KeyAuthorizationMethods allowedMethods)
{
if (allowedMethods.HasFlag(KeyAuthorizationMethods.ProjectMetadata))
{
var keySet = MetadataAuthorizedPublicKeySet
.FromMetadata(this.projectDetails.CommonInstanceMetadata);
return (keySet?.Keys).EnsureNotNull();
}
else
{
return Enumerable.Empty<MetadataAuthorizedPublicKey>();
}
}
public async Task RemoveAuthorizedKeyAsync(
MetadataAuthorizedPublicKey key,
CancellationToken cancellationToken)
{
await this.computeClient
.UpdateCommonInstanceMetadataAsync(
new ProjectLocator(this.projectDetails.Name),
metadata => RemovePublicKeyFromMetadata(metadata, key),
cancellationToken)
.ConfigureAwait(false);
}
}
public class InstanceMetadataAuthorizedPublicKeyProcessor : MetadataAuthorizedPublicKeyProcessor
{
private readonly IComputeEngineClient computeClient;
private readonly IResourceManagerClient resourceManagerAdapter;
private readonly InstanceLocator instance;
private readonly Instance instanceDetails;
private readonly Project projectDetails;
internal InstanceMetadataAuthorizedPublicKeyProcessor(
IComputeEngineClient computeClient,
IResourceManagerClient resourceManagerAdapter,
InstanceLocator instance,
Instance instanceDetails,
Project projectDetails)
{
this.computeClient = computeClient;
this.resourceManagerAdapter = resourceManagerAdapter;
this.instance = instance;
this.instanceDetails = instanceDetails;
this.projectDetails = projectDetails;
}
public override bool IsOsLoginEnabled
=> this.instanceDetails.GetFlag(this.projectDetails, EnableOsLoginFlag) == true;
public override bool IsOsLoginWithSecurityKeyEnabled
=> this.instanceDetails.GetFlag(this.projectDetails, EnableOsLoginWithSecurityKeyFlag) == true;
public override bool AreProjectSshKeysBlocked
=> this.instanceDetails.GetFlag(this.projectDetails, BlockProjectSshKeysFlag) == true;
private bool IsLegacySshKeyPresent
=> !string.IsNullOrEmpty(this.instanceDetails
.Metadata
.GetValue(MetadataAuthorizedPublicKeySet.LegacyMetadataKey));
public override IEnumerable<MetadataAuthorizedPublicKey> ListAuthorizedKeys(
KeyAuthorizationMethods allowedMethods)
{
var keys = new List<MetadataAuthorizedPublicKey>();
if (allowedMethods.HasFlag(KeyAuthorizationMethods.ProjectMetadata))
{
keys.AddRange(MetadataAuthorizedPublicKeySet
.FromMetadata(this.projectDetails.CommonInstanceMetadata)
.Keys);
}
if (allowedMethods.HasFlag(KeyAuthorizationMethods.InstanceMetadata))
{
keys.AddRange(MetadataAuthorizedPublicKeySet
.FromMetadata(this.instanceDetails.Metadata)
.Keys);
}
return keys;
}
public async Task RemoveAuthorizedKeyAsync(
MetadataAuthorizedPublicKey key,
KeyAuthorizationMethods allowedMethods,
CancellationToken cancellationToken)
{
if (allowedMethods.HasFlag(KeyAuthorizationMethods.ProjectMetadata))
{
await this.computeClient
.UpdateCommonInstanceMetadataAsync(
this.instance.Project,
metadata => RemovePublicKeyFromMetadata(metadata, key),
cancellationToken)
.ConfigureAwait(false);
}
if (allowedMethods.HasFlag(KeyAuthorizationMethods.InstanceMetadata))
{
await this.computeClient
.UpdateMetadataAsync(
this.instance,
metadata => RemovePublicKeyFromMetadata(metadata, key),
cancellationToken)
.ConfigureAwait(false);
}
}
public async Task<PlatformCredential> AuthorizeKeyAsync(
IAsymmetricKeySigner key,
TimeSpan validity,
string username,
KeyAuthorizationMethods allowedMethods,
IAuthorization authorization,
CancellationToken cancellationToken)
{
key.ExpectNotNull(nameof(key));
username.ExpectNotNull(nameof(username));
Debug.Assert(!this.IsOsLoginEnabled);
var instanceMetadata = this.instanceDetails.Metadata;
var projectMetadata = this.projectDetails.CommonInstanceMetadata;
//
// Check if there is a legacy SSH key. If there is one,
// other keys are ignored.
//
// NB. legacy SSH keys were instance-only, so checking
// the instance metadata is sufficient.
//
if (this.IsLegacySshKeyPresent)
{
throw new UnsupportedLegacySshKeyEncounteredException(
$"Connecting to the VM instance {this.instance.Name} is not supported " +
"because the instance uses legacy SSH keys in its metadata (sshKeys)",
HelpTopics.ManagingMetadataAuthorizedKeys);
}
//
// There is no legacy key, so we're good to push a new key.
//
// Now figure out which username to use and where to push it.
//
var blockProjectSshKeys = this.AreProjectSshKeysBlocked;
bool useInstanceKeySet;
if (allowedMethods.HasFlag(KeyAuthorizationMethods.ProjectMetadata) &&
allowedMethods.HasFlag(KeyAuthorizationMethods.InstanceMetadata))
{
//
// Both allowed - use project metadata unless:
// - project keys are blocked
// - we do not have the permission to update project metadata.
//
var canUpdateProjectMetadata = await this.resourceManagerAdapter
.IsAccessGrantedAsync(
this.instance.Project,
new[] {
Permissions.ComputeProjectsSetCommonInstanceMetadata,
Permissions.ServiceAccountsActAs
},
cancellationToken)
.ConfigureAwait(false);
useInstanceKeySet = blockProjectSshKeys || !canUpdateProjectMetadata;
}
else if (allowedMethods.HasFlag(KeyAuthorizationMethods.ProjectMetadata))
{
// Only project allowed.
if (blockProjectSshKeys)
{
throw new InvalidOperationException(
$"Project {this.instance.ProjectId} does not allow project-level SSH keys");
}
else
{
useInstanceKeySet = false;
}
}
else if (allowedMethods.HasFlag(KeyAuthorizationMethods.InstanceMetadata))
{
// Only instance allowed.
useInstanceKeySet = true;
}
else
{
// Neither project nor instance allowed.
throw new ArgumentException(nameof(allowedMethods));
}
var metadataKey = new ManagedMetadataAuthorizedPublicKey(
username,
key.PublicKey.Type,
Convert.ToBase64String(key.PublicKey.WireFormatValue),
new ManagedMetadataAuthorizedPublicKey.PublicKeyMetadata(
authorization.Session.Username,
DateTime.UtcNow.Add(validity)));
var existingKeySet = MetadataAuthorizedPublicKeySet.FromMetadata(
useInstanceKeySet
? instanceMetadata
: projectMetadata);
if (existingKeySet
.RemoveExpiredKeys()
.Contains(metadataKey))
{
//
// The key is there already, so we are all set.
//
ApplicationTraceSource.Log.TraceVerbose(
"Existing SSH key found for {0}",
username);
}
else
{
//
// Key not known yet, so we have to push it to
// the metadata.
//
ApplicationTraceSource.Log.TraceVerbose(
"Pushing new SSH key for {0}",
username);
await ModifyMetadataAndHandleErrorsAsync(
async token =>
{
if (useInstanceKeySet)
{
await this.computeClient
.UpdateMetadataAsync(
this.instance,
metadata => AddPublicKeyToMetadata(metadata, metadataKey),
token)
.ConfigureAwait(false);
}
else
{
await this.computeClient
.UpdateCommonInstanceMetadataAsync(
this.instance.Project,
metadata => AddPublicKeyToMetadata(metadata, metadataKey),
token)
.ConfigureAwait(false);
}
},
cancellationToken)
.ConfigureAwait(false);
}
return new PlatformCredential(
key,
useInstanceKeySet
? KeyAuthorizationMethods.InstanceMetadata
: KeyAuthorizationMethods.ProjectMetadata,
username);
}
}
public class UnsupportedLegacySshKeyEncounteredException : Exception, IExceptionWithHelpTopic
{
public IHelpTopic Help { get; }
public UnsupportedLegacySshKeyEncounteredException(
string message,
IHelpTopic helpTopic)
: base(message)
{
this.Help = helpTopic;
}
}
public class SshKeyPushFailedException : Exception, IExceptionWithHelpTopic
{
public IHelpTopic Help { get; }
public SshKeyPushFailedException(
string message,
IHelpTopic helpTopic)
: base(message)
{
this.Help = helpTopic;
}
}
}