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(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() : number.ToByteArray(isUnsigned: true, isBigEndian: true); var zeros = text.TakeWhile(c => c == '1').Count(); return new byte[zeros].Concat(body).ToArray(); } } }