using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text;
namespace SQLInsight.Core.Configuration;
///
/// A SQL login password at rest in the repository, readable only by the agent.
///
/// 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.
///
///
/// The envelope is bound to the server and login it was entered for (): 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.
///
/// Format: version (1) · key id length (1) · key id (UTF-8) · wrapped key length (2, big-endian) · wrapped key · nonce (12) · tag (16) · ciphertext.
///
public static class CredentialEnvelope
{
public const byte FormatVersion = 1;
private const int NonceSize = 12;
private const int TagSize = 16;
private const int KeySize = 32;
/// What a password is bound to: the server (with port) and the login it was entered for.
public static string Context(string server, string? userName) =>
"sqlinsight-credential-v1\n" + (server ?? string.Empty).Trim().ToUpperInvariant() + "\n" + (userName ?? string.Empty).Trim().ToUpperInvariant();
/// Seals for the agent whose public key (SubjectPublicKeyInfo, DER) is given.
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);
}
}
/// The id of the agent key an envelope was sealed for, or null if it is not an envelope.
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]);
}
/// Opens an envelope with the agent's private key. Throws when it cannot.
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);
}
/// A stored password the agent cannot use. The message is Danish and shown to the user.
public sealed class CredentialException : Exception
{
public CredentialException()
{
}
public CredentialException(string message)
: base(message)
{
}
public CredentialException(string message, Exception innerException)
: base(message, innerException)
{
}
}