sqlinsight-pro/src/SQLInsight.Core/Configuration/CredentialEnvelope.cs
Søren Eggert Lundsteen Olsen 8776faefb3 Instance configuration core: database selection, sealed credentials, agent request contracts
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>
2026-10-02 13:21:59 +02:00

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)
{
}
}