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

@@ -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();
}
}
}