Add unit test for linked data signatures

This commit is contained in:
Laura Hausmann 2024-01-20 20:39:29 +01:00
parent 2c9e40f63e
commit 9d70c68dbd
No known key found for this signature in database
GPG key ID: D044E84C5BE01605
8 changed files with 89 additions and 47 deletions

View file

@ -32,7 +32,7 @@ public class ActivityPubController(ILogger<ActivityPubController> logger, Databa
var user = await db.Users.FirstOrDefaultAsync(p => p.Id == id); var user = await db.Users.FirstOrDefaultAsync(p => p.Id == id);
if (user == null) return NotFound(); if (user == null) return NotFound();
var rendered = await userRenderer.Render(user); var rendered = await userRenderer.Render(user);
var compacted = LDHelpers.Compact(rendered); var compacted = LdHelpers.Compact(rendered);
return Ok(compacted); return Ok(compacted);
} }
} }

View file

@ -18,7 +18,7 @@ public class ActivityPubService(HttpClient client, HttpRequestService httpRqSvc)
var input = await response.Content.ReadAsStringAsync(); var input = await response.Content.ReadAsStringAsync();
var json = JsonConvert.DeserializeObject<JObject?>(input, JsonSerializerSettings); var json = JsonConvert.DeserializeObject<JObject?>(input, JsonSerializerSettings);
var res = LDHelpers.Expand(json); var res = LdHelpers.Expand(json);
if (res == null) throw new Exception("Failed to expand JSON-LD object"); if (res == null) throw new Exception("Failed to expand JSON-LD object");
return res; return res;
} }

View file

@ -10,7 +10,7 @@ using VDS.RDF.Writing.Formatting;
namespace Iceshrimp.Backend.Core.Federation.ActivityStreams; namespace Iceshrimp.Backend.Core.Federation.ActivityStreams;
public static class LDHelpers { public static class LdHelpers {
private static readonly Dictionary<string, RemoteDocument> ContextCache = new() { private static readonly Dictionary<string, RemoteDocument> ContextCache = new() {
{ {
"https://www.w3.org/ns/activitystreams", new RemoteDocument { "https://www.w3.org/ns/activitystreams", new RemoteDocument {
@ -53,6 +53,7 @@ public static class LDHelpers {
} }
public static JObject? Compact(object obj) => Compact(JToken.FromObject(obj)); public static JObject? Compact(object obj) => Compact(JToken.FromObject(obj));
public static JArray? Expand(object obj) => Expand(JToken.FromObject(obj));
public static JObject? Compact(JToken? json) => JsonLdProcessor.Compact(json, DefaultContext, Options); public static JObject? Compact(JToken? json) => JsonLdProcessor.Compact(json, DefaultContext, Options);
public static JArray? Expand(JToken? json) => JsonLdProcessor.Expand(json, Options); public static JArray? Expand(JToken? json) => JsonLdProcessor.Expand(json, Options);
public static string Canonicalize(JArray json) => JsonLdProcessor.Canonicalize(json); public static string Canonicalize(JArray json) => JsonLdProcessor.Canonicalize(json);

View file

@ -11,28 +11,33 @@ using VC = Iceshrimp.Backend.Core.Federation.ActivityStreams.Types.ValueObjectCo
namespace Iceshrimp.Backend.Core.Federation.Cryptography; namespace Iceshrimp.Backend.Core.Federation.Cryptography;
public static class LdSignature { public static class LdSignature {
public static async Task<bool> Verify(JArray activity, string key) { public static Task<bool> Verify(JArray activity, string key) {
foreach (var act in activity) { if (activity.ToArray() is not [JObject obj]) throw new Exception("Invalid activity");
var options = act["https://w3id.org/security#signature"]; return Verify(obj, key);
if (options?.ToObject<SignatureOptions[]>() is not { Length: 1 } signatures) return false;
var signature = signatures[0];
if (signature.Type is not ["_:RsaSignature2017"]) return false;
if (signature.Signature is null) return false;
var signatureData = await GetSignatureData(act, options);
if (signatureData is null) return false;
var rsa = RSA.Create();
rsa.ImportFromPem(key);
var verify = rsa.VerifyData(signatureData, Convert.FromBase64String(signature.Signature),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
if (!verify) return false;
}
return true;
} }
public static async Task<JToken> Sign(JToken data, string key, string? creator) { public static async Task<bool> Verify(JObject activity, string key) {
var options = activity["https://w3id.org/security#signature"];
if (options?.ToObject<SignatureOptions[]>() is not { Length: 1 } signatures) return false;
var signature = signatures[0];
if (signature.Type is not ["_:RsaSignature2017"]) return false;
if (signature.Signature is null) return false;
var signatureData = await GetSignatureData(activity, options);
if (signatureData is null) return false;
var rsa = RSA.Create();
rsa.ImportFromPem(key);
return rsa.VerifyData(signatureData, Convert.FromBase64String(signature.Signature),
HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
}
public static Task<JObject> Sign(JArray activity, string key, string? creator) {
if (activity.ToArray() is not [JObject obj]) throw new Exception("Invalid activity");
return Sign(obj, key, creator);
}
public static async Task<JObject> Sign(JObject activity, string key, string? creator) {
var options = new SignatureOptions { var options = new SignatureOptions {
Created = DateTime.Now, Created = DateTime.Now,
Creator = creator, Creator = creator,
@ -41,24 +46,23 @@ public static class LdSignature {
Domain = null, Domain = null,
}; };
var signatureData = await GetSignatureData(data, options); var signatureData = await GetSignatureData(activity, options);
if (signatureData == null) throw new NullReferenceException("Signature data must not be null"); if (signatureData == null) throw new NullReferenceException("Signature data must not be null");
var rsa = RSA.Create(); var rsa = RSA.Create();
rsa.ImportFromPem(key); rsa.ImportFromPem(key);
var signatureBytes = rsa.SignData(signatureData, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); var signatureBytes = rsa.SignData(signatureData, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var signature = Convert.ToBase64String(signatureBytes); var signature = Convert.ToBase64String(signatureBytes);
options.Signature = signature; options.Signature = signature;
if (data is not JObject obj) throw new Exception(); activity.Add("https://w3id.org/security#signature", JToken.FromObject(options));
obj.Add("https://w3id.org/security#signature", JToken.FromObject(options));
return obj; return LdHelpers.Expand(activity)?[0] as JObject ?? throw new Exception("Failed to expand signed activity");
} }
private static Task<byte[]?> GetSignatureData(JToken data, SignatureOptions options) => private static Task<byte[]?> GetSignatureData(JToken data, SignatureOptions options) =>
GetSignatureData(data, LDHelpers.Expand(JObject.FromObject(options))!); GetSignatureData(data, LdHelpers.Expand(JObject.FromObject(options))!);
private static async Task<byte[]?> GetSignatureData(JToken data, JToken options) { private static async Task<byte[]?> GetSignatureData(JToken data, JToken options) {
if (data is not JObject inputData) return null; if (data is not JObject inputData) return null;
@ -71,8 +75,8 @@ public static class LdSignature {
inputData.Remove("https://w3id.org/security#signature"); inputData.Remove("https://w3id.org/security#signature");
var canonicalData = LDHelpers.Canonicalize(inputData); var canonicalData = LdHelpers.Canonicalize(inputData);
var canonicalOptions = LDHelpers.Canonicalize(inputOptions); var canonicalOptions = LdHelpers.Canonicalize(inputOptions);
var dataHash = await DigestHelpers.Sha256Digest(canonicalData); var dataHash = await DigestHelpers.Sha256Digest(canonicalData);
var optionsHash = await DigestHelpers.Sha256Digest(canonicalOptions); var optionsHash = await DigestHelpers.Sha256Digest(canonicalOptions);
@ -81,7 +85,7 @@ public static class LdSignature {
} }
private class SignatureOptions { private class SignatureOptions {
[J("@type")] public required List<string> Type { get; set; } [J("@type")] public required List<string> Type { get; set; }
[J("https://w3id.org/security#signatureValue")] [J("https://w3id.org/security#signatureValue")]
[JC(typeof(VC))] [JC(typeof(VC))]

View file

@ -0,0 +1,34 @@
using System.Security.Cryptography;
using FluentAssertions;
using Iceshrimp.Backend.Core.Federation.ActivityStreams;
using Iceshrimp.Backend.Core.Federation.ActivityStreams.Types;
using Iceshrimp.Backend.Core.Federation.Cryptography;
using Iceshrimp.Backend.Core.Helpers;
namespace Iceshrimp.Tests.Cryptography;
[TestClass]
public class LdSignatureTests {
[TestMethod]
public async Task RoundtripTest() {
var keypair = RSA.Create();
var actor = new ASActor {
Id = $"https://example.org/users/{IdHelpers.GenerateSlowflakeId()}",
Type = ["https://www.w3.org/ns/activitystreams#Person"],
Url = new ASLink($"https://example.org/@test"),
Username = "test",
DisplayName = "Test account",
IsCat = false,
IsDiscoverable = true,
IsLocked = true
};
var expanded = LdHelpers.Expand(actor);
var signed = await LdSignature.Sign(expanded!, keypair.ExportRSAPrivateKeyPem(), actor.Id + "#main-key");
var verify = await LdSignature.Verify(signed, keypair.ExportRSAPublicKeyPem());
verify.Should().BeTrue();
}
}

View file

@ -1,9 +0,0 @@
namespace Iceshrimp.Tests.Cryptography;
[TestClass]
public class LdSignatures {
[TestMethod]
public void TestMethod1() {
}
}

View file

@ -10,10 +10,19 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/> <PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.4"/> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0" />
<PackageReference Include="MSTest.TestFramework" Version="3.0.4"/> <PackageReference Include="MSTest.TestAdapter" Version="3.0.4" />
<PackageReference Include="coverlet.collector" Version="6.0.0"/> <PackageReference Include="MSTest.TestFramework" Version="3.0.4" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Iceshrimp.Backend\Iceshrimp.Backend.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="configuration.ini" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -0,0 +1,3 @@
[Instance]
WebDomain=shrimp.example.org
AccountDomain=example.org