commit d478178bde8e7d201afa4c47a4d2d2946d53a4fe Author: nicoeri Date: Mon Feb 2 16:36:48 2026 -0300 Introduce initial implementation of the Aegis backend platform. Includes APIs, data access, cryptography utilities, user resolution middleware, and initial SQLite migrations. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd967fc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..add57be --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ \ No newline at end of file diff --git a/Aegis.API/Aegis.API.csproj b/Aegis.API/Aegis.API.csproj new file mode 100644 index 0000000..6b1223f --- /dev/null +++ b/Aegis.API/Aegis.API.csproj @@ -0,0 +1,27 @@ + + + + net10.0 + enable + enable + true + Linux + + + + + + + + + + .dockerignore + + + + + + + + + diff --git a/Aegis.API/Auth/CurrentUserAccessor.cs b/Aegis.API/Auth/CurrentUserAccessor.cs new file mode 100644 index 0000000..0e5e9e2 --- /dev/null +++ b/Aegis.API/Auth/CurrentUserAccessor.cs @@ -0,0 +1,16 @@ +using System.Security.Claims; +using Aegis.Domain; + +namespace Aegis.API.Auth; + +public sealed class CurrentUserAccessor +{ + public UserId GetUserId(ClaimsPrincipal user) + { + var userId = user.FindFirstValue("aegis_uid"); + if (string.IsNullOrWhiteSpace(userId)) + throw new InvalidOperationException("Missing claim: aegis_uid (middleware not executed?)"); + + return new UserId(userId); + } +} \ No newline at end of file diff --git a/Aegis.API/Auth/UserResolutionMiddleware.cs b/Aegis.API/Auth/UserResolutionMiddleware.cs new file mode 100644 index 0000000..b9e8303 --- /dev/null +++ b/Aegis.API/Auth/UserResolutionMiddleware.cs @@ -0,0 +1,54 @@ +using System.Security.Claims; +using Aegis.Application.Abstractions; +using Microsoft.Extensions.Caching.Memory; + +namespace Aegis.API.Auth; + +public class UserResolutionMiddleware(RequestDelegate next, IMemoryCache cache) +{ + // TTL do cache (ajuste como quiser) + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(1); + public async Task InvokeAsync(HttpContext ctx, IUserIdentityRepository repo, CancellationToken ct) + { + // Só roda para requests autenticadas + if (ctx.User?.Identity?.IsAuthenticated == true) + { + var sub = ctx.User.FindFirstValue("sub"); + var iss = ctx.User.FindFirstValue("iss"); + + if (!string.IsNullOrWhiteSpace(sub) && !string.IsNullOrWhiteSpace(iss)) + { + var cacheKey = $"aegis_uid|{iss}|{sub}"; + + if (!cache.TryGetValue(cacheKey, out string? userIdValue)) + { + var displayName = ctx.User.FindFirstValue("name") + ?? ctx.User.FindFirstValue("preferred_username"); + + var email = ctx.User.FindFirstValue("email"); + + var userId = await repo.GetOrCreateAsync(sub, iss, displayName, email, ct); + userIdValue = userId.Value; + + cache.Set( + cacheKey, + userIdValue, + new MemoryCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = CacheTtl, + SlidingExpiration = TimeSpan.FromMinutes(15) + }); + } + + // Injeta claim aegis_uid se não existir + if (ctx.User.FindFirst("aegis_uid") is null && userIdValue is not null) + { + if (ctx.User.Identity is ClaimsIdentity identity) + identity.AddClaim(new Claim("aegis_uid", userIdValue)); + } + } + } + + await next(ctx); + } +} \ No newline at end of file diff --git a/Aegis.API/Controllers/DataStoreController.cs b/Aegis.API/Controllers/DataStoreController.cs new file mode 100644 index 0000000..039a3cd --- /dev/null +++ b/Aegis.API/Controllers/DataStoreController.cs @@ -0,0 +1,26 @@ +using Aegis.API.Auth; +using Aegis.API.Dtos; +using Aegis.Application.DataStores.ListDataStores; +using Microsoft.AspNetCore.Mvc; + +namespace Aegis.API.Controllers; + +public class DataStoreController(CurrentUserAccessor currentUser, ListDataStoresUseCase listDataStoresUseCase) + : ControllerBase +{ + [HttpGet] + public async Task>> List(CancellationToken ct) + { + var userId = currentUser.GetUserId(User); + var summaries = await listDataStoresUseCase.ExecuteAsync(userId, ct); + + var dtos = summaries.Select(s => new DataStoreDto( + s.DataStoreId.Value, + s.Name, + s.Role.ToString().ToUpperInvariant(), + s.Locked + )).ToList(); + + return Ok(dtos); + } +} \ No newline at end of file diff --git a/Aegis.API/Dockerfile b/Aegis.API/Dockerfile new file mode 100644 index 0000000..a6abdb3 --- /dev/null +++ b/Aegis.API/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["Aegis/Aegis.csproj", "Aegis/"] +RUN dotnet restore "Aegis/Aegis.csproj" +COPY . . +WORKDIR "/src/Aegis" +RUN dotnet build "./Aegis.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./Aegis.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Aegis.dll"] diff --git a/Aegis.API/Dtos/DataStoreDto.cs b/Aegis.API/Dtos/DataStoreDto.cs new file mode 100644 index 0000000..bb0a908 --- /dev/null +++ b/Aegis.API/Dtos/DataStoreDto.cs @@ -0,0 +1,8 @@ +namespace Aegis.API.Dtos; + +public sealed record DataStoreDto( + string DatastoreId, + string Name, + string Role, + bool Locked +); \ No newline at end of file diff --git a/Aegis.API/Program.cs b/Aegis.API/Program.cs new file mode 100644 index 0000000..c5336eb --- /dev/null +++ b/Aegis.API/Program.cs @@ -0,0 +1,85 @@ +using Aegis.API.Auth; +using Aegis.Application.Abstractions; +using Aegis.Application.DataStores.ListDataStores; +using Aegis.Repository.Caching; +using Aegis.Repository.Crypto; +using Aegis.Repository.SQLite; +using Aegis.Repository.SQLite.Repositories; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddOpenApi(); + +builder.Services.AddMemoryCache(); + +var authority = builder.Configuration["Auth:Authority"] + ?? throw new InvalidOperationException("Missing config: Auth:Authority"); +var audience = builder.Configuration["Auth:Audience"] + ?? throw new InvalidOperationException("Missing config: Auth:Audience"); + +builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(o => + { + o.Authority = authority; + o.Audience = audience; + + o.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + NameClaimType = "name", + RoleClaimType = "roles" + }; + }); + +builder.Services.AddAuthorization(); + +var cs = builder.Configuration.GetConnectionString("AegisManifest") + ?? "Data Source=aegis_manifest.db;Cache=Shared;"; +builder.Services.AddSingleton(new SqliteConnectionFactory(cs)); + +// Resolve (iss, sub) -> user_id (1x por TTL) +builder.Services.AddScoped(); + +// ListDatastores +builder.Services.AddScoped(); + +// Unlocked cache (status) +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); + +// Label key / crypto (LK) +var serverSecretB64 = builder.Configuration["Aegis:LabelKey:ServerSecretB64"] + ?? throw new InvalidOperationException("Missing config: Aegis:LabelKey:ServerSecretB64"); +var serverSecret = Convert.FromBase64String(serverSecretB64); + +builder.Services.AddSingleton(new ServerSecretLabelKeyProvider(serverSecret)); +builder.Services.AddSingleton(); + +// Use case +builder.Services.AddScoped(); + +// Helpers API +builder.Services.AddHttpContextAccessor(); +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.UseHttpsRedirection(); + +// Auth pipeline +app.UseAuthentication(); +app.UseMiddleware(); // injeta claim aegis_uid via cache/DB +app.UseAuthorization(); + +app.MapControllers(); + +app.Run(); \ No newline at end of file diff --git a/Aegis.API/Properties/launchSettings.json b/Aegis.API/Properties/launchSettings.json new file mode 100644 index 0000000..83ea3f5 --- /dev/null +++ b/Aegis.API/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5164", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/Aegis.API/appsettings.Development.json b/Aegis.API/appsettings.Development.json new file mode 100644 index 0000000..64b5d12 --- /dev/null +++ b/Aegis.API/appsettings.Development.json @@ -0,0 +1,20 @@ +{ + "Auth": { + "Authority": "https://auth.evolucao.io/application/o/aegis/", + "Audience": "qT1jT7HbAapchwbPVCXTnVmk6iM1icC8p0FqYMvm" + }, + "Aegis": { + "LabelKey": { + "ServerSecretB64": "UP3siJ9K8uESWv7P5/qRcK5/tUl/bk2FB8UkFFdaBgc=" + } + }, + "ConnectionStrings": { + "AegisManifest": "Data Source=aegis_manifest.db;Cache=Shared;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Aegis.API/appsettings.json b/Aegis.API/appsettings.json new file mode 100644 index 0000000..f3e5438 --- /dev/null +++ b/Aegis.API/appsettings.json @@ -0,0 +1,21 @@ +{ + "Auth": { + "Authority": "https://auth.seudominio.com/application/o/aegis/", + "Audience": "aegis-api" + }, + "Aegis": { + "LabelKey": { + "ServerSecretB64": "COLOQUE_UM_BASE64_DE_PELO_MENOS_32_BYTES" + } + }, + "ConnectionStrings": { + "AegisManifest": "Data Source=aegis_manifest.db;Cache=Shared;" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/Aegis.Application/Abstractions/DataStoreRow.cs b/Aegis.Application/Abstractions/DataStoreRow.cs new file mode 100644 index 0000000..1240474 --- /dev/null +++ b/Aegis.Application/Abstractions/DataStoreRow.cs @@ -0,0 +1,12 @@ +using Aegis.Domain; + +namespace Aegis.Application.Abstractions; + +public sealed record DataStoreRow( + DataStoreId DataStoreId, + DataStoreRole Role, + byte[] NameEnc, + byte[] NameNonce, + string LkKid, + int LkVersion +); \ No newline at end of file diff --git a/Aegis.Application/Abstractions/IDataStoreReadRepository.cs b/Aegis.Application/Abstractions/IDataStoreReadRepository.cs new file mode 100644 index 0000000..212ee8b --- /dev/null +++ b/Aegis.Application/Abstractions/IDataStoreReadRepository.cs @@ -0,0 +1,8 @@ +using Aegis.Domain; + +namespace Aegis.Application.Abstractions; + +public interface IDataStoreReadRepository +{ + Task> ListForUserAsync(UserId userId, CancellationToken ct); +} \ No newline at end of file diff --git a/Aegis.Application/Abstractions/ILabelCrypto.cs b/Aegis.Application/Abstractions/ILabelCrypto.cs new file mode 100644 index 0000000..a7a32fd --- /dev/null +++ b/Aegis.Application/Abstractions/ILabelCrypto.cs @@ -0,0 +1,12 @@ +namespace Aegis.Application.Abstractions; + +public interface ILabelCrypto +{ + string DecryptDatastoreName( + byte[] nameEnc, + byte[] nameNonce, + string lkKid, + int lkVersion, + string aad + ); +} \ No newline at end of file diff --git a/Aegis.Application/Abstractions/IUnlockedDataStoreCache.cs b/Aegis.Application/Abstractions/IUnlockedDataStoreCache.cs new file mode 100644 index 0000000..5c8f851 --- /dev/null +++ b/Aegis.Application/Abstractions/IUnlockedDataStoreCache.cs @@ -0,0 +1,8 @@ +using Aegis.Domain; + +namespace Aegis.Application.Abstractions; + +public interface IUnlockedDataStoreCache +{ + bool IsUnlocked(UserId userId, DataStoreId dataStoreId); +} \ No newline at end of file diff --git a/Aegis.Application/Abstractions/IUserIdentityRepository.cs b/Aegis.Application/Abstractions/IUserIdentityRepository.cs new file mode 100644 index 0000000..cecc5bb --- /dev/null +++ b/Aegis.Application/Abstractions/IUserIdentityRepository.cs @@ -0,0 +1,14 @@ +using Aegis.Domain; + +namespace Aegis.Application.Abstractions; + +public interface IUserIdentityRepository +{ + // “Get or create” user_id baseado em OIDC identity + Task GetOrCreateAsync( + string subject, + string issuer, + string? displayName, + string? email, + CancellationToken ct); +} \ No newline at end of file diff --git a/Aegis.Application/Aegis.Application.csproj b/Aegis.Application/Aegis.Application.csproj new file mode 100644 index 0000000..341abd4 --- /dev/null +++ b/Aegis.Application/Aegis.Application.csproj @@ -0,0 +1,13 @@ + + + + net10.0 + enable + enable + + + + + + + diff --git a/Aegis.Application/DataStores/ListDataStores/DataStoreSummary.cs b/Aegis.Application/DataStores/ListDataStores/DataStoreSummary.cs new file mode 100644 index 0000000..b81ea5a --- /dev/null +++ b/Aegis.Application/DataStores/ListDataStores/DataStoreSummary.cs @@ -0,0 +1,10 @@ +using Aegis.Domain; + +namespace Aegis.Application.DataStores.ListDataStores; + +public sealed record DataStoreSummary( + DataStoreId DataStoreId, + string Name, + DataStoreRole Role, + bool Locked +); \ No newline at end of file diff --git a/Aegis.Application/DataStores/ListDataStores/ListDatastoresUseCase.cs b/Aegis.Application/DataStores/ListDataStores/ListDatastoresUseCase.cs new file mode 100644 index 0000000..e1f4b33 --- /dev/null +++ b/Aegis.Application/DataStores/ListDataStores/ListDatastoresUseCase.cs @@ -0,0 +1,45 @@ +using Aegis.Application.Abstractions; +using Aegis.Domain; + +namespace Aegis.Application.DataStores.ListDataStores; + +public sealed class ListDataStoresUseCase +{ + private readonly IDataStoreReadRepository _repo; + private readonly ILabelCrypto _labelCrypto; + private readonly IUnlockedDataStoreCache _unlockedCache; + + public ListDataStoresUseCase( + IDataStoreReadRepository repo, + ILabelCrypto labelCrypto, + IUnlockedDataStoreCache unlockedCache) + { + _repo = repo; + _labelCrypto = labelCrypto; + _unlockedCache = unlockedCache; + } + + public async Task> ExecuteAsync(UserId userId, CancellationToken ct) + { + var rows = await _repo.ListForUserAsync(userId, ct); + + var result = new List(rows.Count); + foreach (var row in rows) + { + var aad = $"aegis:datastore-name:{row.DataStoreId.Value}"; + var name = _labelCrypto.DecryptDatastoreName( + row.NameEnc, + row.NameNonce, + row.LkKid, + row.LkVersion, + aad + ); + + var unlocked = _unlockedCache.IsUnlocked(userId, row.DataStoreId); + result.Add(new DataStoreSummary(row.DataStoreId, name, row.Role, Locked: !unlocked)); + } + + result.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + return result; + } +} \ No newline at end of file diff --git a/Aegis.Domain/Aegis.Domain.csproj b/Aegis.Domain/Aegis.Domain.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/Aegis.Domain/Aegis.Domain.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Aegis.Domain/DataStoreId.cs b/Aegis.Domain/DataStoreId.cs new file mode 100644 index 0000000..961bb99 --- /dev/null +++ b/Aegis.Domain/DataStoreId.cs @@ -0,0 +1,6 @@ +namespace Aegis.Domain; + +public readonly record struct DataStoreId(string Value) +{ + public override string ToString() => Value; +} \ No newline at end of file diff --git a/Aegis.Domain/DataStoreRole.cs b/Aegis.Domain/DataStoreRole.cs new file mode 100644 index 0000000..da8ea26 --- /dev/null +++ b/Aegis.Domain/DataStoreRole.cs @@ -0,0 +1,9 @@ +namespace Aegis.Domain; + +public enum DataStoreRole +{ + Owner, + Admin, + Editor, + Viewer +} \ No newline at end of file diff --git a/Aegis.Domain/UserId.cs b/Aegis.Domain/UserId.cs new file mode 100644 index 0000000..fee1c03 --- /dev/null +++ b/Aegis.Domain/UserId.cs @@ -0,0 +1,6 @@ +namespace Aegis.Domain; + +public readonly record struct UserId(string Value) +{ + public override string ToString() => Value; +} \ No newline at end of file diff --git a/Aegis.Infrastructure/Aegis.Infrastructure.csproj b/Aegis.Infrastructure/Aegis.Infrastructure.csproj new file mode 100644 index 0000000..f069328 --- /dev/null +++ b/Aegis.Infrastructure/Aegis.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + + net10.0 + enable + enable + Aegis.Repository + + + + + + + + + + + + diff --git a/Aegis.Infrastructure/Caching/MemoryUnlockedDatastoreCache.cs b/Aegis.Infrastructure/Caching/MemoryUnlockedDatastoreCache.cs new file mode 100644 index 0000000..51989a9 --- /dev/null +++ b/Aegis.Infrastructure/Caching/MemoryUnlockedDatastoreCache.cs @@ -0,0 +1,39 @@ +using System.Collections.Concurrent; +using Aegis.Application.Abstractions; +using Aegis.Domain; + +namespace Aegis.Repository.Caching; + +public class MemoryUnlockedDatastoreCache : IUnlockedDataStoreCache +{ + // key: "userId|datastoreId" + private readonly ConcurrentDictionary _expirations = new(); + + public bool IsUnlocked(UserId userId, DataStoreId datastoreId) + { + var key = $"{userId.Value}|{datastoreId.Value}"; + if (!_expirations.TryGetValue(key, out var exp)) + return false; + + if (DateTimeOffset.UtcNow >= exp) + { + _expirations.TryRemove(key, out _); + return false; + } + + return true; + } + + // Helper para quando você implementar unlock: + public void MarkUnlocked(UserId userId, DataStoreId datastoreId, TimeSpan ttl) + { + var key = $"{userId.Value}|{datastoreId.Value}"; + _expirations[key] = DateTimeOffset.UtcNow.Add(ttl); + } + + public void Lock(UserId userId, DataStoreId datastoreId) + { + var key = $"{userId.Value}|{datastoreId.Value}"; + _expirations.TryRemove(key, out _); + } +} \ No newline at end of file diff --git a/Aegis.Infrastructure/Crypto/AesGcmLabelCrypto.cs b/Aegis.Infrastructure/Crypto/AesGcmLabelCrypto.cs new file mode 100644 index 0000000..c1bc8f3 --- /dev/null +++ b/Aegis.Infrastructure/Crypto/AesGcmLabelCrypto.cs @@ -0,0 +1,55 @@ +using System.Security.Cryptography; +using System.Text; +using Aegis.Application.Abstractions; + +namespace Aegis.Repository.Crypto; + +public class AesGcmLabelCrypto : ILabelCrypto +{ + private readonly ILabelKeyProvider _keys; + private const int TagSize = 16; + + public AesGcmLabelCrypto(ILabelKeyProvider keys) + => _keys = keys; + + public string DecryptDatastoreName( + byte[] nameEnc, + byte[] nameNonce, + string lkKid, + int lkVersion, + string aad) + { + var key = _keys.GetLabelKey(lkKid, lkVersion); + try + { + return DecryptUtf8AesGcm(key, nameEnc, nameNonce, Encoding.UTF8.GetBytes(aad)); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + private static string DecryptUtf8AesGcm(byte[] key, byte[] cipherWithTag, byte[] nonce, byte[] aad) + { + if (cipherWithTag.Length < TagSize) + throw new CryptographicException("Ciphertext inválido."); + + var cipherLen = cipherWithTag.Length - TagSize; + var ciphertext = new byte[cipherLen]; + var tag = new byte[TagSize]; + + Buffer.BlockCopy(cipherWithTag, 0, ciphertext, 0, cipherLen); + Buffer.BlockCopy(cipherWithTag, cipherLen, tag, 0, TagSize); + + var plaintext = new byte[cipherLen]; + using var aead = new AesGcm(key); + aead.Decrypt(nonce, ciphertext, tag, plaintext, aad); + + var text = Encoding.UTF8.GetString(plaintext); + CryptographicOperations.ZeroMemory(plaintext); + CryptographicOperations.ZeroMemory(ciphertext); + CryptographicOperations.ZeroMemory(tag); + return text; + } +} \ No newline at end of file diff --git a/Aegis.Infrastructure/Crypto/ILabelKeyProvider.cs b/Aegis.Infrastructure/Crypto/ILabelKeyProvider.cs new file mode 100644 index 0000000..7e1b839 --- /dev/null +++ b/Aegis.Infrastructure/Crypto/ILabelKeyProvider.cs @@ -0,0 +1,7 @@ +namespace Aegis.Repository.Crypto; + +public interface ILabelKeyProvider +{ + // Retorna a chave simétrica (32 bytes) para o LK identificado por lkKid/lkVersion + byte[] GetLabelKey(string lkKid, int lkVersion); +} \ No newline at end of file diff --git a/Aegis.Infrastructure/Crypto/ServerSecretLabelKeyProvider.cs b/Aegis.Infrastructure/Crypto/ServerSecretLabelKeyProvider.cs new file mode 100644 index 0000000..c59332c --- /dev/null +++ b/Aegis.Infrastructure/Crypto/ServerSecretLabelKeyProvider.cs @@ -0,0 +1,58 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Aegis.Repository.Crypto; + +public class ServerSecretLabelKeyProvider : ILabelKeyProvider +{ + private readonly byte[] _serverSecret; + + public ServerSecretLabelKeyProvider(byte[] serverSecret) + { + if (serverSecret.Length < 32) + throw new ArgumentException("Server secret deve ter pelo menos 32 bytes."); + + _serverSecret = serverSecret; + } + + public byte[] GetLabelKey(string lkKid, int lkVersion) + { + // HKDF simples via HMACSHA256 (ok para derivação) + // info amarra ao kid+version + var info = Encoding.UTF8.GetBytes($"aegis:lk:{lkKid}:v{lkVersion}"); + return HkdfSha256(_serverSecret, salt: null, info, 32); + } + + private static byte[] HkdfSha256(byte[] ikm, byte[]? salt, byte[] info, int len) + { + salt ??= new byte[32]; // salt zero (ok se ikm for segredo forte) + using var hmac = new HMACSHA256(salt); + var prk = hmac.ComputeHash(ikm); + + var okm = new byte[len]; + byte[] t = Array.Empty(); + var pos = 0; + byte counter = 1; + + using var hmac2 = new HMACSHA256(prk); + while (pos < len) + { + var input = new byte[t.Length + info.Length + 1]; + Buffer.BlockCopy(t, 0, input, 0, t.Length); + Buffer.BlockCopy(info, 0, input, t.Length, info.Length); + input[^1] = counter; + + t = hmac2.ComputeHash(input); + + var take = Math.Min(t.Length, len - pos); + Buffer.BlockCopy(t, 0, okm, pos, take); + + pos += take; + counter++; + } + + CryptographicOperations.ZeroMemory(prk); + CryptographicOperations.ZeroMemory(t); + return okm; + } +} \ No newline at end of file diff --git a/Aegis.Infrastructure/SQLite/Queries/DataStoreQueries.cs b/Aegis.Infrastructure/SQLite/Queries/DataStoreQueries.cs new file mode 100644 index 0000000..f981ca3 --- /dev/null +++ b/Aegis.Infrastructure/SQLite/Queries/DataStoreQueries.cs @@ -0,0 +1,19 @@ +namespace Aegis.Repository.SQLite.Queries; + +public static class DataStoreQueries +{ + public const string ListForUser = """ + SELECT + ds.datastore_id, + m.role, + ds.name_enc, + ds.name_nonce, + ds.lk_kid, + ds.lk_version + FROM datastore_members m + JOIN datastores ds ON ds.datastore_id = m.datastore_id + WHERE m.user_id = $userId + AND ds.deleted_at IS NULL + ORDER BY ds.created_at ASC; + """; +} \ No newline at end of file diff --git a/Aegis.Infrastructure/SQLite/Queries/UserQueries.cs b/Aegis.Infrastructure/SQLite/Queries/UserQueries.cs new file mode 100644 index 0000000..ce226cb --- /dev/null +++ b/Aegis.Infrastructure/SQLite/Queries/UserQueries.cs @@ -0,0 +1,15 @@ +namespace Aegis.Repository.SQLite.Queries; + +public static class UserQueries +{ + // Cria user se não existir (subject+issuer é UNIQUE). + // Em seguida retorna o user_id. + public const string GetOrCreate = """ + INSERT INTO users (user_id, created_at, subject, issuer, display_name, email) + VALUES ($user_id, $created_at, $sub, $iss, $display_name, $email) + ON CONFLICT(subject, issuer) DO UPDATE SET + display_name = COALESCE(excluded.display_name, users.display_name), + email = COALESCE(excluded.email, users.email) + RETURNING user_id; + """; +} \ No newline at end of file diff --git a/Aegis.Infrastructure/SQLite/Repositories/DataStoreReadRepository.cs b/Aegis.Infrastructure/SQLite/Repositories/DataStoreReadRepository.cs new file mode 100644 index 0000000..f333cd5 --- /dev/null +++ b/Aegis.Infrastructure/SQLite/Repositories/DataStoreReadRepository.cs @@ -0,0 +1,43 @@ +using Aegis.Application.Abstractions; +using Aegis.Domain; +using Aegis.Repository.SQLite.Queries; + +namespace Aegis.Repository.SQLite.Repositories; + +public class DataStoreReadRepository(SqliteConnectionFactory factory) : IDataStoreReadRepository +{ + public async Task> ListForUserAsync(UserId userId, CancellationToken ct) + { + await using var conn = factory.Open(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = DataStoreQueries.ListForUser; + cmd.Parameters.AddWithValue("$userId", userId.Value); + + var items = new List(); + + await using var reader = await cmd.ExecuteReaderAsync(ct); + while (await reader.ReadAsync(ct)) + { + var datastoreId = new DataStoreId(reader.GetString(0)); + var role = ParseRole(reader.GetString(1)); + var nameEnc = (byte[])reader["name_enc"]; + var nameNonce = (byte[])reader["name_nonce"]; + var lkKid = reader.GetString(4); + var lkVersion = reader.GetInt32(5); + + items.Add(new DataStoreRow(datastoreId, role, nameEnc, nameNonce, lkKid, lkVersion)); + } + + return items; + } + + private static DataStoreRole ParseRole(string role) + => role.ToUpperInvariant() switch + { + "OWNER" => DataStoreRole.Owner, + "ADMIN" => DataStoreRole.Admin, + "EDITOR" => DataStoreRole.Editor, + "VIEWER" => DataStoreRole.Viewer, + _ => throw new InvalidOperationException($"Unknown role: {role}") + }; +} \ No newline at end of file diff --git a/Aegis.Infrastructure/SQLite/Repositories/UserIdentityRepository.cs b/Aegis.Infrastructure/SQLite/Repositories/UserIdentityRepository.cs new file mode 100644 index 0000000..03d9f93 --- /dev/null +++ b/Aegis.Infrastructure/SQLite/Repositories/UserIdentityRepository.cs @@ -0,0 +1,37 @@ +using Aegis.Application.Abstractions; +using Aegis.Domain; +using Aegis.Repository.SQLite.Queries; + +namespace Aegis.Repository.SQLite.Repositories; + +public class UserIdentityRepository(SqliteConnectionFactory factory) : IUserIdentityRepository +{ + public async Task GetOrCreateAsync( + string subject, + string issuer, + string? displayName, + string? email, + CancellationToken ct) + { + await using var conn = factory.Open(); + await using var cmd = conn.CreateCommand(); + + cmd.CommandText = UserQueries.GetOrCreate; + + cmd.Parameters.AddWithValue("$user_id", NewId()); + cmd.Parameters.AddWithValue("$created_at", DateTimeOffset.UtcNow.ToUnixTimeSeconds()); + cmd.Parameters.AddWithValue("$sub", subject); + cmd.Parameters.AddWithValue("$iss", issuer); + cmd.Parameters.AddWithValue("$display_name", (object?)displayName ?? DBNull.Value); + cmd.Parameters.AddWithValue("$email", (object?)email ?? DBNull.Value); + + var result = await cmd.ExecuteScalarAsync(ct); + if (result is null) + throw new InvalidOperationException("Failed to resolve user_id."); + + return new UserId((string)result); + } + + private static string NewId() + => Guid.NewGuid().ToString("N"); // pode trocar por ULID depois +} \ No newline at end of file diff --git a/Aegis.Infrastructure/SQLite/SqliteConnectionFactory.cs b/Aegis.Infrastructure/SQLite/SqliteConnectionFactory.cs new file mode 100644 index 0000000..612da3e --- /dev/null +++ b/Aegis.Infrastructure/SQLite/SqliteConnectionFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.Data.Sqlite; + +namespace Aegis.Repository.SQLite; + +public sealed class SqliteConnectionFactory +{ + private readonly string _connectionString; + + public SqliteConnectionFactory(string connectionString) + => _connectionString = connectionString; + + public SqliteConnection Open() + { + var conn = new SqliteConnection(_connectionString); + conn.Open(); + + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA temp_store = MEMORY; + """; + cmd.ExecuteNonQuery(); + + return conn; + } +} \ No newline at end of file diff --git a/Aegis.Utils/Aegis.Utils.csproj b/Aegis.Utils/Aegis.Utils.csproj new file mode 100644 index 0000000..237d661 --- /dev/null +++ b/Aegis.Utils/Aegis.Utils.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/Aegis.sln b/Aegis.sln new file mode 100644 index 0000000..eb0ad4d --- /dev/null +++ b/Aegis.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.API", "Aegis.API\Aegis.API.csproj", "{51B1A32B-AB64-4D34-8DEA-687895A27930}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Domain", "Aegis.Domain\Aegis.Domain.csproj", "{3A3B3084-69B1-4350-93D4-980A4D7DED38}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Infrastructure", "Aegis.Infrastructure\Aegis.Infrastructure.csproj", "{94DB7195-18D0-49C2-87C1-2BC1A8722621}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Utils", "Aegis.Utils\Aegis.Utils.csproj", "{9343A17C-D414-4327-BD46-EA10F9278B10}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aegis.Application", "Aegis.Application\Aegis.Application.csproj", "{B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {51B1A32B-AB64-4D34-8DEA-687895A27930}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {51B1A32B-AB64-4D34-8DEA-687895A27930}.Debug|Any CPU.Build.0 = Debug|Any CPU + {51B1A32B-AB64-4D34-8DEA-687895A27930}.Release|Any CPU.ActiveCfg = Release|Any CPU + {51B1A32B-AB64-4D34-8DEA-687895A27930}.Release|Any CPU.Build.0 = Release|Any CPU + {3A3B3084-69B1-4350-93D4-980A4D7DED38}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3A3B3084-69B1-4350-93D4-980A4D7DED38}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3A3B3084-69B1-4350-93D4-980A4D7DED38}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3A3B3084-69B1-4350-93D4-980A4D7DED38}.Release|Any CPU.Build.0 = Release|Any CPU + {94DB7195-18D0-49C2-87C1-2BC1A8722621}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94DB7195-18D0-49C2-87C1-2BC1A8722621}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94DB7195-18D0-49C2-87C1-2BC1A8722621}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94DB7195-18D0-49C2-87C1-2BC1A8722621}.Release|Any CPU.Build.0 = Release|Any CPU + {9343A17C-D414-4327-BD46-EA10F9278B10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9343A17C-D414-4327-BD46-EA10F9278B10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9343A17C-D414-4327-BD46-EA10F9278B10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9343A17C-D414-4327-BD46-EA10F9278B10}.Release|Any CPU.Build.0 = Release|Any CPU + {B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8A70ACC-23BA-4D69-95CF-BD300B9FCA1C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Scripts/first_migrations.sql b/Scripts/first_migrations.sql new file mode 100644 index 0000000..150ed00 --- /dev/null +++ b/Scripts/first_migrations.sql @@ -0,0 +1,286 @@ +-- ============================================================ +-- AEGIS MANIFEST (SQLite) - UPDATED: datastore label visible, inner requires PIN +-- ============================================================ + +-- ---------------------------- +-- PRAGMAS (aplicar na abertura da conexão) +-- ---------------------------- +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA temp_store = MEMORY; + +-- ============================================================ +-- USERS (OIDC identities) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, -- GUID/ULID (string) + created_at INTEGER NOT NULL, -- epoch seconds + disabled_at INTEGER, -- null = active + + -- OIDC identity (stable pairing) + subject TEXT NOT NULL, -- "sub" + issuer TEXT NOT NULL, -- "iss" + + display_name TEXT, + email TEXT, + + UNIQUE(subject, issuer) + ); + +-- ============================================================ +-- DATASTORES +-- ============================================================ +-- IMPORTANT: +-- - datastore name is decryptable WITHOUT PIN (using Label Key mechanism) +-- - datastore inner content requires PIN (Master Key unwrapped with PIN-derived KEK) + +CREATE TABLE IF NOT EXISTS datastores ( + datastore_id TEXT PRIMARY KEY, -- GUID/ULID + created_at INTEGER NOT NULL, + deleted_at INTEGER, + + -- -------------------------- + -- LABEL (visible without PIN) + -- -------------------------- + -- name_enc is encrypted with a "Label Key" (LK) that does NOT require datastore PIN. + -- LK can be derived from a server secret, KMS, or any non-PIN mechanism. + name_enc BLOB NOT NULL, + name_nonce BLOB NOT NULL, + + -- Identifies which LK mechanism/version was used (for rotation/migration). + -- Examples: "lk:server:v1", "lk:kms:key/123", "lk:yubikey:piv:9c" + lk_kid TEXT NOT NULL, + lk_version INTEGER NOT NULL DEFAULT 1, + + -- -------------------------- + -- MASTER KEY (PIN protected) + -- -------------------------- + -- mk_wrapped is the datastore Master Key (MK) encrypted ("wrapped") with a KEK derived from the datastore PIN. + mk_wrapped BLOB NOT NULL, + mk_nonce BLOB NOT NULL, + mk_version INTEGER NOT NULL DEFAULT 1, + + -- PIN KDF parameters (so you can change KDF settings per datastore over time) + pin_kdf TEXT NOT NULL DEFAULT 'argon2id', -- 'argon2id' recommended + pin_salt BLOB NOT NULL, + -- Store params as JSON text (portable and easy to evolve) + -- Example: {"memory_kib":65536,"iterations":3,"parallelism":1,"output_len":32} + pin_kdf_params TEXT NOT NULL, + + -- Optional: quick verifier to reduce unwrap attempts (can be null). + -- If used, store an AEAD-encrypted constant (e.g., "AEGIS_PIN_OK") with KEK_pin and a nonce. + -- Otherwise you simply "try unwrap MK" and treat failure as "wrong PIN". + pin_verifier_enc BLOB, + pin_verifier_nonce BLOB, + + -- Root node of the datastore tree + root_node_id TEXT NOT NULL, + + FOREIGN KEY(root_node_id) REFERENCES nodes(node_id) + ); + +-- ============================================================ +-- DATASTORE MEMBERS (authorization) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS datastore_members ( + datastore_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role TEXT NOT NULL, -- 'OWNER'|'ADMIN'|'EDITOR'|'VIEWER' + created_at INTEGER NOT NULL, + + PRIMARY KEY(datastore_id, user_id), + FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id), + FOREIGN KEY(user_id) REFERENCES users(user_id) + ); + +-- ============================================================ +-- NODES (logical tree) - INTERNAL: requires PIN +-- ============================================================ + +CREATE TABLE IF NOT EXISTS nodes ( + node_id TEXT PRIMARY KEY, -- GUID/ULID + datastore_id TEXT NOT NULL, + parent_id TEXT, -- null only for root + kind INTEGER NOT NULL, -- 1=DIR, 2=FILE + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + deleted_at INTEGER, -- soft delete/trash + + -- Encrypted node name (requires MK -> PIN) + name_enc BLOB NOT NULL, + name_nonce BLOB NOT NULL, + + -- File-only fields (kind=FILE) + main_object_id TEXT, -- FK objects.object_id + size_plain INTEGER, -- optional (can leak info; keep or encrypt later) + mime_enc BLOB, -- optional: encrypt mime to reduce metadata leak + mime_nonce BLOB, + + version INTEGER NOT NULL DEFAULT 1, -- optimistic lock + + FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id), + FOREIGN KEY(parent_id) REFERENCES nodes(node_id), + FOREIGN KEY(main_object_id) REFERENCES objects(object_id) + ); + +-- ============================================================ +-- OBJECTS (encrypted blobs) - INTERNAL: requires PIN +-- ============================================================ + +CREATE TABLE IF NOT EXISTS objects ( + object_id TEXT PRIMARY KEY, -- GUID/ULID + datastore_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + + -- AEAD algorithm info + cipher TEXT NOT NULL, -- e.g. 'AES-256-GCM' + nonce BLOB NOT NULL, -- base nonce / object nonce (format depends on your file container) + + -- DEK wrapped by datastore MK (requires PIN) + dek_wrapped BLOB NOT NULL, + dek_nonce BLOB NOT NULL, + + -- Reference to blob location (also encrypted to avoid path leaks) + blob_ref_enc BLOB NOT NULL, + blob_ref_nonce BLOB NOT NULL, + + size_cipher INTEGER NOT NULL, + hash_plain BLOB, -- optional (SHA-256) for integrity/dedupe v2 + + FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id) + ); + +-- ============================================================ +-- DERIVATIVES (thumbs/previews/posters/etc.) - INTERNAL: requires PIN +-- ============================================================ + +CREATE TABLE IF NOT EXISTS derivatives ( + derivative_id TEXT PRIMARY KEY, -- GUID/ULID + datastore_id TEXT NOT NULL, + node_id TEXT NOT NULL, -- owning file node + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + + kind TEXT NOT NULL, -- 'THUMB'|'POSTER'|'PREVIEW'|'WAVEFORM'|'SPRITE'... + profile TEXT NOT NULL, -- '256w','512w','page1','t=3s','wave:1024',... + + object_id TEXT, -- object containing the derivative blob (when READY) + status INTEGER NOT NULL, -- 0=PENDING,1=READY,2=FAILED + + -- store error details encrypted (may contain sensitive paths/info) + error_enc BLOB, + error_nonce BLOB, + + FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id), + FOREIGN KEY(node_id) REFERENCES nodes(node_id), + FOREIGN KEY(object_id) REFERENCES objects(object_id), + + UNIQUE(datastore_id, node_id, kind, profile) + ); + +-- ============================================================ +-- JOBS (SQLite queue) - does NOT require PIN itself +-- Payload should be encrypted with MK if it contains sensitive info (recommended). +-- ============================================================ + +CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, -- GUID/ULID + datastore_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + available_at INTEGER NOT NULL, -- for backoff scheduling + picked_at INTEGER, + finished_at INTEGER, + + kind TEXT NOT NULL, -- 'GEN_DERIVATIVES'|'REWRAP_KEYS' etc. + + payload_enc BLOB NOT NULL, + payload_nonce BLOB NOT NULL, + + status INTEGER NOT NULL, -- 0=QUEUED,1=RUNNING,2=DONE,3=FAILED + attempts INTEGER NOT NULL DEFAULT 0, + + FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id) + ); + +-- ============================================================ +-- AUDIT LOG (encrypted details) - INTERNAL: may require PIN depending on what you put in details +-- ============================================================ + +CREATE TABLE IF NOT EXISTS audit_log ( + audit_id TEXT PRIMARY KEY, -- GUID/ULID + datastore_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + actor_user_id TEXT, -- null for system/worker + action TEXT NOT NULL, -- 'UPLOAD','RENAME','MOVE','DELETE','DOWNLOAD','UNLOCK','LOCK'... + + target_node_id TEXT, + details_enc BLOB NOT NULL, + details_nonce BLOB NOT NULL, + + FOREIGN KEY(datastore_id) REFERENCES datastores(datastore_id), + FOREIGN KEY(actor_user_id) REFERENCES users(user_id), + FOREIGN KEY(target_node_id) REFERENCES nodes(node_id) + ); + +-- ============================================================ +-- INDEXES (hot paths) +-- ============================================================ + +-- list directory: by datastore + parent, excluding deleted +CREATE INDEX IF NOT EXISTS idx_nodes_parent + ON nodes(datastore_id, parent_id, deleted_at); + +CREATE INDEX IF NOT EXISTS idx_nodes_parent_kind + ON nodes(datastore_id, parent_id, kind, deleted_at); + +CREATE INDEX IF NOT EXISTS idx_nodes_main_object + ON nodes(main_object_id); + +CREATE INDEX IF NOT EXISTS idx_objects_datastore_created + ON objects(datastore_id, created_at); + +CREATE INDEX IF NOT EXISTS idx_derivatives_node + ON derivatives(datastore_id, node_id, kind, status); + +-- jobs queue +CREATE INDEX IF NOT EXISTS idx_jobs_queue + ON jobs(status, available_at); + +-- members lookup +CREATE INDEX IF NOT EXISTS idx_members_user + ON datastore_members(user_id); + +-- datastore listing for a user typically joins members -> datastores +CREATE INDEX IF NOT EXISTS idx_members_datastore + ON datastore_members(datastore_id); + +-- audit time queries +CREATE INDEX IF NOT EXISTS idx_audit_datastore_time + ON audit_log(datastore_id, created_at); + +-- ============================================================ +-- TRIGGERS (touch updated_at / version) +-- NOTE: SQLite default PRAGMA recursive_triggers is OFF, so these won't recurse infinitely. +-- ============================================================ + +CREATE TRIGGER IF NOT EXISTS trg_nodes_touch +AFTER UPDATE ON nodes + FOR EACH ROW +BEGIN +UPDATE nodes +SET updated_at = strftime('%s','now'), + version = version + 1 +WHERE node_id = NEW.node_id; +END; + +CREATE TRIGGER IF NOT EXISTS trg_derivatives_touch +AFTER UPDATE ON derivatives + FOR EACH ROW +BEGIN +UPDATE derivatives +SET updated_at = strftime('%s','now') +WHERE derivative_id = NEW.derivative_id; +END;