diff --git a/docs/sealed-ballot-motion.md b/docs/sealed-ballot-motion.md index 35ce1e8..ca43fe3 100644 --- a/docs/sealed-ballot-motion.md +++ b/docs/sealed-ballot-motion.md @@ -99,3 +99,45 @@ Only through the existing member cookie, and awkwardly. Read from the code: Answer: not cleanly. A Mind can hold a session if a human registers and approves it and the Mind keeps one cookie jar alive; Bifrost has no durable non-browser credential for a Mind. I invented no auth path. Whether to give Minds one (a Persona-signed or token intake like `tools/bifrost-signed-identity.mjs`) is a ruling for the stewards, not this PR. + +## Signed Mind intake + +A Mind casts through `POST /api/sealed-motions/{id}/mind` with a signed envelope. No session or cookie is involved; the +signature is the credential, and a verified envelope runs the same `SealedBallotService` verbs as a member cast, as the +persona's member account. + +**Envelope:** `{persona, verb: "commit"|"reveal", motionId, nonce, commitment | (round, candidate, reason, salt), signature}`. +`signature` is base64 Ed25519 over `MindIntakeService.SigningText`: `gamecult.sealed_intake.v0`, persona, verb, motion id, +nonce, commitment, round, candidate, reason, salt, one per line, unused fields empty. Ed25519 is not in .NET 8's built-ins; +verification uses BouncyCastle, already a dependency (`HeimdallTokenVerifier.cs`). + +**Refusals** (each tested; none carries a ballot): malformed envelope or nonce, or a motion id different from the route: 400; +no registered, unrevoked key for that persona verifies the signature (unknown persona, revoked key, wrong key, tampered +field, persona that does not match the signing key: one 401 answer, nothing to probe); a replayed nonce (stored per persona, +spent only after the signature verifies, so an unsigned caller cannot burn nonces): 409; wrong phase: 409; a reveal whose +persona does not match the persona bound into the commitment: 400. + +**Persona id in the commitment:** the commitment hash is `sha256(schema, motion, voter account, persona, round, candidate, +reason, salt)`; member casts use an empty persona. A reveal through the intake must carry the signing persona, so a commitment +made without it cannot be revealed as that persona. + +**Keys and trust.** Bifrost stores only public keys (`MindKeys`). A moderator registers a key for a persona and its Active member +account with `POST /api/sealed-motions/mind-keys {persona, userAccountId, publicKey}` and revokes with +`DELETE /api/sealed-motions/mind-keys/{id}`; both are audited (`mindkey.registered`, `mindkey.revoked`). A persona stays bound +to the first account it was registered against. The trust is the moderator's: Bifrost cannot tell whether a registered public +key really belongs to the Mind. + +**What a Mind holds.** An Ed25519 private key (32-byte seed) at `/etc/gamecult/minds//sealed-motion.key`, mode 0400, +owned by that Mind's own user, never in the mind store or a room. Eureka generates it on the host and prints only the public +half; a moderator registers it. **Whoever can read that file can vote as that Mind.** Root on the host can; Bifrost cannot. + +**Signing.** `tools/sealed-motion-sign.mjs` (`pubkey`, `commit`, `reveal`) reads the key file, checks its type, mode and owner, +and prints the envelope; it posts nothing. `commit` also prints the ballot (candidate, reason, salt), which the Mind keeps in +private state to send `reveal` later; the persona turn posts the printed envelope with `curl`. The voter id `commit` needs is +the persona's member account id (the `userAccountId` the moderator registered). + +**Rotation.** Generate a new key, register it, revoke the old one. A ballot committed or revealed before revocation stays valid +for its motion. A key revoked mid-motion can no longer reveal, so rotate between motions. A lost key is handled as rotation. + +**Not covered:** no rate limit or body-size limit on the intake endpoint beyond the host's; the nonce table grows without +pruning; the moderator's identity is the member cookie, as for every moderator act. diff --git a/src/Bifrost.Web/Data/BifrostDbContext.cs b/src/Bifrost.Web/Data/BifrostDbContext.cs index fa4d2ae..ec24585 100644 --- a/src/Bifrost.Web/Data/BifrostDbContext.cs +++ b/src/Bifrost.Web/Data/BifrostDbContext.cs @@ -43,6 +43,10 @@ public sealed class BifrostDbContext(DbContextOptions options) public DbSet SealedBallots => Set(); + public DbSet MindKeys => Set(); + + public DbSet MindNonces => Set(); + public DbSet LedgerEntries => Set(); public DbSet PayoutProposalBatches => Set(); @@ -316,6 +320,16 @@ public sealed class BifrostDbContext(DbContextOptions options) .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.PersonaId, x.PublicKey }).IsUnique(); + }); + + modelBuilder.Entity(entity => + { + entity.HasIndex(x => new { x.PersonaId, x.Nonce }).IsUnique(); + }); + modelBuilder.Entity(entity => { entity.Property(x => x.EntryType).HasConversion(); diff --git a/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs b/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs new file mode 100644 index 0000000..f355680 --- /dev/null +++ b/src/Bifrost.Web/Data/Migrations/20261109210000_AddMindKeys.cs @@ -0,0 +1,52 @@ +using System; +using Bifrost.Web.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Bifrost.Web.Data.Migrations +{ + // Hand-written like AddSealedBallotMotions: no dotnet-ef on the authoring host; the snapshot was edited to match. + [DbContext(typeof(BifrostDbContext))] + [Migration("20261109210000_AddMindKeys")] + public partial class AddMindKeys : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MindKeys", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PersonaId = table.Column(type: "text", nullable: false), + UserAccountId = table.Column(type: "uuid", nullable: false), + PublicKey = table.Column(type: "text", nullable: false), + RegisteredByUserAccountId = table.Column(type: "uuid", nullable: false), + RegisteredAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + RevokedAtUtc = table.Column(type: "timestamp with time zone", nullable: true), + RevokedByUserAccountId = table.Column(type: "uuid", nullable: true) + }, + constraints: table => table.PrimaryKey("PK_MindKeys", x => x.Id)); + migrationBuilder.CreateIndex(name: "IX_MindKeys_PersonaId_PublicKey", table: "MindKeys", columns: new[] { "PersonaId", "PublicKey" }, unique: true); + + migrationBuilder.CreateTable( + name: "MindNonces", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + PersonaId = table.Column(type: "text", nullable: false), + Nonce = table.Column(type: "text", nullable: false), + SeenAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => table.PrimaryKey("PK_MindNonces", x => x.Id)); + migrationBuilder.CreateIndex(name: "IX_MindNonces_PersonaId_Nonce", table: "MindNonces", columns: new[] { "PersonaId", "Nonce" }, unique: true); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "MindNonces"); + migrationBuilder.DropTable(name: "MindKeys"); + } + } +} diff --git a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs index 289201d..edd7968 100644 --- a/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs +++ b/src/Bifrost.Web/Data/Migrations/BifrostDbContextModelSnapshot.cs @@ -1440,6 +1440,68 @@ namespace Bifrost.Web.Data.Migrations b.ToTable("VolunteerClaims"); }); + modelBuilder.Entity("Bifrost.Web.Domain.MindKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PersonaId") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegisteredByUserAccountId") + .HasColumnType("uuid"); + + b.Property("RegisteredAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("RevokedByUserAccountId") + .HasColumnType("uuid"); + + b.Property("UserAccountId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PersonaId", "PublicKey") + .IsUnique(); + + b.ToTable("MindKeys"); + }); + + modelBuilder.Entity("Bifrost.Web.Domain.MindNonce", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Nonce") + .IsRequired() + .HasColumnType("text"); + + b.Property("PersonaId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PersonaId", "Nonce") + .IsUnique(); + + b.ToTable("MindNonces"); + }); + modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b => { b.Property("Id") diff --git a/src/Bifrost.Web/Domain/BifrostModels.cs b/src/Bifrost.Web/Domain/BifrostModels.cs index 5d6aab6..fcb86e1 100644 --- a/src/Bifrost.Web/Domain/BifrostModels.cs +++ b/src/Bifrost.Web/Domain/BifrostModels.cs @@ -1153,3 +1153,37 @@ public sealed class AuditEvent public UserAccount? ActorUserAccount { get; set; } } + +/// A Mind's registered Ed25519 public key. Bifrost never holds a private key. +public sealed class MindKey +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string PersonaId { get; set; } = string.Empty; + + // The member account this persona votes as; fixed by the first registration for the persona. + public Guid UserAccountId { get; set; } + + // Base64 of the 32-byte public key. + public string PublicKey { get; set; } = string.Empty; + + public Guid RegisteredByUserAccountId { get; set; } + + public DateTimeOffset RegisteredAtUtc { get; set; } + + public DateTimeOffset? RevokedAtUtc { get; set; } + + public Guid? RevokedByUserAccountId { get; set; } +} + +/// A nonce already spent by a persona; a verified envelope with a seen nonce is refused as a replay. +public sealed class MindNonce +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public string PersonaId { get; set; } = string.Empty; + + public string Nonce { get; set; } = string.Empty; + + public DateTimeOffset SeenAtUtc { get; set; } +} diff --git a/src/Bifrost.Web/Features/Motions/MindIntakeService.cs b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs new file mode 100644 index 0000000..ae507cd --- /dev/null +++ b/src/Bifrost.Web/Features/Motions/MindIntakeService.cs @@ -0,0 +1,177 @@ +using System.Text; +using System.Text.RegularExpressions; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Shared; +using Microsoft.EntityFrameworkCore; +using Org.BouncyCastle.Math.EC.Rfc8032; + +namespace Bifrost.Web.Features.Motions; + +/// +/// Signed intake for Minds. A Mind holds an Ed25519 private key; Bifrost holds only registered public keys. The signature, +/// not a session, is the credential: a verified envelope is handed to as the Mind's member +/// account, so Minds and members share one set of rules. Registration and revocation are moderator acts and are audited. +/// +public sealed class MindIntakeService( + BifrostDbContext dbContext, + SealedBallotService sealedBallots, + AuditTrailService auditTrailService, + TimeProvider timeProvider) +{ + public const string Domain = "gamecult.sealed_intake.v0"; + private static readonly Regex PersonaPattern = new("^[a-z0-9][a-z0-9-]{0,31}$", RegexOptions.Compiled); + private static readonly Regex NoncePattern = new("^[A-Za-z0-9_-]{16,64}$", RegexOptions.Compiled); + + public async Task RegisterKeyAsync( + CurrentBifrostActor actor, string persona, Guid userAccountId, string publicKey, CancellationToken cancellationToken) + { + var moderator = RequireModerator(actor); + persona = (persona ?? string.Empty).Trim(); + if (!PersonaPattern.IsMatch(persona)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A persona id is 1-32 lowercase letters, digits or hyphens."); + } + + if (!TryKey(publicKey, out _)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "A public key is the base64 of 32 bytes (Ed25519)."); + } + + var account = await dbContext.UserAccounts.Include(x => x.Membership).SingleOrDefaultAsync(x => x.Id == userAccountId, cancellationToken); + if (account?.Membership?.Status != MembershipStatus.Active) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The persona's member account must exist and be Active."); + } + + var bound = await dbContext.MindKeys.Where(x => x.PersonaId == persona).Select(x => (Guid?)x.UserAccountId).FirstOrDefaultAsync(cancellationToken); + if (bound is { } other && other != userAccountId) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "That persona is already bound to a different member account."); + } + + if (await dbContext.MindKeys.AnyAsync(x => x.PersonaId == persona && x.PublicKey == publicKey, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That key is already registered for the persona."); + } + + var key = new MindKey + { + PersonaId = persona, UserAccountId = userAccountId, PublicKey = publicKey, + RegisteredByUserAccountId = moderator.Id, RegisteredAtUtc = timeProvider.GetUtcNow() + }; + dbContext.MindKeys.Add(key); + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.registered", + $"Registered a key for persona {persona} (member account {userAccountId}).", cancellationToken); + return View(key); + } + + public async Task RevokeKeyAsync(CurrentBifrostActor actor, Guid keyId, CancellationToken cancellationToken) + { + var moderator = RequireModerator(actor); + var key = await dbContext.MindKeys.SingleOrDefaultAsync(x => x.Id == keyId, cancellationToken) + ?? throw new SealedBallotException(SealedBallotFailure.NotFound, "No such key."); + if (key.RevokedAtUtc is not null) + { + return; + } + + key.RevokedAtUtc = timeProvider.GetUtcNow(); + key.RevokedByUserAccountId = moderator.Id; + await dbContext.SaveChangesAsync(cancellationToken); + await auditTrailService.RecordAsync(moderator.Id, nameof(MindKey), key.Id, "mindkey.revoked", + $"Revoked a key for persona {key.PersonaId}.", cancellationToken); + } + + public async Task> ListKeysAsync(CurrentBifrostActor actor, CancellationToken cancellationToken) + { + RequireModerator(actor); + return (await dbContext.MindKeys.AsNoTracking().OrderBy(x => x.PersonaId).ThenBy(x => x.RegisteredAtUtc).ToListAsync(cancellationToken)) + .Select(View).ToList(); + } + + /// Verifies the envelope, spends its nonce, then runs the verb as the persona's member account. + public async Task HandleAsync(Guid routeMotionId, MindEnvelope envelope, CancellationToken cancellationToken) + { + var persona = envelope.Persona ?? string.Empty; + if (!PersonaPattern.IsMatch(persona) || envelope.Verb is not ("commit" or "reveal") || !NoncePattern.IsMatch(envelope.Nonce ?? string.Empty) || + new[] { envelope.Commitment, envelope.Candidate, envelope.Reason, envelope.Salt }.Any(f => (f ?? string.Empty).AsSpan().IndexOfAny('\n', '\r') >= 0)) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope is malformed."); + } + + if (envelope.MotionId != routeMotionId) + { + throw new SealedBallotException(SealedBallotFailure.Invalid, "The envelope was signed for a different motion."); + } + + var keys = await dbContext.MindKeys.AsNoTracking().Where(x => x.PersonaId == persona && x.RevokedAtUtc == null).ToListAsync(cancellationToken); + var message = Encoding.UTF8.GetBytes(SigningText(envelope)); + var signature = Convert.TryFromBase64String(envelope.Signature ?? string.Empty, new byte[64], out var written) && written == 64 + ? Convert.FromBase64String(envelope.Signature!) : null; + var signer = signature is null ? null : keys.FirstOrDefault(k => + TryKey(k.PublicKey, out var pub) && Ed25519.Verify(signature, 0, pub, 0, message, 0, message.Length)); + if (signer is null) + { + // One answer for unknown persona, revoked key, wrong key and bad signature: nothing to probe. + throw new SealedBallotException(SealedBallotFailure.Unauthorized, "The envelope is not signed by a registered key for that persona."); + } + + // Spend the nonce only after the signature verifies, so an unsigned caller cannot burn a persona's nonces. + if (await dbContext.MindNonces.AnyAsync(x => x.PersonaId == persona && x.Nonce == envelope.Nonce, cancellationToken)) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + } + + dbContext.MindNonces.Add(new MindNonce { PersonaId = persona, Nonce = envelope.Nonce!, SeenAtUtc = timeProvider.GetUtcNow() }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + throw new SealedBallotException(SealedBallotFailure.WrongPhase, "That nonce was already used."); + } + + var account = await dbContext.UserAccounts.AsNoTracking() + .Include(x => x.MemberProfile) + .Include(x => x.Membership!).ThenInclude(x => x.RoleAssignments) + .Include(x => x.Membership!).ThenInclude(x => x.TierSnapshots) + .SingleAsync(x => x.Id == signer.UserAccountId, cancellationToken); + var actor = new CurrentBifrostActor(true, account); + + return envelope.Verb == "commit" + ? await sealedBallots.CommitAsync(actor, routeMotionId, envelope.Commitment ?? string.Empty, cancellationToken) + : await sealedBallots.RevealAsync(actor, routeMotionId, + new SealedBallotDocument(SealedBallotDocument.SchemaId, routeMotionId, envelope.Round, envelope.Candidate ?? string.Empty, + envelope.Reason ?? string.Empty, envelope.Salt ?? string.Empty, persona), + cancellationToken, persona); + } + + /// The exact text a Mind signs: every field, fixed order, newline separated, unused fields empty. + public static string SigningText(MindEnvelope e) => + string.Join('\n', Domain, e.Persona, e.Verb, e.MotionId.ToString("D"), e.Nonce, e.Commitment ?? "", e.Round, e.Candidate ?? "", e.Reason ?? "", e.Salt ?? ""); + + private static bool TryKey(string base64, out byte[] key) + { + key = new byte[32]; + return Convert.TryFromBase64String(base64 ?? string.Empty, key, out var n) && n == 32; + } + + private static UserAccount RequireModerator(CurrentBifrostActor actor) => + actor is { IsActiveMember: true, CanModerateMotions: true, UserAccount: { } user } + ? user + : throw new SealedBallotException(SealedBallotFailure.Forbidden, "Only a moderator manages Mind keys."); + + private static MindKeyView View(MindKey k) => new(k.Id, k.PersonaId, k.UserAccountId, k.PublicKey, k.RegisteredAtUtc, k.RevokedAtUtc); +} + +/// A signed Mind verb. Signature is base64 Ed25519 over . +public sealed record MindEnvelope( + string Persona, string Verb, Guid MotionId, string Nonce, + string? Commitment, int Round, string? Candidate, string? Reason, string? Salt, string Signature); + +public sealed record MindKeyView(Guid Id, string Persona, Guid UserAccountId, string PublicKey, DateTimeOffset RegisteredAtUtc, DateTimeOffset? RevokedAtUtc); + +public sealed record RegisterMindKeyRequest(string Persona, Guid UserAccountId, string PublicKey); diff --git a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs index 4517d55..818465e 100644 --- a/src/Bifrost.Web/Features/Motions/SealedBallotService.cs +++ b/src/Bifrost.Web/Features/Motions/SealedBallotService.cs @@ -126,7 +126,8 @@ public sealed class SealedBallotService( CurrentBifrostActor actor, Guid motionId, SealedBallotDocument document, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + string persona = "") { var user = RequireMember(actor); var motion = await LoadAsync(motionId, cancellationToken); @@ -142,7 +143,7 @@ public sealed class SealedBallotService( var reason = (document.Reason ?? string.Empty).Trim(); var candidate = (document.Candidate ?? string.Empty).Trim(); - if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || document.Round != motion.Round || + if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || (document.Persona ?? string.Empty) != persona || document.Round != motion.Round || !CandidatesOf(motion).Contains(candidate, StringComparer.Ordinal) || reason.Length is 0 or > MaxReasonLength || reason.Contains('\n') || reason.Contains('\r') || !HexSalt.IsMatch(document.Salt ?? string.Empty)) @@ -366,7 +367,7 @@ public sealed class SealedBallotService( public enum SealedPhase { Commit, Reveal, Closed } -public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid } +public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid, Unauthorized } public sealed class SealedBallotException(SealedBallotFailure failure, string message) : Exception(message) { @@ -375,15 +376,15 @@ public sealed class SealedBallotException(SealedBallotFailure failure, string me /// /// The typed ballot document, schema gamecult.sealed_ballot.v0. A Delphi second round is another motion carrying this -/// same type with a higher Round. The commitment binds the voter's account id, so a commitment cannot be replayed by another member. +/// same type with a higher Round. The commitment binds the voter's account id and, for a Mind casting through the signed intake, its persona id (empty for member casts), so a commitment cannot be replayed by another member or persona. /// -public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt) +public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt, string Persona = "") { public const string SchemaId = "gamecult.sealed_ballot.v0"; public static string Commit(SealedBallotDocument d, Guid voterUserAccountId) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes( - $"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant(); + $"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Persona}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant(); } public sealed record OpenSealedMotionCommand( diff --git a/src/Bifrost.Web/Program.cs b/src/Bifrost.Web/Program.cs index 4993e43..b8d2fd2 100644 --- a/src/Bifrost.Web/Program.cs +++ b/src/Bifrost.Web/Program.cs @@ -122,6 +122,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -562,6 +563,7 @@ async Task SealedCall(Func> call) statusCode: error.Failure switch { SealedBallotFailure.Forbidden => StatusCodes.Status403Forbidden, + SealedBallotFailure.Unauthorized => StatusCodes.Status401Unauthorized, SealedBallotFailure.NotFound => StatusCodes.Status404NotFound, SealedBallotFailure.WrongPhase => StatusCodes.Status409Conflict, _ => StatusCodes.Status400BadRequest @@ -580,6 +582,22 @@ sealedMotions.MapPost("/{id:guid}/reveal", (ICurrentBifrostActorAccessor actors, sealedMotions.MapGet("/{id:guid}/result", (ICurrentBifrostActorAccessor actors, SealedBallotService service, Guid id, CancellationToken ct) => SealedCall(async () => await service.GetResultAsync(await actors.GetAsync(ct), id, ct))); +sealedMotions.MapPost("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, RegisterMindKeyRequest body, CancellationToken ct) => + SealedCall(async () => await service.RegisterKeyAsync(await actors.GetAsync(ct), body.Persona, body.UserAccountId, body.PublicKey, ct))); +sealedMotions.MapGet("/mind-keys", (ICurrentBifrostActorAccessor actors, MindIntakeService service, CancellationToken ct) => + SealedCall(async () => await service.ListKeysAsync(await actors.GetAsync(ct), ct))); +sealedMotions.MapDelete("/mind-keys/{keyId:guid}", (ICurrentBifrostActorAccessor actors, MindIntakeService service, Guid keyId, CancellationToken ct) => + SealedCall(async () => + { + await service.RevokeKeyAsync(await actors.GetAsync(ct), keyId, ct); + return new { revoked = keyId }; + })); + +// Signed Mind intake: the Ed25519 signature is the credential. No session, no cookie; the verified envelope runs the same +// SealedBallotService verbs as a member cast. +app.MapPost("/api/sealed-motions/{id:guid}/mind", (MindIntakeService service, Guid id, MindEnvelope envelope, CancellationToken ct) => + SealedCall(async () => await service.HandleAsync(id, envelope, ct))).AllowAnonymous(); + app.MapGet("/auth/heimdall/complete", (HttpContext httpContext) => { httpContext.Response.Headers.CacheControl = "no-store"; diff --git a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs index 47fc76f..3d4b738 100644 --- a/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs +++ b/tests/Bifrost.Web.Tests/MigrationDiscoveryTests.cs @@ -23,6 +23,7 @@ public sealed class MigrationDiscoveryTests "20261010043937_AddConceptVotes", "20261022163159_AddOpenIddict", "20261109200000_AddSealedBallotMotions", + "20261109210000_AddMindKeys", ]; // Database.GetMigrations() reads assembly metadata only; no connection is opened. @@ -52,7 +53,7 @@ public sealed class MigrationDiscoveryTests } [Fact] - public void All_fifteen_migrations_are_listed_in_order() + public void All_sixteen_migrations_are_listed_in_order() { Assert.Equal(ExpectedMigrations, Discovered()); } diff --git a/tests/Bifrost.Web.Tests/MindIntakeTests.cs b/tests/Bifrost.Web.Tests/MindIntakeTests.cs new file mode 100644 index 0000000..08cdf9e --- /dev/null +++ b/tests/Bifrost.Web.Tests/MindIntakeTests.cs @@ -0,0 +1,264 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Bifrost.Web.Data; +using Bifrost.Web.Domain; +using Bifrost.Web.Features.Motions; +using Bifrost.Web.Tests.Support; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; + +namespace Bifrost.Web.Tests; + +public sealed class MindIntakeTests(TestWebApplicationFactory factory) : IClassFixture +{ + private const string Salt = "00112233445566778899aabbccddeeff"; + private const string Reason = "Distinctive-mind-reason-xyzzy."; + private static int _next = 5000; + + private sealed record Key(Ed25519PrivateKeyParameters Private) + { + public string Public => Convert.ToBase64String(Private.GeneratePublicKey().GetEncoded()); + public static Key New() => new(new Ed25519PrivateKeyParameters(new SecureRandom())); + } + + private sealed class World + { + public required string Persona { get; init; } + public required Guid Account { get; init; } + public required Guid RevealMotion { get; init; } + public required Guid CommitMotion { get; init; } + public required Key Key { get; init; } + public required Guid KeyId { get; init; } + public required HttpClient Moderator { get; init; } + public required string MemberGitHubId { get; init; } + } + + private async Task WorldAsync(bool bindRevealToPersona = true) + { + using (var warmup = factory.CreateClient()) { _ = await warmup.GetAsync("/App"); } + var n = Interlocked.Add(ref _next, 10); + var persona = $"mind{n}"; + var now = DateTimeOffset.UtcNow; + Guid account, reveal, commit; + using (var scope = factory.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + UserAccount Make(long gh, string name) + { + var u = new UserAccount { GitHubUserId = gh, GitHubLogin = name, NormalizedGitHubLogin = name.ToUpperInvariant(), DisplayName = name }; + u.MemberProfile = new MemberProfile { UserAccountId = u.Id, Nickname = name, Headline = "seed", UpdatedAtUtc = now }; + u.Membership = new Domain.Membership { UserAccountId = u.Id, Status = MembershipStatus.Active, CreatedAtUtc = now }; + Bifrost.Web.Features.Shared.ApplicationBootstrapper.EnsureRole(u.Membership, MemberRole.StandardMember, null, now, "seed"); + db.UserAccounts.Add(u); + return u; + } + + var mind = Make(n + 1, persona); + Make(n + 2, $"member{n}"); + Motion M(DateTimeOffset commitCloses, DateTimeOffset closes) + { + var m = new Motion + { + CreatedByUserAccountId = mind.Id, Scope = MotionScope.Management, Kind = MotionKind.SealedBallot, Title = "t", Summary = "s", + Candidates = "Ana\nBo", OpensAtUtc = now.AddHours(-3), CommitClosesAtUtc = commitCloses, ClosesAtUtc = closes, CreatedAtUtc = now + }; + db.Motions.Add(m); + return m; + } + + var r = M(now.AddHours(-1), now.AddHours(1)); + var c = M(now.AddHours(1), now.AddHours(2)); + db.SealedBallots.Add(new SealedBallot + { + MotionId = r.Id, UserAccountId = mind.Id, CommittedAtUtc = now.AddHours(-2), + Commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, r.Id, 1, "Bo", Reason, Salt, bindRevealToPersona ? persona : ""), mind.Id) + }); + await db.SaveChangesAsync(); + (account, reveal, commit) = (mind.Id, r.Id, c.Id); + } + + var moderator = factory.CreateClient(); + var key = Key.New(); + var registered = await moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona, userAccountId = account, publicKey = key.Public }); + Assert.True(registered.StatusCode == HttpStatusCode.OK, await registered.Content.ReadAsStringAsync()); + var keyId = JsonDocument.Parse(await registered.Content.ReadAsStringAsync()).RootElement.GetProperty("id").GetGuid(); + return new World { Persona = persona, Account = account, RevealMotion = reveal, CommitMotion = commit, Key = key, KeyId = keyId, Moderator = moderator, MemberGitHubId = (n + 2).ToString() }; + } + + private static MindEnvelope Sign(Key key, string persona, string verb, Guid motion, string? nonce = null, string? commitment = null, + string? candidate = null, string? reason = null, string? salt = null) + { + var e = new MindEnvelope(persona, verb, motion, nonce ?? Guid.NewGuid().ToString("N"), commitment, 1, candidate, reason, salt, ""); + var signer = new Ed25519Signer(); + signer.Init(true, key.Private); + var bytes = System.Text.Encoding.UTF8.GetBytes(MindIntakeService.SigningText(e)); + signer.BlockUpdate(bytes, 0, bytes.Length); + return e with { Signature = Convert.ToBase64String(signer.GenerateSignature()) }; + } + + private HttpClient Anonymous() + { + var c = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + c.DefaultRequestHeaders.Add("X-Test-Anonymous", "true"); // the intake needs no session; this proves it + return c; + } + + private static Task Post(HttpClient c, Guid route, MindEnvelope e) => c.PostAsJsonAsync($"/api/sealed-motions/{route}/mind", e); + + private static async Task Refused(HttpResponseMessage r, HttpStatusCode expected) + { + var body = await r.Content.ReadAsStringAsync(); + Assert.True(r.StatusCode == expected, $"{r.StatusCode}: {body}"); + Assert.DoesNotContain(Reason, body); + Assert.DoesNotContain(Salt, body); + Assert.DoesNotContain("Bo", body.Replace("Bonce", "")); + } + + [Fact] + public async Task A_mind_commits_and_reveals_by_signature_alone_and_the_ballot_stays_sealed() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var commitment = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, Salt, w.Persona), w.Account); + var commit = await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment)); + Assert.True(commit.StatusCode == HttpStatusCode.OK, await commit.Content.ReadAsStringAsync()); + + var reveal = await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)); + Assert.True(reveal.StatusCode == HttpStatusCode.OK, await reveal.Content.ReadAsStringAsync()); + Assert.DoesNotContain(Reason, await reveal.Content.ReadAsStringAsync()); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.registered"); + var member = factory.CreateClient(); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member"); + var state = await (await member.GetAsync($"/api/sealed-motions/{w.RevealMotion}")).Content.ReadAsStringAsync(); + Assert.DoesNotContain(Reason, state); + Assert.DoesNotContain(Salt, state); + } + + [Fact] + public async Task Every_refusal_is_typed_and_leaks_no_ballot() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var other = Key.New(); + MindEnvelope Reveal(Key k, string persona = "", string? nonce = null, string? reason = null) => + Sign(k, persona == "" ? w.Persona : persona, "reveal", w.RevealMotion, nonce, candidate: "Bo", reason: reason ?? Reason, salt: Salt); + + await Refused(await Post(anon, w.RevealMotion, Reveal(other)), HttpStatusCode.Unauthorized); // key not registered + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, "nobody")), HttpStatusCode.Unauthorized); // unknown persona + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Reason = "tampered" }), HttpStatusCode.Unauthorized); // signature covers every field + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Signature = "AAAA" }), HttpStatusCode.Unauthorized); + await Refused(await Post(anon, w.CommitMotion, Reveal(w.Key)), HttpStatusCode.BadRequest); // signed for another motion than the route + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key, nonce: "short")), HttpStatusCode.BadRequest); // malformed nonce + await Refused(await Post(anon, w.RevealMotion, Reveal(w.Key) with { Verb = "tally" }), HttpStatusCode.BadRequest); + await Refused(await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "reveal", w.CommitMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Conflict); // wrong phase + + // A second persona with its own key cannot speak as the first: persona id must match the signing key. + var rival = await WorldAsync(); + await Refused(await Post(anon, w.RevealMotion, Sign(rival.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized); + + // Replay: the same signed envelope is accepted once, then refused. + var once = Reveal(w.Key); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, once)).StatusCode); + await Refused(await Post(anon, w.RevealMotion, once), HttpStatusCode.Conflict); + // A bad signature does not spend the nonce. + var fresh = Reveal(w.Key); + await Refused(await Post(anon, w.RevealMotion, fresh with { Reason = "x" }), HttpStatusCode.Unauthorized); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, fresh)).StatusCode); + } + + [Fact] + public async Task A_commitment_not_bound_to_the_persona_cannot_be_revealed_through_the_intake() + { + var w = await WorldAsync(bindRevealToPersona: false); + await Refused(await Post(Anonymous(), w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.BadRequest); + } + + [Fact] + public async Task A_revoked_key_is_refused_and_a_commitment_made_before_revocation_stays() + { + var w = await WorldAsync(); + var anon = Anonymous(); + var commitment = new string('c', 64); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.CommitMotion, Sign(w.Key, w.Persona, "commit", w.CommitMotion, commitment: commitment))).StatusCode); + + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + await Refused(await Post(anon, w.RevealMotion, Sign(w.Key, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt)), HttpStatusCode.Unauthorized); + + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(commitment, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + Assert.Contains(db.AuditEvents, x => x.Action == "mindkey.revoked"); + + // Rotation: a new key registered for the same persona works. + var next = Key.New(); + Assert.Equal(HttpStatusCode.OK, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = next.Public })).StatusCode); + Assert.Equal(HttpStatusCode.OK, (await Post(anon, w.RevealMotion, Sign(next, w.Persona, "reveal", w.RevealMotion, candidate: "Bo", reason: Reason, salt: Salt))).StatusCode); + } + + [Fact] + public async Task Only_a_moderator_manages_keys_and_a_persona_stays_bound_to_one_account() + { + var w = await WorldAsync(); + var member = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Id", w.MemberGitHubId); + member.DefaultRequestHeaders.Add("X-Test-GitHub-Login", "member"); + var body = new { persona = w.Persona, userAccountId = w.Account, publicKey = Key.New().Public }; + Assert.Equal(HttpStatusCode.Forbidden, (await member.PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await member.DeleteAsync($"/api/sealed-motions/mind-keys/{w.KeyId}")).StatusCode); + Assert.Equal(HttpStatusCode.Forbidden, (await member.GetAsync("/api/sealed-motions/mind-keys")).StatusCode); + Assert.NotEqual(HttpStatusCode.OK, (await Anonymous().PostAsJsonAsync("/api/sealed-motions/mind-keys", body)).StatusCode); + + var elsewhere = new { persona = w.Persona, userAccountId = Guid.NewGuid(), publicKey = Key.New().Public }; + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", elsewhere)).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = "Bad Name", userAccountId = w.Account, publicKey = Key.New().Public })).StatusCode); + Assert.Equal(HttpStatusCode.BadRequest, (await w.Moderator.PostAsJsonAsync("/api/sealed-motions/mind-keys", new { persona = w.Persona, userAccountId = w.Account, publicKey = "AAAA" })).StatusCode); + } + + [Fact] + public async Task The_signing_helper_and_the_server_agree_on_the_envelope_and_the_commitment() + { + var w = await WorldAsync(); + var root = AppContext.BaseDirectory; + while (!Directory.Exists(Path.Combine(root, "tools"))) root = Path.GetDirectoryName(root)!; + var keyFile = Path.Combine(Path.GetTempPath(), $"sealed-{Guid.NewGuid():N}.key"); + var seed = w.Key.Private.GetEncoded(); + await File.WriteAllBytesAsync(keyFile, seed); + File.SetUnixFileMode(keyFile, UnixFileMode.UserRead); + try + { + string Run(params string[] a) + { + var psi = new ProcessStartInfo("node") { RedirectStandardOutput = true, RedirectStandardError = true }; + psi.ArgumentList.Add(Path.Combine(root, "tools", "sealed-motion-sign.mjs")); + foreach (var x in a) psi.ArgumentList.Add(x); + using var p = Process.Start(psi)!; + var o = p.StandardOutput.ReadToEnd(); + p.WaitForExit(); + Assert.True(p.ExitCode == 0, p.StandardError.ReadToEnd()); + return o.Trim(); + } + + Assert.Equal(w.Key.Public, Run("pubkey", "--key", keyFile)); + var committed = JsonDocument.Parse(Run("commit", "--key", keyFile, "--persona", w.Persona, "--motion", w.CommitMotion.ToString(), + "--voter", w.Account.ToString(), "--candidate", "Ana", "--reason", Reason)); + var post = await Anonymous().PostAsync($"/api/sealed-motions/{w.CommitMotion}/mind", + new StringContent(committed.RootElement.GetProperty("envelope").GetRawText(), System.Text.Encoding.UTF8, "application/json")); + Assert.True(post.StatusCode == HttpStatusCode.OK, await post.Content.ReadAsStringAsync()); + + var ballot = committed.RootElement.GetProperty("ballot"); + var expected = SealedBallotDocument.Commit(new(SealedBallotDocument.SchemaId, w.CommitMotion, 1, "Ana", Reason, ballot.GetProperty("salt").GetString()!, w.Persona), w.Account); + using var db = factory.Services.CreateScope().ServiceProvider.GetRequiredService(); + Assert.Equal(expected, (await db.SealedBallots.SingleAsync(x => x.MotionId == w.CommitMotion)).Commitment); + } + finally { File.Delete(keyFile); } + } +} diff --git a/tests/Bifrost.Web.Tests/SealedBallotTests.cs b/tests/Bifrost.Web.Tests/SealedBallotTests.cs index 1c91cb2..38c3879 100644 --- a/tests/Bifrost.Web.Tests/SealedBallotTests.cs +++ b/tests/Bifrost.Web.Tests/SealedBallotTests.cs @@ -135,7 +135,7 @@ public sealed class SealedBallotTests while (!Directory.Exists(Path.Combine(root, "src", "Bifrost.Web"))) root = Path.GetDirectoryName(root)!; var offenders = Directory.EnumerateFiles(Path.Combine(root, "src", "Bifrost.Web"), "*.cs", SearchOption.AllDirectories) .Where(f => !f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}")) - .Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("BifrostModels.cs")) + .Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("MindIntakeService.cs") && !f.EndsWith("BifrostModels.cs")) .Where(f => File.ReadAllText(f) is var s && (s.Contains("RevealedCandidate") || s.Contains("RevealedReason") || s.Contains(".Salt"))) .ToList(); Assert.Empty(offenders); diff --git a/tools/sealed-motion-sign.mjs b/tools/sealed-motion-sign.mjs new file mode 100644 index 0000000..3c5fc71 --- /dev/null +++ b/tools/sealed-motion-sign.mjs @@ -0,0 +1,48 @@ +// Signing helper for a Mind's sealed-motion verbs. Reads the Mind's Ed25519 key file (32-byte seed, mode 0400/0600, owned by +// the running user: loadSignedIdentity checks all three), prints a signed envelope on stdout, and posts nothing. +// The output of `commit` also holds the ballot (candidate, reason, salt): keep it in the Mind's private state, never in a room. +// pubkey --key +// commit --key --persona --motion --voter --candidate --reason [--round 1] [--salt ] +// reveal --key --persona --motion --ballot '' +// Post an envelope with: curl -H 'Content-Type: application/json' -d @envelope.json https:///api/sealed-motions//mind +import { createHash, randomBytes, sign } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { loadSignedIdentity } from "./bifrost-signed-identity.mjs"; + +const DOMAIN = "gamecult.sealed_intake.v0"; +const BALLOT_SCHEMA = "gamecult.sealed_ballot.v0"; + +export function commitmentOf({ motion, voter, persona, round, candidate, reason, salt }) { + return createHash("sha256").update(`${BALLOT_SCHEMA}\n${motion}\n${voter}\n${persona}\n${round}\n${candidate}\n${reason}\n${salt}`, "utf8").digest("hex"); +} + +export function signEnvelope(identity, e) { + const text = [DOMAIN, e.persona, e.verb, e.motionId, e.nonce, e.commitment ?? "", e.round ?? 0, e.candidate ?? "", e.reason ?? "", e.salt ?? ""].join("\n"); + return { ...e, signature: sign(null, Buffer.from(text, "utf8"), identity.key).toString("base64") }; +} + +function args(argv) { + const out = {}; + for (let i = 1; i < argv.length; i += 2) out[argv[i].replace(/^--/, "")] = argv[i + 1]; + return out; +} + +async function main(argv) { + const [cmd] = argv, a = args(argv); + const identity = await loadSignedIdentity(a.key, "Mind sealed-motion key"); + if (cmd === "pubkey") return console.log(Buffer.from(identity.publicKey).toString("base64")); + const nonce = randomBytes(16).toString("base64url"); + if (cmd === "commit") { + const ballot = { round: Number(a.round ?? 1), candidate: a.candidate, reason: a.reason, salt: a.salt ?? randomBytes(16).toString("hex") }; + const commitment = commitmentOf({ motion: a.motion, voter: a.voter, persona: a.persona, ...ballot }); + const envelope = signEnvelope(identity, { persona: a.persona, verb: "commit", motionId: a.motion, nonce, commitment, round: ballot.round }); + return console.log(JSON.stringify({ envelope, ballot })); + } + if (cmd === "reveal") { + const b = JSON.parse(a.ballot).ballot ?? JSON.parse(a.ballot); + return console.log(JSON.stringify(signEnvelope(identity, { persona: a.persona, verb: "reveal", motionId: a.motion, nonce, round: b.round, candidate: b.candidate, reason: b.reason, salt: b.salt }))); + } + throw new Error("usage: pubkey | commit | reveal (see the header of this file)"); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(process.argv.slice(2)).catch(e => { console.error(e.message); process.exit(1); });