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