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:
parent
ab4fc20b85
commit
3213254b96
10 changed files with 346 additions and 37 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue