Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/KeetaNet.Anchor/Crypto/BlockFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ public BlockOperation TokenAdminSupply(BigInteger amount, AdjustMethod method)
return new BlockOperation(_runtime, handle);
}

/// <summary>
/// A <c>MANAGE_CERTIFICATE</c> add operation publishing
/// <paramref name="certificate"/> on-chain, recording
/// <paramref name="intermediates"/> alongside it as its bundle.
/// </summary>
public BlockOperation ManageCertificateAdd(Certificate certificate, IReadOnlyList<Certificate>? intermediates = null)
{
string der = Convert.ToHexString(certificate.ToDer());
string joined = string.Join(
'\n',
(intermediates ?? Array.Empty<Certificate>()).Select(bundled => Convert.ToHexString(bundled.ToDer())));
int handle = _runtime.OpManageCertificateAdd(der, joined);

return new BlockOperation(_runtime, handle);
}

/// <summary>
/// A <c>MANAGE_CERTIFICATE</c> remove operation retiring the published
/// certificate addressed by <paramref name="hash"/>.
/// </summary>
public BlockOperation ManageCertificateRemove(CertificateHash hash)
{
int handle = _runtime.OpManageCertificateRemove(hash.ToString());
return new BlockOperation(_runtime, handle);
}

/// <summary>A <c>CREATE_IDENTIFIER</c> operation claiming <paramref name="identifier"/>.</summary>
public BlockOperation CreateIdentifier(Account identifier)
{
Expand Down
15 changes: 15 additions & 0 deletions src/KeetaNet.Anchor/Crypto/Certificate.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using System.Security.Cryptography.X509Certificates;

namespace KeetaNet.Anchor.Crypto;

/// <summary>
Expand Down Expand Up @@ -65,5 +67,18 @@ public DateTimeOffset NotAfter
/// </summary>
public CertificateHash Hash => CertificateHash.Parse(Runtime.CertificateHash(Convert.ToHexString(ToDer())));

/// <summary>
/// The certificate as the native .NET X.509 type, for inspection and
/// platform interop. Representation only: chain trust must be evaluated
/// by the core (<see cref="KycCertificate.Verify"/>), which understands
/// the reference's signature algorithms where .NET does not.
/// </summary>
public X509Certificate2 ToX509Certificate() =>
#if NET9_0_OR_GREATER
X509CertificateLoader.LoadCertificate(ToDer());
#else
new(ToDer());
#endif

private protected override void Release(WasmRuntime runtime, int handle) => runtime.CertificateFree(handle);
}
4 changes: 4 additions & 0 deletions src/KeetaNet.Anchor/Crypto/CertificateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,8 @@ public Certificate ParseDer(byte[] der)
int handle = _runtime.CertificateParseDer(der);
return new(_runtime, handle);
}

/// <summary>Adopt a native .NET certificate as a core-owned <see cref="Certificate"/>.</summary>
public Certificate Parse(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) =>
ParseDer(certificate.RawData);
}
10 changes: 10 additions & 0 deletions src/KeetaNet.Anchor/Crypto/KycCertificate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,16 @@ public Certificate Base()
return new(Runtime, handle);
}

/// <summary>
/// The leaf as the native .NET X.509 type, through its base certificate.
/// See <see cref="Certificate.ToX509Certificate"/> for the trust caveat.
/// </summary>
public System.Security.Cryptography.X509Certificates.X509Certificate2 ToX509Certificate()
{
using Certificate baseCertificate = Base();
return baseCertificate.ToX509Certificate();
}

/// <summary>Whether the certificate is valid at <paramref name="moment"/>.</summary>
public bool IsValidAt(DateTimeOffset moment)
{
Expand Down
25 changes: 25 additions & 0 deletions src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,31 @@ internal int OpTokenAdminSupply(string amount, string method) =>
return TakeHandle(result);
});

internal int OpManageCertificateAdd(string certificateDerHex, string intermediatesJoined) =>
Run(() =>
{
using var arguments = new ArgumentScope(this);
Argument certificate = arguments.Write(certificateDerHex);
Argument intermediates = arguments.Write(intermediatesJoined);

int result = Invoke<int, int, int, int, int>(
"keeta_op_manage_certificate_add",
certificate.Pointer, certificate.Length,
intermediates.Pointer, intermediates.Length);
return TakeHandle(result);
});

internal int OpManageCertificateRemove(string hashHex) =>
Run(() =>
{
using var arguments = new ArgumentScope(this);
Argument hash = arguments.Write(hashHex);

int result = Invoke<int, int, int>(
"keeta_op_manage_certificate_remove", hash.Pointer, hash.Length);
return TakeHandle(result);
});

internal int OpCreateIdentifier(int identifier) =>
Run(() => TakeHandle(Invoke<int, int>("keeta_op_create_identifier", identifier)));

Expand Down
52 changes: 52 additions & 0 deletions src/KeetaNet.Anchor/Services/Node/UserClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,47 @@ public async Task<bool> SetRep(
return await BuildAndTransmit(setRep, options, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Add or remove <paramref name="certificate"/> on the operating account,
/// the reference <c>modifyCertificate</c>. An add records
/// <paramref name="intermediates"/> alongside the certificate; a
/// subtract retires it by its hash and ignores them.
/// </summary>
public async Task<bool> ModifyCertificate(
Crypto.AdjustMethod method,
Crypto.Certificate certificate,
IReadOnlyList<Crypto.Certificate>? intermediates = null,
TransmitOptions? options = null,
CancellationToken cancellationToken = default)
{
if (method == Crypto.AdjustMethod.Subtract)
{
return await ModifyCertificate(method, certificate.Hash, options, cancellationToken).ConfigureAwait(false);
}

RequireAdjust(method, Crypto.AdjustMethod.Add);
using Crypto.BlockOperation add = _runtime.Blocks.ManageCertificateAdd(certificate, intermediates);

return await BuildAndTransmit(add, options, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Remove the operating account's published certificate addressed by
/// <paramref name="hash"/>. Only <see cref="Crypto.AdjustMethod.Subtract"/>
/// applies: an add needs the certificate itself.
/// </summary>
public async Task<bool> ModifyCertificate(
Crypto.AdjustMethod method,
Crypto.CertificateHash hash,
TransmitOptions? options = null,
CancellationToken cancellationToken = default)
{
RequireAdjust(method, Crypto.AdjustMethod.Subtract);
using Crypto.BlockOperation remove = _runtime.Blocks.ManageCertificateRemove(hash);

return await BuildAndTransmit(remove, options, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Publish the operating account's on-chain info.
/// <paramref name="defaultPermission"/> is required for identifier accounts.
Expand Down Expand Up @@ -298,6 +339,17 @@ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options)
return resolved;
}

/// <summary>Reject any certificate adjust method other than <paramref name="expected"/>.</summary>
private static void RequireAdjust(Crypto.AdjustMethod method, Crypto.AdjustMethod expected)
{
if (method != expected)
{
throw new KeetaException(
"ADJUST_METHOD",
$"certificates support add and subtract; this overload handles {expected}");
}
}

/// <summary>The bound signer, required by every write.</summary>
private Crypto.Account RequireSigner() =>
_signer ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer to the user client to build or transmit blocks");
Expand Down
23 changes: 23 additions & 0 deletions tests/KeetaNet.Anchor.E2eTests/Anchors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,31 @@ public string SetRep(string seed, string representativeSeed)
var arguments = new JsonObject { ["account"] = account };
return _harness.Request("head", arguments).GetProperty("head").GetString();
}

/// <summary>
/// Have the reference issue a CA and a leaf for the seed-derived holder.
/// The reference builder emits the CA extensions the node's certificate
/// graph check demands, which the core's KYC builder omits.
/// </summary>
public IssuedChain IssueChain(string seed)
{
var arguments = new JsonObject
{
["seed"] = seed,
["algorithm"] = "secp256k1",
};
JsonElement issued = _harness.Request("issueChain", arguments);

return new IssuedChain(
issued.GetProperty("ca").GetString()!,
issued.GetProperty("leaf").GetString()!,
issued.GetProperty("leafHash").GetString()!);
}
}

/// <summary>A reference-issued CA and holder leaf, as PEM strings.</summary>
internal sealed record IssuedChain(string Ca, string Leaf, string LeafHash);

/// <summary>
/// A live asset-movement anchor HTTP server started by the harness, alongside
/// the fixture values its callbacks report back.
Expand Down
2 changes: 1 addition & 1 deletion tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public void CsharpDecryptsAndVerifiesTheTypescriptContainer()
Assert.Equal(tsSigner.PublicKeyAndType, Convert.ToHexString(recoveredSigner!), ignoreCase: true);
}

[Fact(Skip = "known zlib compression divergence in the TS reference breaks C#-to-TS signature validation; quarantined pending an upstream compression fix")]
[Fact]
public void TypescriptDecryptsAndVerifiesTheCsharpContainer()
{
using var runtime = WasmRuntime.Load();
Expand Down
48 changes: 48 additions & 0 deletions tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
using KeetaNet.Anchor.Crypto;
using Xunit;

// `Certificate` also names the published-record DTO in `KeetaNet.Anchor`.
using CryptoCertificate = KeetaNet.Anchor.Crypto.Certificate;

namespace KeetaNet.Anchor.E2eTests;

/// <summary>
Expand Down Expand Up @@ -353,6 +356,51 @@ public async Task ChainHistoryAndAclReadsRoundTripAgainstTheLiveNode()
harness.Shutdown();
}

[Fact]
public async Task CertificateWritesRoundTripAgainstTheLiveNode()
{
CancellationToken cancellationToken = TestContext.Current.CancellationToken;
using var harness = NodeHarness.Spawn("node");
LedgerNode node = LedgerNode.Start(harness);

using var runtime = WasmRuntime.Load();
using Account holder = runtime.Accounts.FromSeed(E2eSeeds.Subject, 0, E2eSeeds.Secp256k1);
using UserClient user = runtime.CreateUserClient(node.Api, holder, network: node.Network);

node.Fund(E2eSeeds.Subject, Funding);

// A reference-issued chain for the holder: the node's graph check
// demands the CA extensions only the reference builder emits.
IssuedChain issued = node.IssueChain(E2eSeeds.Subject);
using CryptoCertificate leaf = runtime.Certificates.Parse(issued.Leaf);
using CryptoCertificate authority = runtime.Certificates.Parse(issued.Ca);
Assert.Equal(CertificateHash.Parse(issued.LeafHash), leaf.Hash);

// The add publishes the leaf with the authority recorded as its
// bundle, and the account's certificate reads serve both back.
Assert.True(await user.ModifyCertificate(
AdjustMethod.Add, leaf, new[] { authority }, cancellationToken: cancellationToken));

IReadOnlyList<Certificate> published = await user.GetAllCertificates(cancellationToken);
Certificate record = Assert.Single(published);
using (CryptoCertificate readBack = runtime.Certificates.Parse(record.Value))
{
Assert.Equal(leaf.Hash, readBack.Hash);
}

Assert.Single(record.Intermediates);
Assert.NotNull(await user.GetCertificateByHash(leaf.Hash, cancellationToken));

// The subtract retires the leaf by its hash; the reads empty out.
Assert.True(await user.ModifyCertificate(
AdjustMethod.Subtract, leaf, cancellationToken: cancellationToken));

Assert.Empty(await user.GetAllCertificates(cancellationToken));
Assert.Null(await user.GetCertificateByHash(leaf.Hash, cancellationToken));

harness.Shutdown();
}

/// <summary>
/// A signed base-token send from <paramref name="user"/>'s operating
/// account to <paramref name="to"/>, opening the chain when
Expand Down
65 changes: 65 additions & 0 deletions tests/KeetaNet.Anchor.Tests/BlockTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using KeetaNet.Anchor.Crypto;
using Xunit;

// `Certificate` also names the published-record DTO in `KeetaNet.Anchor`.
using CryptoCertificate = KeetaNet.Anchor.Crypto.Certificate;

namespace KeetaNet.Anchor.Tests;

/// <summary>
Expand Down Expand Up @@ -250,6 +253,68 @@ public async Task TransmitRefusesAReadOnlyUserClientEvenWithAFeeFactory()
Assert.Equal("SIGNER_REQUIRED", refusedPublish.Code);
}

[Fact]
public void CertificateOperationsBuildIntoASignedBlock()
{
using var runtime = WasmRuntime.Load();
using Account subject = runtime.Accounts.FromSeed(TestSeeds.Subject, 0, TestSeeds.DefaultAlgorithm);
using Account issuer = runtime.Accounts.FromSeed(TestSeeds.Issuer, 0, TestSeeds.DefaultAlgorithm);

using KycCertificate authority = runtime.KycCertificates.Builder()
.Subject(issuer)
.Issuer(issuer)
.SubjectName("Authority")
.IssuerName("Authority")
.Serial(1)
.Validity(TestSeeds.NotBefore, TestSeeds.NotAfter)
.AsCertificateAuthority()
.Build();
using KycCertificate leaf = runtime.KycCertificates.Builder()
.Subject(subject)
.Issuer(issuer)
.SubjectName("Leaf")
.IssuerName("Authority")
.Serial(2)
.Validity(TestSeeds.NotBefore, TestSeeds.NotAfter)
.Build();

using CryptoCertificate leafBase = leaf.Base();
using CryptoCertificate authorityBase = authority.Base();

using BlockOperation add = runtime.Blocks.ManageCertificateAdd(leafBase, new[] { authorityBase });
using BlockOperation remove = runtime.Blocks.ManageCertificateRemove(leafBase.Hash);

// The core forbids adding and removing the same certificate in one
// block, so the add and the remove live in a chained pair.
using var opening = runtime.Blocks.NewBuilder();
opening
.WithVersion(2)
.WithNetwork(Network)
.WithAccount(subject)
.WithSigner(subject)
.WithDate(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_000_000))
.AsOpening()
.AddOperation(add);

using Block added = opening.Build();
using Block addedDecoded = runtime.Blocks.ParseHex(Convert.ToHexString(added.ToBytes()));
Assert.Equal(added.Hash, addedDecoded.Hash);

using var successor = runtime.Blocks.NewBuilder();
successor
.WithVersion(2)
.WithNetwork(Network)
.WithAccount(subject)
.WithSigner(subject)
.WithDate(DateTimeOffset.FromUnixTimeMilliseconds(1_700_000_060_000))
.WithPrevious(added.Hash)
.AddOperation(remove);

using Block removed = successor.Build();
using Block removedDecoded = runtime.Blocks.ParseHex(Convert.ToHexString(removed.ToBytes()));
Assert.Equal(removed.Hash, removedDecoded.Hash);
}

[Fact]
public void TheBaseTokenDerivesDeterministicallyFromTheNetwork()
{
Expand Down
Loading
Loading