* CryptoContext provides a cryptographic context for encrypting and decrypting messages - * using public-key authenticated encryption. It manages nonce generation and validation - * to ensure message uniqueness and replay protection. + * using public-key authenticated encryption. It manages nonce generation for outgoing messages + * and provides a basic safeguard against immediate reuse of the previous incoming nonce + * (see {@link #decrypt(byte[])} for the precise, limited guarantee). *
** Thread Safety: The nonce generation for outgoing messages is synchronized to ensure @@ -138,14 +139,16 @@ public byte[] encrypt(byte[] data) { /** * Decrypts the given data, verifying and extracting the prepended nonce. *
- * This method checks for nonce reuse to prevent replay attacks. If the nonce - * is duplicated (i.e., the same as the last received nonce), a {@link CryptoException} - * is thrown. + * As a basic safeguard this rejects an exact repeat of the immediately previous + * peer nonce (throwing {@link CryptoException}). This is not full replay + * protection: it does not detect reuse of any earlier nonce, and the check is not thread-safe + * (concurrent {@code decrypt} calls may race). Callers that need strong replay protection must + * track seen nonces themselves. *
* * @param data The encrypted data, with the nonce prepended (nonce || ciphertext). * @return The decrypted plaintext data. - * @throws CryptoException If the input is invalid, the nonce is duplicated, or decryption fails. + * @throws CryptoException If the input is invalid, the nonce repeats the previous one, or decryption fails. * @throws NullPointerException if {@code data} is {@code null}. */ public byte[] decrypt(byte[] data) throws CryptoException { diff --git a/api/src/main/java/io/bosonnetwork/Id.java b/api/src/main/java/io/bosonnetwork/Id.java index 01ba391f..c6276521 100644 --- a/api/src/main/java/io/bosonnetwork/Id.java +++ b/api/src/main/java/io/bosonnetwork/Id.java @@ -83,6 +83,9 @@ public class Id implements Comparable
+ * Lookup result conventions: {@link #findNode} returns a {@link Result} that may carry the
+ * node's IPv4 and/or IPv6 address (either side may be {@code null}). Single-result lookups
+ * ({@link #findValue}, the single-result {@link #findPeer(Id)}) complete with {@code null} when
+ * nothing is found, while collection lookups ({@link #getPeers}, {@link #findPeer(Id, int, int, LookupOption)})
+ * complete with an empty list.
*/
public interface Node extends Identity {
/** The maximum age for a peer (2 hours). */
@@ -242,10 +249,10 @@ default CompletableFuture
+ * The concrete implementation is discovered via the {@link ServiceLoader} mechanism,
+ * looking up a registered {@link NodeFactory} provider (the Kademlia DHT node is
+ * provided by the {@code boson-dht} module).
*
* @param config the node configuration
* @return an initialized {@link Node} instance
- * @throws BosonException if the KadNode cannot be initialized
+ * @throws BosonException if no node implementation is available or it cannot be initialized
*/
static Node kadNode(NodeConfiguration config) throws BosonException {
- try {
- return (Node) Class.forName("io.bosonnetwork.kademlia.KadNode")
- .getConstructor(NodeConfiguration.class)
- .newInstance(config);
- } catch (ClassNotFoundException e) {
- throw new BosonException("KadNode not found in classpath", e);
- } catch (Exception e) {
- throw new BosonException("Internal error: can not instantiate KadNode", e);
- }
+ NodeFactory factory = ServiceLoader.load(NodeFactory.class).findFirst()
+ .orElseThrow(() -> new BosonException("No NodeFactory implementation found in classpath"));
+ return factory.create(config);
}
}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/NodeConfiguration.java b/api/src/main/java/io/bosonnetwork/NodeConfiguration.java
index 933d8a56..14d76a50 100644
--- a/api/src/main/java/io/bosonnetwork/NodeConfiguration.java
+++ b/api/src/main/java/io/bosonnetwork/NodeConfiguration.java
@@ -122,7 +122,7 @@ default Path dataDir() {
/**
* Provides the URL for database storage used by the DHT node.
*
- * @return the external database URL as a string, or {@code null} if not configured.
+ * @return the database URL as a string; defaults to {@code "jdbc:sqlite:node.db"}.
*/
default String databaseUri() {
return "jdbc:sqlite:node.db";
diff --git a/api/src/main/java/io/bosonnetwork/NodeFactory.java b/api/src/main/java/io/bosonnetwork/NodeFactory.java
new file mode 100644
index 00000000..090def52
--- /dev/null
+++ b/api/src/main/java/io/bosonnetwork/NodeFactory.java
@@ -0,0 +1,46 @@
+/*
+ * 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;
+
+/**
+ * Service provider interface for creating {@link Node} instances.
+ *
+ * Implementations are discovered at runtime via the {@link java.util.ServiceLoader}
+ * mechanism, which decouples the {@code boson-api} contract from the concrete node
+ * implementation (e.g. the Kademlia DHT node in {@code boson-dht}). Providers register
+ * themselves through a {@code META-INF/services/io.bosonnetwork.NodeFactory} entry, or a
+ * {@code provides io.bosonnetwork.NodeFactory with ...} declaration when running on the
+ * Java module path.
+ *
+ * @see Node#kadNode(NodeConfiguration)
+ */
+public interface NodeFactory {
+ /**
+ * Creates and initializes a new {@link Node} instance using the provided configuration.
+ *
+ * @param config the node configuration
+ * @return an initialized {@link Node} instance
+ * @throws BosonException if the node cannot be initialized
+ */
+ Node create(NodeConfiguration config) throws BosonException;
+}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/NodeInfo.java b/api/src/main/java/io/bosonnetwork/NodeInfo.java
index 2aa73ebd..386e7fa4 100644
--- a/api/src/main/java/io/bosonnetwork/NodeInfo.java
+++ b/api/src/main/java/io/bosonnetwork/NodeInfo.java
@@ -29,7 +29,7 @@
import java.util.Objects;
/**
- * THis class represent the node information in the Boson network, it contains
+ * This class represents the node information in the Boson network; it contains
* basic node network information.
*/
public class NodeInfo {
@@ -44,12 +44,8 @@ public class NodeInfo {
* @param addr the node socket address.
*/
public NodeInfo(Id id, InetSocketAddress addr) {
- if (id == null)
- throw new IllegalArgumentException("Invalid node id: null");
-
- if (addr == null)
- throw new IllegalArgumentException("Invalid socket address: null");
-
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(addr, "addr");
if (addr.getPort() <= 0 || addr.getPort() > 65535)
throw new IllegalArgumentException("Invalid port: " + addr.getPort());
@@ -65,12 +61,8 @@ public NodeInfo(Id id, InetSocketAddress addr) {
* @param port the node port number.
*/
public NodeInfo(Id id, InetAddress addr, int port) {
- if (id == null)
- throw new IllegalArgumentException("Invalid node id: null");
-
- if (addr == null)
- throw new IllegalArgumentException("Invalid socket address: null");
-
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(addr, "addr");
if (port <= 0 || port > 65535)
throw new IllegalArgumentException("Invalid port: " + port);
@@ -86,12 +78,8 @@ public NodeInfo(Id id, InetAddress addr, int port) {
* @param port the node port number.
*/
public NodeInfo(Id id, String host, int port) {
- if (id == null)
- throw new IllegalArgumentException("Invalid node id: null");
-
- if (host == null)
- throw new IllegalArgumentException("Invalid socket address: null");
-
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(host, "host");
if (port <= 0 || port > 65535)
throw new IllegalArgumentException("Invalid port: " + port);
@@ -107,10 +95,8 @@ public NodeInfo(Id id, String host, int port) {
* @param port the node port number.
*/
public NodeInfo(Id id, byte[] addr, int port) {
- if (id == null)
- throw new IllegalArgumentException("Invalid node id: null");
- if (addr == null)
- throw new IllegalArgumentException("Invalid socket address: null");
+ Objects.requireNonNull(id, "id");
+ Objects.requireNonNull(addr, "addr");
if (port <= 0 || port > 65535)
throw new IllegalArgumentException("Invalid port: " + port);
@@ -129,8 +115,7 @@ public NodeInfo(Id id, byte[] addr, int port) {
* @param ni another node info object.
*/
protected NodeInfo(NodeInfo ni) {
- if (ni == null)
- throw new IllegalArgumentException("Invalid node info: null");
+ Objects.requireNonNull(ni, "ni");
this.id = ni.id;
this.addr = ni.addr;
@@ -202,10 +187,12 @@ public int getVersion() {
}
/**
- * Checks if the node information is identical with the other one.
+ * 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
+ * collisions, not full equality (see {@link #equals(Object)}).
*
* @param other another node info object to check
- * @return true if the other node info object is identical with this, false otherwise.
+ * @return true if this and {@code other} share the same id or the same address, false otherwise.
*/
public boolean matches(NodeInfo other) {
if (other != null)
@@ -216,7 +203,7 @@ public boolean matches(NodeInfo other) {
@Override
public int hashCode() {
- return 0x6030A + Objects.hash(id, addr, version);
+ return 0x6030A + Objects.hash(id, addr);
}
@Override
diff --git a/api/src/main/java/io/bosonnetwork/PeerInfo.java b/api/src/main/java/io/bosonnetwork/PeerInfo.java
index aa65c7a4..00e38ac7 100644
--- a/api/src/main/java/io/bosonnetwork/PeerInfo.java
+++ b/api/src/main/java/io/bosonnetwork/PeerInfo.java
@@ -25,7 +25,6 @@
import static java.nio.charset.StandardCharsets.UTF_8;
-import java.nio.ByteBuffer;
import java.security.MessageDigest;
import java.text.Normalizer;
import java.util.Arrays;
@@ -37,8 +36,9 @@
import io.bosonnetwork.crypto.Hash;
import io.bosonnetwork.crypto.Random;
import io.bosonnetwork.crypto.Signature;
-import io.bosonnetwork.utils.Hex;
import io.bosonnetwork.json.Json;
+import io.bosonnetwork.utils.Bytes;
+import io.bosonnetwork.utils.Hex;
/**
* PeerInfo describes the service information published over the Boson DHT network.
@@ -65,7 +65,7 @@
public class PeerInfo {
/** The number of bytes in the nonce. */
- public static int NONCE_BYTES = 24;
+ public static final int NONCE_BYTES = 24;
/** Attribute key to omit the peer ID in the peer info used in JsonContext. */
public static final Object ATTRIBUTE_OMIT_PEER_ID = new Object();
@@ -102,15 +102,15 @@ public class PeerInfo {
private PeerInfo(Id peerId, byte[] privateKey, byte[] nonce, int sequenceNumber, Id nodeId, byte[] nodeSig,
byte[] signature, long fingerprint, String endpoint, byte[] extraData) {
this.publicKey = peerId;
- this.privateKey = privateKey;
- this.nonce = nonce;
+ this.privateKey = privateKey == null ? null : privateKey.clone();
+ this.nonce = nonce == null ? null : nonce.clone();
this.sequenceNumber = sequenceNumber;
this.nodeId = nodeId;
- this.nodeSig = nodeSig;
- this.signature = signature;
+ this.nodeSig = nodeSig == null ? null : nodeSig.clone();
+ this.signature = signature == null ? null : signature.clone();
this.fingerprint = fingerprint;
this.endpoint = endpoint;
- this.extraData = extraData;
+ this.extraData = extraData == null ? null : extraData.clone();
}
/**
@@ -149,14 +149,16 @@ public static PeerInfo of(Id peerId, byte[] nonce, int sequenceNumber, Id nodeId
*/
public static PeerInfo of(Id peerId, byte[] privateKey, byte[] nonce, int sequenceNumber, Id nodeId, byte[] nodeSig,
byte[] signature, long fingerprint, String endpoint, byte[] extraData) {
- if (peerId == null)
- throw new IllegalArgumentException("Invalid peer id: must not be null");
+ Objects.requireNonNull(peerId, "peerId");
+ Objects.requireNonNull(nonce, "nonce");
+ Objects.requireNonNull(signature, "signature");
+ Objects.requireNonNull(endpoint, "endpoint");
// noinspection DuplicatedCode
if (privateKey != null && privateKey.length != Signature.PrivateKey.BYTES)
throw new IllegalArgumentException("Invalid private key: incorrect length");
- if (nonce == null || nonce.length != NONCE_BYTES)
+ if (nonce.length != NONCE_BYTES)
throw new IllegalArgumentException("Invalid nonce: must be exactly NONCE_BYTES (24 bytes)");
if (sequenceNumber < 0)
@@ -170,11 +172,11 @@ public static PeerInfo of(Id peerId, byte[] privateKey, byte[] nonce, int sequen
throw new IllegalArgumentException("Invalid node signature: must be null when nodeId is null");
}
- if (signature == null || signature.length != Signature.BYTES)
+ if (signature.length != Signature.BYTES)
throw new IllegalArgumentException("Invalid signature: incorrect length");
- if (endpoint == null || endpoint.isEmpty())
- throw new IllegalArgumentException("Invalid endpoint: must not be null or empty");
+ if (endpoint.isEmpty())
+ throw new IllegalArgumentException("Invalid endpoint: must not be empty");
endpoint = Normalizer.normalize(endpoint, Normalizer.Form.NFC);
@@ -212,7 +214,7 @@ private static PeerInfo create(Identity peer, byte[] privateKey, Identity node,
byte[] nodeSig;
if (node != null) {
nodeId = node.getId();
- byte[] digest = Hash.sha256(publicKey.bytes(), nodeId.bytes(), nonce);
+ byte[] digest = Hash.sha256(publicKey.bytesUnsafe(), nodeId.bytesUnsafe(), nonce);
nodeSig = node.sign(digest);
} else {
nodeId = null;
@@ -250,7 +252,7 @@ public boolean hasPrivateKey() {
* @return The private key.
*/
public byte[] getPrivateKey() {
- return privateKey;
+ return privateKey == null ? null : privateKey.clone();
}
/**
@@ -259,7 +261,7 @@ public byte[] getPrivateKey() {
* @return the nonce
*/
public byte[] getNonce() {
- return nonce;
+ return nonce == null ? null : nonce.clone();
}
/**
@@ -286,7 +288,7 @@ public Id getNodeId() {
* @return the node signature
*/
public byte[] getNodeSignature() {
- return nodeSig;
+ return nodeSig == null ? null : nodeSig.clone();
}
/**
@@ -311,7 +313,7 @@ public boolean isAuthenticated() {
* @return The signature.
*/
public byte[] getSignature() {
- return signature;
+ return signature == null ? null : signature.clone();
}
/**
@@ -347,7 +349,7 @@ public boolean hasExtra() {
* @return the extra data
*/
public byte[] getExtraData() {
- return extraData;
+ return extraData == null ? null : extraData.clone();
}
/**
@@ -379,14 +381,14 @@ public Map For mutable values, this verifies the signature against the computed digest.
+ *
+ * For mutable values, this verifies the signature against the computed digest.
* For immutable values, this verifies that the id matches the hash of the data.
*
* @return {@code true} if the value is valid, {@code false} otherwise.
@@ -621,7 +629,7 @@ private enum Type { IMMUTABLE, SIGNED, ENCRYPTED }
private Identity identity = null;
private boolean keepPrivateKey;
- Id recipient = null;
+ private Id recipient = null;
private int sequenceNumber = 0;
private byte[] data = null;
diff --git a/api/src/main/java/io/bosonnetwork/Version.java b/api/src/main/java/io/bosonnetwork/Version.java
index 8e5b5af1..f200967a 100644
--- a/api/src/main/java/io/bosonnetwork/Version.java
+++ b/api/src/main/java/io/bosonnetwork/Version.java
@@ -23,7 +23,9 @@
package io.bosonnetwork;
+import java.nio.charset.StandardCharsets;
import java.util.Map;
+import java.util.Objects;
/**
* A representation of a version information for a Boson node. A version information
@@ -37,6 +39,9 @@ public final class Version {
"MK", "Meerkat" // Native regular node
);
+ private Version() {
+ }
+
/**
* Build a version from the software name and version number.
*
@@ -45,12 +50,14 @@ public final class Version {
* @return an integer that represent the version information
*/
public static int build(String name, int version) {
- byte[] nameBytes = name.getBytes();
+ Objects.requireNonNull(name, "name");
+ byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
+ if (nameBytes.length < 2)
+ throw new IllegalArgumentException("Invalid name: must be at least 2 characters");
return Byte.toUnsignedInt(nameBytes[0]) << 24 |
Byte.toUnsignedInt(nameBytes[1]) << 16 |
- (version & 0x0000FF00) |
- (version & 0x000000FF);
+ (version & 0x0000FFFF);
}
/**
@@ -64,9 +71,8 @@ public static String toString(int version) {
return VERSION_NOT_AVAILABLE;
String n = new String(new byte[] { (byte)(version >>> 24),
- (byte)((version & 0x00ff0000) >>> 16) });
- String v = Integer.toString((version & 0x0000ff00) |
- (version & 0x000000ff));
+ (byte)((version & 0x00ff0000) >>> 16) }, StandardCharsets.US_ASCII);
+ String v = Integer.toString(version & 0x0000ffff);
return names.getOrDefault(n, n) + "/" + v;
}
diff --git a/api/src/main/java/io/bosonnetwork/crypto/CachedCryptoIdentity.java b/api/src/main/java/io/bosonnetwork/crypto/CachedCryptoIdentity.java
index 209b9c40..c5817f82 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/CachedCryptoIdentity.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/CachedCryptoIdentity.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 java.util.Objects;
@@ -90,27 +112,37 @@ public void clearCache() {
cryptoContexts.invalidateAll();
}
+ /**
+ * Destroys this identity, first closing and clearing any cached {@link CryptoContext}
+ * instances (each holds a native shared key) and then wiping the underlying key material.
+ */
+ @Override
+ public void destroy() {
+ clearCache();
+ super.destroy();
+ }
+
private CryptoContext getContext(Id id) throws CryptoException {
return cryptoContexts != null ? cryptoContexts.get(id) : super.createCryptoContext(id);
}
/**
- * Performs one-shot encryption of the given data for the specified receiver.
+ * Performs one-shot encryption of the given data for the specified recipient.
*
- * This operation leverages a cached {@link CryptoContext} instance associated with the receiver,
+ * This operation leverages a cached {@link CryptoContext} instance associated with the recipient,
* reducing the overhead of repeatedly computing cryptographic contexts.
*
- * @param receiver the receiver's {@link Id}; must not be {@code null}
+ * @param recipient the recipient's {@link Id}; must not be {@code null}
* @param data the plaintext data to encrypt; must not be {@code null}
* @return the encrypted data including the nonce prepended
- * @throws NullPointerException if {@code receiver} or {@code data} is {@code null}
+ * @throws NullPointerException if {@code recipient} or {@code data} is {@code null}
* @throws CryptoException if an error occurs during encryption
*/
@Override
- public byte[] encrypt(Id receiver, byte[] data) throws CryptoException {
- Objects.requireNonNull(receiver, "receiver");
+ public byte[] encrypt(Id recipient, byte[] data) throws CryptoException {
+ Objects.requireNonNull(recipient, "recipient");
Objects.requireNonNull(data, "data");
- return getContext(receiver).encrypt(data);
+ return getContext(recipient).encrypt(data);
}
/**
diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java
index 23ec2547..0fb9d689 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoBox.java
@@ -41,6 +41,7 @@ public class CryptoBox implements AutoCloseable, Destroyable {
public static final int MAC_BYTES = 16;
private final Box box;
+ private boolean destroyed = false;
/**
* The crypto box public key object.
@@ -86,7 +87,7 @@ Box.PublicKey raw() {
}
/**
- * Get the raw bytes of this key.
+ * Returns the raw bytes of this key.
*
* @return the raw bytes of this key.
*/
@@ -94,7 +95,7 @@ public byte[] bytes() {
if (bytes == null)
bytes = key.bytesArray();
- return bytes;
+ return bytes.clone();
}
@Override
@@ -186,15 +187,15 @@ Box.SecretKey raw() {
}
/**
- * Get the raw bytes of this key.
+ * Returns the raw bytes of this secret key.
*
- * @return the raw bytes of this key.
+ * @return the raw bytes of this secret key.
*/
public byte[] bytes() {
if (bytes == null)
bytes = key.bytesArray();
- return bytes;
+ return bytes.clone();
}
@Override
@@ -245,7 +246,7 @@ public boolean isDestroyed() {
/**
* The crypto box key pair.
*/
- public static class KeyPair {
+ public static class KeyPair implements Destroyable {
/**
* The seed length in bytes.
*/
@@ -254,6 +255,7 @@ public static class KeyPair {
private final Box.KeyPair keyPair;
private PublicKey pk;
private PrivateKey sk;
+ private boolean destroyed = false;
private KeyPair(Box.KeyPair keyPair) {
this.keyPair = keyPair;
@@ -362,6 +364,28 @@ public boolean equals(Object obj) {
public int hashCode() {
return 0x6030A + keyPair.hashCode();
}
+
+ /**
+ * Destroys this key pair, wiping the underlying public and private key material.
+ */
+ @Override
+ public void destroy() {
+ if (!destroyed) {
+ publicKey().destroy();
+ privateKey().destroy();
+ destroyed = true;
+ }
+ }
+
+ /**
+ * Determine if this key pair has been destroyed.
+ *
+ * @return true if this key pair has been destroyed, false otherwise.
+ */
+ @Override
+ public boolean isDestroyed() {
+ return destroyed;
+ }
}
/**
@@ -534,7 +558,7 @@ public static byte[] encryptSealed(byte[] message, PublicKey receiver) {
public byte[] decrypt(byte[] cipher, Nonce nonce) throws CryptoException {
byte[] plain = box.decrypt(cipher, nonce.raw());
if (plain == null)
- throw new CryptoException("crypto_box_open_easy_afternm: failed");
+ throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure");
return plain;
}
@@ -552,7 +576,7 @@ public byte[] decrypt(byte[] cipher, Nonce nonce) throws CryptoException {
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("crypto_box_open_easy: failed");
+ throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure");
return plain;
}
@@ -569,7 +593,7 @@ public static byte[] decrypt(byte[] cipher, PublicKey sender, PrivateKey receive
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("crypto_box_seal_open: failed");
+ throw new CryptoException("Sealed-box decryption failed: invalid ciphertext or authentication failure");
return plain;
}
@@ -581,13 +605,15 @@ public void close() {
@Override
public void destroy() {
- box.close();
+ if (!destroyed) {
+ box.close();
+ destroyed = true;
+ }
}
@Override
public boolean isDestroyed() {
- // always return false as the inner box not exposed the isDestroyed() method
- return false;
+ return destroyed;
}
static {
diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoException.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoException.java
index dbb2241c..d4d42a1a 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/CryptoException.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoException.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 io.bosonnetwork.BosonException;
diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java
index d93d716f..2582c09a 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoIdentity.java
@@ -1,7 +1,30 @@
+/*
+ * 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.Arrays;
import java.util.Objects;
+import javax.security.auth.Destroyable;
import org.apache.tuweni.crypto.sodium.SodiumException;
@@ -14,10 +37,11 @@
* Provides functionality for signing, verifying, encrypting, and decrypting data
* using signature and encryption key pairs.
*/
-public class CryptoIdentity implements Identity {
+public class CryptoIdentity implements Identity, Destroyable {
private final Id id;
private final Signature.KeyPair keyPair;
private final CryptoBox.KeyPair encryptionKeyPair;
+ private boolean destroyed = false;
/**
* Constructs a new {@code CryptoIdentity} with a randomly generated signature key pair.
@@ -77,14 +101,14 @@ public boolean verify(byte[] data, byte[] signature) {
* {@inheritDoc}
*/
@Override
- public byte[] encrypt(Id receiver, byte[] data) throws CryptoException {
- Objects.requireNonNull(receiver, "receiver");
+ 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 = receiver.toEncryptionKey();
+ CryptoBox.PublicKey pk = recipient.toEncryptionKey();
CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey();
byte[] cipher = CryptoBox.encrypt(data, pk, sk, nonce);
@@ -101,14 +125,14 @@ public byte[] encrypt(Id receiver, byte[] data) throws CryptoException {
* {@inheritDoc}
*/
@Override
- public byte[] encrypt(Id receiver, byte[] nonce, byte[] data) throws CryptoException {
- Objects.requireNonNull(receiver, "receiver");
+ public byte[] encrypt(Id recipient, byte[] nonce, byte[] data) throws CryptoException {
+ Objects.requireNonNull(recipient, "recipient");
Objects.requireNonNull(nonce, "nonce");
Objects.requireNonNull(data, "data");
try {
CryptoBox.Nonce n = CryptoBox.Nonce.fromBytes(nonce);
- CryptoBox.PublicKey pk = receiver.toEncryptionKey();
+ CryptoBox.PublicKey pk = recipient.toEncryptionKey();
CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey();
return CryptoBox.encrypt(data, pk, sk, n);
} catch (SodiumException e) {
@@ -132,10 +156,6 @@ public byte[] decrypt(Id sender, byte[] data) throws CryptoException {
byte[] n = Arrays.copyOfRange(data, 0, CryptoBox.Nonce.BYTES);
CryptoBox.Nonce nonce = CryptoBox.Nonce.fromBytes(n);
- //if (lastPeerNonce != null && nonce.equals(lastPeerNonce))
- // throw new CryptoException("Duplicated nonce");
-
- // lastPeerNonce = nonce;
CryptoBox.PublicKey pk = sender.toEncryptionKey();
CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey();
byte[] cipher = Arrays.copyOfRange(data, CryptoBox.Nonce.BYTES, data.length);
@@ -160,10 +180,6 @@ public byte[] decrypt(Id sender, byte[] nonce, byte[] data) throws CryptoExcepti
try {
CryptoBox.Nonce n = CryptoBox.Nonce.fromBytes(nonce);
- //if (lastPeerNonce != null && nonce.equals(lastPeerNonce))
- // throw new CryptoException("Duplicated nonce");
-
- // lastPeerNonce = nonce;
CryptoBox.PublicKey pk = sender.toEncryptionKey();
CryptoBox.PrivateKey sk = encryptionKeyPair.privateKey();
return CryptoBox.decrypt(data, pk, sk, n);
@@ -221,4 +237,27 @@ public boolean equals(Object o) {
return false;
}
+
+ /**
+ * Destroys this identity, wiping the underlying signature and encryption key material.
+ * After this call the identity must not be used for further cryptographic operations.
+ */
+ @Override
+ public void destroy() {
+ if (!destroyed) {
+ keyPair.destroy();
+ encryptionKeyPair.destroy();
+ destroyed = true;
+ }
+ }
+
+ /**
+ * Determine if this identity has been destroyed.
+ *
+ * @return true if this identity has been destroyed, false otherwise.
+ */
+ @Override
+ public boolean isDestroyed() {
+ return destroyed;
+ }
}
\ 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
index 7eb77a10..98460255 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java
@@ -43,15 +43,16 @@
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.Random;
import java.util.TimeZone;
import io.vertx.core.buffer.Buffer;
import io.vertx.core.net.PfxOptions;
+import io.bosonnetwork.BosonException;
import io.bosonnetwork.utils.Base58;
/**
@@ -164,7 +165,7 @@ private static byte[] encodeTBS(BigInteger serial, String cn, Date notBefore, Da
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().addUtcTime(notBefore).addUtcTime(notAfter)); // Validity
+ 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
@@ -262,10 +263,18 @@ public DerBuilder addPrintableString(String s) throws IOException {
return addTag((byte) 0x13, s.getBytes(StandardCharsets.US_ASCII));
}
- public DerBuilder addUtcTime(Date d) throws IOException {
- SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss'Z'");
- sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
- return addTag((byte) 0x17, sdf.format(d).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 {
@@ -327,7 +336,7 @@ public static String randomPassword(int length) {
String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+=<>?/|";
StringBuilder sb = new StringBuilder(length);
- java.util.Random random = new Random();
+ SecureRandom random = new SecureRandom();
for (int i = 0; i < length; i++) {
int index = random.nextInt(characters.length());
sb.append(characters.charAt(index));
@@ -405,7 +414,7 @@ public static PfxOptions pfxOptionsFromCertAndPrivateKey(PemCertificateAndKey ce
/**
* Exception thrown when an error occurs during key conversion or certificate generation.
*/
- public static class KeyConvertException extends Exception {
+ public static class KeyConvertException extends BosonException {
private static final long serialVersionUID = -5975318365528633648L;
/**
diff --git a/api/src/main/java/io/bosonnetwork/crypto/Hash.java b/api/src/main/java/io/bosonnetwork/crypto/Hash.java
index e713490a..b5b4652a 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/Hash.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/Hash.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 java.security.MessageDigest;
@@ -6,8 +28,15 @@
/**
* Utility class providing methods for generating SHA-256 and MD5 hashes.
* Supports hashing single or multiple byte arrays, as well as performing double SHA-256 hashing.
+ *
+ * Security note: MD5 is cryptographically broken (not collision-resistant) and
+ * is provided only for non-cryptographic uses such as legacy checksums and interop. Never use the
+ * {@code md5*} methods for security-sensitive purposes; use the SHA-256 methods instead.
*/
public class Hash {
+ private Hash() {
+ }
+
/**
* Create a new SHA256 message digest object.
*
@@ -116,8 +145,11 @@ public static byte[] sha256Twice(byte[]... inputs) {
/**
* Create a new MD5 message digest object.
+ *
+ * Security note: MD5 is not collision-resistant; use only for
+ * non-cryptographic purposes. Prefer {@link #sha256()} for anything security-related.
*
- * @return the current thread's MD5 {@code MessageDigest}
+ * @return a new MD5 {@code MessageDigest} instance
*/
public static MessageDigest md5() {
try {
diff --git a/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java b/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java
index ac173cfc..dc751cad 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java
@@ -57,7 +57,7 @@ public class HybridTrustManager implements X509TrustManager {
public HybridTrustManager(String expectedCn, byte[] expectedPublicKey) {
this.defaultTrustManager = getDefaultTrustManager();
this.expectedCn = expectedCn;
- this.expectedPublicKey = expectedPublicKey;
+ this.expectedPublicKey = expectedPublicKey == null ? null : expectedPublicKey.clone();
}
private static X509TrustManager getDefaultTrustManager() {
@@ -90,6 +90,35 @@ private static X509TrustManager getDefaultTrustManager() {
*/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
+ checkTrusted(chain, authType, false);
+ }
+
+ /**
+ * Checks whether the provided client certificate chain can be trusted.
+ *
+ * If the certificate is self-signed, it is validated against the expected CN and public key
+ * (same pinning as {@link #checkServerTrusted}). Otherwise, the validation is delegated to the
+ * system default trust manager. This implementation delegates the check to the system default trust manager.
+ * The string is hashed with SHA-256 and the 32-byte digest is folded down to the 8 bytes
+ * required by {@link KeyDerivation#contextLength()}.
+ *
+ * Note: the 8-byte context is a lossy reduction (libsodium's 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.
+ *
+ * @param context the context string; must not be null or empty
+ * @return the 8-byte derivation context
+ */
+ private static byte[] deriveContextBytes(String context) {
+ Objects.requireNonNull(context, "context");
+ if (context.isEmpty())
+ throw new IllegalArgumentException("context must not be empty");
+
+ final int len = KeyDerivation.contextLength(); // 8 bytes
+ byte[] contextBytes = new byte[len];
+ try {
+ MessageDigest sha = MessageDigest.getInstance("SHA-256");
+ byte[] hashBytes = sha.digest(context.getBytes(StandardCharsets.UTF_8));
+ for (int i = 0; i < hashBytes.length; i++)
+ contextBytes[i % len] += hashBytes[i];
+ } catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException(e);
+ }
+ return contextBytes;
+ }
+
/**
* Signs a message with a given key.
*
diff --git a/api/src/main/java/io/bosonnetwork/crypto/package-info.java b/api/src/main/java/io/bosonnetwork/crypto/package-info.java
index 06b96182..d16d6209 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/package-info.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/package-info.java
@@ -22,7 +22,24 @@
*/
/**
- * The Ed25591 and Curve25519 crypto wrappers based on libsodium. See
- * https://www.libsodium.org/
+ * Cryptographic primitives for Boson — Ed25519 signatures and Curve25519 key exchange / encryption,
+ * wrapping libsodium.
+ *
+ * Boson uses libsodium-style 64-byte private keys (32-byte seed concatenated with the 32-byte
+ * public key). Holders of secret key material expose explicit {@code destroy()}/{@code close()}
+ * methods; failures are reported as {@link io.bosonnetwork.crypto.CryptoException}.
*/
package io.bosonnetwork.crypto;
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/cwt/Algorithm.java b/api/src/main/java/io/bosonnetwork/cwt/Algorithm.java
index b765eba3..c7ca1bc6 100644
--- a/api/src/main/java/io/bosonnetwork/cwt/Algorithm.java
+++ b/api/src/main/java/io/bosonnetwork/cwt/Algorithm.java
@@ -23,24 +23,28 @@
package io.bosonnetwork.cwt;
/**
- * Enumeration of cryptographic algorithms supported by the CWT implementation.
- * The integer values correspond to the algorithm identifiers defined in RFC 8152.
+ * Enumeration of COSE algorithm identifiers (RFC 8152) referenced by this package.
+ *
+ * Note: {@link SignedCwt} strictly enforces {@link #EDDSA} (Ed25519); it is the only
+ * algorithm accepted when signing or verifying. The {@code ES256}/{@code ES384}/{@code ES512}
+ * constants are defined for registry completeness and are not supported — a token using
+ * any of them is rejected during parsing.
*/
public enum Algorithm {
/**
- * ECDSA w/ SHA-256
+ * ECDSA w/ SHA-256. Defined for completeness; not supported by {@link SignedCwt}.
*/
ES256(-7),
/**
- * ECDSA w/ SHA-384
+ * ECDSA w/ SHA-384. Defined for completeness; not supported by {@link SignedCwt}.
*/
ES384(-35),
/**
- * ECDSA w/ SHA-512
+ * ECDSA w/ SHA-512. Defined for completeness; not supported by {@link SignedCwt}.
*/
ES512(-36),
/**
- * EdDSA / Ed25519
+ * EdDSA / Ed25519 — the only algorithm supported by {@link SignedCwt}.
*/
EDDSA(-8);
diff --git a/api/src/main/java/io/bosonnetwork/cwt/Claim.java b/api/src/main/java/io/bosonnetwork/cwt/Claim.java
index 421e0444..74044fd1 100644
--- a/api/src/main/java/io/bosonnetwork/cwt/Claim.java
+++ b/api/src/main/java/io/bosonnetwork/cwt/Claim.java
@@ -58,7 +58,7 @@ public enum Claim {
*/
ISSUED_AT(6),
/**
- * cti (CWT ID) Claim。
+ * cti (CWT ID) Claim
*/
CWT_ID(7),
@@ -103,9 +103,14 @@ public int getValue() {
/**
* Retrieves the Claim enumeration corresponding to the given integer value.
+ *
+ * Unlike {@link Header#valueOf(int)} and {@link Algorithm#valueOf(int)}, which throw on an
+ * unknown value, this method is deliberately lenient: any unrecognized key maps to
+ * {@link #APPLICATION_DEFINED}, since the CWT claim registry is open and tokens may legitimately
+ * carry application- or future-defined claims.
*
* @param value the integer value of the claim.
- * @return the corresponding Claim enumeration.
+ * @return the corresponding Claim enumeration, or {@link #APPLICATION_DEFINED} if unknown.
*/
public static Claim valueOf(int value) {
return switch (value) {
diff --git a/api/src/main/java/io/bosonnetwork/cwt/SignedCwt.java b/api/src/main/java/io/bosonnetwork/cwt/SignedCwt.java
index e4018f96..411d6d23 100644
--- a/api/src/main/java/io/bosonnetwork/cwt/SignedCwt.java
+++ b/api/src/main/java/io/bosonnetwork/cwt/SignedCwt.java
@@ -50,6 +50,13 @@
* cryptographic {@link Identity} and {@link Id} system, ensuring that the {@code "iss"} (Issuer)
* claim is intrinsically bound to the issuer's public key identifier.
*
+ * Security — the issuer is self-asserted: a token is verified against the Ed25519 public
+ * key carried in its own {@code "iss"} claim. A successful {@link #parse(byte[]) parse} therefore
+ * proves only that the token was signed by the holder of the key it names as issuer — it does
+ * not establish that this issuer is trusted. Callers MUST pin trust by configuring
+ * {@link Parser#requireIssuer(Id)} or by validating the {@code "iss"} claim against an allow-list
+ * after parsing. A valid signature alone is not authentication.
+ *
* References:
*
+ * The map itself is read-only, but {@code byte[]} claim values are returned by reference and
+ * must not be mutated by the caller.
*
- * @return a map of claims.
+ * @return an unmodifiable view of the claims.
*/
public Map
+ * Security: the signature is verified against the public key in the token's own
+ * {@code "iss"} claim (self-asserted issuer). Unless {@link #requireIssuer(Id)} was
+ * configured, a successful parse does not authenticate the issuer — validate {@code "iss"}
+ * against a trust anchor yourself. See the {@link SignedCwt class documentation}.
+ *
* @param coseBytes the raw CBOR bytes of the CWT.
* @return the parsed and verified {@code SignedCwt} object.
* @throws CwtException if parsing, structural validation, or cryptographic verification fails.
@@ -520,7 +529,11 @@ public SignedCwt parse(byte[] coseBytes) throws CwtException {
* but conveniently to check manually.
*/
Objects.requireNonNull(coseBytes);
+ if (coseBytes.length == 0)
+ throw new InvalidCborTagException("Empty token");
+ // Only the canonical single-byte encoding of CBOR tag 18 (0xD2) is accepted, matching
+ // what build() emits. Multi-byte tag encodings of the same value are not recognized.
int tag = Byte.toUnsignedInt(coseBytes[0]);
if (tag != COSE_SIGN_1_TAG)
throw new InvalidCborTagException("Unknown CBOR tag: " + tag);
@@ -549,6 +562,13 @@ public SignedCwt parse(byte[] coseBytes) throws CwtException {
if (alg != Algorithm.EDDSA.getValue())
throw new InvalidAlgorithmException("Unsupported algorithm: " + value);
+ // RFC 8152/9052 §3.1: a recipient that does not understand a header listed in "crit"
+ // MUST reject the token. This implementation understands no critical extensions, so the
+ // mere presence of a (non-empty) "crit" header is a rejection.
+ Object crit = protectedHeaders.get(Header.CRIT.getValue());
+ if (crit != null && !(crit instanceof List> l && l.isEmpty()))
+ throw new InvalidCoseStructureException("Unsupported critical headers (crit) present");
+
if (!(sign1.get(1) instanceof Map, ?> uph))
throw new InvalidCoseStructureException("Unprotected headers should be a map");
@@ -580,7 +600,7 @@ public SignedCwt parse(byte[] coseBytes) throws CwtException {
throw new InvalidIssuerKeyException("Invalid issuer key");
if (expectedIssuer != null) {
- if (!Arrays.equals(issuer, expectedIssuer.bytes()))
+ if (!Arrays.equals(issuer, expectedIssuer.bytesUnsafe()))
throw new InvalidClaimException("Issuer mismatch");
}
@@ -647,7 +667,16 @@ public SignedCwt parse(byte[] coseBytes) throws CwtException {
* @throws CwtException if there is an error during parsing
*/
public SignedCwt parse(String base64Token) throws CwtException {
- return parse(Json.BASE64_DECODER.decode(base64Token));
+ if (base64Token == null)
+ throw new InvalidCoseStructureException("Token cannot be null");
+
+ byte[] coseBytes;
+ try {
+ coseBytes = Json.BASE64_DECODER.decode(base64Token);
+ } catch (IllegalArgumentException e) {
+ throw new InvalidCoseStructureException("Invalid base64 token", e);
+ }
+ return parse(coseBytes);
}
}
@@ -656,7 +685,6 @@ public SignedCwt parse(String base64Token) throws CwtException {
*/
public static class Builder {
private final Identity issuer;
- private long leeway;
private final Map
+ * Security: a token is verified against the public key carried in its own
+ * {@code "iss"} claim (self-asserted issuer), so a successful parse proves only that the holder of
+ * that key signed it — callers must pin trust via
+ * {@link io.bosonnetwork.cwt.SignedCwt.Parser#requireIssuer(io.bosonnetwork.Id)} or validate the
+ * issuer against a trust anchor after parsing.
+ *
+ *
- * Only letters, digits, and underscores are allowed (safe for SQL identifiers).
- * Optionally supports qualified names with a single dot (e.g., "table.column").
- *
* Example:
* Pagination p = Pagination.page(3, 20); // pageIndex=3, pageSize=20
- * p.toSql(); // " OFFSET 40 LIMIT 20"
+ * p.toSql(); // " LIMIT 20 OFFSET 40"
*
+ * These identifiers are interpolated directly into SQL/templates (only bound values are
+ * parameterized), so they must be restricted to safe characters to prevent SQL injection. Used by
+ * the query builders ({@link Filter}, {@link Ordering}) and by schema/configuration handling.
+ */
+public final class SqlSafety {
+ // Letters, digits and underscore; optionally a single qualifying dot (e.g. "table.column").
+ private static final Pattern COLUMN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?$");
+ // Bind-parameter token: letters, digits and underscore.
+ private static final Pattern PARAM_NAME = Pattern.compile("^[A-Za-z_][A-Za-z0-9_]*$");
+ // Schema name: a lowercase letter followed by up to 31 lowercase letters, digits or underscores.
+ private static final Pattern SCHEMA_NAME = Pattern.compile("^[a-z][a-z0-9_]{0,31}$");
+
+ private SqlSafety() {
+ }
+
+ /**
+ * Validates a SQL column name (optionally qualified, e.g. {@code table.column}) and returns it,
+ * so callers can validate and assign in one expression.
+ *
+ * @param column the column name; must not be null
+ * @return the validated column name
+ * @throws IllegalArgumentException if the column name is null or not a safe identifier
+ */
+ public static String validateColumn(String column) {
+ if (column == null || !COLUMN.matcher(column).matches())
+ throw new IllegalArgumentException("Invalid SQL column name: " + column);
+ return column;
+ }
+
+ /**
+ * Validates a bind-parameter name (the token used inside a {@code #{...}} placeholder) and
+ * returns it, so callers can validate and assign in one expression.
+ *
+ * @param paramName the parameter name; must not be null
+ * @return the validated parameter name
+ * @throws IllegalArgumentException if the parameter name is null or not a safe identifier
+ */
+ public static String validateParamName(String paramName) {
+ if (paramName == null || !PARAM_NAME.matcher(paramName).matches())
+ throw new IllegalArgumentException("Invalid SQL parameter name: " + paramName);
+ return paramName;
+ }
+
+ /**
+ * Validates and normalizes an optional SQL schema name. A {@code null} or empty name is treated
+ * as "no schema" and mapped to {@code null}; any other value must be a valid schema identifier
+ * and is returned unchanged.
+ *
+ * @param schema the schema name, may be {@code null} or empty
+ * @return the validated schema name, or {@code null} if the input was null or empty
+ * @throws IllegalArgumentException if a non-empty schema name is not a safe identifier
+ */
+ public static String validateSchema(String schema) {
+ if (schema == null || schema.isEmpty())
+ return null;
+
+ if (!SCHEMA_NAME.matcher(schema).matches())
+ throw new IllegalArgumentException("Invalid schema name: " + schema);
+
+ return schema;
+ }
+}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/database/VersionedSchema.java b/api/src/main/java/io/bosonnetwork/database/VersionedSchema.java
index 0f6279b3..f23c8752 100644
--- a/api/src/main/java/io/bosonnetwork/database/VersionedSchema.java
+++ b/api/src/main/java/io/bosonnetwork/database/VersionedSchema.java
@@ -22,6 +22,8 @@
package io.bosonnetwork.database;
+import static io.bosonnetwork.database.SqlSafety.validateSchema;
+
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
@@ -31,12 +33,12 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.vertx.core.Future;
-import io.vertx.core.Promise;
import io.vertx.core.Vertx;
import io.vertx.sqlclient.Row;
import io.vertx.sqlclient.RowSet;
@@ -133,12 +135,6 @@ public Path path() {
@Override
public int compareTo(Migration that) {
- if (this.version == that.version) {
- log.error("Migration check: Migration file version must be unique. File names: {} and {}",
- this.fileName(), that.fileName());
- throw new IllegalStateException("Migration file version must be unique");
- }
-
return Integer.compare(this.version, that.version);
}
}
@@ -176,10 +172,7 @@ public static VersionedSchema init(Vertx vertx, SqlClient client, Path migration
* @return a new {@link VersionedSchema} instance configured for the provided parameters
*/
public static VersionedSchema init(Vertx vertx, SqlClient client, String schema, Path migrationPath) {
- if (schema != null && !schema.matches("[a-z][a-z0-9_]{0,31}"))
- throw new IllegalArgumentException("Invalid schema name");
-
- return new VersionedSchema(vertx, client, schema, migrationPath);
+ return new VersionedSchema(vertx, client, validateSchema(schema), migrationPath);
}
/**
@@ -193,9 +186,13 @@ public SqlClient getClient() {
}
/**
- * Returns the last successfully applied schema version, if any.
+ * Returns the last successfully applied schema version.
+ *
+ * Before any migration has been applied (or recorded), this returns the empty sentinel version
+ * (version {@code 0}, with a {@code null} {@link SchemaVersion#hash()}) rather than {@code null}.
+ * Use {@code getCurrentVersion().version() == 0} to detect the "no migrations applied" state.
*
- * @return the current version or {@code null} if none recorded
+ * @return the current schema version; never {@code null}
*/
public SchemaVersion getCurrentVersion() {
return currentVersion;
@@ -217,6 +214,11 @@ public SchemaVersion getCurrentVersion() {
* If an already-applied migration differs in version or checksum,
* the migration process fails immediately. Concurrency: this is not designed to run concurrently against the same database from
+ * multiple instances. The {@code version} primary key prevents history corruption, but a losing
+ * concurrent runner may fail with a duplicate-key error. Run migrations from a single instance,
+ * or guard the call with an external lock.
+ *
+ *
+ *
*
*/
-public class Json {
+public final class Json {
private static final String BOSON_JSON_MODULE_NAME = "io.bosonnetwork.utils.json.module";
/** Pre-configured Base64 encoder for URL-safe encoding without padding. */
@@ -95,43 +95,147 @@ public class Json {
/** Pre-configured Base64 decoder for URL-safe decoding. */
public static final Base64.Decoder BASE64_DECODER = Base64.getUrlDecoder();
- private static TypeReference{@link io.bosonnetwork.cwt.SignedCwt}
+ * The entry point: a CWT structured as a COSE_Sign1 single-signer object. It is specialized for
+ * Boson — it strictly enforces the EdDSA (Ed25519) signature algorithm and binds the {@code "iss"}
+ * (issuer) claim to a Boson {@link io.bosonnetwork.Id} / {@link io.bosonnetwork.Identity}. Build
+ * tokens with {@link io.bosonnetwork.cwt.SignedCwt#builder(io.bosonnetwork.Identity)} and verify
+ * them with {@link io.bosonnetwork.cwt.SignedCwt#parser()}.
+ * Codec vocabulary
+ * {@link io.bosonnetwork.cwt.Claim} (CWT claim keys), {@link io.bosonnetwork.cwt.Header} (COSE
+ * header parameters) and {@link io.bosonnetwork.cwt.Algorithm} (COSE algorithm identifiers)
+ * enumerate the integer keys used on the wire.
+ *
+ * Errors
+ * All parsing/validation failures are reported as {@link io.bosonnetwork.cwt.CwtException} or one
+ * of its typed subclasses (invalid CBOR tag, COSE structure, algorithm, signature, issuer key,
+ * claim, expiration, not-before, issued-at).
+ *
+ * References
+ *
+ */
+package io.bosonnetwork.cwt;
diff --git a/api/src/main/java/io/bosonnetwork/database/CollectionParameter.java b/api/src/main/java/io/bosonnetwork/database/CollectionParameter.java
index edb60bd0..8dcb75de 100644
--- a/api/src/main/java/io/bosonnetwork/database/CollectionParameter.java
+++ b/api/src/main/java/io/bosonnetwork/database/CollectionParameter.java
@@ -46,14 +46,16 @@ public class CollectionParameterLIKE condition
*/
public static Filter like(String column, Object value) {
- return like(column, column, value);
+ return like(column, defaultParamName(column, "like"), value);
}
/**
@@ -242,8 +238,7 @@ public static Filter like(String column, Object value) {
*/
public static Filter isNull(String column) {
Objects.requireNonNull(column);
- validateColumn(column);
- return new Unary(column, "IS NULL");
+ return new Unary(validateColumn(column), "IS NULL");
}
/**
@@ -254,8 +249,7 @@ public static Filter isNull(String column) {
*/
public static Filter isNotNull(String column) {
Objects.requireNonNull(column);
- validateColumn(column);
- return new Unary(column, "IS NOT NULL");
+ return new Unary(validateColumn(column), "IS NOT NULL");
}
/**
@@ -267,10 +261,12 @@ public static Filter isNotNull(String column) {
*/
public static Filter in(String column, Map
* Ordering order = Ordering.by("name").asc()
- * .then("created").desc();
+ * .then("created").desc()
+ * .build();
* String sql = order.toSql(); // " ORDER BY name ASC, created DESC"
*
*/
@@ -157,8 +160,7 @@ public static final class Builder {
private Builder(String column, Direction direction) {
Objects.requireNonNull(column);
- validateColumn(column);
- list.add(new Field(column, direction)); // default
+ list.add(new Field(validateColumn(column), direction)); // default
}
/**
@@ -190,8 +192,7 @@ public Builder desc() {
*/
public Builder then(String column, Direction direction) {
Objects.requireNonNull(column);
- validateColumn(column);
- list.add(new Field(column, direction));
+ list.add(new Field(validateColumn(column), direction));
return this;
}
@@ -220,20 +221,4 @@ public Ordering build() {
return new Ordering(list);
}
}
-
- /**
- * Validates that the column name contains only safe characters.
- * Safe query building
+ * Only bound values are parameterized; SQL identifiers (column / parameter / schema names)
+ * are interpolated, so they are validated through {@link io.bosonnetwork.database.SqlSafety} to
+ * prevent injection. The builders compose Vert.x {@code SqlTemplate} fragments:
+ *
+ *
+ *
+ * Execution
+ * {@link io.bosonnetwork.database.VertxDatabase} wraps a {@code SqlClient} (pool or single
+ * connection) with {@code withConnection}/{@code withTransaction} helpers and small row-mapping
+ * utilities. Per the project convention, use {@code withTransaction} for writes and
+ * {@code withConnection} for reads.
+ *
+ * Migrations
+ * {@link io.bosonnetwork.database.VersionedSchema} applies versioned
+ * {@code