sources/Google.Solutions.Apis/Compute/WindowsCredentialGenerator.cs (247 lines of code) (raw):
//
// Copyright 2019 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.Diagnostics;
using Google.Solutions.Apis.Locator;
using Google.Solutions.Common.Diagnostics;
using Google.Solutions.Common.Linq;
using Google.Solutions.Common.Util;
using Newtonsoft.Json;
using System;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace Google.Solutions.Apis.Compute
{
/// <summary>
/// Uses the OS Agent's account manager to generate Windows
/// logon credentials.
/// </summary>
/// <see href="https://cloud.google.com/compute/docs/instances/windows/automate-pw-generation"/>
public interface IWindowsCredentialGenerator
{
Task<bool> IsGrantedPermissionToCreateWindowsCredentialsAsync(
InstanceLocator instanceRef);
/// <summary>
/// Reset a SAM account password. If the SAM account does not exist,
/// it is created and made a local Administrator.
/// </summary>
Task<NetworkCredential> CreateWindowsCredentialsAsync(
InstanceLocator instanceRef,
string username,
UserFlags tyerType,
CancellationToken token);
/// <summary>
/// Reset a SAM account password. If the SAM account does not exist,
/// it is created and made a local Administrator.
/// </summary>
Task<NetworkCredential> CreateWindowsCredentialsAsync(
InstanceLocator instanceRef,
string username,
UserFlags tyerType,
TimeSpan timeout,
CancellationToken token);
}
[Flags]
public enum UserFlags
{
/// <summary>
/// Add to local Administrators group. This is the default
/// behavior.
/// </summary>
AddToAdministrators = 1,
/// <summary>
/// Don't modify group memverships. This is only supported by
/// newer OS agent versions (Jan 2020 and later).
/// </summary>
None = 0
}
public sealed class WindowsCredentialGenerator : IWindowsCredentialGenerator
{
private const int RsaKeySize = 2048;
private const int SerialPort = 4;
private const string MetadataKey = "windows-keys";
//
// Default settings for password encryption.
//
private const string DefaultHashFunction = "sha256";
private static readonly RSAEncryptionPadding DefaultEncryptionPadding
= RSAEncryptionPadding.OaepSHA256;
private readonly IComputeEngineClient computeClient;
//---------------------------------------------------------------------
// Ctor.
//---------------------------------------------------------------------
public WindowsCredentialGenerator(
IComputeEngineClient computeClient)
{
this.computeClient = computeClient;
}
//---------------------------------------------------------------------
// IWindowsCredentialService.
//---------------------------------------------------------------------
public async Task<NetworkCredential> CreateWindowsCredentialsAsync(
InstanceLocator instanceRef,
string username,
UserFlags userType,
CancellationToken token)
{
using (ApiTraceSource.Log.TraceMethod().WithParameters(instanceRef, username))
using (var rsa = new RSACng(RsaKeySize))
{
var keyParameters = rsa.ExportParameters(false);
var requestPayload = new RequestPayload()
{
ExpireOn = DateTime.UtcNow.AddMinutes(5),
Username = username,
Email = username,
Modulus = Convert.ToBase64String(keyParameters.Modulus),
Exponent = Convert.ToBase64String(keyParameters.Exponent),
AddToAdministrators = userType.HasFlag(UserFlags.AddToAdministrators),
HashFunction = DefaultHashFunction
};
//
// Send the request to the instance via a special metadata entry.
//
try
{
var requestJson = JsonConvert.SerializeObject(requestPayload);
await this.computeClient.UpdateMetadataAsync(
instanceRef,
existingMetadata =>
{
existingMetadata.Add(new Metadata()
{
Items = new[]
{
new Metadata.ItemsData()
{
Key = MetadataKey,
Value = requestJson
}
}
});
},
token)
.ConfigureAwait(false);
}
catch (ResourceNotFoundException e)
{
ApiTraceSource.Log.TraceVerbose("Instance does not exist: {0}", e.Message);
throw new WindowsCredentialCreationFailedException(
$"Instance {instanceRef.Name} was not found.");
}
catch (ResourceAccessDeniedException e)
{
ApiTraceSource.Log.TraceVerbose(
"Setting request payload metadata failed with 403: {0}",
e.FullMessage());
//
// Setting metadata failed due to lack of permissions. Note that
// the Error object is not always populated, hence the OR filter.
//
throw new WindowsCredentialCreationFailedException(
"You do not have sufficient permissions to reset a Windows password. " +
"You need the 'Service Account User' and " +
"'Compute Instance Admin' roles (or equivalent custom roles) " +
"to perform this action.",
HelpTopics.PermissionsToResetWindowsUser);
}
catch (GoogleApiException e) when (e.IsBadRequestCausedByServiceAccountAccessDenied())
{
ApiTraceSource.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 WindowsCredentialCreationFailedException(
"You do not have sufficient permissions to reset a Windows password. " +
"Because this VM instance uses a service account, you also need the " +
"'Service Account User' role.",
HelpTopics.PermissionsToResetWindowsUser);
}
//
// Read response from serial port.
//
using (var serialPortStream = this.computeClient.GetSerialPortOutput(
instanceRef,
SerialPort))
{
//
// It is rare, but sometimes a single JSON can be split over multiple
// API reads. Therefore, maintain a buffer.
//
var logBuffer = new StringBuilder(64 * 1024);
while (true)
{
ApiTraceSource.Log.TraceVerbose("Waiting for agent to supply response...");
token.ThrowIfCancellationRequested();
var logDelta = await serialPortStream.ReadAsync(token).ConfigureAwait(false);
if (string.IsNullOrEmpty(logDelta))
{
// Reached end of stream, wait and try again.
await Task.Delay(500, token).ConfigureAwait(false);
continue;
}
logBuffer.Append(logDelta);
//
// NB. Old versions of the Windows guest agent wrongly added a '\'
// before every '/' in base64-encoded data. This affects the search
// for the modulus.
//
var response = logBuffer.ToString()
.Split('\n')
.Where(line => line.Contains(requestPayload.Modulus) ||
line.Replace("\\/", "/").Contains(requestPayload.Modulus))
.FirstOrDefault();
if (response == null)
{
//
// That was not the output we are looking for, keep reading.
//
continue;
}
var responsePayload = JsonConvert.DeserializeObject<ResponsePayload>(response);
if (responsePayload == null ||
!string.IsNullOrEmpty(responsePayload.ErrorMessage))
{
throw new WindowsCredentialCreationFailedException(
responsePayload?.ErrorMessage ?? "The response is empty");
}
//
// Old versions of the Windows guest agent unconditionally used
// OaepSHA1. Current versions vary the padding based on the
// hash function passed in the request, and echo the hash function
// in the response.
//
// If the response contains the hash function we requested, then
// we know it's a current version and we can use the appropriate
// OAEP padding. If the response contains no hash function field,
// then we're dealing with an old version and need to fall back
// to OaepSHA1.
//
var padding = responsePayload.HashFunction == DefaultHashFunction
? DefaultEncryptionPadding
: RSAEncryptionPadding.OaepSHA1;
var password = rsa.Decrypt(
Convert.FromBase64String(responsePayload.EncryptedPassword),
padding);
return new NetworkCredential(
username,
new UTF8Encoding().GetString(password),
null);
}
}
}
}
public async Task<NetworkCredential> CreateWindowsCredentialsAsync(
InstanceLocator instanceRef,
string username,
UserFlags userType,
TimeSpan timeout,
CancellationToken token)
{
using (var timeoutCts = new CancellationTokenSource())
{
timeoutCts.CancelAfter(timeout);
using (var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, token))
{
try
{
return await CreateWindowsCredentialsAsync(
instanceRef,
username,
userType,
combinedCts.Token).ConfigureAwait(false);
}
catch (Exception e) when (e.IsCancellation() && timeoutCts.IsCancellationRequested)
{
ApiTraceSource.Log.TraceError(e);
//
// This task was cancelled because of a timeout, not because
// the enclosing job was cancelled.
//
throw new WindowsCredentialCreationFailedException(
$"Timeout waiting for Compute Engine agent to reset password for user {username}. " +
"Verify that the agent is running and that the account manager feature is enabled.");
}
}
}
}
//---------------------------------------------------------------------
// Permission check.
//---------------------------------------------------------------------
public Task<bool> IsGrantedPermissionToCreateWindowsCredentialsAsync(InstanceLocator instanceRef)
{
//
// Resetting a user requires
// (1) compute.instances.setMetadata
// (2) iam.serviceAccounts.actAs (if the instance runs as service account)
//
// For performance reasons, only check (1).
//
return this.computeClient.IsAccessGrantedAsync(
instanceRef,
Permissions.ComputeInstancesSetMetadata);
}
//---------------------------------------------------------------------
// Data classes.
//---------------------------------------------------------------------
internal class RequestPayload
{
[JsonProperty("userName")]
public string? Username { get; set; }
[JsonProperty("email")]
public string? Email { get; set; }
[JsonProperty("expireOn")]
public DateTime? ExpireOn { get; set; }
[JsonProperty("modulus")]
public string? Modulus { get; set; }
[JsonProperty("exponent")]
public string? Exponent { get; set; }
[JsonProperty("addToAdministrators")]
public bool? AddToAdministrators { get; set; }
[JsonProperty("hashFunction")]
public string? HashFunction { get; set; }
}
internal class ResponsePayload
{
[JsonProperty("encryptedPassword")]
public string? EncryptedPassword { get; set; }
[JsonProperty("errorMessage")]
public string? ErrorMessage { get; set; }
[JsonProperty("hashFunction")]
public string? HashFunction { get; set; }
}
}
public class WindowsCredentialCreationFailedException : Exception, IExceptionWithHelpTopic
{
public IHelpTopic? Help { get; }
public WindowsCredentialCreationFailedException(string message) : base(message)
{
}
public WindowsCredentialCreationFailedException(string message, IHelpTopic helpTopic)
: base(message)
{
this.Help = helpTopic;
}
}
}