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