From 3213254b9624abfd79ee1b5a18d593a2f10661de Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:50:41 +0000 Subject: [PATCH] Signed Mind intake: verify before revealing state, dummy verify, issuedAt window and nonce pruning, per-source rate limit and body cap, one account per persona, DbUpdateException is replay only for the nonce Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 29 ++-- src/Bifrost.Web/Data/BifrostDbContext.cs | 8 ++ .../Migrations/20261109210000_AddMindKeys.cs | 13 ++ .../BifrostDbContextModelSnapshot.cs | 24 ++++ src/Bifrost.Web/Domain/BifrostModels.cs | 10 ++ .../Features/Motions/MindIntakeService.cs | 129 +++++++++++++++--- src/Bifrost.Web/Program.cs | 47 ++++++- tests/Bifrost.Web.Tests/MindIntakeTests.cs | 116 +++++++++++++++- .../Support/TestWebApplicationFactory.cs | 1 + tools/sealed-motion-sign.mjs | 8 +- 10 files changed, 347 insertions(+), 38 deletions(-) diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index ca43fe3..7119dda 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -106,16 +106,25 @@ A Mind casts through `POST /api/sealed-motions/{id}/mind` with a signed envelope signature is the credential, and a verified envelope runs the same `SealedBallotService` verbs as a member cast, as the persona's member account. -**Envelope:** `{persona, verb: "commit"|"reveal", motionId, nonce, commitment | (round, candidate, reason, salt), signature}`. +**Envelope:** `{persona, verb: "commit"|"reveal", motionId, nonce, issuedAt (unix seconds), commitment | (round, candidate, reason, salt), signature}`. `signature` is base64 Ed25519 over `MindIntakeService.SigningText`: `gamecult.sealed_intake.v0`, persona, verb, motion id, -nonce, commitment, round, candidate, reason, salt, one per line, unused fields empty. Ed25519 is not in .NET 8's built-ins; +nonce, issued-at, commitment, round, candidate, reason, salt, one per line, unused fields empty. Ed25519 is not in .NET 8's built-ins; verification uses BouncyCastle, already a dependency (`HeimdallTokenVerifier.cs`). -**Refusals** (each tested; none carries a ballot): malformed envelope or nonce, or a motion id different from the route: 400; -no registered, unrevoked key for that persona verifies the signature (unknown persona, revoked key, wrong key, tampered -field, persona that does not match the signing key: one 401 answer, nothing to probe); a replayed nonce (stored per persona, -spent only after the signature verifies, so an unsigned caller cannot burn nonces): 409; wrong phase: 409; a reveal whose -persona does not match the persona bound into the commitment: 400. +**Order and refusals** (each tested; none carries a ballot). Stateless shape checks (persona, verb and nonce patterns, no +newlines) answer 400. Then the signature is verified, and every refusal up to there costs one Ed25519 verification, a dummy one +when the persona has no unrevoked key or the signature is not well-formed base64 of 64 bytes, so unknown persona, revoked key, +wrong key, tampered field, persona not matching the signing key and a malformed signature are one 401 answer with the same work +(`MindIntakeService.SignatureChecks` counts verifications for the test; a timing difference below that is not measured here). +Only after a verified signature: a motion id different from the route: 400; `issuedAt` more than 10 minutes from the server +clock: 400; a replayed nonce (stored per persona, spent only after the signature verifies): 409; then the verb itself (wrong +phase 409, bad document 400, reveal not matching the persona-bound commitment 400). Nonces older than 20 minutes are pruned on +each accepted envelope, which is safe because the clock window already refuses their envelopes. A database failure that is not +a nonce collision is a server fault, not a replay. + +**Limits.** Per-source (client IP after forwarded-header processing; an IPv6 /64 is not grouped) fixed-window budget on the +shared `FixedWindowBudget`, 30 per minute by default, spent before the body is read: 429 with Retry-After. Body cap 4096 bytes: +413. Both are `MindIntake:*` options. The budget is per process and in memory. **Persona id in the commitment:** the commitment hash is `sha256(schema, motion, voter account, persona, round, candidate, reason, salt)`; member casts use an empty persona. A reveal through the intake must carry the signing persona, so a commitment @@ -124,7 +133,8 @@ made without it cannot be revealed as that persona. **Keys and trust.** Bifrost stores only public keys (`MindKeys`). A moderator registers a key for a persona and its Active member account with `POST /api/sealed-motions/mind-keys {persona, userAccountId, publicKey}` and revokes with `DELETE /api/sealed-motions/mind-keys/{id}`; both are audited (`mindkey.registered`, `mindkey.revoked`). A persona stays bound -to the first account it was registered against. The trust is the moderator's: Bifrost cannot tell whether a registered public +to one member account and the account to that one persona (`MindPersonas`, unique on both columns, so a second persona +cannot share the account and overwrite the first's ballot, and a registration race loses on the index). The trust is the moderator's: Bifrost cannot tell whether a registered public key really belongs to the Mind. **What a Mind holds.** An Ed25519 private key (32-byte seed) at `/etc/gamecult/minds//sealed-motion.key`, mode 0400, @@ -139,5 +149,4 @@ the persona's member account id (the `userAccountId` the moderator registered). **Rotation.** Generate a new key, register it, revoke the old one. A ballot committed or revealed before revocation stays valid for its motion. A key revoked mid-motion can no longer reveal, so rotate between motions. A lost key is handled as rotation. -**Not covered:** no rate limit or body-size limit on the intake endpoint beyond the host's; the nonce table grows without -pruning; the moderator's identity is the member cookie, as for every moderator act. +**Not covered:** the budget is per process, so several app instances multiply it; the moderator's identity is the member cookie, as for every moderator act. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index ec24585..87f4dd1 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -47,6 +47,8 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet MindNonces => Set(); + public DbSet MindPersonas => Set(); + public DbSet LedgerEntries => Set(); public DbSet PayoutProposalBatches => Set(); @@ -325,6 +327,12 @@ public sealed class BifrostDbContext(DbContextOptions options) entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => x.PersonaId).IsUnique(); + entity.HasIndex(x => x.UserAccountId).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.HasIndex(x => new { x.PersonaId, x.Nonce }).IsUnique(); diff --git a/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs b/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs index f355680..02794e1 100644 --- a/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs +++ b/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs @@ -30,6 +30,18 @@ namespace Bifrost.Web.Data.Migrations constraints: table => table.PrimaryKey("PK_MindKeys", x => x.Id)); migrationBuilder.CreateIndex(name: "IX_MindKeys_PersonaId_PublicKey", table: "MindKeys", columns: new[] { "PersonaId", "PublicKey" }, unique: true); + migrationBuilder.CreateTable( + name: "MindPersonas", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PersonaId = table.Column(type: "text", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false) + }, + constraints: table => table.PrimaryKey("PK_MindPersonas", x => x.Id)); + migrationBuilder.CreateIndex(name: "IX_MindPersonas_PersonaId", table: "MindPersonas", column: "PersonaId", unique: true); + migrationBuilder.CreateIndex(name: "IX_MindPersonas_UserAccountId", table: "MindPersonas", column: "UserAccountId", unique: true); + migrationBuilder.CreateTable( name: "MindNonces", columns: table => new @@ -46,6 +58,7 @@ namespace Bifrost.Web.Data.Migrations protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.DropTable(name: "MindNonces"); + migrationBuilder.DropTable(name: "MindPersonas"); migrationBuilder.DropTable(name: "MindKeys"); } } diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index edd7968..b2f8dd0 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1477,6 +1477,30 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("MindKeys"); }); + modelBuilder.Entity("Bifrost.Web.Domain.MindPersona", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PersonaId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PersonaId") + .IsUnique(); + + b.HasIndex("UserAccountId") + .IsUnique(); + + b.ToTable("MindPersonas"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.MindNonce", b => { b.Property("Id") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index fcb86e1..2f8be41 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1187,3 +1187,13 @@ public sealed class MindNonce public DateTimeOffset SeenAtUtc { get; set; } } + +/// The one member account a persona votes as. Unique on both columns: a persona has one account and an account one persona. +public sealed class MindPersona +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string PersonaId { get; set; } = string.Empty; + + public Guid UserAccountId { get; set; } +} diff --git a/src/Bifrost.Web/Features/Motions/MindIntakeService.cs b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs index ae507cd..2fa39f1 100644 --- a/src/Bifrost.Web/Features/Motions/MindIntakeService.cs +++ b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs @@ -20,8 +20,33 @@ public sealed class MindIntakeService( TimeProvider timeProvider) { public const string Domain = "gamecult.sealed_intake.v0"; - private static readonly Regex PersonaPattern = new("^[a-z0-9][a-z0-9-]{0,31}$", RegexOptions.Compiled); - private static readonly Regex NoncePattern = new("^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled); + public static readonly TimeSpan Skew = TimeSpan.FromMinutes(10); + + // Diagnostics: every Ed25519 verification the intake performs, real or dummy. A refusal that skips the verify is a timing tell. + public static long SignatureChecks; + + // A valid signature by a throwaway key over a different message: verifying it does the full work and fails at the end. + private static readonly (byte[] Public, byte[] Signature, byte[] Message) Dummy = MakeDummy(); + + private static (byte[], byte[], byte[]) MakeDummy() + { + var secret = new byte[32]; + System.Security.Cryptography.RandomNumberGenerator.Fill(secret); + var pub = new byte[32]; + Ed25519.GeneratePublicKey(secret, 0, pub, 0); + var message = Encoding.UTF8.GetBytes("dummy"); + var sig = new byte[64]; + Ed25519.Sign(secret, 0, pub, 0, null, message, 0, message.Length, sig, 0); + return (pub, sig, Encoding.UTF8.GetBytes("not the dummy message")); + } + + private static bool Verify(byte[] signature, byte[] publicKey, byte[] message) + { + Interlocked.Increment(ref SignatureChecks); + return Ed25519.Verify(signature, 0, publicKey, 0, message, 0, message.Length); + } + private static readonly Regex PersonaPattern = new(@"^[a-z0-9][a-z0-9-]{0,31}\z", RegexOptions.Compiled); + private static readonly Regex NoncePattern = new(@"^[A-Za-z0-9_-]{16,64}\z", RegexOptions.Compiled); public async Task RegisterKeyAsync( CurrentBifrostActor actor, string persona, Guid userAccountId, string publicKey, CancellationToken cancellationToken) @@ -44,10 +69,12 @@ public sealed class MindIntakeService( throw new SealedBallotException(SealedBallotFailure.Invalid, "The persona's member account must exist and be Active."); } - var bound = await dbContext.MindKeys.Where(x => x.PersonaId == persona).Select(x => (Guid?)x.UserAccountId).FirstOrDefaultAsync(cancellationToken); - if (bound is { } other && other != userAccountId) + // One persona <-> one account, both ways (a shared account would let one persona overwrite the other's commit). + // The unique indexes on MindPersonas are the arbiter; these reads only give a plain error before them. + var binding = await dbContext.MindPersonas.SingleOrDefaultAsync(x => x.PersonaId == persona || x.UserAccountId == userAccountId, cancellationToken); + if (binding is not null && (binding.PersonaId != persona || binding.UserAccountId != userAccountId)) { - throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona is already bound to a different member account."); + throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona or member account is already bound to a different account or persona."); } if (await dbContext.MindKeys.AnyAsync(x => x.PersonaId == persona && x.PublicKey == publicKey, cancellationToken)) @@ -61,7 +88,21 @@ public sealed class MindIntakeService( RegisteredByUserAccountId = moderator.Id, RegisteredAtUtc = timeProvider.GetUtcNow() }; dbContext.MindKeys.Add(key); - await dbContext.SaveChangesAsync(cancellationToken); + if (binding is null) + { + dbContext.MindPersonas.Add(new MindPersona { PersonaId = persona, UserAccountId = userAccountId }); + } + + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) when (binding is null) + { + // Lost a registration race on the unique persona/account indexes. + throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona or member account is already bound to a different account or persona."); + } + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.registered", $"Registered a key for persona {persona} (member account {userAccountId}).", cancellationToken); return View(key); @@ -101,37 +142,63 @@ public sealed class MindIntakeService( throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope is malformed."); } - if (envelope.MotionId != routeMotionId) - { - throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope was signed for a different motion."); - } - + // Signature first. Every path below that can tell a caller anything about state comes after a verified signature, and + // every refusal before it costs one Ed25519 verification (a dummy one when there is no key or no usable signature). var keys = await dbContext.MindKeys.AsNoTracking().Where(x => x.PersonaId == persona && x.RevokedAtUtc == null).ToListAsync(cancellationToken); var message = Encoding.UTF8.GetBytes(SigningText(envelope)); - var signature = Convert.TryFromBase64String(envelope.Signature ?? string.Empty, new byte[64], out var written) && written == 64 - ? Convert.FromBase64String(envelope.Signature!) : null; - var signer = signature is null ? null : keys.FirstOrDefault(k => - TryKey(k.PublicKey, out var pub) && Ed25519.Verify(signature, 0, pub, 0, message, 0, message.Length)); + var raw = new byte[64]; + var wellFormed = Convert.TryFromBase64String(envelope.Signature ?? string.Empty, raw, out var written) && written == 64; + MindKey? signer = null; + if (!wellFormed || keys.Count == 0) + { + _ = Verify(Dummy.Signature, Dummy.Public, Dummy.Message); + } + else + { + signer = keys.FirstOrDefault(k => TryKey(k.PublicKey, out var pub) && Verify(raw, pub, message)); + } + if (signer is null) { // One answer for unknown persona, revoked key, wrong key and bad signature: nothing to probe. throw new SealedBallotException(SealedBallotFailure.Unauthorized, "The envelope is not signed by a registered key for that persona."); } - // Spend the nonce only after the signature verifies, so an unsigned caller cannot burn a persona's nonces. + if (envelope.MotionId != routeMotionId) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope was signed for a different motion."); + } + + var now = timeProvider.GetUtcNow(); + if (Math.Abs((now - DateTimeOffset.FromUnixTimeSeconds(Math.Clamp(envelope.IssuedAt, 0, 253402300799))).TotalSeconds) > Skew.TotalSeconds) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope is outside the accepted clock window."); + } + + // Spend the nonce only after the signature verifies, so an unsigned caller cannot burn a persona's nonces. Nonces older + // than twice the skew can no longer pass the window above, so they are pruned here. if (await dbContext.MindNonces.AnyAsync(x => x.PersonaId == persona && x.Nonce == envelope.Nonce, cancellationToken)) { throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); } - dbContext.MindNonces.Add(new MindNonce { PersonaId = persona, Nonce = envelope.Nonce!, SeenAtUtc = timeProvider.GetUtcNow() }); + var cutoff = now - 2 * Skew; + dbContext.MindNonces.RemoveRange(await dbContext.MindNonces.Where(x => x.SeenAtUtc < cutoff).ToListAsync(cancellationToken)); + dbContext.MindNonces.Add(new MindNonce { PersonaId = persona, Nonce = envelope.Nonce!, SeenAtUtc = now }); try { await dbContext.SaveChangesAsync(cancellationToken); } catch (DbUpdateException) { - throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + // A replay only if the nonce is now there (the unique index lost a race); any other write failure is a fault, not a replay. + dbContext.ChangeTracker.Clear(); + if (await dbContext.MindNonces.AnyAsync(x => x.PersonaId == persona && x.Nonce == envelope.Nonce, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + } + + throw; } var account = await dbContext.UserAccounts.AsNoTracking() @@ -151,7 +218,7 @@ public sealed class MindIntakeService( /// The exact text a Mind signs: every field, fixed order, newline separated, unused fields empty. public static string SigningText(MindEnvelope e) => - string.Join('\n', Domain, e.Persona, e.Verb, e.MotionId.ToString("D"), e.Nonce, e.Commitment ?? "", e.Round, e.Candidate ?? "", e.Reason ?? "", e.Salt ?? ""); + string.Join('\n', Domain, e.Persona, e.Verb, e.MotionId.ToString("D"), e.Nonce, e.IssuedAt, e.Commitment ?? "", e.Round, e.Candidate ?? "", e.Reason ?? "", e.Salt ?? ""); private static bool TryKey(string base64, out byte[] key) { @@ -169,9 +236,31 @@ public sealed class MindIntakeService( /// A signed Mind verb. Signature is base64 Ed25519 over . public sealed record MindEnvelope( - string Persona, string Verb, Guid MotionId, string Nonce, + string Persona, string Verb, Guid MotionId, string Nonce, long IssuedAt, string? Commitment, int Round, string? Candidate, string? Reason, string? Salt, string Signature); public sealed record MindKeyView(Guid Id, string Persona, Guid UserAccountId, string PublicKey, DateTimeOffset RegisteredAtUtc, DateTimeOffset? RevokedAtUtc); public sealed record RegisterMindKeyRequest(string Persona, Guid UserAccountId, string PublicKey); + +/// Limits on the anonymous signed-intake endpoint (section "MindIntake"). +public sealed class MindIntakeOptions +{ + public const string SectionName = "MindIntake"; + + public int Permits { get; init; } = 30; + + public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1); + + public int MaxTrackedKeys { get; init; } = 10_000; + + public int MaxBodyBytes { get; init; } = 4096; +} + +/// Per-source permit budget for the intake, on the shared . Spent before the body is read. +public sealed class MindIntakeRateLimit(Microsoft.Extensions.Options.IOptions options, TimeProvider clock) +{ + private readonly FixedWindowBudget budget = new(options.Value.Permits, options.Value.Window, options.Value.MaxTrackedKeys, clock); + + public BudgetDenial? Spend(string source) => budget.Spend(source); +} diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index b8d2fd2..bf6103a 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -123,6 +123,8 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddOptions().BindConfiguration(MindIntakeOptions.SectionName); +builder.Services.AddSingleton(); // TimeProvider and options resolve from the container builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -595,8 +597,49 @@ sealedMotions.MapDelete("/mind-keys/{keyId:guid}", (ICurrentBifrostActorAccessor // Signed Mind intake: the Ed25519 signature is the credential. No session, no cookie; the verified envelope runs the same // SealedBallotService verbs as a member cast. -app.MapPost("/api/sealed-motions/{id:guid}/mind", (MindIntakeService service, Guid id, MindEnvelope envelope, CancellationToken ct) => - SealedCall(async () => await service.HandleAsync(id, envelope, ct))).AllowAnonymous(); +app.MapPost("/api/sealed-motions/{id:guid}/mind", async ( + HttpContext http, MindIntakeService service, MindIntakeRateLimit limiter, IOptions limits, Guid id, CancellationToken ct) => +{ + // Per-source permit and a hard body cap, both before any parsing or database work. + if (limiter.Spend(http.Connection.RemoteIpAddress?.ToString() ?? "unknown") is { } denied) + { + http.Response.Headers.RetryAfter = Math.Ceiling(denied.RetryAfter.TotalSeconds).ToString(System.Globalization.CultureInfo.InvariantCulture); + return Results.Text("{\"error\":\"Too many requests.\"}", "application/json", statusCode: StatusCodes.Status429TooManyRequests); + } + + var max = limits.Value.MaxBodyBytes; + if (http.Request.ContentLength > max) + { + return Results.Text("{\"error\":\"Envelope too large.\"}", "application/json", statusCode: StatusCodes.Status413PayloadTooLarge); + } + + var buffer = new byte[max + 1]; + var read = 0; + int n; + while (read < buffer.Length && (n = await http.Request.Body.ReadAsync(buffer.AsMemory(read), ct)) > 0) + { + read += n; + } + + if (read > max) + { + return Results.Text("{\"error\":\"Envelope too large.\"}", "application/json", statusCode: StatusCodes.Status413PayloadTooLarge); + } + + MindEnvelope? envelope; + try + { + envelope = JsonSerializer.Deserialize(buffer.AsSpan(0, read), new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + catch (JsonException) + { + envelope = null; + } + + return envelope is null + ? Results.Text("{\"error\":\"The envelope is malformed.\"}", "application/json", statusCode: StatusCodes.Status400BadRequest) + : await SealedCall(async () => await service.HandleAsync(id, envelope, ct)); +}).AllowAnonymous(); app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => { diff --git a/tests/Bifrost.Web.Tests/MindIntakeTests.cs b/tests/Bifrost.Web.Tests/MindIntakeTests.cs index 08cdf9e..29b0684 100644 --- a/tests/Bifrost.Web.Tests/MindIntakeTests.cs +++ b/tests/Bifrost.Web.Tests/MindIntakeTests.cs @@ -5,6 +5,9 @@ using System.Text.Json; using Bifrost.Web.Data; using Bifrost.Web.Domain; using Bifrost.Web.Features.Motions; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; using Bifrost.Web.Tests.Support; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.EntityFrameworkCore; @@ -92,9 +95,9 @@ public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassF } private static MindEnvelope Sign(Key key, string persona, string verb, Guid motion, string? nonce = null, string? commitment = null, - string? candidate = null, string? reason = null, string? salt = null) + string? candidate = null, string? reason = null, string? salt = null, long? issuedAt = null) { - var e = new MindEnvelope(persona, verb, motion, nonce ?? Guid.NewGuid().ToString("N"), commitment, 1, candidate, reason, salt, ""); + var e = new MindEnvelope(persona, verb, motion, nonce ?? Guid.NewGuid().ToString("N"), issuedAt ?? DateTimeOffset.UtcNow.ToUnixTimeSeconds(), commitment, 1, candidate, reason, salt, ""); var signer = new Ed25519Signer(); signer.Init(true, key.Private); var bytes = System.Text.Encoding.UTF8.GetBytes(MindIntakeService.SigningText(e)); @@ -261,4 +264,113 @@ public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassF } finally { File.Delete(keyFile); } } + + [Fact] + public async Task Every_refusal_before_a_verified_signature_costs_one_signature_check_and_reveals_nothing_about_state() + { + var w = await WorldAsync(); + var anon = Anonymous(); + long Checks() => Interlocked.Read(ref MindIntakeService.SignatureChecks); + MindEnvelope Reveal(Key k, string persona) => Sign(k, persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt); + + var before = Checks(); + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona + Assert.True(Checks() > before, "unknown persona skipped the verification"); + + before = Checks(); + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, w.Persona) with { Signature = "not-base64!" }), HttpStatusCode.Unauthorized); // malformed signature + Assert.True(Checks() > before, "malformed signature skipped the verification"); + + // Route/motion mismatch and a stale clock are answered 400 only to a caller whose signature verified; an unsigned one gets the 401. + var wrongRoute = Reveal(w.Key, w.Persona); + await Refused(await Post(anon, w.CommitMotion, wrongRoute with { Signature = Convert.ToBase64String(new byte[64]) }), HttpStatusCode.Unauthorized); + await Refused(await Post(anon, w.CommitMotion, wrongRoute), HttpStatusCode.BadRequest); + + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + before = Checks(); + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, w.Persona)), HttpStatusCode.Unauthorized); // revoked + Assert.True(Checks() > before, "revoked key skipped the verification"); + } + + [Fact] + public async Task A_stale_envelope_is_refused_and_old_nonces_are_pruned() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var old = DateTimeOffset.UtcNow.AddHours(-1); + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.MindNonces.Add(new MindNonce { PersonaId = w.Persona, Nonce = "old-nonce-old-nonce-1234", SeenAtUtc = old }); + await db.SaveChangesAsync(); + } + + await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt, + issuedAt: DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds())), HttpStatusCode.BadRequest); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode); + + using var check = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.DoesNotContain(check.MindNonces, x => x.Nonce == "old-nonce-old-nonce-1234"); + Assert.Single(check.MindNonces.Where(x => x.PersonaId == w.Persona)); + } + + [Fact] + public async Task The_endpoint_caps_the_body_and_rate_limits_per_source() + { + var w = await WorldAsync(); + var big = new StringContent(new string('x', 10_000), System.Text.Encoding.UTF8, "application/json"); + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await Anonymous().PostAsync($"/api/sealed-motions/{w.RevealMotion}/mind", big)).StatusCode); + + using var limited = factory.WithWebHostBuilder(b => b.ConfigureAppConfiguration((_, c) => c.AddInMemoryCollection(new Dictionary { ["MindIntake:Permits"] = "3" }))); + var client = limited.CreateClient(); + client.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); + var codes = new List(); + for (var i = 0; i < 5; i++) + { + codes.Add((await Post(client, w.RevealMotion, Sign(Key.New(), w.Persona, "reveal", w.RevealMotion))).StatusCode); + } + + Assert.Equal([HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, HttpStatusCode.Unauthorized, HttpStatusCode.TooManyRequests, HttpStatusCode.TooManyRequests], codes); + } + + [Fact] + public async Task One_account_cannot_carry_two_personas_and_the_bindings_are_unique_in_the_model() + { + var w = await WorldAsync(); + var second = await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona + "b", userAccountId = w.Account, publicKey = Key.New().Public }); + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + var type = db.Model.FindEntityType(typeof(MindPersona))!; + Assert.Contains(type.GetIndexes(), i => i.IsUnique && i.Properties.Single().Name == nameof(MindPersona.PersonaId)); + Assert.Contains(type.GetIndexes(), i => i.IsUnique && i.Properties.Single().Name == nameof(MindPersona.UserAccountId)); + Assert.Contains(db.Model.FindEntityType(typeof(MindNonce))!.GetIndexes(), i => i.IsUnique && i.Properties.Count == 2); + } + + private sealed class ThrowOnNonce : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync(DbContextEventData e, InterceptionResult r, CancellationToken ct = default) => + e.Context!.ChangeTracker.Entries().Any(x => x.State == EntityState.Added) + ? throw new DbUpdateException("disk fault") + : ValueTask.FromResult(r); + } + + [Fact] + public async Task A_write_failure_that_is_not_a_nonce_collision_is_not_reported_as_a_replay() + { + var clock = TimeProvider.System; + await using var db = new BifrostDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"intake-fault-{Guid.NewGuid():N}").AddInterceptors(new ThrowOnNonce()).Options); + var key = Key.New(); + var user = new UserAccount { DisplayName = "m", GitHubLogin = "m", BifrostIdentity = "m", NormalizedBifrostIdentity = "M", NormalizedGitHubLogin = "M" }; + user.Membership = new Domain.Membership { UserAccountId = user.Id, Status = MembershipStatus.Active }; + db.UserAccounts.Add(user); + db.MindKeys.Add(new MindKey { PersonaId = "mindx", UserAccountId = user.Id, PublicKey = key.Public, RegisteredAtUtc = DateTimeOffset.UtcNow }); + await db.SaveChangesAsync(); + var audit = new AuditTrailService(db, clock); + var service = new MindIntakeService(db, new SealedBallotService(db, audit, clock), audit, clock); + var motion = Guid.NewGuid(); + + await Assert.ThrowsAsync(() => service.HandleAsync(motion, Sign(key, "mindx", "commit", motion, commitment: new string('a', 64)), default)); + } } diff --git a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs index 3018cd3..563aee2 100644 --- a/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs +++ b/tests/Bifrost.Web.Tests/Support/TestWebApplicationFactory.cs @@ -37,6 +37,7 @@ public class TestWebApplicationFactory : WebApplicationFactory ["Bridge:LocalBridgeToken"] = "test-bridge-token", ["ConceptVotes:CastPermits"] = "100000", ["ConceptVotes:MintPermits"] = "100000", + ["MindIntake:Permits"] = "100000", ["Host:PublicBaseUrl"] = "https://localhost", ["Host:ExpectedHost"] = "localhost", ["Host:RequireStrictHostValidation"] = "false", diff --git a/tools/sealed-motion-sign.mjs b/tools/sealed-motion-sign.mjs index 3c5fc71..602162e 100644 --- a/tools/sealed-motion-sign.mjs +++ b/tools/sealed-motion-sign.mjs @@ -17,7 +17,7 @@ export function commitmentOf({ motion, voter, persona, round, candidate, reason, } export function signEnvelope(identity, e) { - const text = [DOMAIN, e.persona, e.verb, e.motionId, e.nonce, e.commitment ?? "", e.round ?? 0, e.candidate ?? "", e.reason ?? "", e.salt ?? ""].join("\n"); + const text = [DOMAIN, e.persona, e.verb, e.motionId, e.nonce, e.issuedAt, e.commitment ?? "", e.round ?? 0, e.candidate ?? "", e.reason ?? "", e.salt ?? ""].join("\n"); return { ...e, signature: sign(null, Buffer.from(text, "utf8"), identity.key).toString("base64") }; } @@ -31,16 +31,16 @@ async function main(argv) { const [cmd] = argv, a = args(argv); const identity = await loadSignedIdentity(a.key, "Mind sealed-motion key"); if (cmd === "pubkey") return console.log(Buffer.from(identity.publicKey).toString("base64")); - const nonce = randomBytes(16).toString("base64url"); + const nonce = randomBytes(16).toString("base64url"), issuedAt = Math.floor(Date.now() / 1000); if (cmd === "commit") { const ballot = { round: Number(a.round ?? 1), candidate: a.candidate, reason: a.reason, salt: a.salt ?? randomBytes(16).toString("hex") }; const commitment = commitmentOf({ motion: a.motion, voter: a.voter, persona: a.persona, ...ballot }); - const envelope = signEnvelope(identity, { persona: a.persona, verb: "commit", motionId: a.motion, nonce, commitment, round: ballot.round }); + const envelope = signEnvelope(identity, { persona: a.persona, verb: "commit", motionId: a.motion, nonce, issuedAt, commitment, round: ballot.round }); return console.log(JSON.stringify({ envelope, ballot })); } if (cmd === "reveal") { const b = JSON.parse(a.ballot).ballot ?? JSON.parse(a.ballot); - return console.log(JSON.stringify(signEnvelope(identity, { persona: a.persona, verb: "reveal", motionId: a.motion, nonce, round: b.round, candidate: b.candidate, reason: b.reason, salt: b.salt }))); + return console.log(JSON.stringify(signEnvelope(identity, { persona: a.persona, verb: "reveal", motionId: a.motion, nonce, issuedAt, round: b.round, candidate: b.candidate, reason: b.reason, salt: b.salt }))); } throw new Error("usage: pubkey | commit | reveal (see the header of this file)"); }