Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using Squid.Core.Services.DeploymentExecution.Transport;
using Squid.Core.Services.DeploymentExecution.Variables;
using Squid.Core.Services.Security;
using Squid.Message.Constants;
using Squid.Message.Models.Deployments.Machine;
using Squid.Message.Models.Deployments.Variable;
Expand All @@ -8,6 +9,17 @@ namespace Squid.Core.Services.DeploymentExecution.Ssh;

public class SshEndpointVariableContributor : IEndpointVariableContributor
{
private readonly IAtRestSecretProtector _protector;

// Optional: when DI-resolved on the deploy path the real protector decrypts the at-rest ProxyPassword
// before it is contributed as the SSH connection variable. When constructed without one (the SSH
// health check's internal use — which reads ProxyPassword separately — or tests), the value passes
// through; since Unprotect is read-both, a legacy plaintext is identical either way.
public SshEndpointVariableContributor(IAtRestSecretProtector protector = null)
{
_protector = protector;
}

public EndpointResourceReferences ParseResourceReferences(string endpointJson)
{
var endpoint = EndpointVariableFactory.TryDeserialize<SshEndpointDto>(endpointJson);
Expand All @@ -28,6 +40,12 @@ public List<VariableDto> ContributeVariables(EndpointContext context)

var accountData = context.GetAccountData();

// Decrypt the at-rest proxy password before it becomes the SSH connection variable. Read-both:
// a legacy unprefixed plaintext passes through unchanged; no protector (non-deploy use) = passthrough.
var proxyPassword = _protector != null
? _protector.Unprotect(endpoint.ProxyPassword, SshEndpointDto.ProxyPasswordKdfScope)
: endpoint.ProxyPassword;

var vars = new List<VariableDto>
{
EndpointVariableFactory.Make(SpecialVariables.Machine.Hostname, endpoint.Host ?? string.Empty),
Expand All @@ -39,7 +57,7 @@ public List<VariableDto> ContributeVariables(EndpointContext context)
EndpointVariableFactory.Make(SpecialVariables.Ssh.ProxyHost, endpoint.ProxyHost ?? string.Empty),
EndpointVariableFactory.Make(SpecialVariables.Ssh.ProxyPort, endpoint.ProxyPort.ToString()),
EndpointVariableFactory.Make(SpecialVariables.Ssh.ProxyUsername, endpoint.ProxyUsername ?? string.Empty),
EndpointVariableFactory.Make(SpecialVariables.Ssh.ProxyPassword, endpoint.ProxyPassword ?? string.Empty, isSensitive: true),
EndpointVariableFactory.Make(SpecialVariables.Ssh.ProxyPassword, proxyPassword ?? string.Empty, isSensitive: true),
EndpointVariableFactory.Make(SpecialVariables.Ssh.PackageBaseDirectory, PackageBaseDirectory(endpoint.RemoteWorkingDirectory))
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using Squid.Core.Persistence.Entities.Deployments;
using Squid.Core.Services.DeploymentExecution.Transport;
using Squid.Core.Services.DeploymentExecution.Variables;
using Squid.Core.Services.Security;
using Squid.Message.Constants;
using Squid.Message.Enums;
using Squid.Message.Models.Deployments.Account;
Expand All @@ -16,11 +17,16 @@ public class SshHealthCheckStrategy : IHealthCheckStrategy

private readonly IEndpointContextBuilder _endpointContextBuilder;
private readonly ISshConnectionFactory _connectionFactory;
private readonly IAtRestSecretProtector _protector;

public SshHealthCheckStrategy(IEndpointContextBuilder endpointContextBuilder, ISshConnectionFactory connectionFactory)
// Protector optional: DI supplies the real one so the at-rest proxy password is decrypted before the
// health probe builds its connection. Without one (tests), the read-both Unprotect passes a legacy
// plaintext through unchanged.
public SshHealthCheckStrategy(IEndpointContextBuilder endpointContextBuilder, ISshConnectionFactory connectionFactory, IAtRestSecretProtector protector = null)
{
_endpointContextBuilder = endpointContextBuilder;
_connectionFactory = connectionFactory;
_protector = protector;
}

public async Task<HealthCheckResult> CheckHealthAsync(Machine machine, MachineConnectivityPolicyDto connectivityPolicy, CancellationToken ct, MachineHealthCheckPolicyDto healthCheckPolicy = null)
Expand Down Expand Up @@ -62,7 +68,13 @@ private async Task<SshConnectionInfo> BuildConnectionInfoAsync(Machine machine,

var timeout = TimeSpan.FromSeconds(connectivityPolicy?.ConnectTimeoutSeconds ?? DefaultConnectTimeoutSeconds);

return new SshConnectionInfo(endpoint.Host, endpoint.Port, username, privateKey, passphrase, password, endpoint.Fingerprint, timeout, endpoint.ProxyType, endpoint.ProxyHost, endpoint.ProxyPort, endpoint.ProxyUsername, endpoint.ProxyPassword);
// Decrypt the at-rest proxy password before building the probe connection. Read-both: a legacy
// plaintext passes through; no protector (tests) = passthrough.
var proxyPassword = _protector != null
? _protector.Unprotect(endpoint.ProxyPassword, SshEndpointDto.ProxyPasswordKdfScope)
: endpoint.ProxyPassword;

return new SshConnectionInfo(endpoint.Host, endpoint.Port, username, privateKey, passphrase, password, endpoint.Fingerprint, timeout, endpoint.ProxyType, endpoint.ProxyHost, endpoint.ProxyPort, endpoint.ProxyUsername, proxyPassword);
}

private HealthCheckResult ProbeConnectivity(SshConnectionInfo connectionInfo, string customScript)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,14 @@ public async Task<RegisterMachineResponseData> RegisterSshAsync(RegisterSshComma
};
}

private static string BuildSshEndpointJson(RegisterSshCommand command)
private string BuildSshEndpointJson(RegisterSshCommand command)
{
// Encrypt the proxy password at rest before it lands in the endpoint JSON column. Idempotent +
// read-both via the shared protector; without a protector (tests) it is stored as-is.
var proxyPassword = _protector != null
? _protector.Protect(command.ProxyPassword, SshEndpointDto.ProxyPasswordKdfScope)
: command.ProxyPassword;

return JsonSerializer.Serialize(new SshEndpointDto
{
CommunicationStyle = nameof(CommunicationStyleEnum.Ssh),
Expand All @@ -36,7 +42,7 @@ private static string BuildSshEndpointJson(RegisterSshCommand command)
ProxyHost = command.ProxyHost,
ProxyPort = command.ProxyPort,
ProxyUsername = command.ProxyUsername,
ProxyPassword = command.ProxyPassword,
ProxyPassword = proxyPassword,
ResourceReferences = command.ResourceReferences
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,24 @@ public partial class MachineRegistrationService : IMachineRegistrationService
private readonly IEnvironmentDataProvider _environmentDataProvider;
private readonly IPollingTrustDistributor _trustDistributor;
private readonly SelfCertSetting _selfCertSetting;
private readonly Security.IAtRestSecretProtector _protector;

// Protector optional: DI supplies the real one so an SSH proxy password is encrypted at rest before
// the endpoint JSON is persisted. Without one (tests), it is stored as-is (pre-feature behaviour).
public MachineRegistrationService(
IMachineDataProvider dataProvider,
IMachinePolicyDataProvider policyDataProvider,
IEnvironmentDataProvider environmentDataProvider,
IPollingTrustDistributor trustDistributor,
SelfCertSetting selfCertSetting)
SelfCertSetting selfCertSetting,
Security.IAtRestSecretProtector protector = null)
{
_dataProvider = dataProvider;
_policyDataProvider = policyDataProvider;
_environmentDataProvider = environmentDataProvider;
_trustDistributor = trustDistributor;
_selfCertSetting = selfCertSetting;
_protector = protector;
}

private async Task EnsureUniqueNameAsync(string name, int spaceId, CancellationToken ct)
Expand Down
16 changes: 13 additions & 3 deletions src/Squid.Core/Services/Machines/MachineService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,17 @@ public class MachineService : IMachineService
private readonly IMachineDataProvider _machineDataProvider;
private readonly IPollingTrustDistributor _trustDistributor;
private readonly IMachineRuntimeCapabilitiesCache _runtimeCache;
private readonly Security.IAtRestSecretProtector _protector;

public MachineService(IMapper mapper, IMachineDataProvider machineDataProvider, IPollingTrustDistributor trustDistributor, IMachineRuntimeCapabilitiesCache runtimeCache)
// Protector optional: DI supplies the real one so an updated SSH proxy password is re-encrypted at
// rest. Without one (tests), the value is stored as-is (pre-feature behaviour).
public MachineService(IMapper mapper, IMachineDataProvider machineDataProvider, IPollingTrustDistributor trustDistributor, IMachineRuntimeCapabilitiesCache runtimeCache, Security.IAtRestSecretProtector protector = null)
{
_mapper = mapper;
_machineDataProvider = machineDataProvider;
_trustDistributor = trustDistributor;
_runtimeCache = runtimeCache;
_protector = protector;
}

public async Task<GetMachinesResponse> GetMachinesAsync(GetMachinesRequest request, CancellationToken cancellationToken)
Expand Down Expand Up @@ -233,7 +237,7 @@ void Check(bool isSet, string fieldName, params CommunicationStyle[] allowed)
/// <c>endpoint.X = command.X ?? endpoint.X</c> ladder — any field the
/// command doesn't set is preserved from the existing endpoint JSON.
/// </summary>
private static void ApplyEndpointUpdate(Persistence.Entities.Deployments.Machine machine, CommunicationStyle style, UpdateMachineCommand c)
private void ApplyEndpointUpdate(Persistence.Entities.Deployments.Machine machine, CommunicationStyle style, UpdateMachineCommand c)
{
machine.Endpoint = style switch
{
Expand Down Expand Up @@ -281,7 +285,7 @@ private static string MergeOpenClaw(string json, UpdateMachineCommand c)
return JsonSerializer.Serialize(e);
}

private static string MergeSsh(string json, UpdateMachineCommand c)
private string MergeSsh(string json, UpdateMachineCommand c)
{
var e = Deserialise<SshEndpointDto>(json);
e.Host = c.Host ?? e.Host;
Expand All @@ -294,6 +298,12 @@ private static string MergeSsh(string json, UpdateMachineCommand c)
e.ProxyUsername = c.ProxyUsername ?? e.ProxyUsername;
e.ProxyPassword = c.ProxyPassword ?? e.ProxyPassword;
e.ResourceReferences = c.ResourceReferences ?? e.ResourceReferences;

// Encrypt the merged proxy password at rest. Protect is idempotent: a plaintext from the command
// is encrypted; an unchanged value carried from the existing (already-encrypted) endpoint passes
// through untouched. Without a protector (tests), the value is stored as-is.
if (_protector != null) e.ProxyPassword = _protector.Protect(e.ProxyPassword, SshEndpointDto.ProxyPasswordKdfScope);

return JsonSerializer.Serialize(e);
}

Expand Down
9 changes: 9 additions & 0 deletions src/Squid.Core/Services/Security/AtRestSecretProtector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ public interface IAtRestSecretProtector : IScopedDependency
/// <summary>Decrypt an at-rest <paramref name="value"/>, read-both: a legacy unprefixed plaintext
/// is returned verbatim. <paramref name="kdfScope"/> must match the scope used to Protect it.</summary>
Task<string> UnprotectAsync(string value, int kdfScope);

/// <summary>Synchronous <see cref="UnprotectAsync"/> — decrypt is pure CPU work (no I/O), so a caller
/// on a synchronous path (e.g. an endpoint variable contributor) can decrypt without an async
/// signature. Same read-both contract: legacy unprefixed plaintext passes through verbatim.</summary>
string Unprotect(string value, int kdfScope);
}

public sealed class AtRestSecretProtector(IVariableEncryptionService encryption) : IAtRestSecretProtector
Expand All @@ -54,4 +59,8 @@ public string Protect(string value, int kdfScope)
public Task<string> UnprotectAsync(string value, int kdfScope)
// DecryptAsync is read-both: an unprefixed plaintext value passes through verbatim.
=> encryption.DecryptAsync(value, kdfScope);

public string Unprotect(string value, int kdfScope)
// Decrypt is the synchronous core of DecryptAsync (pure CPU); same read-both passthrough.
=> encryption.Decrypt(value, kdfScope);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ public interface IVariableEncryptionService : IScopedDependency

Task<string> DecryptAsync(string encryptedText, int variableSetId);

/// <summary>Synchronous decrypt — DecryptAsync's work is pure CPU (AES-GCM + PBKDF2, no I/O), so
/// callers on a sync path can decrypt without an async signature. Read-both: a value lacking the
/// envelope prefix (legacy plaintext) is returned verbatim.</summary>
string Decrypt(string encryptedText, int variableSetId);

Task<List<Variable>> EncryptSensitiveVariablesAsync(
List<Variable> variables,
int variableSetId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,13 @@ public string EncryptAsync(string plainText, int variableSetId)
}
}

public async Task<string> DecryptAsync(string encryptedText, int variableSetId)
// DecryptAsync's work is entirely synchronous CPU (AES-GCM + PBKDF2 — no I/O), so it delegates to
// this sync core. Callers on a synchronous path (e.g. IEndpointVariableContributor.ContributeVariables)
// can decrypt without forcing their signatures async. The "Async" name is kept for the existing API.
public Task<string> DecryptAsync(string encryptedText, int variableSetId)
=> Task.FromResult(Decrypt(encryptedText, variableSetId));

public string Decrypt(string encryptedText, int variableSetId)
{
if (string.IsNullOrEmpty(encryptedText) || !IsValidEncryptedValue(encryptedText))
return encryptedText;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ namespace Squid.Message.Models.Deployments.Machine;

public class SshEndpointDto
{
/// <summary>KDF scope used when encrypting/decrypting <see cref="ProxyPassword"/> at rest. The V2
/// envelope uses a random per-payload salt so this value is not security-relevant — it is a shared
/// constant so the write seam (machine register/update) and the read seams (deploy variable
/// contributor, SSH health check) agree. Mirrors the KdfScope=0 the column-based providers use.</summary>
public const int ProxyPasswordKdfScope = 0;

public string CommunicationStyle { get; set; }
public string Host { get; set; }
public int Port { get; set; } = 22;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Squid.Core.Persistence.Db;
using Squid.Core.Persistence.Entities.Deployments;
using Squid.Core.Services.DeploymentExecution.Ssh;
using Squid.Core.Services.DeploymentExecution.Transport;
using Squid.Core.Services.Machines;
using Squid.Core.Services.Security;
using Squid.Message.Constants;
using Squid.Message.Models.Deployments.Machine;

namespace Squid.IntegrationTests.Services.Machines;

/// <summary>
/// End-to-end (real Postgres + real DI + real AES-256-GCM) coverage of the SSH proxy-password at-rest
/// READ seam: a proxy password encrypted via the real <see cref="IAtRestSecretProtector"/> and persisted
/// inside a machine's endpoint JSON (Postgres jsonb) is transparently decrypted by the DI-resolved
/// <see cref="SshEndpointVariableContributor"/> — proving the protector is actually injected into the
/// contributor (the read seam is not a silent no-op). A legacy plaintext endpoint reads back verbatim
/// (read-both / non-breaking).
/// </summary>
public class IntegrationSshProxyPasswordAtRest : TestBase
{
public IntegrationSshProxyPasswordAtRest()
: base("SshProxyPasswordAtRest", "squid_it_ssh_proxy_atrest")
{
}

[Fact]
public async Task EncryptedProxyPassword_IsStoredAsEnvelope_AndDiContributorDecryptsIt()
{
const string secret = "proxy-secret-9c3f-VALUE";

var envelope = await Run<IAtRestSecretProtector, string>(p =>
Task.FromResult(p.Protect(secret, SshEndpointDto.ProxyPasswordKdfScope))).ConfigureAwait(false);

envelope.ShouldStartWith("SQUID_ENCRYPTED_V2:", customMessage: "the real protector must emit a V2 envelope.");
envelope.ShouldNotContain(secret);

var machineId = await SeedSshMachineAsync(envelope).ConfigureAwait(false);

await Run<IMachineDataProvider, IEnumerable<IEndpointVariableContributor>>(async (machines, contributors) =>
{
var machine = await machines.GetMachinesByIdAsync(machineId, CancellationToken.None).ConfigureAwait(false);

// The raw column carries the envelope, never the cleartext.
machine.Endpoint.ShouldContain("SQUID_ENCRYPTED_V2:");
machine.Endpoint.ShouldNotContain(secret);

var ssh = contributors.OfType<SshEndpointVariableContributor>().Single();
var vars = ssh.ContributeVariables(new EndpointContext { EndpointJson = machine.Endpoint });

vars.First(v => v.Name == SpecialVariables.Ssh.ProxyPassword).Value.ShouldBe(secret,
customMessage: "the DI-resolved SSH contributor must decrypt the at-rest proxy password — proves the protector is wired into the read seam.");
}).ConfigureAwait(false);
}

[Fact]
public async Task LegacyPlaintextProxyPassword_ReadsBackVerbatim()
{
// A machine registered before encryption has a plaintext proxy password in its endpoint JSON.
// Read-both: the DI contributor returns it unchanged — non-breaking for existing machines.
const string legacy = "legacy-plaintext-proxy";

var machineId = await SeedSshMachineAsync(legacy).ConfigureAwait(false);

await Run<IMachineDataProvider, IEnumerable<IEndpointVariableContributor>>(async (machines, contributors) =>
{
var machine = await machines.GetMachinesByIdAsync(machineId, CancellationToken.None).ConfigureAwait(false);

var ssh = contributors.OfType<SshEndpointVariableContributor>().Single();
var vars = ssh.ContributeVariables(new EndpointContext { EndpointJson = machine.Endpoint });

vars.First(v => v.Name == SpecialVariables.Ssh.ProxyPassword).Value.ShouldBe(legacy);
}).ConfigureAwait(false);
}

private async Task<int> SeedSshMachineAsync(string proxyPassword)
{
var endpointJson = JsonSerializer.Serialize(new SshEndpointDto
{
CommunicationStyle = "Ssh", Host = "ssh-host", Port = 22, ProxyPassword = proxyPassword
});

var machineId = 0;

await Run<IRepository, IUnitOfWork>(async (repository, unitOfWork) =>
{
var machine = new Machine
{
Name = $"ssh-{Guid.NewGuid():N}",
IsDisabled = false,
Roles = "[]",
EnvironmentIds = "[]",
SpaceId = 1,
Endpoint = endpointJson,
Slug = $"ssh-{Guid.NewGuid():N}"
};

await repository.InsertAsync(machine).ConfigureAwait(false);
await unitOfWork.SaveChangesAsync().ConfigureAwait(false);
machineId = machine.Id;
}).ConfigureAwait(false);

return machineId;
}
}
Loading
Loading