From 8e6afc43edcb82f3cbd5d83b7c311406d56eed93 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:37:47 +0000 Subject: [PATCH 1/3] Signed Mind intake for sealed motions: Ed25519 envelopes, registered public keys, nonce replay refusal, persona bound into the commitment Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 42 +++ src/Bifrost.Web/Data/BifrostDbContext.cs | 14 + .../Migrations/20261109210000_AddMindKeys.cs | 52 ++++ .../BifrostDbContextModelSnapshot.cs | 62 ++++ src/Bifrost.Web/Domain/BifrostModels.cs | 34 +++ .../Features/Motions/MindIntakeService.cs | 177 ++++++++++++ .../Features/Motions/SealedBallotService.cs | 13 +- src/Bifrost.Web/Program.cs | 18 ++ .../MigrationDiscoveryTests.cs | 3 +- tests/Bifrost.Web.Tests/MindIntakeTests.cs | 264 ++++++++++++++++++ tests/Bifrost.Web.Tests/SealedBallotTests.cs | 2 +- tools/sealed-motion-sign.mjs | 48 ++++ 12 files changed, 721 insertions(+), 8 deletions(-) create mode 100644 src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs create mode 100644 src/Bifrost.Web/Features/Motions/MindIntakeService.cs create mode 100644 tests/Bifrost.Web.Tests/MindIntakeTests.cs create mode 100644 tools/sealed-motion-sign.mjs diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 35ce1e8..ca43fe3 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -99,3 +99,45 @@ Only through the existing member cookie, and awkwardly. Read from the code: Answer: not cleanly. A Mind can hold a session if a human registers and approves it and the Mind keeps one cookie jar alive; Bifrost has no durable non-browser credential for a Mind. I invented no auth path. Whether to give Minds one (a Persona-signed or token intake like `tools/bifrost-signed-identity.mjs`) is a ruling for the stewards, not this PR. + +## Signed Mind intake + +A Mind casts through `POST /api/sealed-motions/{id}/mind` with a signed envelope. No session or cookie is involved; the +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}`. +`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; +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. + +**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 +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 +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, +owned by that Mind's own user, never in the mind store or a room. Eureka generates it on the host and prints only the public +half; a moderator registers it. **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot. + +**Signing.** `tools/sealed-motion-sign.mjs` (`pubkey`, `commit`, `reveal`) reads the key file, checks its type, mode and owner, +and prints the envelope; it posts nothing. `commit` also prints the ballot (candidate, reason, salt), which the Mind keeps in +private state to send `reveal` later; the persona turn posts the printed envelope with `curl`. The voter id `commit` needs is +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. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index fa4d2ae..ec24585 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -43,6 +43,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet SealedBallots => Set(); + public DbSet MindKeys => Set(); + + public DbSet MindNonces => Set(); + public DbSet LedgerEntries => Set(); public DbSet PayoutProposalBatches => Set(); @@ -316,6 +320,16 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.PersonaId, x.Nonce }).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.Property(x => x.EntryType).HasConversion(); diff --git a/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs b/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs new file mode 100644 index 0000000..f355680 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs @@ -0,0 +1,52 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddSealedBallotMotions: no dotnet-ef on the authoring host; the snapshot was edited to match. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261109210000_AddMindKeys")] + public partial class AddMindKeys : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MindKeys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PersonaId = table.Column(type: "text", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + PublicKey = table.Column(type: "text", nullable: false), + RegisteredByUserAccountId = table.Column(type: "uuid", nullable: false), + RegisteredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RevokedByUserAccountId = table.Column(type: "uuid", nullable: true) + }, + 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: "MindNonces", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PersonaId = table.Column(type: "text", nullable: false), + Nonce = table.Column(type: "text", nullable: false), + SeenAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => table.PrimaryKey("PK_MindNonces", x => x.Id)); + migrationBuilder.CreateIndex(name: "IX_MindNonces_PersonaId_Nonce", table: "MindNonces", columns: new[] { "PersonaId", "Nonce" }, unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "MindNonces"); + migrationBuilder.DropTable(name: "MindKeys"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 289201d..edd7968 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1440,6 +1440,68 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("VolunteerClaims"); }); + modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PersonaId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegisteredByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PersonaId", "PublicKey") + .IsUnique(); + + b.ToTable("MindKeys"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MindNonce", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("text"); + + b.Property("PersonaId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PersonaId", "Nonce") + .IsUnique(); + + b.ToTable("MindNonces"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b => { b.Property("Id") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 5d6aab6..fcb86e1 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1153,3 +1153,37 @@ public sealed class AuditEvent public UserAccount? ActorUserAccount { get; set; } } + +/// A Mind's registered Ed25519 public key. Bifrost never holds a private key. +public sealed class MindKey +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string PersonaId { get; set; } = string.Empty; + + // The member account this persona votes as; fixed by the first registration for the persona. + public Guid UserAccountId { get; set; } + + // Base64 of the 32-byte public key. + public string PublicKey { get; set; } = string.Empty; + + public Guid RegisteredByUserAccountId { get; set; } + + public DateTimeOffset RegisteredAtUtc { get; set; } + + public DateTimeOffset? RevokedAtUtc { get; set; } + + public Guid? RevokedByUserAccountId { get; set; } +} + +/// A nonce already spent by a persona; a verified envelope with a seen nonce is refused as a replay. +public sealed class MindNonce +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string PersonaId { get; set; } = string.Empty; + + public string Nonce { get; set; } = string.Empty; + + public DateTimeOffset SeenAtUtc { get; set; } +} diff --git a/src/Bifrost.Web/Features/Motions/MindIntakeService.cs b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs new file mode 100644 index 0000000..ae507cd --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs @@ -0,0 +1,177 @@ +using System.Text; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Motions; + +/// +/// Signed intake for Minds. A Mind holds an Ed25519 private key; Bifrost holds only registered public keys. The signature, +/// not a session, is the credential: a verified envelope is handed to as the Mind's member +/// account, so Minds and members share one set of rules. Registration and revocation are moderator acts and are audited. +/// +public sealed class MindIntakeService( + BifrostDbContext dbContext, + SealedBallotService sealedBallots, + AuditTrailService auditTrailService, + 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 async Task RegisterKeyAsync( + CurrentBifrostActor actor, string persona, Guid userAccountId, string publicKey, CancellationToken cancellationToken) + { + var moderator = RequireModerator(actor); + persona = (persona ?? string.Empty).Trim(); + if (!PersonaPattern.IsMatch(persona)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A persona id is 1-32 lowercase letters, digits or hyphens."); + } + + if (!TryKey(publicKey, out _)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A public key is the base64 of 32 bytes (Ed25519)."); + } + + var account = await dbContext.UserAccounts.Include(x => x.Membership).SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + if (account?.Membership?.Status != MembershipStatus.Active) + { + 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) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona is already bound to a different member account."); + } + + if (await dbContext.MindKeys.AnyAsync(x => x.PersonaId == persona && x.PublicKey == publicKey, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That key is already registered for the persona."); + } + + var key = new MindKey + { + PersonaId = persona, UserAccountId = userAccountId, PublicKey = publicKey, + RegisteredByUserAccountId = moderator.Id, RegisteredAtUtc = timeProvider.GetUtcNow() + }; + dbContext.MindKeys.Add(key); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.registered", + $"Registered a key for persona {persona} (member account {userAccountId}).", cancellationToken); + return View(key); + } + + public async Task RevokeKeyAsync(CurrentBifrostActor actor, Guid keyId, CancellationToken cancellationToken) + { + var moderator = RequireModerator(actor); + var key = await dbContext.MindKeys.SingleOrDefaultAsync(x => x.Id == keyId, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No such key."); + if (key.RevokedAtUtc is not null) + { + return; + } + + key.RevokedAtUtc = timeProvider.GetUtcNow(); + key.RevokedByUserAccountId = moderator.Id; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.revoked", + $"Revoked a key for persona {key.PersonaId}.", cancellationToken); + } + + public async Task> ListKeysAsync(CurrentBifrostActor actor, CancellationToken cancellationToken) + { + RequireModerator(actor); + return (await dbContext.MindKeys.AsNoTracking().OrderBy(x => x.PersonaId).ThenBy(x => x.RegisteredAtUtc).ToListAsync(cancellationToken)) + .Select(View).ToList(); + } + + /// Verifies the envelope, spends its nonce, then runs the verb as the persona's member account. + public async Task HandleAsync(Guid routeMotionId, MindEnvelope envelope, CancellationToken cancellationToken) + { + var persona = envelope.Persona ?? string.Empty; + if (!PersonaPattern.IsMatch(persona) || envelope.Verb is not ("commit" or "reveal") || !NoncePattern.IsMatch(envelope.Nonce ?? string.Empty) || + new[] { envelope.Commitment, envelope.Candidate, envelope.Reason, envelope.Salt }.Any(f => (f ?? string.Empty).AsSpan().IndexOfAny('\n', '\r') >= 0)) + { + 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."); + } + + 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)); + 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 (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() }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + } + + var account = await dbContext.UserAccounts.AsNoTracking() + .Include(x => x.MemberProfile) + .Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots) + .SingleAsync(x => x.Id == signer.UserAccountId, cancellationToken); + var actor = new CurrentBifrostActor(true, account); + + return envelope.Verb == "commit" + ? await sealedBallots.CommitAsync(actor, routeMotionId, envelope.Commitment ?? string.Empty, cancellationToken) + : await sealedBallots.RevealAsync(actor, routeMotionId, + new SealedBallotDocument(SealedBallotDocument.SchemaId, routeMotionId, envelope.Round, envelope.Candidate ?? string.Empty, + envelope.Reason ?? string.Empty, envelope.Salt ?? string.Empty, persona), + cancellationToken, persona); + } + + /// 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 ?? ""); + + private static bool TryKey(string base64, out byte[] key) + { + key = new byte[32]; + return Convert.TryFromBase64String(base64 ?? string.Empty, key, out var n) && n == 32; + } + + private static UserAccount RequireModerator(CurrentBifrostActor actor) => + actor is { IsActiveMember: true, CanModerateMotions: true, UserAccount: { } user } + ? user + : throw new SealedBallotException(SealedBallotFailure.Forbidden, "Only a moderator manages Mind keys."); + + private static MindKeyView View(MindKey k) => new(k.Id, k.PersonaId, k.UserAccountId, k.PublicKey, k.RegisteredAtUtc, k.RevokedAtUtc); +} + +/// A signed Mind verb. Signature is base64 Ed25519 over . +public sealed record MindEnvelope( + string Persona, string Verb, Guid MotionId, string Nonce, + 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); diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index 4517d55..818465e 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -126,7 +126,8 @@ public sealed class SealedBallotService( CurrentBifrostActor actor, Guid motionId, SealedBallotDocument document, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string persona = "") { var user = RequireMember(actor); var motion = await LoadAsync(motionId, cancellationToken); @@ -142,7 +143,7 @@ public sealed class SealedBallotService( var reason = (document.Reason ?? string.Empty).Trim(); var candidate = (document.Candidate ?? string.Empty).Trim(); - if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || document.Round != motion.Round || + if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || (document.Persona ?? string.Empty) != persona || document.Round != motion.Round || !CandidatesOf(motion).Contains(candidate, StringComparer.Ordinal) || reason.Length is 0 or > MaxReasonLength || reason.Contains('\n') || reason.Contains('\r') || !HexSalt.IsMatch(document.Salt ?? string.Empty)) @@ -366,7 +367,7 @@ public sealed class SealedBallotService( public enum SealedPhase { Commit, Reveal, Closed } -public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid } +public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid, Unauthorized } public sealed class SealedBallotException(SealedBallotFailure failure, string message) : Exception(message) { @@ -375,15 +376,15 @@ public sealed class SealedBallotException(SealedBallotFailure failure, string me /// /// The typed ballot document, schema gamecult.sealed_ballot.v0. A Delphi second round is another motion carrying this -/// same type with a higher Round. The commitment binds the voter's account id, so a commitment cannot be replayed by another member. +/// same type with a higher Round. The commitment binds the voter's account id and, for a Mind casting through the signed intake, its persona id (empty for member casts), so a commitment cannot be replayed by another member or persona. /// -public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt) +public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt, string Persona = "") { public const string SchemaId = "gamecult.sealed_ballot.v0"; public static string Commit(SealedBallotDocument d, Guid voterUserAccountId) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( - $"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant(); + $"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Persona}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant(); } public sealed record OpenSealedMotionCommand( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 4993e43..b8d2fd2 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -122,6 +122,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -562,6 +563,7 @@ async Task SealedCall(Func> call) statusCode: error.Failure switch { SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden, + SealedBallotFailure.Unauthorized => StatusCodes.Status401Unauthorized, SealedBallotFailure.NotFound => StatusCodes.Status404NotFound, SealedBallotFailure.WrongPhase => StatusCodes.Status409Conflict, _ => StatusCodes.Status400BadRequest @@ -580,6 +582,22 @@ sealedMotions.MapPost("/{id:guid}/reveal", (ICurrentBifrostActorAccessor actors, sealedMotions.MapGet("/{id:guid}/result", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) => SealedCall(async () => await service.GetResultAsync(await actors.GetAsync(ct), id, ct))); +sealedMotions.MapPost("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, RegisterMindKeyRequest body, CancellationToken ct) => + SealedCall(async () => await service.RegisterKeyAsync(await actors.GetAsync(ct), body.Persona, body.UserAccountId, body.PublicKey, ct))); +sealedMotions.MapGet("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, CancellationToken ct) => + SealedCall(async () => await service.ListKeysAsync(await actors.GetAsync(ct), ct))); +sealedMotions.MapDelete("/mind-keys/{keyId:guid}", (ICurrentBifrostActorAccessor actors, MindIntakeService service, Guid keyId, CancellationToken ct) => + SealedCall(async () => + { + await service.RevokeKeyAsync(await actors.GetAsync(ct), keyId, ct); + return new { revoked = keyId }; + })); + +// 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.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => { httpContext.Response.Headers.CacheControl = "no-store"; diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 47fc76f..3d4b738 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -23,6 +23,7 @@ public sealed class MigrationDiscoveryTests "20261010043937_AddConceptVotes", "20261022163159_AddOpenIddict", "20261109200000_AddSealedBallotMotions", + "20261109210000_AddMindKeys", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -52,7 +53,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_fifteen_migrations_are_listed_in_order() + public void All_sixteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/MindIntakeTests.cs b/tests/Bifrost.Web.Tests/MindIntakeTests.cs new file mode 100644 index 0000000..08cdf9e --- /dev/null +++ b/tests/Bifrost.Web.Tests/MindIntakeTests.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; + +namespace Bifrost.Web.Tests; + +public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassFixture +{ + private const string Salt = "00112233445566778899aabbccddeeff"; + private const string Reason = "Distinctive-mind-reason-xyzzy."; + private static int _next = 5000; + + private sealed record Key(Ed25519PrivateKeyParameters Private) + { + public string Public => Convert.ToBase64String(Private.GeneratePublicKey().GetEncoded()); + public static Key New() => new(new Ed25519PrivateKeyParameters(new SecureRandom())); + } + + private sealed class World + { + public required string Persona { get; init; } + public required Guid Account { get; init; } + public required Guid RevealMotion { get; init; } + public required Guid CommitMotion { get; init; } + public required Key Key { get; init; } + public required Guid KeyId { get; init; } + public required HttpClient Moderator { get; init; } + public required string MemberGitHubId { get; init; } + } + + private async Task WorldAsync(bool bindRevealToPersona = true) + { + using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); } + var n = Interlocked.Add(ref _next, 10); + var persona = $"mind{n}"; + var now = DateTimeOffset.UtcNow; + Guid account, reveal, commit; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + UserAccount Make(long gh, string name) + { + var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name }; + u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now }; + u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = MembershipStatus.Active, CreatedAtUtc = now }; + Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed"); + db.UserAccounts.Add(u); + return u; + } + + var mind = Make(n + 1, persona); + Make(n + 2, $"member{n}"); + Motion M(DateTimeOffset commitCloses, DateTimeOffset closes) + { + var m = new Motion + { + CreatedByUserAccountId = mind.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", + Candidates = "Ana\nBo", OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, ClosesAtUtc = closes, CreatedAtUtc = now + }; + db.Motions.Add(m); + return m; + } + + var r = M(now.AddHours(-1), now.AddHours(1)); + var c = M(now.AddHours(1), now.AddHours(2)); + db.SealedBallots.Add(new SealedBallot + { + MotionId = r.Id, UserAccountId = mind.Id, CommittedAtUtc = now.AddHours(-2), + Commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, r.Id, 1, "Bo", Reason, Salt, bindRevealToPersona ? persona : ""), mind.Id) + }); + await db.SaveChangesAsync(); + (account, reveal, commit) = (mind.Id, r.Id, c.Id); + } + + var moderator = factory.CreateClient(); + var key = Key.New(); + var registered = await moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona, userAccountId = account, publicKey = key.Public }); + Assert.True(registered.StatusCode == HttpStatusCode.OK, await registered.Content.ReadAsStringAsync()); + var keyId = JsonDocument.Parse(await registered.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid(); + return new World { Persona = persona, Account = account, RevealMotion = reveal, CommitMotion = commit, Key = key, KeyId = keyId, Moderator = moderator, MemberGitHubId = (n + 2).ToString() }; + } + + 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) + { + var e = new MindEnvelope(persona, verb, motion, nonce ?? Guid.NewGuid().ToString("N"), commitment, 1, candidate, reason, salt, ""); + var signer = new Ed25519Signer(); + signer.Init(true, key.Private); + var bytes = System.Text.Encoding.UTF8.GetBytes(MindIntakeService.SigningText(e)); + signer.BlockUpdate(bytes, 0, bytes.Length); + return e with { Signature = Convert.ToBase64String(signer.GenerateSignature()) }; + } + + private HttpClient Anonymous() + { + var c = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + c.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); // the intake needs no session; this proves it + return c; + } + + private static Task Post(HttpClient c, Guid route, MindEnvelope e) => c.PostAsJsonAsync($"/api/sealed-motions/{route}/mind", e); + + private static async Task Refused(HttpResponseMessage r, HttpStatusCode expected) + { + var body = await r.Content.ReadAsStringAsync(); + Assert.True(r.StatusCode == expected, $"{r.StatusCode}: {body}"); + Assert.DoesNotContain(Reason, body); + Assert.DoesNotContain(Salt, body); + Assert.DoesNotContain("Bo", body.Replace("Bonce", "")); + } + + [Fact] + public async Task A_mind_commits_and_reveals_by_signature_alone_and_the_ballot_stays_sealed() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, Salt, w.Persona), w.Account); + var commit = await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment)); + Assert.True(commit.StatusCode == HttpStatusCode.OK, await commit.Content.ReadAsStringAsync()); + + var reveal = await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)); + Assert.True(reveal.StatusCode == HttpStatusCode.OK, await reveal.Content.ReadAsStringAsync()); + Assert.DoesNotContain(Reason, await reveal.Content.ReadAsStringAsync()); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.registered"); + var member = factory.CreateClient(); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member"); + var state = await (await member.GetAsync($"/api/sealed-motions/{w.RevealMotion}")).Content.ReadAsStringAsync(); + Assert.DoesNotContain(Reason, state); + Assert.DoesNotContain(Salt, state); + } + + [Fact] + public async Task Every_refusal_is_typed_and_leaks_no_ballot() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var other = Key.New(); + MindEnvelope Reveal(Key k, string persona = "", string? nonce = null, string? reason = null) => + Sign(k, persona == "" ? w.Persona : persona, "reveal", w.RevealMotion, nonce, candidate: "Bo", reason: reason ?? Reason, salt: Salt); + + await Refused(await Post(anon, w.RevealMotion, Reveal(other)), HttpStatusCode.Unauthorized); // key not registered + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Reason = "tampered" }), HttpStatusCode.Unauthorized); // signature covers every field + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Signature = "AAAA" }), HttpStatusCode.Unauthorized); + await Refused(await Post(anon, w.CommitMotion, Reveal(w.Key)), HttpStatusCode.BadRequest); // signed for another motion than the route + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, nonce: "short")), HttpStatusCode.BadRequest); // malformed nonce + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Verb = "tally" }), HttpStatusCode.BadRequest); + await Refused(await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "reveal", w.CommitMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Conflict); // wrong phase + + // A second persona with its own key cannot speak as the first: persona id must match the signing key. + var rival = await WorldAsync(); + await Refused(await Post(anon, w.RevealMotion, Sign(rival.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized); + + // Replay: the same signed envelope is accepted once, then refused. + var once = Reveal(w.Key); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, once)).StatusCode); + await Refused(await Post(anon, w.RevealMotion, once), HttpStatusCode.Conflict); + // A bad signature does not spend the nonce. + var fresh = Reveal(w.Key); + await Refused(await Post(anon, w.RevealMotion, fresh with { Reason = "x" }), HttpStatusCode.Unauthorized); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, fresh)).StatusCode); + } + + [Fact] + public async Task A_commitment_not_bound_to_the_persona_cannot_be_revealed_through_the_intake() + { + var w = await WorldAsync(bindRevealToPersona: false); + await Refused(await Post(Anonymous(), w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.BadRequest); + } + + [Fact] + public async Task A_revoked_key_is_refused_and_a_commitment_made_before_revocation_stays() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var commitment = new string('c', 64); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment))).StatusCode); + + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.revoked"); + + // Rotation: a new key registered for the same persona works. + var next = Key.New(); + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = next.Public })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(next, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode); + } + + [Fact] + public async Task Only_a_moderator_manages_keys_and_a_persona_stays_bound_to_one_account() + { + var w = await WorldAsync(); + var member = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member"); + var body = new { persona = w.Persona, userAccountId = w.Account, publicKey = Key.New().Public }; + Assert.Equal(HttpStatusCode.Forbidden, (await member.PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await member.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await member.GetAsync("/api/sealed-motions/mind-keys")).StatusCode); + Assert.NotEqual(HttpStatusCode.OK, (await Anonymous().PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode); + + var elsewhere = new { persona = w.Persona, userAccountId = Guid.NewGuid(), publicKey = Key.New().Public }; + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", elsewhere)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = "Bad Name", userAccountId = w.Account, publicKey = Key.New().Public })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = "AAAA" })).StatusCode); + } + + [Fact] + public async Task The_signing_helper_and_the_server_agree_on_the_envelope_and_the_commitment() + { + var w = await WorldAsync(); + var root = AppContext.BaseDirectory; + while (!Directory.Exists(Path.Combine(root, "tools"))) root = Path.GetDirectoryName(root)!; + var keyFile = Path.Combine(Path.GetTempPath(), $"sealed-{Guid.NewGuid():N}.key"); + var seed = w.Key.Private.GetEncoded(); + await File.WriteAllBytesAsync(keyFile, seed); + File.SetUnixFileMode(keyFile, UnixFileMode.UserRead); + try + { + string Run(params string[] a) + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true }; + psi.ArgumentList.Add(Path.Combine(root, "tools", "sealed-motion-sign.mjs")); + foreach (var x in a) psi.ArgumentList.Add(x); + using var p = Process.Start(psi)!; + var o = p.StandardOutput.ReadToEnd(); + p.WaitForExit(); + Assert.True(p.ExitCode == 0, p.StandardError.ReadToEnd()); + return o.Trim(); + } + + Assert.Equal(w.Key.Public, Run("pubkey", "--key", keyFile)); + var committed = JsonDocument.Parse(Run("commit", "--key", keyFile, "--persona", w.Persona, "--motion", w.CommitMotion.ToString(), + "--voter", w.Account.ToString(), "--candidate", "Ana", "--reason", Reason)); + var post = await Anonymous().PostAsync($"/api/sealed-motions/{w.CommitMotion}/mind", + new StringContent(committed.RootElement.GetProperty("envelope").GetRawText(), System.Text.Encoding.UTF8, "application/json")); + Assert.True(post.StatusCode == HttpStatusCode.OK, await post.Content.ReadAsStringAsync()); + + var ballot = committed.RootElement.GetProperty("ballot"); + var expected = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, ballot.GetProperty("salt").GetString()!, w.Persona), w.Account); + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(expected, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + } + finally { File.Delete(keyFile); } + } +} diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 1c91cb2..38c3879 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -135,7 +135,7 @@ public sealed class SealedBallotTests while (!Directory.Exists(Path.Combine(root, "src", "Bifrost.Web"))) root = Path.GetDirectoryName(root)!; var offenders = Directory.EnumerateFiles(Path.Combine(root, "src", "Bifrost.Web"), "*.cs", SearchOption.AllDirectories) .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}")) - .Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("BifrostModels.cs")) + .Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("MindIntakeService.cs") && !f.EndsWith("BifrostModels.cs")) .Where(f => File.ReadAllText(f) is var s && (s.Contains("RevealedCandidate") || s.Contains("RevealedReason") || s.Contains(".Salt"))) .ToList(); Assert.Empty(offenders); diff --git a/tools/sealed-motion-sign.mjs b/tools/sealed-motion-sign.mjs new file mode 100644 index 0000000..3c5fc71 --- /dev/null +++ b/tools/sealed-motion-sign.mjs @@ -0,0 +1,48 @@ +// Signing helper for a Mind's sealed-motion verbs. Reads the Mind's Ed25519 key file (32-byte seed, mode 0400/0600, owned by +// the running user: loadSignedIdentity checks all three), prints a signed envelope on stdout, and posts nothing. +// The output of `commit` also holds the ballot (candidate, reason, salt): keep it in the Mind's private state, never in a room. +// pubkey --key +// commit --key --persona --motion --voter --candidate --reason [--round 1] [--salt ] +// reveal --key --persona --motion --ballot '' +// Post an envelope with: curl -H 'Content-Type: application/json' -d @envelope.json https:///api/sealed-motions//mind +import { createHash, randomBytes, sign } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { loadSignedIdentity } from "./bifrost-signed-identity.mjs"; + +const DOMAIN = "gamecult.sealed_intake.v0"; +const BALLOT_SCHEMA = "gamecult.sealed_ballot.v0"; + +export function commitmentOf({ motion, voter, persona, round, candidate, reason, salt }) { + return createHash("sha256").update(`${BALLOT_SCHEMA}\n${motion}\n${voter}\n${persona}\n${round}\n${candidate}\n${reason}\n${salt}`, "utf8").digest("hex"); +} + +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"); + return { ...e, signature: sign(null, Buffer.from(text, "utf8"), identity.key).toString("base64") }; +} + +function args(argv) { + const out = {}; + for (let i = 1; i < argv.length; i += 2) out[argv[i].replace(/^--/, "")] = argv[i + 1]; + return out; +} + +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"); + 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 }); + 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 }))); + } + throw new Error("usage: pubkey | commit | reveal (see the header of this file)"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2)).catch(e => { console.error(e.message); process.exit(1); }); -- 2.47.3 From 1c9cb9937ee8e0c6ed47ceb4d653279c8e3e1df1 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:50:41 +0000 Subject: [PATCH 2/3] 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)"); } -- 2.47.3 From 0d7c3ec07836b7daa94589f93ecac41163406c71 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Mon, 9 Nov 2026 20:55:03 +0000 Subject: [PATCH 3/3] Mind intake: nonce prune is best-effort and never fails a valid request; test the five-lost-races contended branch; doc the retried reveal's phase judgement Co-Authored-By: Claude Sonnet 5.5 --- docs/sealed-ballot-motion.md | 4 +- .../Features/Motions/MindIntakeService.cs | 15 +++++++- tests/Bifrost.Web.Tests/MindIntakeTests.cs | 37 +++++++++++++++++++ tests/Bifrost.Web.Tests/SealedBallotTests.cs | 34 +++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 7119dda..d47fa9d 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -16,7 +16,7 @@ publication at close, no mediator. Code: `Features/Motions/SealedBallotService.c `SealedBallotCloser` sweep if nobody reads, writes status `Closed` and a digest of all commitments, then returns every revealed ballot (voter, candidate, one-sentence reason, salt, commitment) at once. The close freezes the reveal set: `Motion.RevealSeq` (bumped by every accepted reveal) and `Status` are concurrency tokens, so a reveal racing the close either lands before it (the close re-reads and its digest covers that reveal) or is - refused with 409 ("the motion closed"). A conflict from another voter's reveal while the window is open is re-read and retried, up to five times, then answered 409 "contended; send it again"; the same 409 "contended" is what `/result` answers if its close loses five races in a row; the digest covers `reveals:` plus every commitment's revealed/unrevealed state. A reveal after + refused with 409 ("the motion closed"). A conflict from another voter's reveal while the window is open is re-read and retried, up to five times (a retried reveal is judged for phase once, at the start of the request, but it lands in the frozen digest through `RevealSeq` like any other), then answered 409 "contended; send it again"; the same 409 "contended" is what `/result` answers if its close loses five races in a row; the digest covers `reveals:` plus every commitment's revealed/unrevealed state. A reveal after the deadline or after close is refused. Anyone can recompute each commitment from the published fields. Nobody can close early, extend, edit or break a tie. Canonical document (`gamecult.sealed_ballot.v0`): `{schema, motionId, round, candidate, reason, salt}`. Commitment = @@ -119,7 +119,7 @@ wrong key, tampered field, persona not matching the signing key and a malformed 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 +each accepted envelope, in a separate best-effort write (a prune that fails, for example two requests deleting the same rows, never fails the request), 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 diff --git a/src/Bifrost.Web/Features/Motions/MindIntakeService.cs b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs index 2fa39f1..0b111fb 100644 --- a/src/Bifrost.Web/Features/Motions/MindIntakeService.cs +++ b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs @@ -182,8 +182,19 @@ public sealed class MindIntakeService( throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); } - var cutoff = now - 2 * Skew; - dbContext.MindNonces.RemoveRange(await dbContext.MindNonces.Where(x => x.SeenAtUtc < cutoff).ToListAsync(cancellationToken)); + // Pruning is housekeeping and runs apart from the write that spends the nonce: two requests can load and delete the same + // expired rows, and the loser's concurrency exception must never fail a valid request. + try + { + var cutoff = now - 2 * Skew; + dbContext.MindNonces.RemoveRange(await dbContext.MindNonces.Where(x => x.SeenAtUtc < cutoff).ToListAsync(cancellationToken)); + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + dbContext.ChangeTracker.Clear(); + } + dbContext.MindNonces.Add(new MindNonce { PersonaId = persona, Nonce = envelope.Nonce!, SeenAtUtc = now }); try { diff --git a/tests/Bifrost.Web.Tests/MindIntakeTests.cs b/tests/Bifrost.Web.Tests/MindIntakeTests.cs index 29b0684..776bbac 100644 --- a/tests/Bifrost.Web.Tests/MindIntakeTests.cs +++ b/tests/Bifrost.Web.Tests/MindIntakeTests.cs @@ -373,4 +373,41 @@ public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassF await Assert.ThrowsAsync(() => service.HandleAsync(motion, Sign(key, "mindx", "commit", motion, commitment: new string('a', 64)), default)); } + + private sealed class FailOnNonceDelete : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync(DbContextEventData e, InterceptionResult r, CancellationToken ct = default) => + e.Context!.ChangeTracker.Entries().Any(x => x.State == EntityState.Deleted) + ? throw new DbUpdateConcurrencyException("the other request deleted these rows first") + : ValueTask.FromResult(r); + } + + [Fact] + public async Task A_prune_that_loses_a_race_never_fails_a_valid_request() + { + var clock = TimeProvider.System; + await using var db = new BifrostDbContext(new DbContextOptionsBuilder() + .UseInMemoryDatabase($"intake-prune-{Guid.NewGuid():N}").AddInterceptors(new FailOnNonceDelete()).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 }; + var now = DateTimeOffset.UtcNow; + var motion = new Motion + { + CreatedByUserAccountId = user.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", Candidates = "Ana\nBo", + OpensAtUtc = now, CommitClosesAtUtc = now.AddHours(1), ClosesAtUtc = now.AddHours(2), CreatedAtUtc = now + }; + db.UserAccounts.Add(user); + db.Motions.Add(motion); + db.MindKeys.Add(new MindKey { PersonaId = "mindx", UserAccountId = user.Id, PublicKey = key.Public, RegisteredAtUtc = now }); + db.MindNonces.Add(new MindNonce { PersonaId = "mindx", Nonce = "expired-nonce-expired-1", SeenAtUtc = now.AddHours(-2) }); + await db.SaveChangesAsync(); + var audit = new AuditTrailService(db, clock); + var service = new MindIntakeService(db, new SealedBallotService(db, audit, clock), audit, clock); + + var receipt = await service.HandleAsync(motion.Id, Sign(key, "mindx", "commit", motion.Id, commitment: new string('a', 64)), default); + + Assert.Equal(new string('a', 64), receipt.Commitment); + Assert.Equal(1, await db.MindNonces.CountAsync(x => x.Nonce != "expired-nonce-expired-1")); // the nonce was still spent + } } diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 38c3879..29ebd19 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -397,4 +397,38 @@ public sealed class SealedBallotTests Assert.Equal(SealedBallotFailure.Invalid, ex.Failure); Assert.Null((await rig.Db.SealedBallots.SingleAsync()).RevealedAtUtc); } + + // Moves RevealSeq from another context just before every save, so each attempt of a reveal loses its race. + private sealed class LoseEveryRevealRace(string name, Guid motionId) : SaveChangesInterceptor + { + public override async ValueTask> SavingChangesAsync(DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + if (eventData.Context!.ChangeTracker.Entries().Any(e => e.State == EntityState.Modified)) + { + await using var other = new BifrostDbContext(new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options); + (await other.Motions.SingleAsync(x => x.Id == motionId)).RevealSeq++; + await other.SaveChangesAsync(); + } + + return result; + } + } + + [Fact] + public async Task Five_lost_reveal_races_answer_contended_and_record_nothing() + { + var name = $"sealed-contended-{Guid.NewGuid():N}"; + var seeder = Peer(name, T0); + var (actor, motion) = await SeedPeerWorld(seeder.Db, seeder.Service); + var revealer = Peer(name, T0.AddMinutes(90), new LoseEveryRevealRace(name, motion.Id)); + + var ex = await Assert.ThrowsAsync(() => + revealer.Service.RevealAsync(actor, motion.Id, new SealedBallotDocument(SealedBallotDocument.SchemaId, motion.Id, 1, "Ana", "Mine.", Salt), default)); + + Assert.Equal(SealedBallotFailure.WrongPhase, ex.Failure); + Assert.Contains("contended", ex.Message); + await using var check = Peer(name, T0.AddMinutes(90)).Db; + Assert.Null((await check.SealedBallots.AsNoTracking().SingleAsync()).RevealedAtUtc); + Assert.Equal(5, (await check.Motions.AsNoTracking().SingleAsync()).RevealSeq); // five rival writes, none of them this reveal + } } -- 2.47.3