From 3fdc343715b0f6ccaa1eda42e6169336426b016b Mon Sep 17 00:00:00 2001 From: Jingyu Date: Wed, 24 Jun 2026 13:50:43 +0800 Subject: [PATCH 01/10] Make NodeInfo truly immutable --- api/src/main/java/io/bosonnetwork/Node.java | 6 ++ .../main/java/io/bosonnetwork/NodeInfo.java | 91 ++++++++----------- .../java/io/bosonnetwork/NodeInfoTests.java | 33 ++++--- .../io/bosonnetwork/kademlia/KadNode.java | 51 +++++++++-- .../io/bosonnetwork/kademlia/impl/DHT.java | 3 +- .../kademlia/routing/KBucketEntry.java | 22 +++++ .../io/bosonnetwork/kademlia/tasks/Task.java | 3 +- .../bosonnetwork/kademlia/NodeAsyncTests.java | 78 ++++++++++++++++ .../bosonnetwork/kademlia/NodeSyncTests.java | 67 ++++++++++++++ .../security/SuspiciousNodeDetectorTests.java | 2 +- 10 files changed, 282 insertions(+), 74 deletions(-) diff --git a/api/src/main/java/io/bosonnetwork/Node.java b/api/src/main/java/io/bosonnetwork/Node.java index c92c13ac..927e8eb5 100644 --- a/api/src/main/java/io/bosonnetwork/Node.java +++ b/api/src/main/java/io/bosonnetwork/Node.java @@ -159,6 +159,12 @@ default CompletableFuture> findNode(Id id) { /** * Finds a node by its ID with a specific lookup option. + *

+ * When present, the returned {@link NodeInfo} records which address families answered the lookup: + * {@link NodeInfo#hasAddress4()} and {@link NodeInfo#hasAddress6()} are true only for the families + * that contributed a result. A dual-stack node that responded over a single family therefore yields + * a single-address {@link NodeInfo} (a {@link LookupOption#CONSERVATIVE} lookup queries both families + * and still succeeds with a partial result if only one responds). * * @param id the {@link Id} of the node to find * @param option the {@link LookupOption} to use diff --git a/api/src/main/java/io/bosonnetwork/NodeInfo.java b/api/src/main/java/io/bosonnetwork/NodeInfo.java index c5f67bf0..7246c50f 100644 --- a/api/src/main/java/io/bosonnetwork/NodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/NodeInfo.java @@ -42,17 +42,15 @@ * {@link #getIpAddress()}) prefer the IPv4 address and fall back to IPv6; use the family-specific * accessors to target a particular protocol family. *

- * The id and addresses are immutable and define {@link #equals(Object)}/{@link #hashCode()}; the - * version and the default protocol family ({@link #narrowDown(StandardProtocolFamily)}) are mutable - * and excluded from equality. Instances are not thread-safe for the mutable fields; callers that - * share an instance across threads should treat it as effectively immutable. + * Instances are immutable: the id and addresses define {@link #equals(Object)}/{@link #hashCode()}, + * and the preferred protocol family is fixed at construction. {@link #narrowDown(StandardProtocolFamily)} + * returns a new instance rather than mutating. Immutable instances are safe to share across threads. */ public class NodeInfo { private final Id id; private final @Nullable InetSocketAddress addr4; private final @Nullable InetSocketAddress addr6; - private int version; - private @Nullable StandardProtocolFamily defaultProtocolFamily; + private final StandardProtocolFamily defaultProtocolFamily; private NodeInfo(Id id, @Nullable InetSocketAddress sockAddr4, @Nullable InetSocketAddress sockAddr6) { Objects.requireNonNull(id, "id"); @@ -115,7 +113,6 @@ protected NodeInfo(NodeInfo ni) { this.id = ni.id; this.addr4 = ni.addr4; this.addr6 = ni.addr6; - this.version = ni.version; this.defaultProtocolFamily = ni.defaultProtocolFamily; } @@ -322,27 +319,33 @@ public Id getId() { } /** - * Narrow the node down to a single protocol family, making the given family the one returned by - * the generic accessors ({@link #getAddress()}, {@link #getHost()}, {@link #getPort()}, etc.). + * Returns a view of this node narrowed to a single protocol family, dropping any address of the + * other family. The returned node carries only the requested family's address, so its generic + * accessors unambiguously refer to that family and it compares equal only to other single-family + * nodes with the same id and address. If this node already has only the requested family, it is + * returned unchanged. * - * @param family the protocol family to make default; the node must have an address for it. + * @param family the protocol family to keep (INET or INET6); the node must have an address for it. + * @return a single-address {@code NodeInfo} for the requested family. * @throws IllegalStateException if no address of the requested family is available. * @throws IllegalArgumentException if the family is not INET or INET6. */ - public void narrowDown(StandardProtocolFamily family) { - switch (family) { - case INET -> { - if (addr4 == null) - throw new IllegalStateException("No IPv4 address is available"); - } - case INET6 -> { - if (addr6 == null) - throw new IllegalStateException("No IPv6 address is available"); - } + public NodeInfo narrowDown(StandardProtocolFamily family) { + InetSocketAddress addr = switch (family) { + case INET -> addr4; + case INET6 -> addr6; default -> throw new IllegalArgumentException("Unsupported protocol family: " + family); - } + }; - this.defaultProtocolFamily = family; + if (addr == null) + throw new IllegalStateException("No " + + (family == StandardProtocolFamily.INET ? "IPv4" : "IPv6") + " address is available"); + + // Already single-family (of the requested family, since its address is present): share it. + if (!hasMultiAddresses()) + return this; + + return new NodeInfo(id, addr); } /** @@ -388,15 +391,25 @@ public boolean hasMultiAddresses() { } /** - * Gets the socket address of the node. - * Returns the IPv4 address if available, otherwise returns the IPv6 address. + * Returns the protocol family used by the generic accessors ({@link #getAddress()}, + * {@link #getHost()}, {@link #getPort()}, {@link #getIpAddress()}). For a dual-stack node this is + * IPv4 by default; for a single-stack node it is the only available family. + * + * @return the preferred protocol family (INET or INET6). + */ + public StandardProtocolFamily getPreferredFamily() { + return defaultProtocolFamily; + } + + /** + * Gets the socket address of the node for the {@linkplain #getPreferredFamily() preferred family}. + * For a dual-stack node this is the IPv4 address; for a single-stack node it is the only available + * address. Use {@link #getAddress4()}/{@link #getAddress6()} or {@link #getAddress(StandardProtocolFamily)} + * to target a specific family. * * @return the socket address. - * @throws IllegalStateException if no address is available. */ public InetSocketAddress getAddress() { - if (defaultProtocolFamily == null) - throw new IllegalStateException("No default protocol family is set"); return Objects.requireNonNull(getAddress(defaultProtocolFamily)); } @@ -440,8 +453,6 @@ public InetSocketAddress getAddress() { * @return the IP address. */ public InetAddress getIpAddress() { - if (defaultProtocolFamily == null) - throw new IllegalStateException("No default protocol family is set"); return Objects.requireNonNull(getIpAddress(defaultProtocolFamily)); } @@ -486,8 +497,6 @@ public InetAddress getIpAddress() { * @return the host name or string of IP address. */ public String getHost() { - if (defaultProtocolFamily == null) - throw new IllegalStateException("No default protocol family is set"); return Objects.requireNonNull(getHost(defaultProtocolFamily)); } @@ -534,8 +543,6 @@ public String getHost() { * @return the port number. */ public int getPort() { - if (defaultProtocolFamily == null) - throw new IllegalStateException("No default protocol family is set"); return getPort(defaultProtocolFamily); } @@ -572,24 +579,6 @@ public int getPort6() { return addr6 != null ? addr6.getPort() : -1; } - /** - * Sets the node version number. - * - * @param version the version number. - */ - public void setVersion(int version) { - this.version = version; - } - - /** - * Gets the node version. - * - * @return the version number. - */ - public int getVersion() { - return version; - } - /** * Checks whether this node info conflicts with another, i.e.; they share the same id * or the same socket address. This is a partial match used to detect identity/address diff --git a/api/src/test/java/io/bosonnetwork/NodeInfoTests.java b/api/src/test/java/io/bosonnetwork/NodeInfoTests.java index d7c85cd9..c570fb76 100644 --- a/api/src/test/java/io/bosonnetwork/NodeInfoTests.java +++ b/api/src/test/java/io/bosonnetwork/NodeInfoTests.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -48,10 +49,8 @@ void testConstructors4() throws Exception { assertEquals(socketAddr, ni4.getAddress4()); // Test copy constructor - ni1.setVersion(5); NodeInfo ni5 = new NodeInfo(ni1); assertEquals(ni1, ni5); - assertEquals(5, ni5.getVersion()); } @Test @@ -85,10 +84,8 @@ void testConstructors6() throws Exception { assertEquals(socketAddr, ni4.getAddress6()); // Test copy constructor - ni1.setVersion(5); NodeInfo ni5 = new NodeInfo(ni1); assertEquals(ni1, ni5); - assertEquals(5, ni5.getVersion()); } @Test @@ -115,17 +112,31 @@ void testConstructors46() throws Exception { // Generic accessors on a dual-stack node must prefer IPv4 (and must not throw). assertTrue(ni1.hasMultiAddresses()); + assertEquals(java.net.StandardProtocolFamily.INET, ni1.getPreferredFamily()); assertEquals(socketAddr4, ni1.getAddress()); assertEquals(addr4, ni1.getIpAddress()); assertEquals(host4, ni1.getHost()); assertEquals(port4, ni1.getPort()); - // narrowDown switches the generic accessors to the requested family. - ni1.narrowDown(java.net.StandardProtocolFamily.INET6); - assertEquals(socketAddr6, ni1.getAddress()); - assertEquals(port6, ni1.getPort()); - ni1.narrowDown(java.net.StandardProtocolFamily.INET); - assertEquals(socketAddr4, ni1.getAddress()); + // narrowDown returns a single-address copy for the requested family, leaving the original intact. + NodeInfo narrowed6 = ni1.narrowDown(java.net.StandardProtocolFamily.INET6); + assertFalse(narrowed6.hasMultiAddresses()); + assertTrue(narrowed6.hasAddress6()); + assertFalse(narrowed6.hasAddress4()); + assertEquals(socketAddr6, narrowed6.getAddress()); + assertEquals(port6, narrowed6.getPort()); + + NodeInfo narrowed4 = ni1.narrowDown(java.net.StandardProtocolFamily.INET); + assertFalse(narrowed4.hasMultiAddresses()); + assertEquals(socketAddr4, narrowed4.getAddress()); + + // Original is unchanged (narrowDown does not mutate). + assertTrue(ni1.hasMultiAddresses()); + + // Narrowing an already single-family node to its own family returns the same instance. + assertSame(narrowed4, narrowed4.narrowDown(java.net.StandardProtocolFamily.INET)); + // Narrowing to an absent family is rejected. + assertThrows(IllegalStateException.class, () -> narrowed4.narrowDown(java.net.StandardProtocolFamily.INET6)); // Test constructor with InetAddress and port NodeInfo ni2 = NodeInfo.of(id, addr4, port4, addr6, port6); @@ -146,10 +157,8 @@ void testConstructors46() throws Exception { assertEquals(socketAddr6, ni4.getAddress6()); // Test copy constructor - ni1.setVersion(5); NodeInfo ni5 = new NodeInfo(ni1); assertEquals(ni1, ni5); - assertEquals(5, ni5.getVersion()); } @Test diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java index e713b271..15297eae 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java @@ -161,6 +161,11 @@ public Optional getNodeInfo() { /** * Combine the per-family results into a single transport-agnostic {@link NodeInfo}, taking the IPv4 * address from {@code n4} and the IPv6 address from {@code n6}. Returns {@code null} if both are null. + *

+ * The presence of each address records which family answered: on the returned node, + * {@link NodeInfo#hasAddress4()}/{@link NodeInfo#hasAddress6()} are true only for the families that + * contributed a result. A dual-stack node that responded on only one family therefore yields a + * single-address {@link NodeInfo}. */ private static @Nullable NodeInfo mergeNodeInfo(Id id, @Nullable NodeInfo n4, @Nullable NodeInfo n6) { if (n4 == null && n6 == null) @@ -171,6 +176,36 @@ public Optional getNodeInfo() { n6 != null ? n6.getAddress6() : null); } + /** + * Normalize a lookup result to a plain {@link NodeInfo} before it crosses the public API boundary, + * so internal mutable subtypes ({@code KBucketEntry}, {@code CandidateNode}) are never handed to + * callers. A plain {@code NodeInfo} (immutable) is returned as-is. + */ + private static @Nullable NodeInfo toPublicNodeInfo(@Nullable NodeInfo n) { + return (n == null || n.getClass() == NodeInfo.class) ? n : + NodeInfo.of(n.getId(), n.getAddress4(), n.getAddress6()); + } + + /** + * Log a warning when exactly one address family's DHT lookup failed while the other succeeded. The + * lookup as a whole still succeeds with a partial result, but a persistent single-family outage + * (e.g. the IPv6 path is down) is worth surfacing operationally. Both futures must be settled, so + * this is only meaningful after a {@code Future.join}. + * + * @param operation the lookup name, for the log message. + * @param target the lookup target, for the log message. + * @param future4 the settled IPv4 lookup future. + * @param future6 the settled IPv6 lookup future. + */ + private static void logPartialFailure(String operation, Object target, Future future4, Future future6) { + if (future4.failed() && future6.succeeded()) + log.warn("{} {}: IPv4 DHT lookup failed but IPv6 succeeded; returning partial result", + operation, target, future4.cause()); + else if (future6.failed() && future4.succeeded()) + log.warn("{} {}: IPv6 DHT lookup failed but IPv4 succeeded; returning partial result", + operation, target, future6.cause()); + } + @Override public String getVersion() { return NAME + "/" + VERSION_NUMBER; @@ -417,11 +452,14 @@ public ContextualFuture> findNode(Id id, @Nullable LookupOpti * at least one family succeeds, and only fails when both families fail. Used for CONSERVATIVE * lookups that accumulate their results as a side effect. */ - private static Future joinTolerant(Future future4, Future future6) { + private static Future joinTolerant(String operation, Object target, Future future4, Future future6) { // Future.join waits for both to complete; afterwards each original future is settled and can be // inspected directly. Succeed if at least one succeeded; fail only if both failed. - return Future.join(future4, future6).transform(ar -> (future4.succeeded() || future6.succeeded()) ? - Future.succeededFuture() : Future.failedFuture(future4.cause())); + return Future.join(future4, future6).transform(ar -> { + logPartialFailure(operation, target, future4, future6); + return (future4.succeeded() || future6.succeeded()) ? + Future.succeededFuture() : Future.failedFuture(future4.cause()); + }); } private Future> doFindNode(Id id, LookupOption option) { @@ -430,7 +468,7 @@ private Future> doFindNode(Id id, LookupOption option) { if (dht4 == null || dht6 == null) { DHT dht = dht4 != null ? dht4 : dht6; - return dht.findNode(id, option).map(Optional::ofNullable); + return dht.findNode(id, option).map(n -> Optional.ofNullable(toPublicNodeInfo(n))); } else { Future<@Nullable NodeInfo> future4 = dht4.findNode(id, option); Future<@Nullable NodeInfo> future6 = dht6.findNode(id, option); @@ -441,6 +479,7 @@ private Future> doFindNode(Id id, LookupOption option) { if (!future4.succeeded() && !future6.succeeded()) return Future.failedFuture(future4.cause()); + logPartialFailure("findNode", id, future4, future6); NodeInfo n4 = future4.succeeded() ? future4.result() : null; NodeInfo n6 = future6.succeeded() ? future6.result() : null; return Future.succeededFuture(Optional.ofNullable(mergeNodeInfo(id, n4, n6))); @@ -523,7 +562,7 @@ private Future doFindValue(Id id, int expectedSequenceNumber, LookupOption }); if (option == LookupOption.CONSERVATIVE) - return joinTolerant(future4, future6); + return joinTolerant("findValue", id, future4, future6); return Future.any(future4, future6).compose(cf -> { if (future4.isComplete() && result.isEmpty()) @@ -664,7 +703,7 @@ private Future doFindPeer(Id id, int expectedSequenceNumber, int expectedC }); if (option == LookupOption.CONSERVATIVE) - return joinTolerant(future4, future6); + return joinTolerant("findPeer", id, future4, future6); return Future.any(future4, future6).compose(cf -> { if (future4.isComplete() && !result.reachedCapacity()) diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java b/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java index 629bd79f..97d58038 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/impl/DHT.java @@ -550,8 +550,7 @@ private Collection addBootstrapNodes(Collection nodes) { } // Ensure bootstrap nodes only use a single address compatible with this DHT's network family. - NodeInfo bootstrapNode = node.hasMultiAddresses() ? - NodeInfo.of(node.getId(), Objects.requireNonNull(node.getAddress(network.protocolFamily()))) : node; + NodeInfo bootstrapNode = node.narrowDown(network.protocolFamily()); dedup.put(bootstrapNode.getId(), bootstrapNode); added.put(bootstrapNode.getId(), bootstrapNode); } diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/routing/KBucketEntry.java b/dht/src/main/java/io/bosonnetwork/kademlia/routing/KBucketEntry.java index c9c2e877..07eb481d 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/routing/KBucketEntry.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/routing/KBucketEntry.java @@ -88,6 +88,9 @@ public class KBucketEntry extends NodeInfo { */ private int failedRequests; + // The peer's software version as observed on the wire. Routing metadata only; not part of NodeInfo. + private int version; + private final ExponentialWeightedMovingAverage avgRTT = new ExponentialWeightedMovingAverage(RTT_EMA_WEIGHT); /** @@ -127,6 +130,25 @@ public KBucketEntry(KBucketEntry entry) { lastSend = entry.lastSend(); reachable = entry.isReachable(); failedRequests = entry.failedRequests(); + version = entry.version; + } + + /** + * Sets the peer's software version, as observed on the wire. + * + * @param version the version number. + */ + public void setVersion(int version) { + this.version = version; + } + + /** + * Gets the peer's software version, as observed on the wire. + * + * @return the version number, or 0 if unknown. + */ + public int getVersion() { + return version; } /** diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/Task.java b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/Task.java index 8ea064d8..f9be2252 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/tasks/Task.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/tasks/Task.java @@ -28,7 +28,6 @@ import java.util.EnumSet; import java.util.HashMap; import java.util.Map; -import java.util.Objects; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -525,7 +524,7 @@ protected boolean sendCall(NodeInfo node, Message request, Consumer bef // Ensure the target node only use a single address compatible with current network family. if (node.hasMultiAddresses()) - node = NodeInfo.of(node.getId(), Objects.requireNonNull(node.getAddress(getContext().getNetwork().protocolFamily()))); + node = node.narrowDown(getContext().getNetwork().protocolFamily()); RpcCall call = new RpcCall(node, request) .addListener(this::onCallStateChange); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java index aac2a2ed..494ca75e 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java @@ -4,22 +4,31 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.File; import java.io.IOException; import java.io.PrintStream; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; import java.net.Inet4Address; import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletionException; import java.util.concurrent.TimeUnit; import java.util.function.Function; +import java.util.stream.Collectors; +import io.vertx.core.Future; import io.vertx.core.Promise; import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; @@ -36,6 +45,8 @@ import io.bosonnetwork.ConnectionStatusListener; import io.bosonnetwork.Id; +import io.bosonnetwork.LookupOption; +import io.bosonnetwork.Node; import io.bosonnetwork.NodeConfiguration; import io.bosonnetwork.NodeInfo; import io.bosonnetwork.PeerInfo; @@ -281,6 +292,73 @@ void testFindNode(VertxTestContext context) { }).toVertxFuture().onComplete(context.succeedingThenComplete()); } + /** + * Enforces the invariant that the public {@link Node} API never leaks internal {@link NodeInfo} + * subtypes ({@code KBucketEntry}, {@code CandidateNode}). Those are mutable and mutate on the DHT + * event loop, so handing one to a caller would be an encapsulation and thread-safety leak (see + * {@code KadNode.toPublicNodeInfo}). + */ + @Test + @Timeout(value = TEST_NODES, timeUnit = TimeUnit.MINUTES) + void testNodeApiDoesNotLeakNodeInfoSubtypes(VertxTestContext context) throws Exception { + // Structural tripwire: the set of NodeInfo-returning Node API methods must match this allowlist. + // If this fails, a new NodeInfo-returning method was added - make sure its result is normalized + // to a plain NodeInfo and extend the runtime checks below before updating the allowlist. + Set nodeInfoReturningMethods = Arrays.stream(Node.class.getMethods()) + .filter(m -> mentionsNodeInfo(m.getGenericReturnType())) + .map(Method::getName) + .collect(Collectors.toSet()); + assertEquals(Set.of("findNode", "getNodeInfo"), nodeInfoReturningMethods, + "Unexpected NodeInfo-returning Node API method; ensure its result is normalized to a plain NodeInfo"); + + // Runtime: actual results must be exactly NodeInfo, never an internal subtype. + var node = testNodes.get(0); + var target = testNodes.get(TEST_NODES - 1); + + // getNodeInfo() is built by merge - always a fresh, plain NodeInfo. + assertSame(NodeInfo.class, node.getNodeInfo().orElseThrow().getClass()); + + // findNode() conservative lookup - the single-stack passthrough is normalized. + Future.fromCompletionStage(node.findNode(target.getId())) + .onComplete(context.succeeding(found -> { + context.verify(() -> { + assertFalse(found.isEmpty()); + assertSame(NodeInfo.class, found.get().getClass()); + }); + })); + + // findNode(LOCAL) returns a routing-table entry (KBucketEntry) directly and must be normalized. + // After the conservative lookup the target is usually cached; assert only when present. + Future.fromCompletionStage(node.findNode(target.getId(), LookupOption.LOCAL)) + .onComplete(context.succeeding(found -> { + context.verify(() -> { + assertFalse(found.isEmpty()); + assertSame(NodeInfo.class, found.get().getClass()); + }); + context.completeNow(); + })); + } + + /** Recursively checks whether {@link NodeInfo} appears anywhere in a (possibly generic) type. */ + private static boolean mentionsNodeInfo(Type type) { + if (type instanceof Class clazz) + return clazz == NodeInfo.class; + + if (type instanceof ParameterizedType pt) { + for (Type arg : pt.getActualTypeArguments()) + if (mentionsNodeInfo(arg)) + return true; + } + + if (type instanceof WildcardType wt) { + for (Type bound : wt.getUpperBounds()) + if (mentionsNodeInfo(bound)) + return true; + } + + return false; + } + @Test @Timeout(value = TEST_NODES, timeUnit = TimeUnit.MINUTES) void testUpdateAndFindPeer(VertxTestContext context) { diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java index c1bbf7ee..b238b400 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java @@ -4,19 +4,27 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import java.io.File; import java.io.PrintStream; +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; import java.net.Inet4Address; import java.net.InetAddress; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import io.vertx.core.Vertx; import io.vertx.core.VertxOptions; @@ -30,7 +38,9 @@ import io.bosonnetwork.ConnectionStatusListener; import io.bosonnetwork.Id; import io.bosonnetwork.LookupOption; +import io.bosonnetwork.Node; import io.bosonnetwork.NodeConfiguration; +import io.bosonnetwork.NodeInfo; import io.bosonnetwork.PeerInfo; import io.bosonnetwork.Value; import io.bosonnetwork.crypto.Random; @@ -218,6 +228,63 @@ void testFindNode() throws Exception { } } + /** + * Enforces the invariant that the public {@link Node} API never leaks internal {@link NodeInfo} + * subtypes ({@code KBucketEntry}, {@code CandidateNode}). Those are mutable and mutate on the DHT + * event loop, so handing one to a caller would be an encapsulation and thread-safety leak (see + * {@code KadNode.toPublicNodeInfo}). + */ + @Test + @Timeout(value = TEST_NODES, unit = TimeUnit.MINUTES) + void testNodeApiDoesNotLeakNodeInfoSubtypes() throws Exception { + // Structural tripwire: the set of NodeInfo-returning Node API methods must match this allowlist. + // If this fails, a new NodeInfo-returning method was added - make sure its result is normalized + // to a plain NodeInfo and extend the runtime checks below before updating the allowlist. + Set nodeInfoReturningMethods = Arrays.stream(Node.class.getMethods()) + .filter(m -> mentionsNodeInfo(m.getGenericReturnType())) + .map(Method::getName) + .collect(Collectors.toSet()); + assertEquals(Set.of("findNode", "getNodeInfo"), nodeInfoReturningMethods, + "Unexpected NodeInfo-returning Node API method; ensure its result is normalized to a plain NodeInfo"); + + // Runtime: actual results must be exactly NodeInfo, never an internal subtype. + var node = testNodes.get(0); + var target = testNodes.get(TEST_NODES - 1); + + // getNodeInfo() is built by merge - always a fresh, plain NodeInfo. + assertSame(NodeInfo.class, node.getNodeInfo().orElseThrow().getClass()); + + // findNode() conservative lookup - the single-stack passthrough is normalized. + var found = node.findNode(target.getId()).get(); + assertFalse(found.isEmpty()); + assertSame(NodeInfo.class, found.get().getClass()); + + // findNode(LOCAL) returns a routing-table entry (KBucketEntry) directly and must be normalized. + // After the conservative lookup the target is usually cached; assert only when present. + node.findNode(target.getId(), LookupOption.LOCAL).get() + .ifPresent(ni -> assertSame(NodeInfo.class, ni.getClass())); + } + + /** Recursively checks whether {@link NodeInfo} appears anywhere in a (possibly generic) type. */ + private static boolean mentionsNodeInfo(Type type) { + if (type instanceof Class clazz) + return clazz == NodeInfo.class; + + if (type instanceof ParameterizedType pt) { + for (Type arg : pt.getActualTypeArguments()) + if (mentionsNodeInfo(arg)) + return true; + } + + if (type instanceof WildcardType wt) { + for (Type bound : wt.getUpperBounds()) + if (mentionsNodeInfo(bound)) + return true; + } + + return false; + } + @Test @Timeout(value = TEST_NODES, unit = TimeUnit.MINUTES) void testUpdateAndFindPeer() throws Exception { diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/security/SuspiciousNodeDetectorTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/security/SuspiciousNodeDetectorTests.java index 9c65f9d5..37d6c753 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/security/SuspiciousNodeDetectorTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/security/SuspiciousNodeDetectorTests.java @@ -187,7 +187,7 @@ public void testInconsistentAddress() throws Exception { assertFalse(detector.isBanned("192.168.100." + i)); } - Thread.sleep(BAN_DURATION); + Thread.sleep(BAN_DURATION + 2000); detector.purge(); System.out.println(detector); From a73c41ce744c58522bcb46831d109053a34e868e Mon Sep 17 00:00:00 2001 From: Jingyu Date: Wed, 24 Jun 2026 14:49:19 +0800 Subject: [PATCH 02/10] Fix random fails in NodeAsyncTests --- .../bosonnetwork/kademlia/NodeAsyncTests.java | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java index 494ca75e..64bc9082 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java @@ -180,6 +180,27 @@ private static ContextualFuture startTestNodes() { }); } + /** + * Actively converge the freshly-joined network before running lookups. Nodes join sequentially, so + * each node's bootstrap self-lookup ran against a partial network and the node was never re-announced + * once the rest joined. Now that all nodes are present, have every node perform a final self-lookup: + * each lookup announces the node to its neighborhood (the queried nodes add it to their routing + * tables) and fills the node's own buckets - Kademlia's standard convergence step - so every node + * becomes mutually findable. This targets reachability directly rather than inferring it from + * routing-table sizes; Kademlia keeps only k entries per bucket, so no node ever holds every other + * node anyway, and iterative routing - not full tables - is what makes a target findable. + */ + private static ContextualFuture awaitConvergence() { + System.out.println("\n\n\007⌛ Converging the network via self-lookups ..."); + List> lookups = new ArrayList<>(testNodes.size()); + for (var node : testNodes) + // Result is irrelevant; the lookup's side effect (announce + bucket fill) is what converges. + lookups.add((ContextualFuture) node.findNode(node.getId())); + + return ContextualFuture.allOf(lookups) + .whenComplete((v, e) -> System.out.println("\007🟢 Network converged via self-lookups")); + } + private static ContextualFuture stopTestNodes() { System.out.println("\n\n\007🟢 Stopping all the nodes ...\n"); // cannot stop all the nodes in parallel, it will cause vertx internal error. @@ -221,14 +242,16 @@ static void setup(VertxTestContext context) throws Exception { Files.createDirectories(testDir); - startBootstrap().thenCompose(v -> startTestNodes()).whenComplete((v, e) -> { - if (e == null) { - System.out.println("\n\n\007🟢 All the nodes are ready!!! starting to run the test cases"); - context.completeNow(); - } else { - context.failNow(e); - } - }); + startBootstrap().thenCompose(v -> startTestNodes()) + .thenCompose(v -> awaitConvergence()) + .whenComplete((v, e) -> { + if (e == null) { + System.out.println("\n\n\007🟢 All the nodes are ready!!! starting to run the test cases"); + context.completeNow(); + } else { + context.failNow(e); + } + }); } @AfterAll From 9bf2ea00d4c4cc7684c22bb2925a9794fad75a5d Mon Sep 17 00:00:00 2001 From: Jingyu Date: Wed, 24 Jun 2026 15:08:42 +0800 Subject: [PATCH 03/10] Fix the javadoc errors --- api/src/main/java/io/bosonnetwork/NodeInfo.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/api/src/main/java/io/bosonnetwork/NodeInfo.java b/api/src/main/java/io/bosonnetwork/NodeInfo.java index 7246c50f..cdddae5c 100644 --- a/api/src/main/java/io/bosonnetwork/NodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/NodeInfo.java @@ -82,6 +82,12 @@ private NodeInfo(Id id, @Nullable InetSocketAddress sockAddr4, @Nullable InetSoc this.defaultProtocolFamily = sockAddr4 != null ? StandardProtocolFamily.INET : StandardProtocolFamily.INET6; } + /** + * Construct a {@code NodeInfo} object from a single socket address. + * + * @param id the node id. + * @param sockAddr the node socket address, can be IPv4 or IPv6. + */ protected NodeInfo(Id id, InetSocketAddress sockAddr) { Objects.requireNonNull(id, "id"); Objects.requireNonNull(sockAddr, "sockAddr"); @@ -121,6 +127,7 @@ protected NodeInfo(NodeInfo ni) { * * @param id the node id. * @param sockAddr the node socket address, can be IPv4 or IPv6. + * @return the constructed {@code NodeInfo}. */ public static NodeInfo of(Id id, InetSocketAddress sockAddr) { return new NodeInfo(id, sockAddr); @@ -132,6 +139,7 @@ public static NodeInfo of(Id id, InetSocketAddress sockAddr) { * @param id the node id. * @param inetAddr the node IP address, can be IPv4 or IPv6. * @param port the node port number. + * @return the constructed {@code NodeInfo}. */ public static NodeInfo of(Id id, InetAddress inetAddr, int port) { Objects.requireNonNull(id, "id"); @@ -145,6 +153,7 @@ public static NodeInfo of(Id id, InetAddress inetAddr, int port) { * @param id the node id. * @param host the node host name or address string. * @param port the node port number. + * @return the constructed {@code NodeInfo}. */ public static NodeInfo of(Id id, String host, int port) { Objects.requireNonNull(id, "id"); @@ -158,6 +167,7 @@ public static NodeInfo of(Id id, String host, int port) { * @param id the node id. * @param inetAddr the node raw IP address, can be IPv4 or IPv6. * @param port the node port number. + * @return the constructed {@code NodeInfo}. */ public static NodeInfo of(Id id, byte[] inetAddr, int port) { Objects.requireNonNull(id, "id"); @@ -175,6 +185,7 @@ public static NodeInfo of(Id id, byte[] inetAddr, int port) { * @param id the node id. * @param sockAddr4 the IPv4 socket address, can be null. * @param sockAddr6 the IPv6 socket address, can be null. + * @return the constructed {@code NodeInfo}. * @throws IllegalArgumentException if both addresses are null, or if the port is invalid. */ public static NodeInfo of(Id id, @Nullable InetSocketAddress sockAddr4, @Nullable InetSocketAddress sockAddr6) { @@ -189,6 +200,7 @@ public static NodeInfo of(Id id, @Nullable InetSocketAddress sockAddr4, @Nullabl * @param port4 the IPv4 port number, ignored if {@code inetAddr4} is null. * @param inetAddr6 the IPv6 address, can be null. * @param port6 the IPv6 port number, ignored if {@code inetAddr6} is null. + * @return the constructed {@code NodeInfo}. * @throws IllegalArgumentException if both addresses are null, or if an address/port is invalid. */ public static NodeInfo of(Id id, @Nullable InetAddress inetAddr4, int port4, @Nullable InetAddress inetAddr6, int port6) { @@ -227,6 +239,7 @@ public static NodeInfo of(Id id, @Nullable InetAddress inetAddr4, int port4, @Nu * @param port4 the IPv4 port number, ignored if {@code host4} is null. * @param host6 the IPv6 host name or address string, can be null. * @param port6 the IPv6 port number, ignored if {@code host6} is null. + * @return the constructed {@code NodeInfo}. * @throws IllegalArgumentException if both hosts are null, or if an address/port is invalid. */ public static NodeInfo of(Id id, @Nullable String host4, int port4, @Nullable String host6, int port6) { @@ -265,6 +278,7 @@ public static NodeInfo of(Id id, @Nullable String host4, int port4, @Nullable St * @param port4 the IPv4 port number, ignored if {@code inetAddr4} is null. * @param inetAddr6 the raw IPv6 address bytes, can be null. * @param port6 the IPv6 port number, ignored if {@code inetAddr6} is null. + * @return the constructed {@code NodeInfo}. * @throws IllegalArgumentException if both addresses are null, or if an address/port is invalid. */ public static NodeInfo of(Id id, byte @Nullable [] inetAddr4, int port4, byte @Nullable [] inetAddr6, int port6) { From fed6871c5d5d7ebc94164009b8329d9d2aaa05b6 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Wed, 24 Jun 2026 21:44:05 +0800 Subject: [PATCH 04/10] Update SuperNodeInfo to support multiple node addresses --- .../main/java/io/bosonnetwork/NodeInfo.java | 19 +++++++ .../bosonnetwork/service/SuperNodeInfo.java | 14 ++---- .../service/impl/PlainSuperNodeInfo.java | 49 ++++++++++--------- .../service/impl/StaticFederationContext.java | 2 +- .../service/impl/PlainSuperNodeInfoTests.java | 7 +-- .../impl/StaticFederationContextTests.java | 5 +- 6 files changed, 57 insertions(+), 39 deletions(-) diff --git a/api/src/main/java/io/bosonnetwork/NodeInfo.java b/api/src/main/java/io/bosonnetwork/NodeInfo.java index cdddae5c..96c70daf 100644 --- a/api/src/main/java/io/bosonnetwork/NodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/NodeInfo.java @@ -29,6 +29,7 @@ import java.net.InetSocketAddress; import java.net.StandardProtocolFamily; import java.net.UnknownHostException; +import java.util.List; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -415,6 +416,24 @@ public StandardProtocolFamily getPreferredFamily() { return defaultProtocolFamily; } + /** + * Retrieves a list of network addresses, including both IPv4 and IPv6 addresses, if available. + * + * @return a list of InetSocketAddress objects containing the available network addresses. + * The list may include both IPv4 and IPv6 addresses, only IPv4 addresses, + * only IPv6 addresses, or be empty if no addresses are available. + */ + public List getAddresses() { + if (addr4 != null && addr6 != null) + return List.of(addr4, addr6); + else if (addr4 != null) + return List.of(addr4); + else if (addr6 != null) + return List.of(addr6); + else + return List.of(); + } + /** * Gets the socket address of the node for the {@linkplain #getPreferredFamily() preferred family}. * For a dual-stack node this is the IPv4 address; for a single-stack node it is the only available diff --git a/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java b/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java index 3e20488e..2ff39101 100644 --- a/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java @@ -22,6 +22,7 @@ package io.bosonnetwork.service; +import java.util.List; import java.util.Optional; import io.bosonnetwork.Id; @@ -41,18 +42,11 @@ public non-sealed interface SuperNodeInfo extends Principal { Id getId(); /** - * Gets the hostname or IP address of the node. + * Retrieves a list of addresses associated with the node. * - * @return the host string, never {@code null} + * @return a list of address strings; the list may be empty but will never be null */ - String getHost(); - - /** - * Gets the port number on which the node accepts connections. - * - * @return the port number - */ - int getPort(); + List getAddresses(); /** * Gets the API endpoint URL for the node. diff --git a/api/src/main/java/io/bosonnetwork/service/impl/PlainSuperNodeInfo.java b/api/src/main/java/io/bosonnetwork/service/impl/PlainSuperNodeInfo.java index f920b554..a8a8015a 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/PlainSuperNodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/PlainSuperNodeInfo.java @@ -22,6 +22,7 @@ package io.bosonnetwork.service.impl; +import java.util.List; import java.util.Objects; import java.util.Optional; @@ -40,8 +41,7 @@ */ public class PlainSuperNodeInfo implements SuperNodeInfo { private final Id id; - private final String host; - private final int port; + private final List addresses; private final String apiEndpoint; private final long ts; @@ -53,27 +53,37 @@ public class PlainSuperNodeInfo implements SuperNodeInfo { * @param port the port (must be in 1-65535) */ protected PlainSuperNodeInfo(Id nodeId, String host, int port) { - this(nodeId, host, port, null); + Objects.requireNonNull(nodeId); + if (Objects.requireNonNull(host).isEmpty()) + throw new IllegalArgumentException("Invalid host"); + if (port <= 0 || port > 65535) + throw new IllegalArgumentException("Invalid port"); + + this.id = Objects.requireNonNull(nodeId); + this.addresses = List.of(host + ":" + port); + // noinspection HttpUrlsUsage + this.apiEndpoint = "http://" + this.addresses.get(0); + this.ts = System.currentTimeMillis(); } /** - * Creates a super node info with the given attributes. + * Constructs a new PlainSuperNodeInfo instance with the specified node ID, a list of addresses, and an optional API endpoint. * - * @param nodeId the super node id - * @param host the host name or address - * @param port the port (must be in 1-65535) - * @param apiEndpoint the API endpoint; a {@code http://host:port} URL is used when null or empty - * @throws IllegalArgumentException if the port is out of range + * @param nodeId the unique identifier for the super node, must not be null. + * @param addresses the list of addresses associated with the super node, must not be empty. + * @param apiEndpoint the optional API endpoint for the super node; if null or empty, it defaults to using the first address in the list with "http://" as the prefix. + * @throws IllegalArgumentException if the addresses list is empty. + * @throws NullPointerException if the nodeId is null. */ - protected PlainSuperNodeInfo(Id nodeId, String host, int port, @Nullable String apiEndpoint) { - if (port <= 0 || port > 65535) - throw new IllegalArgumentException("Invalid port: " + port); + protected PlainSuperNodeInfo(Id nodeId, List addresses, @Nullable String apiEndpoint) { + Objects.requireNonNull(nodeId); + if (Objects.requireNonNull(addresses).isEmpty()) + throw new IllegalArgumentException("Empty addresses"); this.id = Objects.requireNonNull(nodeId); - this.host = Objects.requireNonNull(host); - this.port = port; + this.addresses = List.copyOf(addresses); // noinspection HttpUrlsUsage - this.apiEndpoint = apiEndpoint == null || apiEndpoint.isEmpty() ? "http://" + host + ":" + port : apiEndpoint; + this.apiEndpoint = apiEndpoint == null || apiEndpoint.isEmpty() ? "http://" + addresses.get(0) : apiEndpoint; this.ts = System.currentTimeMillis(); } @@ -83,13 +93,8 @@ public Id getId() { } @Override - public String getHost() { - return host; - } - - @Override - public int getPort() { - return port; + public List getAddresses() { + return addresses; } @Override diff --git a/api/src/main/java/io/bosonnetwork/service/impl/StaticFederationContext.java b/api/src/main/java/io/bosonnetwork/service/impl/StaticFederationContext.java index 1786db3f..ba54b800 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/StaticFederationContext.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/StaticFederationContext.java @@ -99,7 +99,7 @@ public boolean addNode(Id nodeId, String host, int port, @Nullable String apiEnd return false; nodeServicesRegistry.computeIfAbsent(nodeId, k -> - new SuperNodeAndServices(new PlainSuperNodeInfo(nodeId, host, port, apiEndpoint), List.of())); + new SuperNodeAndServices(new PlainSuperNodeInfo(nodeId, List.of(host + ":" + port), apiEndpoint), List.of())); return true; } diff --git a/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java b/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java index ed4b2b2d..a87ef17e 100644 --- a/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java +++ b/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java @@ -5,6 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; + import org.junit.jupiter.api.Test; import io.bosonnetwork.Id; @@ -16,8 +18,7 @@ public void testBasicProperties() { PlainSuperNodeInfo node = new PlainSuperNodeInfo(id, "127.0.0.1", 8080); assertEquals(id, node.getId()); - assertEquals("127.0.0.1", node.getHost()); - assertEquals(8080, node.getPort()); + assertEquals(List.of("127.0.0.1:8080"), node.getAddresses()); assertEquals("http://127.0.0.1:8080", node.getApiEndpoint()); assertFalse(node.getSoftware().isPresent()); assertFalse(node.getVersion().isPresent()); @@ -35,7 +36,7 @@ public void testBasicProperties() { @Test public void testCustomApiEndpoint() { Id id = Id.random(); - PlainSuperNodeInfo node = new PlainSuperNodeInfo(id, "127.0.0.1", 8080, "https://api.example.com"); + PlainSuperNodeInfo node = new PlainSuperNodeInfo(id, List.of("127.0.0.1:8080"), "https://api.example.com"); assertEquals("https://api.example.com", node.getApiEndpoint()); } diff --git a/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java b/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java index 7c53b051..125ebe26 100644 --- a/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java +++ b/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java @@ -3,7 +3,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; @@ -16,8 +15,8 @@ import io.bosonnetwork.Id; import io.bosonnetwork.Identity; import io.bosonnetwork.crypto.CryptoIdentity; -import io.bosonnetwork.service.SuperNodeInfo; import io.bosonnetwork.service.ServiceInfo; +import io.bosonnetwork.service.SuperNodeInfo; public class StaticFederationContextTests { private StaticFederationContext context; @@ -41,7 +40,7 @@ public void testAddAndGetNode() throws ExecutionException, InterruptedException SuperNodeInfo node = context.getNode(nodeId, true).get().orElseThrow(); assertNotNull(node); assertEquals(nodeId, node.getId()); - assertEquals("localhost", node.getHost()); + assertEquals(List.of("localhost:8080"), node.getAddresses()); assertTrue(context.getNode(Id.random(), true).get().isEmpty()); } From 2a311e690f66c568dc1ccd5d5bfa581b9fbca022 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Thu, 25 Jun 2026 21:47:11 +0800 Subject: [PATCH 05/10] Refactor crypto architecture: introduce CryptoProvider SPI with Bouncy Castle as the default provider --- api/pom.xml | 27 +- .../crypto/BouncyCastleCryptoProvider.java | 805 ++++++++++++++++++ .../io/bosonnetwork/crypto/CryptoBox.java | 453 +++------- .../bosonnetwork/crypto/CryptoIdentity.java | 78 +- .../bosonnetwork/crypto/CryptoProvider.java | 321 +++++++ .../bosonnetwork/crypto/CryptoProviders.java | 66 ++ .../io/bosonnetwork/crypto/PasswordHash.java | 220 ++--- .../io/bosonnetwork/crypto/Signature.java | 298 ++----- .../io.bosonnetwork.crypto.CryptoProvider | 1 + .../bosonnetwork/crypto/CryptoBoxTests.java | 10 +- .../crypto/CryptoCompatibilityTest.java | 270 ++++++ .../crypto/PasswordHashTests.java | 24 +- .../bosonnetwork/crypto/SignatureTests.java | 6 +- .../crypto/SodiumCryptoProvider.java | 439 ++++++++++ 14 files changed, 2263 insertions(+), 755 deletions(-) create mode 100644 api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java create mode 100644 api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java create mode 100644 api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java create mode 100644 api/src/main/resources/META-INF/services/io.bosonnetwork.crypto.CryptoProvider create mode 100644 api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java create mode 100644 api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java diff --git a/api/pom.xml b/api/pom.xml index a5c6631b..5fbefeae 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -102,19 +102,15 @@ true + - com.github.jnr - jnr-ffi - - - io.tmio - tuweni-crypto + org.bouncycastle + bcprov-jdk18on org.bouncycastle bcpkix-jdk18on - test @@ -163,6 +159,23 @@ test + + + com.github.jnr + jnr-ffi + test + + + io.tmio + tuweni-crypto + test + + io.vertx vertx-web-client diff --git a/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java b/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java new file mode 100644 index 00000000..52fa8505 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java @@ -0,0 +1,805 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +import static org.bouncycastle.util.Arrays.constantTimeAreEqual; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +import org.bouncycastle.crypto.digests.Blake2bDigest; +import org.bouncycastle.crypto.digests.SHA512Digest; +import org.bouncycastle.crypto.engines.XSalsa20Engine; +import org.bouncycastle.crypto.generators.Argon2BytesGenerator; +import org.bouncycastle.crypto.macs.Poly1305; +import org.bouncycastle.crypto.params.Argon2Parameters; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.params.KeyParameter; +import org.bouncycastle.crypto.params.ParametersWithIV; +import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.bouncycastle.math.ec.rfc7748.X25519; +import org.jspecify.annotations.Nullable; + +/** + * Pure-Java {@link CryptoProvider} backed by Bouncy Castle. This is the default Boson crypto + * backend; it has no native dependency and runs on the JVM and Android alike. + *

+ * Every construction is byte-for-byte compatible with libsodium. Where Bouncy Castle does not + * expose a libsodium building block directly, it is implemented here against verified test + * vectors (see the crypto compatibility test): the HSalsa20 core used by {@code crypto_box} + * key derivation, the Ed25519 to Curve25519 birational map, the NaCl secretbox layout, and the + * Argon2 PHC string format produced by {@code crypto_pwhash_str}. + */ +public class BouncyCastleCryptoProvider implements CryptoProvider { + // "expand 32-byte k" - the Salsa20/HSalsa20 sigma constant. + private static final byte[] SIGMA = "expand 32-byte k".getBytes(StandardCharsets.US_ASCII); + // Curve25519 field prime: 2^255 - 19. + private static final BigInteger P = BigInteger.TWO.pow(255).subtract(BigInteger.valueOf(19)); + + @Override + public String name() { + return "bc"; + } + + // ---- Ed25519 ---------------------------------------------------------- + + private static final class Ed25519SecretKey implements Signature.PrivateKey { + // The 32-byte seed is the authoritative material; the BC parameter object is rebuilt on + // demand so destroy() can actually wipe the secret. + private byte @Nullable [] seed; + + private Ed25519SecretKey(byte[] seed) { + this.seed = seed.clone(); + } + + private byte[] seedOrThrow() { + if (seed == null) + throw new IllegalStateException("Private key has been destroyed"); + return seed; + } + + private Ed25519PrivateKeyParameters params() { + return new Ed25519PrivateKeyParameters(seedOrThrow(), 0); + } + + @Override + public byte[] seed() { + return seedOrThrow().clone(); + } + + @Override + public byte[] bytes() { + byte[] pub = params().generatePublicKey().getEncoded(); + byte[] out = new byte[SIGN_SECRET_KEY_BYTES]; + System.arraycopy(seedOrThrow(), 0, out, 0, SIGN_SEED_BYTES); + System.arraycopy(pub, 0, out, SIGN_SEED_BYTES, SIGN_PUBLIC_KEY_BYTES); + return out; + } + + @Override + public void destroy() { + if (seed != null) { + Arrays.fill(seed, (byte) 0); + seed = null; + } + } + + @Override + public boolean isDestroyed() { + return seed == null; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof Signature.PrivateKey that) || isDestroyed() || that.isDestroyed()) + return false; + return constantTimeAreEqual(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + private static final class Ed25519PublicKey implements Signature.PublicKey { + private @Nullable Ed25519PublicKeyParameters key; + + private Ed25519PublicKey(Ed25519PublicKeyParameters key) { + this.key = key; + } + + private Ed25519PublicKeyParameters params() { + if (key == null) + throw new IllegalStateException("Public key has been destroyed"); + return key; + } + + @Override + public byte[] bytes() { + return params().getEncoded(); + } + + @Override + public void destroy() { + key = null; + } + + @Override + public boolean isDestroyed() { + return key == null; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof Signature.PublicKey that) || isDestroyed() || that.isDestroyed()) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + @Override + public Signature.PrivateKey ed25519SecretKeyFromSeed(byte[] seed) { + return new Ed25519SecretKey(seed); + } + + @Override + public Signature.PrivateKey ed25519SecretKeyFromBytes(byte[] key) { + // libsodium secret key is seed || public key; the seed is the first 32 bytes. + return new Ed25519SecretKey(Arrays.copyOfRange(key, 0, SIGN_SEED_BYTES)); + } + + private static Ed25519PrivateKeyParameters keyOf(Signature.PrivateKey secretKey) { + return secretKey instanceof Ed25519SecretKey k ? k.params() : + new Ed25519PrivateKeyParameters(secretKey.seed(), 0); + } + + private static Ed25519PublicKeyParameters keyOf(Signature.PublicKey publicKey) { + return publicKey instanceof Ed25519PublicKey k ? k.params() : + new Ed25519PublicKeyParameters(publicKey.bytes(), 0); + } + + @Override + public Signature.PublicKey ed25519PublicKeyFromSecretKey(Signature.PrivateKey secretKey) { + Ed25519PublicKeyParameters pk = keyOf(secretKey).generatePublicKey(); + return new Ed25519PublicKey(pk); + } + + @Override + public Signature.PublicKey ed25519PublicKeyFromBytes(byte[] key) { + return new Ed25519PublicKey(new Ed25519PublicKeyParameters(key, 0)); + } + + @Override + public byte[] ed25519Sign(byte[] message, Signature.PrivateKey secretKey) { + Ed25519Signer signer = new Ed25519Signer(); + signer.init(true, keyOf(secretKey)); + signer.update(message, 0, message.length); + return signer.generateSignature(); + } + + @Override + public boolean ed25519Verify(byte[] message, byte[] signature, Signature.PublicKey publicKey) { + Ed25519Signer verifier = new Ed25519Signer(); + verifier.init(false, keyOf(publicKey)); + verifier.update(message, 0, message.length); + return verifier.verifySignature(signature); + } + + // ---- crypto_kdf (keyed BLAKE2b) --------------------------------------- + + @Override + public byte[] kdfDeriveFromKey(byte[] masterKey, long subKeyId, byte[] context, int subKeyLength) { + // salt[16] = LE64(subKeyId) || zeros; personal[16] = context[0..8] || zeros + byte[] salt = new byte[16]; + for (int i = 0; i < 8; i++) + salt[i] = (byte) (subKeyId >>> (8 * i)); + byte[] personal = new byte[16]; + System.arraycopy(context, 0, personal, 0, KDF_CONTEXT_BYTES); + + Blake2bDigest digest = new Blake2bDigest(masterKey, subKeyLength, salt, personal); + byte[] out = new byte[subKeyLength]; + digest.doFinal(out, 0); // no input bytes + return out; + } + + // ---- Ed25519 -> Curve25519 conversions -------------------------------- + + @Override + public CryptoBox.PublicKey signPublicKeyToBoxPublicKey(Signature.PublicKey publicKey) { + return new BcBoxPublicKey(edPublicKeyToCurve(publicKey.bytes())); + } + + @Override + public CryptoBox.PrivateKey signSecretKeyToBoxSecretKey(Signature.PrivateKey secretKey) { + // Curve25519 secret key = clamp(SHA-512(seed)[0..32]). + byte[] h = sha512(secretKey.seed()); + byte[] sk = Arrays.copyOfRange(h, 0, BOX_SECRET_KEY_BYTES); + sk[0] &= (byte) 248; + sk[31] &= (byte) 127; + sk[31] |= (byte) 64; + return new BcBoxSecretKey(sk); + } + + // Curve25519 u = (1 + y) / (1 - y) (mod p), where y is the Edwards y-coordinate. + private static byte[] edPublicKeyToCurve(byte[] ed25519PublicKey) { + byte[] yle = ed25519PublicKey.clone(); + yle[31] &= 0x7f; // clear the x sign bit + BigInteger y = decodeLittleEndian(yle); + BigInteger oneMinusY = BigInteger.ONE.subtract(y).mod(P); + BigInteger onePlusY = BigInteger.ONE.add(y).mod(P); + BigInteger u = onePlusY.multiply(oneMinusY.modInverse(P)).mod(P); + return encodeLittleEndian(u, BOX_PUBLIC_KEY_BYTES); + } + + // ---- crypto_box ------------------------------------------------------- + + private static final class BcBoxPublicKey implements CryptoBox.PublicKey { + private byte @Nullable [] key; + + private BcBoxPublicKey(byte[] key) { + this.key = key.clone(); + } + + private byte[] keyOrThrow() { + if (key == null) + throw new IllegalStateException("Public key has been destroyed"); + return key; + } + + @Override + public byte[] bytes() { + return keyOrThrow().clone(); + } + + @Override + public void destroy() { + if (key != null) { + Arrays.fill(key, (byte) 0); + key = null; + } + } + + @Override + public boolean isDestroyed() { + return key == null; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof CryptoBox.PublicKey that) || isDestroyed() || that.isDestroyed()) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + private static final class BcBoxSecretKey implements CryptoBox.PrivateKey { + private byte @Nullable [] key; + + private BcBoxSecretKey(byte[] key) { + this.key = key.clone(); + } + + private byte[] keyOrThrow() { + if (key == null) + throw new IllegalStateException("Private key has been destroyed"); + return key; + } + + @Override + public byte[] bytes() { + return keyOrThrow().clone(); + } + + @Override + public void destroy() { + if (key != null) { + Arrays.fill(key, (byte) 0); + key = null; + } + } + + @Override + public boolean isDestroyed() { + return key == null; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof CryptoBox.PrivateKey that) || isDestroyed() || that.isDestroyed()) + return false; + return constantTimeAreEqual(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + private static final class BcBoxNonce implements CryptoBox.Nonce { + private final byte[] nonce; + + private BcBoxNonce(byte[] nonce) { + this.nonce = nonce.clone(); + } + + @Override + public CryptoBox.Nonce increment() { + byte[] next = nonce.clone(); + int c = 1; + for (int i = 0; i < next.length; i++) { + c += next[i] & 0xff; + next[i] = (byte) c; + c >>>= 8; + } + return new BcBoxNonce(next); + } + + @Override + public byte[] bytes() { + return nonce.clone(); + } + + @Override + public int hashCode() { + return Arrays.hashCode(nonce); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof CryptoBox.Nonce that)) + return false; + return Arrays.equals(nonce, that.bytes()); + } + } + + private static final class BcCryptoBox implements CryptoBox { + private byte @Nullable [] sharedKey; + + private BcCryptoBox(byte[] sharedKey) { + this.sharedKey = sharedKey; + } + + private byte[] sharedKeyOrThrow() { + if (sharedKey == null) + throw new IllegalStateException("CryptoBox has been closed"); + return sharedKey; + } + + @Override + public byte[] encrypt(byte[] message, CryptoBox.Nonce nonce) { + return secretboxSeal(message, nonceOf(nonce), sharedKeyOrThrow()); + } + + @Override + public byte[] decrypt(byte[] cipher, CryptoBox.Nonce nonce) throws CryptoException { + byte[] plain = secretboxOpen(cipher, nonceOf(nonce), sharedKeyOrThrow()); + if (plain == null) + throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); + return plain; + } + + @Override + public void close() { + destroy(); + } + + @Override + public void destroy() { + if (sharedKey != null) { + Arrays.fill(sharedKey, (byte) 0); + sharedKey = null; + } + } + + @Override + public boolean isDestroyed() { + return sharedKey == null; + } + } + + private static byte[] boxKeyOf(CryptoBox.PublicKey publicKey) { + return publicKey instanceof BcBoxPublicKey k ? k.keyOrThrow() : publicKey.bytes(); + } + + private static byte[] boxKeyOf(CryptoBox.PrivateKey secretKey) { + return secretKey instanceof BcBoxSecretKey k ? k.keyOrThrow() : secretKey.bytes(); + } + + // shared = HSalsa20(X25519(sk, pk), nonce=0^16, sigma) + private static byte[] sharedKey(byte[] boxPublicKey, byte[] boxSecretKey) { + byte[] s = new byte[BOX_SHARED_KEY_BYTES]; + X25519.calculateAgreement(boxSecretKey, 0, boxPublicKey, 0, s, 0); + return hsalsa20(s, new byte[16], SIGMA); + } + + @Override + public CryptoBox.PublicKey boxPublicKeyFromBytes(byte[] bytes) { + return new BcBoxPublicKey(bytes); + } + + @Override + public CryptoBox.PrivateKey boxSecretKeyFromSeed(byte[] seed) { + // crypto_box_seed_keypair: secret key = SHA-512(seed)[0..32] + byte[] sk = Arrays.copyOfRange(sha512(seed), 0, BOX_SEED_BYTES); + return new BcBoxSecretKey(sk); + } + + @Override + public CryptoBox.PrivateKey boxSecretKeyFromBytes(byte[] bytes) { + return new BcBoxSecretKey(bytes); + } + + @Override + public CryptoBox.PublicKey boxPublicKeyFromSecretKey(CryptoBox.PrivateKey secretKey) { + byte[] pk = new byte[BOX_PUBLIC_KEY_BYTES]; + X25519.scalarMultBase(boxKeyOf(secretKey), 0, pk, 0); + return new BcBoxPublicKey(pk); + } + + @Override + public CryptoBox.Nonce boxNonceFromBytes(byte[] bytes) { + return new BcBoxNonce(bytes); + } + + @Override + public CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return new BcCryptoBox(sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey))); + } + + private static byte[] nonceOf(CryptoBox.Nonce nonce) { + return nonce instanceof BcBoxNonce n ? n.nonce : nonce.bytes(); + } + + @Override + public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return secretboxSeal(message, nonceOf(nonce), sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey))); + } + + @Override + public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return secretboxOpen(cipher, nonceOf(nonce), sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey))); + } + + @Override + public byte[] boxSeal(byte[] message, CryptoBox.PublicKey publicKey) { + byte[] recipientPk = boxKeyOf(publicKey); + byte[] esk = Random.randomBytesSecure(BOX_SECRET_KEY_BYTES); + byte[] epk = new byte[BOX_PUBLIC_KEY_BYTES]; + X25519.scalarMultBase(esk, 0, epk, 0); + byte[] nonce = sealNonce(epk, recipientPk); + byte[] cipher = secretboxSeal(message, nonce, sharedKey(recipientPk, esk)); + + byte[] out = new byte[BOX_PUBLIC_KEY_BYTES + cipher.length]; + System.arraycopy(epk, 0, out, 0, BOX_PUBLIC_KEY_BYTES); + System.arraycopy(cipher, 0, out, BOX_PUBLIC_KEY_BYTES, cipher.length); + return out; + } + + @Override + public byte @Nullable [] boxSealOpen(byte[] cipher, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + if (cipher.length < BOX_PUBLIC_KEY_BYTES + BOX_MAC_BYTES) + return null; + + byte[] epk = Arrays.copyOfRange(cipher, 0, BOX_PUBLIC_KEY_BYTES); + byte[] nonce = sealNonce(epk, boxKeyOf(publicKey)); + byte[] boxed = Arrays.copyOfRange(cipher, BOX_PUBLIC_KEY_BYTES, cipher.length); + return secretboxOpen(boxed, nonce, sharedKey(epk, boxKeyOf(secretKey))); + } + + // crypto_box_seal nonce = BLAKE2b-192(ephemeralPublicKey || recipientPublicKey) + private static byte[] sealNonce(byte[] ephemeralPublicKey, byte[] recipientPublicKey) { + Blake2bDigest digest = new Blake2bDigest(BOX_NONCE_BYTES * 8); // bit length + digest.update(ephemeralPublicKey, 0, ephemeralPublicKey.length); + digest.update(recipientPublicKey, 0, recipientPublicKey.length); + byte[] nonce = new byte[BOX_NONCE_BYTES]; + digest.doFinal(nonce, 0); + return nonce; + } + + // ---- crypto_secretbox: XSalsa20-Poly1305 (NaCl easy layout) ----------- + + private static byte[] secretboxSeal(byte[] message, byte[] nonce, byte[] key) { + XSalsa20Engine cipher = new XSalsa20Engine(); + cipher.init(true, new ParametersWithIV(new KeyParameter(key), nonce)); + + byte[] subkey = new byte[32]; + cipher.processBytes(new byte[32], 0, 32, subkey, 0); // first 32 keystream bytes -> Poly1305 key + + byte[] out = new byte[BOX_MAC_BYTES + message.length]; + cipher.processBytes(message, 0, message.length, out, BOX_MAC_BYTES); + + Poly1305 mac = new Poly1305(); + mac.init(new KeyParameter(subkey)); + mac.update(out, BOX_MAC_BYTES, message.length); + mac.doFinal(out, 0); + return out; + } + + private static byte @Nullable [] secretboxOpen(byte[] boxed, byte[] nonce, byte[] key) { + if (boxed.length < BOX_MAC_BYTES) + return null; + int clen = boxed.length - BOX_MAC_BYTES; + + XSalsa20Engine cipher = new XSalsa20Engine(); + cipher.init(true, new ParametersWithIV(new KeyParameter(key), nonce)); + + byte[] subkey = new byte[32]; + cipher.processBytes(new byte[32], 0, 32, subkey, 0); + + Poly1305 mac = new Poly1305(); + mac.init(new KeyParameter(subkey)); + mac.update(boxed, BOX_MAC_BYTES, clen); + byte[] tag = new byte[BOX_MAC_BYTES]; + mac.doFinal(tag, 0); + + if (!constantTimeAreEqual(BOX_MAC_BYTES, tag, 0, boxed, 0)) + return null; + + byte[] message = new byte[clen]; + cipher.processBytes(boxed, BOX_MAC_BYTES, clen, message, 0); + return message; + } + + // ---- HSalsa20 core (crypto_core_hsalsa20) ----------------------------- + // Salsa20 core run for 20 rounds, emitting the constant/input diagonal words without the + // final feed-forward add. Used by crypto_box to derive the shared key from the X25519 output. + + @SuppressWarnings("SameParameterValue") + private static byte[] hsalsa20(byte[] key, byte[] in, byte[] c) { + int x0 = load(c, 0), x5 = load(c, 4), x10 = load(c, 8), x15 = load(c, 12); + int x1 = load(key, 0), x2 = load(key, 4), x3 = load(key, 8), x4 = load(key, 12); + int x11 = load(key, 16), x12 = load(key, 20), x13 = load(key, 24), x14 = load(key, 28); + int x6 = load(in, 0), x7 = load(in, 4), x8 = load(in, 8), x9 = load(in, 12); + + for (int i = 0; i < 10; i++) { + x4 ^= Integer.rotateLeft(x0 + x12, 7); + x8 ^= Integer.rotateLeft(x4 + x0, 9); + x12 ^= Integer.rotateLeft(x8 + x4, 13); + x0 ^= Integer.rotateLeft(x12 + x8, 18); + x9 ^= Integer.rotateLeft(x5 + x1, 7); + x13 ^= Integer.rotateLeft(x9 + x5, 9); + x1 ^= Integer.rotateLeft(x13 + x9, 13); + x5 ^= Integer.rotateLeft(x1 + x13, 18); + x14 ^= Integer.rotateLeft(x10 + x6, 7); + x2 ^= Integer.rotateLeft(x14 + x10, 9); + x6 ^= Integer.rotateLeft(x2 + x14, 13); + x10 ^= Integer.rotateLeft(x6 + x2, 18); + x3 ^= Integer.rotateLeft(x15 + x11, 7); + x7 ^= Integer.rotateLeft(x3 + x15, 9); + x11 ^= Integer.rotateLeft(x7 + x3, 13); + x15 ^= Integer.rotateLeft(x11 + x7, 18); + + x1 ^= Integer.rotateLeft(x0 + x3, 7); + x2 ^= Integer.rotateLeft(x1 + x0, 9); + x3 ^= Integer.rotateLeft(x2 + x1, 13); + x0 ^= Integer.rotateLeft(x3 + x2, 18); + x6 ^= Integer.rotateLeft(x5 + x4, 7); + x7 ^= Integer.rotateLeft(x6 + x5, 9); + x4 ^= Integer.rotateLeft(x7 + x6, 13); + x5 ^= Integer.rotateLeft(x4 + x7, 18); + x11 ^= Integer.rotateLeft(x10 + x9, 7); + x8 ^= Integer.rotateLeft(x11 + x10, 9); + x9 ^= Integer.rotateLeft(x8 + x11, 13); + x10 ^= Integer.rotateLeft(x9 + x8, 18); + x12 ^= Integer.rotateLeft(x15 + x14, 7); + x13 ^= Integer.rotateLeft(x12 + x15, 9); + x14 ^= Integer.rotateLeft(x13 + x12, 13); + x15 ^= Integer.rotateLeft(x14 + x13, 18); + } + + byte[] out = new byte[32]; + store(out, 0, x0); + store(out, 4, x5); + store(out, 8, x10); + store(out, 12, x15); + store(out, 16, x6); + store(out, 20, x7); + store(out, 24, x8); + store(out, 28, x9); + return out; + } + + private static int load(byte[] b, int off) { + return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8) + | ((b[off + 2] & 0xff) << 16) | ((b[off + 3] & 0xff) << 24); + } + + private static void store(byte[] b, int off, int v) { + b[off] = (byte) v; + b[off + 1] = (byte) (v >>> 8); + b[off + 2] = (byte) (v >>> 16); + b[off + 3] = (byte) (v >>> 24); + } + + // ---- crypto_pwhash (Argon2) ------------------------------------------- + + @Override + public byte[] pwHash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, int algorithm) { + return argon2(password, salt, length, opsLimit, memLimit, algorithm); + } + + @Override + public String pwHashString(byte[] password, long opsLimit, long memLimit, int algorithm) { + byte[] salt = Random.randomBytesSecure(PWHASH_SALT_BYTES); + int memKiB = (int) (memLimit / 1024); + int ops = (int) opsLimit; + byte[] hash = argon2(password, salt, 32, opsLimit, memLimit, algorithm); + + Base64.Encoder b64 = Base64.getEncoder().withoutPadding(); + return "$" + argon2Name(algorithm) + "$v=19$m=" + memKiB + ",t=" + ops + ",p=1$" + + b64.encodeToString(salt) + "$" + b64.encodeToString(hash); + } + + @Override + public boolean pwHashVerify(String hash, byte[] password) { + Phc phc = Phc.parse(hash); + if (phc == null) + return false; + byte[] expected = phc.hash; + byte[] actual = argon2(password, phc.salt, expected.length, phc.t, + (long) phc.m * 1024L, phc.algorithm); + return constantTimeAreEqual(actual, expected); + } + + @Override + public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) { + Phc phc = Phc.parse(hash); + if (phc == null) + return true; + int memKiB = (int) (memLimit / 1024); + return phc.algorithm != PWHASH_ALG_ARGON2ID13 || phc.t != opsLimit || phc.m != memKiB || phc.p != 1; + } + + private static byte[] argon2(byte[] password, byte[] salt, int length, long opsLimit, long memLimit, int algorithm) { + int type = algorithm == PWHASH_ALG_ARGON2I13 ? Argon2Parameters.ARGON2_i : Argon2Parameters.ARGON2_id; + Argon2Parameters params = new Argon2Parameters.Builder(type) + .withVersion(Argon2Parameters.ARGON2_VERSION_13) + .withIterations((int) opsLimit) + .withMemoryAsKB((int) (memLimit / 1024)) + .withParallelism(1) + .withSalt(salt) + .build(); + Argon2BytesGenerator generator = new Argon2BytesGenerator(); + generator.init(params); + byte[] out = new byte[length]; + generator.generateBytes(password, out); + return out; + } + + private static String argon2Name(int algorithm) { + return algorithm == PWHASH_ALG_ARGON2I13 ? "argon2i" : "argon2id"; + } + + // Minimal Argon2 PHC string parser: $argon2id$v=19$m=..,t=..,p=..$$ + private static final class Phc { + final int algorithm; + final int m; + final long t; + final int p; + final byte[] salt; + final byte[] hash; + + private Phc(int algorithm, int m, long t, int p, byte[] salt, byte[] hash) { + this.algorithm = algorithm; + this.m = m; + this.t = t; + this.p = p; + this.salt = salt; + this.hash = hash; + } + + static @Nullable Phc parse(String s) { + try { + // Leading '$' produces an empty first token. + String[] parts = s.split("\\$"); + if (parts.length < 5) + return null; + + int algorithm; + if ("argon2id".equals(parts[1])) + algorithm = PWHASH_ALG_ARGON2ID13; + else if ("argon2i".equals(parts[1])) + algorithm = PWHASH_ALG_ARGON2I13; + else + return null; + + int idx = 2; + if (parts[idx].startsWith("v=")) + idx++; // skip optional version segment + + int m = 0, p = 0; + long t = 0; + for (String kv : parts[idx].split(",")) { + if (kv.startsWith("m=")) + m = Integer.parseInt(kv.substring(2)); + else if (kv.startsWith("t=")) + t = Long.parseLong(kv.substring(2)); + else if (kv.startsWith("p=")) + p = Integer.parseInt(kv.substring(2)); + } + idx++; + + byte[] salt = Base64.getDecoder().decode(parts[idx++]); + byte[] hash = Base64.getDecoder().decode(parts[idx]); + return new Phc(algorithm, m, t, p, salt, hash); + } catch (RuntimeException e) { + return null; + } + } + } + + // ---- small helpers ---------------------------------------------------- + + private static BigInteger decodeLittleEndian(byte[] le) { + byte[] be = new byte[le.length]; + for (int i = 0; i < le.length; i++) + be[i] = le[le.length - 1 - i]; + return new BigInteger(1, be); + } + + @SuppressWarnings("SameParameterValue") + private static byte[] encodeLittleEndian(BigInteger value, int length) { + byte[] be = value.toByteArray(); + byte[] le = new byte[length]; + // be may have a leading zero sign byte or be shorter than length + for (int i = 0; i < be.length; i++) { + int pos = be.length - 1 - i; + if (i < length) + le[i] = be[pos]; + } + return le; + } + + private static byte[] sha512(byte[] data) { + SHA512Digest digest = new SHA512Digest(); + byte[] hashBytes = new byte[digest.getDigestSize()]; + digest.update(data, 0, data.length); + digest.doFinal(hashBytes, 0); + return hashBytes; + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java index 5d9df746..48ef9f01 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java @@ -23,69 +23,55 @@ package io.bosonnetwork.crypto; -import java.util.Arrays; import java.util.Objects; - import javax.security.auth.Destroyable; -import org.apache.tuweni.crypto.sodium.Box; -import org.apache.tuweni.crypto.sodium.Box.Seed; -import org.apache.tuweni.crypto.sodium.Sodium; -import org.jspecify.annotations.Nullable; - /** * Public-key(Curve 25519) authenticated encryption. + *

+ * A {@code CryptoBox} instance is a precomputed shared key for a sender/receiver pair + * (libsodium {@code crypto_box_beforenm}); its {@link #encrypt} / {@link #decrypt} methods are + * the per-message {@code afternm} operations. Keys are provider-specific objects produced and + * consumed by the active {@link CryptoProvider}; callers obtain them through the static + * factories and treat them as opaque handles. */ -public class CryptoBox implements AutoCloseable, Destroyable { +public interface CryptoBox extends AutoCloseable, Destroyable { /** * The Message Authentication Code size of the encrypted data in bytes. */ - public static final int MAC_BYTES = 16; - - private final Box box; - private boolean destroyed = false; + int MAC_BYTES = CryptoProvider.BOX_MAC_BYTES; /** * The crypto box public key object. */ - public static class PublicKey implements Destroyable { + interface PublicKey extends Destroyable { /** * The number of bytes used to represent a public key. */ - public static final int BYTES = Box.PublicKey.length(); - - private final Box.PublicKey key; - private byte @Nullable [] bytes; - - private PublicKey(Box.PublicKey key) { - this.key = key; - } + int BYTES = CryptoProvider.BOX_PUBLIC_KEY_BYTES; /** * Create a {@link PublicKey} from an array of bytes. - * The byte array must be of length {@link #BYTES}. * * @param key the bytes for the public key. * @return the public key object. + * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long. */ - public static PublicKey fromBytes(byte[] key) { - // no SodiumException raised - return new PublicKey(Box.PublicKey.fromBytes(key)); + static PublicKey fromBytes(byte[] key) { + if (Objects.requireNonNull(key, "key").length != BYTES) + throw new IllegalArgumentException("Invalid public key size: expected " + BYTES + " bytes, got " + key.length); + + return provider().boxPublicKeyFromBytes(key); } /** - * Transforms the Ed25519 signature public key to a Curve25519 public key. See - * Libsodium documentation + * Transforms the Ed25519 signature public key to a Curve25519 public key. * * @param key the signature public key. * @return the public key as a Curve25519 public key. */ - public static PublicKey fromSignatureKey(Signature.PublicKey key) { - return new PublicKey(Box.PublicKey.forSignaturePublicKey(key.raw())); - } - - Box.PublicKey raw() { - return key; + static PublicKey fromSignatureKey(Signature.PublicKey key) { + return provider().signPublicKeyToBoxPublicKey(Objects.requireNonNull(key)); } /** @@ -93,99 +79,60 @@ Box.PublicKey raw() { * * @return the raw bytes of this key. */ - public byte[] bytes() { - if (bytes == null) - bytes = key.bytesArray(); - - return bytes.clone(); - } + byte[] bytes(); @Override - public boolean equals(Object obj) { - if (obj == this) - return true; - - if (obj instanceof PublicKey that) - return key.equals(that.key); - - return false; - } + void destroy(); @Override - public int hashCode() { - return 0x6030A + key.hashCode(); - } - - /** - * Destroy this {@code PublicKey}. - * Sensitive information associated with this {@code PublicKey} - * is destroyed or cleared. - */ - @Override - public void destroy() { - if (!key.isDestroyed()) { - key.destroy(); - - if (bytes != null) { - Arrays.fill(bytes, (byte)0); - bytes = null; - } - } - } - - /** - * Determine if this {@code PublicKey} has been destroyed. - * - * @return true if this {@code PublicKey} has been destroyed, - * false otherwise. - */ - @Override - public boolean isDestroyed() { - return key.isDestroyed(); - } + boolean isDestroyed(); } /** * The crypto box private key object. */ - public static class PrivateKey implements Destroyable { + interface PrivateKey extends Destroyable { /** - * The number of bytes used to represent a public key. + * The number of bytes used to represent a private key. */ - public static final int BYTES = Box.SecretKey.length(); + int BYTES = CryptoProvider.BOX_SECRET_KEY_BYTES; - private final Box.SecretKey key; - private byte @Nullable [] bytes; + /** + * Generate a {@link PrivateKey} from a seed (libsodium {@code crypto_box_seed_keypair}). + * + * @param seed the {@link KeyPair#SEED_BYTES}-byte seed. + * @return the private key. + * @throws IllegalArgumentException if {@code seed} is not {@link KeyPair#SEED_BYTES} bytes long. + */ + static PrivateKey fromSeed(byte[] seed) { + if (Objects.requireNonNull(seed, "seed").length != KeyPair.SEED_BYTES) + throw new IllegalArgumentException("Invalid seed size: expected " + KeyPair.SEED_BYTES + " bytes, got " + seed.length); - private PrivateKey(Box.SecretKey key) { - this.key = key; + return provider().boxSecretKeyFromSeed(seed); } /** * Create a {@link PrivateKey} from an array of bytes. - * The byte array must be of length {@link #BYTES}. * * @param key the bytes for the private key. * @return the private key. + * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long. */ - public static PrivateKey fromBytes(byte[] key) { - // no SodiumException raised - return new PrivateKey(Box.SecretKey.fromBytes(key)); + static PrivateKey fromBytes(byte[] key) { + if (Objects.requireNonNull(key, "key").length != BYTES) + throw new IllegalArgumentException("Invalid private key size: expected " + BYTES + " bytes, got " + key.length); + + return provider().boxSecretKeyFromBytes(key); } /** - * Transforms the Ed25519 private key to a Curve25519 private key. See - * Libsodium documentation + * Transforms the Ed25519 private key to a Curve25519 private key. * * @param key the signature secret key * @return the secret key as a Curve25519 private key */ - public static PrivateKey fromSignatureKey(Signature.PrivateKey key) { - return new PrivateKey(Box.SecretKey.forSignatureSecretKey(key.raw())); - } - - Box.SecretKey raw() { - return key; + static PrivateKey fromSignatureKey(Signature.PrivateKey key) { + return provider().signSecretKeyToBoxSecretKey(Objects.requireNonNull(key)); } /** @@ -193,74 +140,30 @@ Box.SecretKey raw() { * * @return the raw bytes of this secret key. */ - public byte[] bytes() { - if (bytes == null) - bytes = key.bytesArray(); - - return bytes.clone(); - } - - @Override - public boolean equals(Object obj) { - if (obj == this) - return true; - - if (obj instanceof PrivateKey that) - return key.equals(that.key); + byte[] bytes(); - return false; - } - - @Override - public int hashCode() { - return 0x6030A + key.hashCode(); - } - - /** - * Destroy this {@code PrivateKey}. - * Sensitive information associated with this {@code PrivateKey} - * is destroyed or cleared. - */ @Override - public void destroy() { - if (!key.isDestroyed()) { - key.destroy(); - - if (bytes != null) { - Arrays.fill(bytes, (byte)0); - bytes = null; - } - } - } + void destroy(); - /** - * Determine if this {@code PrivateKey} has been destroyed. - * - * @return true if this {@code PrivateKey} has been destroyed, - * false otherwise. - */ @Override - public boolean isDestroyed() { - return key.isDestroyed(); - } + boolean isDestroyed(); } /** * The crypto box key pair. */ - public static class KeyPair implements Destroyable { + class KeyPair implements Destroyable { /** * The seed length in bytes. */ - public static final int SEED_BYTES = Seed.length(); + public static final int SEED_BYTES = CryptoProvider.BOX_SEED_BYTES; - private final Box.KeyPair keyPair; - private @Nullable PublicKey pk; - private @Nullable PrivateKey sk; - private boolean destroyed = false; + private final PublicKey pk; + private final PrivateKey sk; - private KeyPair(Box.KeyPair keyPair) { - this.keyPair = keyPair; + private KeyPair(PrivateKey sk) { + this.sk = sk; + this.pk = provider().boxPublicKeyFromSecretKey(sk); } /** @@ -270,9 +173,7 @@ private KeyPair(Box.KeyPair keyPair) { * @return the key pair object. */ public static KeyPair fromPrivateKey(byte[] privateKey) { - Box.SecretKey sk = Box.SecretKey.fromBytes(privateKey); - // Normally, should never raise Exception - return new KeyPair(Box.KeyPair.forSecretKey(sk)); + return new KeyPair(PrivateKey.fromBytes(privateKey)); } /** @@ -282,35 +183,31 @@ public static KeyPair fromPrivateKey(byte[] privateKey) { * @return the key pair object. */ public static KeyPair fromPrivateKey(PrivateKey key) { - // Normally, should never raise Exception - return new KeyPair(Box.KeyPair.forSecretKey(key.raw())); + return new KeyPair(key); } /** - * Generate a new key pair using a seed. - * The seed must be of length {@link #SEED_BYTES}. + * Generate a new key pair using a seed (libsodium {@code crypto_box_seed_keypair}). * - * @param seed the seed bytes. + * @param seed the {@link #SEED_BYTES}-byte seed. * @return the new generated key pair. + * @throws IllegalArgumentException if {@code seed} is not {@link #SEED_BYTES} bytes long. */ public static KeyPair fromSeed(byte[] seed) { - Box.Seed sd = Box.Seed.fromBytes(seed); - // Normally, should never raise Exception - return new KeyPair(Box.KeyPair.fromSeed(sd)); + if (Objects.requireNonNull(seed, "seed").length != SEED_BYTES) + throw new IllegalArgumentException("Invalid seed size: expected " + SEED_BYTES + " bytes, got " + seed.length); + + return new KeyPair(PrivateKey.fromSeed(seed)); } /** - * Converts signature key pair (Ed25519) to a box key pair (Curve25519) - * so that the same key pair can be used both for authenticated encryption - * and for signatures. See - * Libsodium documentation + * Converts a signature key pair (Ed25519) to a box key pair (Curve25519). * - * @param keyPair A {@link Signature.KeyPair}. + * @param keyPair a {@link Signature.KeyPair}. * @return the new generated box key pair. */ - public static KeyPair fromSignatureKeyPair(Signature.KeyPair keyPair) { - // Normally, should never raise Exception - return new KeyPair(Box.KeyPair.forSignatureKeyPair(keyPair.raw())); + public static KeyPair fromSignatureKeyPair(Signature.KeyPair keyPair) { + return new KeyPair(PrivateKey.fromSignatureKey(keyPair.privateKey())); } /** @@ -319,12 +216,7 @@ public static KeyPair fromSignatureKeyPair(Signature.KeyPair keyPair) { * @return a randomly generated key pair. */ public static KeyPair random() { - // Normally, should never raise Exception - return new KeyPair(Box.KeyPair.random()); - } - - Box.KeyPair raw() { - return keyPair; + return new KeyPair(PrivateKey.fromBytes(Random.randomBytesSecure(SEED_BYTES))); } /** @@ -333,9 +225,6 @@ Box.KeyPair raw() { * @return the public key of the key pair. */ public PublicKey publicKey() { - if (pk == null) - pk = new PublicKey(keyPair.publicKey()); - return pk; } @@ -345,9 +234,6 @@ public PublicKey publicKey() { * @return the private key of the key pair. */ public PrivateKey privateKey() { - if (sk == null) - sk = new PrivateKey(keyPair.secretKey()); - return sk; } @@ -357,14 +243,14 @@ public boolean equals(Object obj) { return true; if (obj instanceof KeyPair that) - return keyPair.equals(that.keyPair); + return sk.equals(that.sk) && pk.equals(that.pk); return false; } @Override public int hashCode() { - return 0x6030A + keyPair.hashCode(); + return Objects.hash(sk, pk); } /** @@ -372,11 +258,8 @@ public int hashCode() { */ @Override public void destroy() { - if (!destroyed) { - publicKey().destroy(); - privateKey().destroy(); - destroyed = true; - } + pk.destroy(); + sk.destroy(); } /** @@ -386,35 +269,31 @@ public void destroy() { */ @Override public boolean isDestroyed() { - return destroyed; + return sk.isDestroyed(); } } /** * The nonce object for the crypto box encryption. */ - public static class Nonce { + interface Nonce { /** - * The number of bytes used to represent a public key. + * The number of bytes used to represent a nonce. */ - public static final int BYTES = Box.Nonce.length(); - - private final Box.Nonce nonce; - private byte @Nullable [] bytes; - - private Nonce(Box.Nonce nonce) { - this.nonce = nonce; - } + public static final int BYTES = CryptoProvider.BOX_NONCE_BYTES; /** * Create a Nonce object from an array of bytes. - * The byte array must be of length {@link #BYTES}. * * @param nonce the bytes for the nonce. * @return a nonce object based on these bytes. + * @throws IllegalArgumentException if {@code nonce} is not {@link #BYTES} bytes long. */ - public static Nonce fromBytes(byte[] nonce) { - return new Nonce(Box.Nonce.fromBytes(nonce)); + static Nonce fromBytes(byte[] nonce) { + if (Objects.requireNonNull(nonce, "nonce").length != BYTES) + throw new IllegalArgumentException("Invalid nonce size: expected " + BYTES + " bytes, got " + nonce.length); + + return provider().boxNonceFromBytes(nonce); } /** @@ -422,8 +301,8 @@ public static Nonce fromBytes(byte[] nonce) { * * @return a randomly generated nonce. */ - public static Nonce random() { - return new Nonce(Box.Nonce.random()); + static Nonce random() { + return provider().boxNonceFromBytes(Random.randomBytesSecure(BYTES)); } /** @@ -431,198 +310,138 @@ public static Nonce random() { * * @return a zero nonce object. */ - public static Nonce zero() { - return new Nonce(Box.Nonce.zero()); + static Nonce zero() { + return provider().boxNonceFromBytes(new byte[BYTES]); } - Box.Nonce raw() { - return nonce; - } /** * Increment this nonce. * *

- * Note that this is not synchronized. If multiple threads are creating - * encrypted messages and incrementing this nonce, then external synchronization - * is required to ensure no two encrypt operations use the same nonce. + * The nonce is treated as a little-endian integer and incremented by one, matching + * libsodium's {@code sodium_increment}. * * @return A new nonce object. */ - public Nonce increment() { - return new Nonce(nonce.increment()); - } + Nonce increment(); /** * Provides the bytes of this nonce object. * * @return The bytes of this nonce. */ - public byte[] bytes() { - if (bytes == null) - bytes = nonce.bytesArray(); - - return bytes.clone(); - } - - @Override - public boolean equals(Object obj) { - if (obj == this) - return true; - - if (obj instanceof Nonce that) - return nonce.equals(that.nonce); - - return false; - } - - @Override - public int hashCode() { - return 0x6030A + nonce.hashCode(); - } - } - - private CryptoBox(Box box) { - this.box = box; + byte[] bytes(); } /** * Precompute the shared key for a given sender and receiver. * *

- * Note that the returned instance of CryptoBox should be closed using - * {@link #close()} (or try-with-resources) to ensure timely release of the shared key, - * which is held in native memory. + * Note that the returned instance should be closed using {@link #close()} (or + * try-with-resources) to release the shared key. * * @param pk the public key of the receiver. * @param sk the secret key of the sender. * @return a precomputed crypto box instance. */ - public static CryptoBox fromKeys(PublicKey pk, PrivateKey sk) { + static CryptoBox fromKeys(PublicKey pk, PrivateKey sk) { Objects.requireNonNull(pk); Objects.requireNonNull(sk); - return new CryptoBox(Box.forKeys(pk.raw(), sk.raw())); + return provider().boxBeforeNm(pk, sk); } /** - * Encrypt a message with this precomputed box. + * Encrypt a message with the given keys. * * @param message the message to encrypt. + * @param receiver the public key of the receiver. + * @param sender the private key of the sender. * @param nonce a unique nonce object. * @return the encrypted data. */ - public byte[] encrypt(byte[] message, Nonce nonce) { - return box.encrypt(message, nonce.raw()); + static byte[] encrypt(byte[] message, PublicKey receiver, PrivateKey sender, Nonce nonce) { + return provider().boxEncrypt(message, nonce, receiver, sender); } /** - * Encrypt a message with the given keys + * Decrypt a message using the given keys. * - * @param message the message to encrypt. - * @param receiver the public key of the receiver. - * @param sender the private key of the sender. - * @param nonce a unique nonce object. - * @return the encrypted data. + * @param cipher the cipher text to decrypt. + * @param sender the public key of the sender. + * @param receiver the private key of the receiver. + * @param nonce the nonce that was used for encryption. + * @return the decrypted data. + * @throws CryptoException if the verification or decryption failed. */ - public static byte[] encrypt(byte[] message, PublicKey receiver, PrivateKey sender, Nonce nonce) { - return Box.encrypt(message, receiver.raw(), sender.raw(), nonce.raw()); + static byte[] decrypt(byte[] cipher, PublicKey sender, PrivateKey receiver, Nonce nonce) throws CryptoException { + byte[] plain = provider().boxDecrypt(cipher, nonce, sender, receiver); + if (plain == null) + throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); + + return plain; } /** * Encrypt a sealed message for a given key. *

* Sealed boxes are designed to anonymously send messages to a recipient given its public key. - * Only the recipient can decrypt these messages, using its private key. While - * the recipient can verify the integrity of the message, it cannot verify - * the identity of the sender. - *

- *

- * A message is encrypted using an ephemeral key pair, whose secret part is destroyed - * right after the encryption process. Without knowing the secret key used for a given - * message, the sender cannot decrypt its own message later. And without additional data, - * a message cannot be correlated with the identity of its sender. - *

+ * Only the recipient can decrypt these messages, using its private key. * * @param message the message to encrypt. * @param receiver the public key of the receiver. * @return the encrypted data. */ - public static byte[] encryptSealed(byte[] message, PublicKey receiver) { - return Box.encryptSealed(message, receiver.raw()); + static byte[] encryptSealed(byte[] message, PublicKey receiver) { + return provider().boxSeal(message, receiver); } /** - * Decrypt a message with this precomputed box. + * Decrypt a sealed message using the given keys. * * @param cipher the cipher text to decrypt. - * @param nonce the nonce that was used for encryption. - * @return The decrypted data. + * @param pk the public key of the sender. + * @param sk the private key of the receiver. + * @return the decrypted data. * @throws CryptoException if the verification or decryption failed. */ - public byte[] decrypt(byte[] cipher, Nonce nonce) throws CryptoException { - byte[] plain = box.decrypt(cipher, nonce.raw()); + static byte[] decryptSealed(byte[] cipher, PublicKey pk, PrivateKey sk) throws CryptoException { + byte[] plain = provider().boxSealOpen(cipher, pk, sk); if (plain == null) - throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); + throw new CryptoException("Sealed-box decryption failed: invalid ciphertext or authentication failure"); return plain; } /** - * Decrypt a message using the given keys. + * Encrypt a message with this precomputed box. * - * @param cipher the cipher text to decrypt. - * @param sender the public key of the sender. - * @param receiver the private key of the receiver. - * @param nonce the nonce that was used for encryption. - * @return the decrypted data. - * @throws CryptoException if the verification or decryption failed. + * @param message the message to encrypt. + * @param nonce a unique nonce object. + * @return the encrypted data. */ - public static byte[] decrypt(byte[] cipher, PublicKey sender, PrivateKey receiver, Nonce nonce) throws CryptoException { - byte[] plain = Box.decrypt(cipher, sender.raw(), receiver.raw(), nonce.raw()); - if (plain == null) - throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); - - return plain; - } + byte[] encrypt(byte[] message, Nonce nonce); /** - * Decrypt a sealed message using the given keys. + * Decrypt a message with this precomputed box. * * @param cipher the cipher text to decrypt. - * @param pk the public key of the sender. - * @param sk the private key of the receiver. + * @param nonce the nonce that was used for encryption. * @return the decrypted data. * @throws CryptoException if the verification or decryption failed. */ - public static byte[] decryptSealed(byte[] cipher, PublicKey pk, PrivateKey sk) throws CryptoException { - byte[] plain = Box.decryptSealed(cipher, pk.raw(), sk.raw()); - if (plain == null) - throw new CryptoException("Sealed-box decryption failed: invalid ciphertext or authentication failure"); - - return plain; - } + byte[] decrypt(byte[] cipher, Nonce nonce) throws CryptoException; @Override - public void close() { - destroy(); - } + void close(); @Override - public void destroy() { - if (!destroyed) { - box.close(); - destroyed = true; - } - } + void destroy(); @Override - public boolean isDestroyed() { - return destroyed; - } + boolean isDestroyed(); - static { - if (!Sodium.isAvailable()) { - throw new RuntimeException("Sodium native library is not available!"); - } + private static CryptoProvider provider() { + return CryptoProviders.getDefault(); } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java index 2582c09a..72334d6e 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java @@ -26,8 +26,6 @@ import java.util.Objects; import javax.security.auth.Destroyable; -import org.apache.tuweni.crypto.sodium.SodiumException; - import io.bosonnetwork.CryptoContext; import io.bosonnetwork.Id; import io.bosonnetwork.Identity; @@ -105,20 +103,16 @@ public byte[] encrypt(Id recipient, byte[] data) throws CryptoException { Objects.requireNonNull(recipient, "recipient"); Objects.requireNonNull(data, "data"); - try { - // TODO: how to avoid the memory copy?! - CryptoBox.Nonce nonce = CryptoBox.Nonce.random(); - CryptoBox.PublicKey pk = recipient.toEncryptionKey(); - CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); - byte[] cipher = CryptoBox.encrypt(data, pk, sk, nonce); - - byte[] buf = new byte[CryptoBox.Nonce.BYTES + cipher.length]; - System.arraycopy(nonce.bytes(), 0, buf, 0, CryptoBox.Nonce.BYTES); - System.arraycopy(cipher, 0, buf, CryptoBox.Nonce.BYTES, cipher.length); - return buf; - } catch (SodiumException e) { - throw new CryptoException(e); - } + // TODO: how to avoid the memory copy?! + CryptoBox.Nonce nonce = CryptoBox.Nonce.random(); + CryptoBox.PublicKey pk = recipient.toEncryptionKey(); + CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); + byte[] cipher = CryptoBox.encrypt(data, pk, sk, nonce); + + byte[] buf = new byte[CryptoBox.Nonce.BYTES + cipher.length]; + System.arraycopy(nonce.bytes(), 0, buf, 0, CryptoBox.Nonce.BYTES); + System.arraycopy(cipher, 0, buf, CryptoBox.Nonce.BYTES, cipher.length); + return buf; } /** @@ -130,14 +124,10 @@ public byte[] encrypt(Id recipient, byte[] nonce, byte[] data) throws CryptoExce Objects.requireNonNull(nonce, "nonce"); Objects.requireNonNull(data, "data"); - try { - CryptoBox.Nonce n = CryptoBox.Nonce.fromBytes(nonce); - CryptoBox.PublicKey pk = recipient.toEncryptionKey(); - CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); - return CryptoBox.encrypt(data, pk, sk, n); - } catch (SodiumException e) { - throw new CryptoException(e); - } + CryptoBox.Nonce n = CryptoBox.Nonce.fromBytes(nonce); + CryptoBox.PublicKey pk = recipient.toEncryptionKey(); + CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); + return CryptoBox.encrypt(data, pk, sk, n); } /** @@ -152,17 +142,13 @@ public byte[] decrypt(Id sender, byte[] data) throws CryptoException { throw new CryptoException("Invalid cipher size"); // TODO: how to avoid the memory copy?! - try { - byte[] n = Arrays.copyOfRange(data, 0, CryptoBox.Nonce.BYTES); - CryptoBox.Nonce nonce = CryptoBox.Nonce.fromBytes(n); - - CryptoBox.PublicKey pk = sender.toEncryptionKey(); - CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); - byte[] cipher = Arrays.copyOfRange(data, CryptoBox.Nonce.BYTES, data.length); - return CryptoBox.decrypt(cipher, pk, sk, nonce); - } catch (SodiumException e) { - throw new CryptoException(e); - } + byte[] n = Arrays.copyOfRange(data, 0, CryptoBox.Nonce.BYTES); + CryptoBox.Nonce nonce = CryptoBox.Nonce.fromBytes(n); + + CryptoBox.PublicKey pk = sender.toEncryptionKey(); + CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); + byte[] cipher = Arrays.copyOfRange(data, CryptoBox.Nonce.BYTES, data.length); + return CryptoBox.decrypt(cipher, pk, sk, nonce); } /** @@ -177,15 +163,11 @@ public byte[] decrypt(Id sender, byte[] nonce, byte[] data) throws CryptoExcepti if (data.length <= CryptoBox.MAC_BYTES) throw new CryptoException("Invalid cipher size"); - try { - CryptoBox.Nonce n = CryptoBox.Nonce.fromBytes(nonce); + CryptoBox.Nonce n = CryptoBox.Nonce.fromBytes(nonce); - CryptoBox.PublicKey pk = sender.toEncryptionKey(); - CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); - return CryptoBox.decrypt(data, pk, sk, n); - } catch (SodiumException e) { - throw new CryptoException(e); - } + CryptoBox.PublicKey pk = sender.toEncryptionKey(); + CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey(); + return CryptoBox.decrypt(data, pk, sk, n); } /** @@ -195,13 +177,9 @@ public byte[] decrypt(Id sender, byte[] nonce, byte[] data) throws CryptoExcepti public CryptoContext createCryptoContext(Id id) throws CryptoException { Objects.requireNonNull(id, "id"); - try { - CryptoBox.PublicKey pk = id.toEncryptionKey(); - CryptoBox box = CryptoBox.fromKeys(pk, encryptionKeyPair.privateKey()); - return new CryptoContext(id, box); - } catch (SodiumException e) { - throw new CryptoException(e); - } + CryptoBox.PublicKey pk = id.toEncryptionKey(); + CryptoBox box = CryptoBox.fromKeys(pk, encryptionKeyPair.privateKey()); + return new CryptoContext(id, box); } /** diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java new file mode 100644 index 00000000..92aa8b79 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java @@ -0,0 +1,321 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +import org.jspecify.annotations.Nullable; + +/** + * Service provider interface for the low-level cryptographic primitives used by Boson. + *

+ * Keys and nonces are represented as provider-specific objects ({@link Signature.PublicKey}, + * {@link Signature.PrivateKey}, {@link CryptoBox.PublicKey}, {@link CryptoBox.PrivateKey}, + * {@link CryptoBox.Nonce}, and the precomputed {@link CryptoBox} itself), so a backend can keep + * its native representation across calls; messages, ciphertexts, hashes and salts are plain + * {@code byte[]}. The public wrapper classes ({@link Signature}, {@link CryptoBox}, + * {@link PasswordHash}) delegate to the active provider without exposing any implementation type. + * The default backend is the pure-Java {@link BouncyCastleCryptoProvider}; an alternative backend + * (for example a future JNI binding to libsodium) can be supplied through the + * {@link java.util.ServiceLoader} mechanism, discovered by {@link CryptoProviders}. + *

+ * A key object is owned by the provider that created it. A provider that is handed a foreign key + * object (for example after the active provider was swapped) must still accept it by reconstructing + * from its raw {@link Signature.PublicKey#bytes() bytes}. Once a key object has been destroyed it + * must reject further use rather than read freed or zeroed material. + *

+ * Every implementation must be byte-for-byte compatible with the libsodium constructions: + * Ed25519 detached signatures, {@code crypto_kdf} (keyed BLAKE2b), {@code crypto_box} + * (X25519 + HSalsa20 key derivation + XSalsa20-Poly1305), the Ed25519 to Curve25519 key + * conversions, sealed boxes, and {@code crypto_pwhash} (Argon2). Secret keys use the libsodium + * 64-byte layout (32-byte seed followed by the 32-byte public key). + *

+ * Side-channels: implementations MUST compare secret material - private keys, + * MAC tags and password hashes - in constant time (for example + * {@code org.bouncycastle.util.Arrays.constantTimeAreEqual}). Public values such as public keys + * and nonces may use ordinary equality. + */ +public interface CryptoProvider { + /** Length in bytes of an Ed25519 seed. */ + int SIGN_SEED_BYTES = 32; + /** Length in bytes of an Ed25519 secret key (seed || public key). */ + int SIGN_SECRET_KEY_BYTES = 64; + /** Length in bytes of an Ed25519 public key. */ + int SIGN_PUBLIC_KEY_BYTES = 32; + /** Length in bytes of an Ed25519 signature. */ + int SIGN_BYTES = 64; + /** Length in bytes of the {@code crypto_kdf} derivation context. */ + int KDF_CONTEXT_BYTES = 8; + /** Length in bytes of a Curve25519 (crypto_box) seed. */ + int BOX_SEED_BYTES = 32; + /** Length in bytes of a Curve25519 (crypto_box) public key. */ + int BOX_PUBLIC_KEY_BYTES = 32; + /** Length in bytes of a Curve25519 (crypto_box) secret key. */ + int BOX_SECRET_KEY_BYTES = 32; + /** Length in bytes of a precomputed crypto_box shared key. */ + int BOX_SHARED_KEY_BYTES = 32; + /** Length in bytes of a crypto_box nonce. */ + int BOX_NONCE_BYTES = 24; + /** Length in bytes of the crypto_box message authentication code. */ + int BOX_MAC_BYTES = 16; + /** Length in bytes of a crypto_pwhash salt. */ + int PWHASH_SALT_BYTES = 16; + + /** Argon2i (version 1.3) algorithm id, matching {@code crypto_pwhash_ALG_ARGON2I13}. */ + int PWHASH_ALG_ARGON2I13 = 1; + /** Argon2id (version 1.3) algorithm id, matching {@code crypto_pwhash_ALG_ARGON2ID13}. */ + int PWHASH_ALG_ARGON2ID13 = 2; + + /** + * A short, human-readable identifier for this provider (for example {@code "bc"} or + * {@code "libsodium"}). + * + * @return the provider name. + */ + String name(); + + // ---- Ed25519 ---------------------------------------------------------- + + /** + * Creates an Ed25519 secret key from a 32-byte seed (libsodium {@code crypto_sign_seed_keypair}). + * + * @param seed the {@value #SIGN_SEED_BYTES}-byte seed. + * @return the secret key. + */ + Signature.PrivateKey ed25519SecretKeyFromSeed(byte[] seed); + + /** + * Creates an Ed25519 secret key from its {@value #SIGN_SECRET_KEY_BYTES}-byte encoding + * (seed followed by public key). + * + * @param secretKey the {@value #SIGN_SECRET_KEY_BYTES}-byte secret key. + * @return the secret key. + */ + Signature.PrivateKey ed25519SecretKeyFromBytes(byte[] secretKey); + + /** + * Derives the Ed25519 public key for the given secret key. + * + * @param secretKey the secret key. + * @return the public key. + */ + Signature.PublicKey ed25519PublicKeyFromSecretKey(Signature.PrivateKey secretKey); + + /** + * Creates an Ed25519 public key from its {@value #SIGN_PUBLIC_KEY_BYTES}-byte encoding. + * + * @param bytes the {@value #SIGN_PUBLIC_KEY_BYTES}-byte public key. + * @return the public key. + */ + Signature.PublicKey ed25519PublicKeyFromBytes(byte[] bytes); + + /** + * Computes a detached Ed25519 signature. + * + * @param message the message to sign. + * @param secretKey the secret key. + * @return the {@value #SIGN_BYTES}-byte signature. + */ + byte[] ed25519Sign(byte[] message, Signature.PrivateKey secretKey); + + /** + * Verifies a detached Ed25519 signature. + * + * @param message the message. + * @param signature the {@value #SIGN_BYTES}-byte signature. + * @param publicKey the public key. + * @return true if the signature is valid. + */ + boolean ed25519Verify(byte[] message, byte[] signature, Signature.PublicKey publicKey); + + // ---- crypto_kdf (keyed BLAKE2b) --------------------------------------- + + /** + * Derives a sub-key from a master key using libsodium's {@code crypto_kdf} construction. + * + * @param masterKey the 32-byte master key. + * @param subKeyId the sub-key identifier. + * @param context the {@value #KDF_CONTEXT_BYTES}-byte context. + * @param subKeyLength the length of the derived sub-key. + * @return the derived sub-key. + */ + byte[] kdfDeriveFromKey(byte[] masterKey, long subKeyId, byte[] context, int subKeyLength); + + // ---- Ed25519 -> Curve25519 conversions -------------------------------- + + /** + * Converts an Ed25519 public key to a Curve25519 (crypto_box) public key. + * + * @param publicKey the Ed25519 public key. + * @return the Curve25519 public key. + */ + CryptoBox.PublicKey signPublicKeyToBoxPublicKey(Signature.PublicKey publicKey); + + /** + * Converts an Ed25519 secret key to a Curve25519 (crypto_box) secret key. + * + * @param secretKey the Ed25519 secret key. + * @return the Curve25519 secret key. + */ + CryptoBox.PrivateKey signSecretKeyToBoxSecretKey(Signature.PrivateKey secretKey); + + // ---- crypto_box ------------------------------------------------------- + + /** + * Creates a Curve25519 (crypto_box) secret key from a seed (libsodium + * {@code crypto_box_seed_keypair}). + * + * @param seed the {@value #BOX_SEED_BYTES}-byte seed. + * @return the secret key object. + */ + CryptoBox.PrivateKey boxSecretKeyFromSeed(byte[] seed); + + /** + * Creates a Curve25519 (crypto_box) public key from raw bytes. + * + * @param bytes the 32-byte public key. + * @return the public key object. + */ + CryptoBox.PublicKey boxPublicKeyFromBytes(byte[] bytes); + + /** + * Creates a Curve25519 (crypto_box) secret key from raw bytes. + * + * @param bytes the 32-byte secret key. + * @return the secret key object. + */ + CryptoBox.PrivateKey boxSecretKeyFromBytes(byte[] bytes); + + /** + * Derives the Curve25519 public key for a given Curve25519 secret key. + * + * @param secretKey the secret key. + * @return the public key. + */ + CryptoBox.PublicKey boxPublicKeyFromSecretKey(CryptoBox.PrivateKey secretKey); + + /** + * Creates a crypto_box nonce from its {@value #BOX_NONCE_BYTES}-byte value. + * + * @param bytes the {@value #BOX_NONCE_BYTES}-byte nonce. + * @return the nonce object. + */ + CryptoBox.Nonce boxNonceFromBytes(byte[] bytes); + + /** + * Precomputes the shared key for a sender/receiver key pair (libsodium {@code beforenm}), + * returning a {@link CryptoBox} whose {@link CryptoBox#encrypt}/{@link CryptoBox#decrypt} + * are the per-message {@code afternm} operations. + * + * @param publicKey the peer public key. + * @param secretKey the own secret key. + * @return the precomputed crypto box. + */ + CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey); + + /** + * Encrypts a message with explicit keys (libsodium {@code crypto_box_easy}). + * + * @param message the plaintext. + * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce. + * @param publicKey the receiver's public key. + * @param secretKey the sender's secret key. + * @return the ciphertext (MAC prepended). + */ + byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey); + + /** + * Decrypts a message with explicit keys (libsodium {@code crypto_box_open_easy}). + * + * @param cipher the ciphertext. + * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce. + * @param publicKey the sender's public key. + * @param secretKey the receiver's secret key. + * @return the plaintext, or {@code null} if authentication failed. + */ + byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey); + + /** + * Encrypts an anonymous sealed box for a recipient (libsodium {@code crypto_box_seal}). + * + * @param message the plaintext. + * @param publicKey the recipient's public key. + * @return the sealed ciphertext (ephemeral public key prepended). + */ + byte[] boxSeal(byte[] message, CryptoBox.PublicKey publicKey); + + /** + * Opens an anonymous sealed box (libsodium {@code crypto_box_seal_open}). + * + * @param cipher the sealed ciphertext. + * @param publicKey the recipient's public key. + * @param secretKey the recipient's secret key. + * @return the plaintext, or {@code null} if authentication failed. + */ + byte @Nullable [] boxSealOpen(byte[] cipher, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey); + + // ---- crypto_pwhash (Argon2) ------------------------------------------- + + /** + * Derives a key from a password (libsodium {@code crypto_pwhash}). + * + * @param password the password bytes. + * @param length the derived key length. + * @param salt the {@value #PWHASH_SALT_BYTES}-byte salt. + * @param opsLimit the operations limit. + * @param memLimit the memory limit in bytes. + * @param algorithm the algorithm id ({@link #PWHASH_ALG_ARGON2I13} or {@link #PWHASH_ALG_ARGON2ID13}). + * @return the derived key. + */ + byte[] pwHash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, int algorithm); + + /** + * Hashes a password into an encoded, self-describing PHC string (libsodium + * {@code crypto_pwhash_str}). The string embeds the algorithm, parameters and salt. + * + * @param password the password bytes. + * @param opsLimit the operations limit. + * @param memLimit the memory limit in bytes. + * @param algorithm the algorithm id. + * @return the encoded PHC hash string. + */ + String pwHashString(byte[] password, long opsLimit, long memLimit, int algorithm); + + /** + * Verifies a password against an encoded PHC hash string (libsodium {@code crypto_pwhash_str_verify}). + * + * @param hash the encoded PHC hash string. + * @param password the password bytes. + * @return true if the password matches. + */ + boolean pwHashVerify(String hash, byte[] password); + + /** + * Determines whether an encoded PHC hash string should be recomputed for the given limits + * (libsodium {@code crypto_pwhash_str_needs_rehash}). + * + * @param hash the encoded PHC hash string. + * @param opsLimit the target operations limit. + * @param memLimit the target memory limit in bytes. + * @return true if the hash should be regenerated. + */ + boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit); +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java new file mode 100644 index 00000000..b72baac2 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +import java.util.Objects; +import java.util.ServiceLoader; + +/** + * Resolves and holds the active {@link CryptoProvider}. + *

+ * The provider is discovered once via the {@link ServiceLoader} mechanism (allowing an + * alternative backend, such as a future JNI binding, to register through a + * {@code META-INF/services/io.bosonnetwork.crypto.CryptoProvider} entry). When no provider is + * registered, the built-in pure-Java {@link BouncyCastleCryptoProvider} is used. + */ +public final class CryptoProviders { + private static volatile CryptoProvider current = resolve(); + + private CryptoProviders() { + } + + /** + * Returns the active crypto provider. + * + * @return the active {@link CryptoProvider}. + */ + public static CryptoProvider getDefault() { + return current; + } + + /** + * Overrides the active crypto provider. Package-private: intended for the compatibility + * test suite to run the wrapper classes against an alternative backend. + * + * @param provider the provider to activate. + */ + static void setDefault(CryptoProvider provider) { + current = Objects.requireNonNull(provider, "provider"); + } + + private static CryptoProvider resolve() { + return ServiceLoader.load(CryptoProvider.class) + .findFirst() + .orElseGet(BouncyCastleCryptoProvider::new); + } +} diff --git a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java index 5006fdef..a27a023d 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java +++ b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java @@ -24,54 +24,66 @@ import static java.nio.charset.StandardCharsets.UTF_8; -import org.jspecify.annotations.Nullable; +import java.util.Objects; /** * Utility class for hashing passwords using different security levels and Argon2 algorithms. *

* This class provides static methods for hashing passwords using interactive, moderate, or sensitive security levels, * as well as direct parameterized hashing. It supports Argon2i and Argon2id algorithms, and delegates cryptographic - * operations to Tuweni Sodium. - *

+ * operations to the active {@link CryptoProvider}. The encoded hash strings use the standard Argon2 PHC format and + * are interoperable with libsodium's {@code crypto_pwhash_str}. */ public class PasswordHash { /** - * Maximum allowed size (in bytes) for password hash output as defined by the underlying sodium implementation. + * The fixed number of bytes required for a valid salt. */ - public static final int MAX_HASH_BYTES = org.apache.tuweni.crypto.sodium.PasswordHash.maxHashLength(); + public static final int SALT_BYTES = CryptoProvider.PWHASH_SALT_BYTES; + + /** + * Maximum allowed size (in bytes) for password hash output. + */ + public static final int MAX_HASH_BYTES = Integer.MAX_VALUE; /** - * Minimum allowed size (in bytes) for password hash output as defined by the underlying sodium implementation. + * Minimum allowed size (in bytes) for password hash output. */ - public static final int MIN_HASH_BYTES = org.apache.tuweni.crypto.sodium.PasswordHash.minHashLength(); + public static final int MIN_HASH_BYTES = 16; + + // libsodium crypto_pwhash predefined limits (Argon2id based). + private static final long INTERACTIVE_OPS = 2L; + private static final long INTERACTIVE_MEM = 67108864L; // 64 MiB + private static final long MODERATE_OPS = 3L; + private static final long MODERATE_MEM = 268435456L; // 256 MiB + private static final long SENSITIVE_OPS = 4L; + private static final long SENSITIVE_MEM = 1073741824L; // 1 GiB + + private static CryptoProvider provider() { + return CryptoProviders.getDefault(); + } /** * Enum representing the Argon2 algorithms available for password hashing. *

- * Provides Argon2i (version 1.3) and Argon2id (version 1.3) algorithms. The {@link #DEFAULT} selection checks - * if Argon2id is supported in the native library and uses it; otherwise, falls back to Argon2i. + * Provides Argon2i (version 1.3) and Argon2id (version 1.3) algorithms. The {@link #DEFAULT} selection uses + * Argon2id. *

*/ public enum Algorithm { /** * Argon2i version 1.3 algorithm. */ - ARGON2I13(1), + ARGON2I13(CryptoProvider.PWHASH_ALG_ARGON2I13), /** * Argon2id version 1.3 algorithm. */ - ARGON2ID13(2); + ARGON2ID13(CryptoProvider.PWHASH_ALG_ARGON2ID13); private final int id; /** - * The default algorithm to use for password hashing. - *

- * If Argon2id is supported by the loaded sodium library, it is selected; otherwise, Argon2i is used. - *

+ * The default algorithm to use for password hashing (Argon2id). */ - public static final Algorithm DEFAULT = - org.apache.tuweni.crypto.sodium.PasswordHash.Algorithm.argon2id13().isSupported() ? - ARGON2ID13 : ARGON2I13; + public static final Algorithm DEFAULT = ARGON2ID13; Algorithm(int id) { this.id = id; @@ -85,9 +97,9 @@ public enum Algorithm { * @throws IllegalArgumentException if the id is invalid */ public static Algorithm valueOf(int id) { - if (id == 1) + if (id == CryptoProvider.PWHASH_ALG_ARGON2I13) return ARGON2I13; - else if (id == 2) + else if (id == CryptoProvider.PWHASH_ALG_ARGON2ID13) return ARGON2ID13; else throw new IllegalArgumentException("Invalid algorithm id: " + id); @@ -101,87 +113,6 @@ else if (id == 2) public int id() { return id; } - - org.apache.tuweni.crypto.sodium.PasswordHash.Algorithm raw() { - if (id == 1) - return org.apache.tuweni.crypto.sodium.PasswordHash.Algorithm.argon2i13(); - else - return org.apache.tuweni.crypto.sodium.PasswordHash.Algorithm.argon2id13(); - } - } - - /** - * Represents a salt value used for password hashing. - *

- * Provides methods to generate a random salt or create a salt from an existing byte array. - *

- */ - public static class Salt { - /** - * The fixed number of bytes required for a valid salt. - */ - public static final int BYTES = org.apache.tuweni.crypto.sodium.PasswordHash.Salt.length(); - - private final org.apache.tuweni.crypto.sodium.PasswordHash.Salt salt; - private byte @Nullable [] bytes; - - private Salt(org.apache.tuweni.crypto.sodium.PasswordHash.Salt salt) { - this.salt = salt; - } - - /** - * Creates a salt object from the given byte array. - * - * @param salt the byte array containing the salt value - * @return a new {@code Salt} instance wrapping the given bytes - * @throws IllegalArgumentException if the byte array is not of the correct length - */ - public static Salt fromBytes(byte[] salt) { - // No SodiumException raised - return new Salt(org.apache.tuweni.crypto.sodium.PasswordHash.Salt.fromBytes(salt)); - } - - /** - * Generates a new random salt suitable for password hashing. - * - * @return a new randomly generated {@code Salt} instance - */ - public static Salt random() { - // No SodiumException raised - return new Salt(org.apache.tuweni.crypto.sodium.PasswordHash.Salt.random()); - } - - org.apache.tuweni.crypto.sodium.PasswordHash.Salt raw() { - return salt; - } - - /** - * Provides the bytes of this salt. - * - * @return the bytes of this salt - */ - public byte[] bytes() { - if (bytes == null) - bytes = salt.bytesArray(); - - return bytes.clone(); - } - - @Override - public boolean equals(Object obj) { - if (obj == this) - return true; - - if (obj instanceof Salt that) - return salt.equals(that.salt); - - return false; - } - - @Override - public int hashCode() { - return 0x6030A + salt.hashCode(); - } } /** @@ -194,7 +125,7 @@ public int hashCode() { * @param algorithm The algorithm to use. * @return The derived key. */ - public static byte[] hashInteractive(String password, int length, Salt salt, Algorithm algorithm) { + public static byte[] hashInteractive(String password, int length, byte[] salt, Algorithm algorithm) { return hashInteractive(password.getBytes(UTF_8), length, salt, algorithm); } @@ -208,8 +139,12 @@ public static byte[] hashInteractive(String password, int length, Salt salt, Alg * @param algorithm The algorithm to use. * @return The derived key. */ - public static byte[] hashInteractive(byte[] password, int length, Salt salt, Algorithm algorithm) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hashInteractive(password, length, salt.raw(), algorithm.raw()); + public static byte[] hashInteractive(byte[] password, int length, byte[] salt, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); + if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) + throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); + + return provider().pwHash(password, length, salt, INTERACTIVE_OPS, INTERACTIVE_MEM, algorithm.id()); } /** @@ -222,7 +157,7 @@ public static byte[] hashInteractive(byte[] password, int length, Salt salt, Alg * @param algorithm The algorithm to use. * @return The derived key. */ - public static byte[] hashModerate(String password, int length, Salt salt, Algorithm algorithm) { + public static byte[] hashModerate(String password, int length, byte[] salt, Algorithm algorithm) { return hashModerate(password.getBytes(UTF_8), length, salt, algorithm); } @@ -236,8 +171,12 @@ public static byte[] hashModerate(String password, int length, Salt salt, Algori * @param algorithm The algorithm to use. * @return The derived key. */ - public static byte[] hashModerate(byte[] password, int length, Salt salt, Algorithm algorithm) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hash(password, length, salt.raw(), algorithm.raw()); + public static byte[] hashModerate(byte[] password, int length, byte[] salt, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); + if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) + throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); + + return provider().pwHash(password, length, salt, MODERATE_OPS, MODERATE_MEM, algorithm.id()); } /** @@ -250,7 +189,7 @@ public static byte[] hashModerate(byte[] password, int length, Salt salt, Algori * @param algorithm The algorithm to use. * @return The derived key. */ - public static byte[] hashSensitive(String password, int length, Salt salt, Algorithm algorithm) { + public static byte[] hashSensitive(String password, int length, byte[] salt, Algorithm algorithm) { return hashSensitive(password.getBytes(UTF_8), length, salt, algorithm); } @@ -264,8 +203,12 @@ public static byte[] hashSensitive(String password, int length, Salt salt, Algor * @param algorithm The algorithm to use. * @return The derived key. */ - public static byte[] hashSensitive(byte[] password, int length, Salt salt, Algorithm algorithm) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hashSensitive(password, length, salt.raw(), algorithm.raw()); + public static byte[] hashSensitive(byte[] password, int length, byte[] salt, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); + if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) + throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); + + return provider().pwHash(password, length, salt, SENSITIVE_OPS, SENSITIVE_MEM, algorithm.id()); } /** @@ -274,15 +217,12 @@ public static byte[] hashSensitive(byte[] password, int length, Salt salt, Algor * @param password The password to hash. * @param length The key length to generate. * @param salt A salt. - * @param opsLimit The operations limit, which must be in the range minOpsLimit to maxOpsLimit. - * @param memLimit The memory limit, which must be in the range minMemLimit to maxMemLimit. + * @param opsLimit The operations limit. + * @param memLimit The memory limit in bytes. * @param algorithm The algorithm to use. * @return The derived key. - * @throws IllegalArgumentException If the opsLimit is too low for the specified algorithm. - * @throws UnsupportedOperationException If the specified algorithm is not supported by the currently loaded sodium - * native library. */ - public static byte[] hash(String password, int length, Salt salt, long opsLimit, long memLimit, Algorithm algorithm) { + public static byte[] hash(String password, int length, byte[] salt, long opsLimit, long memLimit, Algorithm algorithm) { return hash(password.getBytes(UTF_8), length, salt, opsLimit, memLimit, algorithm); } @@ -292,16 +232,17 @@ public static byte[] hash(String password, int length, Salt salt, long opsLimit, * @param password The password to hash. * @param length The key length to generate. * @param salt A salt. - * @param opsLimit The operations limit, which must be in the range minOpsLimit to maxOpsLimit. - * @param memLimit The memory limit, which must be in the range minMemLimit to maxMemLimit. + * @param opsLimit The operations limit. + * @param memLimit The memory limit in bytes. * @param algorithm The algorithm to use. * @return The derived key. - * @throws IllegalArgumentException If the opsLimit is too low for the specified algorithm. - * @throws UnsupportedOperationException If the specified algorithm is not supported by the currently loaded sodium - * native library. */ - public static byte[] hash(byte[] password, int length, Salt salt, long opsLimit, long memLimit, Algorithm algorithm) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hash(password, length, salt.raw(), opsLimit, memLimit, algorithm.raw()); + public static byte[] hash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); + if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) + throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); + + return provider().pwHash(password, length, salt, opsLimit, memLimit, algorithm.id()); } /** @@ -312,7 +253,8 @@ public static byte[] hash(byte[] password, int length, Salt salt, long opsLimit, * @return The hash string. */ public static String hashInteractive(String password) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hashInteractive(password); + return provider().pwHashString(password.getBytes(UTF_8), INTERACTIVE_OPS, INTERACTIVE_MEM, + Algorithm.DEFAULT.id()); } /** @@ -323,7 +265,7 @@ public static String hashInteractive(String password) { * @return The hash string. */ public static String hashModerate(String password) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hash(password); + return provider().pwHashString(password.getBytes(UTF_8), MODERATE_OPS, MODERATE_MEM, Algorithm.DEFAULT.id()); } /** @@ -334,19 +276,19 @@ public static String hashModerate(String password) { * @return The hash string. */ public static String hashSensitive(String password) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hashSensitive(password); + return provider().pwHashString(password.getBytes(UTF_8), SENSITIVE_OPS, SENSITIVE_MEM, Algorithm.DEFAULT.id()); } /** * Compute a hash from a password. * * @param password The password to hash. - * @param opsLimit The operations limit, which must be in the range minOpsLimit to maxOpsLimit. - * @param memLimit The memory limit, which must be in the range minMemLimit to maxMemLimit. + * @param opsLimit The operations limit. + * @param memLimit The memory limit in bytes. * @return The hash string. */ public static String hash(String password, long opsLimit, long memLimit) { - return org.apache.tuweni.crypto.sodium.PasswordHash.hash(password, opsLimit, memLimit); + return provider().pwHashString(password.getBytes(UTF_8), opsLimit, memLimit, Algorithm.DEFAULT.id()); } /** @@ -357,51 +299,39 @@ public static String hash(String password, long opsLimit, long memLimit) { * @return {@code true} if the password matches the hash. */ public static boolean verify(String hash, String password) { - return org.apache.tuweni.crypto.sodium.PasswordHash.verify(hash, password); + return provider().pwHashVerify(hash, password.getBytes(UTF_8)); } /** * Check if a hash needs to be regenerated using limits on operations and memory * that are suitable for interactive use-cases. * - *

- * Note: only supported when the sodium native library version >= 10.0.14 is - * available. - * * @param hash The hash. * @return {@code true} if the hash should be regenerated. */ public static boolean needsRehashForInteractive(String hash) { - return org.apache.tuweni.crypto.sodium.PasswordHash.needsRehashForInteractive(hash); + return provider().pwHashNeedsRehash(hash, INTERACTIVE_OPS, INTERACTIVE_MEM); } /** * Check if a hash needs to be regenerated using limits on operations and memory * that are suitable for most moderate use-cases. * - *

- * Note: only supported when the sodium native library version >= 10.0.14 is - * available. - * * @param hash The hash. * @return {@code true} if the hash should be regenerated. */ public static boolean needsRehashForModerate(String hash) { - return org.apache.tuweni.crypto.sodium.PasswordHash.needsRehash(hash); + return provider().pwHashNeedsRehash(hash, MODERATE_OPS, MODERATE_MEM); } /** * Check if a hash needs to be regenerated using limits on operations and memory * that are suitable for sensitive use-cases. * - *

- * Note: only supported when the sodium native library version >= 10.0.14 is - * available. - * * @param hash The hash. * @return {@code true} if the hash should be regenerated. */ public static boolean needsRehashForSensitive(String hash) { - return org.apache.tuweni.crypto.sodium.PasswordHash.needsRehashForSensitive(hash); + return provider().pwHashNeedsRehash(hash, SENSITIVE_OPS, SENSITIVE_MEM); } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/Signature.java b/api/src/main/java/io/bosonnetwork/crypto/Signature.java index 7597964f..6da29d5a 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/Signature.java +++ b/api/src/main/java/io/bosonnetwork/crypto/Signature.java @@ -26,34 +26,21 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Arrays; import java.util.Objects; import javax.security.auth.Destroyable; -import org.apache.tuweni.crypto.sodium.KeyDerivation; -import org.apache.tuweni.crypto.sodium.Signature.Seed; -import org.apache.tuweni.crypto.sodium.Sodium; -import org.jspecify.annotations.Nullable; - /** * Public-key(Ed25519) signatures. */ -public class Signature { +public interface Signature { /** * The signing(Ed25519) public key object. */ - public static class PublicKey implements Destroyable { + interface PublicKey extends Destroyable { /** * The number of bytes used to represent a public key. */ - public static final int BYTES = org.apache.tuweni.crypto.sodium.Signature.PublicKey.length(); - - private final org.apache.tuweni.crypto.sodium.Signature.PublicKey key; - private byte @Nullable [] bytes; - - private PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey key) { - this.key = key; - } + int BYTES = CryptoProvider.SIGN_PUBLIC_KEY_BYTES; /** * Create a PublicKey from an array of bytes. The byte array must be of @@ -61,14 +48,24 @@ private PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey key) { * * @param key the bytes for the public key. * @return the created public key object. + * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long. */ - public static PublicKey fromBytes(byte[] key) { - // No SodiumException raised - return new PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey.fromBytes(key)); + static PublicKey fromBytes(byte[] key) { + if (Objects.requireNonNull(key, "key").length != BYTES) + throw new IllegalArgumentException("Invalid public key size: expected " + BYTES + " bytes, got " + key.length); + + return provider().ed25519PublicKeyFromBytes(key); } - org.apache.tuweni.crypto.sodium.Signature.PublicKey raw() { - return key; + /** + * Derive the public key that corresponds to the given private key. + * + * @param key the private key. + * @return the matching public key. + */ + static PublicKey fromPrivateKey(PrivateKey key) { + Objects.requireNonNull(key, "key"); + return provider().ed25519PublicKeyFromSecretKey(key); } /** @@ -76,12 +73,7 @@ org.apache.tuweni.crypto.sodium.Signature.PublicKey raw() { * * @return the bytes of this key. */ - public byte[] bytes() { - if (bytes == null) - bytes = key.bytesArray(); - - return bytes.clone(); - } + byte[] bytes(); /** * Verifies the signature of a message. @@ -90,67 +82,38 @@ public byte[] bytes() { * @param signature the signature of the message. * @return true if the signature matches the message according to this public key. */ - public boolean verify(byte[] message, byte[] signature) { - return Signature.verify(message, signature, this); + default boolean verify(byte[] message, byte[] signature) { + return provider().ed25519Verify(message, signature, this); } @Override - public boolean equals(Object obj) { - if (obj == this) - return true; - - if (obj instanceof PublicKey that) - return key.equals(that.key); - - return false; - } + void destroy(); @Override - public int hashCode() { - return 0x6030A + key.hashCode(); - } - - /** - * Destroy this PublicKey object. - * Sensitive information associated with this object is destroyed or cleared. - */ - @Override - public void destroy() { - if (!key.isDestroyed()) { - key.destroy(); - - if (bytes != null) { - Arrays.fill(bytes, (byte) 0); - bytes = null; - } - } - } - - /** - * Determine if this object has been destroyed. - * - * @return true if this object has been destroyed, false otherwise. - */ - @Override - public boolean isDestroyed() { - return key.isDestroyed(); - } + boolean isDestroyed(); } /** * The signing(Ed25519) private key object. */ - public static class PrivateKey implements Destroyable { + interface PrivateKey extends Destroyable { /** - * The number of bytes used to represent a public key. + * The number of bytes used to represent a private key (seed followed by public key). */ - public static final int BYTES = org.apache.tuweni.crypto.sodium.Signature.SecretKey.length(); + int BYTES = CryptoProvider.SIGN_SECRET_KEY_BYTES; - private final org.apache.tuweni.crypto.sodium.Signature.SecretKey key; - private byte @Nullable [] bytes; + /** + * Creates a new {@code PrivateKey} object from the specified seed. + * + * @param seed the {@link KeyPair#SEED_BYTES}-byte seed for the private key. Must not be null. + * @return a new {@code PrivateKey} created from the given seed. + * @throws IllegalArgumentException if {@code seed} is not {@link KeyPair#SEED_BYTES} bytes long. + */ + static PrivateKey fromSeed(byte[] seed) { + if (Objects.requireNonNull(seed, "seed").length != KeyPair.SEED_BYTES) + throw new IllegalArgumentException("Invalid seed size: expected " + KeyPair.SEED_BYTES + " bytes, got " + seed.length); - private PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey key) { - this.key = key; + return provider().ed25519SecretKeyFromSeed(seed); } /** @@ -159,38 +122,28 @@ private PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey key) { * * @param key the bytes for the secret key. * @return the created private key object. + * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long. */ - public static PrivateKey fromBytes(byte[] key) { - // no SodiumException raised - return new PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(key)); + static PrivateKey fromBytes(byte[] key) { + if (Objects.requireNonNull(key, "key").length != BYTES) + throw new IllegalArgumentException("Invalid private key size: expected " + BYTES + " bytes, got " + key.length); + + return provider().ed25519SecretKeyFromBytes(key); } /** - * Creates a new {@code PrivateKey} object from the specified seed. + * Provides the {@link KeyPair#SEED_BYTES}-byte seed of this secret key. * - * @param seed the byte array representing the seed for the private key. Must not be null. - * @return a new {@code PrivateKey} created from the given seed. + * @return the seed bytes. */ - public static PrivateKey fromSeed(byte[] seed) { - return new PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromSeed(Seed.fromBytes(seed))); - } - - org.apache.tuweni.crypto.sodium.Signature.SecretKey raw() { - return key; - } + byte[] seed(); /** * Provides the bytes of this secret key. * * @return the bytes of this secret key. */ - public byte[] bytes() { - if (bytes == null) - bytes = key.bytesArray(); - - return bytes.clone(); - } - + byte[] bytes(); /** * Derives a new {@code PrivateKey} based on the provided subkey ID and context string. @@ -200,11 +153,8 @@ public byte[] bytes() { * @param context the context string used during the key derivation process. Must not be null. * @return a newly derived {@code PrivateKey} created using the specified subkey ID and context. */ - public PrivateKey derive(long subKeyId, String context) { - byte[] contextBytes = deriveContextBytes(context); - KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(bytes(), 0, 32)); - byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, contextBytes); - return PrivateKey.fromSeed(subSeed); + default PrivateKey derive(long subKeyId, String context) { + return derive(subKeyId, deriveContextBytes(context)); } /** @@ -216,10 +166,13 @@ public PrivateKey derive(long subKeyId, String context) { * array and cannot be null. * @return a new {@code PrivateKey} derived using the specified subkey ID and context. */ - public PrivateKey derive(long subKeyId, byte[] context) { - Objects.requireNonNull(context, "context"); - KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(bytes(), 0, 32)); - byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, context); + default PrivateKey derive(long subKeyId, byte[] context) { + if (Objects.requireNonNull(context, "context").length != CryptoProvider.KDF_CONTEXT_BYTES) + throw new IllegalArgumentException("Invalid context size: expected " + + CryptoProvider.KDF_CONTEXT_BYTES + " bytes, got " + context.length); + + byte[] master = seed(); + byte[] subSeed = provider().kdfDeriveFromKey(master, subKeyId, context, KeyPair.SEED_BYTES); return PrivateKey.fromSeed(subSeed); } @@ -229,70 +182,32 @@ public PrivateKey derive(long subKeyId, byte[] context) { * @param message the message to sign. * @return the signature of the message. */ - public byte[] sign(byte[] message) { - return Signature.sign(message, this); + default byte[] sign(byte[] message) { + return provider().ed25519Sign(message, this); } @Override - public boolean equals(Object obj) { - if (obj == this) - return true; - - if (obj instanceof PrivateKey that) - return key.equals(that.key); - - return false; - } + void destroy(); @Override - public int hashCode() { - return 0x6030A + key.hashCode(); - } - - /** - * Destroy this private key. - * Sensitive information associated with this private key - * is destroyed or cleared. - */ - @Override - public void destroy() { - if (!key.isDestroyed()) { - key.destroy(); - - if (bytes != null) { - Arrays.fill(bytes, (byte) 0); - bytes = null; - } - } - } - - /** - * Determine if this object has been destroyed. - * - * @return true if this object has been destroyed, false otherwise. - */ - @Override - public boolean isDestroyed() { - return key.isDestroyed(); - } + boolean isDestroyed(); } /** * The signing(Ed25519) key pair. */ - public static class KeyPair implements Destroyable { + class KeyPair implements Destroyable { /** * The seed length in bytes. */ - public static final int SEED_BYTES = Seed.length(); + public static final int SEED_BYTES = CryptoProvider.SIGN_SEED_BYTES; - private final org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair; - private @Nullable PublicKey pk; - private @Nullable PrivateKey sk; - private boolean destroyed = false; + private final PublicKey pk; + private final PrivateKey sk; - private KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair) { - this.keyPair = keyPair; + private KeyPair(PrivateKey sk) { + this.sk = sk; + this.pk = PublicKey.fromPrivateKey(sk); } /** @@ -303,9 +218,7 @@ private KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair) { * @return the created key pair object. */ public static KeyPair fromPrivateKey(byte[] privateKey) { - org.apache.tuweni.crypto.sodium.Signature.SecretKey sk = org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(privateKey); - // Normally, should never raise Exception - return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.forSecretKey(sk)); + return new KeyPair(PrivateKey.fromBytes(privateKey)); } /** @@ -315,8 +228,7 @@ public static KeyPair fromPrivateKey(byte[] privateKey) { * @return the created key pair object. */ public static KeyPair fromPrivateKey(PrivateKey privateKey) { - // Normally, should never raise Exception - return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.forSecretKey(privateKey.raw())); + return new KeyPair(privateKey); } /** @@ -327,9 +239,7 @@ public static KeyPair fromPrivateKey(PrivateKey privateKey) { * @return the created key pair object. */ public static KeyPair fromSeed(byte[] seed) { - org.apache.tuweni.crypto.sodium.Signature.Seed sd = org.apache.tuweni.crypto.sodium.Signature.Seed.fromBytes(seed); - // Normally, should never raise Exception - return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.fromSeed(sd)); + return new KeyPair(PrivateKey.fromSeed(seed)); } /** @@ -338,12 +248,7 @@ public static KeyPair fromSeed(byte[] seed) { * @return a randomly generated key pair. */ public static KeyPair random() { - // Normally, should never raise Exception - return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.random()); - } - - org.apache.tuweni.crypto.sodium.Signature.KeyPair raw() { - return keyPair; + return fromSeed(Random.randomBytesSecure(SEED_BYTES)); } /** @@ -352,9 +257,6 @@ org.apache.tuweni.crypto.sodium.Signature.KeyPair raw() { * @return the public key of the key pair. */ public PublicKey publicKey() { - if (pk == null) - pk = new PublicKey(keyPair.publicKey()); - return pk; } @@ -364,9 +266,6 @@ public PublicKey publicKey() { * @return the private key of the key pair. */ public PrivateKey privateKey() { - if (sk == null) - sk = new PrivateKey(keyPair.secretKey()); - return sk; } @@ -379,10 +278,7 @@ public PrivateKey privateKey() { * @return the derived {@code KeyPair} instance. */ public KeyPair derive(long subKeyId, String context) { - byte[] contextBytes = deriveContextBytes(context); - KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(privateKey().bytes(), 0, 32)); - byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, contextBytes); - return KeyPair.fromSeed(subSeed); + return new KeyPair(sk.derive(subKeyId, context)); } /** @@ -390,15 +286,12 @@ public KeyPair derive(long subKeyId, String context) { * * @param subKeyId the identifier for the derived subkey. This is used to ensure the generated key * is unique per subkey ID. - * @param context the context-specific data used during key derivation. Must be provided as a byte - * array and cannot be null. + * @param context the context-specific data used during the key derivation process. Must be provided + * as a byte array and cannot be null. * @return the derived {@code KeyPair} instance. */ public KeyPair derive(long subKeyId, byte[] context) { - Objects.requireNonNull(context, "context"); - KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(privateKey().bytes(), 0, 32)); - byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, context); - return KeyPair.fromSeed(subSeed); + return new KeyPair(sk.derive(subKeyId, context)); } @Override @@ -407,14 +300,14 @@ public boolean equals(Object obj) { return true; if (obj instanceof KeyPair that) - return keyPair.equals(that.keyPair); + return sk.equals(that.sk) && pk.equals(that.pk); return false; } @Override public int hashCode() { - return 0x6030A + keyPair.hashCode(); + return Objects.hash(sk, pk); } /** @@ -422,11 +315,8 @@ public int hashCode() { */ @Override public void destroy() { - if (!destroyed) { - publicKey().destroy(); - privateKey().destroy(); - destroyed = true; - } + pk.destroy(); + sk.destroy(); } /** @@ -436,24 +326,22 @@ public void destroy() { */ @Override public boolean isDestroyed() { - return destroyed; + return sk.isDestroyed(); } } - // Can not access internal method - // should be (int)Sodium.crypto_sign_bytes(); /** * The number of bytes used to represent a signature. */ - public static final int BYTES = 64; + public static final int BYTES = CryptoProvider.SIGN_BYTES; /** - * Derives the fixed-length (8-byte) libsodium key-derivation context from a context string. + * Derives the fixed-length (8-byte) key-derivation context from a context string. *

* The string is hashed with SHA-256 and the 32-byte digest is folded down to the 8 bytes - * required by {@link KeyDerivation#contextLength()}. + * required by the {@code crypto_kdf} context. *

- * Note: the 8-byte context is a lossy reduction (libsodium's fixed context + * Note: the 8-byte context is a lossy reduction (the fixed context * size), so distinct context strings can still collide and, for the same sub-key id, derive the * same key. Use distinct sub-key ids when strong domain separation is required. * @@ -465,7 +353,7 @@ private static byte[] deriveContextBytes(String context) { if (context.isEmpty()) throw new IllegalArgumentException("context must not be empty"); - final int len = KeyDerivation.contextLength(); // 8 bytes + final int len = CryptoProvider.KDF_CONTEXT_BYTES; // 8 bytes byte[] contextBytes = new byte[len]; try { MessageDigest sha = MessageDigest.getInstance("SHA-256"); @@ -485,9 +373,8 @@ private static byte[] deriveContextBytes(String context) { * @param key the private key to sign the message with. * @return the signature of the message. */ - public static byte[] sign(byte[] message, PrivateKey key) { - // Normally, should never raise SodiumException - return org.apache.tuweni.crypto.sodium.Signature.signDetached(message, key.raw()); + static byte[] sign(byte[] message, PrivateKey key) { + return provider().ed25519Sign(message, key); } /** @@ -498,14 +385,11 @@ public static byte[] sign(byte[] message, PrivateKey key) { * @param key the public key to verify the message with. * @return true if the signature matches the message according to this public key. */ - public static boolean verify(byte[] message, byte[] signature, PublicKey key) { - // Normally, should never raise SodiumException - return org.apache.tuweni.crypto.sodium.Signature.verifyDetached(message, signature, key.raw()); + static boolean verify(byte[] message, byte[] signature, PublicKey key) { + return key.verify(message, signature); } - static { - if (!Sodium.isAvailable()) { - throw new RuntimeException("Sodium native library is not available!"); - } + private static CryptoProvider provider() { + return CryptoProviders.getDefault(); } } \ No newline at end of file diff --git a/api/src/main/resources/META-INF/services/io.bosonnetwork.crypto.CryptoProvider b/api/src/main/resources/META-INF/services/io.bosonnetwork.crypto.CryptoProvider new file mode 100644 index 00000000..d91b3b20 --- /dev/null +++ b/api/src/main/resources/META-INF/services/io.bosonnetwork.crypto.CryptoProvider @@ -0,0 +1 @@ +io.bosonnetwork.crypto.BouncyCastleCryptoProvider diff --git a/api/src/test/java/io/bosonnetwork/crypto/CryptoBoxTests.java b/api/src/test/java/io/bosonnetwork/crypto/CryptoBoxTests.java index 820c97a5..52f7c297 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/CryptoBoxTests.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CryptoBoxTests.java @@ -222,14 +222,8 @@ public void testDestroy() { assertTrue(keyPair.privateKey().isDestroyed()); assertTrue(keyPair.publicKey().isDestroyed()); - IllegalStateException ex = assertThrows(IllegalStateException.class, () -> { - keyPair.privateKey().bytes(); - }); - assertEquals("allocated value has been destroyed", ex.getMessage()); + assertThrows(IllegalStateException.class, () -> keyPair.privateKey().bytes()); - ex = assertThrows(IllegalStateException.class, () -> { - keyPair.publicKey().bytes(); - }); - assertEquals("allocated value has been destroyed", ex.getMessage()); + assertThrows(IllegalStateException.class, () -> keyPair.publicKey().bytes()); } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java new file mode 100644 index 00000000..d855a683 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; + +import org.apache.tuweni.crypto.sodium.Sodium; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * Proves that the production {@link BouncyCastleCryptoProvider} is byte-for-byte compatible + * with libsodium (via {@link SodiumCryptoProvider}) for every primitive Boson uses, and that + * the provider key objects honour the destroy/equality contract. + */ +public class CryptoCompatibilityTest { + private static final CryptoProvider BC = new BouncyCastleCryptoProvider(); + private static final CryptoProvider LS = new SodiumCryptoProvider(); + private static final SecureRandom RND = new SecureRandom(); + + // libsodium crypto_pwhash INTERACTIVE limits for Argon2id. + private static final long OPS = 2L; + private static final long MEM = 67108864L; + + @BeforeAll + static void setup() { + assumeTrue(Sodium.isAvailable(), "Sodium native library is not available"); + } + + private static byte[] rb(int n) { + byte[] b = new byte[n]; + RND.nextBytes(b); + return b; + } + + private static CryptoBox.PrivateKey boxSk(CryptoProvider p, byte[] seed) { + return p.signSecretKeyToBoxSecretKey(p.ed25519SecretKeyFromSeed(seed)); + } + + @Test + void ed25519Matches() { + byte[] seed = rb(32); + byte[] msg = "boson ed25519".getBytes(StandardCharsets.UTF_8); + + Signature.PrivateKey bcSk = BC.ed25519SecretKeyFromSeed(seed); + Signature.PrivateKey lsSk = LS.ed25519SecretKeyFromSeed(seed); + assertArrayEquals(lsSk.bytes(), bcSk.bytes(), "secret key from seed"); + assertArrayEquals(LS.ed25519PublicKeyFromSecretKey(lsSk).bytes(), + BC.ed25519PublicKeyFromSecretKey(bcSk).bytes(), "public key from secret key"); + + byte[] bcSig = BC.ed25519Sign(msg, bcSk); + byte[] lsSig = LS.ed25519Sign(msg, lsSk); + assertArrayEquals(lsSig, bcSig, "deterministic signature"); + + Signature.PublicKey bcPk = BC.ed25519PublicKeyFromSecretKey(bcSk); + Signature.PublicKey lsPk = LS.ed25519PublicKeyFromSecretKey(lsSk); + assertTrue(BC.ed25519Verify(msg, lsSig, bcPk), "BC verifies libsodium signature"); + assertTrue(LS.ed25519Verify(msg, bcSig, lsPk), "libsodium verifies BC signature"); + } + + @Test + void ed25519FromBytesMatches() { + // Regression: PrivateKey.fromBytes must accept the 64-byte (seed || pub) secret key on + // both backends (libsodium rejects a 64-byte value passed to the seed factory). + byte[] seed = rb(32); + byte[] sk64 = BC.ed25519SecretKeyFromSeed(seed).bytes(); + assertEquals(64, sk64.length); + assertArrayEquals(LS.ed25519SecretKeyFromBytes(sk64).bytes(), + BC.ed25519SecretKeyFromBytes(sk64).bytes()); + } + + @Test + void kdfMatches() { + byte[] master = rb(32); + byte[] ctx = "boson-kd".getBytes(StandardCharsets.US_ASCII); + assertArrayEquals(LS.kdfDeriveFromKey(master, 7L, ctx, 32), BC.kdfDeriveFromKey(master, 7L, ctx, 32)); + } + + @Test + void ed25519ToCurveMatches() { + byte[] seed = rb(32); + Signature.PrivateKey bcSk = BC.ed25519SecretKeyFromSeed(seed); + Signature.PrivateKey lsSk = LS.ed25519SecretKeyFromSeed(seed); + assertArrayEquals(LS.signPublicKeyToBoxPublicKey(LS.ed25519PublicKeyFromSecretKey(lsSk)).bytes(), + BC.signPublicKeyToBoxPublicKey(BC.ed25519PublicKeyFromSecretKey(bcSk)).bytes(), "public key conversion"); + assertArrayEquals(LS.signSecretKeyToBoxSecretKey(lsSk).bytes(), + BC.signSecretKeyToBoxSecretKey(bcSk).bytes(), "secret key conversion"); + } + + @Test + void cryptoBoxMatches() throws CryptoException { + byte[] msg = "boson crypto_box payload".getBytes(StandardCharsets.UTF_8); + byte[] seedA = rb(32); + byte[] seedB = rb(32); + CryptoBox.Nonce nonce = BC.boxNonceFromBytes(rb(24)); + + CryptoBox.PrivateKey bcSkA = boxSk(BC, seedA); + CryptoBox.PublicKey bcPkA = BC.boxPublicKeyFromSecretKey(bcSkA); + CryptoBox.PrivateKey bcSkB = boxSk(BC, seedB); + CryptoBox.PublicKey bcPkB = BC.boxPublicKeyFromSecretKey(bcSkB); + + CryptoBox.PrivateKey lsSkA = boxSk(LS, seedA); + CryptoBox.PrivateKey lsSkB = boxSk(LS, seedB); + CryptoBox.PublicKey lsPkB = LS.boxPublicKeyFromSecretKey(lsSkB); + + assertArrayEquals(LS.boxPublicKeyFromSecretKey(lsSkA).bytes(), bcPkA.bytes(), "public key from secret key"); + + byte[] bcBox = BC.boxEncrypt(msg, nonce, bcPkB, bcSkA); + byte[] lsBox = LS.boxEncrypt(msg, nonce, lsPkB, lsSkA); + assertArrayEquals(lsBox, bcBox, "crypto_box ciphertext (validates HSalsa20 beforenm)"); + + // precomputed (beforenm/afternm) path equals the full path on both backends + try (CryptoBox bcPre = BC.boxBeforeNm(bcPkB, bcSkA); CryptoBox lsPre = LS.boxBeforeNm(lsPkB, lsSkA)) { + assertArrayEquals(lsBox, bcPre.encrypt(msg, CryptoBox.Nonce.fromBytes(nonce.bytes())), "BC afternm == full"); + assertArrayEquals(lsBox, lsPre.encrypt(msg, CryptoBox.Nonce.fromBytes(nonce.bytes())), "libsodium afternm == full"); + } + + // receiver opens with sender public key + own secret key, across backends + assertArrayEquals(msg, BC.boxDecrypt(lsBox, nonce, bcPkA, bcSkB), "BC opens libsodium box"); + assertArrayEquals(msg, LS.boxDecrypt(bcBox, nonce, LS.boxPublicKeyFromSecretKey(lsSkA), lsSkB), + "libsodium opens BC box"); + + try (CryptoBox bcDec = BC.boxBeforeNm(bcPkA, bcSkB)) { + assertArrayEquals(msg, bcDec.decrypt(lsBox, CryptoBox.Nonce.fromBytes(nonce.bytes())), "BC afternm opens libsodium box"); + } + } + + @Test + void crossProviderKeyFallback() { + // A key object from one provider must still work when handed to another (keyOf falls + // back to its raw bytes). This is the contract that keeps provider swapping safe. + byte[] msg = "fallback".getBytes(StandardCharsets.UTF_8); + CryptoBox.Nonce nonce = BC.boxNonceFromBytes(rb(24)); + CryptoBox.PrivateKey bcSk = boxSk(BC, rb(32)); + CryptoBox.PrivateKey lsPeerSk = boxSk(LS, rb(32)); + CryptoBox.PublicKey lsPeerPk = LS.boxPublicKeyFromSecretKey(lsPeerSk); + CryptoBox.PublicKey bcSelfPk = BC.boxPublicKeyFromSecretKey(bcSk); + + // BC encrypts using an LS public key object (foreign to BC) + byte[] cipher = BC.boxEncrypt(msg, nonce, lsPeerPk, bcSk); + assertArrayEquals(msg, LS.boxDecrypt(cipher, nonce, bcSelfPk, lsPeerSk), "foreign key object accepted"); + } + + @Test + void boxSeedKeyPairMatches() { + // crypto_box_seed_keypair: BC computes SHA-512(seed)[0..32] (unclamped); libsodium uses + // Box.KeyPair.fromSeed. They must agree on both the secret and derived public key. + byte[] seed = rb(32); + assertArrayEquals(LS.boxSecretKeyFromSeed(seed).bytes(), BC.boxSecretKeyFromSeed(seed).bytes(), + "box seed -> secret key"); + assertArrayEquals( + LS.boxPublicKeyFromSecretKey(LS.boxSecretKeyFromSeed(seed)).bytes(), + BC.boxPublicKeyFromSecretKey(BC.boxSecretKeyFromSeed(seed)).bytes(), + "box seed -> public key"); + } + + @Test + void nonceIncrementMatches() { + byte[] init = rb(24); + CryptoBox.Nonce bc = BC.boxNonceFromBytes(init); + CryptoBox.Nonce ls = LS.boxNonceFromBytes(init); + assertArrayEquals(ls.bytes(), bc.bytes()); + for (int i = 0; i < 5; i++) { + bc = bc.increment(); + ls = ls.increment(); + assertArrayEquals(ls.bytes(), bc.bytes(), "increment step " + i); + } + + // carry across byte boundaries (little-endian sodium_increment) + byte[] edge = new byte[24]; + edge[0] = (byte) 0xff; + edge[1] = (byte) 0xff; + assertArrayEquals( + LS.boxNonceFromBytes(edge).increment().bytes(), + BC.boxNonceFromBytes(edge).increment().bytes(), "carry"); + } + + @Test + void sealedBoxMatches() { + byte[] msg = "boson sealed box".getBytes(StandardCharsets.UTF_8); + byte[] seed = rb(32); + CryptoBox.PrivateKey bcSk = boxSk(BC, seed); + CryptoBox.PublicKey bcPk = BC.boxPublicKeyFromSecretKey(bcSk); + CryptoBox.PrivateKey lsSk = boxSk(LS, seed); + CryptoBox.PublicKey lsPk = LS.boxPublicKeyFromSecretKey(lsSk); + + assertArrayEquals(msg, BC.boxSealOpen(LS.boxSeal(msg, lsPk), bcPk, bcSk), "BC opens libsodium sealed box"); + assertArrayEquals(msg, LS.boxSealOpen(BC.boxSeal(msg, bcPk), lsPk, lsSk), "libsodium opens BC sealed box"); + } + + @Test + void keyEqualityAndDestroy() { + byte[] seed = rb(32); + + // value equality (not identity), on both providers + assertEquals(BC.ed25519SecretKeyFromSeed(seed), BC.ed25519SecretKeyFromSeed(seed)); + assertEquals(LS.ed25519SecretKeyFromSeed(seed), LS.ed25519SecretKeyFromSeed(seed)); + assertEquals(BC.ed25519SecretKeyFromSeed(seed).hashCode(), BC.ed25519SecretKeyFromSeed(seed).hashCode()); + assertNotEquals(BC.ed25519SecretKeyFromSeed(seed), BC.ed25519SecretKeyFromSeed(rb(32))); + + // wrapper KeyPair equality flows through the key objects + assertEquals(Signature.KeyPair.fromSeed(seed), Signature.KeyPair.fromSeed(seed)); + assertEquals(CryptoBox.KeyPair.fromSeed(seed), CryptoBox.KeyPair.fromSeed(seed)); + + // destroy() wipes and blocks further use on both backends + for (Signature.PrivateKey sk : new Signature.PrivateKey[] { + BC.ed25519SecretKeyFromSeed(seed), LS.ed25519SecretKeyFromSeed(seed) }) { + assertFalse(sk.isDestroyed()); + sk.destroy(); + assertTrue(sk.isDestroyed()); + assertThrows(Exception.class, sk::bytes); + } + + CryptoBox.PrivateKey boxSk = BC.signSecretKeyToBoxSecretKey(BC.ed25519SecretKeyFromSeed(seed)); + boxSk.destroy(); + assertTrue(boxSk.isDestroyed()); + assertThrows(Exception.class, boxSk::bytes); + } + + @Test + void pwHashRawMatches() { + byte[] pw = "correct horse battery staple".getBytes(StandardCharsets.UTF_8); + byte[] salt = rb(16); + assertArrayEquals( + LS.pwHash(pw, 32, salt, OPS, MEM, CryptoProvider.PWHASH_ALG_ARGON2ID13), + BC.pwHash(pw, 32, salt, OPS, MEM, CryptoProvider.PWHASH_ALG_ARGON2ID13)); + } + + @Test + void pwHashStringInteroperatesBothDirections() { + byte[] pw = "correct horse battery staple".getBytes(StandardCharsets.UTF_8); + + String lsPhc = LS.pwHashString(pw, OPS, MEM, CryptoProvider.PWHASH_ALG_ARGON2ID13); + assertTrue(BC.pwHashVerify(lsPhc, pw), "BC verifies a libsodium-generated PHC string"); + + String bcPhc = BC.pwHashString(pw, OPS, MEM, CryptoProvider.PWHASH_ALG_ARGON2ID13); + assertTrue(LS.pwHashVerify(bcPhc, pw), "libsodium verifies a BC-generated PHC string"); + assertTrue(BC.pwHashVerify(bcPhc, pw), "BC verifies its own PHC string"); + assertFalse(BC.pwHashVerify(bcPhc, "wrong".getBytes(StandardCharsets.UTF_8)), "wrong password rejected"); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/crypto/PasswordHashTests.java b/api/src/test/java/io/bosonnetwork/crypto/PasswordHashTests.java index 011c44c8..0e485134 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/PasswordHashTests.java +++ b/api/src/test/java/io/bosonnetwork/crypto/PasswordHashTests.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import io.bosonnetwork.crypto.PasswordHash.Algorithm; -import io.bosonnetwork.crypto.PasswordHash.Salt; public class PasswordHashTests { @Test @@ -75,14 +74,11 @@ void testHashSensitive() { void testKeyDeriveInteractive() { var password = "secret"; - var salt = Salt.random(); + var salt = Random.randomBytesSecure(PasswordHash.SALT_BYTES); var key = PasswordHash.hashInteractive(password, 32, salt, Algorithm.ARGON2ID13); assertEquals(32, key.length); - var salt2 = Salt.fromBytes(salt.bytes()); - assertEquals(salt, salt2); - - var key2 = PasswordHash.hashInteractive(password, 32, salt2, Algorithm.DEFAULT); + var key2 = PasswordHash.hashInteractive(password, 32, salt, Algorithm.DEFAULT); assertEquals(32, key2.length); assertArrayEquals(key, key2); } @@ -91,14 +87,11 @@ void testKeyDeriveInteractive() { void testKeyDeriveModerate() { var password = "secret"; - var salt = Salt.random(); + var salt = Random.randomBytesSecure(PasswordHash.SALT_BYTES); var key = PasswordHash.hashModerate(password, 32, salt, Algorithm.ARGON2ID13); assertEquals(32, key.length); - var salt2 = Salt.fromBytes(salt.bytes()); - assertEquals(salt, salt2); - - var key2 = PasswordHash.hashModerate(password, 32, salt2, Algorithm.DEFAULT); + var key2 = PasswordHash.hashModerate(password, 32, salt, Algorithm.DEFAULT); assertEquals(32, key2.length); assertArrayEquals(key, key2); } @@ -107,15 +100,12 @@ void testKeyDeriveModerate() { void testKeyDeriveSensitive() { var password = "secret"; - var salt = Salt.random(); + var salt = Random.randomBytesSecure(PasswordHash.SALT_BYTES); var key = PasswordHash.hashSensitive(password, 32, salt, Algorithm.ARGON2ID13); assertEquals(32, key.length); - var salt2 = Salt.fromBytes(salt.bytes()); - assertEquals(salt, salt2); - - var key2 = PasswordHash.hashSensitive(password, 32, salt2, Algorithm.DEFAULT); + var key2 = PasswordHash.hashSensitive(password, 32, salt, Algorithm.DEFAULT); assertEquals(32, key2.length); assertArrayEquals(key, key2); } -} +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/crypto/SignatureTests.java b/api/src/test/java/io/bosonnetwork/crypto/SignatureTests.java index 9a1f4222..b8b0130d 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/SignatureTests.java +++ b/api/src/test/java/io/bosonnetwork/crypto/SignatureTests.java @@ -110,11 +110,9 @@ public void testDestroy() { assertTrue(keyPair.privateKey().isDestroyed()); assertTrue(keyPair.publicKey().isDestroyed()); - var ex = assertThrows(IllegalStateException.class, () -> keyPair.privateKey().bytes()); - assertEquals("allocated value has been destroyed", ex.getMessage()); + assertThrows(IllegalStateException.class, () -> keyPair.privateKey().bytes()); - ex = assertThrows(IllegalStateException.class, () -> keyPair.publicKey().bytes()); - assertEquals("allocated value has been destroyed", ex.getMessage()); + assertThrows(IllegalStateException.class, () -> keyPair.publicKey().bytes()); } @Test diff --git a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java new file mode 100644 index 00000000..ce861f68 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java @@ -0,0 +1,439 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import org.apache.tuweni.crypto.sodium.Box; +import org.apache.tuweni.crypto.sodium.KeyDerivation; +import org.apache.tuweni.crypto.sodium.PasswordHash; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Test-only {@link CryptoProvider} backed by libsodium through Apache Tuweni. It exists solely + * so the crypto compatibility test can verify, primitive by primitive, that the production + * {@link BouncyCastleCryptoProvider} stays byte-for-byte compatible with libsodium. + *

+ * Key, nonce and precomputed-box objects wrap the corresponding native Tuweni handles directly: + * {@link #boxBeforeNm} returns a {@link CryptoBox} backed by a real Tuweni {@link Box} (from + * {@link Box#forKeys}) whose native shared key is released on {@code close()}. A foreign key + * object created by another provider is accepted by reconstructing the Tuweni handle from its + * raw bytes. + */ +@NullMarked +public class SodiumCryptoProvider implements CryptoProvider { + @Override + public String name() { + return "libsodium"; + } + + private static class Ed25519SecretKey implements Signature.PrivateKey { + private final org.apache.tuweni.crypto.sodium.Signature.SecretKey key; + + private Ed25519SecretKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey key) { + this.key = key; + } + + @Override + public byte[] seed() { + // guard before touching native memory: bytesArray() after destroy() is a use-after-free + if (isDestroyed()) + throw new IllegalStateException("Private key has been destroyed"); + return Arrays.copyOfRange(key.bytesArray(), 0, SIGN_SEED_BYTES); + } + + @Override + public byte[] bytes() { + if (isDestroyed()) + throw new IllegalStateException("Private key has been destroyed"); + return key.bytesArray(); + } + + @Override + public void destroy() { + key.destroy(); + } + + @Override + public boolean isDestroyed() { + return key.isDestroyed(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof Signature.PrivateKey that) || isDestroyed() || that.isDestroyed()) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + private static class Ed25519PublicKey implements Signature.PublicKey { + private final org.apache.tuweni.crypto.sodium.Signature.PublicKey key; + + private Ed25519PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey key) { + this.key = key; + } + + @Override + public byte[] bytes() { + if (isDestroyed()) + throw new IllegalStateException("Public key has been destroyed"); + return key.bytesArray(); + } + + @Override + public void destroy() { + key.destroy(); + } + + @Override + public boolean isDestroyed() { + return key.isDestroyed(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof Signature.PublicKey that) || isDestroyed() || that.isDestroyed()) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + @Override + public Signature.PrivateKey ed25519SecretKeyFromSeed(byte[] seed) { + // Use KeyPair.fromSeed to obtain the full 64-byte secret key (seed || public key); + // SecretKey.fromSeed alone does not expand it, which corrupts later sk_to_pk reads. + org.apache.tuweni.crypto.sodium.Signature.KeyPair kp = + org.apache.tuweni.crypto.sodium.Signature.KeyPair.fromSeed( + org.apache.tuweni.crypto.sodium.Signature.Seed.fromBytes(seed)); + return new Ed25519SecretKey(kp.secretKey()); + } + + @Override + public Signature.PrivateKey ed25519SecretKeyFromBytes(byte[] key) { + org.apache.tuweni.crypto.sodium.Signature.SecretKey sk = + org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(key); + return new Ed25519SecretKey(sk); + } + + private static org.apache.tuweni.crypto.sodium.Signature.SecretKey keyOf(Signature.PrivateKey secretKey) { + return secretKey instanceof Ed25519SecretKey k ? k.key : + org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(secretKey.bytes()); + } + + private static org.apache.tuweni.crypto.sodium.Signature.PublicKey keyOf(Signature.PublicKey publicKey) { + return publicKey instanceof Ed25519PublicKey k ? k.key : + org.apache.tuweni.crypto.sodium.Signature.PublicKey.fromBytes(publicKey.bytes()); + } + + @Override + public Signature.PublicKey ed25519PublicKeyFromSecretKey(Signature.PrivateKey secretKey) { + org.apache.tuweni.crypto.sodium.Signature.PublicKey pk = + org.apache.tuweni.crypto.sodium.Signature.KeyPair.forSecretKey(keyOf(secretKey)).publicKey(); + return new Ed25519PublicKey(pk); + } + + @Override + public Signature.PublicKey ed25519PublicKeyFromBytes(byte[] key) { + org.apache.tuweni.crypto.sodium.Signature.PublicKey pk = + org.apache.tuweni.crypto.sodium.Signature.PublicKey.fromBytes(key); + return new Ed25519PublicKey(pk); + } + + @Override + public byte[] ed25519Sign(byte[] message, Signature.PrivateKey secretKey) { + return org.apache.tuweni.crypto.sodium.Signature.signDetached(message, keyOf(secretKey)); + } + + @Override + public boolean ed25519Verify(byte[] message, byte[] signature, Signature.PublicKey publicKey) { + return org.apache.tuweni.crypto.sodium.Signature.verifyDetached(message, signature, keyOf(publicKey)); + } + + @Override + public byte[] kdfDeriveFromKey(byte[] masterKey, long subKeyId, byte[] context, int subKeyLength) { + return KeyDerivation.MasterKey.fromBytes(masterKey).deriveKeyArray(subKeyLength, subKeyId, context); + } + + private static class SodiumBoxPublicKey implements CryptoBox.PublicKey { + private final Box.PublicKey key; + + private SodiumBoxPublicKey(Box.PublicKey key) { + this.key = key; + } + + @Override + public byte[] bytes() { + if (isDestroyed()) + throw new IllegalStateException("Public key has been destroyed"); + return key.bytesArray(); + } + + @Override + public void destroy() { + key.destroy(); + } + + @Override + public boolean isDestroyed() { + return key.isDestroyed(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof CryptoBox.PublicKey that) || isDestroyed() || that.isDestroyed()) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + private static class SodiumBoxSecretKey implements CryptoBox.PrivateKey { + private final Box.SecretKey key; + + private SodiumBoxSecretKey(Box.SecretKey key) { + this.key = key; + } + + @Override + public byte[] bytes() { + if (isDestroyed()) + throw new IllegalStateException("Private key has been destroyed"); + return key.bytesArray(); + } + + @Override + public void destroy() { + key.destroy(); + } + + @Override + public boolean isDestroyed() { + return key.isDestroyed(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof CryptoBox.PrivateKey that) || isDestroyed() || that.isDestroyed()) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + + @Override + public int hashCode() { + return isDestroyed() ? 0 : Arrays.hashCode(bytes()); + } + } + + private static class SodiumBoxNonce implements CryptoBox.Nonce { + private final Box.Nonce nonce; + + private SodiumBoxNonce(Box.Nonce nonce) { + this.nonce = nonce; + } + + @Override + public CryptoBox.Nonce increment() { + return new SodiumBoxNonce(nonce.increment()); + } + + @Override + public byte[] bytes() { + return nonce.bytesArray(); + } + + @Override + public int hashCode() { + return nonce.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof CryptoBox.Nonce that)) + return false; + return Arrays.equals(bytes(), that.bytes()); + } + } + + // Holds the real precomputed Tuweni Box (crypto_box_beforenm), released on close(). + private static class SodiumCryptoBox implements CryptoBox { + private final Box box; + private boolean destroyed = false; + + private SodiumCryptoBox(Box box) { + this.box = box; + } + + @Override + public byte[] encrypt(byte[] message, CryptoBox.Nonce nonce) { + return box.encrypt(message, nonceOf(nonce)); + } + + @Override + public byte[] decrypt(byte[] cipher, CryptoBox.Nonce nonce) throws CryptoException { + byte[] plain = box.decrypt(cipher, nonceOf(nonce)); + if (plain == null) + throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); + return plain; + } + + @Override + public void close() { + destroy(); + } + + @Override + public void destroy() { + if (!destroyed) { + box.close(); + destroyed = true; + } + } + + @Override + public boolean isDestroyed() { + return destroyed; + } + } + + private static Box.PublicKey keyOf(CryptoBox.PublicKey publicKey) { + return publicKey instanceof SodiumBoxPublicKey k ? k.key : Box.PublicKey.fromBytes(publicKey.bytes()); + } + + private static Box.SecretKey keyOf(CryptoBox.PrivateKey secretKey) { + return secretKey instanceof SodiumBoxSecretKey k ? k.key : Box.SecretKey.fromBytes(secretKey.bytes()); + } + + @Override + public CryptoBox.PublicKey signPublicKeyToBoxPublicKey(Signature.PublicKey publicKey) { + return new SodiumBoxPublicKey(Box.PublicKey.forSignaturePublicKey(keyOf(publicKey))); + } + + @Override + public CryptoBox.PrivateKey signSecretKeyToBoxSecretKey(Signature.PrivateKey secretKey) { + return new SodiumBoxSecretKey(Box.SecretKey.forSignatureSecretKey(keyOf(secretKey))); + } + + @Override + public CryptoBox.PrivateKey boxSecretKeyFromSeed(byte[] seed) { + return new SodiumBoxSecretKey(Box.KeyPair.fromSeed(Box.Seed.fromBytes(seed)).secretKey()); + } + + @Override + public CryptoBox.PublicKey boxPublicKeyFromBytes(byte[] bytes) { + return new SodiumBoxPublicKey(Box.PublicKey.fromBytes(bytes)); + } + + @Override + public CryptoBox.PrivateKey boxSecretKeyFromBytes(byte[] bytes) { + return new SodiumBoxSecretKey(Box.SecretKey.fromBytes(bytes)); + } + + @Override + public CryptoBox.PublicKey boxPublicKeyFromSecretKey(CryptoBox.PrivateKey secretKey) { + return new SodiumBoxPublicKey(Box.KeyPair.forSecretKey(keyOf(secretKey)).publicKey()); + } + + @Override + public CryptoBox.Nonce boxNonceFromBytes(byte[] bytes) { + return new SodiumBoxNonce(Box.Nonce.fromBytes(bytes)); + } + + @Override + public CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return new SodiumCryptoBox(Box.forKeys(keyOf(publicKey), keyOf(secretKey))); + } + + private static Box.Nonce nonceOf(CryptoBox.Nonce nonce) { + return nonce instanceof SodiumBoxNonce n ? n.nonce : Box.Nonce.fromBytes(nonce.bytes()); + } + + @Override + public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return Box.encrypt(message, keyOf(publicKey), keyOf(secretKey), nonceOf(nonce)); + } + + @Override + public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return Box.decrypt(cipher, keyOf(publicKey), keyOf(secretKey), nonceOf(nonce)); + } + + @Override + public byte[] boxSeal(byte[] message, CryptoBox.PublicKey publicKey) { + return Box.encryptSealed(message, keyOf(publicKey)); + } + + @Override + public byte @Nullable [] boxSealOpen(byte[] cipher, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) { + return Box.decryptSealed(cipher, keyOf(publicKey), keyOf(secretKey)); + } + + @Override + public byte[] pwHash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, int algorithm) { + return PasswordHash.hash(password, length, PasswordHash.Salt.fromBytes(salt), opsLimit, memLimit, + algorithm == PWHASH_ALG_ARGON2I13 ? PasswordHash.Algorithm.argon2i13() + : PasswordHash.Algorithm.argon2id13()); + } + + @Override + public String pwHashString(byte[] password, long opsLimit, long memLimit, int algorithm) { + return PasswordHash.hash(new String(password, StandardCharsets.UTF_8), opsLimit, memLimit); + } + + @Override + public boolean pwHashVerify(String hash, byte[] password) { + return PasswordHash.verify(hash, new String(password, StandardCharsets.UTF_8)); + } + + @Override + public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) { + return PasswordHash.needsRehash(hash); + } +} \ No newline at end of file From a888a690b8b09c156df42e534c56ca3cd951745a Mon Sep 17 00:00:00 2001 From: Jingyu Date: Thu, 25 Jun 2026 21:56:51 +0800 Subject: [PATCH 06/10] Expand compatibility test coverage to ensure BC and Libsodium produce compatible results --- .../crypto/CryptoCompatibilityTest.java | 190 ++++++++++++++++++ .../crypto/SodiumCryptoProvider.java | 4 +- 2 files changed, 193 insertions(+), 1 deletion(-) diff --git a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java index d855a683..25bf65cb 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java @@ -26,6 +26,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -217,6 +218,195 @@ void sealedBoxMatches() { assertArrayEquals(msg, LS.boxSealOpen(BC.boxSeal(msg, bcPk), lsPk, lsSk), "libsodium opens BC sealed box"); } + // Message sizes exercising XSalsa20 / Poly1305 block boundaries (empty, sub-block, exact + // block multiples, +/- 1) plus a multi-block payload. + private static final int[] SIZES = {0, 1, 15, 16, 17, 31, 32, 33, 63, 64, 65, 127, 128, 255, 256, 1000}; + + @Test + void ed25519PublicKeyFromBytesMatches() { + for (int i = 0; i < 16; i++) { + byte[] seed = rb(32); + byte[] msg = rb(1 + (i * 13)); + byte[] pkBytes = BC.ed25519PublicKeyFromSecretKey(BC.ed25519SecretKeyFromSeed(seed)).bytes(); + + // round-trip identical on both backends + assertArrayEquals(pkBytes, BC.ed25519PublicKeyFromBytes(pkBytes).bytes()); + assertArrayEquals(pkBytes, LS.ed25519PublicKeyFromBytes(pkBytes).bytes()); + + // a from-bytes public key verifies a real signature on both backends + byte[] sig = LS.ed25519Sign(msg, LS.ed25519SecretKeyFromSeed(seed)); + assertTrue(BC.ed25519Verify(msg, sig, BC.ed25519PublicKeyFromBytes(pkBytes))); + assertTrue(LS.ed25519Verify(msg, sig, LS.ed25519PublicKeyFromBytes(pkBytes))); + } + } + + @Test + void ed25519SignMatchesAcrossSizesAndSeeds() { + for (int iter = 0; iter < 8; iter++) { + byte[] seed = rb(32); + Signature.PrivateKey bcSk = BC.ed25519SecretKeyFromSeed(seed); + Signature.PrivateKey lsSk = LS.ed25519SecretKeyFromSeed(seed); + assertArrayEquals(lsSk.seed(), bcSk.seed(), "seed()"); + Signature.PublicKey bcPk = BC.ed25519PublicKeyFromSecretKey(bcSk); + Signature.PublicKey lsPk = LS.ed25519PublicKeyFromSecretKey(lsSk); + + for (int size : SIZES) { + byte[] msg = rb(size); + byte[] bcSig = BC.ed25519Sign(msg, bcSk); + byte[] lsSig = LS.ed25519Sign(msg, lsSk); + assertArrayEquals(lsSig, bcSig, "signature size=" + size); + assertTrue(BC.ed25519Verify(msg, lsSig, bcPk), "BC verify size=" + size); + assertTrue(LS.ed25519Verify(msg, bcSig, lsPk), "LS verify size=" + size); + } + } + } + + @Test + void kdfMatchesAcrossLengthsIdsAndContexts() { + byte[][] contexts = { + "boson-kd".getBytes(StandardCharsets.US_ASCII), + "01234567".getBytes(StandardCharsets.US_ASCII), + new byte[8] }; + long[] ids = {0L, 1L, 42L, 0xFFFFFFFFL, Long.MAX_VALUE}; + int[] lengths = {16, 24, 32, 48, 64}; + for (int i = 0; i < 8; i++) { + byte[] master = rb(32); + for (byte[] ctx : contexts) + for (long id : ids) + for (int len : lengths) + assertArrayEquals(LS.kdfDeriveFromKey(master, id, ctx, len), + BC.kdfDeriveFromKey(master, id, ctx, len), + "kdf len=" + len + " id=" + id); + } + } + + @Test + void boxMatchesAcrossSizes() throws CryptoException { + for (int iter = 0; iter < 4; iter++) { + byte[] seedA = rb(32); + byte[] seedB = rb(32); + CryptoBox.PrivateKey bcSkA = boxSk(BC, seedA); + CryptoBox.PublicKey bcPkA = BC.boxPublicKeyFromSecretKey(bcSkA); + CryptoBox.PrivateKey bcSkB = boxSk(BC, seedB); + CryptoBox.PublicKey bcPkB = BC.boxPublicKeyFromSecretKey(bcSkB); + CryptoBox.PrivateKey lsSkA = boxSk(LS, seedA); + CryptoBox.PublicKey lsPkA = LS.boxPublicKeyFromSecretKey(lsSkA); + CryptoBox.PrivateKey lsSkB = boxSk(LS, seedB); + CryptoBox.PublicKey lsPkB = LS.boxPublicKeyFromSecretKey(lsSkB); + CryptoBox.Nonce nonce = BC.boxNonceFromBytes(rb(24)); + CryptoBox.Nonce lsNonce = LS.boxNonceFromBytes(nonce.bytes()); + + for (int size : SIZES) { + byte[] msg = rb(size); + byte[] bcBox = BC.boxEncrypt(msg, nonce, bcPkB, bcSkA); + byte[] lsBox = LS.boxEncrypt(msg, lsNonce, lsPkB, lsSkA); + assertArrayEquals(lsBox, bcBox, "box ciphertext size=" + size); + assertArrayEquals(msg, BC.boxDecrypt(lsBox, nonce, bcPkA, bcSkB), "BC opens LS size=" + size); + assertArrayEquals(msg, LS.boxDecrypt(bcBox, lsNonce, lsPkA, lsSkB), "LS opens BC size=" + size); + } + } + } + + @Test + void boxFromBytesRoundTrips() { + byte[] seedA = rb(32); + byte[] seedB = rb(32); + byte[] skABytes = BC.boxSecretKeyFromSeed(seedA).bytes(); + byte[] skBBytes = BC.boxSecretKeyFromSeed(seedB).bytes(); + byte[] pkBBytes = BC.boxPublicKeyFromSecretKey(BC.boxSecretKeyFromBytes(skBBytes)).bytes(); + + // raw bytes survive the round trip identically on both backends + assertArrayEquals(skABytes, BC.boxSecretKeyFromBytes(skABytes).bytes()); + assertArrayEquals(skABytes, LS.boxSecretKeyFromBytes(skABytes).bytes()); + assertArrayEquals(pkBBytes, BC.boxPublicKeyFromBytes(pkBBytes).bytes()); + assertArrayEquals(pkBBytes, LS.boxPublicKeyFromBytes(pkBBytes).bytes()); + + // and from-bytes keys produce byte-identical ciphertext + byte[] msg = rb(100); + byte[] nonceBytes = rb(24); + byte[] bcBox = BC.boxEncrypt(msg, BC.boxNonceFromBytes(nonceBytes), + BC.boxPublicKeyFromBytes(pkBBytes), BC.boxSecretKeyFromBytes(skABytes)); + byte[] lsBox = LS.boxEncrypt(msg, LS.boxNonceFromBytes(nonceBytes), + LS.boxPublicKeyFromBytes(pkBBytes), LS.boxSecretKeyFromBytes(skABytes)); + assertArrayEquals(lsBox, bcBox, "from-bytes key ciphertext"); + } + + @Test + void boxAuthFailureRejectedByBoth() { + byte[] seedA = rb(32); + byte[] seedB = rb(32); + CryptoBox.PrivateKey bcSkA = boxSk(BC, seedA); + CryptoBox.PublicKey bcPkA = BC.boxPublicKeyFromSecretKey(bcSkA); + CryptoBox.PrivateKey bcSkB = boxSk(BC, seedB); + CryptoBox.PublicKey bcPkB = BC.boxPublicKeyFromSecretKey(bcSkB); + CryptoBox.PrivateKey lsSkA = boxSk(LS, seedA); + CryptoBox.PublicKey lsPkA = LS.boxPublicKeyFromSecretKey(lsSkA); + CryptoBox.PrivateKey lsSkB = boxSk(LS, seedB); + + CryptoBox.Nonce nonce = BC.boxNonceFromBytes(rb(24)); + byte[] box = BC.boxEncrypt(rb(64), nonce, bcPkB, bcSkA); + + // tampered ciphertext is rejected (null) by both backends + byte[] tampered = box.clone(); + tampered[tampered.length - 1] ^= 0x01; + assertNull(BC.boxDecrypt(tampered, nonce, bcPkA, bcSkB), "BC rejects tampered"); + assertNull(LS.boxDecrypt(tampered, nonce, lsPkA, lsSkB), "LS rejects tampered"); + + // wrong nonce is rejected by both + CryptoBox.Nonce wrong = BC.boxNonceFromBytes(rb(24)); + assertNull(BC.boxDecrypt(box, wrong, bcPkA, bcSkB), "BC rejects wrong nonce"); + assertNull(LS.boxDecrypt(box, LS.boxNonceFromBytes(wrong.bytes()), lsPkA, lsSkB), "LS rejects wrong nonce"); + } + + @Test + void sealedBoxAuthFailureRejectedByBoth() { + byte[] seed = rb(32); + CryptoBox.PrivateKey bcSk = boxSk(BC, seed); + CryptoBox.PublicKey bcPk = BC.boxPublicKeyFromSecretKey(bcSk); + CryptoBox.PrivateKey lsSk = boxSk(LS, seed); + CryptoBox.PublicKey lsPk = LS.boxPublicKeyFromSecretKey(lsSk); + + byte[] sealed = BC.boxSeal(rb(48), bcPk); + byte[] tampered = sealed.clone(); + tampered[tampered.length - 1] ^= 0x01; + assertNull(BC.boxSealOpen(tampered, bcPk, bcSk), "BC rejects tampered sealed box"); + assertNull(LS.boxSealOpen(tampered, lsPk, lsSk), "LS rejects tampered sealed box"); + } + + @Test + void pwHashArgon2iMatches() { + // Argon2i (distinct from Argon2id): interactive limits (ops=4, mem=32 MiB). + byte[] pw = "correct horse battery staple".getBytes(StandardCharsets.UTF_8); + byte[] salt = rb(16); + long ops = 4L; + long mem = 33554432L; + assertArrayEquals( + LS.pwHash(pw, 32, salt, ops, mem, CryptoProvider.PWHASH_ALG_ARGON2I13), + BC.pwHash(pw, 32, salt, ops, mem, CryptoProvider.PWHASH_ALG_ARGON2I13), + "argon2i raw"); + // a different output length, still byte-identical + assertArrayEquals( + LS.pwHash(pw, 64, salt, ops, mem, CryptoProvider.PWHASH_ALG_ARGON2I13), + BC.pwHash(pw, 64, salt, ops, mem, CryptoProvider.PWHASH_ALG_ARGON2I13), + "argon2i raw len=64"); + } + + @Test + void pwHashNeedsRehashMatches() { + byte[] pw = "correct horse battery staple".getBytes(StandardCharsets.UTF_8); + String phc = BC.pwHashString(pw, OPS, MEM, CryptoProvider.PWHASH_ALG_ARGON2ID13); + + // same limits -> no rehash; changed ops or mem -> rehash. Both backends must agree. + assertEquals(LS.pwHashNeedsRehash(phc, OPS, MEM), BC.pwHashNeedsRehash(phc, OPS, MEM)); + assertFalse(BC.pwHashNeedsRehash(phc, OPS, MEM), "matching params -> no rehash"); + + assertEquals(LS.pwHashNeedsRehash(phc, OPS + 1, MEM), BC.pwHashNeedsRehash(phc, OPS + 1, MEM)); + assertTrue(BC.pwHashNeedsRehash(phc, OPS + 1, MEM), "more ops -> rehash"); + + assertEquals(LS.pwHashNeedsRehash(phc, OPS, MEM * 2), BC.pwHashNeedsRehash(phc, OPS, MEM * 2)); + assertTrue(BC.pwHashNeedsRehash(phc, OPS, MEM * 2), "more mem -> rehash"); + } + @Test void keyEqualityAndDestroy() { byte[] seed = rb(32); diff --git a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java index ce861f68..45b00593 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java +++ b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java @@ -434,6 +434,8 @@ public boolean pwHashVerify(String hash, byte[] password) { @Override public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) { - return PasswordHash.needsRehash(hash); + // Honour the requested limits (matches libsodium crypto_pwhash_str_needs_rehash); + // the no-arg needsRehash(hash) would compare against the MODERATE defaults instead. + return PasswordHash.needsRehash(hash, opsLimit, memLimit); } } \ No newline at end of file From ae60b93d7784d19da99dafcb4d922ce840d4da87 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Thu, 25 Jun 2026 23:22:41 +0800 Subject: [PATCH 07/10] Improve error handling in boson crypto APIs --- .../crypto/BouncyCastleCryptoProvider.java | 30 ++++---- .../io/bosonnetwork/crypto/CryptoBox.java | 69 ++++++++++++++----- .../bosonnetwork/crypto/CryptoProvider.java | 43 +++++++++++- .../io/bosonnetwork/crypto/PasswordHash.java | 59 +++++++++++----- .../io/bosonnetwork/crypto/Signature.java | 34 ++++++--- .../crypto/CryptoCompatibilityTest.java | 61 +++++++++++++++- .../crypto/SodiumCryptoProvider.java | 30 ++++---- 7 files changed, 253 insertions(+), 73 deletions(-) diff --git a/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java b/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java index 52fa8505..7f9ac0f1 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java +++ b/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java @@ -410,19 +410,6 @@ private byte[] sharedKeyOrThrow() { return sharedKey; } - @Override - public byte[] encrypt(byte[] message, CryptoBox.Nonce nonce) { - return secretboxSeal(message, nonceOf(nonce), sharedKeyOrThrow()); - } - - @Override - public byte[] decrypt(byte[] cipher, CryptoBox.Nonce nonce) throws CryptoException { - byte[] plain = secretboxOpen(cipher, nonceOf(nonce), sharedKeyOrThrow()); - if (plain == null) - throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); - return plain; - } - @Override public void close() { destroy(); @@ -491,6 +478,23 @@ public CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey return new BcCryptoBox(sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey))); } + private static byte[] sharedKeyOf(CryptoBox box) { + if (box instanceof BcCryptoBox c) + return c.sharedKeyOrThrow(); + + throw new IllegalStateException("Not a BcCryptoBox: " + box.getClass().getName()); + } + + @Override + public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox box) { + return secretboxSeal(message, nonceOf(nonce), sharedKeyOf(box)); + } + + @Override + public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox box) { + return secretboxOpen(cipher, nonceOf(nonce), sharedKeyOf(box)); + } + private static byte[] nonceOf(CryptoBox.Nonce nonce) { return nonce instanceof BcBoxNonce n ? n.nonce : nonce.bytes(); } diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java index 48ef9f01..5bd39ec1 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java @@ -354,27 +354,37 @@ static CryptoBox fromKeys(PublicKey pk, PrivateKey sk) { /** * Encrypt a message with the given keys. * - * @param message the message to encrypt. - * @param receiver the public key of the receiver. - * @param sender the private key of the sender. - * @param nonce a unique nonce object. + * @param message the message to encrypt. Must not be null. + * @param receiver the public key of the receiver. Must not be null. + * @param sender the private key of the sender. Must not be null. + * @param nonce a unique nonce object. Must not be null. * @return the encrypted data. + * @throws NullPointerException if any argument is null. */ static byte[] encrypt(byte[] message, PublicKey receiver, PrivateKey sender, Nonce nonce) { + Objects.requireNonNull(message, "message"); + Objects.requireNonNull(receiver, "receiver"); + Objects.requireNonNull(sender, "sender"); + Objects.requireNonNull(nonce, "nonce"); return provider().boxEncrypt(message, nonce, receiver, sender); } /** * Decrypt a message using the given keys. * - * @param cipher the cipher text to decrypt. - * @param sender the public key of the sender. - * @param receiver the private key of the receiver. - * @param nonce the nonce that was used for encryption. + * @param cipher the cipher text to decrypt. Must not be null. + * @param sender the public key of the sender. Must not be null. + * @param receiver the private key of the receiver. Must not be null. + * @param nonce the nonce that was used for encryption. Must not be null. * @return the decrypted data. + * @throws NullPointerException if any argument is null. * @throws CryptoException if the verification or decryption failed. */ static byte[] decrypt(byte[] cipher, PublicKey sender, PrivateKey receiver, Nonce nonce) throws CryptoException { + Objects.requireNonNull(cipher, "cipher"); + Objects.requireNonNull(sender, "sender"); + Objects.requireNonNull(receiver, "receiver"); + Objects.requireNonNull(nonce, "nonce"); byte[] plain = provider().boxDecrypt(cipher, nonce, sender, receiver); if (plain == null) throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); @@ -388,24 +398,31 @@ static byte[] decrypt(byte[] cipher, PublicKey sender, PrivateKey receiver, Nonc * Sealed boxes are designed to anonymously send messages to a recipient given its public key. * Only the recipient can decrypt these messages, using its private key. * - * @param message the message to encrypt. - * @param receiver the public key of the receiver. + * @param message the message to encrypt. Must not be null. + * @param receiver the public key of the receiver. Must not be null. * @return the encrypted data. + * @throws NullPointerException if {@code message} or {@code receiver} is null. */ static byte[] encryptSealed(byte[] message, PublicKey receiver) { + Objects.requireNonNull(message, "message"); + Objects.requireNonNull(receiver, "receiver"); return provider().boxSeal(message, receiver); } /** * Decrypt a sealed message using the given keys. * - * @param cipher the cipher text to decrypt. - * @param pk the public key of the sender. - * @param sk the private key of the receiver. + * @param cipher the cipher text to decrypt. Must not be null. + * @param pk the public key of the sender. Must not be null. + * @param sk the private key of the receiver. Must not be null. * @return the decrypted data. + * @throws NullPointerException if any argument is null. * @throws CryptoException if the verification or decryption failed. */ static byte[] decryptSealed(byte[] cipher, PublicKey pk, PrivateKey sk) throws CryptoException { + Objects.requireNonNull(cipher, "cipher"); + Objects.requireNonNull(pk, "pk"); + Objects.requireNonNull(sk, "sk"); byte[] plain = provider().boxSealOpen(cipher, pk, sk); if (plain == null) throw new CryptoException("Sealed-box decryption failed: invalid ciphertext or authentication failure"); @@ -416,21 +433,35 @@ static byte[] decryptSealed(byte[] cipher, PublicKey pk, PrivateKey sk) throws C /** * Encrypt a message with this precomputed box. * - * @param message the message to encrypt. - * @param nonce a unique nonce object. + * @param message the message to encrypt. Must not be null. + * @param nonce a unique nonce object. Must not be null. * @return the encrypted data. + * @throws NullPointerException if {@code message} or {@code nonce} is null. */ - byte[] encrypt(byte[] message, Nonce nonce); + default byte[] encrypt(byte[] message, Nonce nonce) { + Objects.requireNonNull(message, "message"); + Objects.requireNonNull(nonce, "nonce"); + return provider().boxEncrypt(message, nonce, this); + } /** * Decrypt a message with this precomputed box. * - * @param cipher the cipher text to decrypt. - * @param nonce the nonce that was used for encryption. + * @param cipher the cipher text to decrypt. Must not be null. + * @param nonce the nonce that was used for encryption. Must not be null. * @return the decrypted data. + * @throws NullPointerException if {@code cipher} or {@code nonce} is null. * @throws CryptoException if the verification or decryption failed. */ - byte[] decrypt(byte[] cipher, Nonce nonce) throws CryptoException; + default byte[] decrypt(byte[] cipher, Nonce nonce) throws CryptoException { + Objects.requireNonNull(cipher, "cipher"); + Objects.requireNonNull(nonce, "nonce"); + byte[] plain = provider().boxDecrypt(cipher, nonce, this); + if (plain == null) + throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); + + return plain; + } @Override void close(); diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java index 92aa8b79..cdc7d123 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java @@ -40,7 +40,12 @@ * A key object is owned by the provider that created it. A provider that is handed a foreign key * object (for example after the active provider was swapped) must still accept it by reconstructing * from its raw {@link Signature.PublicKey#bytes() bytes}. Once a key object has been destroyed it - * must reject further use rather than read freed or zeroed material. + * must reject further use rather than read freed or zeroed material. The one exception to the + * foreign-object fallback is the precomputed {@link CryptoBox}: it exposes no shared-key bytes (a + * native backend may keep the key only in native memory), so it cannot be reconstructed from a + * foreign instance - {@link #boxEncrypt(byte[], CryptoBox.Nonce, CryptoBox)} and + * {@link #boxDecrypt(byte[], CryptoBox.Nonce, CryptoBox)} require a box created by the same provider + * and reject one from another. *

* Every implementation must be byte-for-byte compatible with the libsodium constructions: * Ed25519 detached signatures, {@code crypto_kdf} (keyed BLAKE2b), {@code crypto_box} @@ -52,6 +57,15 @@ * MAC tags and password hashes - in constant time (for example * {@code org.bouncycastle.util.Arrays.constantTimeAreEqual}). Public values such as public keys * and nonces may use ordinary equality. + *

+ * Argument validation and errors: the public wrapper layer ({@link Signature}, + * {@link CryptoBox}, {@link PasswordHash}) validates every caller-supplied argument - null checks, + * key/nonce/salt sizes and value ranges - before dispatching to this interface. Implementations may + * therefore assume non-null, correctly-sized inputs and are not expected to re-check them. + * Implementations also never throw a checked exception: an authentication or decryption failure is + * reported by returning {@code null} (see {@link #boxDecrypt} and {@link #boxSealOpen}), which the + * wrapper translates into a checked {@link CryptoException}. Keeping providers free of the Boson + * exception hierarchy keeps a backend a pure cryptographic mechanism. */ public interface CryptoProvider { /** Length in bytes of an Ed25519 seed. */ @@ -231,6 +245,33 @@ public interface CryptoProvider { */ CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey); + /** + * Encrypts a message with a precomputed shared key (libsodium {@code crypto_box_easy_afternm}). + *

+ * Unlike a key object, a {@link CryptoBox} does not expose its shared key as bytes, so it cannot + * be reconstructed from a foreign instance: {@code box} must be one returned by this provider's + * {@link #boxBeforeNm}. Implementations should reject a box from another provider. + * + * @param message the plaintext. + * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce. + * @param box a precomputed crypto box created by this provider's {@link #boxBeforeNm}. + * @return the ciphertext (MAC prepended). + */ + byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox box); + + /** + * Decrypts a message with a precomputed shared key (libsodium {@code crypto_box_open_easy_afternm}). + *

+ * As with {@link #boxEncrypt(byte[], CryptoBox.Nonce, CryptoBox)}, {@code box} must be one returned + * by this provider's {@link #boxBeforeNm}; implementations should reject a box from another provider. + * + * @param cipher the ciphertext. + * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce. + * @param box a precomputed crypto box created by this provider's {@link #boxBeforeNm}. + * @return the plaintext, or {@code null} if authentication failed. + */ + byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox box); + /** * Encrypts a message with explicit keys (libsodium {@code crypto_box_easy}). * diff --git a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java index a27a023d..dc913f02 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java +++ b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java @@ -57,10 +57,35 @@ public class PasswordHash { private static final long SENSITIVE_OPS = 4L; private static final long SENSITIVE_MEM = 1073741824L; // 1 GiB + // libsodium crypto_pwhash minimums (crypto_pwhash_argon2id_*_MIN); the wrapper rejects + // out-of-range parameters before they reach the provider. + private static final long OPSLIMIT_MIN = 1L; + private static final long MEMLIMIT_MIN = 8192L; + private static CryptoProvider provider() { return CryptoProviders.getDefault(); } + // Validates the raw-hash parameters shared by every hash(...) overload. + private static void checkHashParams(byte[] password, int length, byte[] salt, long opsLimit, + long memLimit, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); + Objects.requireNonNull(algorithm, "Algorithm must not be null"); + if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) + throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); + if (length < MIN_HASH_BYTES) + throw new IllegalArgumentException("Invalid hash length: expected at least " + MIN_HASH_BYTES + " bytes, got " + length); + checkLimits(opsLimit, memLimit); + } + + // Validates the operations and memory limits shared by the raw and string hash entry points. + private static void checkLimits(long opsLimit, long memLimit) { + if (opsLimit < OPSLIMIT_MIN) + throw new IllegalArgumentException("Invalid opsLimit: expected at least " + OPSLIMIT_MIN + ", got " + opsLimit); + if (memLimit < MEMLIMIT_MIN) + throw new IllegalArgumentException("Invalid memLimit: expected at least " + MEMLIMIT_MIN + " bytes, got " + memLimit); + } + /** * Enum representing the Argon2 algorithms available for password hashing. *

@@ -126,6 +151,7 @@ public int id() { * @return The derived key. */ public static byte[] hashInteractive(String password, int length, byte[] salt, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); return hashInteractive(password.getBytes(UTF_8), length, salt, algorithm); } @@ -140,10 +166,7 @@ public static byte[] hashInteractive(String password, int length, byte[] salt, A * @return The derived key. */ public static byte[] hashInteractive(byte[] password, int length, byte[] salt, Algorithm algorithm) { - Objects.requireNonNull(password, "Password must not be null"); - if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) - throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); - + checkHashParams(password, length, salt, INTERACTIVE_OPS, INTERACTIVE_MEM, algorithm); return provider().pwHash(password, length, salt, INTERACTIVE_OPS, INTERACTIVE_MEM, algorithm.id()); } @@ -158,6 +181,7 @@ public static byte[] hashInteractive(byte[] password, int length, byte[] salt, A * @return The derived key. */ public static byte[] hashModerate(String password, int length, byte[] salt, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); return hashModerate(password.getBytes(UTF_8), length, salt, algorithm); } @@ -172,10 +196,7 @@ public static byte[] hashModerate(String password, int length, byte[] salt, Algo * @return The derived key. */ public static byte[] hashModerate(byte[] password, int length, byte[] salt, Algorithm algorithm) { - Objects.requireNonNull(password, "Password must not be null"); - if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) - throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); - + checkHashParams(password, length, salt, MODERATE_OPS, MODERATE_MEM, algorithm); return provider().pwHash(password, length, salt, MODERATE_OPS, MODERATE_MEM, algorithm.id()); } @@ -190,6 +211,7 @@ public static byte[] hashModerate(byte[] password, int length, byte[] salt, Algo * @return The derived key. */ public static byte[] hashSensitive(String password, int length, byte[] salt, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); return hashSensitive(password.getBytes(UTF_8), length, salt, algorithm); } @@ -204,10 +226,7 @@ public static byte[] hashSensitive(String password, int length, byte[] salt, Alg * @return The derived key. */ public static byte[] hashSensitive(byte[] password, int length, byte[] salt, Algorithm algorithm) { - Objects.requireNonNull(password, "Password must not be null"); - if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) - throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); - + checkHashParams(password, length, salt, SENSITIVE_OPS, SENSITIVE_MEM, algorithm); return provider().pwHash(password, length, salt, SENSITIVE_OPS, SENSITIVE_MEM, algorithm.id()); } @@ -223,6 +242,7 @@ public static byte[] hashSensitive(byte[] password, int length, byte[] salt, Alg * @return The derived key. */ public static byte[] hash(String password, int length, byte[] salt, long opsLimit, long memLimit, Algorithm algorithm) { + Objects.requireNonNull(password, "Password must not be null"); return hash(password.getBytes(UTF_8), length, salt, opsLimit, memLimit, algorithm); } @@ -238,10 +258,7 @@ public static byte[] hash(String password, int length, byte[] salt, long opsLimi * @return The derived key. */ public static byte[] hash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, Algorithm algorithm) { - Objects.requireNonNull(password, "Password must not be null"); - if (Objects.requireNonNull(salt, "Salt must not be null").length != SALT_BYTES) - throw new IllegalArgumentException("Invalid salt length: expected " + SALT_BYTES + " bytes, got " + salt.length); - + checkHashParams(password, length, salt, opsLimit, memLimit, algorithm); return provider().pwHash(password, length, salt, opsLimit, memLimit, algorithm.id()); } @@ -253,6 +270,7 @@ public static byte[] hash(byte[] password, int length, byte[] salt, long opsLimi * @return The hash string. */ public static String hashInteractive(String password) { + Objects.requireNonNull(password, "Password must not be null"); return provider().pwHashString(password.getBytes(UTF_8), INTERACTIVE_OPS, INTERACTIVE_MEM, Algorithm.DEFAULT.id()); } @@ -265,6 +283,7 @@ public static String hashInteractive(String password) { * @return The hash string. */ public static String hashModerate(String password) { + Objects.requireNonNull(password, "Password must not be null"); return provider().pwHashString(password.getBytes(UTF_8), MODERATE_OPS, MODERATE_MEM, Algorithm.DEFAULT.id()); } @@ -276,6 +295,7 @@ public static String hashModerate(String password) { * @return The hash string. */ public static String hashSensitive(String password) { + Objects.requireNonNull(password, "Password must not be null"); return provider().pwHashString(password.getBytes(UTF_8), SENSITIVE_OPS, SENSITIVE_MEM, Algorithm.DEFAULT.id()); } @@ -288,6 +308,8 @@ public static String hashSensitive(String password) { * @return The hash string. */ public static String hash(String password, long opsLimit, long memLimit) { + Objects.requireNonNull(password, "Password must not be null"); + checkLimits(opsLimit, memLimit); return provider().pwHashString(password.getBytes(UTF_8), opsLimit, memLimit, Algorithm.DEFAULT.id()); } @@ -299,6 +321,8 @@ public static String hash(String password, long opsLimit, long memLimit) { * @return {@code true} if the password matches the hash. */ public static boolean verify(String hash, String password) { + Objects.requireNonNull(hash, "Hash must not be null"); + Objects.requireNonNull(password, "Password must not be null"); return provider().pwHashVerify(hash, password.getBytes(UTF_8)); } @@ -310,6 +334,7 @@ public static boolean verify(String hash, String password) { * @return {@code true} if the hash should be regenerated. */ public static boolean needsRehashForInteractive(String hash) { + Objects.requireNonNull(hash, "Hash must not be null"); return provider().pwHashNeedsRehash(hash, INTERACTIVE_OPS, INTERACTIVE_MEM); } @@ -321,6 +346,7 @@ public static boolean needsRehashForInteractive(String hash) { * @return {@code true} if the hash should be regenerated. */ public static boolean needsRehashForModerate(String hash) { + Objects.requireNonNull(hash, "Hash must not be null"); return provider().pwHashNeedsRehash(hash, MODERATE_OPS, MODERATE_MEM); } @@ -332,6 +358,7 @@ public static boolean needsRehashForModerate(String hash) { * @return {@code true} if the hash should be regenerated. */ public static boolean needsRehashForSensitive(String hash) { + Objects.requireNonNull(hash, "Hash must not be null"); return provider().pwHashNeedsRehash(hash, SENSITIVE_OPS, SENSITIVE_MEM); } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/Signature.java b/api/src/main/java/io/bosonnetwork/crypto/Signature.java index 6da29d5a..e739e678 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/Signature.java +++ b/api/src/main/java/io/bosonnetwork/crypto/Signature.java @@ -78,11 +78,19 @@ static PublicKey fromPrivateKey(PrivateKey key) { /** * Verifies the signature of a message. * - * @param message the message to verify. - * @param signature the signature of the message. - * @return true if the signature matches the message according to this public key. + * @param message the message to verify. Must not be null. + * @param signature the signature of the message. Must not be null. + * @return true if the signature matches the message according to this public key; false if the + * signature is not {@link Signature#BYTES} bytes long or does not verify. + * @throws NullPointerException if {@code message} or {@code signature} is null. */ default boolean verify(byte[] message, byte[] signature) { + Objects.requireNonNull(message, "message"); + // A wrong-length signature is simply not a valid signature (verify is routinely called on + // untrusted input), so reject it with a false result rather than an exception. + if (Objects.requireNonNull(signature, "signature").length != Signature.BYTES) + return false; + return provider().ed25519Verify(message, signature, this); } @@ -179,10 +187,12 @@ default PrivateKey derive(long subKeyId, byte[] context) { /** * Signs a message with this private key. * - * @param message the message to sign. + * @param message the message to sign. Must not be null. * @return the signature of the message. + * @throws NullPointerException if {@code message} is null. */ default byte[] sign(byte[] message) { + Objects.requireNonNull(message, "message"); return provider().ed25519Sign(message, this); } @@ -369,23 +379,27 @@ private static byte[] deriveContextBytes(String context) { /** * Signs a message with a given key. * - * @param message the message to sign. - * @param key the private key to sign the message with. + * @param message the message to sign. Must not be null. + * @param key the private key to sign the message with. Must not be null. * @return the signature of the message. + * @throws NullPointerException if {@code message} or {@code key} is null. */ static byte[] sign(byte[] message, PrivateKey key) { - return provider().ed25519Sign(message, key); + Objects.requireNonNull(key, "key"); + return key.sign(message); } /** * Verifies the signature of a message. * - * @param message the message to verify. - * @param signature the signature of the message. - * @param key the public key to verify the message with. + * @param message the message to verify. Must not be null. + * @param signature the signature of the message. Must not be null. + * @param key the public key to verify the message with. Must not be null. * @return true if the signature matches the message according to this public key. + * @throws NullPointerException if {@code message}, {@code signature} or {@code key} is null. */ static boolean verify(byte[] message, byte[] signature, PublicKey key) { + Objects.requireNonNull(key, "key"); return key.verify(message, signature); } diff --git a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java index 25bf65cb..d731a04f 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java @@ -142,7 +142,11 @@ void cryptoBoxMatches() throws CryptoException { // precomputed (beforenm/afternm) path equals the full path on both backends try (CryptoBox bcPre = BC.boxBeforeNm(bcPkB, bcSkA); CryptoBox lsPre = LS.boxBeforeNm(lsPkB, lsSkA)) { assertArrayEquals(lsBox, bcPre.encrypt(msg, CryptoBox.Nonce.fromBytes(nonce.bytes())), "BC afternm == full"); - assertArrayEquals(lsBox, lsPre.encrypt(msg, CryptoBox.Nonce.fromBytes(nonce.bytes())), "libsodium afternm == full"); + //assertArrayEquals(lsBox, lsPre.encrypt(msg, CryptoBox.Nonce.fromBytes(nonce.bytes())), "libsodium afternm == full"); + // The libsodium backend (via Tuweni) encapsulates the shared key within its own + // Box object and does not expose the raw shared key bytes. This prevents + // sharing a precomputed CryptoBox across different providers. + assertThrows(IllegalStateException.class, () -> lsPre.encrypt(msg, CryptoBox.Nonce.fromBytes(nonce.bytes()))); } // receiver opens with sender public key + own secret key, across backends @@ -307,6 +311,61 @@ void boxMatchesAcrossSizes() throws CryptoException { } } + @Test + void boxAfterNmMatches() { + // Direct differential coverage for the precomputed-box (afternm) SPI methods + // boxEncrypt(msg, nonce, CryptoBox) / boxDecrypt(cipher, nonce, CryptoBox). The public + // CryptoBox.encrypt/decrypt wrappers always dispatch to the default (BC) provider, so the + // libsodium implementation of these two methods is only reachable by calling LS directly. + byte[] seedA = rb(32); + byte[] seedB = rb(32); + CryptoBox.PrivateKey bcSkA = boxSk(BC, seedA); + CryptoBox.PublicKey bcPkA = BC.boxPublicKeyFromSecretKey(bcSkA); + CryptoBox.PrivateKey bcSkB = boxSk(BC, seedB); + CryptoBox.PublicKey bcPkB = BC.boxPublicKeyFromSecretKey(bcSkB); + CryptoBox.PrivateKey lsSkA = boxSk(LS, seedA); + CryptoBox.PublicKey lsPkA = LS.boxPublicKeyFromSecretKey(lsSkA); + CryptoBox.PrivateKey lsSkB = boxSk(LS, seedB); + CryptoBox.PublicKey lsPkB = LS.boxPublicKeyFromSecretKey(lsSkB); + CryptoBox.Nonce nonce = BC.boxNonceFromBytes(rb(24)); + CryptoBox.Nonce lsNonce = LS.boxNonceFromBytes(nonce.bytes()); + + try (CryptoBox bcEnc = BC.boxBeforeNm(bcPkB, bcSkA); + CryptoBox lsEnc = LS.boxBeforeNm(lsPkB, lsSkA); + CryptoBox bcDec = BC.boxBeforeNm(bcPkA, bcSkB); + CryptoBox lsDec = LS.boxBeforeNm(lsPkA, lsSkB)) { + + for (int size : SIZES) { + byte[] msg = rb(size); + byte[] bcBox = BC.boxEncrypt(msg, nonce, bcEnc); + byte[] lsBox = LS.boxEncrypt(msg, lsNonce, lsEnc); + // afternm ciphertext agrees across backends and equals the explicit-key path + assertArrayEquals(lsBox, bcBox, "afternm ciphertext size=" + size); + assertArrayEquals(BC.boxEncrypt(msg, nonce, bcPkB, bcSkA), bcBox, "BC afternm == explicit size=" + size); + + // each backend opens the other's afternm box via its own precomputed box + assertArrayEquals(msg, BC.boxDecrypt(lsBox, nonce, bcDec), "BC afternm opens LS size=" + size); + assertArrayEquals(msg, LS.boxDecrypt(bcBox, lsNonce, lsDec), "LS afternm opens BC size=" + size); + } + + // tampered ciphertext is rejected (null) by both afternm decryptors + byte[] tampered = BC.boxEncrypt(rb(64), nonce, bcEnc); + tampered[tampered.length - 1] ^= 0x01; + assertNull(BC.boxDecrypt(tampered, nonce, bcDec), "BC afternm rejects tampered"); + assertNull(LS.boxDecrypt(tampered, lsNonce, lsDec), "LS afternm rejects tampered"); + } + + // a precomputed box from another provider cannot be reused (its shared key is opaque) + try (CryptoBox lsEnc = LS.boxBeforeNm(lsPkB, lsSkA)) { + assertThrows(IllegalStateException.class, () -> BC.boxEncrypt(rb(16), nonce, lsEnc), + "BC rejects a foreign CryptoBox"); + } + try (CryptoBox bcEnc = BC.boxBeforeNm(bcPkB, bcSkA)) { + assertThrows(IllegalStateException.class, () -> LS.boxEncrypt(rb(16), lsNonce, bcEnc), + "LS rejects a foreign CryptoBox"); + } + } + @Test void boxFromBytesRoundTrips() { byte[] seedA = rb(32); diff --git a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java index 45b00593..b8fb44c7 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java +++ b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java @@ -311,19 +311,6 @@ private SodiumCryptoBox(Box box) { this.box = box; } - @Override - public byte[] encrypt(byte[] message, CryptoBox.Nonce nonce) { - return box.encrypt(message, nonceOf(nonce)); - } - - @Override - public byte[] decrypt(byte[] cipher, CryptoBox.Nonce nonce) throws CryptoException { - byte[] plain = box.decrypt(cipher, nonceOf(nonce)); - if (plain == null) - throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure"); - return plain; - } - @Override public void close() { destroy(); @@ -391,6 +378,23 @@ public CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey return new SodiumCryptoBox(Box.forKeys(keyOf(publicKey), keyOf(secretKey))); } + private static Box boxOf(CryptoBox box) { + if (box instanceof SodiumCryptoBox b) + return b.box; + else + throw new IllegalStateException("Not a SodiumCryptoBox: " + box.getClass().getName()); + } + + @Override + public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox box) { + return boxOf(box).encrypt(message, nonceOf(nonce)); + } + + @Override + public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox box) { + return boxOf(box).decrypt(cipher, nonceOf(nonce)); + } + private static Box.Nonce nonceOf(CryptoBox.Nonce nonce) { return nonce instanceof SodiumBoxNonce n ? n.nonce : Box.Nonce.fromBytes(nonce.bytes()); } From 60e7a2de19a4a87f15fd5c92a99dcb63a4bd9fc8 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Fri, 26 Jun 2026 15:06:24 +0800 Subject: [PATCH 08/10] crypto: move CertUtil certificate generation into the CryptoProvider SPI with a JCE-free Bouncy Castle default impl --- .../crypto/BouncyCastleCryptoProvider.java | 135 +++++- .../java/io/bosonnetwork/crypto/CertUtil.java | 161 +++++++ .../bosonnetwork/crypto/CryptoProvider.java | 20 + .../io/bosonnetwork/crypto/CryptoUtil.java | 443 ------------------ .../crypto/PemCertificateAndKey.java | 32 ++ .../crypto/CertUtilBouncyCastle.java | 194 -------- ...ryptoUtilTests.java => CertUtilTests.java} | 45 +- .../crypto/CryptoCompatibilityTest.java | 127 +++++ .../crypto/SodiumCryptoProvider.java | 274 ++++++++++- 9 files changed, 782 insertions(+), 649 deletions(-) create mode 100644 api/src/main/java/io/bosonnetwork/crypto/CertUtil.java delete mode 100644 api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java create mode 100644 api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java delete mode 100644 api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java rename api/src/test/java/io/bosonnetwork/crypto/{CryptoUtilTests.java => CertUtilTests.java} (75%) diff --git a/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java b/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java index 7f9ac0f1..d07e1753 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java +++ b/api/src/main/java/io/bosonnetwork/crypto/BouncyCastleCryptoProvider.java @@ -24,11 +24,35 @@ import static org.bouncycastle.util.Arrays.constantTimeAreEqual; +import java.io.IOException; +import java.io.StringWriter; import java.math.BigInteger; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; - +import java.util.Date; +import java.util.List; + +import org.bouncycastle.asn1.edec.EdECObjectIdentifiers; +import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.AlgorithmIdentifier; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.ExtendedKeyUsage; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyPurposeId; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509ExtensionUtils; +import org.bouncycastle.cert.X509v3CertificateBuilder; import org.bouncycastle.crypto.digests.Blake2bDigest; import org.bouncycastle.crypto.digests.SHA512Digest; import org.bouncycastle.crypto.engines.XSalsa20Engine; @@ -40,9 +64,20 @@ import org.bouncycastle.crypto.params.KeyParameter; import org.bouncycastle.crypto.params.ParametersWithIV; import org.bouncycastle.crypto.signers.Ed25519Signer; +import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; +import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory; import org.bouncycastle.math.ec.rfc7748.X25519; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.DigestCalculator; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.bc.BcDigestCalculatorProvider; +import org.bouncycastle.operator.bc.BcEdECContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; import org.jspecify.annotations.Nullable; +import io.bosonnetwork.utils.Base58; + /** * Pure-Java {@link CryptoProvider} backed by Bouncy Castle. This is the default Boson crypto * backend; it has no native dependency and runs on the JVM and Android alike. @@ -699,6 +734,104 @@ public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) { return phc.algorithm != PWHASH_ALG_ARGON2ID13 || phc.t != opsLimit || phc.m != memKiB || phc.p != 1; } + @Override + public PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey secretKey, + @Nullable String ipAddress, @Nullable String hostName, + boolean enableWildcard) throws CryptoException { + try { + // Extract the 32-byte seed and public key from libsodium 64-byte SK + byte[] sk = secretKey.bytes(); + byte[] seed = new byte[32]; + System.arraycopy(sk, 0, seed, 0, 32); + byte[] pk = new byte[32]; + System.arraycopy(sk, 32, pk, 0, 32); + String keyId = Base58.encode(pk); + + // Build Bouncy Castle Ed25519 key parameters. The whole certificate is produced with the + // Bouncy Castle low-level API (no JCA provider), so callers do not have to register the BC + // JCE provider via Security.addProvider(). + Ed25519PrivateKeyParameters privateKeyParams = new Ed25519PrivateKeyParameters(seed); + Ed25519PublicKeyParameters publicKeyParams = new Ed25519PublicKeyParameters(pk); + + /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410) + // Convert to JCA PrivateKey / PublicKey via PKCS#8 v2 DER encoding (version=1, include public key) + // Encode to PKCS#8 DER, then load via JCA KeyFactory + byte[] pkcs8Bytes = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams).getEncoded(); + */ + + // Encode the private key as PKCS#8 v1 DER (version=0, no public key). + // BC defaults to v2 (RFC 5958) for Ed25519 which Vert.x (Netty) rejects. + PrivateKeyInfo v2PrivateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams); + PrivateKeyInfo v1PrivateKeyInfo = new PrivateKeyInfo( + v2PrivateKeyInfo.getPrivateKeyAlgorithm(), + v2PrivateKeyInfo.parsePrivateKey() + ); + byte[] pkcs8Bytes = v1PrivateKeyInfo.getEncoded(); + + SubjectPublicKeyInfo spki = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(publicKeyParams); + + // Build a self-signed X.509 certificate + X500Name subject = new X500Name("CN=" + keyId); + BigInteger serial = new BigInteger(128, new SecureRandom()); + + // Subtract 10 minutes to handle clock skew + Instant now = Instant.now(); + Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES)); + Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); + + // Without SAN, modern browsers and most TLS clients REJECT the cert + // Chrome/Firefox dropped CN-only matching in 2017 + List subjectAltNames = new ArrayList<>(); + if (hostName != null) + subjectAltNames.add(new GeneralName(GeneralName.dNSName, hostName)); + if (enableWildcard && hostName != null) + subjectAltNames.add(new GeneralName(GeneralName.dNSName, "*." + hostName)); + if (ipAddress != null) + subjectAltNames.add(new GeneralName(GeneralName.iPAddress, ipAddress)); + if (subjectAltNames.isEmpty()) + throw new IllegalArgumentException("At least one SAN (hostname or IP) must be provided"); + + // Sign with the Bouncy Castle Ed25519 implementation directly (no JCA provider). + ContentSigner signer = new BcEdECContentSignerBuilder(new AlgorithmIdentifier(EdECObjectIdentifiers.id_Ed25519)) + .build(privateKeyParams); + + DigestCalculator digestCalc = new BcDigestCalculatorProvider() + .get(new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1)); + + X509CertificateHolder certHolder = new X509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, spki) + // Subject Key Identifier (optional but good practice) + .addExtension(Extension.subjectKeyIdentifier, false, + new X509ExtensionUtils(digestCalc).createSubjectKeyIdentifier(spki)) + // KeyUsage: required for TLS + .addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)) + // SAN - critical for client acceptance + .addExtension(Extension.subjectAlternativeName, false, + new GeneralNames(subjectAltNames.toArray(new GeneralName[0]))) + // BasicConstraints: CA=false, this is a server/end-entity cert + .addExtension(Extension.basicConstraints, true, new BasicConstraints(false)) + // Extended Key Usage: HTTPS, WSS, MQTTS server, only if also used for mTLS client certs + .addExtension(Extension.extendedKeyUsage, false, + new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})) + .build(signer); + + // Write private key and certificate to PEM strings + String keyPem = toPem("PRIVATE KEY", pkcs8Bytes); + String certPem = toPem("CERTIFICATE", certHolder.getEncoded()); + + return new PemCertificateAndKey(certPem, keyPem); + } catch (IOException | OperatorCreationException e) { + throw new CryptoException("Failed to convert key to PEM format key and certificate", e); + } + } + + private static String toPem(String type, byte[] der) throws IOException { + StringWriter sw = new StringWriter(); + try (PemWriter writer = new PemWriter(sw)) { + writer.writeObject(new PemObject(type, der)); + } + return sw.toString(); + } + private static byte[] argon2(byte[] password, byte[] salt, int length, long opsLimit, long memLimit, int algorithm) { int type = algorithm == PWHASH_ALG_ARGON2I13 ? Argon2Parameters.ARGON2_i : Argon2Parameters.ARGON2_id; Argon2Parameters params = new Argon2Parameters.Builder(type) diff --git a/api/src/main/java/io/bosonnetwork/crypto/CertUtil.java b/api/src/main/java/io/bosonnetwork/crypto/CertUtil.java new file mode 100644 index 00000000..4e2e1c05 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/crypto/CertUtil.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Base64; +import java.util.Objects; + +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.PfxOptions; +import org.jspecify.annotations.Nullable; + +/** + * Utility class for certificate and key management. + */ +public class CertUtil { + private CertUtil() {} + + /** + * Generates a self-signed X.509 certificate and private key from a signature private key without Bouncy Castle. + * + * @param privateKey the signature private key + * @param ipAddress the IP address to include in the Subject Alternative Name (SAN), or + * {@code null} to omit an IP SAN entry + * @param hostName the host name to include in the Subject Alternative Name (SAN), or + * {@code null} to omit a DNS SAN entry + * @param enableWildcard whether to include a wildcard host name in the SAN + * @return a {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key + * @throws CryptoException if an error occurs during key conversion or certificate generation + */ + public static PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey privateKey, @Nullable String ipAddress, + @Nullable String hostName, boolean enableWildcard) throws CryptoException { + Objects.requireNonNull(privateKey, "privateKey"); + if (ipAddress == null && hostName == null) + throw new IllegalArgumentException("At least one SAN entry must be specified"); + + return CryptoProviders.getDefault().certificateFromSignatureKey(privateKey, ipAddress, hostName, enableWildcard); + } + + /* + * Although using Vert.x PemKeyCertOptions is more direct: + * + * PemKeyCertOptions keyCertOptions = new PemKeyCertOptions() + * .setKeyValue(Buffer.buffer(certAndKey.privateKey())) + * .setCertValue(Buffer.buffer(certAndKey.cert())); + * options.setKeyCertOptions(keyCertOptions); + * + * Vert.x (Netty) does not currently support PEM-encoded PKCS#8 Ed25519 private keys. + * Therefore, we must package them into a PKCS#12 keystore and use PfxOptions instead. + */ + /** + * Creates a {@link PfxOptions} instance from a pair of PEM-encoded certificate and private key. + * + * @param certAndKey the {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key + * @return a {@link PfxOptions} containing a PKCS#12 keystore created from the provided certificate and private key + * @throws InvalidKeySpecException if the private key could not be parsed correctly + * @throws NoSuchAlgorithmException if the "Ed25519" algorithm required for the private key is not available + * @throws CertificateException if the certificate could not be parsed correctly + * @throws KeyStoreException if an error occurs while accessing or modifying the keystore + */ + public static PfxOptions pfxOptionsFromCertAndPrivateKey(PemCertificateAndKey certAndKey) + throws InvalidKeySpecException, NoSuchAlgorithmException, CertificateException, KeyStoreException { + // Remove PEM headers + String normalized = certAndKey.privateKey() + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + // Decode DER + byte[] der = Base64.getDecoder().decode(normalized); + // PKCS#8 -> PrivateKey + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); + // Note: PKCS8EncodedKeySpec#getAlgorithm() returns null since it doesn't parse the DER, + // so we must specify "Ed25519" explicitly for the KeyFactory. + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + PrivateKey privateKey = kf.generatePrivate(spec); + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + X509Certificate cert = (X509Certificate) cf.generateCertificate( + new ByteArrayInputStream(certAndKey.cert().getBytes(StandardCharsets.US_ASCII))); + KeyStore ks = KeyStore.getInstance("PKCS12"); + try { + ks.load(null, null); + } catch (IOException e) { + throw new KeyStoreException("Failed to load empty KeyStore", e); + } + String password = randomPassword(16); + ks.setKeyEntry( + "server", + privateKey, + password.toCharArray(), + new Certificate[]{cert}); + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + ks.store(bos, password.toCharArray()); + } catch (IOException e) { + throw new KeyStoreException("Failed to store KeyStore", e); + } + return new PfxOptions() + .setValue(Buffer.buffer(bos.toByteArray())) + .setPassword(password); + } + + /** + * Generates a random password containing a mix of uppercase and lowercase letters, digits, and special characters. + * + * @param length the length of the password to generate; must be a positive integer + * @return a randomly generated password as a String + * @throws IllegalArgumentException if the specified length is not positive + */ + private static String randomPassword(int length) { + if (length <= 0) + throw new IllegalArgumentException("Length must be positive"); + + String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+=<>?/|"; + StringBuilder sb = new StringBuilder(length); + SecureRandom random = new SecureRandom(); + for (int i = 0; i < length; i++) { + int index = random.nextInt(characters.length()); + sb.append(characters.charAt(index)); + } + + return sb.toString(); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java index cdc7d123..c09c97a0 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java +++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoProvider.java @@ -359,4 +359,24 @@ public interface CryptoProvider { * @return true if the hash should be regenerated. */ boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit); + + // ---- PEM certification ------------------------------------------- + + /** + * Generates a self-signed Ed25519 X.509 certificate and private key from a signature private key. + *

+ * At least one Subject Alternative Name (SAN) entry must be produced: if both {@code ipAddress} + * and {@code hostName} are {@code null} the implementation throws {@link IllegalArgumentException}. + * + * @param privateKey the signature private key + * @param ipAddress the IP address to include in the Subject Alternative Name (SAN), or + * {@code null} to omit an IP SAN entry + * @param hostName the host name to include in the Subject Alternative Name (SAN), or + * {@code null} to omit a DNS SAN entry + * @param enableWildcard whether to include a wildcard host name in the SAN + * @return a {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key + * @throws CryptoException if an error occurs during key conversion or certificate generation + */ + PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey privateKey, @Nullable String ipAddress, + @Nullable String hostName, boolean enableWildcard) throws CryptoException; } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java deleted file mode 100644 index 51b926c3..00000000 --- a/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java +++ /dev/null @@ -1,443 +0,0 @@ -/* - * Copyright (c) 2023 - bosonnetwork.io - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package io.bosonnetwork.crypto; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.math.BigInteger; -import java.net.InetAddress; -import java.nio.charset.StandardCharsets; -import java.security.KeyFactory; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.PrivateKey; -import java.security.SecureRandom; -import java.security.cert.Certificate; -import java.security.cert.CertificateException; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.PKCS8EncodedKeySpec; -import java.text.SimpleDateFormat; -import java.time.Instant; -import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; -import java.util.Base64; -import java.util.Date; -import java.util.TimeZone; - -import io.vertx.core.buffer.Buffer; -import io.vertx.core.net.PfxOptions; - -import org.jspecify.annotations.Nullable; - -import io.bosonnetwork.BosonException; -import io.bosonnetwork.utils.Base58; - -/** - * Utility class for certificate and key management. - */ -public class CryptoUtil { - /** - * Represents a pair of PEM-encoded certificate and private key. - * - * @param cert the PEM-encoded certificate - * @param privateKey the PEM-encoded private key - */ - public record PemCertificateAndKey(String cert, String privateKey) { - } - - /** - * Generates a self-signed X.509 certificate and private key from a signature private key without Bouncy Castle. - * - * @param signaturePrivateKey the signature private key - * @param ipAddress the IP address to include in the Subject Alternative Name (SAN), or - * {@code null} to omit an IP SAN entry - * @param hostName the host name to include in the Subject Alternative Name (SAN), or - * {@code null} to omit a DNS SAN entry - * @param enableWildcard whether to include a wildcard host name in the SAN - * @return a {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key - * @throws KeyConvertException if an error occurs during key conversion or certificate generation - */ - public static PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey, - @Nullable String ipAddress, @Nullable String hostName, boolean enableWildcard) - throws KeyConvertException { - try { - // Extract the 32-byte seed and public key from libsodium 64-byte SK - byte[] sodiumSecretKey = signaturePrivateKey.bytes(); - byte[] sodiumSeed = new byte[32]; - System.arraycopy(sodiumSecretKey, 0, sodiumSeed, 0, 32); - byte[] sodiumPublicKey = new byte[32]; - System.arraycopy(sodiumSecretKey, 32, sodiumPublicKey, 0, 32); - String keyId = Base58.encode(sodiumPublicKey); - - /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410) - // Use standard JDK 15+ Ed25519 support - // PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410) - // Version v2 (1) because we include the public key - // AlgorithmIdentifier: 1.3.101.112 - // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed) - // PublicKey: [1] IMPLICIT BIT STRING (32 bytes) - byte[] pkcs8Bytes = new byte[83]; - System.arraycopy(new byte[]{ - 0x30, 0x51, // SEQUENCE (81 bytes) - 0x02, 0x01, 0x01, // Version v2 (1) - 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112) - 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes) - }, 0, pkcs8Bytes, 0, 16); - System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32); - System.arraycopy(new byte[]{ - (byte) 0x81, 0x21, 0x00 // [1] IMPLICIT BIT STRING (33 bytes: 0 padding + 32 bytes) - }, 0, pkcs8Bytes, 48, 3); - System.arraycopy(sodiumPublicKey, 0, pkcs8Bytes, 51, 32); - */ - - // Use standard JDK 15+ Ed25519 support - // PKCS#8 v1 OneAsymmetricKey for Ed25519 (RFC 8410) - // Version v1 (0) because we don't include the public key - // AlgorithmIdentifier: 1.3.101.112 - // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed) - byte[] pkcs8Bytes = new byte[48]; - System.arraycopy(new byte[]{ - 0x30, 0x2e, // SEQUENCE (46 bytes) - 0x02, 0x01, 0x00, // Version v1 (0) - 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112) - 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes) - }, 0, pkcs8Bytes, 0, 16); - System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32); - - KeyFactory kf = KeyFactory.getInstance("Ed25519"); - PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Bytes)); - - // Build TBSCertificate - BigInteger serial = new BigInteger(128, new SecureRandom()); - Instant now = Instant.now(); - Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES)); - Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); - - // Construct manual ASN.1/DER certificate - byte[] tbs = encodeTBS(serial, keyId, notBefore, notAfter, sodiumPublicKey, ipAddress, hostName, enableWildcard); - - java.security.Signature sig = java.security.Signature.getInstance("Ed25519"); - sig.initSign(privateKey); - sig.update(tbs); - byte[] signatureValue = sig.sign(); - - byte[] certDer = encodeCert(tbs, signatureValue); - - String keyPem = "-----BEGIN PRIVATE KEY-----\n" + - Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(pkcs8Bytes) + - "\n-----END PRIVATE KEY-----\n"; - - String certPem = "-----BEGIN CERTIFICATE-----\n" + - Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(certDer) + - "\n-----END CERTIFICATE-----\n"; - - return new PemCertificateAndKey(certPem, keyPem); - } catch (Exception e) { - throw new KeyConvertException("Failed to convert key using simple implementation", e); - } - } - - private static byte[] encodeTBS(BigInteger serial, String cn, Date notBefore, Date notAfter, byte[] pubKey, - @Nullable String ip, @Nullable String host, boolean wildcard) throws IOException { - DerBuilder tbs = new DerBuilder(); - tbs.addTag((byte) 0xA0, new DerBuilder().addInt(2).build()); // Version v3 - tbs.addInt(serial); - tbs.addSeq(new DerBuilder().addOid("1.3.101.112")); // Algorithm: Ed25519 - tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Issuer - tbs.addSeq(new DerBuilder().addTime(notBefore).addTime(notAfter)); // Validity - tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Subject - tbs.addSeq(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.101.112")).addBitString(pubKey)); // SubjectPublicKeyInfo - - // Extensions - DerBuilder exts = new DerBuilder(); - - // Subject Key Identifier (critical=false) - try { - MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); - // SKI = SHA-1 (public key bytes). - // For Ed25519, BouncyCastle calculates SHA-1 over the raw 32-byte public key. - // The extension value is an OCTET STRING containing the key identifier (which is also an OCTET STRING per RFC 5280). - // However, in X.509, the extension value field is ALREADY an OCTET STRING, so we just wrap the hash in an OCTET STRING. - byte[] ski = sha1.digest(pubKey); - exts.addSeq(new DerBuilder().addOid("2.5.29.14").addOctetString(new DerBuilder().addOctetString(ski).build())); - } catch (NoSuchAlgorithmException e) { - throw new IOException("SHA-1 not found", e); - } - - // KeyUsage (critical=true, digitalSignature=bit 0) - exts.addSeq(new DerBuilder().addOid("2.5.29.15").addBool(true).addOctetString(new DerBuilder().addBitString(new byte[]{(byte) 0x80}, 7).build())); - - // SAN (critical=false) - DerBuilder san = new DerBuilder(); - if (host != null) san.addTag((byte) 0x82, host.getBytes(StandardCharsets.US_ASCII)); - if (wildcard && host != null) san.addTag((byte) 0x82, ("*." + host).getBytes(StandardCharsets.US_ASCII)); - if (ip != null) { - byte[] ipBytes = InetAddress.getByName(ip).getAddress(); // 16 bytes - san.addTag((byte) 0x87, ipBytes); - } - if (ip != null || host != null) - exts.addSeq(new DerBuilder().addOid("2.5.29.17").addOctetString(new DerBuilder().addSeq(san).build())); - - // BasicConstraints (critical=true, CA=false) - exts.addSeq(new DerBuilder().addOid("2.5.29.19").addBool(true).addOctetString(new DerBuilder().addSeq(new DerBuilder()).build())); - - // ExtendedKeyUsage (critical=false, serverAuth, clientAuth) - exts.addSeq(new DerBuilder().addOid("2.5.29.37").addOctetString(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.6.1.5.5.7.3.1").addOid("1.3.6.1.5.5.7.3.2")).build())); - - tbs.addTag((byte) 0xA3, new DerBuilder().addSeq(exts).build()); - - return tbs.buildSeq(); - } - - private static byte[] encodeCert(byte[] tbs, byte[] signature) throws IOException { - return new DerBuilder() - .addRaw(tbs) - .addSeq(new DerBuilder().addOid("1.3.101.112")) - .addBitString(signature) - .buildSeq(); - } - - private static class DerBuilder { - private final ByteArrayOutputStream out = new ByteArrayOutputStream(); - - public DerBuilder addRaw(byte[] raw) throws IOException { - out.write(raw); - return this; - } - - public DerBuilder addTag(byte tag, byte[] val) throws IOException { - out.write(tag); - writeLen(val.length); - out.write(val); - return this; - } - - public DerBuilder addInt(long v) throws IOException { - return addInt(BigInteger.valueOf(v)); - } - - public DerBuilder addInt(BigInteger v) throws IOException { - return addTag((byte) 0x02, v.toByteArray()); - } - - public DerBuilder addOid(String oid) throws IOException { - String[] parts = oid.split("\\."); - ByteArrayOutputStream b = new ByteArrayOutputStream(); - b.write(Integer.parseInt(parts[0]) * 40 + Integer.parseInt(parts[1])); - for (int i = 2; i < parts.length; i++) { - long v = Long.parseLong(parts[i]); - if (v == 0) b.write(0); - else { - byte[] buf = new byte[10]; - int pos = 10; - buf[--pos] = (byte) (v & 0x7F); - while ((v >>= 7) > 0) buf[--pos] = (byte) ((v & 0x7F) | 0x80); - b.write(buf, pos, 10 - pos); - } - } - return addTag((byte) 0x06, b.toByteArray()); - } - - public DerBuilder addPrintableString(String s) throws IOException { - return addTag((byte) 0x13, s.getBytes(StandardCharsets.US_ASCII)); - } - - // RFC 5280: encode dates before 2050 as UTCTime, and 2050 or later as GeneralizedTime. - public DerBuilder addTime(Date d) throws IOException { - int year = d.toInstant().atZone(ZoneOffset.UTC).getYear(); - if (year < 2050) { - SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss'Z'"); - sdf.setTimeZone(TimeZone.getTimeZone("UTC")); - return addTag((byte) 0x17, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // UTCTime - } else { - SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss'Z'"); - sdf.setTimeZone(TimeZone.getTimeZone("UTC")); - return addTag((byte) 0x18, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // GeneralizedTime - } - } - - public DerBuilder addBitString(byte[] b) throws IOException { - return addBitString(b, 0); - } - - public DerBuilder addBitString(byte[] b, int pad) throws IOException { - byte[] val = new byte[b.length + 1]; - val[0] = (byte) pad; - System.arraycopy(b, 0, val, 1, b.length); - return addTag((byte) 0x03, val); - } - - public DerBuilder addOctetString(byte[] b) throws IOException { - return addTag((byte) 0x04, b); - } - - public DerBuilder addBool(boolean v) throws IOException { - return addTag((byte) 0x01, new byte[]{(byte) (v ? 0xFF : 0x00)}); - } - - public DerBuilder addSeq(DerBuilder b) throws IOException { - return addTag((byte) 0x30, b.build()); - } - - public DerBuilder addSet(DerBuilder b) throws IOException { - return addTag((byte) 0x31, b.build()); - } - - public byte[] build() { - return out.toByteArray(); - } - - public byte[] buildSeq() throws IOException { - return new DerBuilder().addSeq(this).build(); - } - - private void writeLen(int len) { - if (len < 128) out.write(len); - else { - byte[] b = BigInteger.valueOf(len).toByteArray(); - int skip = (b.length > 1 && b[0] == 0) ? 1 : 0; - out.write(0x80 | (b.length - skip)); - out.write(b, skip, b.length - skip); - } - } - } - - /** - * Generates a random password containing a mix of uppercase and lowercase letters, digits, and special characters. - * - * @param length the length of the password to generate; must be a positive integer - * @return a randomly generated password as a String - * @throws IllegalArgumentException if the specified length is not positive - */ - public static String randomPassword(int length) { - if (length <= 0) - throw new IllegalArgumentException("Length must be positive"); - - String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+=<>?/|"; - StringBuilder sb = new StringBuilder(length); - SecureRandom random = new SecureRandom(); - for (int i = 0; i < length; i++) { - int index = random.nextInt(characters.length()); - sb.append(characters.charAt(index)); - } - - return sb.toString(); - } - - /* - * Although using Vert.x PemKeyCertOptions is more direct: - * - * PemKeyCertOptions keyCertOptions = new PemKeyCertOptions() - * .setKeyValue(Buffer.buffer(certAndKey.privateKey())) - * .setCertValue(Buffer.buffer(certAndKey.cert())); - * options.setKeyCertOptions(keyCertOptions); - * - * Vert.x (Netty) does not currently support PEM-encoded PKCS#8 Ed25519 private keys. - * Therefore, we must package them into a PKCS#12 keystore and use PfxOptions instead. - */ - - /** - * Creates a {@link PfxOptions} instance from a pair of PEM-encoded certificate and private key. - * - * @param certAndKey the {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key - * @return a {@link PfxOptions} containing a PKCS#12 keystore created from the provided certificate and private key - * @throws InvalidKeySpecException if the private key could not be parsed correctly - * @throws NoSuchAlgorithmException if the "Ed25519" algorithm required for the private key is not available - * @throws CertificateException if the certificate could not be parsed correctly - * @throws KeyStoreException if an error occurs while accessing or modifying the keystore - */ - public static PfxOptions pfxOptionsFromCertAndPrivateKey(PemCertificateAndKey certAndKey) - throws InvalidKeySpecException, NoSuchAlgorithmException, CertificateException, KeyStoreException { - // Remove PEM headers - String normalized = certAndKey.privateKey() - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") - .replaceAll("\\s", ""); - - // Decode DER - byte[] der = Base64.getDecoder().decode(normalized); - // PKCS#8 -> PrivateKey - PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der); - // Note: PKCS8EncodedKeySpec#getAlgorithm() returns null since it doesn't parse the DER, - // so we must specify "Ed25519" explicitly for the KeyFactory. - KeyFactory kf = KeyFactory.getInstance("Ed25519"); - PrivateKey privateKey = kf.generatePrivate(spec); - - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - X509Certificate cert = (X509Certificate) cf.generateCertificate( - new ByteArrayInputStream(certAndKey.cert().getBytes(StandardCharsets.US_ASCII))); - KeyStore ks = KeyStore.getInstance("PKCS12"); - try { - ks.load(null, null); - } catch (IOException e) { - throw new KeyStoreException("Failed to load empty KeyStore", e); - } - String password = randomPassword(16); - ks.setKeyEntry( - "server", - privateKey, - password.toCharArray(), - new Certificate[]{cert}); - - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - try { - ks.store(bos, password.toCharArray()); - } catch (IOException e) { - throw new KeyStoreException("Failed to store KeyStore", e); - } - return new PfxOptions() - .setValue(Buffer.buffer(bos.toByteArray())) - .setPassword(password); - } - - /** - * Exception thrown when an error occurs during key conversion or certificate generation. - */ - public static class KeyConvertException extends BosonException { - private static final long serialVersionUID = -5975318365528633648L; - - /** - * Constructs a new KeyConvertException with the specified detail message. - * - * @param message the detail message - */ - public KeyConvertException(String message) { - super(message); - } - - /** - * Constructs a new KeyConvertException with the specified detail message and cause. - * - * @param message the detail message - * @param cause the cause - */ - public KeyConvertException(String message, Throwable cause) { - super(message, cause); - } - } -} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java b/api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java new file mode 100644 index 00000000..534739e5 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package io.bosonnetwork.crypto; + +/** + * Represents a pair of PEM-encoded certificate and private key. + * + * @param cert the PEM-encoded certificate + * @param privateKey the PEM-encoded private key + */ +public record PemCertificateAndKey(String cert, String privateKey) { +} diff --git a/api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java b/api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java deleted file mode 100644 index 0565398c..00000000 --- a/api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright (c) 2023 - bosonnetwork.io - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -package io.bosonnetwork.crypto; - -import java.io.IOException; -import java.io.StringWriter; -import java.math.BigInteger; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.SecureRandom; -import java.security.Security; -import java.security.spec.PKCS8EncodedKeySpec; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; - -import org.bouncycastle.asn1.oiw.OIWObjectIdentifiers; -import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.asn1.x509.AlgorithmIdentifier; -import org.bouncycastle.asn1.x509.BasicConstraints; -import org.bouncycastle.asn1.x509.ExtendedKeyUsage; -import org.bouncycastle.asn1.x509.Extension; -import org.bouncycastle.asn1.x509.GeneralName; -import org.bouncycastle.asn1.x509.GeneralNames; -import org.bouncycastle.asn1.x509.KeyPurposeId; -import org.bouncycastle.asn1.x509.KeyUsage; -import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.X509ExtensionUtils; -import org.bouncycastle.cert.X509v3CertificateBuilder; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; -import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; -import org.bouncycastle.crypto.util.SubjectPublicKeyInfoFactory; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.DigestCalculator; -import org.bouncycastle.operator.bc.BcDigestCalculatorProvider; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; - -import io.bosonnetwork.utils.Base58; - -/** - * Utility class for performing cryptographic operations and generating PEM-encoded certificates and keys - * using the Bouncy Castle library. - */ -public class CertUtilBouncyCastle { - /** - * Initializes the security provider. - * Adds {@link BouncyCastleProvider} to the security providers. - */ - public static void init() { - Security.addProvider(new BouncyCastleProvider()); - } - - /** - * Generates a self-signed X.509 certificate and private key from a signature private key using Bouncy Castle. - * - * @param signaturePrivateKey the signature private key - * @param ipAddress the IP address to include in the Subject Alternative Name (SAN) - * @param hostName the host name to include in the Subject Alternative Name (SAN) - * @param enableWildcard whether to include a wildcard host name in the SAN - * @return a {@link CryptoUtil.PemCertificateAndKey} containing the PEM-encoded certificate and private key - * @throws CryptoUtil.KeyConvertException if an error occurs during key conversion or certificate generation - */ - public static CryptoUtil.PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey, - String ipAddress, String hostName, boolean enableWildcard) - throws CryptoUtil.KeyConvertException { - try { - // Extract the 32-byte seed and public key from libsodium 64-byte SK - byte[] sodiumSecretKey = signaturePrivateKey.bytes(); - byte[] sodiumSeed = new byte[32]; - System.arraycopy(sodiumSecretKey, 0, sodiumSeed, 0, 32); - byte[] sodiumPublicKey = new byte[32]; - System.arraycopy(sodiumSecretKey, 32, sodiumPublicKey, 0, 32); - String keyId = Base58.encode(sodiumPublicKey); - - // Build Bouncy Castle Ed25519 key parameters - Ed25519PrivateKeyParameters privateKeyParams = new Ed25519PrivateKeyParameters(sodiumSeed); - Ed25519PublicKeyParameters publicKeyParams = new Ed25519PublicKeyParameters(sodiumPublicKey); - - /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410) - // Convert to JCA PrivateKey / PublicKey via PKCS#8 v2 DER encoding (version=1, include public key) - // Encode to PKCS#8 DER, then load via JCA KeyFactory - byte[] pkcs8Bytes = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams).getEncoded(); - */ - - // Convert to JCA PrivateKey via PKCS#8 v1 DER encoding (version=0, no public key) - // BC defaults to v2 (RFC 5958) for Ed25519 which Vert.x rejects - PrivateKeyInfo v2PrivateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams); - PrivateKeyInfo v1PrivateKeyInfo = new PrivateKeyInfo( - v2PrivateKeyInfo.getPrivateKeyAlgorithm(), - v2PrivateKeyInfo.parsePrivateKey() - ); - byte[] pkcs8Bytes = v1PrivateKeyInfo.getEncoded(); - - byte[] spkiBytes = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(publicKeyParams).getEncoded(); - - KeyFactory kf = KeyFactory.getInstance("Ed25519", "BC"); - PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Bytes)); - // unused, but useful for verifying the public key matches the private key - // PublicKey publicKey = kf.generatePublic(new X509EncodedKeySpec(spkiBytes)); - - // Build a self-signed X.509 certificate - X500Name subject = new X500Name("CN=" + keyId); - BigInteger serial = new BigInteger(128, new SecureRandom()); - - // Subtract 10 minutes to handle clock skew - Instant now = Instant.now(); - Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES)); - Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); - - SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(spkiBytes); - - // Without SAN, modern browsers and most TLS clients REJECT the cert - // Chrome/Firefox dropped CN-only matching in 2017 - List subjectAltNames = new ArrayList<>(); - if (hostName != null) - subjectAltNames.add(new GeneralName(GeneralName.dNSName, hostName)); - if (enableWildcard && hostName != null) - subjectAltNames.add(new GeneralName(GeneralName.dNSName, "*." + hostName)); - if (ipAddress != null) - subjectAltNames.add(new GeneralName(GeneralName.iPAddress, ipAddress)); - if (subjectAltNames.isEmpty()) - throw new CryptoUtil.KeyConvertException("At least one SAN (hostname or IP) must be provided"); - - // Ed25519 signatures don't use a hash - pass "Ed25519" directly - ContentSigner signer = new JcaContentSignerBuilder("Ed25519") - .setProvider("BC") - .build(privateKey); - - DigestCalculator digestCalc = new BcDigestCalculatorProvider() - .get(new AlgorithmIdentifier(OIWObjectIdentifiers.idSHA1)); - - X509CertificateHolder certHolder = new X509v3CertificateBuilder(subject, serial, notBefore, notAfter, subject, spki) - // Subject Key Identifier (optional but good practice) - .addExtension(Extension.subjectKeyIdentifier, false, - new X509ExtensionUtils(digestCalc).createSubjectKeyIdentifier(spki)) - // KeyUsage: required for TLS - .addExtension(Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature)) - // SAN - critical for client acceptance - .addExtension(Extension.subjectAlternativeName, false, - new GeneralNames(subjectAltNames.toArray(new GeneralName[0]))) - // BasicConstraints: CA=false, this is a server/end-entity cert - .addExtension(Extension.basicConstraints, true, new BasicConstraints(false)) - // Extended Key Usage: HTTPS, WSS, MQTTS server, only if also used for mTLS client certs - .addExtension(Extension.extendedKeyUsage, false, - new ExtendedKeyUsage(new KeyPurposeId[]{KeyPurposeId.id_kp_serverAuth, KeyPurposeId.id_kp_clientAuth})) - .build(signer); - - // Write private key and certificate to PEM strings - String keyPem = toPemString(privateKey); - String certPem = toPemString(certHolder); - - return new CryptoUtil.PemCertificateAndKey(certPem, keyPem); - } catch (CryptoUtil.KeyConvertException e) { - throw e; - } catch (Exception e) { - throw new CryptoUtil.KeyConvertException("Failed to convert key to PEM format key and certificate", e); - } - } - - private static String toPemString(Object obj) throws IOException { - StringWriter sw = new StringWriter(); - try (JcaPEMWriter writer = new JcaPEMWriter(sw)) { - writer.writeObject(obj); - } - return sw.toString(); - } -} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java b/api/src/test/java/io/bosonnetwork/crypto/CertUtilTests.java similarity index 75% rename from api/src/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java rename to api/src/test/java/io/bosonnetwork/crypto/CertUtilTests.java index aaa80851..9ec5f66c 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CertUtilTests.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023 - bosonnetwork.io + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + package io.bosonnetwork.crypto; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -7,21 +29,24 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.ByteArrayInputStream; +import java.security.Security; import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Date; +import org.bouncycastle.jce.provider.BouncyCastleProvider; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import io.bosonnetwork.utils.Base58; -public class CryptoUtilTests { +public class CertUtilTests { @BeforeAll - public static void setup() { - CertUtilBouncyCastle.init(); + static void setup() { + Security.addProvider(new BouncyCastleProvider()); } @Test @@ -29,7 +54,7 @@ public void testCertificateFromSignatureKeyBCWithIP() throws Exception { Signature.KeyPair kp = Signature.KeyPair.random(); String ipAddress = "127.0.0.1"; - CryptoUtil.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, null, false); + PemCertificateAndKey result = CertUtil.certificateFromSignatureKey(kp.privateKey(), ipAddress, null, false); assertNotNull(result); assertNotNull(result.cert()); @@ -49,7 +74,7 @@ public void testCertificateFromSignatureKeyBCWithHostName() throws Exception { Signature.KeyPair kp = Signature.KeyPair.random(); String hostName = "localhost"; - CryptoUtil.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), null, hostName, true); + PemCertificateAndKey result = CertUtil.certificateFromSignatureKey(kp.privateKey(), null, hostName, true); assertNotNull(result); assertNotNull(result.cert()); @@ -70,7 +95,7 @@ public void testCertificateFromSignatureKeyBCWithBoth() throws Exception { String ipAddress = "127.0.0.1"; String hostName = "localhost"; - CryptoUtil.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); + PemCertificateAndKey result = CertUtil.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); assertNotNull(result); assertNotNull(result.cert()); @@ -87,8 +112,8 @@ public void testCertificateFromSignatureKeyBCWithBoth() throws Exception { public void testCertificateFromSignatureKeyBCNoSAN() { Signature.KeyPair kp = Signature.KeyPair.random(); - assertThrows(CryptoUtil.KeyConvertException.class, () -> - CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), null, null, false) + assertThrows(IllegalArgumentException.class, () -> + CertUtil.certificateFromSignatureKey(kp.privateKey(), null, null, false) ); } @@ -98,7 +123,7 @@ public void testCertificateFromSignatureKey() throws Exception { String ipAddress = "127.0.0.1"; String hostName = "localhost"; - CryptoUtil.PemCertificateAndKey result = CryptoUtil.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); + PemCertificateAndKey result = CertUtil.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); assertNotNull(result); assertNotNull(result.cert()); @@ -112,7 +137,7 @@ public void testCertificateFromSignatureKey() throws Exception { assertTrue(result.privateKey().contains("-----BEGIN PRIVATE KEY-----")); // Compare with reference implementation - CryptoUtil.PemCertificateAndKey ref = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); + PemCertificateAndKey ref = CertUtil.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); System.out.println("Reference Implementation Result:"); System.out.println(ref.cert()); diff --git a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java index d731a04f..8dbd3191 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CryptoCompatibilityTest.java @@ -23,18 +23,30 @@ package io.bosonnetwork.crypto; import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeTrue; +import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.security.SecureRandom; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Date; import org.apache.tuweni.crypto.sodium.Sodium; + import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -516,4 +528,119 @@ void pwHashStringInteroperatesBothDirections() { assertTrue(BC.pwHashVerify(bcPhc, pw), "BC verifies its own PHC string"); assertFalse(BC.pwHashVerify(bcPhc, "wrong".getBytes(StandardCharsets.UTF_8)), "wrong password rejected"); } + + private static byte[] pemToDer(String pem) { + String base64 = pem.replaceAll("-----[A-Z ]+-----", "").replaceAll("\\s", ""); + return Base64.getDecoder().decode(base64); + } + + private static void assertCertEquals(PemCertificateAndKey cnk1, PemCertificateAndKey cnk2) { + // Compare the decoded PKCS#8 DER rather than the raw PEM text, so the check is independent + // of PEM line wrapping or line terminators between the two implementations. + assertArrayEquals(pemToDer(cnk1.privateKey()), pemToDer(cnk2.privateKey()), "PKCS#8 private key DER"); + + // Verify the certificate can be parsed by standard JDK CertificateFactory + CertificateFactory cf; + try { + cf = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + fail("CertificateFactory", e); + return; + } + + X509Certificate cert1; + X509Certificate cert2; + try { + cert1 = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(cnk1.cert().getBytes())); + cert2 = (X509Certificate) cf.generateCertificate(new ByteArrayInputStream(cnk2.cert().getBytes())); + } catch (CertificateException e) { + fail("generateCertificate", e); + return; + } + + assertNotNull(cert1); + assertNotNull(cert2); + + assertDoesNotThrow(() -> cert1.checkValidity()); + assertDoesNotThrow(() -> cert2.checkValidity()); + + // Each certificate is self-signed, so it must verify against its own public key. + assertDoesNotThrow(() -> cert1.verify(cert1.getPublicKey()), "cert1 self-signature"); + assertDoesNotThrow(() -> cert2.verify(cert2.getPublicKey()), "cert2 self-signature"); + + assertEquals(3, cert1.getVersion(), "Cert Version should be 3"); + assertEquals(cert1.getVersion(), cert2.getVersion(), "Cert version"); + + assertEquals("X.509", cert1.getType(), "Cert type should be X.509"); + assertEquals(cert1.getType(), cert2.getType(), "Cert type"); + + assertEquals("Ed25519", cert1.getSigAlgName(), "SigAlgorithm should be Ed25519"); + assertEquals(cert1.getSigAlgName(), cert2.getSigAlgName(), "Cert SigAlgorithm"); + + + Instant now = Instant.now(); + Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES)); + Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); + + assertTrue(cert1.getNotAfter().getTime() <= notAfter.getTime(), "Cert NotAfter"); + assertTrue(cert2.getNotAfter().getTime() <= notAfter.getTime(), "Cert NotAfter"); + assertTrue(Math.abs(cert1.getNotAfter().getTime() - cert2.getNotAfter().getTime()) < 1000, "Cert NotAfter"); + + assertTrue(cert1.getNotAfter().getTime() > Date.from(now.plus(3649, ChronoUnit.DAYS)).getTime(), "Cert NotAfter"); + assertTrue(cert2.getNotAfter().getTime() > Date.from(now.plus(3649, ChronoUnit.DAYS)).getTime(), "Cert NotAfter"); + + assertTrue(cert1.getNotBefore().getTime() <= notBefore.getTime(), "Cert NotBefore"); + assertTrue(cert2.getNotBefore().getTime() <= notBefore.getTime(), "Cert NotBefore"); + assertTrue(Math.abs(cert1.getNotBefore().getTime() - cert2.getNotBefore().getTime()) < 1000, "Cert NotBefore"); + + assertTrue(cert1.getNotBefore().getTime() > Date.from(now.minus(11, ChronoUnit.MINUTES)).getTime(), "Cert NotBefore"); + assertTrue(cert2.getNotBefore().getTime() > Date.from(now.minus(11, ChronoUnit.MINUTES)).getTime(), "Cert NotBefore"); + + assertEquals(cert1.getSubjectX500Principal().getName(), cert2.getSubjectX500Principal().getName(), "Cert Subject"); + assertEquals(cert1.getIssuerX500Principal().getName(), cert2.getIssuerX500Principal().getName(), "Cert Issuer"); + assertNotEquals(cert1.getSerialNumber().toString(16), cert2.getSerialNumber().toString(16), "Cert SerialNumber"); + assertEquals(cert1.getPublicKey().getAlgorithm(), cert2.getPublicKey().getAlgorithm(), "Cert PublicKey Algorithm"); + assertEquals(cert1.getPublicKey().getFormat(), cert2.getPublicKey().getFormat(), "Cert PublicKey Format"); + assertArrayEquals(cert1.getPublicKey().getEncoded(), cert2.getPublicKey().getEncoded(), "Cert PublicKey Encoded"); + assertEquals(cert1.getSigAlgOID(), cert2.getSigAlgOID(), "Cert SigAlgOID"); + + assertEquals(cert1.getCriticalExtensionOIDs(), cert2.getCriticalExtensionOIDs(), "Cert critical extension OIDs"); + for (String oid : cert1.getCriticalExtensionOIDs()) + assertArrayEquals(cert1.getExtensionValue(oid), cert2.getExtensionValue(oid), "Cert extension " + oid); + + assertEquals(cert1.getNonCriticalExtensionOIDs(), cert2.getNonCriticalExtensionOIDs(), "Cert non-critical extension OIDs"); + for (String oid : cert1.getNonCriticalExtensionOIDs()) + assertArrayEquals(cert1.getExtensionValue(oid), cert2.getExtensionValue(oid), "Cert non-critical extension " + oid); + } + + @Test + void certFromEd25519SeedMatches() throws CryptoException { + Signature.PrivateKey secretKey = Signature.PrivateKey.fromSeed(rb(32)); + + PemCertificateAndKey bcCert = BC.certificateFromSignatureKey(secretKey, "192.168.8.1", null, false); + PemCertificateAndKey lsCert = LS.certificateFromSignatureKey(secretKey, "192.168.8.1", null, false); + assertCertEquals(bcCert, lsCert); + + bcCert = BC.certificateFromSignatureKey(secretKey, null, "example.com", false); + lsCert = LS.certificateFromSignatureKey(secretKey, null, "example.com", false); + assertCertEquals(bcCert, lsCert); + + bcCert = BC.certificateFromSignatureKey(secretKey, null, "example.com", true); + lsCert = LS.certificateFromSignatureKey(secretKey, null, "example.com", true); + assertCertEquals(bcCert, lsCert); + + bcCert = BC.certificateFromSignatureKey(secretKey, "192.168.8.1", "example.com", false); + lsCert = LS.certificateFromSignatureKey(secretKey, "192.168.8.1", "example.com", false); + assertCertEquals(bcCert, lsCert); + + bcCert = BC.certificateFromSignatureKey(secretKey, "192.168.8.1", "example.com", true); + lsCert = LS.certificateFromSignatureKey(secretKey, "192.168.8.1", "example.com", true); + assertCertEquals(bcCert, lsCert); + + // Both providers must reject a request with no SAN entry. + assertThrows(IllegalArgumentException.class, + () -> BC.certificateFromSignatureKey(secretKey, null, null, false), "BC requires a SAN"); + assertThrows(IllegalArgumentException.class, + () -> LS.certificateFromSignatureKey(secretKey, null, null, false), "LS requires a SAN"); + } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java index b8fb44c7..4e8f474e 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java +++ b/api/src/test/java/io/bosonnetwork/crypto/SodiumCryptoProvider.java @@ -22,16 +22,37 @@ package io.bosonnetwork.crypto; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.net.InetAddress; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.SecureRandom; +import java.security.SignatureException; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.text.SimpleDateFormat; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; import java.util.Arrays; +import java.util.Base64; +import java.util.Date; +import java.util.TimeZone; import org.apache.tuweni.crypto.sodium.Box; import org.apache.tuweni.crypto.sodium.KeyDerivation; import org.apache.tuweni.crypto.sodium.PasswordHash; - import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +import io.bosonnetwork.utils.Base58; + /** * Test-only {@link CryptoProvider} backed by libsodium through Apache Tuweni. It exists solely * so the crypto compatibility test can verify, primitive by primitive, that the production @@ -442,4 +463,255 @@ public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) { // the no-arg needsRehash(hash) would compare against the MODERATE defaults instead. return PasswordHash.needsRehash(hash, opsLimit, memLimit); } + + @Override + public PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey, + @Nullable String ipAddress, @Nullable String hostName, + boolean enableWildcard) throws CryptoException { + // Mirror BouncyCastleCryptoProvider: at least one SAN entry is required. + if (ipAddress == null && hostName == null) + throw new IllegalArgumentException("At least one SAN (hostname or IP) must be provided"); + + try { + // Extract the 32-byte seed and public key from libsodium 64-byte SK + byte[] sodiumSecretKey = signaturePrivateKey.bytes(); + byte[] sodiumSeed = new byte[32]; + System.arraycopy(sodiumSecretKey, 0, sodiumSeed, 0, 32); + byte[] sodiumPublicKey = new byte[32]; + System.arraycopy(sodiumSecretKey, 32, sodiumPublicKey, 0, 32); + String keyId = Base58.encode(sodiumPublicKey); + + /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410) + // Use standard JDK 15+ Ed25519 support + // PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410) + // Version v2 (1) because we include the public key + // AlgorithmIdentifier: 1.3.101.112 + // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed) + // PublicKey: [1] IMPLICIT BIT STRING (32 bytes) + byte[] pkcs8Bytes = new byte[83]; + System.arraycopy(new byte[]{ + 0x30, 0x51, // SEQUENCE (81 bytes) + 0x02, 0x01, 0x01, // Version v2 (1) + 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112) + 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes) + }, 0, pkcs8Bytes, 0, 16); + System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32); + System.arraycopy(new byte[]{ + (byte) 0x81, 0x21, 0x00 // [1] IMPLICIT BIT STRING (33 bytes: 0 padding + 32 bytes) + }, 0, pkcs8Bytes, 48, 3); + System.arraycopy(sodiumPublicKey, 0, pkcs8Bytes, 51, 32); + */ + + // Use standard JDK 15+ Ed25519 support + // PKCS#8 v1 OneAsymmetricKey for Ed25519 (RFC 8410) + // Version v1 (0) because we don't include the public key + // AlgorithmIdentifier: 1.3.101.112 + // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed) + byte[] pkcs8Bytes = new byte[48]; + System.arraycopy(new byte[]{ + 0x30, 0x2e, // SEQUENCE (46 bytes) + 0x02, 0x01, 0x00, // Version v1 (0) + 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112) + 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes) + }, 0, pkcs8Bytes, 0, 16); + System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32); + + KeyFactory kf = KeyFactory.getInstance("Ed25519"); + PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Bytes)); + + // Build TBSCertificate + BigInteger serial = new BigInteger(128, new SecureRandom()); + Instant now = Instant.now(); + Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES)); + Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS)); + + // Construct manual ASN.1/DER certificate + byte[] tbs = encodeTBS(serial, keyId, notBefore, notAfter, sodiumPublicKey, ipAddress, hostName, enableWildcard); + + java.security.Signature sig = java.security.Signature.getInstance("Ed25519"); + sig.initSign(privateKey); + sig.update(tbs); + byte[] signatureValue = sig.sign(); + + byte[] certDer = encodeCert(tbs, signatureValue); + + String keyPem = "-----BEGIN PRIVATE KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(pkcs8Bytes) + + "\n-----END PRIVATE KEY-----\n"; + + String certPem = "-----BEGIN CERTIFICATE-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(certDer) + + "\n-----END CERTIFICATE-----\n"; + + return new PemCertificateAndKey(certPem, keyPem); + } catch (IOException | InvalidKeyException | SignatureException | NoSuchAlgorithmException | + InvalidKeySpecException e) { + throw new CryptoException("Failed to convert key using simple implementation", e); + } + } + + private static byte[] encodeTBS(BigInteger serial, String cn, Date notBefore, Date notAfter, byte[] pubKey, + @Nullable String ip, @Nullable String host, boolean wildcard) throws IOException { + DerBuilder tbs = new DerBuilder(); + tbs.addTag((byte) 0xA0, new DerBuilder().addInt(2).build()); // Version v3 + tbs.addInt(serial); + tbs.addSeq(new DerBuilder().addOid("1.3.101.112")); // Algorithm: Ed25519 + tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Issuer + tbs.addSeq(new DerBuilder().addTime(notBefore).addTime(notAfter)); // Validity + tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Subject + tbs.addSeq(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.101.112")).addBitString(pubKey)); // SubjectPublicKeyInfo + + // Extensions + DerBuilder exts = new DerBuilder(); + + // Subject Key Identifier (critical=false) + try { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + // SKI = SHA-1 (public key bytes). + // For Ed25519, BouncyCastle calculates SHA-1 over the raw 32-byte public key. + // The extension value is an OCTET STRING containing the key identifier (which is also an OCTET STRING per RFC 5280). + // However, in X.509, the extension value field is ALREADY an OCTET STRING, so we just wrap the hash in an OCTET STRING. + byte[] ski = sha1.digest(pubKey); + exts.addSeq(new DerBuilder().addOid("2.5.29.14").addOctetString(new DerBuilder().addOctetString(ski).build())); + } catch (NoSuchAlgorithmException e) { + throw new IOException("SHA-1 not found", e); + } + + // KeyUsage (critical=true, digitalSignature=bit 0) + exts.addSeq(new DerBuilder().addOid("2.5.29.15").addBool(true).addOctetString(new DerBuilder().addBitString(new byte[]{(byte) 0x80}, 7).build())); + + // SAN (critical=false) + DerBuilder san = new DerBuilder(); + if (host != null) san.addTag((byte) 0x82, host.getBytes(StandardCharsets.US_ASCII)); + if (wildcard && host != null) san.addTag((byte) 0x82, ("*." + host).getBytes(StandardCharsets.US_ASCII)); + if (ip != null) { + byte[] ipBytes = InetAddress.getByName(ip).getAddress(); // 4 bytes for IPv4, 16 for IPv6 + san.addTag((byte) 0x87, ipBytes); + } + if (ip != null || host != null) + exts.addSeq(new DerBuilder().addOid("2.5.29.17").addOctetString(new DerBuilder().addSeq(san).build())); + + // BasicConstraints (critical=true, CA=false) + exts.addSeq(new DerBuilder().addOid("2.5.29.19").addBool(true).addOctetString(new DerBuilder().addSeq(new DerBuilder()).build())); + + // ExtendedKeyUsage (critical=false, serverAuth, clientAuth) + exts.addSeq(new DerBuilder().addOid("2.5.29.37").addOctetString(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.6.1.5.5.7.3.1").addOid("1.3.6.1.5.5.7.3.2")).build())); + + tbs.addTag((byte) 0xA3, new DerBuilder().addSeq(exts).build()); + + return tbs.buildSeq(); + } + + private static byte[] encodeCert(byte[] tbs, byte[] signature) throws IOException { + return new DerBuilder() + .addRaw(tbs) + .addSeq(new DerBuilder().addOid("1.3.101.112")) + .addBitString(signature) + .buildSeq(); + } + + private static class DerBuilder { + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + + public DerBuilder addRaw(byte[] raw) throws IOException { + out.write(raw); + return this; + } + + public DerBuilder addTag(byte tag, byte[] val) throws IOException { + out.write(tag); + writeLen(val.length); + out.write(val); + return this; + } + + public DerBuilder addInt(long v) throws IOException { + return addInt(BigInteger.valueOf(v)); + } + + public DerBuilder addInt(BigInteger v) throws IOException { + return addTag((byte) 0x02, v.toByteArray()); + } + + public DerBuilder addOid(String oid) throws IOException { + String[] parts = oid.split("\\."); + ByteArrayOutputStream b = new ByteArrayOutputStream(); + b.write(Integer.parseInt(parts[0]) * 40 + Integer.parseInt(parts[1])); + for (int i = 2; i < parts.length; i++) { + long v = Long.parseLong(parts[i]); + if (v == 0) b.write(0); + else { + byte[] buf = new byte[10]; + int pos = 10; + buf[--pos] = (byte) (v & 0x7F); + while ((v >>= 7) > 0) buf[--pos] = (byte) ((v & 0x7F) | 0x80); + b.write(buf, pos, 10 - pos); + } + } + return addTag((byte) 0x06, b.toByteArray()); + } + + public DerBuilder addPrintableString(String s) throws IOException { + return addTag((byte) 0x13, s.getBytes(StandardCharsets.US_ASCII)); + } + + // RFC 5280: encode dates before 2050 as UTCTime, and 2050 or later as GeneralizedTime. + public DerBuilder addTime(Date d) throws IOException { + int year = d.toInstant().atZone(ZoneOffset.UTC).getYear(); + if (year < 2050) { + SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return addTag((byte) 0x17, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // UTCTime + } else { + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss'Z'"); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return addTag((byte) 0x18, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // GeneralizedTime + } + } + + public DerBuilder addBitString(byte[] b) throws IOException { + return addBitString(b, 0); + } + + public DerBuilder addBitString(byte[] b, int pad) throws IOException { + byte[] val = new byte[b.length + 1]; + val[0] = (byte) pad; + System.arraycopy(b, 0, val, 1, b.length); + return addTag((byte) 0x03, val); + } + + public DerBuilder addOctetString(byte[] b) throws IOException { + return addTag((byte) 0x04, b); + } + + public DerBuilder addBool(boolean v) throws IOException { + return addTag((byte) 0x01, new byte[]{(byte) (v ? 0xFF : 0x00)}); + } + + public DerBuilder addSeq(DerBuilder b) throws IOException { + return addTag((byte) 0x30, b.build()); + } + + public DerBuilder addSet(DerBuilder b) throws IOException { + return addTag((byte) 0x31, b.build()); + } + + public byte[] build() { + return out.toByteArray(); + } + + public byte[] buildSeq() throws IOException { + return new DerBuilder().addSeq(this).build(); + } + + private void writeLen(int len) { + if (len < 128) out.write(len); + else { + byte[] b = BigInteger.valueOf(len).toByteArray(); + int skip = (b.length > 1 && b[0] == 0) ? 1 : 0; + out.write(0x80 | (b.length - skip)); + out.write(b, skip, b.length - skip); + } + } + } } \ No newline at end of file From 5d9e07e0d97525213253c478dd681aa64e4535a8 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Mon, 29 Jun 2026 11:28:57 +0800 Subject: [PATCH 09/10] Avoid javax.naming references for cross-platform compatibility --- .../crypto/HybridTrustManager.java | 66 +++++++++++++++---- 1 file changed, 54 insertions(+), 12 deletions(-) diff --git a/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java b/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java index 0085130e..49e62fe6 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java +++ b/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java @@ -29,8 +29,6 @@ import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.util.Arrays; -import javax.naming.InvalidNameException; -import javax.naming.ldap.LdapName; import javax.net.ssl.TrustManager; import javax.net.ssl.TrustManagerFactory; import javax.net.ssl.X509TrustManager; @@ -139,16 +137,9 @@ private void checkTrusted(X509Certificate[] chain, String authType, boolean clie // 3. Validate CN String dn = cert.getSubjectX500Principal().getName(); - LdapName ldapName; - try { - ldapName = new LdapName(dn); - } catch (InvalidNameException e) { - throw new CertificateException(e); - } - String cn = ldapName.getRdns().stream() - .filter(r -> r.getType().equalsIgnoreCase("CN")) - .map(r -> r.getValue().toString()) - .findFirst().orElseThrow(() -> new CertificateException("No CN in certificate")); + String cn = extractCn(dn); + if (cn == null) + throw new CertificateException("No CN in certificate"); if (!cn.equals(expectedCn)) throw new CertificateException("CN mismatch"); @@ -168,6 +159,57 @@ private void checkTrusted(X509Certificate[] chain, String authType, boolean clie } } + /** + * Extracts the Common Name (CN) value from an RFC 2253 distinguished name, as returned by + * {@link javax.security.auth.x500.X500Principal#getName()}. + * + *

This intentionally avoids {@code javax.naming.ldap.LdapName}, which is unavailable on Android. + * It handles backslash escapes and double-quoted values, and stops an attribute value at an + * unescaped RDN separator ({@code ,} or {@code +}). + * + * @param dn the RFC 2253 distinguished name + * @return the first CN value found, or {@code null} if the DN has no CN attribute + */ + private static @Nullable String extractCn(String dn) { + int i = 0; + final int n = dn.length(); + while (i < n) { + int eq = i; + while (eq < n && dn.charAt(eq) != '=') + eq++; + if (eq >= n) + break; + + String type = dn.substring(i, eq).trim(); + StringBuilder value = new StringBuilder(); + int j = eq + 1; + boolean quoted = false; + while (j < n) { + char c = dn.charAt(j); + if (c == '\\' && j + 1 < n) { + value.append(dn.charAt(j + 1)); + j += 2; + continue; + } + if (c == '"') { + quoted = !quoted; + j++; + continue; + } + if (!quoted && (c == ',' || c == '+')) + break; + value.append(c); + j++; + } + + if (type.equalsIgnoreCase("CN")) + return value.toString().trim(); + + i = j + 1; + } + return null; + } + /** * Returns the list of certificate issuer authorities which are trusted for * authenticating peers. From abc2acedad6b17690c785825761903b186963146 Mon Sep 17 00:00:00 2001 From: Jingyu Date: Tue, 30 Jun 2026 18:41:46 +0800 Subject: [PATCH 10/10] Fix null check warnings --- .gitignore | 2 +- .../java/io/bosonnetwork/utils/Variable.java | 16 ++++++++-------- .../io/bosonnetwork/vertx/BosonVerticle.java | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 479bae81..0f65574c 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,4 @@ local.properties #.project .DS_Store - +*.args \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/utils/Variable.java b/api/src/main/java/io/bosonnetwork/utils/Variable.java index 4597f1aa..9376bdb4 100644 --- a/api/src/main/java/io/bosonnetwork/utils/Variable.java +++ b/api/src/main/java/io/bosonnetwork/utils/Variable.java @@ -295,11 +295,11 @@ public Variable flatMap(Function stream() { - if (!isPresent()) - return Stream.empty(); - else - return Stream.of(value); + return Stream.ofNullable(value); } /** @@ -405,11 +405,11 @@ public T orElseThrow(Supplier exceptionSuppli * @return an {@code Optional} containing the value of this {@code Variable} * if present, otherwise an empty {@code Optional} */ + // Suppress NullAway: value is non-null past the guard, but T's @Nullable bound + // defeats Optional.of's non-null type parameter (JSpecify non-null projection limit). + @SuppressWarnings("NullAway") public Optional toOptional() { - if (value == null) - return Optional.empty(); - - return Optional.of(value); + return Optional.ofNullable(value); } /** diff --git a/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java b/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java index 46c76958..040afce1 100644 --- a/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java +++ b/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java @@ -104,7 +104,7 @@ public final String deploymentID() { * * @return the configuration as a {@link JsonObject} */ - protected final JsonObject vertxConfig() { + protected final @Nullable JsonObject vertxConfig() { Objects.requireNonNull(vertxContext, "Vert.x context is not available."); return vertxContext.config(); }