Files
Teamup/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs
T
soroush.asadi 1559975518 M3: BYOK — encrypted owner-only API configs + model adapters
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>
2026-06-09 23:26:28 +03:30

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