Signed Mind intake for sealed motions (stacked on #21) #22
13 changed files with 1113 additions and 9 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
277
src/Bifrost.Web/Features/Motions/MindIntakeService.cs
Normal file
277
src/Bifrost.Web/Features/Motions/MindIntakeService.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
413
tests/Bifrost.Web.Tests/MindIntakeTests.cs
Normal file
413
tests/Bifrost.Web.Tests/MindIntakeTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
48
tools/sealed-motion-sign.mjs
Normal file
48
tools/sealed-motion-sign.mjs
Normal 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); });
|
||||
Loading…
Reference in a new issue