Skip to content

Commit

Permalink
Add TPM capability (#285)
Browse files Browse the repository at this point in the history
* Add capability for TPM support
* Use dedicated HGS key protector for eryph

Closes #268
  • Loading branch information
ChristopherMann authored Jan 3, 2025
1 parent 54ead70 commit bb9d6a6
Show file tree
Hide file tree
Showing 16 changed files with 550 additions and 7 deletions.
3 changes: 3 additions & 0 deletions src/core/src/Eryph.Core/EryphConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ public static class EryphConstants

public static readonly int DefaultCatletMemoryMb = 1024;

public static readonly string HgsGuardianName = "eryph-hgs-guardian";

public static class BuildInRoles
{
public static readonly Guid Owner = Guid.Parse("{918D2C23-8E9A-41AE-8F0E-ADACA3BECBC4}");
Expand All @@ -40,6 +42,7 @@ public static class Capabilities
public static readonly string SecureBoot = "secure_boot";
public static readonly string NestedVirtualization = "nested_virtualization";
public static readonly string DynamicMemory = "dynamic_memory";
public static readonly string Tpm = "tpm";
}

public static class CapabilityDetails
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,6 @@ public class VirtualMachineData : MachineData

public VirtualMachineFirmwareData Firmware { get; set; }

public VirtualMachineSecurityData Security { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Eryph.Resources.Machines;

public class VirtualMachineSecurityData
{
public bool TpmEnabled { get; init; }

public bool KsdEnabled { get; init; }

public bool Shielded { get; init; }

public bool EncryptStateAndVmMigrationTraffic { get; init; }

public bool VirtualizationBasedSecurityOptOut { get; init; }

public bool BindToHostTpm { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public override async Task<Either<Error, TypedPsObject<VirtualMachineInfo>>> Con
?? "MicrosoftWindows";

var onOffState = (secureBootCapability.Details?.Any(x =>
string.Equals(x, EryphConstants.CapabilityDetails.Disabled, StringComparison.InvariantCultureIgnoreCase))).GetValueOrDefault() ? OnOffState.Off : OnOffState.On;
string.Equals(x, EryphConstants.CapabilityDetails.Disabled, StringComparison.OrdinalIgnoreCase))).GetValueOrDefault() ? OnOffState.Off : OnOffState.On;

return await (from currentFirmware in Context.Engine.GetObjectsAsync<VMFirmwareInfo>(new PsCommandBuilder()
.AddCommand("get-VMFirmware")
Expand Down
156 changes: 156 additions & 0 deletions src/core/src/Eryph.VmManagement/Converging/ConvergeTpm.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System;
using System.Threading.Tasks;
using Eryph.ConfigModel.Catlets;
using Eryph.Core;
using Eryph.VmManagement.Data.Core;
using Eryph.VmManagement.Data.Full;
using LanguageExt;
using LanguageExt.Common;

using static LanguageExt.Prelude;

namespace Eryph.VmManagement.Converging;

/// <summary>
/// This task converges the settings for the TPM.
/// </summary>
/// <remarks>
/// <para>
/// The TPM can only be enabled after creating a key protector
/// with <c>Set-VMKeyProtector</c>.
/// </para>
/// <para>
/// The key protector itself is protected by an HGS guardian.
/// We create a dedicated one for eryph with <c>New-HgsGuardian</c>.
/// The guardian stores a signing and an encryption certificate
/// in the computer certificate store. These certificates (and
/// their private keys) are required to access the TPMs inside
/// the catlets.
/// </para>
/// </remarks>
public class ConvergeTpm(ConvergeContext context) : ConvergeTaskBase(context)
{
public override Task<Either<Error, TypedPsObject<VirtualMachineInfo>>> Converge(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
ConvergeTpmState(vmInfo).ToEither();

private EitherAsync<Error, TypedPsObject<VirtualMachineInfo>> ConvergeTpmState(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from _ in RightAsync<Error, Unit>(unit)
let tpmCapability = Context.Config.Capabilities.ToSeq()
.Find(c => c.Name == EryphConstants.Capabilities.Tpm)
let expectedTpmState = tpmCapability.Map(IsEnabled).IfNone(false)
from vmSecurityInfo in GetVmSecurityInfo(vmInfo)
let currentTpmState = vmSecurityInfo.TpmEnabled
from __ in expectedTpmState == currentTpmState
? RightAsync<Error, Unit>(unit)
: ConfigureTpm(vmInfo, expectedTpmState)
from updatedVmInfo in vmInfo.RecreateOrReload(Context.Engine)
select updatedVmInfo;

private EitherAsync<Error, VMSecurityInfo> GetVmSecurityInfo(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from _ in RightAsync<Error, Unit>(unit)
let command = PsCommandBuilder.Create()
.AddCommand("Get-VMSecurity")
.AddParameter("VM", vmInfo.PsObject)
from vmSecurityInfos in Context.Engine.GetObjectValuesAsync<VMSecurityInfo>(command)
.ToError()
from vMSecurityInfo in vmSecurityInfos.HeadOrNone()
.ToEitherAsync(Error.New($"Failed to fetch security information for the VM {vmInfo.Value.Id}."))
select vMSecurityInfo;

private EitherAsync<Error, Unit> ConfigureTpm(
TypedPsObject<VirtualMachineInfo> vmInfo,
bool enableTpm) =>
enableTpm ? EnableTpm(vmInfo) : DisableTpm(vmInfo);

private EitherAsync<Error, Unit> EnableTpm(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from _ in EnsureKeyProtector(vmInfo)
let command = PsCommandBuilder.Create()
.AddCommand("Enable-VMTPM")
.AddParameter("VM", vmInfo.PsObject)
from __ in Context.Engine.RunAsync(command).ToError().ToAsync()
select unit;

private EitherAsync<Error, Unit> EnsureKeyProtector(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from _ in RightAsync<Error, Unit>(unit)
let getCommand = PsCommandBuilder.Create()
.AddCommand("Get-VMKeyProtector")
.AddParameter("VM", vmInfo.PsObject)
from vmKeyProtectors in Context.Engine.GetObjectValuesAsync<byte[]>(getCommand)
.ToError()
let hasKeyProtector = vmKeyProtectors.HeadOrNone()
// Get-VMKeyProtector returns the protector as a byte array. When a proper
// protector exists, the byte array contains XML describing the protector.
// Even when no protector exists, Hyper-V returns a short byte array (e.g.
// [0, 0, 0, 4]). Hence, we just check for a minimal length.
.Filter(p => p.Length >= 16)
.IsSome
// We cannot change the key protector when one is present as this would brick the
// TPM and prevent the VM from starting. When the user manually enabled the TPM
// with a different protector, we just need to keep that protector.
from __ in hasKeyProtector
? RightAsync<Error, Unit>(unit)
: CreateKeyProtector(vmInfo)
select unit;

private EitherAsync<Error, Unit> CreateKeyProtector(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from guardian in EnsureHgsGuardian()
let createCommand = PsCommandBuilder.Create()
.AddCommand("New-HgsKeyProtector")
.AddParameter("Owner", guardian.PsObject)
// AllowUntrustedRoot is required as we use an HSG guardian with locally
// generated certificates which are self-signed.
.AddParameter("AllowUntrustedRoot")
from protectors in Context.Engine.GetObjectsAsync<CimHgsKeyProtector>(createCommand)
.ToError().ToAsync()
from protector in protectors.HeadOrNone()
.ToEitherAsync(Error.New("Failed to create HGS key protector."))
let command = PsCommandBuilder.Create()
.AddCommand("Set-VMKeyProtector")
.AddParameter("VM", vmInfo.PsObject)
.AddParameter("KeyProtector", protector.Value.RawData)
from _ in Context.Engine.RunAsync(command).ToError().ToAsync()
select unit;

private EitherAsync<Error, TypedPsObject<CimHgsGuardian>> EnsureHgsGuardian() =>
from _ in RightAsync<Error, Unit>(unit)
let command = PsCommandBuilder.Create()
.AddCommand("Get-HgsGuardian")
from existingGuardians in Context.Engine.GetObjectsAsync<CimHgsGuardian>(command)
.ToError()
.ToAsync()
from guardian in existingGuardians
.Find(g => g.Value.Name == EryphConstants.HgsGuardianName)
.Match(Some: g => g, None: CreateHgsGuardian)
select guardian;

private EitherAsync<Error, TypedPsObject<CimHgsGuardian>> CreateHgsGuardian() =>
from _ in RightAsync<Error, Unit>(unit)
let command = PsCommandBuilder.Create()
.AddCommand("New-HgsGuardian")
.AddParameter("Name", EryphConstants.HgsGuardianName)
.AddParameter("GenerateCertificates")
from results in Context.Engine.GetObjectsAsync<CimHgsGuardian>(command)
.ToError().ToAsync()
from guardian in results.HeadOrNone()
.ToEitherAsync(Error.New("Failed to create HGS guardian."))
select guardian;

private EitherAsync<Error, Unit> DisableTpm(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from _ in RightAsync<Error, Unit>(unit)
let command = PsCommandBuilder.Create()
.AddCommand("Disable-VMTPM")
.AddParameter("VM", vmInfo.PsObject)
from __ in Context.Engine.RunAsync(command).ToError().ToAsync()
select unit;

private static bool IsEnabled(CatletCapabilityConfig capabilityConfig) =>
capabilityConfig.Details.ToSeq()
.All(d => !string.Equals(d, EryphConstants.CapabilityDetails.Disabled, StringComparison.OrdinalIgnoreCase));
}
24 changes: 24 additions & 0 deletions src/core/src/Eryph.VmManagement/Data/Core/CimHgsGuardian.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Threading.Tasks;
using JetBrains.Annotations;

namespace Eryph.VmManagement.Data.Core;

/// <summary>
/// Represents an HGS guardian as returned by the Cmdlet
/// <c>Get-HgsGuardian</c>.
/// </summary>
public class CimHgsGuardian
{
[CanBeNull] public string Name { get; init; }

public bool HasPrivateSigningKey { get; init; }

public X509Certificate2 EncryptionCertificate { get; init; }

public X509Certificate2 SigningCertificate { get; init; }
}
18 changes: 18 additions & 0 deletions src/core/src/Eryph.VmManagement/Data/Core/CimHgsKeyProtector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace Eryph.VmManagement.Data.Core;

/// <summary>
/// Represent a HGS key protector as returned by the Cmdlets
/// <c>ConvertTo-HgsKeyProtector</c> or <c>New-HgsKeyProtector</c>.
/// </summary>
public class CimHgsKeyProtector
{
public CimHgsGuardian Owner { get; init; }

public byte[] RawData { get; init; }
}
20 changes: 20 additions & 0 deletions src/core/src/Eryph.VmManagement/Data/Core/VMSecurityInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
namespace Eryph.VmManagement.Data.Core;

/// <summary>
/// Contains information about the security settings of a Hyper-V VM
/// as returned by <c>Get-VMSecurity</c>.
/// </summary>
public class VMSecurityInfo
{
public bool TpmEnabled { get; init; }

public bool KsdEnabled { get; init; }

public bool Shielded { get; init; }

public bool EncryptStateAndVmMigrationTraffic { get; init; }

public bool VirtualizationBasedSecurityOptOut { get; init; }

public bool BindToHostTpm { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
using LanguageExt;
using LanguageExt.Common;

using static LanguageExt.Prelude;

namespace Eryph.VmManagement.Inventory;

public class VirtualMachineInventory(
Expand All @@ -22,13 +24,14 @@ public class VirtualMachineInventory(
public EitherAsync<Error, VirtualMachineData> InventorizeVM(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from hostInfo in hostInfoProvider.GetHostInfoAsync()
from vm in Prelude.RightAsync<Error, TypedPsObject<VirtualMachineInfo>>(vmInfo)
from vm in RightAsync<Error, TypedPsObject<VirtualMachineInfo>>(vmInfo)
from vmStorageSettings in VMStorageSettings.FromVM(vmHostAgentConfig, vm)
from diskStorageSettings in CurrentHardDiskDriveStorageSettings.Detect(
engine, vmHostAgentConfig, vm.GetList(x => x.HardDrives))
from cpuData in GetCpuData(vmInfo)
from memoryData in GetMemoryData(vmInfo)
from firmwareData in GetFirmwareData(vmInfo)
from securityData in GetSecurityData(vmInfo)
from networks in VirtualNetworkQuery.GetNetworksByAdapters(hostInfo, vm.GetList(x => x.NetworkAdapters))
select new VirtualMachineData
{
Expand Down Expand Up @@ -63,6 +66,7 @@ from networks in VirtualNetworkQuery.GetNetworksByAdapters(hostInfo, vm.GetList(
return res;
}).ToArray(),
Networks = networks.ToArray(),
Security = securityData,
};

private EitherAsync<Error, VirtualMachineCpuData> GetCpuData(TypedPsObject<VirtualMachineInfo> vmInfo)
Expand Down Expand Up @@ -102,6 +106,26 @@ private EitherAsync<Error, VirtualMachineFirmwareData> GetFirmwareData(TypedPsOb
));
}

private EitherAsync<Error, VirtualMachineSecurityData> GetSecurityData(
TypedPsObject<VirtualMachineInfo> vmInfo) =>
from _ in RightAsync<Error, Unit>(unit)
let command = PsCommandBuilder.Create()
.AddCommand("Get-VMSecurity")
.AddParameter("VM", vmInfo.PsObject)
from vmSecurityInfos in engine.GetObjectValuesAsync<VMSecurityInfo>(command)
.ToError()
from vMSecurityInfo in vmSecurityInfos.HeadOrNone()
.ToEitherAsync(Error.New($"Failed to fetch security information for the VM {vmInfo.Value.Id}."))
select new VirtualMachineSecurityData
{
BindToHostTpm = vMSecurityInfo.BindToHostTpm,
EncryptStateAndVmMigrationTraffic = vMSecurityInfo.EncryptStateAndVmMigrationTraffic,
KsdEnabled = vMSecurityInfo.KsdEnabled,
Shielded = vMSecurityInfo.Shielded,
TpmEnabled = vMSecurityInfo.TpmEnabled,
VirtualizationBasedSecurityOptOut = vMSecurityInfo.VirtualizationBasedSecurityOptOut,
};

private static Guid GetMetadataId(TypedPsObject<VirtualMachineInfo> vmInfo)
{
var notes = vmInfo.Value.Notes;
Expand Down
13 changes: 13 additions & 0 deletions src/core/src/Eryph.VmManagement/MapperExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
using System;
using System.Linq;
using System.Reflection;
using AutoMapper;
using AutoMapper.Internal;
using Eryph.VmManagement.Data.Core;
using Microsoft.Extensions.Logging;
using Microsoft.Management.Infrastructure;

namespace Eryph.VmManagement;

#pragma warning disable CS0168
public static class MapperExtensions
{
public static IProfileExpression AddCimInstanceMapping<TDestination>(
this IProfileExpression profile)
{
profile.CreateMap<CimInstance, TDestination>().ConvertUsing(
(source, _, context) => context.Mapper.Map<TDestination>(
source.CimInstanceProperties.ToDictionary(p => p.Name, p => p.Value)));

return profile;
}

public static IProfileExpression AddHyperVMapping<TDestination>(this IProfileExpression profile, Assembly assembly, string name, Action<IMappingExpression> configure = null)
{
var sourceType = assembly.GetType($"Microsoft.HyperV.PowerShell.{name}", false);
Expand Down
Loading

0 comments on commit bb9d6a6

Please sign in to comment.