diff --git a/src/KeetaNet.Anchor/Crypto/BlockFactory.cs b/src/KeetaNet.Anchor/Crypto/BlockFactory.cs
index 8a29ccc..628861a 100644
--- a/src/KeetaNet.Anchor/Crypto/BlockFactory.cs
+++ b/src/KeetaNet.Anchor/Crypto/BlockFactory.cs
@@ -87,6 +87,32 @@ public BlockOperation TokenAdminSupply(BigInteger amount, AdjustMethod method)
return new BlockOperation(_runtime, handle);
}
+ ///
+ /// A MANAGE_CERTIFICATE add operation publishing
+ /// on-chain, recording
+ /// alongside it as its bundle.
+ ///
+ public BlockOperation ManageCertificateAdd(Certificate certificate, IReadOnlyList? intermediates = null)
+ {
+ string der = Convert.ToHexString(certificate.ToDer());
+ string joined = string.Join(
+ '\n',
+ (intermediates ?? Array.Empty()).Select(bundled => Convert.ToHexString(bundled.ToDer())));
+ int handle = _runtime.OpManageCertificateAdd(der, joined);
+
+ return new BlockOperation(_runtime, handle);
+ }
+
+ ///
+ /// A MANAGE_CERTIFICATE remove operation retiring the published
+ /// certificate addressed by .
+ ///
+ public BlockOperation ManageCertificateRemove(CertificateHash hash)
+ {
+ int handle = _runtime.OpManageCertificateRemove(hash.ToString());
+ return new BlockOperation(_runtime, handle);
+ }
+
/// A CREATE_IDENTIFIER operation claiming .
public BlockOperation CreateIdentifier(Account identifier)
{
diff --git a/src/KeetaNet.Anchor/Crypto/Certificate.cs b/src/KeetaNet.Anchor/Crypto/Certificate.cs
index 82c5890..50b37d7 100644
--- a/src/KeetaNet.Anchor/Crypto/Certificate.cs
+++ b/src/KeetaNet.Anchor/Crypto/Certificate.cs
@@ -1,3 +1,5 @@
+using System.Security.Cryptography.X509Certificates;
+
namespace KeetaNet.Anchor.Crypto;
///
@@ -65,5 +67,18 @@ public DateTimeOffset NotAfter
///
public CertificateHash Hash => CertificateHash.Parse(Runtime.CertificateHash(Convert.ToHexString(ToDer())));
+ ///
+ /// The certificate as the native .NET X.509 type, for inspection and
+ /// platform interop. Representation only: chain trust must be evaluated
+ /// by the core (), which understands
+ /// the reference's signature algorithms where .NET does not.
+ ///
+ 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);
}
diff --git a/src/KeetaNet.Anchor/Crypto/CertificateFactory.cs b/src/KeetaNet.Anchor/Crypto/CertificateFactory.cs
index ab0996c..4716e69 100644
--- a/src/KeetaNet.Anchor/Crypto/CertificateFactory.cs
+++ b/src/KeetaNet.Anchor/Crypto/CertificateFactory.cs
@@ -23,4 +23,8 @@ public Certificate ParseDer(byte[] der)
int handle = _runtime.CertificateParseDer(der);
return new(_runtime, handle);
}
+
+ /// Adopt a native .NET certificate as a core-owned .
+ public Certificate Parse(System.Security.Cryptography.X509Certificates.X509Certificate2 certificate) =>
+ ParseDer(certificate.RawData);
}
diff --git a/src/KeetaNet.Anchor/Crypto/KycCertificate.cs b/src/KeetaNet.Anchor/Crypto/KycCertificate.cs
index 704bc51..45387bd 100644
--- a/src/KeetaNet.Anchor/Crypto/KycCertificate.cs
+++ b/src/KeetaNet.Anchor/Crypto/KycCertificate.cs
@@ -50,6 +50,16 @@ public Certificate Base()
return new(Runtime, handle);
}
+ ///
+ /// The leaf as the native .NET X.509 type, through its base certificate.
+ /// See for the trust caveat.
+ ///
+ public System.Security.Cryptography.X509Certificates.X509Certificate2 ToX509Certificate()
+ {
+ using Certificate baseCertificate = Base();
+ return baseCertificate.ToX509Certificate();
+ }
+
/// Whether the certificate is valid at .
public bool IsValidAt(DateTimeOffset moment)
{
diff --git a/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs b/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs
index 23496bc..8ed2e38 100644
--- a/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs
+++ b/src/KeetaNet.Anchor/Interop/WasmRuntime.Blocks.cs
@@ -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(
+ "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(
+ "keeta_op_manage_certificate_remove", hash.Pointer, hash.Length);
+ return TakeHandle(result);
+ });
+
internal int OpCreateIdentifier(int identifier) =>
Run(() => TakeHandle(Invoke("keeta_op_create_identifier", identifier)));
diff --git a/src/KeetaNet.Anchor/Services/Node/UserClient.cs b/src/KeetaNet.Anchor/Services/Node/UserClient.cs
index 774f35b..9d8961a 100644
--- a/src/KeetaNet.Anchor/Services/Node/UserClient.cs
+++ b/src/KeetaNet.Anchor/Services/Node/UserClient.cs
@@ -229,6 +229,47 @@ public async Task SetRep(
return await BuildAndTransmit(setRep, options, cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// Add or remove on the operating account,
+ /// the reference modifyCertificate. An add records
+ /// alongside the certificate; a
+ /// subtract retires it by its hash and ignores them.
+ ///
+ public async Task ModifyCertificate(
+ Crypto.AdjustMethod method,
+ Crypto.Certificate certificate,
+ IReadOnlyList? 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);
+ }
+
+ ///
+ /// Remove the operating account's published certificate addressed by
+ /// . Only
+ /// applies: an add needs the certificate itself.
+ ///
+ public async Task 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);
+ }
+
///
/// Publish the operating account's on-chain info.
/// is required for identifier accounts.
@@ -298,6 +339,17 @@ private TransmitOptions OrDefaultFeePayer(TransmitOptions? options)
return resolved;
}
+ /// Reject any certificate adjust method other than .
+ 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}");
+ }
+ }
+
/// The bound signer, required by every write.
private Crypto.Account RequireSigner() =>
_signer ?? throw new KeetaException("SIGNER_REQUIRED", "bind a signer to the user client to build or transmit blocks");
diff --git a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs
index c1c03f3..2957768 100644
--- a/tests/KeetaNet.Anchor.E2eTests/Anchors.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/Anchors.cs
@@ -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();
}
+
+ ///
+ /// 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.
+ ///
+ 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()!);
+ }
}
+/// A reference-issued CA and holder leaf, as PEM strings.
+internal sealed record IssuedChain(string Ca, string Leaf, string LeafHash);
+
///
/// A live asset-movement anchor HTTP server started by the harness, alongside
/// the fixture values its callbacks report back.
diff --git a/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs b/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs
index f804acf..072f70d 100644
--- a/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/ContainerInteropTests.cs
@@ -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();
diff --git a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
index d05a8ed..ce841a7 100644
--- a/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
+++ b/tests/KeetaNet.Anchor.E2eTests/NodeFlowTests.cs
@@ -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;
///
@@ -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 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();
+ }
+
///
/// A signed base-token send from 's operating
/// account to , opening the chain when
diff --git a/tests/KeetaNet.Anchor.Tests/BlockTests.cs b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
index 6aa043a..cd7d1c5 100644
--- a/tests/KeetaNet.Anchor.Tests/BlockTests.cs
+++ b/tests/KeetaNet.Anchor.Tests/BlockTests.cs
@@ -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;
///
@@ -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()
{
diff --git a/tests/KeetaNet.Anchor.Tests/IssueTests.cs b/tests/KeetaNet.Anchor.Tests/IssueTests.cs
index 9a2ffbc..0f17cc7 100644
--- a/tests/KeetaNet.Anchor.Tests/IssueTests.cs
+++ b/tests/KeetaNet.Anchor.Tests/IssueTests.cs
@@ -2,6 +2,7 @@
using System.Text.Json;
using KeetaNet.Anchor.Crypto;
using Xunit;
+using CryptoCertificate = KeetaNet.Anchor.Crypto.Certificate;
namespace KeetaNet.Anchor.Tests;
@@ -58,6 +59,39 @@ public void IssuedLeafRoundTripsEveryAttributeShape(string subjectAlgorithm)
Assert.False(parsed.IsValidAt(TestSeeds.NotAfter.AddDays(1)));
}
+ [Fact]
+ public void TheNativeX509BridgeRoundTripsBothCertificateTypes()
+ {
+ 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, "ecdsa_secp256k1");
+
+ using KycCertificate leaf = runtime.KycCertificates.Builder()
+ .Subject(subject)
+ .Issuer(issuer)
+ .SubjectName("Subject")
+ .IssuerName("Issuer")
+ .Serial(7)
+ .Validity(TestSeeds.NotBefore, TestSeeds.NotAfter)
+ .Build();
+ using CryptoCertificate baseCertificate = leaf.Base();
+
+ // The native view agrees with the core on identity and validity.
+ using var native = baseCertificate.ToX509Certificate();
+ Assert.Contains("Subject", native.Subject, StringComparison.Ordinal);
+ Assert.Contains("Issuer", native.Issuer, StringComparison.Ordinal);
+ Assert.Equal(TestSeeds.NotBefore, new DateTimeOffset(native.NotBefore.ToUniversalTime()));
+ Assert.Equal(TestSeeds.NotAfter, new DateTimeOffset(native.NotAfter.ToUniversalTime()));
+
+ // The KYC leaf bridges through its base to the identical bytes.
+ using var fromLeaf = leaf.ToX509Certificate();
+ Assert.Equal(native.RawData, fromLeaf.RawData);
+
+ // Adopting the native certificate back yields the same ledger hash.
+ using CryptoCertificate adopted = runtime.Certificates.Parse(native);
+ Assert.Equal(baseCertificate.Hash, adopted.Hash);
+ }
+
[Fact]
public void AnIncompleteBuilderRefusesToIssue()
{
diff --git a/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs b/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs
index b576442..398d277 100644
--- a/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs
+++ b/tests/KeetaNet.Anchor.Tests/LifecycleTests.cs
@@ -96,6 +96,8 @@ public void FinalizerBackstopReclaimsForgottenHandle()
#if DEBUG
[Fact]
+ [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP017:Prefer using",
+ Justification = "Explicit dispose so the counter can be observed before and after.")]
public void LeakCounterFlagsForgottenDispose()
{
using var runtime = WasmRuntime.Load();
diff --git a/tests/node-harness/src/node.ts b/tests/node-harness/src/node.ts
index df7494c..f942bf9 100644
--- a/tests/node-harness/src/node.ts
+++ b/tests/node-harness/src/node.ts
@@ -6,11 +6,17 @@
* node client can read every surface back over the real node API.
*/
+import type * as KeetaNetModule from '@keetanetwork/keetanet-client';
+
import type { ChainNode, SigningAccount } from './chain.js';
import type { HarnessResponse } from './core.js';
import { accountFromSeed } from './accounts.js';
import { bootChainNode } from './chain.js';
-import { runHarness } from './core.js';
+import { referenceResolver, runHarness } from './core.js';
+
+const KeetaNet = referenceResolver().client();
+const Account = KeetaNet.lib.Account;
+const CertificateBuilder = KeetaNet.lib.Utils.Certificate.CertificateBuilder;
/** The running reference node, if any. */
let chain: ChainNode | undefined;
@@ -56,6 +62,18 @@ interface HeadRequest {
account: string;
}
+/**
+ * Issue a reference CA and a leaf for the seed-derived holder, so a binding
+ * can publish a node-valid bundle through its own write path. The reference
+ * builder emits the CA extensions (basic constraints, key identifiers) that
+ * the node's certificate graph check demands.
+ */
+interface IssueChainRequest {
+ cmd: 'issueChain';
+ seed: string;
+ algorithm?: string;
+}
+
interface ShutdownRequest {
cmd: 'shutdown';
}
@@ -66,6 +84,7 @@ type NodeRequest =
SetInfoRequest |
SetRepRequest |
HeadRequest |
+ IssueChainRequest |
ShutdownRequest;
function running(): ChainNode {
@@ -154,6 +173,37 @@ async function handleHead(request: HeadRequest): Promise {
});
}
+async function handleIssueChain(request: IssueChainRequest): Promise {
+ const holder = signer(request);
+ const caAccount = Account.fromSeed(Account.generateRandomSeed(), 0);
+ const validFrom = new Date(Date.now() - 30_000);
+ const validTo = new Date(Date.now() + (60 * 60 * 1000));
+
+ // Self-signed, so the reference builder marks it a CA automatically.
+ const ca = await new CertificateBuilder({
+ subjectPublicKey: caAccount,
+ issuer: caAccount,
+ serial: 1,
+ validFrom,
+ validTo
+ }).build();
+
+ const leaf = await new CertificateBuilder({
+ subjectPublicKey: holder,
+ issuer: caAccount,
+ serial: 2,
+ validFrom,
+ validTo
+ }).build();
+
+ return({
+ event: 'chain-issued',
+ ca: ca.toPEM(),
+ leaf: leaf.toPEM(),
+ leafHash: leaf.hash().toString()
+ });
+}
+
async function handle(request: NodeRequest): Promise {
switch (request.cmd) {
case 'startNode': return(await handleStartNode());
@@ -161,6 +211,7 @@ async function handle(request: NodeRequest): Promise {
case 'setInfo': return(await handleSetInfo(request));
case 'setRep': return(await handleSetRep(request));
case 'head': return(await handleHead(request));
+ case 'issueChain': return(await handleIssueChain(request));
case 'shutdown': return({ event: 'shutdown' });
}
}