Signed Mind intake for sealed motions (stacked on #21) #22

Merged
heimdall merged 3 commits from heimdall/sealed-mind-intake into main 2026-11-09 20:58:26 +00:00
13 changed files with 1113 additions and 9 deletions

View file

@ -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:<RevealSeq>` 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:<RevealSeq>` 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 =
@ -99,3 +99,54 @@ 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, 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, 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`).
**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, 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
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
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 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,
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:** the budget is per process, so several app instances multiply it; the moderator's identity is the member cookie, as for every moderator act.

View file

@ -43,6 +43,12 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
public DbSet<SealedBallot> SealedBallots => Set<SealedBallot>();
public DbSet<MindKey> MindKeys => Set<MindKey>();
public DbSet<MindNonce> MindNonces => Set<MindNonce>();
public DbSet<MindPersona> MindPersonas => Set<MindPersona>();
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
@ -316,6 +322,22 @@ public sealed class BifrostDbContext(DbContextOptions<BifrostDbContext> options)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<MindKey>(entity =>
{
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();
});
modelBuilder.Entity<LedgerEntry>(entity =>
{
entity.Property(x => x.EntryType).HasConversion<string>();

View file

@ -0,0 +1,65 @@
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<Guid>(type: "uuid", nullable: false),
PersonaId = table.Column<string>(type: "text", nullable: false),
UserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
PublicKey = table.Column<string>(type: "text", nullable: false),
RegisteredByUserAccountId = table.Column<Guid>(type: "uuid", nullable: false),
RegisteredAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
RevokedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
RevokedByUserAccountId = table.Column<Guid>(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: "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
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
PersonaId = table.Column<string>(type: "text", nullable: false),
Nonce = table.Column<string>(type: "text", nullable: false),
SeenAtUtc = table.Column<DateTimeOffset>(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: "MindPersonas");
migrationBuilder.DropTable(name: "MindKeys");
}
}
}

View file

@ -1440,6 +1440,92 @@ namespace Bifrost.Web.Data.Migrations
b.ToTable("VolunteerClaims");
});
modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("PersonaId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PublicKey")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("RegisteredByUserAccountId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("RegisteredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("RevokedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("RevokedByUserAccountId")
.HasColumnType("uuid");
b.Property<Guid>("UserAccountId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("PersonaId", "PublicKey")
.IsUnique();
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")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Nonce")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PersonaId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("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<Guid>("Id")

View file

@ -1153,3 +1153,47 @@ public sealed class AuditEvent
public UserAccount? ActorUserAccount { get; set; }
}
/// <summary>A Mind's registered Ed25519 public key. Bifrost never holds a private key.</summary>
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; }
}
/// <summary>A nonce already spent by a persona; a verified envelope with a seen nonce is refused as a replay.</summary>
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; }
}
/// <summary>The one member account a persona votes as. Unique on both columns: a persona has one account and an account one persona.</summary>
public sealed class MindPersona
{
public Guid Id { get; set; } = Guid.NewGuid();
public string PersonaId { get; set; } = string.Empty;
public Guid UserAccountId { get; set; }
}

View file

@ -0,0 +1,277 @@
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;
/// <summary>
/// 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 <see cref="SealedBallotService"/> as the Mind's member
/// account, so Minds and members share one set of rules. Registration and revocation are moderator acts and are audited.
/// </summary>
public sealed class MindIntakeService(
BifrostDbContext dbContext,
SealedBallotService sealedBallots,
AuditTrailService auditTrailService,
TimeProvider timeProvider)
{
public const string Domain = "gamecult.sealed_intake.v0";
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)
{
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.");
}
// 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 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))
{
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);
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);
}
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<IReadOnlyList<MindKeyView>> 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();
}
/// <summary>Verifies the envelope, spends its nonce, then runs the verb as the persona's member account.</summary>
public async Task<SealedBallotReceipt> 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.");
}
// 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 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.");
}
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.");
}
// 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
{
await dbContext.SaveChangesAsync(cancellationToken);
}
catch (DbUpdateException)
{
// 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()
.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);
}
/// <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.IssuedAt, 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);
}
/// <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, long IssuedAt,
string? Commitment, int Round, string? Candidate, string? Reason, string? Salt, string Signature);
public sealed record MindKeyView(Guid Id, string Persona, Guid UserAccountId, string PublicKey, DateTimeOffset RegisteredAtUtc, DateTimeOffset? RevokedAtUtc);
public sealed record RegisterMindKeyRequest(string Persona, Guid UserAccountId, string PublicKey);
/// <summary>Limits on the anonymous signed-intake endpoint (section "MindIntake").</summary>
public sealed class MindIntakeOptions
{
public const string SectionName = "MindIntake";
public int Permits { get; init; } = 30;
public TimeSpan Window { get; init; } = TimeSpan.FromMinutes(1);
public int MaxTrackedKeys { get; init; } = 10_000;
public int MaxBodyBytes { get; init; } = 4096;
}
/// <summary>Per-source permit budget for the intake, on the shared <see cref="FixedWindowBudget"/>. Spent before the body is read.</summary>
public sealed class MindIntakeRateLimit(Microsoft.Extensions.Options.IOptions<MindIntakeOptions> options, TimeProvider clock)
{
private readonly FixedWindowBudget budget = new(options.Value.Permits, options.Value.Window, options.Value.MaxTrackedKeys, clock);
public BudgetDenial? Spend(string source) => budget.Spend(source);
}

View file

@ -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
/// <summary>
/// The typed ballot document, schema <c>gamecult.sealed_ballot.v0</c>. 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.
/// </summary>
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(

View file

@ -122,6 +122,9 @@ builder.Services.AddScoped<BifrostIdentityService>();
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>();
@ -562,6 +565,7 @@ async Task<IResult> SealedCall(Func<Task<object>> 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 +584,63 @@ 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", 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) =>
{
httpContext.Response.Headers.CacheControl = "no-store";

View file

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

View file

@ -0,0 +1,413 @@
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.Features.Shared;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
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<TestWebApplicationFactory>
{
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<World> 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<BifrostDbContext>();
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, long? issuedAt = null)
{
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));
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<HttpResponseMessage> 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<BifrostDbContext>();
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<BifrostDbContext>();
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<BifrostDbContext>();
Assert.Equal(expected, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment);
}
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));
}
private sealed class FailOnNonceDelete : 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.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<BifrostDbContext>()
.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
}
}

View file

@ -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);
@ -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<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
if (eventData.Context!.ChangeTracker.Entries<SealedBallot>().Any(e => e.State == EntityState.Modified))
{
await using var other = new BifrostDbContext(new DbContextOptionsBuilder<BifrostDbContext>().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<SealedBallotException>(() =>
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
}
}

View file

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

View file

@ -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 <file>
// commit --key <file> --persona <id> --motion <uuid> --voter <member account uuid> --candidate <name> --reason <one line> [--round 1] [--salt <hex>]
// reveal --key <file> --persona <id> --motion <uuid> --ballot '<json from commit>'
// Post an envelope with: curl -H 'Content-Type: application/json' -d @envelope.json https://<bifrost>/api/sealed-motions/<motion>/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.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") };
}
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"), 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, 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, 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)");
}
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); });