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 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-09 20:50:41 +00:00
commit 3213254b96
10 changed files with 346 additions and 37 deletions

View file

@ -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/<persona>/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.

View file

@ -47,6 +47,8 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
public DbSet<MindPersona> MindPersonas => Set<MindPersona>();
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
@ -325,6 +327,12 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique();
});
modelBuilder.Entity<MindPersona>(entity =>
{
entity.HasIndex(x => x.PersonaId).IsUnique();
entity.HasIndex(x => x.UserAccountId).IsUnique();
});
modelBuilder.Entity<MindNonce>(entity =>
{
entity.HasIndex(x => new { x.PersonaId, x.Nonce }).IsUnique();

View file

@ -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<Guid>(type: "uuid", nullable: false),
PersonaId = table.Column<string>(type: "text", nullable: false),
UserAccountId = table.Column<Guid>(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");
}
}

View file

@ -1477,6 +1477,30 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("MindKeys");
});
modelBuilder.Entity("Bifrost.Web.Domain.MindPersona", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("PersonaId")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("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<Guid>("Id")

View file

@ -1187,3 +1187,13 @@ public sealed class MindNonce
public DateTimeOffset SeenAtUtc { get; set; }
}
/// <summary>The one member account a persona votes as. Unique on both columns: a persona has one account and an account one persona.</summary>
public sealed class MindPersona
{
public Guid Id { get; set; } = Guid.NewGuid();
public string PersonaId { get; set; } = string.Empty;
public Guid UserAccountId { get; set; }
}

View file

@ -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<MindKeyView> 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(
/// <summary>The exact text a Mind signs: every field, fixed order, newline separated, unused fields empty.</summary>
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(
/// <summary>A signed Mind verb. <c>Signature</c> is base64 Ed25519 over <see cref="MindIntakeService.SigningText"/>.</summary>
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);
/// <summary>Limits on the anonymous signed-intake endpoint (section "MindIntake").</summary>
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;
}
/// <summary>Per-source permit budget for the intake, on the shared <see cref="FixedWindowBudget"/>. Spent before the body is read.</summary>
public sealed class MindIntakeRateLimit(Microsoft.Extensions.Options.IOptions<MindIntakeOptions> 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);
}

View file

@ -123,6 +123,8 @@ builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
builder.Services.AddScoped<SealedBallotService>();
builder.Services.AddScoped<MindIntakeService>();
builder.Services.AddOptions<MindIntakeOptions>().BindConfiguration(MindIntakeOptions.SectionName);
builder.Services.AddSingleton<MindIntakeRateLimit>(); // TimeProvider and options resolve from the container
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
@ -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<MindIntakeOptions> 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<MindEnvelope>(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) =>
{

View file

@ -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<BifrostDbContext>();
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<BifrostDbContext>();
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<string, string?> { ["MindIntake:Permits"] = "3" })));
var client = limited.CreateClient();
client.DefaultRequestHeaders.Add("X-Test-Anonymous", "true");
var codes = new List<HttpStatusCode>();
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<BifrostDbContext>();
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<InterceptionResult<int>> SavingChangesAsync(DbContextEventData e, InterceptionResult<int> r, CancellationToken ct = default) =>
e.Context!.ChangeTracker.Entries<MindNonce>().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<BifrostDbContext>()
.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<DbUpdateException>(() => service.HandleAsync(motion, Sign(key, "mindx", "commit", motion, commitment: new string('a', 64)), default));
}
}

View file

@ -37,6 +37,7 @@ public class TestWebApplicationFactory : WebApplicationFactory<Program>
["Bridge:LocalBridgeToken"] = "test-bridge-token",
["ConceptVotes:CastPermits"] = "100000",
["ConceptVotes:MintPermits"] = "100000",
["MindIntake:Permits"] = "100000",
["Host:PublicBaseUrl"] = "https://localhost",
["Host:ExpectedHost"] = "localhost",
["Host:RequireStrictHostValidation"] = "false",

View file

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