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
128 lines
5.1 KiB
C#
128 lines
5.1 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|