1559975518
SharedKernel: Autonomy dial enum; IModelClient (ModelRequest/ModelCompletion);
IApiConfigResolver (+ ApiConfigSummary/ResolvedApiConfig) — server-side, decrypted.
Integrations module:
- ApiConfig entity (org-scoped) + IntegrationsDbContext (schema "integrations") +
InitialIntegrations migration; the key is AES-256-GCM encrypted at rest (key derived from
Encryption:MasterKey) and never returned to a client.
- Model adapters: StubModelClient (no-network, provider "stub"/"echo"), an OpenAI-compatible
HTTP adapter, and a ModelClientRouter; ApiConfigResolver decrypts server-side only.
- Endpoints: POST/GET/DELETE /api/integrations/api-configs and POST .../{id}/test. Create/
test/delete require ManageApiKeys (owner); listing requires ConfigureAgents (assign-only,
no key). Dev master key in appsettings; override Encryption__MasterKey in prod.
Verified: build green; ArchitectureTests 8/8 (Integrations references only SharedKernel);
IntegrationTests 26/26 incl. a BYOK flow — key never appears in any response, the connection
test succeeds (stub), and a Member is 403'd from create + list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
73 lines
2.3 KiB
C#
73 lines
2.3 KiB
C#
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace TeamUp.Modules.Integrations.Security;
|
|
|
|
internal sealed class EncryptionOptions
|
|
{
|
|
public const string SectionName = "Encryption";
|
|
|
|
/// <summary>Deployment master secret. A 32-byte AES key is derived from it (SHA-256).</summary>
|
|
public string MasterKey { get; set; } = string.Empty;
|
|
}
|
|
|
|
internal interface ISecretProtector
|
|
{
|
|
string Protect(string plaintext);
|
|
|
|
string Unprotect(string protectedValue);
|
|
}
|
|
|
|
/// <summary>
|
|
/// AES-256-GCM authenticated encryption with a key derived from the deployment master secret.
|
|
/// Output blob = nonce(12) ‖ tag(16) ‖ ciphertext, base64-encoded.
|
|
/// </summary>
|
|
internal sealed class AesGcmSecretProtector : ISecretProtector
|
|
{
|
|
private const int NonceSize = 12;
|
|
private const int TagSize = 16;
|
|
private readonly byte[] _key;
|
|
|
|
public AesGcmSecretProtector(IOptions<EncryptionOptions> options)
|
|
{
|
|
var masterKey = options.Value.MasterKey;
|
|
if (string.IsNullOrWhiteSpace(masterKey))
|
|
{
|
|
throw new InvalidOperationException("Missing 'Encryption:MasterKey'.");
|
|
}
|
|
|
|
_key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey));
|
|
}
|
|
|
|
public string Protect(string plaintext)
|
|
{
|
|
var plain = Encoding.UTF8.GetBytes(plaintext);
|
|
var nonce = RandomNumberGenerator.GetBytes(NonceSize);
|
|
var cipher = new byte[plain.Length];
|
|
var tag = new byte[TagSize];
|
|
|
|
using var aes = new AesGcm(_key, TagSize);
|
|
aes.Encrypt(nonce, plain, cipher, tag);
|
|
|
|
var blob = new byte[NonceSize + TagSize + cipher.Length];
|
|
Buffer.BlockCopy(nonce, 0, blob, 0, NonceSize);
|
|
Buffer.BlockCopy(tag, 0, blob, NonceSize, TagSize);
|
|
Buffer.BlockCopy(cipher, 0, blob, NonceSize + TagSize, cipher.Length);
|
|
return Convert.ToBase64String(blob);
|
|
}
|
|
|
|
public string Unprotect(string protectedValue)
|
|
{
|
|
var blob = Convert.FromBase64String(protectedValue);
|
|
var nonce = blob.AsSpan(0, NonceSize);
|
|
var tag = blob.AsSpan(NonceSize, TagSize);
|
|
var cipher = blob.AsSpan(NonceSize + TagSize);
|
|
var plain = new byte[cipher.Length];
|
|
|
|
using var aes = new AesGcm(_key, TagSize);
|
|
aes.Decrypt(nonce, cipher, tag, plain);
|
|
return Encoding.UTF8.GetString(plain);
|
|
}
|
|
}
|