Personas prove what goes to relays; the server says what it reads

FEP-521a and FEP-8b32. Every persona has an Ed25519 key of its own (Avatar.SigningKey; migration 014 gives the earlier
ones theirs), named in its actor's assertionMethod as a Multikey, the terms defined in the actor's own context. A
persona's activity going to a relay carries an eddsa-jcs-2022 proof (JSON canonicalised by RFC 8785, Jcs), so what
Activity-Relay forwards reaches Mastodon, which verifies it with its own code. Nothing else carries one: Mitra takes a
proof over the HTTP signature and refuses one by a key it has not read, without reading the actor again. Received: an
actor's own Multikeys are kept, and a forwarded activity whose proof one of them verifies is taken as it came instead of
being read again from its origin.

Discovery: WebFinger for the server's origin links its instance actor (FEP-d556), NodeInfo links it as the application
actor (FEP-2677), and actors name RFC 9421 under implements (FEP-844e).

Checked live: relay 16 (Activity-Relay's forward of alice's post reaches Mastodon), Mitra, GoToSocial and Mastodon
unchanged (165 in all).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-06 09:01:21 +02:00
1 parent c47b6e5533
commit 9ab87b2779
22 files changed
+702 -30

No files matched your search

@@ -40,6 +40,7 @@ namespace PrivaPub.Federation.Actors
public List<CustomEmoji> Emojis { get; init; } = new();
public Dictionary<string, string> Fields { get; init; } = new();
public IReadOnlyList<ActorKey> Keys { get; init; } = Array.Empty<ActorKey>();
public List<Models.User.AssertionKey> AssertionKeys { get; init; } = new();
public List<string> Features { get; init; } = new();
public ActorKey Key(string keyId) => Keys.FirstOrDefault(k => k.Id == keyId);
@@ -81,6 +82,7 @@ namespace PrivaPub.Federation.Actors
Icon = root.TryGetProperty("icon", out var icon) ? RemoteActorService.Text(icon, "url") : default,
Discoverable = !root.TryGetProperty("discoverable", out var discoverable) || discoverable.ValueKind != JsonValueKind.False,
Keys = ParseKeys(root, id),
AssertionKeys = ParseAssertionKeys(root, id),
Indexable = Flag(root, "indexable"),
Locked = Flag(root, "manuallyApprovesFollowers"),
Memorial = Flag(root, "memorial"),
@@ -121,6 +123,32 @@ namespace PrivaPub.Federation.Actors
return fields;
}
const int MaxAssertionKeys = 5;
// its Ed25519 keys (FEP-521a) its document holds, as Mastodon reads them: Multikeys it controls, under its own id
static List<Models.User.AssertionKey> ParseAssertionKeys(JsonElement root, string actorId)
{
var keys = new List<Models.User.AssertionKey>();
if (!root.TryGetProperty("assertionMethod", out var methods))
return keys;
var candidates = methods.ValueKind switch
{
JsonValueKind.Object => new[] { methods },
JsonValueKind.Array => methods.EnumerateArray().Where(k => k.ValueKind == JsonValueKind.Object).ToArray(),
_ => Array.Empty<JsonElement>()
};
foreach (var key in candidates.Take(MaxAssertionKeys))
{
var keyId = RemoteActorService.Text(key, "id");
var publicKey = Signing.IntegrityProofs.FromMultikey(RemoteActorService.Text(key, "publicKeyMultibase"));
if (RemoteActorService.Text(key, "type") != "Multikey" || RemoteActorService.Text(key, "controller") != actorId
|| keyId == default || !keyId.StartsWith(actorId + "#", StringComparison.Ordinal) || publicKey == default)
continue;
keys.Add(new Models.User.AssertionKey { Id = keyId, PublicKey = Convert.ToBase64String(publicKey) });
}
return keys;
}
static List<ActorKey> ParseKeys(JsonElement root, string actorId)
{
var keys = new List<ActorKey>();
@@ -26,6 +26,7 @@ namespace PrivaPub.Federation.Actors
public string ThumbnailURL { get; init; }
public string PrivateKeyPem { get; init; }
public string PublicKeyPem { get; init; }
public string SigningKey { get; init; }//a persona's Ed25519 seed (FEP-521a), for the proofs its activities carry (FEP-8b32)
public bool Discoverable { get; init; } = true;
public bool ManuallyApprovesFollowers { get; init; }
public bool IsFederated { get; init; } = true;
@@ -40,6 +41,7 @@ namespace PrivaPub.Federation.Actors
public string Uri => $"{BaseAddress}/peasants/{UserName}";
public string KeyId => $"{Uri}#main-key";
public string AssertionKeyId => $"{Uri}#ed25519-key";
public string Inbox => $"{Uri}/mouth";
public string Outbox => $"{Uri}/anus";
public string Followers => $"{Uri}/groupies";
@@ -231,6 +233,7 @@ namespace PrivaPub.Federation.Actors
ThumbnailURL = avatar.ThumbnailURL,
PrivateKeyPem = avatar.PrivateKey,
PublicKeyPem = avatar.PublicKey,
SigningKey = avatar.SigningKey,
Published = avatar.PublishedOn,
Fields = avatar.Fields ?? new Dictionary<string, string>(),
ManuallyApprovesFollowers = avatar.Settings?.IsLocked == true,
@@ -206,6 +206,7 @@ namespace PrivaPub.Federation.Actors
.Modify(a => a.FollowingURL, actor.Following)
.Modify(a => a.FeaturedURL, Origin.Same(actor.Featured, actor.Id) ? actor.Featured : default)
.Modify(a => a.WallURL, Origin.Same(actor.Wall, actor.Id) ? actor.Wall : default)
.Modify(a => a.AssertionKeys, actor.AssertionKeys)
.Modify(a => a.SharedInboxURL, actor.SharedInbox)
.Modify(a => a.PictureURL, actor.Icon)
.Modify(a => a.ThumbnailURL, actor.Header)
@@ -36,6 +36,19 @@ namespace PrivaPub.Federation.Controllers
{
if (string.IsNullOrEmpty(resource))
return BadRequest();
// the server itself (FEP-d556): its instance actor
if (resource.TrimEnd('/') == _localActors.BaseAddress)
{
var instance = await _localActors.GetInstanceActor(token);
return Content(new JsonObject
{
["subject"] = resource,
["links"] = new JsonArray(new JsonObject
{
["rel"] = "https://www.w3.org/ns/activitystreams#Service", ["type"] = "application/activity+json", ["href"] = instance.Uri
})
}.ToJsonString(), "application/jrd+json; charset=utf-8");
}
LocalActor actor;
// Mastodon, GoToSocial and Pleroma also take a bare user@domain or @user@domain, so we do too.
@@ -72,7 +85,7 @@ namespace PrivaPub.Federation.Controllers
}
[HttpGet, Route("/.well-known/nodeinfo")]
public IActionResult NodeInfoLinks()
public async Task<IActionResult> NodeInfoLinks(CancellationToken token)
{
var document = new JsonObject
{
@@ -86,6 +99,12 @@ namespace PrivaPub.Federation.Controllers
{
["rel"] = "http://nodeinfo.diaspora.software/ns/schema/2.0",
["href"] = $"{_localActors.BaseAddress}/nodeinfo/2.0"
},
// the application actor (FEP-2677)
new JsonObject
{
["rel"] = "https://www.w3.org/ns/activitystreams#Application",
["href"] = (await _localActors.GetInstanceActor(token)).Uri
})
};
return Content(document.ToJsonString(), "application/json; charset=utf-8");
+13 -1
View File
@@ -17,9 +17,21 @@ namespace PrivaPub.Federation.Inbox
// activity in its threads. The signature proves who passed it on, never who wrote it, so nothing in it is believed: a
// Create or an Update is taken as its object reads at the actor's origin now, a Delete once that origin says the object
// is gone, and anything else is let go (a vote or a follow cannot be checked against its origin). An LD signature
// (RsaSignature2017) would prove the author, but needs JSON-LD; integrity proofs (FEP-8b32) will.
// (RsaSignature2017) would prove the author, but needs JSON-LD. An integrity proof (FEP-8b32, eddsa-jcs-2022) by one of
// the actor's Ed25519 keys does: such an activity is taken as forwarded, read again from nowhere.
public static class Forwarded
{
// whether the activity carries a proof made by one of the actor's own keys (FEP-521a)
public static bool Proven(JsonNode activity, Models.User.ForeignAvatar actor) =>
activity is JsonObject document && document["proof"] is JsonObject proof && Value(proof, "verificationMethod") is { } method
&& actor.AssertionKeys.FirstOrDefault(k => k.Id == method) is { } key
&& Signing.IntegrityProofs.Verify(document, Convert.FromBase64String(key.PublicKey));
// whether it names a key of the actor's own that we do not hold (the actor was read before it had one)
public static bool NamesUnknownKey(JsonNode activity, Models.User.ForeignAvatar actor) =>
activity is JsonObject document && document["proof"] is JsonObject proof && Value(proof, "verificationMethod") is { } method
&& method.StartsWith(actor.ActorURI + "#", StringComparison.Ordinal) && actor.AssertionKeys.All(k => k.Id != method);
// what may be taken from a forwarder: a post of the activity's actor, created, edited or deleted
public static bool Takeable(string type, JsonNode activity, string actorUri)
{
+3 -1
View File
@@ -65,7 +65,9 @@ namespace PrivaPub.Federation.Inbox
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded");
}
if (payload.ForwardedBy != default)
if (payload.ForwardedBy != default && Forwarded.NamesUnknownKey(activity, actor))
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
if (payload.ForwardedBy != default && !Forwarded.Proven(activity, actor))
{
var (confirmed, drop) = await Forwarded.Confirm(activity, type, actor.ActorURI, _remoteActors, token);
if (drop != default)
+19 -2
View File
@@ -47,9 +47,26 @@ namespace PrivaPub.Federation.Outbox
{
var body = activity.ToJsonString();
var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default;
var jobs = inboxes
var targets = inboxes
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase))
.Distinct(StringComparer.Ordinal)
.ToList();
// what goes to a relay carries the signer's proof (FEP-8b32): the relay passes it on as it came, signed with its own
// key, and Mastodon takes it on the proof's strength. Nothing else does: a server that knew the persona before it
// had its key (Mitra) refuses a proof by a key it does not hold, and does not read the actor again
var relayed = new HashSet<string>(StringComparer.Ordinal);
if (!string.IsNullOrEmpty(signer.SigningKey) && activity["actor"] is JsonValue actor && actor.TryGetValue<string>(out var actorUri) && actorUri == signer.Uri)
relayed = (await DB.Default.Find<RelaySubscription, string>().Match(s => targets.Contains(s.InboxURL)).Project(s => s.InboxURL).ExecuteAsync(token))
.ToHashSet(StringComparer.Ordinal);
var provenBody = default(string);
if (relayed.Count > 0)
{
var proven = activity.DeepClone().AsObject();
proven.Remove("proof");
proven["proof"] = IntegrityProofs.Create(proven, signer.AssertionKeyId, signer.SigningKey, DateTime.UtcNow);
provenBody = proven.ToJsonString();
}
var jobs = targets
.Select(inbox => Uri.TryCreate(inbox, UriKind.Absolute, out var uri) ? (inbox, uri) : default)
.Where(target => target.uri != default)
.Select(target => new Job
@@ -57,7 +74,7 @@ namespace PrivaPub.Federation.Outbox
Kind = JobKind.Deliver,
Host = target.uri.Host.ToLowerInvariant(),
DedupeKey = activityId == default ? default : again == default ? $"{activityId}|{target.inbox}" : $"{activityId}|{target.inbox}|{again}",
Payload = JsonSerializer.Serialize(new DeliveryPayload(signer.Id, signer.Kind, target.inbox, body))
Payload = JsonSerializer.Serialize(new DeliveryPayload(signer.Id, signer.Kind, target.inbox, relayed.Contains(target.inbox) ? provenBody : body))
})
.ToList();
if (jobs.Count > 0)
@@ -79,9 +79,29 @@ namespace PrivaPub.Federation.Rendering
["privacySettings"] = "sm:privacySettings",
["wallPosting"] = "sm:wallPosting",
["wallPostVisibility"] = "sm:wallPostVisibility",
["allowedTo"] = "sm:allowedTo"
["allowedTo"] = "sm:allowedTo",
// FEP-521a keys and FEP-8b32 proofs, defined here rather than by the data-integrity and multikey contexts,
// which strict JSON-LD readers would have to fetch
["assertionMethod"] = new JsonObject { ["@id"] = "sec:assertionMethod", ["@type"] = "@id", ["@container"] = "@set" },
["Multikey"] = "sec:Multikey",
["controller"] = new JsonObject { ["@id"] = "sec:controller", ["@type"] = "@id" },
["publicKeyMultibase"] = new JsonObject { ["@id"] = "sec:publicKeyMultibase", ["@type"] = "sec:multibase" },
["DataIntegrityProof"] = "sec:DataIntegrityProof",
["proof"] = new JsonObject { ["@id"] = "sec:proof", ["@type"] = "@id", ["@container"] = "@graph" },
["cryptosuite"] = new JsonObject { ["@id"] = "sec:cryptosuite", ["@type"] = "sec:cryptosuiteString" },
["proofValue"] = new JsonObject { ["@id"] = "sec:proofValue", ["@type"] = "sec:multibase" },
["proofPurpose"] = new JsonObject { ["@id"] = "sec:proofPurpose", ["@type"] = "@vocab" },
["verificationMethod"] = new JsonObject { ["@id"] = "sec:verificationMethod", ["@type"] = "@id" },
["implements"] = new JsonObject { ["@id"] = "https://w3id.org/fep/844e/implements", ["@type"] = "@id", ["@container"] = "@set" }
});
// what PrivaPub reads that a sender cannot tell from our documents (FEP-844e): RFC 9421 signatures, with RSA keys
static JsonArray Implements() => new(new JsonObject
{
["href"] = "https://datatracker.ietf.org/doc/html/rfc9421",
["name"] = "RFC-9421: HTTP Message Signatures"
});
public static string Html(string markdown) =>
string.IsNullOrEmpty(markdown) ? string.Empty : Markdown.ToHtml(markdown, Pipeline).Trim();
@@ -150,6 +170,20 @@ namespace PrivaPub.Federation.Rendering
document["attributedTo"] = actor.Wardens;
document["postingRestrictedToMods"] = actor.PostingRestrictedToModerators;
}
// what the server reads (FEP-844e): on the application actor itself, on any other as its generator
if (actor.Kind == LocalActorKind.Application)
document["implements"] = Implements();
else
document["generator"] = new JsonObject { ["type"] = "Application", ["implements"] = Implements() };
// its Ed25519 key (FEP-521a), which signs the proofs its activities carry (FEP-8b32)
if (!string.IsNullOrEmpty(actor.SigningKey))
document["assertionMethod"] = new JsonArray(new JsonObject
{
["id"] = actor.AssertionKeyId,
["type"] = "Multikey",
["controller"] = actor.Uri,
["publicKeyMultibase"] = Signing.IntegrityProofs.Multikey(Signing.IntegrityProofs.PublicKey(actor.SigningKey))
});
if (!string.IsNullOrEmpty(actor.PictureURL))
document["icon"] = new JsonObject { ["type"] = "Image", ["url"] = actor.PictureURL };
if (!string.IsNullOrEmpty(actor.ThumbnailURL))
@@ -0,0 +1,127 @@
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using System.Globalization;
using System.Numerics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Signing
{
// FEP-8b32 object integrity proofs, eddsa-jcs-2022, by an actor's Ed25519 key, which its document names under
// assertionMethod as a FEP-521a Multikey. An activity that carries one needs no HTTP signature by its actor: Mastodon
// 4.7 takes what a relay forwards on its strength.
public static class IntegrityProofs
{
public const string Cryptosuite = "eddsa-jcs-2022";
const string Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
static readonly byte[] Ed25519Codec = { 0xed, 0x01 };
// a new key: its 32-byte seed, in base64
public static string NewSeed() => Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
public static byte[] PublicKey(string seed) => new Ed25519PrivateKeyParameters(Convert.FromBase64String(seed)).GeneratePublicKey().GetEncoded();
// the public key as a Multikey's publicKeyMultibase: base58btc ("z") of the ed25519-pub multicodec and the key
public static string Multikey(byte[] publicKey) => "z" + Base58(Ed25519Codec.Concat(publicKey).ToArray());
// the Ed25519 public key a publicKeyMultibase holds, or null
public static byte[] FromMultikey(string multibase)
{
var bytes = multibase is { Length: > 1 } && multibase[0] == 'z' ? FromBase58(multibase[1..]) : default;
return bytes is { Length: 34 } && bytes[0] == Ed25519Codec[0] && bytes[1] == Ed25519Codec[1] ? bytes[2..] : default;
}
public static JsonObject Create(JsonObject document, string verificationMethod, string seed, DateTime created)
{
var proof = new JsonObject
{
["type"] = "DataIntegrityProof",
["cryptosuite"] = Cryptosuite,
["verificationMethod"] = verificationMethod,
["proofPurpose"] = "assertionMethod",
["created"] = DateTime.SpecifyKind(created, DateTimeKind.Utc).ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture)
};
var signer = new Ed25519Signer();
signer.Init(true, new Ed25519PrivateKeyParameters(Convert.FromBase64String(seed)));
var data = SignedData(proof, Unsecured(document));
signer.BlockUpdate(data, 0, data.Length);
proof["proofValue"] = "z" + Base58(signer.GenerateSignature());
return proof;
}
// whether the document's proof was made by the key, as Mastodon checks it: a proof that names an @context makes that
// the document's for the check, which must begin with it
public static bool Verify(JsonObject document, byte[] publicKey)
{
if (document["proof"] is not JsonObject proof || Text(proof, "type") != "DataIntegrityProof" || Text(proof, "cryptosuite") != Cryptosuite
|| Text(proof, "proofValue") is not { Length: > 1 } value || value[0] != 'z' || publicKey is not { Length: 32 })
return false;
var options = proof.DeepClone().AsObject();
options.Remove("proofValue");
var unsecured = Unsecured(document);
if (options["@context"] is JsonArray context)
{
if (unsecured["@context"] is not JsonArray own || own.Count < context.Count
|| !context.Select((c, i) => JsonNode.DeepEquals(c, own[i])).All(same => same))
return false;
unsecured["@context"] = context.DeepClone();
}
var signature = FromBase58(value[1..]);
if (signature is not { Length: 64 })
return false;
var verifier = new Ed25519Signer();
verifier.Init(false, new Ed25519PublicKeyParameters(publicKey));
var data = SignedData(options, unsecured);
verifier.BlockUpdate(data, 0, data.Length);
return verifier.VerifySignature(signature);
}
static JsonObject Unsecured(JsonObject document)
{
var unsecured = document.DeepClone().AsObject();
unsecured.Remove("proof");
return unsecured;
}
// what eddsa-jcs-2022 signs: the SHA-256 of the canonical proof options, then that of the canonical document
static byte[] SignedData(JsonObject options, JsonObject document) =>
SHA256.HashData(Encoding.UTF8.GetBytes(Jcs.Serialize(options))).Concat(SHA256.HashData(Encoding.UTF8.GetBytes(Jcs.Serialize(document)))).ToArray();
static string Text(JsonObject node, string name) => node[name] is JsonValue value && value.TryGetValue<string>(out var text) ? text : default;
public static string Base58(byte[] bytes)
{
var number = new BigInteger(bytes, isUnsigned: true, isBigEndian: true);
var builder = new StringBuilder();
while (number > 0)
{
number = BigInteger.DivRem(number, 58, out var remainder);
builder.Insert(0, Base58Alphabet[(int)remainder]);
}
foreach (var b in bytes)
{
if (b != 0)
break;
builder.Insert(0, '1');
}
return builder.ToString();
}
public static byte[] FromBase58(string text)
{
var number = BigInteger.Zero;
foreach (var c in text)
{
var digit = Base58Alphabet.IndexOf(c);
if (digit < 0)
return default;
number = number * 58 + digit;
}
var body = number.IsZero ? Array.Empty<byte>() : number.ToByteArray(isUnsigned: true, isBigEndian: true);
var zeros = text.TakeWhile(c => c == '1').Count();
return new byte[zeros].Concat(body).ToArray();
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace PrivaPub.Federation.Signing
{
// RFC 8785, the JSON Canonicalization Scheme: members sorted by their names' UTF-16 code units, no whitespace, strings
// escaped only where JSON must be, numbers as ECMAScript writes them. What an eddsa-jcs-2022 proof signs (FEP-8b32).
public static class Jcs
{
public static string Serialize(JsonNode node)
{
var builder = new StringBuilder();
Write(builder, node);
return builder.ToString();
}
static void Write(StringBuilder builder, JsonNode node)
{
switch (node)
{
case null:
builder.Append("null");
break;
case JsonObject obj:
builder.Append('{');
var first = true;
foreach (var (name, value) in obj.OrderBy(p => p.Key, StringComparer.Ordinal))
{
if (!first)
builder.Append(',');
first = false;
String(builder, name);
builder.Append(':');
Write(builder, value);
}
builder.Append('}');
break;
case JsonArray array:
builder.Append('[');
for (var i = 0; i < array.Count; i++)
{
if (i > 0)
builder.Append(',');
Write(builder, array[i]);
}
builder.Append(']');
break;
case JsonValue value:
switch (value.GetValueKind())
{
case JsonValueKind.String:
String(builder, value.GetValue<string>());
break;
case JsonValueKind.Number:
builder.Append(Number(double.Parse(value.ToJsonString(), NumberStyles.Float, CultureInfo.InvariantCulture)));
break;
case JsonValueKind.True:
builder.Append("true");
break;
case JsonValueKind.False:
builder.Append("false");
break;
default:
builder.Append("null");
break;
}
break;
}
}
static void String(StringBuilder builder, string text)
{
builder.Append('"');
foreach (var c in text)
{
switch (c)
{
case '"':
builder.Append("\\\"");
break;
case '\\':
builder.Append("\\\\");
break;
case '\b':
builder.Append("\\b");
break;
case '\f':
builder.Append("\\f");
break;
case '\n':
builder.Append("\\n");
break;
case '\r':
builder.Append("\\r");
break;
case '\t':
builder.Append("\\t");
break;
default:
if (c < 0x20)
builder.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture));
else
builder.Append(c);
break;
}
}
builder.Append('"');
}
// ECMAScript's Number.prototype.toString: integers without a fraction, the shortest digits that read back the same,
// an exponent from 1e21 up and below 1e-6
public static string Number(double value)
{
if (double.IsNaN(value) || double.IsInfinity(value))
throw new ArgumentException("JSON has no NaN or Infinity", nameof(value));
if (value == 0)
return "0";
var abs = Math.Abs(value);
if (abs >= 1e21 || abs < 1e-6)
{
var exponential = value.ToString("R", CultureInfo.InvariantCulture).Replace("E", "e");
var at = exponential.IndexOf('e');
if (at < 0)
return exponential;
var mantissa = exponential[..at];
var exponent = int.Parse(exponential[(at + 1)..], CultureInfo.InvariantCulture);
return $"{mantissa}e{(exponent < 0 ? "-" : "+")}{Math.Abs(exponent)}";
}
var text = value.ToString("R", CultureInfo.InvariantCulture);
if (!text.Contains('E'))
return text;
// "R" chose an exponent ECMAScript would not: written out in full
return decimal.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture).ToString(CultureInfo.InvariantCulture);
}
}
}
@@ -0,0 +1,18 @@
using MongoDB.Entities;
using PrivaPub.Federation.Signing;
using PrivaPub.Models.User;
namespace PrivaPub.Infrastructure.Data.Migrations
{
// every persona signs the proofs its activities carry (FEP-8b32) with an Ed25519 key of its own (FEP-521a): new ones
// get theirs when made, the earlier ones once here
public class _014_personas_have_ed25519_keys : IMigration
{
public async Task UpgradeAsync()
{
foreach (var avatar in await DB.Default.Find<Avatar>().Match(a => a.SigningKey == null).Project(p => p.Include(a => a.ID)).ExecuteAsync())
await DB.Default.Update<Avatar>().Match(a => a.ID == avatar.ID && a.SigningKey == null).Modify(a => a.SigningKey, IntegrityProofs.NewSeed()).ExecuteAsync();
}
}
}
+8
View File
@@ -14,6 +14,7 @@ namespace PrivaPub.Models.User
public Dictionary<string, string> SharedPersonalContacts { get; set; } = new();
public string PrivateKey { get; set; }
public string PublicKey { get; set; }
public string SigningKey { get; set; }//the seed of its Ed25519 key (FEP-521a), which signs its activities' proofs (FEP-8b32)
public AvatarAccountState AccountState { get; set; } = AvatarAccountState.Normal;
public AvatarSettings Settings { get; set; } = new();
public Dictionary<string, string> Fields { get; set; } = new();
@@ -76,6 +77,7 @@ namespace PrivaPub.Models.User
public string InboxURL { get; set; }
public string OutboxURL { get; set; }
public string FeaturedURL { get; set; }//featured: the posts it pins
public List<AssertionKey> AssertionKeys { get; set; } = new();//its Ed25519 keys (FEP-521a), which prove what it sends (FEP-8b32)
public string WallURL { get; set; }//sm:wall (FEP-400e): where others write to it, Smithereen's walls
public string MovedToURL { get; set; }
public List<string> AlsoKnownAs { get; set; } = new();//alsoKnownAs: the accounts it says it also is
@@ -158,4 +160,10 @@ namespace PrivaPub.Models.User
Banned,
Deleted
}
public class AssertionKey
{
public string Id { get; set; }
public string PublicKey { get; set; }//the raw Ed25519 key, base64
}
}
+1
View File
@@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BouncyCastle.Cryptography" Version="2.7.0" />
<PackageReference Include="FFMpegCore" Version="5.5.0" />
<PackageReference Include="HtmlSanitizer" Version="9.2.1039" />
<PackageReference Include="MailKit" Version="4.18.0" />
@@ -67,6 +67,7 @@ namespace PrivaPub.Services.ClientToServer.Private
PersonalNote = form.PersonalNote,
PrivateKey = privateKey,
PublicKey = publicKey,
SigningKey = Federation.Signing.IntegrityProofs.NewSeed(),
Domain = _localActors.BaseAddress
};
newAvatar.ID = (string)newAvatar.GenerateNewID();