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:
parent
55b7f79c13
commit
ab4fc20b85
12 changed files with 721 additions and 8 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
177
src/Bifrost.Web/Features/Motions/MindIntakeService.cs
Normal file
177
src/Bifrost.Web/Features/Motions/MindIntakeService.cs
Normal 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);
|
||||
|
|
@ -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,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";
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
264
tests/Bifrost.Web.Tests/MindIntakeTests.cs
Normal file
264
tests/Bifrost.Web.Tests/MindIntakeTests.cs
Normal 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); }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
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.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); });
|
||||
Loading…
Reference in a new issue