DatabaseSelection decides which databases the database-specific checks run in (system databases always), and which may send Dynamics AX findings to the hub (default none). CredentialEnvelope seals a SQL login password with the agent's RSA public key (RSA-OAEP-SHA256 + AES-256-GCM), bound to server and login, so only the agent can open it. Shared contracts for connection tests and discovery, server address validation, the service account login rule, and the least-privilege GRANT script filled in for one login. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
160 lines
6.9 KiB
C#
160 lines
6.9 KiB
C#
using System.Buffers.Binary;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
|
|
namespace SQLInsight.Core.Configuration;
|
|
|
|
/// <summary>
|
|
/// A SQL login password at rest in the repository, readable only by the agent.
|
|
/// <para>
|
|
/// The agent has an RSA key pair. The private key never leaves the agent's computer (protected with DPAPI
|
|
/// LocalMachine on Windows); the public key is published in the repository (insight.AgentKeys). SQLInsight Web seals a
|
|
/// password with the public key and can never open it again, so neither the web UI, the API nor anyone who can read the
|
|
/// repository can get it back. Each password gets its own AES-256-GCM key, wrapped with RSA-OAEP-SHA256.
|
|
/// </para>
|
|
/// <para>
|
|
/// The envelope is bound to the server and login it was entered for (<see cref="Context"/>): moving the ciphertext to
|
|
/// another row, or pointing the row at another server, makes it unreadable, so the agent never sends a password to a
|
|
/// server it was not entered for.
|
|
/// </para>
|
|
/// Format: version (1) · key id length (1) · key id (UTF-8) · wrapped key length (2, big-endian) · wrapped key · nonce (12) · tag (16) · ciphertext.
|
|
/// </summary>
|
|
public static class CredentialEnvelope
|
|
{
|
|
public const byte FormatVersion = 1;
|
|
private const int NonceSize = 12;
|
|
private const int TagSize = 16;
|
|
private const int KeySize = 32;
|
|
|
|
/// <summary>What a password is bound to: the server (with port) and the login it was entered for.</summary>
|
|
public static string Context(string server, string? userName) =>
|
|
"sqlinsight-credential-v1\n" + (server ?? string.Empty).Trim().ToUpperInvariant() + "\n" + (userName ?? string.Empty).Trim().ToUpperInvariant();
|
|
|
|
/// <summary>Seals <paramref name="secret"/> for the agent whose public key (SubjectPublicKeyInfo, DER) is given.</summary>
|
|
public static byte[] Seal(string secret, string keyId, byte[] publicKey, string context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(secret);
|
|
ArgumentException.ThrowIfNullOrEmpty(keyId);
|
|
ArgumentNullException.ThrowIfNull(publicKey);
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var keyIdBytes = Encoding.UTF8.GetBytes(keyId);
|
|
if (keyIdBytes.Length > byte.MaxValue)
|
|
{
|
|
throw new ArgumentException("Nøgle-id'et er for langt.", nameof(keyId));
|
|
}
|
|
|
|
using var rsa = RSA.Create();
|
|
rsa.ImportSubjectPublicKeyInfo(publicKey, out _);
|
|
var key = RandomNumberGenerator.GetBytes(KeySize);
|
|
try
|
|
{
|
|
var wrapped = rsa.Encrypt(key, RSAEncryptionPadding.OaepSHA256);
|
|
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
|
var plaintext = Encoding.UTF8.GetBytes(secret);
|
|
var ciphertext = new byte[plaintext.Length];
|
|
var tag = new byte[TagSize];
|
|
using (var aes = new AesGcm(key, TagSize))
|
|
{
|
|
aes.Encrypt(nonce, plaintext, ciphertext, tag, AssociatedData(keyId, context));
|
|
}
|
|
|
|
CryptographicOperations.ZeroMemory(plaintext);
|
|
var envelope = new byte[1 + 1 + keyIdBytes.Length + 2 + wrapped.Length + NonceSize + TagSize + ciphertext.Length];
|
|
var at = 0;
|
|
envelope[at++] = FormatVersion;
|
|
envelope[at++] = (byte)keyIdBytes.Length;
|
|
keyIdBytes.CopyTo(envelope, at);
|
|
at += keyIdBytes.Length;
|
|
BinaryPrimitives.WriteUInt16BigEndian(envelope.AsSpan(at), (ushort)wrapped.Length);
|
|
at += 2;
|
|
wrapped.CopyTo(envelope, at);
|
|
at += wrapped.Length;
|
|
nonce.CopyTo(envelope, at);
|
|
at += NonceSize;
|
|
tag.CopyTo(envelope, at);
|
|
at += TagSize;
|
|
ciphertext.CopyTo(envelope, at);
|
|
return envelope;
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(key);
|
|
}
|
|
}
|
|
|
|
/// <summary>The id of the agent key an envelope was sealed for, or null if it is not an envelope.</summary>
|
|
public static string? KeyIdOf(byte[]? envelope)
|
|
{
|
|
if (envelope is null || envelope.Length < 2 || envelope[0] != FormatVersion || envelope.Length < 2 + envelope[1])
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return Encoding.UTF8.GetString(envelope, 2, envelope[1]);
|
|
}
|
|
|
|
/// <summary>Opens an envelope with the agent's private key. Throws <see cref="CredentialException"/> when it cannot.</summary>
|
|
public static string Open(byte[] envelope, string keyId, RSA privateKey, string context)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(envelope);
|
|
ArgumentNullException.ThrowIfNull(privateKey);
|
|
ArgumentNullException.ThrowIfNull(context);
|
|
var sealedFor = KeyIdOf(envelope) ?? throw new CredentialException("Adgangskoden er ikke gemt i et format, agenten kender.");
|
|
if (!string.Equals(sealedFor, keyId, StringComparison.Ordinal))
|
|
{
|
|
throw new CredentialException("Adgangskoden er krypteret til en anden agent-nøgle (fx før agenten blev geninstalleret). Indtast den igen.");
|
|
}
|
|
|
|
try
|
|
{
|
|
var at = 2 + envelope[1];
|
|
var wrappedLength = BinaryPrimitives.ReadUInt16BigEndian(envelope.AsSpan(at));
|
|
at += 2;
|
|
var wrapped = envelope.AsSpan(at, wrappedLength).ToArray();
|
|
at += wrappedLength;
|
|
var nonce = envelope.AsSpan(at, NonceSize);
|
|
at += NonceSize;
|
|
var tag = envelope.AsSpan(at, TagSize);
|
|
at += TagSize;
|
|
var ciphertext = envelope.AsSpan(at);
|
|
var key = privateKey.Decrypt(wrapped, RSAEncryptionPadding.OaepSHA256);
|
|
try
|
|
{
|
|
var plaintext = new byte[ciphertext.Length];
|
|
using var aes = new AesGcm(key, TagSize);
|
|
aes.Decrypt(nonce, ciphertext, tag, plaintext, AssociatedData(keyId, context));
|
|
var secret = Encoding.UTF8.GetString(plaintext);
|
|
CryptographicOperations.ZeroMemory(plaintext);
|
|
return secret;
|
|
}
|
|
finally
|
|
{
|
|
CryptographicOperations.ZeroMemory(key);
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is CryptographicException or ArgumentException)
|
|
{
|
|
throw new CredentialException("Adgangskoden kan ikke dekrypteres. Er server eller login ændret, uden at adgangskoden blev indtastet igen? Indtast den igen.", ex);
|
|
}
|
|
}
|
|
|
|
private static byte[] AssociatedData(string keyId, string context) => Encoding.UTF8.GetBytes(keyId + "\n" + context);
|
|
}
|
|
|
|
/// <summary>A stored password the agent cannot use. The message is Danish and shown to the user.</summary>
|
|
public sealed class CredentialException : Exception
|
|
{
|
|
public CredentialException()
|
|
{
|
|
}
|
|
|
|
public CredentialException(string message)
|
|
: base(message)
|
|
{
|
|
}
|
|
|
|
public CredentialException(string message, Exception innerException)
|
|
: base(message, innerException)
|
|
{
|
|
}
|
|
}
|