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:
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user