Signed Mind intake for sealed motions: Ed25519 envelopes, registered public keys, nonce replay refusal, persona bound into the commitment

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Heimdall 2026-11-09 20:37:47 +00:00
commit ab4fc20b85
12 changed files with 721 additions and 8 deletions

View file

@ -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/<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:** 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.

View file

@ -43,6 +43,10 @@ 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<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
public DbSet<PayoutProposalBatch> PayoutProposalBatches => Set<PayoutProposalBatch>();
@ -316,6 +320,16 @@ 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<MindNonce>(entity =>
{
entity.HasIndex(x => new { x.PersonaId, x.Nonce }).IsUnique();
});
modelBuilder.Entity<LedgerEntry>(entity =>
{
entity.Property(x => x.EntryType).HasConversion<string>();

View file

@ -0,0 +1,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<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: "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: "MindKeys");
}
}
}

View file

@ -1440,6 +1440,68 @@ 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.MindNonce", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Nonce")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PersonaId")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset>("SeenAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("PersonaId", "Nonce")
.IsUnique();
b.ToTable("MindNonces");
});
modelBuilder.Entity("Bifrost.Web.Domain.SealedBallot", b =>
{
b.Property<Guid>("Id")

View file

@ -1153,3 +1153,37 @@ 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; }
}

View file

@ -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;
/// <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";
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<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.");
}
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<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.");
}
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);
}
/// <summary>The exact text a Mind signs: every field, fixed order, newline separated, unused fields empty.</summary>
public static string SigningText(MindEnvelope e) =>
string.Join('\n', Domain, e.Persona, e.Verb, e.MotionId.ToString("D"), e.Nonce, e.Commitment ?? "", e.Round, e.Candidate ?? "", e.Reason ?? "", e.Salt ?? "");
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,
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);

View file

@ -126,7 +126,8 @@ public sealed class SealedBallotService(
CurrentBifrostActor actor,
Guid motionId,
SealedBallotDocument document,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string persona = "")
{
var user = RequireMember(actor);
var motion = await LoadAsync(motionId, cancellationToken);
@ -142,7 +143,7 @@ public sealed class SealedBallotService(
var reason = (document.Reason ?? string.Empty).Trim();
var candidate = (document.Candidate ?? string.Empty).Trim();
if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || document.Round != motion.Round ||
if (document.Schema != SealedBallotDocument.SchemaId || document.MotionId != motionId || (document.Persona ?? string.Empty) != persona || document.Round != motion.Round ||
!CandidatesOf(motion).Contains(candidate, StringComparer.Ordinal) ||
reason.Length is 0 or > MaxReasonLength || reason.Contains('\n') || reason.Contains('\r') ||
!HexSalt.IsMatch(document.Salt ?? string.Empty))
@ -366,7 +367,7 @@ public sealed class SealedBallotService(
public enum SealedPhase { Commit, Reveal, Closed }
public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid }
public enum SealedBallotFailure { Forbidden, NotFound, WrongPhase, Invalid, Unauthorized }
public sealed class SealedBallotException(SealedBallotFailure failure, string message) : Exception(message)
{
@ -375,15 +376,15 @@ public sealed class SealedBallotException(SealedBallotFailure failure, string me
/// <summary>
/// The typed ballot document, schema <c>gamecult.sealed_ballot.v0</c>. A Delphi second round is another motion carrying this
/// same type with a higher Round. The commitment binds the voter's account id, so a commitment cannot be replayed by another member.
/// same type with a higher Round. The commitment binds the voter's account id and, for a Mind casting through the signed intake, its persona id (empty for member casts), so a commitment cannot be replayed by another member or persona.
/// </summary>
public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt)
public sealed record SealedBallotDocument(string Schema, Guid MotionId, int Round, string Candidate, string Reason, string Salt, string Persona = "")
{
public const string SchemaId = "gamecult.sealed_ballot.v0";
public static string Commit(SealedBallotDocument d, Guid voterUserAccountId) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(
$"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant();
$"{SchemaId}\n{d.MotionId:D}\n{voterUserAccountId:D}\n{d.Persona}\n{d.Round}\n{d.Candidate}\n{d.Reason}\n{d.Salt}"))).ToLowerInvariant();
}
public sealed record OpenSealedMotionCommand(

View file

@ -122,6 +122,7 @@ builder.Services.AddScoped<BifrostIdentityService>();
builder.Services.AddScoped<GitHubWebhookService>();
builder.Services.AddScoped<MotionGovernanceService>();
builder.Services.AddScoped<SealedBallotService>();
builder.Services.AddScoped<MindIntakeService>();
builder.Services.AddHostedService<SealedBallotCloser>();
builder.Services.AddScoped<MotionEveSurfaceService>();
builder.Services.AddScoped<PatronageService>();
@ -562,6 +563,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 +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";

View file

@ -23,6 +23,7 @@ public sealed class MigrationDiscoveryTests
"20261010043937_AddConceptVotes",
"20261022163159_AddOpenIddict",
"20261109200000_AddSealedBallotMotions",
"20261109210000_AddMindKeys",
];
// Database.GetMigrations() reads assembly metadata only; no connection is opened.
@ -52,7 +53,7 @@ public sealed class MigrationDiscoveryTests
}
[Fact]
public void All_fifteen_migrations_are_listed_in_order()
public void All_sixteen_migrations_are_listed_in_order()
{
Assert.Equal(ExpectedMigrations, Discovered());
}

View file

@ -0,0 +1,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<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)
{
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<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); }
}
}

View file

@ -135,7 +135,7 @@ public sealed class SealedBallotTests
while (!Directory.Exists(Path.Combine(root, "src", "Bifrost.Web"))) root = Path.GetDirectoryName(root)!;
var offenders = Directory.EnumerateFiles(Path.Combine(root, "src", "Bifrost.Web"), "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains($"{Path.DirectorySeparatorChar}Migrations{Path.DirectorySeparatorChar}"))
.Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("BifrostModels.cs"))
.Where(f => !f.EndsWith("SealedBallotService.cs") && !f.EndsWith("MindIntakeService.cs") && !f.EndsWith("BifrostModels.cs"))
.Where(f => File.ReadAllText(f) is var s && (s.Contains("RevealedCandidate") || s.Contains("RevealedReason") || s.Contains(".Salt")))
.ToList();
Assert.Empty(offenders);

View file

@ -0,0 +1,48 @@
// Signing helper for a Mind's sealed-motion verbs. Reads the Mind's Ed25519 key file (32-byte seed, mode 0400/0600, owned by
// the running user: loadSignedIdentity checks all three), prints a signed envelope on stdout, and posts nothing.
// The output of `commit` also holds the ballot (candidate, reason, salt): keep it in the Mind's private state, never in a room.
// pubkey --key <file>
// commit --key <file> --persona <id> --motion <uuid> --voter <member account uuid> --candidate <name> --reason <one line> [--round 1] [--salt <hex>]
// reveal --key <file> --persona <id> --motion <uuid> --ballot '<json from commit>'
// Post an envelope with: curl -H 'Content-Type: application/json' -d @envelope.json https://<bifrost>/api/sealed-motions/<motion>/mind
import { createHash, randomBytes, sign } from "node:crypto";
import { pathToFileURL } from "node:url";
import { loadSignedIdentity } from "./bifrost-signed-identity.mjs";
const DOMAIN = "gamecult.sealed_intake.v0";
const BALLOT_SCHEMA = "gamecult.sealed_ballot.v0";
export function commitmentOf({ motion, voter, persona, round, candidate, reason, salt }) {
return createHash("sha256").update(`${BALLOT_SCHEMA}\n${motion}\n${voter}\n${persona}\n${round}\n${candidate}\n${reason}\n${salt}`, "utf8").digest("hex");
}
export function signEnvelope(identity, e) {
const text = [DOMAIN, e.persona, e.verb, e.motionId, e.nonce, e.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); });