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' }); } }