diff --git a/api/pom.xml b/api/pom.xml index 24fd4ae4..712a56da 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -99,7 +99,7 @@ org.bouncycastle bcpkix-jdk18on - true + test diff --git a/api/src/main/java/io/bosonnetwork/CryptoContext.java b/api/src/main/java/io/bosonnetwork/CryptoContext.java index 73df192d..89385fcb 100644 --- a/api/src/main/java/io/bosonnetwork/CryptoContext.java +++ b/api/src/main/java/io/bosonnetwork/CryptoContext.java @@ -34,8 +34,9 @@ /** *

* 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 { /** * 3-way comparator. For sorting {@code Id} instances based on their * distance to a target identifier using the XOR metric. + * + * @deprecated use {@link Id#threeWayCompare(Id, Id)} directly via a comparator instead, + * e.g. {@code (a, b) -> target.threeWayCompare(a, b)}. */ @Deprecated public static class ThreeWayComparator implements java.util.Comparator { @@ -372,7 +375,7 @@ public byte[] getBytes() { * * @return the internal byte array (must not be modified). */ - public final byte[] bytes() { + public final byte[] bytesUnsafe() { // Performance critical method: returns internal array directly return bytes; } diff --git a/api/src/main/java/io/bosonnetwork/Identity.java b/api/src/main/java/io/bosonnetwork/Identity.java index fc3d9d27..2100e55d 100644 --- a/api/src/main/java/io/bosonnetwork/Identity.java +++ b/api/src/main/java/io/bosonnetwork/Identity.java @@ -63,29 +63,30 @@ public interface Identity { boolean verify(byte[] data, byte[] signature); /** - * Encrypts the provided data for the specified receiver using a one-shot encryption + * Encrypts the provided data for the specified recipient using a one-shot encryption * operation. Random nonce is generated and prefixed to the encrypted data to ensure * uniqueness and prevent replay attacks. * - * @param receiver the {@link Id} of the intended receiver for whom the data is encrypted + * @param recipient the {@link Id} of the intended recipient for whom the data is encrypted * @param data the plaintext data to encrypt * @return the encrypted data as a byte array, prefixed with a random nonce * @throws CryptoException if the encryption process fails due to cryptographic errors */ - byte[] encrypt(Id receiver, byte[] data) throws CryptoException; + byte[] encrypt(Id recipient, byte[] data) throws CryptoException; /** - * Encrypts the provided data for the specified receiver using a one-shot encryption - * operation. The encryption process may also incorporate the supplied nonce to ensure - * data uniqueness and prevent replay attacks. + * Encrypts the provided data for the specified recipient using a one-shot encryption + * operation with the caller-supplied nonce. Unlike {@link #encrypt(Id, byte[])}, the nonce is + * provided by the caller and is not prepended to the returned ciphertext; + * the caller is responsible for ensuring the nonce is unique per key and message. * - * @param receiver the {@link Id} of the intended receiver for whom the data is encrypted - * @param nonce the byte array used as nonce for the encryption process, ensuring uniqueness + * @param recipient the {@link Id} of the intended recipient for whom the data is encrypted + * @param nonce the byte array used as nonce for the encryption process; must be unique per key/message * @param data the plaintext data to encrypt * @return the encrypted data as a byte array * @throws CryptoException if the encryption fails due to cryptographic errors or invalid parameters */ - byte[] encrypt(Id receiver, byte[] nonce, byte[] data) throws CryptoException; + byte[] encrypt(Id recipient, byte[] nonce, byte[] data) throws CryptoException; /** * Decrypts the provided encrypted data sent by the specified sender using a one-shot decryption operation. diff --git a/api/src/main/java/io/bosonnetwork/Network.java b/api/src/main/java/io/bosonnetwork/Network.java index 09f24a89..058738a0 100644 --- a/api/src/main/java/io/bosonnetwork/Network.java +++ b/api/src/main/java/io/bosonnetwork/Network.java @@ -69,7 +69,7 @@ public enum Network { * Checks if the specified socket address can apply for this network. * * @param addr the socket address to check. - * @return true is the address can apply for this network, otherwise false. + * @return true if the address can apply for this network, otherwise false. */ public boolean canUseSocketAddress(InetSocketAddress addr) { return canUseAddress(addr.getAddress()); @@ -79,7 +79,7 @@ public boolean canUseSocketAddress(InetSocketAddress addr) { * Checks if the specified IP address can apply for this network. * * @param addr the IP address to check. - * @return true is the address can apply for this network, otherwise false. + * @return true if the address can apply for this network, otherwise false. */ public boolean canUseAddress(InetAddress addr) { return preferredAddressType.isInstance(addr); diff --git a/api/src/main/java/io/bosonnetwork/Node.java b/api/src/main/java/io/bosonnetwork/Node.java index 30b32223..ef168f76 100644 --- a/api/src/main/java/io/bosonnetwork/Node.java +++ b/api/src/main/java/io/bosonnetwork/Node.java @@ -25,6 +25,7 @@ import java.util.Collection; import java.util.List; +import java.util.ServiceLoader; import java.util.concurrent.CompletableFuture; import io.bosonnetwork.crypto.CryptoException; @@ -40,6 +41,12 @@ *
  • Storing values and announcing peers (optionally persistent)
  • *
  • Cryptographic operations: sign, verify, encrypt, decrypt
  • * + *

    + * 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 storeValue(Value value, boolean persistent) { CompletableFuture storeValue(Value value, int expectedSequenceNumber, boolean persistent); /** - * Finds peers in the network by ID using the default lookup option. + * Finds a peer in the network by ID using the default lookup option. * * @param id the {@link Id} to find peers for - * @return a {@link CompletableFuture} containing the list of {@link PeerInfo} + * @return a {@link CompletableFuture} containing the {@link PeerInfo}, or {@code null} if not found */ default CompletableFuture findPeer(Id id) { return findPeer(id, -1, 1, null) @@ -450,21 +457,19 @@ default CompletableFuture announcePeer(PeerInfo peer, boolean persistent) CryptoContext createCryptoContext(Id id) throws CryptoException; /** - * Creates and initializes a new KadNode instance using the provided configuration. + * Creates and initializes a new {@link Node} instance using the provided configuration. + *

    + * 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 getExtra() { private static byte[] computeDigest(Id publicKey, byte[] nonce, int sequenceNumber, Id nodeId, byte[] nodeSig, long fingerprint, String endpoint, byte[] extraData) { MessageDigest sha = Hash.sha256(); - sha.update(publicKey.bytes()); + sha.update(publicKey.bytesUnsafe()); sha.update(nonce); - sha.update(ByteBuffer.allocate(Integer.BYTES).putInt(sequenceNumber).array()); + sha.update(Bytes.fromInteger(sequenceNumber)); if (nodeId != null) { - sha.update(nodeId.bytes()); + sha.update(nodeId.bytesUnsafe()); sha.update(nodeSig); } - sha.update(ByteBuffer.allocate(Long.BYTES).putLong(fingerprint).array()); + sha.update(Bytes.fromLong(fingerprint)); sha.update(endpoint.getBytes(UTF_8)); if (extraData != null) sha.update(extraData); @@ -424,7 +426,7 @@ public boolean isValid() { return false; Signature.PublicKey nodePk = nodeId.toSignatureKey(); - byte[] digest = Hash.sha256(publicKey.bytes(), nodeId.bytes(), nonce); + byte[] digest = Hash.sha256(publicKey.bytesUnsafe(), nodeId.bytesUnsafe(), nonce); if (!Signature.verify(digest, nodeSig, nodePk)) return false; } else { diff --git a/api/src/main/java/io/bosonnetwork/Result.java b/api/src/main/java/io/bosonnetwork/Result.java index b4650cfd..be427184 100644 --- a/api/src/main/java/io/bosonnetwork/Result.java +++ b/api/src/main/java/io/bosonnetwork/Result.java @@ -131,11 +131,8 @@ public T getValue(Network network) { */ protected void setValue(Network network, T value) { switch (network) { - case IPv4: - v4 = value; - - case IPv6: - v6 = value; + case IPv4 -> v4 = value; + case IPv6 -> v6 = value; } } diff --git a/api/src/main/java/io/bosonnetwork/UserProfile.java b/api/src/main/java/io/bosonnetwork/UserProfile.java index ffe75106..f6874651 100644 --- a/api/src/main/java/io/bosonnetwork/UserProfile.java +++ b/api/src/main/java/io/bosonnetwork/UserProfile.java @@ -96,9 +96,9 @@ public static UserProfile fromCard(Card card) { Credential profile = card.getCredential(DEFAULT_PROFILE_CREDENTIAL_ID); if (profile != null && profile.getTypes().contains(DEFAULT_PROFILE_CREDENTIAL_TYPE)) { Map claims = profile.getSubject().getClaims(); - name = String.valueOf(claims.get(NAME)); - avatar = String.valueOf(claims.get(AVATAR)); - bio = String.valueOf(claims.get(BIO)); + name = (String) claims.get(NAME); + avatar = (String) claims.get(AVATAR); + bio = (String) claims.get(BIO); Object value = claims.get(HOME_NODE); if (value != null) { @@ -217,7 +217,7 @@ private Builder (Identity identity) { */ public Builder name(String name) { this.name = name; - return this; + return this; } /** @@ -239,7 +239,7 @@ public Builder avatar(String avatar) { */ public Builder bio(String bio) { this.bio = bio; - return this; + return this; } /** @@ -261,7 +261,7 @@ public Builder homeNode(Id homeNode) { */ public Builder messagingHomePeer(Id messagingHomePeer) { this.messagingHomePeer = messagingHomePeer; - return this; + return this; } /** diff --git a/api/src/main/java/io/bosonnetwork/Value.java b/api/src/main/java/io/bosonnetwork/Value.java index e565a008..de1ff336 100644 --- a/api/src/main/java/io/bosonnetwork/Value.java +++ b/api/src/main/java/io/bosonnetwork/Value.java @@ -23,7 +23,6 @@ package io.bosonnetwork; -import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Arrays; @@ -34,6 +33,7 @@ import io.bosonnetwork.crypto.Hash; import io.bosonnetwork.crypto.Random; import io.bosonnetwork.crypto.Signature; +import io.bosonnetwork.utils.Bytes; import io.bosonnetwork.utils.Hex; /** @@ -53,7 +53,7 @@ */ public class Value { /** The number of bytes in the nonce. */ - public static int NONCE_BYTES = 24; + public static final int NONCE_BYTES = 24; /** The public key for mutable values. */ private final Id publicKey; @@ -76,19 +76,19 @@ public class Value { * For immutable values, the ID is the SHA-256 hash of the data. * For mutable values, the ID is the public key and identifies the logical record. * Multiple versions (sequence numbers) share the same ID. - * */ + */ private final transient Id id; private Value(Id publicKey, byte[] privateKey, Id recipient, byte[] nonce, int sequenceNumber, byte[] signature, byte[] data) { this.publicKey = publicKey; - this.privateKey = privateKey; + this.privateKey = privateKey != null ? privateKey.clone() : null; this.recipient = recipient; - this.nonce = nonce; + this.nonce = nonce != null ? nonce.clone() : null; this.sequenceNumber = sequenceNumber; - this.signature = signature; - this.data = data; + this.signature = signature != null ? signature.clone() : null; + this.data = data != null ? data.clone() : null; - this.id = calculateId(publicKey, data); + this.id = calculateId(publicKey, this.data); } private Value(Id id, byte[] data) { @@ -99,7 +99,7 @@ private Value(Id id, byte[] data) { this.nonce = null; this.sequenceNumber = 0; this.signature = null; - this.data = data; + this.data = data != null ? data.clone() : null; } /** @@ -122,18 +122,21 @@ public static Value of(Id publicKey, byte[] privateKey, Id recipient, byte[] non if (privateKey != null && privateKey.length != Signature.PrivateKey.BYTES) throw new IllegalArgumentException("Invalid private key: incorrect length"); - if (nonce == null || nonce.length != NONCE_BYTES) + Objects.requireNonNull(nonce, "nonce"); + if (nonce.length != NONCE_BYTES) throw new IllegalArgumentException("Invalid nonce: must be exactly NONCE_BYTES (24 bytes)"); if (sequenceNumber < 0) throw new IllegalArgumentException("Invalid sequence number: must be non-negative"); - if (signature == null || signature.length != Signature.BYTES) + Objects.requireNonNull(signature, "signature"); + if (signature.length != Signature.BYTES) throw new IllegalArgumentException("Invalid signature: incorrect length"); } - if (data == null || data.length == 0) - throw new IllegalArgumentException("Invalid data: must not be null or empty"); + Objects.requireNonNull(data, "data"); + if (data.length == 0) + throw new IllegalArgumentException("Invalid data: must not be empty"); return new Value(publicKey, privateKey, recipient, nonce, sequenceNumber, signature, data); } @@ -175,6 +178,11 @@ public static Value of(Id publicKey, byte[] nonce, int sequenceNumber, byte[] si * @return The new Value instance. */ public static Value of(Id id, byte[] data) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(data, "data"); + if (data.length == 0) + throw new IllegalArgumentException("Invalid data: must not be empty"); + return new Value(id, data); } @@ -223,7 +231,7 @@ private static Value createSigned(Identity identity, byte[] privateKey, int sequ * Creates a new mutable Value object from the data, encrypted for a specific recipient. * * @param identity The owner's identity. - * @param privateKey The owner's private key. Optional. + * @param privateKey The owner's private key. Optional. * @param recipient The recipient's ID. * @param sequenceNumber The sequence number. * @param data The data to encrypt. @@ -319,7 +327,7 @@ public boolean hasPrivateKey() { * @return the private key of the value, or null if the node does not have the private key. */ public byte[] getPrivateKey() { - return privateKey; + return privateKey != null ? privateKey.clone() : null; } /** @@ -337,7 +345,7 @@ public Id getRecipient() { * @return the nonce of the value, or null for immutable values. */ public byte[] getNonce() { - return nonce; + return nonce != null ? nonce.clone() : null; } /** @@ -355,7 +363,7 @@ public int getSequenceNumber() { * @return the signature of the value, or null for immutable values. */ public byte[] getSignature() { - return signature; + return signature != null ? signature.clone() : null; } /** @@ -364,7 +372,7 @@ public byte[] getSignature() { * @return the data of the value. */ public byte[] getData() { - return data; + return data != null ? data.clone() : null; } /** @@ -489,11 +497,11 @@ public boolean isEncrypted() { private static byte[] computeDigest(Id publicKey, Id recipient, byte[] nonce, int sequenceNumber, byte[] data) { MessageDigest sha = Hash.sha256(); if (publicKey != null) { - sha.update(publicKey.bytes()); + sha.update(publicKey.bytesUnsafe()); if (recipient != null) - sha.update(recipient.bytes()); + sha.update(recipient.bytesUnsafe()); sha.update(nonce); - sha.update(ByteBuffer.allocate(Integer.BYTES).putInt(sequenceNumber).array()); + sha.update(Bytes.fromInteger(sequenceNumber)); } sha.update(data); return sha.digest(); @@ -501,8 +509,8 @@ private static byte[] computeDigest(Id publicKey, Id recipient, byte[] nonce, in /** * Validates structural integrity and cryptographic correctness of this value. - * - *

    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.

    + * + * @param chain the certificate chain + * @param authType the key exchange algorithm used + * @throws CertificateException if the certificate chain is invalid or not trusted + */ + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { + checkTrusted(chain, authType, true); + } + + /** + * Shared trust check: pins self-signed certificates against the expected CN and public key, + * otherwise delegates to the system default trust manager (client or server side). + * + * @param chain the certificate chain + * @param authType the authentication type + * @param client {@code true} to delegate non-self-signed chains as a client cert, {@code false} as a server cert + * @throws CertificateException if the certificate chain is invalid or not trusted + */ + private void checkTrusted(X509Certificate[] chain, String authType, boolean client) throws CertificateException { if (chain == null || chain.length == 0) throw new CertificateException("Null or empty certificate chain"); @@ -108,7 +137,7 @@ public void checkServerTrusted(X509Certificate[] chain, String authType) throws // 3. Validate CN String dn = cert.getSubjectX500Principal().getName(); - LdapName ldapName = null; + LdapName ldapName; try { ldapName = new LdapName(dn); } catch (InvalidNameException e) { @@ -121,31 +150,22 @@ public void checkServerTrusted(X509Certificate[] chain, String authType) throws if (!cn.equals(expectedCn)) throw new CertificateException("CN mismatch"); - // 4. Validate public key + // 4. Validate public key (Ed25519 raw key = last 32 bytes of the SPKI encoding) PublicKey publicKey = cert.getPublicKey(); byte[] spki = publicKey.getEncoded(); + if (spki == null || spki.length < 32) + throw new CertificateException("Unexpected public key encoding"); byte[] pk = Arrays.copyOfRange(spki, spki.length - 32, spki.length); if (!Arrays.equals(pk, expectedPublicKey)) throw new CertificateException("Public key mismatch"); } else { - defaultTrustManager.checkServerTrusted(chain, authType); + if (client) + defaultTrustManager.checkClientTrusted(chain, authType); + else + defaultTrustManager.checkServerTrusted(chain, authType); } } - /** - * Checks whether the provided client certificate chain can be trusted. - * - *

    This implementation delegates the check to the system default trust manager.

    - * - * @param chain the certificate chain - * @param authType the key exchange algorithm used - * @throws CertificateException if the certificate chain is invalid or not trusted - */ - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - defaultTrustManager.checkClientTrusted(chain, authType); - } - /** * Returns the list of certificate issuer authorities which are trusted for * authenticating peers. diff --git a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java index ff464ffe..b37822f8 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java +++ b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.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 java.nio.charset.StandardCharsets.UTF_8; @@ -249,7 +271,7 @@ 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 minMemLimit to maxMemLimit. + * @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 algorithm The algorithm to use. * @return The derived key. @@ -267,7 +289,7 @@ 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 minMemLimit to maxMemLimit. + * @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 algorithm The algorithm to use. * @return The derived key. @@ -316,7 +338,7 @@ public static String hashSensitive(String password) { * Compute a hash from a password. * * @param password The password to hash. - * @param opsLimit The operations limit, which must be in the range minMemLimit to maxMemLimit. + * @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. * @return The hash string. */ diff --git a/api/src/main/java/io/bosonnetwork/crypto/Random.java b/api/src/main/java/io/bosonnetwork/crypto/Random.java index 1a84b461..33e0a552 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/Random.java +++ b/api/src/main/java/io/bosonnetwork/crypto/Random.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.SecureRandom; diff --git a/api/src/main/java/io/bosonnetwork/crypto/Signature.java b/api/src/main/java/io/bosonnetwork/crypto/Signature.java index 129e7308..fd5f7032 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/Signature.java +++ b/api/src/main/java/io/bosonnetwork/crypto/Signature.java @@ -79,7 +79,7 @@ public byte[] bytes() { if (bytes == null) bytes = key.bytesArray(); - return bytes; + return bytes.clone(); } /** @@ -179,15 +179,15 @@ org.apache.tuweni.crypto.sodium.Signature.SecretKey raw() { } /** - * Provides the bytes of this key. + * Provides the bytes of this secret key. * - * @return the bytes of this key. + * @return the bytes of this secret key. */ public byte[] bytes() { if (bytes == null) bytes = key.bytesArray(); - return bytes; + return bytes.clone(); } @@ -200,21 +200,7 @@ public byte[] bytes() { * @return a newly derived {@code PrivateKey} created using the specified subkey ID and context. */ public PrivateKey derive(long subKeyId, 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 md = MessageDigest.getInstance("MD5"); - byte[] hashBytes = md.digest(context.getBytes(StandardCharsets.UTF_8)); - for (int i = 0; i < len; i++) - contextBytes[i] = (byte) (hashBytes[i] + hashBytes[i + 8]); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - + 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); @@ -293,7 +279,7 @@ public boolean isDestroyed() { /** * The signing(Ed25519) key pair. */ - public static class KeyPair { + public static class KeyPair implements Destroyable { /** * The seed length in bytes. */ @@ -302,6 +288,7 @@ public static class KeyPair { private final org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair; private PublicKey pk; private PrivateKey sk; + private boolean destroyed = false; private KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair) { this.keyPair = keyPair; @@ -391,21 +378,7 @@ public PrivateKey privateKey() { * @return the derived {@code KeyPair} instance. */ public KeyPair derive(long subKeyId, 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 md = MessageDigest.getInstance("MD5"); - byte[] hashBytes = md.digest(context.getBytes(StandardCharsets.UTF_8)); - for (int i = 0; i < len; i++) - contextBytes[i] = (byte) (hashBytes[i] + hashBytes[i + 8]); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - + 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); @@ -442,6 +415,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; + } } // Can not access internal method @@ -451,6 +446,37 @@ public int hashCode() { */ public static final int BYTES = 64; + /** + * Derives the fixed-length (8-byte) libsodium 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()}. + *

    + * 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. + * + *

      + *
    • {@link io.bosonnetwork.crypto.Signature} — Ed25519 key pairs, signing and verification;
    • + *
    • {@link io.bosonnetwork.crypto.CryptoBox} — Curve25519 authenticated encryption + * ({@code crypto_box}), with nonce handling;
    • + *
    • {@link io.bosonnetwork.crypto.CryptoIdentity} and + * {@link io.bosonnetwork.crypto.CachedCryptoIdentity} — an {@link io.bosonnetwork.Identity} + * backed by an Ed25519 key pair, with a derived encryption context;
    • + *
    • {@link io.bosonnetwork.crypto.Hash} — hashing helpers (SHA-256/512; MD5 is provided only as + * a non-cryptographic legacy checksum);
    • + *
    • {@link io.bosonnetwork.crypto.PasswordHash} — password hashing / key derivation;
    • + *
    • {@link io.bosonnetwork.crypto.Random} — a cryptographically secure random source.
    • + *
    + * + *

    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: *

      *
    • RFC 8392 - CBOR Web Token
    • @@ -116,6 +123,8 @@ public boolean containsClaim(int claim) { * @param claim the integer key of the claim (e.g., from {@link Claim}). * @param the expected type of the claim value. * @return the claim value, or null if not present. + * @throws ClassCastException if the stored value is not assignable to the inferred type {@code T}. + * The cast is unchecked and driven entirely by the caller's expected type. */ @SuppressWarnings("unchecked") public T getClaim(int claim) { @@ -165,9 +174,12 @@ else if (value instanceof String s) } /** - * Returns an unmodifiable map of all claims present in the CWT. + * Returns an unmodifiable view of all claims present in the CWT. + *

      + * 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 getClaims() { return Collections.unmodifiableMap(claims); @@ -176,10 +188,10 @@ public Map getClaims() { /** * Retrieves the cryptographic signature of the CWT. * - * @return the signature bytes. + * @return a copy of the signature bytes. */ public byte[] getSignature() { - return signature; + return signature.clone(); } /** @@ -247,16 +259,6 @@ public String toString() { + Hex.encode(signature); } - /*/ - private static byte[] encode(Object o) { - try { - return Json.cborMapper().writeValueAsBytes(o); - } catch (IOException e) { - throw new IllegalStateException("INTERNAL ERROR: CWT data encode", e); - } - } - */ - // Due to the object mapper cannot create CBOR definite-length map and array, // So we need to use low-level CBORGenerator to create the expected definite-length object private static void writeMap(CBORGenerator gen, Map map) throws IOException { @@ -483,6 +485,8 @@ public Parser setLeeway(int leeway) { return this; } + // Compares a single expected value against a single claim value. A multi-valued ("aud" as a + // CBOR array) claim is not supported and will not match. @SuppressWarnings("BooleanMethodIsAlwaysInverted") private boolean match(Object expected, Object claim) { if (expected instanceof byte[] be && claim instanceof byte[] bc) @@ -490,7 +494,7 @@ private boolean match(Object expected, Object claim) { if (expected instanceof Id id) { if (claim instanceof byte[] bc) - return Arrays.equals(id.bytes(), bc); + return Arrays.equals(id.bytesUnsafe(), bc); if (claim instanceof String s) return id.toString().equals(s); } @@ -500,7 +504,12 @@ private boolean match(Object expected, Object claim) { /** * Parses and validates the provided CBOR bytes according to the configured constraints. - * + *

      + * 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 protectedHeaders; private final Map unprotectedHeaders; @@ -664,14 +692,13 @@ public static class Builder { private Builder(Identity issuer) { this.issuer = issuer; - this.leeway = 0; this.protectedHeaders = new LinkedHashMap<>(); this.unprotectedHeaders = new LinkedHashMap<>(); this.claims = new LinkedHashMap<>(); protectedHeaders.put(Header.ALG.getValue(), Algorithm.EDDSA.getValue()); - claims.put(Claim.ISSUER.getValue(), issuer.getId().bytes()); + claims.put(Claim.ISSUER.getValue(), issuer.getId().bytesUnsafe()); } /** @@ -722,7 +749,7 @@ public Builder claim(int claim, Object value) { */ public Builder subject(Id subject) { Objects.requireNonNull(subject); - claims.put(Claim.SUBJECT.getValue(), subject.bytes()); + claims.put(Claim.SUBJECT.getValue(), subject.bytesUnsafe()); return this; } @@ -746,7 +773,7 @@ public Builder subject(String subject) { */ public Builder audience(Id audience) { Objects.requireNonNull(audience); - claims.put(Claim.AUDIENCE.getValue(), audience.bytes()); + claims.put(Claim.AUDIENCE.getValue(), audience.bytesUnsafe()); return this; } @@ -762,17 +789,6 @@ public Builder audience(String audience) { return this; } - /** - * Sets the allowed clock skew leeway (in seconds) for time-based claims like expiration and not-before. - * - * @param leeway the leeway in seconds. - * @return the builder instance. - */ - public Builder leeway(long leeway) { - this.leeway = leeway; - return this; - } - /** * Sets the "exp" (Expiration Time) claim. * @@ -781,7 +797,7 @@ public Builder leeway(long leeway) { */ public Builder expiration(Date expiration) { Objects.requireNonNull(expiration); - long exp = expiration.getTime() / 1000 + leeway; + long exp = expiration.getTime() / 1000; claims.put(Claim.EXPIRATION.getValue(), exp); return this; } @@ -794,7 +810,7 @@ public Builder expiration(Date expiration) { */ public Builder expiration(Duration expiration) { Objects.requireNonNull(expiration); - long exp = (System.currentTimeMillis() + expiration.toMillis()) / 1000 + leeway; + long exp = (System.currentTimeMillis() + expiration.toMillis()) / 1000; claims.put(Claim.EXPIRATION.getValue(), exp); return this; } @@ -807,7 +823,7 @@ public Builder expiration(Duration expiration) { */ public Builder notBefore(Date notBefore) { Objects.requireNonNull(notBefore); - long nbf = notBefore.getTime() / 1000 - leeway; + long nbf = notBefore.getTime() / 1000; claims.put(Claim.NOT_BEFORE.getValue(), nbf); return this; } @@ -820,7 +836,7 @@ public Builder notBefore(Date notBefore) { */ public Builder notBefore(Duration notBefore) { Objects.requireNonNull(notBefore); - long nbf = (System.currentTimeMillis() + notBefore.toMillis()) / 1000 - leeway; + long nbf = (System.currentTimeMillis() + notBefore.toMillis()) / 1000; claims.put(Claim.NOT_BEFORE.getValue(), nbf); return this; } @@ -831,7 +847,7 @@ public Builder notBefore(Duration notBefore) { * @return the builder instance. */ public Builder notBeforeNow() { - long nbf = System.currentTimeMillis() / 1000 - leeway; + long nbf = System.currentTimeMillis() / 1000; claims.put(Claim.NOT_BEFORE.getValue(), nbf); return this; } @@ -929,7 +945,7 @@ public Builder scope(String scope) { */ public Builder clientId(Id clientId) { Objects.requireNonNull(clientId); - claims.put(Claim.CLIENT_ID.getValue(), clientId.getBytes()); + claims.put(Claim.CLIENT_ID.getValue(), clientId.bytesUnsafe()); return this; } diff --git a/api/src/main/java/io/bosonnetwork/cwt/package-info.java b/api/src/main/java/io/bosonnetwork/cwt/package-info.java new file mode 100644 index 00000000..524a8660 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/cwt/package-info.java @@ -0,0 +1,56 @@ +/* + * 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. + */ + +/** + * CBOR Web Token (CWT) support, used as the authentication token format across Boson's HTTP + * services (super node ↔ client ↔ federation). + * + *

      {@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()}. + *

      + * 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. + * + *

      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 CollectionParameter { /** * Constructs a new CollectionParameter with the specified name and collection of values. * - * @param name the name of the parameter; must not be null + * @param name the name of the parameter; must not be null and must be a safe bind-parameter + * identifier (it is interpolated into {@code #{name_i}} template placeholders) * @param values the collection of values associated with the parameter; must not be null * @throws NullPointerException if {@code name} or {@code values} is null + * @throws IllegalArgumentException if {@code name} is not a safe SQL bind-parameter identifier */ public CollectionParameter(String name, Collection values) { Objects.requireNonNull(name, "name"); Objects.requireNonNull(values, "values"); - this.name = name; + this.name = SqlSafety.validateParamName(name); this.values = List.copyOf(values); } diff --git a/api/src/main/java/io/bosonnetwork/database/Filter.java b/api/src/main/java/io/bosonnetwork/database/Filter.java index 851a51bf..18d5d813 100644 --- a/api/src/main/java/io/bosonnetwork/database/Filter.java +++ b/api/src/main/java/io/bosonnetwork/database/Filter.java @@ -22,6 +22,9 @@ package io.bosonnetwork.database; +import static io.bosonnetwork.database.SqlSafety.validateColumn; +import static io.bosonnetwork.database.SqlSafety.validateParamName; + import java.util.Arrays; import java.util.Collections; import java.util.Map; @@ -63,8 +66,7 @@ public static Filter raw(String sql) { public static Filter eq(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, "=", paramName, value); + return new Binary(validateColumn(column), "=", paramName, value); } /** @@ -75,7 +77,7 @@ public static Filter eq(String column, String paramName, Object value) { * @return a Filter representing the equality condition */ public static Filter eq(String column, Object value) { - return eq(column, column, value); + return eq(column, defaultParamName(column, "eq"), value); } /** @@ -89,8 +91,7 @@ public static Filter eq(String column, Object value) { public static Filter ne(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, "<>", paramName, value); + return new Binary(validateColumn(column), "<>", paramName, value); } /** @@ -101,7 +102,7 @@ public static Filter ne(String column, String paramName, Object value) { * @return a Filter representing the non-equality condition */ public static Filter ne(String column, Object value) { - return ne(column, column, value); + return ne(column, defaultParamName(column, "ne"), value); } /** @@ -115,8 +116,7 @@ public static Filter ne(String column, Object value) { public static Filter lt(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, "<", paramName, value); + return new Binary(validateColumn(column), "<", paramName, value); } /** @@ -127,7 +127,7 @@ public static Filter lt(String column, String paramName, Object value) { * @return a Filter representing the less-than condition */ public static Filter lt(String column, Object value) { - return lt(column, column, value); + return lt(column, defaultParamName(column, "lt"), value); } /** @@ -141,8 +141,7 @@ public static Filter lt(String column, Object value) { public static Filter lte(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, "<=", paramName, value); + return new Binary(validateColumn(column), "<=", paramName, value); } /** @@ -153,7 +152,7 @@ public static Filter lte(String column, String paramName, Object value) { * @return a Filter representing the less-than-or-equal condition */ public static Filter lte(String column, Object value) { - return lte(column, column, value); + return lte(column, defaultParamName(column, "lte"), value); } /** @@ -167,8 +166,7 @@ public static Filter lte(String column, Object value) { public static Filter gt(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, ">", paramName, value); + return new Binary(validateColumn(column), ">", paramName, value); } /** @@ -179,7 +177,7 @@ public static Filter gt(String column, String paramName, Object value) { * @return a Filter representing the greater-than condition */ public static Filter gt(String column, Object value) { - return gt(column, column, value); + return gt(column, defaultParamName(column, "gt"), value); } /** @@ -193,8 +191,7 @@ public static Filter gt(String column, Object value) { public static Filter gte(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, ">=", paramName, value); + return new Binary(validateColumn(column), ">=", paramName, value); } /** @@ -205,7 +202,7 @@ public static Filter gte(String column, String paramName, Object value) { * @return a Filter representing the greater-than-or-equal condition */ public static Filter gte(String column, Object value) { - return gte(column, column, value); + return gte(column, defaultParamName(column, "gte"), value); } /** @@ -219,8 +216,7 @@ public static Filter gte(String column, Object value) { public static Filter like(String column, String paramName, Object value) { Objects.requireNonNull(column); Objects.requireNonNull(paramName); - validateColumn(column); - return new Binary(column, "LIKE", paramName, value); + return new Binary(validateColumn(column), "LIKE", paramName, value); } /** @@ -231,7 +227,7 @@ public static Filter like(String column, String paramName, Object value) { * @return a Filter representing the LIKE 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 params) { Objects.requireNonNull(column); - validateColumn(column); + column = validateColumn(column); if (params == null || params.isEmpty()) // empty IN always false return new Raw(" 1 = 0"); + params.keySet().forEach(SqlSafety::validateParamName); + return new In(column, Collections.unmodifiableMap(params)); } @@ -324,7 +320,6 @@ public boolean isEmpty() { return true; } - /** * Returns the parameter bindings for this filter. * @@ -335,15 +330,20 @@ public Map getParams() { } /** - * Validates that the column name contains only safe characters. + * Derives a safe default bind-parameter name from a column name and the operator. A qualified + * column such as {@code table.col} is mapped to {@code table_col} so it is a valid parameter + * token, and the operator is appended (e.g. {@code col_gte}) so that distinct operators on the + * same column do not collide when combined — for example + * {@code and(gte("ts", lo), lte("ts", hi))} yields the distinct names {@code ts_gte} and + * {@code ts_lte}. Combining two filters that use the same column and operator still + * collides; use the explicit {@code paramName} overload to disambiguate those. * - * @param column the column name to validate - * @throws IllegalArgumentException if the column name is invalid + * @param column the column name (already validated by the caller) + * @param op the operator suffix (e.g. {@code "eq"}, {@code "gte"}) + * @return a valid parameter name, or {@code null} if {@code column} is {@code null} */ - private static void validateColumn(String column) { - // Only letters, digits, and underscore allowed (safe for SQL identifiers) - if (!column.matches("^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?$")) - throw new IllegalArgumentException("Invalid SQL column name: " + column); + private static String defaultParamName(String column, String op) { + return column == null ? null : column.replace('.', '_') + '_' + op; } /** @@ -396,7 +396,7 @@ private static class Binary extends Filter { private Binary(String column, String operator, String paramName, Object value) { this.column = column; this.operator = operator; - this.paramName = paramName; + this.paramName = validateParamName(paramName); this.value = value; } @@ -469,7 +469,11 @@ public Map getParams() { return Arrays.stream(filters) .map(Filter::getParams) .flatMap(m -> m.entrySet().stream()) - .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, + (a, b) -> { + throw new IllegalStateException("Duplicate bind-parameter name in combined filter; " + + "use the explicit paramName overload to disambiguate conditions on the same column"); + })); } } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/database/Ordering.java b/api/src/main/java/io/bosonnetwork/database/Ordering.java index 9551d35c..f312c4a0 100644 --- a/api/src/main/java/io/bosonnetwork/database/Ordering.java +++ b/api/src/main/java/io/bosonnetwork/database/Ordering.java @@ -22,6 +22,8 @@ package io.bosonnetwork.database; +import static io.bosonnetwork.database.SqlSafety.validateColumn; + import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -35,7 +37,8 @@ * Example: *
        * 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. - *

      - * Only letters, digits, and underscores are allowed (safe for SQL identifiers). - * Optionally supports qualified names with a single dot (e.g., "table.column"). - *

      - * - * @param column the column name to validate - * @throws IllegalArgumentException if the column name is invalid - */ - private static void validateColumn(String column) { - // Only letters, digits, and underscore allowed (safe for SQL identifiers) - if (!column.matches("^[A-Za-z_][A-Za-z0-9_]*(?:\\.[A-Za-z_][A-Za-z0-9_]*)?$")) - throw new IllegalArgumentException("Invalid SQL column name: " + column); - } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/database/Pagination.java b/api/src/main/java/io/bosonnetwork/database/Pagination.java index 6490cc0b..572fedab 100644 --- a/api/src/main/java/io/bosonnetwork/database/Pagination.java +++ b/api/src/main/java/io/bosonnetwork/database/Pagination.java @@ -29,7 +29,7 @@ *

      * Example: * Pagination p = Pagination.page(3, 20); // pageIndex=3, pageSize=20 - * p.toSql(); // " OFFSET 40 LIMIT 20" + * p.toSql(); // " LIMIT 20 OFFSET 40" *

      */ public class Pagination { @@ -54,13 +54,19 @@ private Pagination(long offset, long limit) { * Create Pagination using explicit limit/offset. * * @param offset the number of rows to skip - * @param limit the maximum number of rows to return - * @return a new Pagination instance + * @param limit the maximum number of rows to return; must be {@code > 0} unless both + * {@code offset} and {@code limit} are {@code 0} (which yields {@link #NONE}) + * @return a new Pagination instance, or {@link #NONE} if both arguments are {@code 0} + * @throws IllegalArgumentException if {@code offset < 0}, or if {@code limit <= 0} while + * {@code offset > 0} (a zero limit would otherwise mean "return no rows") */ public static Pagination of(long offset, long limit) { if (offset == 0 && limit == 0) return NONE; + if (limit <= 0) + throw new IllegalArgumentException("limit must be > 0 for a paginated query"); + return new Pagination(offset, limit); } @@ -88,7 +94,7 @@ public static Pagination page(long pageIndex, long pageSize) { /** * Generates the SQL LIMIT/OFFSET clause. * - * @return SQL fragment like " OFFSET 40 LIMIT 20". + * @return SQL fragment like " LIMIT 20 OFFSET 40". * If offset and limit are both 0, returns "" (meaning no limit applied). */ public String toSql() { @@ -101,7 +107,7 @@ public String toSql() { /** * Generates a parameterized SQL LIMIT/OFFSET clause. * - * @return A SQL fragment like " OFFSET #{offset} LIMIT #{limit}". + * @return A SQL fragment like " LIMIT #{limit} OFFSET #{offset}". * If offset and limit are both 0, returns an empty string to indicate no limit is applied. */ public String toSqlTemplate() { diff --git a/api/src/main/java/io/bosonnetwork/database/SqlSafety.java b/api/src/main/java/io/bosonnetwork/database/SqlSafety.java new file mode 100644 index 00000000..92873309 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/database/SqlSafety.java @@ -0,0 +1,91 @@ +/* + * 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.database; + +import java.util.regex.Pattern; + +/** + * Validation helpers for SQL identifiers (column names, bind-parameter names, schema names). + *

      + * 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.

      + * * @return a future that completes when all pending migrations have been applied, * or fails if validation or execution fails */ @@ -225,7 +227,7 @@ public Future migrate() { databaseProductName = name; log.debug("Migration check: target database product {}", name); - if (!databaseProductName.toLowerCase().contains("postgres") && schema != null) + if (!databaseProductName.toLowerCase(Locale.ROOT).contains("postgres") && schema != null) return Future.failedFuture(new IllegalStateException("Schema migration with custom schema is not supported for " + databaseProductName)); return Future.succeededFuture(); @@ -394,6 +396,19 @@ private List getMigrations() throws IllegalStateException, IOExceptio } Collections.sort(migrations); + + // Enforce unique versions. compareTo is a pure comparator; uniqueness is checked here, + // where adjacent entries with the same version are now neighbours after sorting. + for (int i = 1; i < migrations.size(); i++) { + Migration prev = migrations.get(i - 1); + Migration cur = migrations.get(i); + if (prev.version == cur.version) { + log.error("Migration check: Migration file version must be unique. File names: {} and {}", + prev.fileName(), cur.fileName()); + throw new IllegalStateException("Migration file version must be unique"); + } + } + return migrations; } } @@ -495,9 +510,9 @@ private static String nextStatement(BufferedReader reader) throws IOException { while (i < line.length()) { char c = line.charAt(i); - // Handle entering/exiting block comments - if (!inSingleQuote && !inDoubleQuote && !inBlockComment && i + 1 < line.length() - && line.charAt(i) == '/' && line.charAt(i + 1) == '*') { + // Handle entering/exiting block comments (not inside dollar-quoted blocks) + if (!inSingleQuote && !inDoubleQuote && !inBlockComment && currentDollarTag == null + && i + 1 < line.length() && line.charAt(i) == '/' && line.charAt(i + 1) == '*') { inBlockComment = true; i += 2; continue; @@ -512,9 +527,9 @@ private static String nextStatement(BufferedReader reader) throws IOException { continue; } - // Handle line comments - if (!inSingleQuote && !inDoubleQuote && !inBlockComment && i + 1 < line.length() - && line.charAt(i) == '-' && line.charAt(i + 1) == '-') { + // Handle line comments (not inside dollar-quoted blocks) + if (!inSingleQuote && !inDoubleQuote && !inBlockComment && currentDollarTag == null + && i + 1 < line.length() && line.charAt(i) == '-' && line.charAt(i + 1) == '-') { // the rest of the line is a comment break; } @@ -605,22 +620,40 @@ private static boolean startsKeyword(String line, int pos, String keyword) { * @param migration the migration to apply * @return a future completing with the applied {@link SchemaVersion} */ + /** Parsed migration script: an optional long description plus the ordered SQL statements. */ + private record ParsedMigration(String description, List statements) {} + + /** + * Reads and splits a migration file into individual SQL statements. This performs blocking + * file I/O and lexing and therefore must be run off the Vert.x event loop (via executeBlocking). + */ + private static ParsedMigration parseMigration(Migration migration) throws IOException { + try (BufferedReader reader = Files.newBufferedReader(migration.path())) { + String description = readDescriptionComment(reader); + List statements = new ArrayList<>(); + String statement; + while ((statement = nextStatement(reader)) != null) + statements.add(statement); + return new ParsedMigration(description, statements); + } + } + private Future applyMigration(Migration migration) { log.info("Migration: applying migration version {} from {}...", migration.version, migration.fileName()); + String appliedBy = appliedByIdentifier(); long begin = System.currentTimeMillis(); - return withSchemaTransaction(connection -> { - Promise promise = Promise.promise(); - Future chain = Future.succeededFuture(); - try (BufferedReader reader = Files.newBufferedReader(migration.path())) { - String longDescription = readDescriptionComment(reader); - if (longDescription != null) - migration.setDescription(longDescription); - String statement; - while ((statement = nextStatement(reader)) != null) { - final String sql = statement; + // Parse the migration file off the event loop (blocking file I/O + lexing), then run the + // resulting statements within the schema transaction. + return vertx.executeBlocking(() -> parseMigration(migration)).compose(parsed -> { + if (parsed.description() != null) + migration.setDescription(parsed.description()); + return withSchemaTransaction(connection -> { + Future chain = Future.succeededFuture(); + for (String statement : parsed.statements()) { + final String sql = statement; chain = chain.compose(vv -> { log.trace("Migration: executing statement {}", sql); return connection.query(sql).execute() @@ -630,34 +663,30 @@ private Future applyMigration(Migration migration) { }).mapEmpty(); }); } - } catch (IOException e) { - return Future.failedFuture(new IllegalStateException("Failed to read migration file", e)); - } - chain.compose(vv -> { - long duration = System.currentTimeMillis() - begin; - log.info("Migration: applied migration file {} in {} ms", migration.fileName(), duration); - log.debug("Migration: updating schema version..."); - SchemaVersion newVersion = new SchemaVersion(migration.version, migration.description, - migration.hash, "", begin, duration, true); - return connection.preparedQuery(insertSchemaVersion()) - .execute( - Tuple.of(newVersion.version, - newVersion.description, - newVersion.hash, - newVersion.appliedBy, - newVersion.appliedAt, - newVersion.consumedTime, - newVersion.success)) - .map(newVersion); - }).andThen(ar -> { - if (ar.succeeded()) - log.debug("Migration: schema version updated to version {}", migration.version); - else - log.error("Migration: failed to update schema version.", ar.cause()); - }).onComplete(promise); - - return promise.future(); + return chain.compose(vv -> { + long duration = System.currentTimeMillis() - begin; + log.info("Migration: applied migration file {} in {} ms", migration.fileName(), duration); + log.debug("Migration: updating schema version..."); + SchemaVersion newVersion = new SchemaVersion(migration.version, migration.description, + migration.hash, appliedBy, begin, duration, true); + return connection.preparedQuery(insertSchemaVersion()) + .execute( + Tuple.of(newVersion.version, + newVersion.description, + newVersion.hash, + newVersion.appliedBy, + newVersion.appliedAt, + newVersion.consumedTime, + newVersion.success)) + .map(newVersion); + }).andThen(ar -> { + if (ar.succeeded()) + log.debug("Migration: schema version updated to version {}", migration.version); + else + log.error("Migration: failed to update schema version.", ar.cause()); + }); + }); }); } @@ -689,6 +718,27 @@ private static boolean getBoolean(Row row, String columnName) { (value instanceof String s && Boolean.parseBoolean(s))); } + /** + * Builds the {@code applied_by} identifier as {@code user@host}, truncated to fit the + * {@code schema_versions.applied_by} column. Falls back to {@code "unknown"} for either part if + * it cannot be determined. + */ + private static String appliedByIdentifier() { + String user = System.getProperty("user.name"); + if (user == null || user.isEmpty()) + user = "unknown"; + + String host; + try { + host = java.net.InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + host = "unknown"; + } + + String identifier = user + "@" + host; + return identifier.length() > 128 ? identifier.substring(0, 128) : identifier; + } + private String sha256(Path path) throws IOException { MessageDigest digest; try { @@ -734,7 +784,7 @@ protected String selectSchemaVersions() { * @return parameterized INSERT SQL suitable for the target database */ protected String insertSchemaVersion() { - if (databaseProductName.toLowerCase().contains("postgres")) + if (databaseProductName.toLowerCase(Locale.ROOT).contains("postgres")) return insertSchemaVersionWithIndexedParameters; else return insertSchemaVersionWithQuestionMarks; @@ -743,7 +793,7 @@ protected String insertSchemaVersion() { private static final String createSchemaVersionTable = """ CREATE TABLE IF NOT EXISTS schema_versions( version INTEGER PRIMARY KEY, - description VARCHAR(512) UNIQUE DEFAULT NULL, + description VARCHAR(512) DEFAULT NULL, hash VARCHAR(128) NOT NULL, applied_by VARCHAR(128), applied_at BIGINT NOT NULL, diff --git a/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java b/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java index c147f67e..f73c42e3 100644 --- a/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java +++ b/api/src/main/java/io/bosonnetwork/database/VertxDatabase.java @@ -118,9 +118,10 @@ private Function> wrappedAsync(Function Future withTransaction(Function> function) { - if (getClient() instanceof Pool p) { + final SqlClient client = getClient(); + if (client instanceof Pool p) { return p.withTransaction(c -> wrappedAsync(function).apply(c)); - } else if (getClient() instanceof SqlConnection connection) { + } else if (client instanceof SqlConnection connection) { return withTransaction(connection, c -> wrappedAsync(function).apply(c)); } else { return Future.failedFuture(new IllegalStateException("Client must be an instance of SqlConnection or Pool")); @@ -154,9 +155,10 @@ private Future withTransaction(SqlConnection connection, Function Future withConnection(Function> function) { - if (getClient() instanceof SqlConnection c) { + final SqlClient client = getClient(); + if (client instanceof SqlConnection c) { return wrappedAsync(function).apply(c); - } else if (getClient() instanceof Pool p) { + } else if (client instanceof Pool p) { return p.withConnection(c -> wrappedAsync(function).apply(c)); } else { return Future.failedFuture(new IllegalStateException("Client must be an instance of SqlConnection or Pool")); @@ -256,7 +258,9 @@ default long findLong(RowSet rowSet) { * @return mapped value or the default */ default T findUniqueOrDefault(RowSet rowSet, Function mapper, T defaultValue) { - return rowSet.size() != 0 ? mapper.apply(rowSet.iterator().next()) : defaultValue; + if (rowSet == null || rowSet.size() == 0) + return defaultValue; + return mapper.apply(rowSet.iterator().next()); } /** @@ -280,6 +284,8 @@ default T findUnique(RowSet rowSet, Function mapper) { * @return list of mapped values (possibly empty) */ default List findMany(RowSet rowSet, Function mapper) { + if (rowSet == null) + return List.of(); return rowSet.stream().map(mapper).collect(Collectors.toList()); } diff --git a/api/src/main/java/io/bosonnetwork/database/package-info.java b/api/src/main/java/io/bosonnetwork/database/package-info.java new file mode 100644 index 00000000..5c3e0774 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/database/package-info.java @@ -0,0 +1,53 @@ +/* + * 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. + */ + +/** + * Lightweight SQL helper layer for Boson modules built on the Vert.x reactive SQL client. It + * provides safe query construction, connection/transaction helpers, and file-based schema + * migration — without pulling in a heavyweight ORM. + * + *

      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: + *
        + *
      • {@link io.bosonnetwork.database.Filter} — {@code WHERE} clauses (eq/ne/lt/gt/like/in, plus + * {@code AND}/{@code OR} composition) with named bind parameters;
      • + *
      • {@link io.bosonnetwork.database.Ordering} — {@code ORDER BY} clauses;
      • + *
      • {@link io.bosonnetwork.database.Pagination} — {@code LIMIT}/{@code OFFSET} clauses;
      • + *
      • {@link io.bosonnetwork.database.CollectionParameter} — expands a collection into the + * placeholder tuple needed for an {@code IN (...)} predicate.
      • + *
      + * + *

      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 _.sql} migration files transactionally, records them in a + * {@code schema_versions} table, and verifies SHA-256 checksums to detect tampering. It targets + * PostgreSQL and SQLite via the Vert.x SQL clients. + */ +package io.bosonnetwork.database; diff --git a/api/src/main/java/io/bosonnetwork/identifier/CachedResolver.java b/api/src/main/java/io/bosonnetwork/identifier/CachedResolver.java index e7312fb4..0cb25f6c 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/CachedResolver.java +++ b/api/src/main/java/io/bosonnetwork/identifier/CachedResolver.java @@ -28,6 +28,7 @@ import com.github.benmanes.caffeine.cache.AsyncCache; import com.github.benmanes.caffeine.cache.Caffeine; +import io.vertx.core.Context; import io.vertx.core.Promise; import io.vertx.core.Vertx; import org.slf4j.Logger; @@ -35,7 +36,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.vertx.VertxCaffeine; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; /** * A resolver implementation that uses in-memory and optional persistent caching @@ -68,7 +69,7 @@ public class CachedResolver implements Resolver { * This cache is used to retrieve and store resolved {@link Card} objects to provide longer-lived caching * across application restarts or multiple instances. */ - private final ResolverCache persistentCache; + private final ResolutionCache persistentCache; private final static Logger log = LoggerFactory.getLogger(CachedResolver.class); @@ -81,20 +82,21 @@ public class CachedResolver implements Resolver { * @param resolver the underlying resolver used for resolving identifiers, must not be null * @param vertx the Vert.x instance used for integration with Vert.x asynchronous features, * may be null if Vert.x support is not required - * @param persistentCache an optional persistent cache implementation for storing resolved values, + * @param persistentCache an optional persistent cache for resolution results; * may be null if no persistent storage is needed */ - public CachedResolver(Resolver resolver, Vertx vertx, ResolverCache persistentCache) { + public CachedResolver(Resolver resolver, Vertx vertx, ResolutionCache persistentCache) { this.resolver = Objects.requireNonNull(resolver, "resolver"); this.persistentCache = persistentCache; if (vertx == null) { - try { - Class.forName("io.vertx.core.Vertx"); - vertx = Vertx.currentContext().owner(); - } catch (ClassNotFoundException ignored) { - } + // Adopt the current Vert.x context's owner if we are running on an event loop; + // otherwise fall back to a plain Caffeine cache. Vertx.currentContext() returns + // null off a Vert.x thread, so it must be null-checked before calling owner(). + Context ctx = Vertx.currentContext(); + if (ctx != null) + vertx = ctx.owner(); } Caffeine caffeine = vertx == null ? @@ -146,12 +148,13 @@ public CompletableFuture> resolve(Id id, ResolutionOption ResolutionOptions opts = options == null ? ResolutionOptions.defaultOptions() : options; // If caching is disabled, directly resolve and update caches - if (!opts.usingCache()) { + if (!opts.useCache()) { log().debug("Resolver cache is disabled, force to resolve: {}", id); return resolver.resolve(id, options).thenApply(result -> { - // Update persistent cache if available - if (persistentCache != null) { + // Only persist successful results; negative results (not found / invalid) carry no + // metadata and should not be cached as if they were authoritative. + if (persistentCache != null && result.succeeded()) { try { persistentCache.put(id, result); } catch (Exception e) { @@ -175,8 +178,10 @@ public CompletableFuture> resolve(Id id, ResolutionOption if (persistentCache != null) { try { ResolutionResult result = persistentCache.get(id); - // Check if the Card exists in the persistent cache and it's valid - if (result != null && result.getResultMetadata().getResolved().getTime() > System.currentTimeMillis() - opts.validTTL()) { + // Only honor a cached successful result that is still within the requested TTL. + // Negative results carry null metadata, so guard against it explicitly. + if (result != null && result.succeeded() && result.getResultMetadata() != null + && result.getResultMetadata().getResolved().getTime() > System.currentTimeMillis() - opts.validTTL()) { promise.complete(result); return; } @@ -188,8 +193,8 @@ public CompletableFuture> resolve(Id id, ResolutionOption // Perform actual resolution if no valid cache found resolver.resolve(id, options).whenComplete((result, error) -> { if (error == null) { - // Update the persistent cache with the result - if (persistentCache != null) { + // Persist successful results only (negative results carry no metadata) + if (persistentCache != null && result.succeeded()) { try { persistentCache.put(id, result); } catch (Exception e) { @@ -204,7 +209,7 @@ public CompletableFuture> resolve(Id id, ResolutionOption }); }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); }); } diff --git a/api/src/main/java/io/bosonnetwork/identifier/Card.java b/api/src/main/java/io/bosonnetwork/identifier/Card.java index 8c23a098..5579db2c 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Card.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Card.java @@ -117,27 +117,31 @@ protected Card(@JsonProperty(value = "id", required = true) Id id, Objects.requireNonNull(signature, "signature"); this.id = id; - this.credentials = credentials == null || credentials.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(credentials); - this.services = services == null || services.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(services); + this.credentials = credentials == null || credentials.isEmpty() ? List.of() : List.copyOf(credentials); + this.services = services == null || services.isEmpty() ? List.of() : List.copyOf(services); this.signedAt = signedAt; - this.signature = signature; + this.signature = signature.clone(); } /** - * Internal constructor used by CardBuilder. - * The caller should transfer ownership of the credentials and services to the new instance. + * Internal constructor for a sat-stamped but unsigned Card. + *

      + * Used by the W3C adapter ({@link DIDDocument.CardView}) at sign time to build the bytes the + * signature will cover. {@code signedAt} must be set before signing because it is part of the + * signed data. * - * @param id the DID identifier + * @param id the DID identifier (required) * @param credentials list of credentials (maybe null or empty) * @param services list of services (maybe null or empty) + * @param signedAt the signing timestamp to embed */ - protected Card(Id id, List credentials, List services) { + protected Card(Id id, List credentials, List services, Date signedAt) { Objects.requireNonNull(id, "id"); this.id = id; - this.credentials = credentials == null || credentials.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(credentials); - this.services = services == null || services.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(services); - this.signedAt = null; + this.credentials = credentials == null || credentials.isEmpty() ? List.of() : List.copyOf(credentials); + this.services = services == null || services.isEmpty() ? List.of() : List.copyOf(services); + this.signedAt = signedAt; this.signature = null; } @@ -145,15 +149,14 @@ protected Card(Id id, List credentials, List services) { * Internal copy constructor used to create a signed Card instance. * * @param profile the unsigned Card instance - * @param signedAt timestamp of signature * @param signature digital signature bytes */ - protected Card(Card profile, Date signedAt, byte[] signature) { + protected Card(Card profile, byte[] signature) { this.id = profile.id; this.credentials = profile.credentials; this.services = profile.services; - this.signedAt = signedAt; + this.signedAt = profile.signedAt; this.signature = signature; } @@ -237,6 +240,7 @@ public List getServices() { * @return list of services matching the specified type, never null */ public List getServices(String type) { + Objects.requireNonNull(type, "type"); return services.stream() .filter(s -> s.getType().equals(type)) .collect(Collectors.toList()); @@ -266,6 +270,8 @@ public Service getService(String id) { * @return the service matching the specified id and type, or null if no such service exists */ public Service getService(String id, String type) { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(type, "type"); return services.stream() .filter(s -> s.getId().equals(id) && s.getType().equals(type)) .findFirst() @@ -274,6 +280,9 @@ public Service getService(String id, String type) { /** * Returns the timestamp when this Card was signed. + *

      + * Note: {@code signedAt} is metadata and is not covered by the signature, so it is not + * cryptographically authenticated; do not rely on it for security decisions. * * @return the signature timestamp, or null if unsigned */ @@ -287,7 +296,7 @@ public Date getSignedAt() { * @return the signature bytes, or null if unsigned */ public byte[] getSignature() { - return signature; + return signature == null ? null : signature.clone(); } /** @@ -328,9 +337,11 @@ public void validate() throws InvalidSignatureException { * @return byte array representing the signing data */ protected byte[] getSignData() { - if (signature != null) // already signed - return new Card(this, null, null).toBytes(); - else // unsigned + if (signature != null) // already signed + // Rebuild the bytes that were signed: {id, c, s, sat} (signature omitted). + // signedAt is part of the signed data so that the signing timestamp is authenticated. + return new Card(this, null).toBytes(); + else // unsigned return toBytes(); } @@ -512,7 +523,7 @@ protected Service(String id, String type, String endpoint, Map p this.id = id; this.type = type; this.endpoint = endpoint; - this.properties = properties == null ? Collections.emptyMap() : properties; + this.properties = properties == null || properties.isEmpty() ? Map.of() : new LinkedHashMap<>(properties); } /** diff --git a/api/src/main/java/io/bosonnetwork/identifier/CardBuilder.java b/api/src/main/java/io/bosonnetwork/identifier/CardBuilder.java index 59914ee1..6d2aa632 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/CardBuilder.java +++ b/api/src/main/java/io/bosonnetwork/identifier/CardBuilder.java @@ -23,7 +23,7 @@ package io.bosonnetwork.identifier; import java.util.ArrayList; -import java.util.Collections; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -361,10 +361,12 @@ public CardBuilder addService(String id, String type, String endpoint, String pr */ @Override public Card build() { - List credentials = this.credentials.isEmpty() ? Collections.emptyList() : new ArrayList<>(this.credentials.values()); - List services = this.services.isEmpty() ? Collections.emptyList() : new ArrayList<>(this.services.values()); - Card unsigned = new Card(identity.getId(), credentials, services); + List credentials = this.credentials.isEmpty() ? List.of() : new ArrayList<>(this.credentials.values()); + List services = this.services.isEmpty() ? List.of() : new ArrayList<>(this.services.values()); + // Stamp signedAt before signing so it is covered by the signature. + Date signedAt = now(); + Card unsigned = new Card(identity.getId(), credentials, services, signedAt); byte[] signature = identity.sign(unsigned.getSignData()); - return new Card(unsigned, now(), signature); + return new Card(unsigned, signature); } -} +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/Credential.java b/api/src/main/java/io/bosonnetwork/identifier/Credential.java index 2e173ca4..b1fda329 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Credential.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Credential.java @@ -149,7 +149,7 @@ protected Credential(@JsonProperty(value = "id", required = true) String id, Objects.requireNonNull(signature, "signature"); this.id = id; - this.types = types == null || types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types == null || types.isEmpty() ? List.of() : List.copyOf(types); this.name = name; this.description = description; this.issuer = issuer; @@ -157,7 +157,7 @@ protected Credential(@JsonProperty(value = "id", required = true) String id, this.validUntil = validUntil; this.subject = subject; this.signedAt = signedAt; - this.signature = signature; + this.signature = signature.clone(); // Ensure the subject's id is consistent with the issuer if implicit this.subject.implicitCheck(issuer); @@ -182,7 +182,7 @@ protected Credential(@JsonProperty(value = "id", required = true) String id, protected Credential(String id, List types, String name, String description, Id issuer, Date validFrom, Date validUntil, Id subject, Map claims, Date signedAt, byte[] signature) { this.id = id; - this.types = types == null || types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types == null || types.isEmpty() ? List.of() : List.copyOf(types); this.name = name; this.description = description; this.issuer = issuer; @@ -200,10 +200,9 @@ protected Credential(String id, List types, String name, String descript * Internal constructor used by CredentialBuilder to create a copy with new signedAt and signature. * * @param cred Original credential - * @param signedAt Signature timestamp (should be trimmed of milliseconds) * @param signature Cryptographic signature bytes */ - protected Credential(Credential cred, Date signedAt, byte[] signature) { + protected Credential(Credential cred, byte[] signature) { this.id = cred.id; this.types = cred.types; this.name = cred.name; @@ -213,7 +212,7 @@ protected Credential(Credential cred, Date signedAt, byte[] signature) { this.validUntil = cred.validUntil; this.subject = cred.subject; - this.signedAt = signedAt; // signedAt should be trimmed the milliseconds + this.signedAt = cred.signedAt; // signedAt should be trimmed the milliseconds this.signature = signature; } @@ -292,6 +291,10 @@ public Subject getSubject() { /** * Gets the signature timestamp. + *

      + * Note: {@code signedAt} is metadata and is not covered by the signature (the signed + * data excludes it), so it is not cryptographically authenticated. The credential's validity + * window ({@link #getValidFrom()}/{@link #getValidUntil()}) is signed. * * @return Signed at date */ @@ -305,17 +308,16 @@ public Date getSignedAt() { * @return Signature byte array */ public byte[] getSignature() { - return signature; + return signature == null ? null : signature.clone(); } /** - * Determines if the credential is self-issued. - * A credential is self-issued if the subject's id is null or equals the issuer. + * Determines if the credential is self-issued, i.e. the subject is the same as the issuer. * * @return True if self-issued, false otherwise */ public boolean selfIssued() { - return subject.getId() == null || subject.getId().equals(issuer); + return Objects.equals(subject.getId(), getIssuer()); } /** @@ -378,9 +380,11 @@ public void validate() throws BeforeValidPeriodException, ExpiredException, Inva * @return Byte array of the data to be signed or verified */ protected byte[] getSignData() { - if (signature != null) // already signed - return new Credential(this, null,null).toBytes(); - else // unsigned + if (signature != null) // already signed + // Rebuild the bytes that were signed: everything except the signature itself. + // signedAt is part of the signed data so that the signing timestamp is authenticated. + return new Credential(this, null).toBytes(); + else // unsigned return toBytes(); } @@ -517,7 +521,7 @@ public static class Subject { private final Map claims; /** Flag indicating if the subject id is implicit (same as issuer) */ - boolean implicit = false; + private boolean implicit = false; /** * Internal constructor used by CredentialBuilder. @@ -529,7 +533,7 @@ public static class Subject { */ protected Subject(Id id, Map claims) { this.id = id; - this.claims = claims == null ? Collections.emptyMap() : claims; + this.claims = claims == null || claims.isEmpty() ? Map.of() : new LinkedHashMap<>(claims); } /** diff --git a/api/src/main/java/io/bosonnetwork/identifier/CredentialBuilder.java b/api/src/main/java/io/bosonnetwork/identifier/CredentialBuilder.java index a5493db2..c5b8fc41 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/CredentialBuilder.java +++ b/api/src/main/java/io/bosonnetwork/identifier/CredentialBuilder.java @@ -258,9 +258,11 @@ public Credential build() { if (claims.isEmpty()) throw new IllegalStateException("Credential must contain at least one claim"); + // Stamp signedAt before signing so it is covered by the signature. + Date signedAt = now(); Credential unsigned = new Credential(id, types, name, description, identity.getId(), validFrom, validUntil, - subject, claims, null, null); + subject, claims, signedAt, null); byte[] signature = identity.sign(unsigned.getSignData()); - return new Credential(unsigned, now(), signature); + return new Credential(unsigned, signature); } -} +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/DHTRegistry.java b/api/src/main/java/io/bosonnetwork/identifier/DHTRegistry.java index af9f01a9..b725906e 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/DHTRegistry.java +++ b/api/src/main/java/io/bosonnetwork/identifier/DHTRegistry.java @@ -50,15 +50,15 @@ class DHTRegistry implements Registry { private static final Logger log = LoggerFactory.getLogger(DHTRegistry.class); /** - * Constructs a new instance of {@code DHTRegistry}, initializing it with the provided Vert.x instance, - * local DHT node, and a persistent cache for resolving values. + * Constructs a new {@code DHTRegistry} backed by the given DHT node, with an optional Vert.x + * context and an optional persistent cache for resolution results. * - * @param node the local DHT node used for storing and retrieving values; must not be null - * @param vertx the Vert.x instance used for asynchronous operations; must be null - * @param persistentCache the cache instance used to persist resolver data; can be null depending on caching needs - * @throws NullPointerException if {@code vertx} or {@code node} is null + * @param node the local DHT node used for publishing and looking up card values; must not be null + * @param vertx the Vert.x instance used for asynchronous operations; may be null + * @param persistentCache the cache used to persist resolution results across runs; may be null + * @throws NullPointerException if {@code node} is null */ - protected DHTRegistry(Node node, Vertx vertx, ResolverCache persistentCache) { + protected DHTRegistry(Node node, Vertx vertx, ResolutionCache persistentCache) { Objects.requireNonNull(node); this.node = node; diff --git a/api/src/main/java/io/bosonnetwork/identifier/DHTResolver.java b/api/src/main/java/io/bosonnetwork/identifier/DHTResolver.java index de3b5b9e..32b0613f 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/DHTResolver.java +++ b/api/src/main/java/io/bosonnetwork/identifier/DHTResolver.java @@ -91,13 +91,13 @@ public CompletableFuture> resolve(Id id, ResolutionOption // Ensure the id is not null Objects.requireNonNull(id, "id"); - LookupOption lookupOption = options != null && options.usingCache() ? + LookupOption lookupOption = options != null && options.useCache() ? LookupOption.ARBITRARY : LookupOption.OPTIMISTIC; return lookup(id, lookupOption).thenApply(value -> { // If no value found in DHT, return the not found result if (value == null) - return ResolutionResult.notfound(); + return ResolutionResult.notFound(); // Check that the id matches the public key in the retrieved value if (!Objects.equals(id, value.getPublicKey())) @@ -112,6 +112,11 @@ public CompletableFuture> resolve(Id id, ResolutionOption return ResolutionResult.invalid(); } + // The resolved card must be for the requested id (defense-in-depth: isGenuine() already + // binds the card to its own subject, and the value's public key was checked above). + if (!id.equals(card.getId())) + return ResolutionResult.invalid(); + // Verify the Card's signature and integrity if (!card.isGenuine()) return ResolutionResult.invalid(); @@ -120,7 +125,7 @@ public CompletableFuture> resolve(Id id, ResolutionOption int version = value.getSequenceNumber(); // Return a successful resolution result with metadata including signature timestamps and version - return new ResolutionResult<>(card, new ResolutionResultMetadata( + return new ResolutionResult<>(card, new ResolutionMetadata( card.getSignedAt(), card.getSignedAt(), new Date(), false, version)); }); } diff --git a/api/src/main/java/io/bosonnetwork/identifier/DIDConstants.java b/api/src/main/java/io/bosonnetwork/identifier/DIDConstants.java index e3776d42..f1e5d2f3 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/DIDConstants.java +++ b/api/src/main/java/io/bosonnetwork/identifier/DIDConstants.java @@ -56,6 +56,12 @@ public class DIDConstants { /** The default fragment ID for verification methods in DID Documents. */ protected static final String DEFAULT_VERIFICATION_METHOD_FRAGMENT = "default"; - /** Marker object used internally to indicate W3C DID format for Boson IDs. */ + /** + * Internal marker used as a Jackson per-call context-attribute key to select the W3C DID + * representation of a Boson {@code Id} during (de)serialization. Not intended for application use. + */ public static final Object BOSON_ID_FORMAT_W3C = new Object(); + + private DIDConstants() { + } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/DIDDocument.java b/api/src/main/java/io/bosonnetwork/identifier/DIDDocument.java index c9a3b3bf..04f2c648 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/DIDDocument.java +++ b/api/src/main/java/io/bosonnetwork/identifier/DIDDocument.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Collections; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -52,9 +53,9 @@ */ @JsonPropertyOrder({"@context", "id", "verificationMethod", "authentication", "assertion", "verifiableCredential", "service", "proof"}) public class DIDDocument extends W3CDIDFormat { + /** The list of JSON-LD context URIs associated with this DID Document. */ @JsonProperty("@context") @JsonInclude(JsonInclude.Include.NON_EMPTY) - /** The list of JSON-LD context URIs associated with this DID Document. */ private final List contexts; /** The unique identifier (DID) for this document. */ @JsonProperty("id") @@ -85,9 +86,9 @@ public class DIDDocument extends W3CDIDFormat { private final Proof proof; /** - * The internal BosonCard adapter for this document, used for signature/serialization. + * The internal CardView adapter for this document, used for signature/serialization. */ - private transient BosonCard bosonCard; + private transient volatile CardView cardView; /** * Constructs a DIDDocument by deserializing all fields. @@ -116,7 +117,7 @@ public DIDDocument(@JsonProperty(value = "@context") List contexts, Objects.requireNonNull(verificationMethods, "verificationMethods"); Objects.requireNonNull(proof, "proof"); - this.contexts = contexts == null || contexts.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(contexts); + this.contexts = contexts == null || contexts.isEmpty() ? List.of() : List.copyOf(contexts); this.id = id; // Validate that verificationMethods contains only concrete methods (no references) @@ -175,11 +176,11 @@ public DIDDocument(@JsonProperty(value = "@context") List contexts, } } - this.verificationMethods = methods.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(methods); - this.authentications = auths.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(auths); - this.assertions = as.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(as); - this.credentials = credentials == null || credentials.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(credentials); - this.services = services == null || services.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(services); + this.verificationMethods = methods.isEmpty() ? List.of() : List.copyOf(methods); + this.authentications = auths.isEmpty() ? List.of() : List.copyOf(auths); + this.assertions = as.isEmpty() ? List.of() : List.copyOf(as); + this.credentials = credentials == null || credentials.isEmpty() ? List.of() : List.copyOf(credentials); + this.services = services == null || services.isEmpty() ? List.of() : List.copyOf(services); this.proof = proof; } @@ -200,11 +201,11 @@ protected DIDDocument(List contexts, Id id, List ver List credentials, List services) { this.contexts = contexts; this.id = id; - this.verificationMethods = verificationMethods.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(verificationMethods); - this.authentications = authentications == null || authentications.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(authentications); - this.assertions = assertions == null || assertions.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(assertions); - this.credentials = credentials == null || credentials.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(credentials); - this.services = services == null || services.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(services); + this.verificationMethods = verificationMethods.isEmpty() ? List.of() : List.copyOf(verificationMethods); + this.authentications = authentications == null || authentications.isEmpty() ? List.of() : List.copyOf(authentications); + this.assertions = assertions == null || assertions.isEmpty() ? List.of() : List.copyOf(assertions); + this.credentials = credentials == null || credentials.isEmpty() ? List.of() : List.copyOf(credentials); + this.services = services == null || services.isEmpty() ? List.of() : List.copyOf(services); this.proof = null; } @@ -256,8 +257,9 @@ public List getVerificationMethods() { * @return List of matching verification methods */ public List getVerificationMethods(VerificationMethod.Type type) { + Objects.requireNonNull(type, "type"); return verificationMethods.stream() - .filter(vm -> vm.getType()== type) + .filter(vm -> vm.getType() == type) .collect(Collectors.toList()); } @@ -459,6 +461,7 @@ public List getServices() { * @return List of matching services */ public List getServices(String type) { + Objects.requireNonNull(type, "type"); return services.stream() .filter(service -> service.getType().equals(type)) .collect(Collectors.toList()); @@ -560,10 +563,10 @@ public void validate() throws InvalidSignatureException { * @return The Card representation of this DID Document */ public Card toCard() { - if (bosonCard == null) - bosonCard = new BosonCard(this); + if (cardView == null) + cardView = new CardView(this); - return bosonCard; + return cardView; } /** @@ -577,7 +580,7 @@ public Card toCard() { */ public static DIDDocument fromCard(Card card, List documentContexts, Map> vcTypeContexts) { - if (card instanceof BosonCard bc) + if (card instanceof CardView bc) return bc.getDocument(); List contexts = new ArrayList<>(); @@ -604,7 +607,7 @@ public static DIDDocument fromCard(Card card, List documentContexts, new Proof(Proof.Type.Ed25519Signature2020, card.getSignedAt(), defaultMethodRef, Proof.Purpose.assertionMethod, card.getSignature())); - doc.bosonCard = new BosonCard(card, doc); + doc.cardView = new CardView(card, doc); return doc; } @@ -632,8 +635,11 @@ public static DIDDocument fromCard(Card card) { * @return Byte array of signable data */ protected byte[] getSignData() { - BosonCard unsigned = bosonCard != null ? bosonCard : new BosonCard(this, true); - return unsigned.getSignData(); + // At verification time the proof is non-null and provides the signed-at timestamp, + // so the CardView view rebuilds the exact bytes the underlying Card signature covers + // (id, credentials, services, sat). + CardView view = cardView != null ? cardView : new CardView(this); + return view.getSignData(); } @Override @@ -691,15 +697,15 @@ public static DIDDocumentBuilder builder(Identity subject) { * Internal adapter class that wraps a DIDDocument as a {@link Card}. * Used for signature and serialization compatibility with Boson cards. */ - protected static class BosonCard extends Card { + protected static class CardView extends Card { /** The wrapped DIDDocument instance. */ private final DIDDocument doc; /** - * Constructs a BosonCard from a DIDDocument (signed). + * Constructs a CardView from a DIDDocument (signed). * @param doc The DIDDocument to wrap */ - protected BosonCard(DIDDocument doc) { + protected CardView(DIDDocument doc) { super(doc.id, doc.credentials.stream().map(VerifiableCredential::toCredential).collect(Collectors.toList()), doc.services.stream().map(s -> new Card.Service(s.getId(), s.getType(), s.getEndpoint(), s.getProperties())) @@ -710,26 +716,32 @@ protected BosonCard(DIDDocument doc) { } /** - * Constructs a BosonCard from a DIDDocument (unsigned). - * @param doc The DIDDocument to wrap - * @param unsigned Unused marker parameter + * Constructs a sat-stamped, unsigned CardView view of a DIDDocument. + *

      + * Used at sign time by {@link DIDDocumentBuilder} to compute the bytes the signature will + * cover. {@code signedAt} must be set so that it is part of the signed data; the resulting + * proof's {@code created} value should be the same timestamp. + * + * @param doc the DIDDocument to wrap + * @param signedAt the signing timestamp to embed in the signed bytes */ - protected BosonCard(DIDDocument doc, boolean unsigned) { + protected CardView(DIDDocument doc, Date signedAt) { super(doc.id, doc.credentials.stream().map(VerifiableCredential::toCredential).collect(Collectors.toList()), doc.services.stream().map(s -> new Card.Service(s.getId(), s.getType(), s.getEndpoint(), s.getProperties())) - .collect(Collectors.toList())); + .collect(Collectors.toList()), + signedAt); this.doc = doc; } /** - * Constructs a BosonCard from an existing Card and DIDDocument. + * Constructs a CardView from an existing Card and DIDDocument. * @param card The Card to wrap * @param doc The associated DIDDocument */ - protected BosonCard(Card card, DIDDocument doc) { - super(card, card.getSignedAt(), card.getSignature()); + protected CardView(Card card, DIDDocument doc) { + super(card, card.getSignature()); this.doc = doc; } @@ -798,7 +810,7 @@ protected Service(String id, String type, String endpoint, Map p this.id = id; this.type = type; this.endpoint = endpoint; - this.properties = properties == null || properties.isEmpty() ? Collections.emptyMap() : properties; + this.properties = properties == null || properties.isEmpty() ? Map.of() : new LinkedHashMap<>(properties); } /** diff --git a/api/src/main/java/io/bosonnetwork/identifier/DIDDocumentBuilder.java b/api/src/main/java/io/bosonnetwork/identifier/DIDDocumentBuilder.java index 7f23000d..0a76fdbe 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/DIDDocumentBuilder.java +++ b/api/src/main/java/io/bosonnetwork/identifier/DIDDocumentBuilder.java @@ -23,7 +23,7 @@ package io.bosonnetwork.identifier; import java.util.ArrayList; -import java.util.Collections; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -505,11 +505,14 @@ public DIDDocumentBuilder addService(String id, String type, String endpoint, St public DIDDocument build() { DIDDocument unsigned = new DIDDocument(contexts, identity.getId(), new ArrayList<>(verificationMethods.values()), new ArrayList<>(authentications.values()), new ArrayList<>(assertions.values()), - credentials.isEmpty() ? Collections.emptyList() : new ArrayList<>(credentials.values()), - services.isEmpty() ? Collections.emptyList() : new ArrayList<>(services.values())); - - byte[] signature = identity.sign(unsigned.getSignData()); - Proof proof = new Proof(Proof.Type.Ed25519Signature2020, now(), defaultMethodRef, + credentials.isEmpty() ? List.of() : new ArrayList<>(credentials.values()), + services.isEmpty() ? List.of() : new ArrayList<>(services.values())); + + // Stamp signedAt before signing so it is covered by the signature, and use the same + // timestamp as the proof's `created` value so verification reconstructs the same bytes. + Date signedAt = now(); + byte[] signature = identity.sign(new DIDDocument.CardView(unsigned, signedAt).getSignData()); + Proof proof = new Proof(Proof.Type.Ed25519Signature2020, signedAt, defaultMethodRef, Proof.Purpose.assertionMethod, signature); return new DIDDocument(unsigned, proof); diff --git a/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java b/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolutionCache.java similarity index 59% rename from api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java rename to api/src/main/java/io/bosonnetwork/identifier/FileSystemResolutionCache.java index 91a87a64..1718a0d2 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolverCache.java +++ b/api/src/main/java/io/bosonnetwork/identifier/FileSystemResolutionCache.java @@ -23,11 +23,14 @@ package io.bosonnetwork.identifier; import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; +import java.time.Duration; import java.util.Objects; import com.fasterxml.jackson.core.type.TypeReference; @@ -38,7 +41,7 @@ import io.bosonnetwork.json.Json; /** - * A file system-based implementation of {@link ResolverCache} that provides persistent storage + * A file system-based implementation of {@link ResolutionCache} that provides persistent storage * for resolver results. This cache stores each entry as a file in a specified directory, supports * configurable expiration (TTL), and provides methods for inserting, retrieving, cleaning up expired * entries, and clearing the cache. @@ -47,8 +50,8 @@ * named after the associated {@link Id}. *

      */ -class FileSystemResolverCache implements ResolverCache { - private static final long DEFAULT_EXPIRATION = 24 * 60 * 60; +class FileSystemResolutionCache implements ResolutionCache { + private static final long DEFAULT_EXPIRATION = Duration.ofHours(24).toSeconds(); /** * The directory where cache entries are stored. Each cache entry is a file in this directory, @@ -61,47 +64,47 @@ class FileSystemResolverCache implements ResolverCache { */ private final long expiration; // expiration time after write, in seconds - private static final Logger log = LoggerFactory.getLogger(FileSystemResolverCache.class); + private static final Logger log = LoggerFactory.getLogger(FileSystemResolutionCache.class); /** - * Constructs a {@code FileSystemResolverCache} using a specified directory and expiration time. + * Constructs a {@code FileSystemResolutionCache} using a specified directory and expiration time. * If the directory does not exist, it will be created. If the expiration time is non-positive, * a default TTL of 24 hours will be used. * * @param cacheDir the directory in which to store cache files; must be a directory or creatable * @param expiration the expiration time (TTL) for cache entries in seconds; if {@code <= 0}, uses default - * @throws IOException if the directory cannot be created or is not a directory + * @throws ResolutionCacheException if the directory cannot be created or is not a directory */ - public FileSystemResolverCache(Path cacheDir, long expiration) throws IOException { + public FileSystemResolutionCache(Path cacheDir, long expiration) throws ResolutionCacheException { Objects.requireNonNull(cacheDir, "cacheDir"); if (Files.exists(cacheDir)) { if (!Files.isDirectory(cacheDir)) { log.error("Resolver cache path {} exists and is not a directory", cacheDir); - throw new IOException("Resolver cache path " + cacheDir + " exists and is not a directory"); + throw new ResolutionCacheException("Resolver cache path " + cacheDir + " exists and is not a directory"); } } else { try { Files.createDirectories(cacheDir); } catch (IOException e) { log.error("Resolver cache path {} can not be created", cacheDir); - throw new IOException("Resolver cache path " + cacheDir + " can not be created", e); + throw new ResolutionCacheException("Resolver cache path " + cacheDir + " can not be created", e); } } this.cacheDir = cacheDir; this.expiration = expiration <= 0 ? DEFAULT_EXPIRATION : expiration; - log.info("Resolver persistent cache created at {}, TTL: {}", cacheDir, expiration); + log.info("Resolver persistent cache created at {}, TTL: {}s", cacheDir, this.expiration); } /** - * Constructs a {@code FileSystemResolverCache} with the default cache directory and default expiration time. + * Constructs a {@code FileSystemResolutionCache} with the default cache directory and default expiration time. * The default directory is {@code ~/.cache/boson/identifier/resolver} if a home directory is available, * otherwise a directory in the system temporary directory. * - * @throws IOException if the default cache directory cannot be created + * @throws ResolutionCacheException if the default cache directory cannot be created */ - public FileSystemResolverCache() throws IOException { + public FileSystemResolutionCache() throws ResolutionCacheException { this(defaultCacheDir(), DEFAULT_EXPIRATION); } @@ -123,18 +126,25 @@ private static Path defaultCacheDir() { * * @param id the identifier for the cache entry * @param result the resolution result to store - * @throws IOException if writing to the cache file fails + * @throws ResolutionCacheException if writing to the cache file fails */ @Override - public void put(Id id, Resolver.ResolutionResult result) throws IOException { + public void put(Id id, Resolver.ResolutionResult result) throws ResolutionCacheException { try { - // Create or overwrite the cache file for the given Id + // Write to a temporary file, then atomically move it into place, so a concurrent reader + // never observes a partially written entry. Path file = cacheDir.resolve(id.toString()); - Json.cborMapper().writeValue(file.toFile(), result); + Path tmp = cacheDir.resolve(id + ".tmp"); + Json.cborMapper().writeValue(tmp.toFile(), result); + try { + Files.move(tmp, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tmp, file, StandardCopyOption.REPLACE_EXISTING); + } log.debug("Resolver persistent cache entry updated: {}", id); } catch (IOException e) { log.error("Resolver persistent cache entry update failed: {}", id, e); - throw e; + throw new ResolutionCacheException("Resolver persistent cache entry update failed", e); } } @@ -144,10 +154,10 @@ public void put(Id id, Resolver.ResolutionResult result) throws IOExceptio * * @param id the identifier for the cache entry * @return the cached resolution result, or {@code null} if not found or expired - * @throws IOException if reading from the cache file fails + * @throws ResolutionCacheException if reading from the cache file fails */ @Override - public Resolver.ResolutionResult get(Id id) throws IOException { + public Resolver.ResolutionResult get(Id id) throws ResolutionCacheException { try { Path file = cacheDir.resolve(id.toString()); // Check if the cache file exists for this Id @@ -161,16 +171,28 @@ public Resolver.ResolutionResult get(Id id) throws IOException { // If the file is older than the expiration threshold, delete and evict it if (attrs.lastModifiedTime().toMillis() < System.currentTimeMillis() - expiration * 1000) { Files.delete(file); + // noinspection LoggingSimilarMessage log.debug("Resolver persistent cache entry expired and evicted: {}", id); return null; } - // Cache hit: read and return the cached result + // Cache hit: read the cached result + Resolver.ResolutionResult result = Json.cborMapper().readValue(file.toFile(), + new TypeReference>() { }); + + // Re-verify on read: the cache file is a trust boundary (a local actor with write access + // to the cache directory could tamper with it), so a successful entry must still verify. + if (result != null && result.succeeded() && result.getResult() != null && !result.getResult().isGenuine()) { + Files.delete(file); + log.warn("Resolver persistent cache entry failed integrity check and was evicted: {}", id); + return null; + } + log.debug("Resolver persistent cache hit: {}", id); - return Json.cborMapper().readValue(file.toFile(), new TypeReference>() { }); + return result; } catch (IOException e) { log.error("Resolver persistent cache entry read failed: {}", id, e); - throw e; + throw new ResolutionCacheException("Resolver persistent cache entry read failed", e); } } @@ -178,41 +200,52 @@ public Resolver.ResolutionResult get(Id id) throws IOException { * Removes all expired entries from the cache directory. Each file is checked for expiration based * on its last modified time and the configured TTL. Expired files are deleted and a debug log is emitted. * - * @throws IOException if an error occurs while accessing or deleting files + * @throws ResolutionCacheException if an error occurs while accessing or deleting files */ @Override - public void cleanup() throws IOException { - Files.walkFileTree(cacheDir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - // Check if the file is expired according to TTL - if (attrs.lastModifiedTime().toMillis() < System.currentTimeMillis() - expiration * 1000) { - Files.delete(file); - log.debug("Resolver persistent cache entry expired and evicted: {}", file); + public void evictExpired() throws ResolutionCacheException { + try { + Files.walkFileTree(cacheDir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + // Check if the file is expired, according to TTL + if (attrs.lastModifiedTime().toMillis() < System.currentTimeMillis() - expiration * 1000) { + Files.delete(file); + // noinspection LoggingSimilarMessage + log.debug("Resolver persistent cache entry expired and evicted: {}", file); + } + + return FileVisitResult.CONTINUE; } - - return FileVisitResult.CONTINUE; - } - }); + }); + } catch (IOException e) { + log.error("Resolver persistent cache cleanup failed", e); + throw new ResolutionCacheException("Resolver persistent cache cleanup failed", e); + } } /** * Removes all entries from the cache directory, regardless of age or expiration. All cache files * are deleted, and an info log is emitted after clearing. * - * @throws Exception if an error occurs while deleting files + * @throws ResolutionCacheException if an error occurs while deleting files */ @Override - public void clear() throws Exception { - Files.walkFileTree(cacheDir, new SimpleFileVisitor<>() { - @Override - public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { - // Delete every file in the cache directory - Files.delete(file); - return FileVisitResult.CONTINUE; - } - }); + public void clear() throws ResolutionCacheException { + try { + Files.walkFileTree(cacheDir, new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + // Delete every file in the cache directory + Files.delete(file); + return FileVisitResult.CONTINUE; + } + }); - log.info("Resolver persistent cache cleared"); + log.info("Resolver persistent cache cleared"); + } catch (IOException e) { + log.error("Resolver persistent cache clear failed", e); + throw new ResolutionCacheException("Resolver persistent cache clear failed", e); + } } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/Proof.java b/api/src/main/java/io/bosonnetwork/identifier/Proof.java index 3b3b4009..a1ff91e5 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Proof.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Proof.java @@ -120,7 +120,7 @@ protected Proof(@JsonProperty(value = "type", required = true, defaultValue = "E this.type = type; this.created = created; this.proofPurpose = proofPurpose; - this.proofValue = proofValue; + this.proofValue = proofValue.clone(); this.verificationMethod = verificationMethod; } @@ -166,7 +166,7 @@ public Purpose getProofPurpose() { * @return the proof value */ public byte[] getProofValue() { - return proofValue; + return proofValue.clone(); } /** diff --git a/api/src/main/java/io/bosonnetwork/identifier/Registry.java b/api/src/main/java/io/bosonnetwork/identifier/Registry.java index 6589ddc2..e0f5ec00 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Registry.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Registry.java @@ -27,33 +27,62 @@ import io.vertx.core.Vertx; +import io.bosonnetwork.Id; import io.bosonnetwork.Identity; import io.bosonnetwork.Node; /** - * The {@code Registry} interface defines the contract for registering and resolving Boson {@link Card}s. + * Publishes and resolves Boson {@link Card}s. *

      - * Implementations of this interface are responsible for securely registering {@code Card} identities - * and providing mechanisms for their asynchronous resolution. All registration and initialization - * operations are performed asynchronously using {@link CompletableFuture}, enabling non-blocking workflows. + * Persistence semantics are backend-specific. The two typical models: + *

        + *
      • Lease/TTL backends (e.g. DHT). A published entry is held for a fixed TTL + * (~2 hours for the built-in DHT registry) and must be re-published periodically to remain + * resolvable. To "remove" an entry, stop re-publishing it and let the lease expire.
      • + *
      • Append-only backends (e.g. blockchain). Each call is a new transaction; + * entries do not auto-expire. Updates supersede prior versions ordered by the + * {@code version} sequence number. Removal, if supported at all, is backend-specific + * (e.g. publishing a tombstone record).
      • + *
      *

      - * This interface also exposes a default Distributed Hash Table (DHT) based registry implementation - * via the static DHTRegistry(...) method. + * What this interface intentionally does not include: + *

        + *
      • No {@code unregister}/{@code remove}. Removal is a backend concern (TTL + * expiry, tombstone, etc.), not a publication-API concern.
      • + *
      • No {@code exists} probe. Existence is whatever + * {@link Resolver#resolve(Id)} reports right now; resolve and inspect the + * {@link Resolver.ResolutionStatus status}.
      • + *
      • No registration receipt. {@code register} returns + * {@code CompletableFuture}; successful completion is the receipt, and the caller + * already knows the {@code version} it submitted.
      • + *
      + *

      + * All operations are asynchronous via {@link CompletableFuture}. The built-in DHT-based + * implementation is reachable through the {@link #DHTRegistry(Node) DHTRegistry(...)} factories. */ public interface Registry { /** - * Registers the {@code Card} as Value in the Distributed Hash Table (DHT). + * Publishes a signed {@link Card} so it can be resolved by its {@link Id}. The persistence + * model is backend-specific (see the {@linkplain Registry class Javadoc}): + *

        + *
      • On a lease/TTL backend (DHT) the published entry expires after the transport's + * TTL unless the owner calls {@code register} again periodically; reusing the same + * {@code version} refreshes the lease, a larger {@code version} publishes updated contents.
      • + *
      • On an append-only backend (blockchain) the call appends a transaction; entries + * do not expire and {@code version} (a sequence number) orders updates per the backend's rules.
      • + *
      + * Implementations should reject the call when the card is not genuine + * ({@link Card#isGenuine()}) or when {@code identity.getId() != card.getId()}; both indicate + * caller misuse. *

      - * The registration is performed asynchronously and requires a cryptographic - * signature to ensure the authenticity and integrity of the {@code Card} data. - * The {@code nonce} and {@code version} parameters provide replay protection and versioning. - *

      + * No receipt is returned: successful completion of the future is the receipt, and the caller + * already knows the {@code version} it submitted. * - * @param identity the {@code Identity} to register, representing the entity owning the {@code Card} - * @param card the {@code Card} containing cryptographic data and metadata to be stored - * @param version the version number associated with the {@code Card}; must be a positive integer - * @return a {@code CompletableFuture} indicating the completion of the registration process, - * which resolves successfully when the {@code Card} is stored or exceptionally if an error occurs + * @param identity the identity that owns the card (must match {@code card.getId()}) + * @param card the signed {@code Card} to publish + * @param version the publication sequence number ({@code >= 0}; backend-specific ordering rules apply) + * @return a future that completes successfully when the card is published, or completes + * exceptionally if the backend fails */ CompletableFuture register(Identity identity, Card card, int version); @@ -72,10 +101,10 @@ public interface Registry { * * @param node the {@code Node} instance representing the local DHT node; must not be null * @param vertx the {@code Vertx} instance to be used for asynchronous operations; may be null. - * @param persistentCache the {@code ResolverCache} implementation to be used for caching resolved entries; may be null + * @param persistentCache the {@code ResolutionCache} implementation to be used for caching resolved entries; may be null * @return a new instance of a DHT-based {@code Registry} */ - static Registry DHTRegistry(Node node, Vertx vertx, ResolverCache persistentCache) { + static Registry DHTRegistry(Node node, Vertx vertx, ResolutionCache persistentCache) { Objects.requireNonNull(node, "node"); return new DHTRegistry(node, vertx, persistentCache); } @@ -84,10 +113,10 @@ static Registry DHTRegistry(Node node, Vertx vertx, ResolverCache persistentCach * Creates a new instance of a Distributed Hash Table (DHT)-based {@code Registry}. * * @param node the {@code Node} instance representing the local DHT node; must not be null - * @param persistentCache the {@code ResolverCache} implementation to be used for caching resolved entries; may be null + * @param persistentCache the {@code ResolutionCache} implementation to be used for caching resolved entries; may be null * @return a new instance of a DHT-based {@code Registry} */ - static Registry DHTRegistry(Node node, ResolverCache persistentCache) { + static Registry DHTRegistry(Node node, ResolutionCache persistentCache) { return new DHTRegistry(node, null, persistentCache); } diff --git a/api/src/main/java/io/bosonnetwork/identifier/ResolutionCache.java b/api/src/main/java/io/bosonnetwork/identifier/ResolutionCache.java new file mode 100644 index 00000000..90611672 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/identifier/ResolutionCache.java @@ -0,0 +1,107 @@ +/* + * 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.identifier; + +import java.nio.file.Path; + +import io.bosonnetwork.Id; + +/** + * A pluggable cache for {@link Resolver.ResolutionResult resolution results} of Boson + * {@link Card}s, keyed by {@link Id}. + *

      + * Implementations cache full resolution results (the resolved {@code Card} plus its + * {@link Resolver.ResolutionMetadata metadata}) so the resolver can short-circuit a + * repeat lookup. Cache misses, expired entries, and storage errors leave it to the caller to + * resolve from the underlying source. + *

      + * Implementations may be in-memory, on disk, or remote; they may purge expired entries lazily + * on access and/or expose an explicit {@link #evictExpired()} hook. + *

      + * Use {@link #fileSystem(Path, long)} or {@link #fileSystem()} for the built-in on-disk + * implementation. + */ +public interface ResolutionCache { + /** + * Stores a resolution result for the given {@link Id}, replacing any previous entry. + * Implementations should only persist results the caller considers authoritative; negative + * or invalid results are typically not worth keeping. + * + * @param id the identifier being cached + * @param result the resolution result to cache (the resolved Card and its metadata) + * @throws ResolutionCacheException if storing fails (I/O error, serialization error, etc.) + */ + void put(Id id, Resolver.ResolutionResult result) throws ResolutionCacheException; + + /** + * Returns the cached resolution result for the given {@link Id}, or {@code null} if absent + * or expired. Implementations may purge expired entries as a side-effect of this lookup. + * + * @param id the identifier whose cached result is requested + * @return the cached resolution result, or {@code null} if no valid entry exists + * @throws ResolutionCacheException if retrieval fails (I/O error, deserialization error, etc.) + */ + Resolver.ResolutionResult get(Id id) throws ResolutionCacheException; + + /** + * Evicts entries whose expiration has passed. Implementations that already purge lazily on + * {@link #get(Id)} may treat this as an explicit sweep hook (e.g., for a scheduled task). + * + * @throws ResolutionCacheException if eviction fails (e.g., I/O error) + */ + void evictExpired() throws ResolutionCacheException; + + /** + * Removes every entry from the cache. For persistent implementations this also removes the + * underlying storage (e.g., deletes all cache files). + * + * @throws ResolutionCacheException if clearing fails (e.g., I/O error) + */ + void clear() throws ResolutionCacheException; + + /** + * Creates an on-disk {@link ResolutionCache} backed by the given directory. + * Entries expire after {@code expiration} seconds; expired entries are purged lazily on + * {@link #get(Id)} and can also be swept via {@link #evictExpired()}. + * + * @param cacheDir the directory in which to store cache files (created if absent) + * @param expiration the entry time-to-live in seconds; values {@code <= 0} fall back to a 24-hour default + * @return a file-system backed cache + * @throws ResolutionCacheException if the cache cannot be created (e.g., the directory is inaccessible) + */ + static ResolutionCache fileSystem(Path cacheDir, long expiration) throws ResolutionCacheException { + return new FileSystemResolutionCache(cacheDir, expiration); + } + + /** + * Creates an on-disk {@link ResolutionCache} using built-in defaults: directory + * {@code ~/.cache/boson/identifier/resolver} (or {@code java.io.tmpdir/boson-resolver-cache} + * when the user has no home directory) and a 24-hour expiration. + * + * @return a file-system backed cache with default settings + * @throws ResolutionCacheException if the cache cannot be created + */ + static ResolutionCache fileSystem() throws ResolutionCacheException { + return new FileSystemResolutionCache(); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/ResolutionCacheException.java b/api/src/main/java/io/bosonnetwork/identifier/ResolutionCacheException.java new file mode 100644 index 00000000..12980190 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/identifier/ResolutionCacheException.java @@ -0,0 +1,73 @@ +/* + * 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.identifier; + +/** + * Thrown by {@link ResolutionCache} implementations when a cache operation + * ({@code put}, {@code get}, {@code evictExpired}, {@code clear}, or construction) cannot be + * completed — typically due to I/O failures or (de)serialization errors. + *

      + * Part of the resolver exception hierarchy ({@link io.bosonnetwork.BosonException} → + * {@link RegistryException} → {@link ResolverException} → {@code ResolutionCacheException}), + * so it is a checked exception. {@link CachedResolver} treats it as recoverable: a cache failure is logged and the + * call falls through to the underlying resolver rather than propagating to the caller. + */ +public class ResolutionCacheException extends ResolverException { + private static final long serialVersionUID = 7301217164571775472L; + + /** + * Constructs a new {@code ResolutionCacheException} with {@code null} as its detail message. + */ + public ResolutionCacheException() { + super(); + } + + /** + * Constructs a new {@code ResolutionCacheException} with the specified detail message. + * + * @param message the detail message that explains the reason for the exception + */ + public ResolutionCacheException(String message) { + super(message); + } + + /** + * Constructs a new {@code ResolutionCacheException} with the specified detail message + * and cause. + * + * @param message the detail message that explains the reason for the exception + * @param cause the cause of the exception (may be {@code null}) + */ + public ResolutionCacheException(String message, Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new {@code ResolutionCacheException} with the specified cause. + * + * @param cause the cause of the exception (may be {@code null}) + */ + public ResolutionCacheException(Throwable cause) { + super(cause); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/Resolver.java b/api/src/main/java/io/bosonnetwork/identifier/Resolver.java index 2c101b18..cd673615 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Resolver.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Resolver.java @@ -116,17 +116,17 @@ public static ResolutionStatus from(int code) { */ class ResolutionOptions { // Default options: use cache, no TTL (0 disables TTL). - private static final ResolutionOptions DEFAULT = new ResolutionOptions(true, 0); // no cache + private static final ResolutionOptions DEFAULT = new ResolutionOptions(true, 0); - private final boolean usingCache; + private final boolean useCache; private final long validTTL; /** - * @param usingCache whether to use cached results if available + * @param useCache whether to use cached results if available * @param validTTL time-to-live for the cached result in milliseconds (0 disables TTL) */ - public ResolutionOptions(boolean usingCache, long validTTL) { - this.usingCache = usingCache; + public ResolutionOptions(boolean useCache, long validTTL) { + this.useCache = useCache; this.validTTL = validTTL; } @@ -134,8 +134,8 @@ public ResolutionOptions(boolean usingCache, long validTTL) { * Whether to use cached results if available. * @return true if cache should be used, false otherwise */ - public boolean usingCache() { - return usingCache; + public boolean useCache() { + return useCache; } /** @@ -160,7 +160,7 @@ public static ResolutionOptions defaultOptions() { * Metadata describing the resolved result, such as creation date, update date, resolution time, * deactivation status, and version. */ - class ResolutionResultMetadata { + class ResolutionMetadata { @JsonProperty("created") private final Date created; @JsonProperty("updated") @@ -179,7 +179,7 @@ class ResolutionResultMetadata { * @param deactivated whether the resource is deactivated * @param version version number of the resource */ - public ResolutionResultMetadata(Date created, Date updated, Date resolved, boolean deactivated, int version) { + public ResolutionMetadata(Date created, Date updated, Date resolved, boolean deactivated, int version) { this.created = created; this.updated = updated; this.resolved = resolved; @@ -239,7 +239,7 @@ class ResolutionResult { @JsonProperty("result") private final T result; @JsonProperty("resultMetadata") - private final ResolutionResultMetadata metadata; + private final ResolutionMetadata metadata; /** * Constructs a resolution result with the given status, result, and metadata. @@ -247,7 +247,7 @@ class ResolutionResult { * @param result the resolved object, or null if not found or invalid * @param metadata metadata about the resolved object */ - public ResolutionResult(ResolutionStatus status, T result, ResolutionResultMetadata metadata) { + public ResolutionResult(ResolutionStatus status, T result, ResolutionMetadata metadata) { this.status = status; this.result = result; this.metadata = metadata; @@ -258,7 +258,7 @@ public ResolutionResult(ResolutionStatus status, T result, ResolutionResultMetad * @param result the resolved object * @param metadata metadata about the resolved object */ - public ResolutionResult(T result, ResolutionResultMetadata metadata) { + public ResolutionResult(T result, ResolutionMetadata metadata) { this(ResolutionStatus.SUCCESS, result, metadata); } @@ -279,7 +279,7 @@ public T getResult() { /** * @return metadata about the resolved object */ - public ResolutionResultMetadata getResultMetadata() { + public ResolutionMetadata getResultMetadata() { return metadata; } @@ -303,7 +303,7 @@ public boolean failed() { * @return a not found result */ @SuppressWarnings("unchecked") - public static ResolutionResult notfound() { + public static ResolutionResult notFound() { return (ResolutionResult) NOT_FOUND; } @@ -348,7 +348,7 @@ default CompletableFuture> resolve(Id id) { * @param options options controlling caching and TTL * @return a future containing the resolution result (status, DID document, and metadata) */ - default CompletableFuture> resolveDID(Id id, ResolutionOptions options) { + default CompletableFuture> resolveDocument(Id id, ResolutionOptions options) { Objects.requireNonNull(id, "id"); // First resolve the Card, then map to a DIDDocument if successful @@ -367,8 +367,8 @@ default CompletableFuture> resolveDID(Id id, Resol * @param id the Boson ID to resolve * @return a future containing the resolution result (status, DID document, and metadata) */ - default CompletableFuture> resolveDID(Id id) { + default CompletableFuture> resolveDocument(Id id) { // Use default options (cache enabled, no TTL) if not specified. - return resolveDID(id, ResolutionOptions.defaultOptions()); + return resolveDocument(id, ResolutionOptions.defaultOptions()); } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/ResolverCache.java b/api/src/main/java/io/bosonnetwork/identifier/ResolverCache.java deleted file mode 100644 index 94addcda..00000000 --- a/api/src/main/java/io/bosonnetwork/identifier/ResolverCache.java +++ /dev/null @@ -1,106 +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.identifier; - -import java.nio.file.Path; - -import io.bosonnetwork.Id; - -/** - * A pluggable cache interface for resolved Boson {@link Card}s. - *

      - * Implementations of this interface provide caching for the results of resolving - * {@link Id} entities to their corresponding {@link Card} objects, typically to - * improve resolution performance and reduce redundant network or computation cost. - *

      - * The cache may be in-memory, persistent (e.g., file-system based), or distributed. - * Implementations may support cache expiration and/or eviction policies. - *

      - * The provided static factory methods allow creation of a file-system based cache - * implementation, which persists cache entries to disk and supports expiration. - */ -public interface ResolverCache { - /** - * Stores the resolution result for a given {@link Id} in the cache. - * - * @param id the identifier for which the result is being cached - * @param result the resolved {@link Card} result to cache - * @throws Exception if storing the result fails (e.g., I/O error or serialization error) - */ - void put(Id id, Resolver.ResolutionResult result) throws Exception; - - /** - * Retrieves the cached resolution result for a given {@link Id}. - * - * @param id the identifier whose cached result is requested - * @return the cached {@link Resolver.ResolutionResult} for the given id, - * or {@code null} if no valid entry exists or the entry has expired - * @throws Exception if retrieval fails (e.g., I/O error or deserialization error) - */ - Resolver.ResolutionResult get(Id id) throws Exception; - - /** - * Performs cache cleanup, such as removing expired entries or reclaiming resources. - *

      - * For file-system based caches, this may delete files whose entries have expired. - * - * @throws Exception if cleanup fails (e.g., I/O error) - */ - void cleanup() throws Exception; - - /** - * Clears all entries from the cache. - *

      - * For persistent caches, this typically removes all stored data (e.g., deletes all cache files). - * - * @throws Exception if clearing fails (e.g., I/O error) - */ - void clear() throws Exception; - - /** - * Creates a file-system based {@link ResolverCache} instance. - *

      - * Cache entries are persisted to the specified directory and will expire after the given duration. - * Expired entries are automatically purged on access or via {@link #cleanup()}. - * - * @param cacheDir the directory to store cache files - * @param expiration the duration in milliseconds after which cached entries expire - * @return a file-system based cache instance - * @throws Exception if the cache cannot be created (e.g., directory inaccessible) - */ - static ResolverCache fileSystem(Path cacheDir, long expiration) throws Exception { - return new FileSystemResolverCache(cacheDir, expiration); - } - - /** - * Creates a file-system based {@link ResolverCache} instance with default directory and expiration. - *

      - * The default location and expiration policy are implementation-defined. - * - * @return a file-system based cache instance with default settings - * @throws Exception if the cache cannot be created - */ - static ResolverCache fileSystem() throws Exception { - return new FileSystemResolverCache(); - } -} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredential.java b/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredential.java index 2a057f5a..5215c463 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredential.java +++ b/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredential.java @@ -121,9 +121,9 @@ public class VerifiableCredential extends W3CDIDFormat { private final Proof proof; /** - * A transient BosonCredential representation of this verifiable credential. + * A transient CredentialView representation of this verifiable credential. */ - private transient BosonCredential bosonCredential; + private transient volatile CredentialView credentialView; /** * Internal constructor used by JSON deserializer. @@ -156,9 +156,9 @@ protected VerifiableCredential(@JsonProperty(value = "@context") List co Objects.requireNonNull(subject, "subject"); Objects.requireNonNull(proof, "proof"); - this.contexts = contexts == null || contexts.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(contexts); + this.contexts = contexts == null || contexts.isEmpty() ? List.of() : List.copyOf(contexts); this.id = id; - this.types = types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types.isEmpty() ? List.of() : List.copyOf(types); this.name = name; this.description = description; this.issuer = issuer; @@ -185,9 +185,9 @@ protected VerifiableCredential(@JsonProperty(value = "@context") List co */ protected VerifiableCredential(List contexts, String id, List types, String name, String description, Id issuer, Date validFrom, Date validUntil, Id subject, Map claims) { - this.contexts = contexts == null || contexts.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(contexts); + this.contexts = contexts == null || contexts.isEmpty() ? List.of() : List.copyOf(contexts); this.id = id; - this.types = types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types.isEmpty() ? List.of() : List.copyOf(types); this.name = name; this.description = description; this.issuer = issuer; @@ -307,12 +307,12 @@ public Proof getProof() { } /** - * Determines if this credential is self-issued, i.e., the subject is the same as the issuer or subject id is null. + * Determines if this credential is self-issued, i.e., the subject is the same as the issuer. * * @return true if self-issued, false otherwise */ public boolean selfIssued() { - return subject.getId() == null || subject.getId().equals(issuer); + return Objects.equals(subject.getId(), issuer); } /** @@ -344,8 +344,14 @@ public boolean isGenuine() { if (proof == null) return false; - // Verify the proof using the issuer's identity and signing data - return proof.verify(issuer, getSignData()); + try { + // Verify the proof using the issuer's identity and signing data + return proof.verify(issuer, getSignData()); + } catch (RuntimeException e) { + // A malformed id (e.g. a non-DID-URL "id") makes the sign-data reconstruction throw; + // treat that as "not genuine" rather than propagating an exception from verification. + return false; + } } /** @@ -373,10 +379,10 @@ public void validate() throws BeforeValidPeriodException, ExpiredException, Inva * @return the Credential object */ public Credential toCredential() { - if (bosonCredential == null) - bosonCredential = new BosonCredential(this); + if (credentialView == null) + credentialView = new CredentialView(this); - return bosonCredential; + return credentialView; } /** @@ -388,7 +394,7 @@ public Credential toCredential() { */ public static VerifiableCredential fromCredential(Credential credential, Map> typeContexts) { Objects.requireNonNull(credential, "credential"); - if (credential instanceof BosonCredential vcCard) + if (credential instanceof CredentialView vcCard) return vcCard.vc; List contexts = new ArrayList<>(); @@ -425,7 +431,7 @@ public static VerifiableCredential fromCredential(Credential credential, Map claims) { this.id = id; - this.claims = claims == null ? Collections.emptyMap() : claims; + this.claims = claims == null || claims.isEmpty() ? Map.of() : new LinkedHashMap<>(claims); } /** @@ -603,15 +609,15 @@ public boolean equals(Object o) { *

      * This class is used internally to optimize storage and signing operations. */ - protected static class BosonCredential extends Credential { + protected static class CredentialView extends Credential { private final VerifiableCredential vc; /** - * Constructs a BosonCredential from a VerifiableCredential. + * Constructs a CredentialView from a VerifiableCredential. * * @param vc the source verifiable credential */ - protected BosonCredential(VerifiableCredential vc) { + protected CredentialView(VerifiableCredential vc) { super(DIDURL.create(vc.id).getFragment(), vc.types.stream().filter(t -> !t.equals(DIDConstants.DEFAULT_VC_TYPE)).collect(Collectors.toList()), vc.name, vc.description, vc.issuer, vc.validFrom, vc.validUntil, @@ -623,13 +629,32 @@ protected BosonCredential(VerifiableCredential vc) { } /** - * Constructs a BosonCredential from a Credential and associates it with a VerifiableCredential. + * Constructs a CredentialView from a Credential and associates it with a VerifiableCredential. * * @param cred the source credential * @param vc the associated verifiable credential */ - protected BosonCredential(Credential cred, VerifiableCredential vc) { - super(cred, cred.getSignedAt(), cred.getSignature()); + protected CredentialView(Credential cred, VerifiableCredential vc) { + super(cred, cred.getSignature()); + this.vc = vc; + } + + /** + * Constructs a sat-stamped, unsigned CredentialView view of a VerifiableCredential. + *

      + * Used at sign time by {@link VerifiableCredentialBuilder} to compute the bytes the + * signature will cover. {@code signedAt} must be set so that it is part of the signed + * data; the resulting proof's {@code created} value should be the same timestamp. + * + * @param vc the source verifiable credential (proof may be null) + * @param signedAt the signing timestamp to embed in the signed bytes + */ + protected CredentialView(VerifiableCredential vc, Date signedAt) { + super(DIDURL.create(vc.id).getFragment(), + vc.types.stream().filter(t -> !t.equals(DIDConstants.DEFAULT_VC_TYPE)).collect(Collectors.toList()), + vc.name, vc.description, vc.issuer, vc.validFrom, vc.validUntil, + vc.subject.id, vc.subject.claims, + signedAt, null); this.vc = vc; } diff --git a/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredentialBuilder.java b/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredentialBuilder.java index 395d3af0..3acc2bc7 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredentialBuilder.java +++ b/api/src/main/java/io/bosonnetwork/identifier/VerifiableCredentialBuilder.java @@ -282,8 +282,11 @@ public VerifiableCredential build() { VerifiableCredential unsigned = new VerifiableCredential(contexts, idUrl.toString(), types, name, description, issuer, validFrom, validUntil, subject, claims); - byte[] signature = identity.sign(unsigned.getSignData()); - Proof proof = new Proof(Proof.Type.Ed25519Signature2020, now(), + // Stamp signedAt before signing so it is covered by the signature, and use the same + // timestamp as the proof's `created` value so verification reconstructs the same bytes. + Date signedAt = now(); + byte[] signature = identity.sign(new VerifiableCredential.CredentialView(unsigned, signedAt).getSignData()); + Proof proof = new Proof(Proof.Type.Ed25519Signature2020, signedAt, VerificationMethod.defaultReferenceOf(issuer), Proof.Purpose.assertionMethod, signature); return new VerifiableCredential(unsigned, proof); diff --git a/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentation.java b/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentation.java index 02e08c9b..d529045d 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentation.java +++ b/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentation.java @@ -1,7 +1,29 @@ +/* + * 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.identifier; import java.util.ArrayList; -import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Map; import java.util.Objects; @@ -51,7 +73,7 @@ public class VerifiablePresentation extends W3CDIDFormat { private final Proof proof; /** Transient compact Boson Vouch representation */ - private transient BosonVouch bosonVouch; + private transient volatile VouchView vouchView; /** * Internal constructor used by JSON deserializer to create a VerifiablePresentation instance. @@ -74,11 +96,11 @@ protected VerifiablePresentation(@JsonProperty(value = "@context") List Objects.requireNonNull(credentials, "credentials"); Objects.requireNonNull(proof, "proof"); - this.contexts = contexts == null || contexts.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(contexts); + this.contexts = contexts == null || contexts.isEmpty() ? List.of() : List.copyOf(contexts); this.id = id; - this.types = types == null || types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types == null || types.isEmpty() ? List.of() : List.copyOf(types); this.holder = holder; - this.credentials = Collections.unmodifiableList(credentials); + this.credentials = List.copyOf(credentials); this.proof = proof; } @@ -93,11 +115,11 @@ protected VerifiablePresentation(@JsonProperty(value = "@context") List * @param credentials list of verifiable credentials */ protected VerifiablePresentation(List contexts, String id, List types, Id holder, List credentials) { - this.contexts = contexts == null || contexts.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(contexts); + this.contexts = contexts == null || contexts.isEmpty() ? List.of() : List.copyOf(contexts); this.id = id; - this.types = types == null || types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types == null || types.isEmpty() ? List.of() : List.copyOf(types); this.holder = holder; - this.credentials = Collections.unmodifiableList(credentials); + this.credentials = List.copyOf(credentials); this.proof = null; } @@ -219,8 +241,13 @@ public Proof getProof() { } /** - * Validates the cryptographic proof of the presentation. - * Throws InvalidSignatureException if the proof is invalid or missing. + * Validates the presentation: the holder's proof and the signature of every embedded + * credential must all verify. Throws InvalidSignatureException if any of them is invalid + * or the proof is missing. + *

      + * Note: this verifies signatures only. The validity period (validFrom/validUntil) + * of the embedded credentials is a caller policy and is NOT checked here; call + * {@link VerifiableCredential#validate()} on the individual credentials if needed. * * @throws InvalidSignatureException if signature verification fails */ @@ -230,15 +257,29 @@ public void validate() throws InvalidSignatureException { } /** - * Checks whether the cryptographic proof is valid and genuine. + * Checks whether this presentation is genuine: the holder's proof verifies and every + * embedded {@link VerifiableCredential} is itself genuine. The holder's envelope signature + * alone is not sufficient, since it does not attest to the authenticity of the contained + * credentials. + *

      + * This checks signatures only, not the validity period of the embedded credentials. * - * @return true if the proof is present and verifies correctly; false otherwise + * @return true if the holder proof and all embedded credential signatures verify; false otherwise */ public boolean isGenuine() { if (proof == null) return false; - return proof.verify(holder, getSignData()); + try { + if (!proof.verify(holder, getSignData())) + return false; + } catch (RuntimeException e) { + // Malformed id makes sign-data reconstruction throw; treat as not genuine. + return false; + } + + // The envelope is authentic; now require every embedded credential to be genuine too. + return credentials.stream().allMatch(VerifiableCredential::isGenuine); } /** @@ -247,10 +288,10 @@ public boolean isGenuine() { * @return Vouch representation of this presentation */ public Vouch toVouch() { - if (bosonVouch == null) - bosonVouch = new BosonVouch(this); + if (vouchView == null) + vouchView = new VouchView(this); - return bosonVouch; + return vouchView; } /** @@ -263,7 +304,7 @@ public Vouch toVouch() { */ public static VerifiablePresentation fromVouch(Vouch vouch, Map> typeContexts) { Objects.requireNonNull(vouch, "vouch"); - if (vouch instanceof BosonVouch bv) + if (vouch instanceof VouchView bv) return bv.getVerifiablePresentation(); List contexts = new ArrayList<>(); @@ -304,7 +345,7 @@ public static VerifiablePresentation fromVouch(Vouch vouch, Map !t.equals(DIDConstants.DEFAULT_VP_TYPE)).collect(Collectors.toList()), @@ -400,29 +443,33 @@ protected BosonVouch(VerifiablePresentation vp) { } /** - * Constructs an unsigned BosonVouch from a VerifiablePresentation. - * This constructor is used for signature generation where proof is absent. + * Constructs a sat-stamped, unsigned VouchView view of a VerifiablePresentation. + *

      + * Used at sign time by {@link VerifiablePresentationBuilder} to compute the bytes the + * signature will cover. {@code signedAt} must be set so that it is part of the signed + * data; the resulting proof's {@code created} value should be the same timestamp. * - * @param vp the VerifiablePresentation instance - * @param unsigned unused boolean flag to differentiate constructor + * @param vp the VerifiablePresentation instance (proof may be null) + * @param signedAt the signing timestamp to embed in the signed bytes */ - protected BosonVouch(VerifiablePresentation vp, boolean unsigned) { + protected VouchView(VerifiablePresentation vp, Date signedAt) { super(vp.id == null ? null : DIDURL.create(vp.id).getFragment(), vp.types.stream().filter(t -> !t.equals(DIDConstants.DEFAULT_VP_TYPE)).collect(Collectors.toList()), vp.holder, - vp.credentials.stream().map(VerifiableCredential::toCredential).collect(Collectors.toList())); + vp.credentials.stream().map(VerifiableCredential::toCredential).collect(Collectors.toList()), + signedAt); this.vp = vp; } /** - * Constructs a BosonVouch from an existing Vouch and associated VerifiablePresentation. + * Constructs a VouchView from an existing Vouch and associated VerifiablePresentation. * * @param vouch the existing Vouch instance * @param vp the associated VerifiablePresentation */ - protected BosonVouch(Vouch vouch, VerifiablePresentation vp) { - super(vouch, vouch.getSignedAt(), vouch.getSignature()); + protected VouchView(Vouch vouch, VerifiablePresentation vp) { + super(vouch, vouch.getSignature()); this.vp = vp; } diff --git a/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentationBuilder.java b/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentationBuilder.java index fdee3d8e..671565c6 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentationBuilder.java +++ b/api/src/main/java/io/bosonnetwork/identifier/VerifiablePresentationBuilder.java @@ -1,6 +1,29 @@ +/* + * 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.identifier; import java.util.ArrayList; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -321,14 +344,15 @@ public VerifiablePresentation build() { List credentials = new ArrayList<>(this.credentials.values()); // Create unsigned VerifiablePresentation object VerifiablePresentation unsigned = new VerifiablePresentation(contexts, id, types, identity.getId(), credentials); - // Sign the presentation data with holder's identity key - byte[] signature = identity.sign(unsigned.getSignData()); - // Create cryptographic proof object with signature - Proof proof = new Proof(Proof.Type.Ed25519Signature2020, now(), + // Stamp signedAt before signing so it is covered by the signature, and use the same + // timestamp as the proof's `created` value so verification reconstructs the same bytes. + Date signedAt = now(); + byte[] signature = identity.sign(new VerifiablePresentation.VouchView(unsigned, signedAt).getSignData()); + Proof proof = new Proof(Proof.Type.Ed25519Signature2020, signedAt, VerificationMethod.defaultReferenceOf(identity.getId()), Proof.Purpose.assertionMethod, signature); // Return the VerifiablePresentation with attached proof return new VerifiablePresentation(unsigned, proof); } -} +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/VerificationMethod.java b/api/src/main/java/io/bosonnetwork/identifier/VerificationMethod.java index bfbe7c68..5e06e457 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/VerificationMethod.java +++ b/api/src/main/java/io/bosonnetwork/identifier/VerificationMethod.java @@ -310,7 +310,7 @@ public boolean equals(Object o) { */ static class Reference extends VerificationMethod { private final String id; - private VerificationMethod entity; + private volatile VerificationMethod entity; /** * Constructs a reference to a verification method by its ID. diff --git a/api/src/main/java/io/bosonnetwork/identifier/Vouch.java b/api/src/main/java/io/bosonnetwork/identifier/Vouch.java index 27582504..3d9d2005 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/Vouch.java +++ b/api/src/main/java/io/bosonnetwork/identifier/Vouch.java @@ -24,7 +24,6 @@ import java.io.IOException; import java.util.Arrays; -import java.util.Collections; import java.util.Date; import java.util.List; import java.util.Objects; @@ -43,17 +42,24 @@ import io.bosonnetwork.json.Json; /** - * Represents the Boson compacted version of a Verifiable Presentation (VP). + * Boson's compact form of a {@link VerifiablePresentation}. *

      - * This class provides a compact form for transmitting and storing a Verifiable Presentation, - * mapping the standard VP fields to short JSON/CBOR keys as follows: + * A vouch is the holder's signed affirmation — "I, the holder, vouch for these credentials" + * — packaged in a short, CBOR/JSON-friendly layout. It mirrors {@code VerifiablePresentation} + * (the W3C form) the same way {@link Card} mirrors {@link DIDDocument}: same content, shorter keys + * and no JSON-LD vocabulary, so it's cheap to ship over the wire or pin in storage. + *

      + * Convert with {@link VerifiablePresentation#toVouch()} and {@link VerifiablePresentation#fromVouch(Vouch, java.util.Map)}. + * The signature covers everything except itself, including {@code signedAt}. + *

      + * Compact key map: *

        - *
      • {@code id} → {@code "id"}: The unique identifier for the presentation.
      • - *
      • {@code types} → {@code "t"}: The types associated with the presentation.
      • - *
      • {@code holder} → {@code "h"}: The identifier of the entity presenting the credentials.
      • - *
      • {@code credentials} → {@code "c"}: The list of credentials included in the presentation.
      • - *
      • {@code signedAt} → {@code "sat"}: The timestamp when the presentation was signed.
      • - *
      • {@code signature} → {@code "sig"}: The signature over the presentation data.
      • + *
      • {@code id} → {@code "id"}: the unique identifier (optional).
      • + *
      • {@code types} → {@code "t"}: presentation types.
      • + *
      • {@code holder} → {@code "h"}: the holder's {@link Id}.
      • + *
      • {@code credentials} → {@code "c"}: the credentials being presented.
      • + *
      • {@code signedAt} → {@code "sat"}: when the holder signed.
      • + *
      • {@code signature} → {@code "sig"}: holder Ed25519 signature.
      • *
      */ @JsonPropertyOrder({"id", "t", "h", "c", "sat", "sig"}) @@ -131,31 +137,33 @@ protected Vouch(@JsonProperty(value = "id") String id, Objects.requireNonNull(signature, "signature"); this.id = id; - // Defensive: always wrap as unmodifiable list (or empty) - this.types = types == null || types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + // Defensive: copy and wrap as unmodifiable list (or empty) + this.types = types == null || types.isEmpty() ? List.of() : List.copyOf(types); this.holder = holder; - this.credentials = Collections.unmodifiableList(credentials); + this.credentials = List.copyOf(credentials); this.signedAt = signedAt; - this.signature = signature; + this.signature = signature.clone(); } /** - * Internal constructor used by {@link VouchBuilder}. + * Internal constructor for a sat-stamped but unsigned Vouch. *

      - * The caller should transfer ownership of the collections to the new instance. - * Used for building unsigned {@code Vouch} objects. + * Used by the W3C adapter ({@link VerifiablePresentation.VouchView}) at sign time to build + * the bytes the signature will cover. {@code signedAt} must be set before signing because it + * is part of the signed data. * - * @param id the unique identifier for the presentation - * @param types the types associated with the presentation - * @param holder the identifier of the entity presenting the credentials - * @param credentials the list of credentials included in the presentation + * @param id the unique identifier for the presentation (may be null) + * @param types the types associated with the presentation (may be null or empty) + * @param holder the holder identifier + * @param credentials the list of credentials + * @param signedAt the signing timestamp to embed */ - protected Vouch(String id, List types, Id holder, List credentials) { + protected Vouch(String id, List types, Id holder, List credentials, Date signedAt) { this.id = id; - this.types = types == null || types.isEmpty() ? Collections.emptyList() : Collections.unmodifiableList(types); + this.types = types == null || types.isEmpty() ? List.of() : List.copyOf(types); this.holder = holder; - this.credentials = Collections.unmodifiableList(credentials); - this.signedAt = null; + this.credentials = List.copyOf(credentials); + this.signedAt = signedAt; this.signature = null; } @@ -163,15 +171,14 @@ protected Vouch(String id, List types, Id holder, List crede * Internal constructor used by {@link VouchBuilder} for copying and adding a signature. * * @param vouch the original vouch to copy - * @param signedAt the timestamp at which the presentation was signed * @param signature the signature over the presentation data */ - protected Vouch(Vouch vouch, Date signedAt, byte[] signature) { + protected Vouch(Vouch vouch, byte[] signature) { this.id = vouch.id; this.types = vouch.types; this.holder = vouch.holder; this.credentials = vouch.credentials; - this.signedAt = signedAt; + this.signedAt = vouch.signedAt; this.signature = signature; } @@ -249,7 +256,8 @@ public Credential getCredential(String id) { /** * Returns the timestamp at which this presentation was signed. *

      - * Mapped to the compact JSON/CBOR key {@code "sat"}. + * Mapped to the compact JSON/CBOR key {@code "sat"}. Note: {@code signedAt} is metadata and is + * not covered by the signature, so it is not cryptographically authenticated. * * @return the signing timestamp, or {@code null} if unsigned */ @@ -265,11 +273,16 @@ public Date getSignedAt() { * @return the signature byte array, or {@code null} if unsigned */ public byte[] getSignature() { - return signature; + return signature == null ? null : signature.clone(); } /** - * Validates the signature of this presentation. + * Validates this presentation: the holder's signature and the signature of every embedded + * credential must all verify. + *

      + * Note: this verifies signatures only. The validity period of the embedded + * credentials is a caller policy and is NOT checked here; call {@link Credential#validate()} + * on the individual credentials if needed. * * @throws InvalidSignatureException if the signature is invalid or not genuine */ @@ -279,17 +292,24 @@ public void validate() throws InvalidSignatureException { } /** - * Checks if the signature of this presentation is genuine. + * Checks if this presentation is genuine: the holder's signature verifies and every + * embedded {@link Credential} is itself genuine. The holder's envelope signature alone is not + * sufficient, since it does not attest to the authenticity of the contained credentials. * - * @return {@code true} if the signature is valid and genuine, {@code false} otherwise + * @return {@code true} if the holder signature and all embedded credential signatures verify, + * {@code false} otherwise */ public boolean isGenuine() { // Signature must be present and of correct length if (signature == null || signature.length != Signature.BYTES) return false; - // Verify the signature against the sign data and holder's public key - return Signature.verify(getSignData(), signature, holder.toSignatureKey()); + // Verify the holder's signature against the sign data and holder's public key + if (!Signature.verify(getSignData(), signature, holder.toSignatureKey())) + return false; + + // The envelope is authentic; now require every embedded credential to be genuine too. + return credentials.stream().allMatch(Credential::isGenuine); } /** @@ -301,10 +321,11 @@ public boolean isGenuine() { * @return the byte array to be signed or verified */ protected byte[] getSignData() { - // If already signed, strip signature/signedAt for sign data - if (signature != null) // already signed - return new Vouch(this, null, null).toBytes(); - else // unsigned + if (signature != null) // already signed + // Rebuild the bytes that were signed: everything except the signature itself. + // signedAt is part of the signed data so that the signing timestamp is authenticated. + return new Vouch(this, null).toBytes(); + else // unsigned return toBytes(); } @@ -412,4 +433,4 @@ public static VouchBuilder builder(Identity holder) { Objects.requireNonNull(holder, "holder"); return new VouchBuilder(holder); } -} +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/VouchBuilder.java b/api/src/main/java/io/bosonnetwork/identifier/VouchBuilder.java index a1bce1a6..ea93c827 100644 --- a/api/src/main/java/io/bosonnetwork/identifier/VouchBuilder.java +++ b/api/src/main/java/io/bosonnetwork/identifier/VouchBuilder.java @@ -1,6 +1,29 @@ +/* + * 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.identifier; import java.util.ArrayList; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -290,11 +313,10 @@ public Vouch build() { throw new IllegalStateException("Vouch must include at least one credential"); List credentials = new ArrayList<>(this.credentials.values()); - // Create an unsigned Vouch with the collected data - Vouch unsigned = new Vouch(id, types, identity.getId(), new ArrayList<>(credentials)); - // Sign the Vouch's data with the identity's private key + // Stamp signedAt before signing so it is covered by the signature. + Date signedAt = now(); + Vouch unsigned = new Vouch(id, types, identity.getId(), new ArrayList<>(credentials), signedAt); byte[] signature = identity.sign(unsigned.getSignData()); - // Return a new signed Vouch with timestamp and signature - return new Vouch(unsigned, now(), signature); + return new Vouch(unsigned, signature); } -} +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/identifier/package-info.java b/api/src/main/java/io/bosonnetwork/identifier/package-info.java new file mode 100644 index 00000000..b6721237 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/identifier/package-info.java @@ -0,0 +1,60 @@ +/* + * 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. + */ + +/** + * Decentralized identity for Boson: a W3C-aligned DID / Verifiable Credential layer anchored to + * Boson {@link io.bosonnetwork.Id} identities and resolved over the DHT. + * + *

      Identity documents and claims

      + *
        + *
      • {@link io.bosonnetwork.identifier.DIDDocument} — the DID document for an identity, with + * {@link io.bosonnetwork.identifier.VerificationMethod}s and embedded credentials;
      • + *
      • {@link io.bosonnetwork.identifier.Card} — a Boson profile/business-card object;
      • + *
      • {@link io.bosonnetwork.identifier.Credential} and + * {@link io.bosonnetwork.identifier.VerifiableCredential} — issued claims about a subject;
      • + *
      • {@link io.bosonnetwork.identifier.VerifiablePresentation} and + * {@link io.bosonnetwork.identifier.Vouch} — holder-presented bundles of credentials;
      • + *
      • {@link io.bosonnetwork.identifier.Proof} — the Ed25519 signature/proof attached to the + * above objects.
      • + *
      + * Each object has a fluent {@code *Builder} (for example + * {@link io.bosonnetwork.identifier.CardBuilder}, {@link io.bosonnetwork.identifier.CredentialBuilder}, + * {@link io.bosonnetwork.identifier.VerifiableCredentialBuilder}) that signs on build. + * + *

      Addressing and resolution

      + * {@link io.bosonnetwork.identifier.DIDURL} parses and builds {@code did:boson:} URLs; + * {@link io.bosonnetwork.identifier.DIDConstants} and {@link io.bosonnetwork.identifier.W3CDIDFormat} + * hold the shared constants. {@link io.bosonnetwork.identifier.Resolver} / + * {@link io.bosonnetwork.identifier.Registry} define DID resolution and publication, with a + * DHT-backed implementation ({@link io.bosonnetwork.identifier.DHTResolver}, + * {@link io.bosonnetwork.identifier.DHTRegistry}) and a + * {@link io.bosonnetwork.identifier.ResolutionCache} (e.g. + * {@link io.bosonnetwork.identifier.FileSystemResolutionCache}) fronted by + * {@link io.bosonnetwork.identifier.CachedResolver}. + * + *

      References

      + * + */ +package io.bosonnetwork.identifier; diff --git a/api/src/main/java/io/bosonnetwork/json/Json.java b/api/src/main/java/io/bosonnetwork/json/Json.java index 6fda7e98..c48986c2 100644 --- a/api/src/main/java/io/bosonnetwork/json/Json.java +++ b/api/src/main/java/io/bosonnetwork/json/Json.java @@ -87,7 +87,7 @@ *
    • Context-aware serialization using {@link JsonContext} for configuration of serialization details.
    • *
    */ -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> _mapType; + private Json() { + } + + // The factories, module, mappers and shared type reference are singletons that are expensive to + // build and immutable once configured. They are published through the initialization-on-demand + // holder idiom: the JVM guarantees that a nested class is initialized lazily, exactly once, and + // with a happens-before edge to every thread that subsequently reads its static field — so these + // accessors are both lazy and thread-safe without explicit locking. + + private static final class ModuleHolder { + static final SimpleModule INSTANCE = buildBosonJsonModule(); + } + + private static final class JsonFactoryHolder { + static final JsonFactory INSTANCE = buildJsonFactory(); + } - private static SimpleModule _bosonJsonModule; + private static final class CborFactoryHolder { + static final CBORFactory INSTANCE = buildCborFactory(); + } - private static JsonFactory _jsonFactory; - private static CBORFactory _cborFactory; + private static final class ObjectMapperHolder { + static final ObjectMapper INSTANCE = buildObjectMapper(); + } + + private static final class CborMapperHolder { + static final CBORMapper INSTANCE = buildCborMapper(); + } + + private static final class YamlMapperHolder { + static final YAMLMapper INSTANCE = buildYamlMapper(); + } + + private static final class MapTypeHolder { + static final TypeReference> INSTANCE = new TypeReference<>() { }; + } - private static ObjectMapper _objectMapper; - private static CBORMapper _cborMapper; - private static YAMLMapper _yamlMapper; + private static SimpleModule buildBosonJsonModule() { + SimpleModule module = new SimpleModule(BOSON_JSON_MODULE_NAME); + module.addSerializer(Date.class, new DateSerializer()); + module.addDeserializer(Date.class, new DateDeserializer()); + module.addSerializer(Id.class, new IdSerializer()); + module.addDeserializer(Id.class, new IdDeserializer()); + module.addSerializer(InetAddress.class, new InetAddressSerializer()); + module.addDeserializer(InetAddress.class, new InetAddressDeserializer()); + + module.addSerializer(NodeInfo.class, new NodeInfoSerializer()); + module.addDeserializer(NodeInfo.class, new NodeInfoDeserializer()); + module.addSerializer(PeerInfo.class, new PeerInfoSerializer()); + module.addDeserializer(PeerInfo.class, new PeerInfoDeserializer()); + module.addSerializer(Value.class, new ValueSerializer()); + module.addDeserializer(Value.class, new ValueDeserializer()); + + return module; + } + + private static JsonFactory buildJsonFactory() { + JsonFactory factory = new JsonFactory(); + factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + return factory; + } + + private static CBORFactory buildCborFactory() { + CBORFactory factory = new CBORFactory(); + factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + return factory; + } + + private static ObjectMapper buildObjectMapper() { + return JsonMapper.builder(jsonFactory()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .disable(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY) + // .defaultDateFormat(getDateFormat()) + .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) + .addModule(bosonJsonModule()) + .build(); + } + + private static CBORMapper buildCborMapper() { + return CBORMapper.builder(cborFactory()) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .disable(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY) + // .defaultDateFormat(getDateFormat()) + .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) + .addModule(bosonJsonModule()) + .build(); + } + + private static YAMLMapper buildYamlMapper() { + YAMLFactory factory = new YAMLFactory(); + factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); + factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); + + return YAMLMapper.builder(factory) + .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .disable(MapperFeature.AUTO_DETECT_CREATORS) + .disable(MapperFeature.AUTO_DETECT_FIELDS) + .disable(MapperFeature.AUTO_DETECT_GETTERS) + .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) + .disable(MapperFeature.AUTO_DETECT_SETTERS) + .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) + .disable(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY) + .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) + .enable(YAMLGenerator.Feature.INDENT_ARRAYS) + .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) + //.defaultDateFormat(getDateFormat()) + .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) + .addModule(bosonJsonModule()) + .build(); + } /** * Returns the Jackson module for Boson types. * * @return the {@link SimpleModule} object */ - protected static SimpleModule bosonJsonModule() { - if (_bosonJsonModule == null) { - SimpleModule module = new SimpleModule(BOSON_JSON_MODULE_NAME); - module.addSerializer(Date.class, new DateSerializer()); - module.addDeserializer(Date.class, new DateDeserializer()); - module.addSerializer(Id.class, new IdSerializer()); - module.addDeserializer(Id.class, new IdDeserializer()); - module.addSerializer(InetAddress.class, new InetAddressSerializer()); - module.addDeserializer(InetAddress.class, new InetAddressDeserializer()); - - module.addSerializer(NodeInfo.class, new NodeInfoSerializer()); - module.addDeserializer(NodeInfo.class, new NodeInfoDeserializer()); - module.addSerializer(PeerInfo.class, new PeerInfoSerializer()); - module.addDeserializer(PeerInfo.class, new PeerInfoDeserializer()); - module.addSerializer(Value.class, new ValueSerializer()); - module.addDeserializer(Value.class, new ValueDeserializer()); - - _bosonJsonModule = module; - } - - return _bosonJsonModule; + private static SimpleModule bosonJsonModule() { + return ModuleHolder.INSTANCE; } /** @@ -140,14 +244,7 @@ protected static SimpleModule bosonJsonModule() { * @return the {@link JsonFactory} object */ public static JsonFactory jsonFactory() { - if (_jsonFactory == null) { - JsonFactory factory = new JsonFactory(); - factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); - _jsonFactory = factory; - } - - return _jsonFactory; + return JsonFactoryHolder.INSTANCE; } /** @@ -156,105 +253,34 @@ public static JsonFactory jsonFactory() { * @return the {@link CBORFactory} object */ public static CBORFactory cborFactory() { - if (_cborFactory == null) { - CBORFactory factory = new CBORFactory(); - factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); - _cborFactory = factory; - } - - return _cborFactory; + return CborFactoryHolder.INSTANCE; } /** - * Creates the Jackson object mapper, with basic Boson types support. + * Returns the shared Jackson object mapper, with basic Boson types support. * - * @return the new {@code ObjectMapper} object. + * @return the {@code ObjectMapper} object. */ public static ObjectMapper objectMapper() { - if (_objectMapper == null) { - _objectMapper = JsonMapper.builder(jsonFactory()) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .disable(MapperFeature.AUTO_DETECT_CREATORS) - .disable(MapperFeature.AUTO_DETECT_FIELDS) - .disable(MapperFeature.AUTO_DETECT_GETTERS) - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) - .disable(MapperFeature.AUTO_DETECT_SETTERS) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - .disable(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY) - // .defaultDateFormat(getDateFormat()) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(bosonJsonModule()) - .build(); - } - - return _objectMapper; + return ObjectMapperHolder.INSTANCE; } /** - * Creates the Jackson CBOR mapper, with basic Boson types support. + * Returns the shared Jackson CBOR mapper, with basic Boson types support. * - * @return the new {@code CBORMapper} object. + * @return the {@code CBORMapper} object. */ public static CBORMapper cborMapper() { - if (_cborMapper == null) { - _cborMapper = CBORMapper.builder(cborFactory()) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .disable(MapperFeature.AUTO_DETECT_CREATORS) - .disable(MapperFeature.AUTO_DETECT_FIELDS) - .disable(MapperFeature.AUTO_DETECT_GETTERS) - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) - .disable(MapperFeature.AUTO_DETECT_SETTERS) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - .disable(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY) - // .defaultDateFormat(getDateFormat()) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(bosonJsonModule()) - .build(); - } - - return _cborMapper; + return CborMapperHolder.INSTANCE; } /** - * Creates the Jackson YAML mapper, with basic Boson types support. + * Returns the shared Jackson YAML mapper, with basic Boson types support. * - * @return the new {@code YAMLMapper} object. + * @return the {@code YAMLMapper} object. */ public static YAMLMapper yamlMapper() { - if (_yamlMapper == null) { - YAMLFactory factory = new YAMLFactory(); - factory.disable(JsonGenerator.Feature.AUTO_CLOSE_TARGET); - factory.disable(JsonParser.Feature.AUTO_CLOSE_SOURCE); - - _yamlMapper = YAMLMapper.builder(factory) - .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) - .disable(MapperFeature.AUTO_DETECT_CREATORS) - .disable(MapperFeature.AUTO_DETECT_FIELDS) - .disable(MapperFeature.AUTO_DETECT_GETTERS) - .disable(MapperFeature.AUTO_DETECT_IS_GETTERS) - .disable(MapperFeature.AUTO_DETECT_SETTERS) - .disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) - .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) - .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) - .disable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES) - .disable(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY) - .disable(YAMLGenerator.Feature.WRITE_DOC_START_MARKER) - .enable(YAMLGenerator.Feature.INDENT_ARRAYS) - .enable(YAMLGenerator.Feature.MINIMIZE_QUOTES) - //.defaultDateFormat(getDateFormat()) - .defaultBase64Variant(Base64Variants.MODIFIED_FOR_URL) - .addModule(bosonJsonModule()) - .build(); - } - - return _yamlMapper; + return YamlMapperHolder.INSTANCE; } /** @@ -366,10 +392,7 @@ public static byte[] toBytes(Object object) { * @return the {@code TypeReference} for {@code Map} */ public static TypeReference> mapType() { - if (_mapType == null) - _mapType = new TypeReference<>() { }; - - return _mapType; + return MapTypeHolder.INSTANCE; } /** @@ -648,6 +671,11 @@ public static T parse(byte[] cbor, JavaType type) { /** * Converts a byte array containing CBOR-encoded data into its equivalent JSON string representation. + *

    + * This is a low-level structural transcoder operating on the raw factories: it copies the CBOR + * token stream into JSON and does not apply the Boson type module or the URL-safe Base64 + * convention used by {@link #objectMapper()}/{@link #cborMapper()}. Binary CBOR fields are emitted + * using Jackson's default Base64 variant. * * @param cbor the byte array containing CBOR-encoded data * @return a JSON string representation of the provided CBOR data @@ -670,6 +698,10 @@ public static String cborToJson(byte[] cbor) { /** * Converts a JSON string to its CBOR (Concise Binary Object Representation) byte array representation. + *

    + * This is a low-level structural transcoder operating on the raw factories: it copies the JSON + * token stream into CBOR and does not apply the Boson type module or the URL-safe Base64 + * convention used by {@link #objectMapper()}/{@link #cborMapper()}. * * @param json the JSON string to be converted to CBOR format; must not be null * @return a byte array containing the CBOR encoded representation of the provided JSON string @@ -691,12 +723,18 @@ public static byte[] jsonToCbor(String json) { } /** - * Initializes and registers the Boson JSON Jackson module with the global Jackson DatabindCodec mapper. + * Initializes and registers the Boson JSON Jackson module with the global Vert.x + * {@link DatabindCodec} mapper. + *

    + * Process-global side effect: this mutates the singleton {@code DatabindCodec.mapper()} + * shared by all Vert.x JSON handling in the JVM — it registers the Boson type module and enables + * {@link DeserializationFeature#USE_BIG_DECIMAL_FOR_FLOATS} for every Vert.x JSON operation in + * the process, not just Boson code. Call it once during application startup. *

    - * This method ensures the Boson JSON module is registered only once. If already registered, - * the method returns immediately. The module adds support for Boson-specific types and serialization behaviors. + * The method is idempotent and synchronized: the module is registered at most once even under + * concurrent calls. */ - public static void initializeBosonJsonModule() { + public static synchronized void initializeBosonJsonModule() { if (DatabindCodec.mapper().getRegisteredModuleIds().stream() .anyMatch(id -> id.equals(BOSON_JSON_MODULE_NAME))) return; // already registered diff --git a/api/src/main/java/io/bosonnetwork/json/JsonContext.java b/api/src/main/java/io/bosonnetwork/json/JsonContext.java index f0656b9f..a9a63de8 100644 --- a/api/src/main/java/io/bosonnetwork/json/JsonContext.java +++ b/api/src/main/java/io/bosonnetwork/json/JsonContext.java @@ -94,7 +94,7 @@ public static JsonContext perCall(Object key, Object value) { * @param value2 the second attribute value * @return a per-call context with the specified attributes */ - static JsonContext perCall(Object key1, Object value1, Object key2, Object value2) { + public static JsonContext perCall(Object key1, Object value1, Object key2, Object value2) { Map m = new HashMap<>(); m.put(key1, value1); m.put(key2, value2); @@ -112,7 +112,7 @@ static JsonContext perCall(Object key1, Object value1, Object key2, Object value * @param value3 the third attribute value * @return a per-call context with the specified attributes */ - static JsonContext perCall(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { + public static JsonContext perCall(Object key1, Object value1, Object key2, Object value2, Object key3, Object value3) { Map m = new HashMap<>(); m.put(key1, value1); m.put(key2, value2); diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java b/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java index a9470919..0375102e 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/DataFormat.java @@ -40,7 +40,7 @@ public class DataFormat { * @return {@code true} if the parser is handling a binary format (CBOR), {@code false} otherwise */ public static boolean isBinary(JsonParser p) { - // Now we only sport JSON, CBOR and TOML formats; CBOR is the only binary format + // Now we only support JSON, CBOR and YAML formats; CBOR is the only binary format return p instanceof CBORParser; } @@ -51,7 +51,7 @@ public static boolean isBinary(JsonParser p) { * @return {@code true} if the generator is handling a binary format (CBOR), {@code false} otherwise */ public static boolean isBinary(JsonGenerator gen) { - // Now we only sport JSON, CBOR and TOML formats; CBOR is the only binary format + // Now we only support JSON, CBOR and YAML formats; CBOR is the only binary format return gen instanceof CBORGenerator; } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java index 787efa7a..f3965038 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/DateDeserializer.java @@ -37,6 +37,10 @@ * Handles deserialization of Date objects from either ISO8601/RFC3339 string format or from epoch milliseconds, * depending on the input format. In text formats, expects ISO8601 or RFC3339 strings; in binary formats, expects * epoch milliseconds. + *

    + * Although {@link DateSerializer} emits text dates at second precision, this deserializer is tolerant + * of both: it first parses the second-precision form and falls back to the millisecond-precision + * form ({@code yyyy-MM-dd'T'HH:mm:ss.SSS'Z'}), so millisecond-bearing strings still parse correctly. */ public class DateDeserializer extends StdDeserializer { private static final long serialVersionUID = -4252894239212420927L; diff --git a/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java index 66a62fe6..1cc1d998 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/DateSerializer.java @@ -34,6 +34,11 @@ *

    * Handles serialization of Date objects to either ISO8601 string format (for text formats like JSON/YAML) * or to epoch milliseconds (for binary formats like CBOR), depending on the output format. + *

    + * Precision: text output is intentionally truncated to second precision + * ({@code yyyy-MM-dd'T'HH:mm:ss'Z'}, matching RFC3339), so sub-second components are dropped in + * JSON/YAML. Binary (CBOR) output preserves full millisecond precision via epoch milliseconds. The + * matching {@link DateDeserializer} still accepts millisecond-precision strings on input. */ public class DateSerializer extends StdSerializer { private static final long serialVersionUID = 4759684498722016230L; diff --git a/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java index 6c993a52..4861f340 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/IdSerializer.java @@ -71,7 +71,7 @@ public void serialize(Id value, JsonGenerator gen, SerializerProvider provider) Boolean attr = (Boolean) provider.getAttribute(DIDConstants.BOSON_ID_FORMAT_W3C); boolean w3cDID = attr != null && attr; if (DataFormat.isBinary(gen)) - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.bytesUnsafe(), 0, Id.BYTES); else gen.writeString(w3cDID ? value.toDIDString() : value.toBase58String()); } diff --git a/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java index d0b81741..e27a6cc9 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/InetAddressDeserializer.java @@ -66,6 +66,9 @@ public InetAddressDeserializer(Class vc) { */ @Override public InetAddress deserialize(JsonParser p, DeserializationContext ctx) throws IOException { + // getByName does not perform a DNS lookup for an IP-address literal (it parses it directly); + // resolution only happens for a genuine hostname. Serializers emit getHostAddress() (a + // literal), so deserializing Boson-produced data never triggers DNS. return DataFormat.isBinary(p) || p.currentToken() != JsonToken.VALUE_STRING ? InetAddress.getByAddress(p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL)) : InetAddress.getByName(p.getText()); diff --git a/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java index 992488cd..174dc2a0 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoDeserializer.java @@ -105,6 +105,10 @@ else if (token == JsonToken.VALUE_EMBEDDED_OBJECT) if (p.nextToken() != JsonToken.END_ARRAY) throw MismatchedInputException.from(p, NodeInfo.class, "Invalid NodeInfo: too many elements in array"); + // Note: a textual host that is a hostname (not an IP literal) is resolved here, mirroring + // NodeInfo's own (Id, String, port) constructor. NodeInfo deserialization can therefore + // perform a blocking name resolution; avoiding that would require a NodeInfo-level change to + // retain unresolved addresses, which would also alter equality semantics. InetSocketAddress isa = addr != null ? new InetSocketAddress(addr, port) : new InetSocketAddress(host, port); return new NodeInfo(id, isa); } diff --git a/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java index 062cf3d2..909c1889 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/NodeInfoSerializer.java @@ -77,7 +77,7 @@ public void serialize(NodeInfo value, JsonGenerator gen, SerializerProvider prov gen.writeStartArray(); if (DataFormat.isBinary(gen)) { - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytesUnsafe(), 0, Id.BYTES); if (value.getAddress().isUnresolved()) { // not attempting to do name resolution gen.writeString(value.getAddress().getHostString()); diff --git a/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java index 409f63ad..107a78f2 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoDeserializer.java @@ -121,11 +121,11 @@ public PeerInfo deserialize(JsonParser p, DeserializationContext ctx) throws IOE fingerprint = p.getLongValue(); break; case "e": - if (p.currentToken() != JsonToken.VALUE_NULL) + if (token != JsonToken.VALUE_NULL) endpoint = p.getValueAsString(); break; case "ex": - if (p.currentToken() != JsonToken.VALUE_NULL) + if (token != JsonToken.VALUE_NULL) extraData = p.getBinaryValue(Base64Variants.MODIFIED_FOR_URL); break; default: diff --git a/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java index 257983b7..ba74da13 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/PeerInfoSerializer.java @@ -80,7 +80,7 @@ public void serialize(PeerInfo value, JsonGenerator gen, SerializerProvider prov if (!omitPeerId) { if (binaryFormat) { gen.writeFieldName("id"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getId().bytesUnsafe(), 0, Id.BYTES); } else { gen.writeStringField("id", value.getId().toBase58String()); } @@ -100,7 +100,7 @@ public void serialize(PeerInfo value, JsonGenerator gen, SerializerProvider prov if (value.getNodeId() != null) { if (binaryFormat) { gen.writeFieldName("o"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getNodeId().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getNodeId().bytesUnsafe(), 0, Id.BYTES); } else { gen.writeStringField("o", value.getNodeId().toBase58String()); } diff --git a/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java b/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java index cce76296..cc7984d8 100644 --- a/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java +++ b/api/src/main/java/io/bosonnetwork/json/internal/ValueSerializer.java @@ -74,7 +74,7 @@ public void serialize(Value value, JsonGenerator gen, SerializerProvider provide // public key if (binaryFormat) { gen.writeFieldName("k"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getPublicKey().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getPublicKey().bytesUnsafe(), 0, Id.BYTES); } else { gen.writeStringField("k", value.getPublicKey().toBase58String()); } @@ -83,7 +83,7 @@ public void serialize(Value value, JsonGenerator gen, SerializerProvider provide if (value.getRecipient() != null) { if (binaryFormat) { gen.writeFieldName("rec"); - gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getRecipient().bytes(), 0, Id.BYTES); + gen.writeBinary(Base64Variants.MODIFIED_FOR_URL, value.getRecipient().bytesUnsafe(), 0, Id.BYTES); } else { gen.writeStringField("rec", value.getRecipient().toBase58String()); } diff --git a/api/src/main/java/io/bosonnetwork/json/package-info.java b/api/src/main/java/io/bosonnetwork/json/package-info.java new file mode 100644 index 00000000..870bd16a --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/json/package-info.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. + */ + +/** + * Jackson-based serialization for Boson, supporting JSON, CBOR and YAML through one configured + * facade. + * + *

    {@link io.bosonnetwork.json.Json}

    + * The single entry point. It exposes pre-configured, shared (thread-safe) Jackson mappers/factories + * and convenience {@code toString} / {@code toPrettyString} / {@code toBytes} / {@code parse} + * helpers. The mappers register a module with custom (de)serializers for Boson domain types + * ({@link io.bosonnetwork.Id}, {@link io.bosonnetwork.NodeInfo}, {@link io.bosonnetwork.PeerInfo}, + * {@link io.bosonnetwork.Value}, {@link java.util.Date}, {@link java.net.InetAddress}). + *

    + * A consistent dual-encoding convention is applied: binary formats (CBOR) carry IDs, addresses and + * other byte fields as binary; text formats (JSON/YAML) use Base58 / string forms. The URL-safe + * Base64 variant is used throughout, and {@link io.bosonnetwork.json.Json#cborMapper()} / + * {@link io.bosonnetwork.json.Json#cborFactory()} also back the CBOR encoding of + * {@link io.bosonnetwork.cwt.SignedCwt}. + * + *

    {@link io.bosonnetwork.json.JsonContext}

    + * An immutable Jackson {@code ContextAttributes} subclass for passing per-call or shared + * serialization options (for example selecting the W3C-DID string form for IDs). + * + *

    The concrete (de)serializers live in the non-published {@code internal} subpackage. + */ +package io.bosonnetwork.json; diff --git a/api/src/main/java/io/bosonnetwork/metrics/package-info.java b/api/src/main/java/io/bosonnetwork/metrics/package-info.java new file mode 100644 index 00000000..7fb55464 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/metrics/package-info.java @@ -0,0 +1,31 @@ +/* + * 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. + */ + +/** + * Service-provider interfaces for monitoring Boson network components. + *

    + * {@link io.bosonnetwork.metrics.Metrics} is the contract implemented by a metrics provider that + * wants to observe Boson services, and {@link io.bosonnetwork.metrics.Measured} is the marker + * interface for components that expose such metrics. The interfaces are intentionally minimal so a + * host application can plug in its own metrics backend. + */ +package io.bosonnetwork.metrics; diff --git a/api/src/main/java/io/bosonnetwork/package-info.java b/api/src/main/java/io/bosonnetwork/package-info.java index 216afe0e..09a54d2b 100644 --- a/api/src/main/java/io/bosonnetwork/package-info.java +++ b/api/src/main/java/io/bosonnetwork/package-info.java @@ -22,6 +22,31 @@ */ /** - * This package contains the public APIs and types for the Boson node. + * The core public API of the Boson network — the root types every other package and third-party + * SDK builds on. + * + *

    Node and identity

    + * {@link io.bosonnetwork.Node} is the DHT node abstraction (lookups, value/peer storage, encrypt / + * decrypt / sign), created via {@link io.bosonnetwork.NodeFactory} from a + * {@link io.bosonnetwork.NodeConfiguration}. {@link io.bosonnetwork.Identity} and + * {@link io.bosonnetwork.UserProfile} model cryptographic identities. + * + *

    Addressing and DHT records

    + * {@link io.bosonnetwork.Id} is the 256-bit Ed25519-based identifier used for nodes, values and + * peers. {@link io.bosonnetwork.Value} (mutable/immutable stored data), + * {@link io.bosonnetwork.PeerInfo} (announced peers) and {@link io.bosonnetwork.NodeInfo} / + * {@link io.bosonnetwork.Network} describe what lives in and around the DHT. + * + *

    Operations and results

    + * Lookups are tuned by {@link io.bosonnetwork.LookupOption} and return their outcome through + * {@link io.bosonnetwork.Result}. Errors surface as {@link io.bosonnetwork.BosonException} and its + * subtypes (e.g. {@link io.bosonnetwork.ExpiredException}), and {@link io.bosonnetwork.Version} + * carries node version metadata. + * + *

    Supporting concerns live in subpackages: {@link io.bosonnetwork.crypto cryptography}, + * {@link io.bosonnetwork.identifier DID/VC identity}, {@link io.bosonnetwork.cwt CWT tokens}, + * {@link io.bosonnetwork.service layer-2 services}, {@link io.bosonnetwork.json serialization}, + * {@link io.bosonnetwork.database database helpers}, {@link io.bosonnetwork.web web auth}, + * {@link io.bosonnetwork.vertx Vert.x helpers} and {@link io.bosonnetwork.utils utilities}. */ package io.bosonnetwork; \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/BosonService.java b/api/src/main/java/io/bosonnetwork/service/BosonService.java index 2fae33f2..4c784329 100644 --- a/api/src/main/java/io/bosonnetwork/service/BosonService.java +++ b/api/src/main/java/io/bosonnetwork/service/BosonService.java @@ -57,31 +57,38 @@ public interface BosonService { String getName(); /** - * Retrieves the host associated with the service. + * Returns the local bind host of the service (the address the service listens on + * locally — e.g. {@code 0.0.0.0}, {@code 127.0.0.1}, or a configured interface). This is + * deployment configuration and is generally different from the publicly-reachable host + * advertised in {@link #getPeerInfo()}. * - * @return a string representing the host's address. + * @return the local bind host. */ String getHost(); /** - * Retrieves the port number associated with the service. + * Returns the local bind port of the service. Deployment configuration; usually + * different from any port advertised through {@link #getPeerInfo()}. * - * @return an integer representing the port number. + * @return the local bind port. */ int getPort(); /** - * Retrieves the endpoint URL associated with the service. + * Returns the public endpoint URL that remote clients should use to reach this + * service. The endpoint encoded in {@link #getPeerInfo()} is normally the same value and is + * what gets gossiped/published over the network. * - * @return a string representing the service endpoint. + * @return the public service endpoint URL. */ String getEndpoint(); /** - * Retrieves detailed information about the peer associated with the service. + * Returns the published peer information for this service — the public-facing identity, the + * advertised endpoint, and any associated metadata. This is what federation peers see when + * looking up the service. * - * @return a {@link PeerInfo} object containing peer-related information, such as - * host, port, and other metadata. + * @return the {@link PeerInfo} for this service. */ PeerInfo getPeerInfo(); @@ -95,7 +102,11 @@ default boolean isFederationEnabled() { } /** - * Get the running status + * Returns whether the service is currently running. + *

    + * The contract is: {@code true} only between a successful completion of {@link #start()} and + * the moment {@link #stop()} begins executing. While a {@code start()} or {@code stop()} + * future is still pending, this returns {@code false}. * * @return true if the service is running, false otherwise. */ @@ -103,6 +114,10 @@ default boolean isFederationEnabled() { /** * Initialize the service instance with the {@link ServiceContext} object. + *

    + * Must not block. {@code init} is called on the Vert.x event loop (following + * the standard Vert.x verticle init pattern); any I/O (config reads, schema setup, network + * calls) must be deferred to {@link #start()} where it can be performed asynchronously. * * @param context the {@link ServiceContext} object to initialize the service. * @throws BosonServiceException if the error occurred during the initialization. diff --git a/api/src/main/java/io/bosonnetwork/service/BosonServiceException.java b/api/src/main/java/io/bosonnetwork/service/BosonServiceException.java index 8003dd32..42364baa 100644 --- a/api/src/main/java/io/bosonnetwork/service/BosonServiceException.java +++ b/api/src/main/java/io/bosonnetwork/service/BosonServiceException.java @@ -66,7 +66,6 @@ public BosonServiceException(String message) { * {@link #getCause()} method). (A {@code null} value is * permitted, and indicates that the cause is nonexistent or * unknown.) - * @since 1.4 */ public BosonServiceException(String message, Throwable cause) { super(message, cause); @@ -84,9 +83,8 @@ public BosonServiceException(String message, Throwable cause) { * {@link #getCause()} method). (A {@code null} value is * permitted, and indicates that the cause is nonexistent or * unknown.) - * @since 1.4 */ public BosonServiceException(Throwable cause) { super(cause); } -} +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/BosonServiceFactory.java b/api/src/main/java/io/bosonnetwork/service/BosonServiceFactory.java new file mode 100644 index 00000000..a5e63a40 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/service/BosonServiceFactory.java @@ -0,0 +1,55 @@ +/* + * 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; + +/** + * Service provider interface for creating {@link BosonService} instances. + *

    + * Implementations are discovered at runtime via the {@link java.util.ServiceLoader} + * mechanism and keyed by their {@link #getType() type}, which lets the Boson super node + * select and instantiate the configured layer2 services without referencing concrete + * implementation class names. Providers register themselves through a + * {@code META-INF/services/io.bosonnetwork.service.BosonServiceFactory} entry, or a + * {@code provides io.bosonnetwork.service.BosonServiceFactory with ...} declaration when + * running on the Java module path. + */ +public interface BosonServiceFactory { + /** + * The unique identifier for the service type produced by this factory. + *

    + * Must equal the {@link BosonService#getType() type} of the services it creates. + * + * @return the unique type identifier string. + */ + String getType(); + + /** + * Creates a new, uninitialized {@link BosonService} instance. + *

    + * The returned service is not yet initialized; the caller is responsible for invoking + * {@link BosonService#init(ServiceContext)} and {@link BosonService#start()}. + * + * @return a new {@link BosonService} instance. + */ + BosonService create(); +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java b/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java index c1fc3f33..642552b3 100644 --- a/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java +++ b/api/src/main/java/io/bosonnetwork/service/ClientAuthenticator.java @@ -29,55 +29,72 @@ /** * Interface for authenticating clients (users and devices) within the Boson network. *

    - * Implementations provide mechanisms to verify the identity of users and devices - * typically using cryptographic challenges and signatures (e.g., Ed25519). + * Implementations verify the identity of users and devices, typically using cryptographic + * challenges and signatures (e.g., Ed25519). + *

    + * Nonce/signature contract. The overloads that accept {@code (nonce, signature)} + * support three call shapes: + *

      + *
    • Both {@code nonce} and {@code signature} non-null — the implementation MUST verify the + * signature against the nonce using the id's signing key, and return the verification result.
    • + *
    • Both {@code nonce} and {@code signature} null — "pre-authenticated" mode: the caller has + * already verified the identity out of band (typically at the transport layer) and is asking + * only whether the id is admissible. The implementation MUST NOT treat the absence of a + * signature as a failure; it should apply its admission policy and return that. The no-nonce + * default overloads delegate to this mode.
    • + *
    • Exactly one of {@code nonce} / {@code signature} is null — caller bug; the implementation + * MUST return {@code false}.
    • + *
    */ public interface ClientAuthenticator { /** - * Authenticates a user based on their ID and a cryptographic signature. + * Authenticates a user. See the {@linkplain ClientAuthenticator interface Javadoc} for the + * nonce/signature contract. * * @param userId the unique identifier of the user - * @param nonce the random challenge data (nonce) used for authentication - * @param signature the digital signature of the nonce, generated using the user's private key - * @return a {@link CompletableFuture} that completes with {@code true} if the user is successfully authenticated, + * @param nonce the challenge data, or {@code null} for pre-authenticated mode + * @param signature the signature over {@code nonce}, or {@code null} for pre-authenticated mode + * @return a {@link CompletableFuture} that completes with {@code true} if the user is admitted, * or {@code false} otherwise */ CompletableFuture authenticateUser(Id userId, byte[] nonce, byte[] signature); /** - * Authenticates a user based on their unique identifier. + * Convenience for pre-authenticated mode — equivalent to + * {@link #authenticateUser(Id, byte[], byte[]) authenticateUser(userId, null, null)}. * * @param userId the unique identifier of the user to authenticate - * @return a {@link CompletableFuture} that completes with {@code true} if the user - * is successfully authenticated, or {@code false} otherwise + * @return a {@link CompletableFuture} that completes with {@code true} if the user is admitted, + * or {@code false} otherwise */ default CompletableFuture authenticateUser(Id userId) { return authenticateUser(userId, null, null); } /** - * Authenticates a specific device belonging to a user. + * Authenticates a specific device belonging to a user. See the + * {@linkplain ClientAuthenticator interface Javadoc} for the nonce/signature contract. * * @param userId the unique identifier of the user who owns the device * @param deviceId the unique identifier of the device attempting to authenticate - * @param nonce the random challenge data (nonce) used for authentication - * @param signature the digital signature of the nonce, generated using the device's private key + * @param nonce the challenge data, or {@code null} for pre-authenticated mode + * @param signature the signature over {@code nonce}, or {@code null} for pre-authenticated mode * @param address the network address (e.g., IP address) from which the device is connecting - * @return a {@link CompletableFuture} that completes with {@code true} if the device is successfully authenticated, + * @return a {@link CompletableFuture} that completes with {@code true} if the device is admitted, * or {@code false} otherwise */ CompletableFuture authenticateDevice(Id userId, Id deviceId, byte[] nonce, byte[] signature, String address); /** - * Authenticates a specific device belonging to a user using the provided user ID, device ID, - * and network address. + * Convenience for pre-authenticated mode — equivalent to + * {@link #authenticateDevice(Id, Id, byte[], byte[], String) authenticateDevice(userId, deviceId, null, null, address)}. * * @param userId the unique identifier of the user who owns the device * @param deviceId the unique identifier of the device attempting to authenticate * @param address the network address (e.g., IP address) from which the device is connecting - * @return a {@link CompletableFuture} that completes with {@code true} if the device is successfully - * authenticated, or {@code false} otherwise + * @return a {@link CompletableFuture} that completes with {@code true} if the device is admitted, + * or {@code false} otherwise */ default CompletableFuture authenticateDevice(Id userId, Id deviceId, String address) { return authenticateDevice(userId, deviceId, null, null, address); diff --git a/api/src/main/java/io/bosonnetwork/service/ClientAuthorizer.java b/api/src/main/java/io/bosonnetwork/service/ClientAuthorizer.java index 4b72d273..b77e1903 100644 --- a/api/src/main/java/io/bosonnetwork/service/ClientAuthorizer.java +++ b/api/src/main/java/io/bosonnetwork/service/ClientAuthorizer.java @@ -30,18 +30,40 @@ /** * Interface for authorizing client requests to access specific services. *

    - * Implementations of this interface define the logic to determine if a user on a specific device - * is granted permission to use the requested service. + * Implementations decide whether a user, acting from a specific device, is permitted to use the + * requested service, and — on success — return any authorization details the service will need + * downstream (issued tokens, granted features, rate-limit overrides, etc.). + *

    + * Result contract. + *

      + *
    • Authorized — the future completes successfully with a + * {@code Map} carrying authorization details. The exact key set is + * implementation-defined for now and may evolve; consumers should treat unknown keys as + * opaque and missing keys as "not provided." An empty map means "authorized with no extra + * details." {@code null} should not be returned.
    • + *
    • Denied or system error — the future completes exceptionally with + * a {@link io.bosonnetwork.BosonException} (or a subtype). Implementations are encouraged + * to distinguish denial from infrastructure failure through the exception subtype/message, + * but both flow through the same exceptional-completion channel from the caller's + * perspective.
    • + *
    + * + *

    + * Stability: the map shape is intentionally untyped pending a refined + * {@code AuthorizationDecision} type; do not couple your code to specific keys without + * coordinating with the implementation you target. */ public interface ClientAuthorizer { /** - * Authorizes the specified user and device for the given service. + * Authorizes the specified user and device for the given service. See the + * {@linkplain ClientAuthorizer interface Javadoc} for the success/failure contract. * - * @param userId the unique identifier of the user requesting access - * @param deviceId the unique identifier of the device used for the request + * @param userId the unique identifier of the user requesting access + * @param deviceId the unique identifier of the device used for the request * @param serviceType the identifier of the target service type to be accessed - * @return a {@link CompletableFuture} that completes with a map containing authorization details - * (e.g., tokens, permissions) if successful, or completes exceptionally if authorization fails + * @return a {@link CompletableFuture} that completes with the authorization-details map on + * success, or completes exceptionally with a {@link io.bosonnetwork.BosonException} + * on denial or system error */ CompletableFuture> authorize(Id userId, Id deviceId, String serviceType); } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/ClientContext.java b/api/src/main/java/io/bosonnetwork/service/ClientContext.java index 29c86d16..8c1f692f 100644 --- a/api/src/main/java/io/bosonnetwork/service/ClientContext.java +++ b/api/src/main/java/io/bosonnetwork/service/ClientContext.java @@ -22,7 +22,6 @@ package io.bosonnetwork.service; -import java.util.List; import java.util.concurrent.CompletableFuture; import io.bosonnetwork.Id; @@ -58,33 +57,14 @@ public interface ClientContext { CompletableFuture existsUser(Id userId); /** - * Retrieves a list of all devices associated with a specific user. + * Retrieves a device associated with the specified user. * - * @param userId the unique identifier of the user whose devices are to be retrieved - * @return a {@link CompletableFuture} that completes with a list of {@link ClientDevice} objects belonging to the user - */ - @Deprecated - CompletableFuture> getDevices(Id userId); - - /** - * Retrieves the device information for a given device ID. - * - * @param deviceId the unique identifier of the device to retrieve - * @return a {@link CompletableFuture} that completes with the {@link ClientDevice} object if found, - * or completes with {@code null} if the device does not exist - */ - @Deprecated - CompletableFuture getDevice(Id deviceId); - - /** - * Checks if a device with the specified ID exists. - * - * @param deviceId the unique identifier of the device to check - * @return a {@link CompletableFuture} that completes with {@code true} if the device exists, - * or {@code false} otherwise + * @param userId the unique identifier of the user + * @param deviceId the unique identifier of the device + * @return a {@link CompletableFuture} that completes with the {@link ClientDevice} if it exists + * and belongs to the user, or completes with {@code null} otherwise */ - @Deprecated - CompletableFuture existsDevice(Id deviceId); + CompletableFuture getDevice(Id userId, Id deviceId); /** * Checks if a specific device exists and is associated with the specified user. @@ -120,28 +100,38 @@ public interface ClientContext { CwtAuth getWebAuthenticator(); /** - * Returns a new client context configured to allow all clients with the specified node identity. - *

    - * This configuration allows any request but associates them with a specific node identity, - * depending on the implementation of {@link AllowAllClientContext}. - *

    + * Returns an "allow-all" client context — intended for development, smoke tests, and bring-up + * of a service that does not yet wire a real client store. Concretely: + *
      + *
    • {@link #getUser(Id)} returns a fresh anonymous {@code PlainUser} for any id, and + * {@link #getDevice(Id, Id)} returns a fresh anonymous {@code PlainDevice};
    • + *
    • {@link #existsUser(Id)} and {@link #existsDevice(Id, Id)} always complete with + * {@code true};
    • + *
    • {@link #getAuthenticator()} accepts any caller (and, when a nonce/signature is supplied, + * verifies it against the id's key);
    • + *
    • {@link #getAuthorizer()} grants access with an empty details map;
    • + *
    • {@link #getWebAuthenticator()} returns a {@link CwtAuth} backed by the same allow-all + * provider, and therefore requires a non-null {@code nodeIdentity}.
    • + *
    * - * @param nodeIdentity the identity of the node allowing access; if null, generic allows-all behavior applies - * @return a {@link ClientContext} instance configured with permissive access rules. + * @param nodeIdentity the identity that will sign issued web tokens (required if + * {@link #getWebAuthenticator()} will be called) + * @return a permissive {@link ClientContext} */ static ClientContext allowAll(Identity nodeIdentity) { return new AllowAllClientContext(nodeIdentity); } /** - * Returns a new in-memory client context suitable for testing with Node Identity. - *

    - * Provides an in-memory simulation environment configured with a specific node identity - * but without Web Token Auth support. - *

    + * Returns an in-memory client context whose user and device registries are populated + * imperatively at test/bring-up time. {@link io.bosonnetwork.service.impl.StaticClientContext} + * exposes {@code addUser(...)}, {@code addDevice(...)}, {@code removeUser(...)}, etc. for + * fixture setup. {@link #getWebAuthenticator()} returns a real {@link CwtAuth} backed by the + * registry and requires a non-null {@code nodeIdentity}. * - * @param nodeIdentity the identity of the node to associate with this static context; if null, generic behavior applies - * @return a {@link ClientContext} instance using an in-memory map store for simulation purposes. + * @param nodeIdentity the identity that will sign issued web tokens (required if + * {@link #getWebAuthenticator()} will be called) + * @return an in-memory {@link ClientContext} */ static ClientContext staticContext(Identity nodeIdentity) { return new StaticClientContext(nodeIdentity); diff --git a/api/src/main/java/io/bosonnetwork/service/ClientUser.java b/api/src/main/java/io/bosonnetwork/service/ClientUser.java index 86a1bb9c..af8b4284 100644 --- a/api/src/main/java/io/bosonnetwork/service/ClientUser.java +++ b/api/src/main/java/io/bosonnetwork/service/ClientUser.java @@ -94,4 +94,13 @@ public interface ClientUser { * @return the plan name */ String getPlanName(); + + /** + * Determines if the user has administrative privileges. + * + * @return {@code true} if the user is an administrator, {@code false} otherwise + */ + default boolean isAdmin() { + return false; + } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java b/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java deleted file mode 100644 index fb395617..00000000 --- a/api/src/main/java/io/bosonnetwork/service/DefaultServiceContext.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Copyright (c) 2022 - 2023 trinity-tech.io - * 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; - -import java.nio.file.Path; -import java.util.HashMap; -import java.util.Map; - -import io.vertx.core.Vertx; - -import io.bosonnetwork.Id; -import io.bosonnetwork.Node; - -/** - * Default implementation of the {@link ServiceContext} interface. - *

    - * This class provides a standard implementation for accessing service context information, - * including the Vert.x instance, the Boson node, authentication/authorization components, - * federation details, configuration, and data persistence paths. - */ -public class DefaultServiceContext implements ServiceContext { - private final Vertx vertx; - private final Node node; - private final ClientContext clientContext; - private final FederationContext federationContext; - private final Map configuration; - private final Path dataDir; - private Map properties; - - /** - * Creates a new {@link ServiceContext} instance. - * - * @param vertx the Vert.x instance to be used - * @param node the host Boson node - * @param clientContext the clients context instance - * @param federationContext the federation context instance - * @param configuration the configuration data for the service - * @param dataDir the path to the persistence data directory, or {@code null} if not available - */ - public DefaultServiceContext(Vertx vertx, Node node, ClientContext clientContext, - FederationContext federationContext, Map configuration, Path dataDir) { - this.vertx = vertx; - this.node = node; - this.clientContext = clientContext; - this.federationContext = federationContext; - this.configuration = configuration; - this.dataDir = dataDir; - } - - /** - * {@inheritDoc} - */ - @Override - public Vertx getVertx() { - return vertx; - } - - /** - * {@inheritDoc} - */ - @Override - public Node getNode() { - return node; - } - - /** - * {@inheritDoc} - */ - @Override - public Id getNodeId() { - return node.getId(); - } - - /** - * {@inheritDoc} - */ - @Override - public Path getDataDir() { - return dataDir; - } - - /** - * {@inheritDoc} - */ - @Override - public ClientContext getClientContext() { - return clientContext; - } - - /** - * {@inheritDoc} - */ - @Override - public FederationContext getFederationContext() { - return federationContext; - } - - /** - * {@inheritDoc} - */ - @Override - public Map getConfiguration() { - return configuration; - } - - private Map properties() { - return properties == null ? properties = new HashMap<>() : properties; - } - - /** - * {@inheritDoc} - */ - @Override - public Object setProperty(Object key, Object value) { - return properties().put(key, value); - } - - /** - * {@inheritDoc} - */ - @Override - @SuppressWarnings("unchecked") - public T getProperty(Object key) { - return (T) properties().get(key); - } -} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/FederationAuthenticator.java b/api/src/main/java/io/bosonnetwork/service/FederationAuthenticator.java index 4186520f..79f72f3b 100644 --- a/api/src/main/java/io/bosonnetwork/service/FederationAuthenticator.java +++ b/api/src/main/java/io/bosonnetwork/service/FederationAuthenticator.java @@ -22,37 +22,49 @@ package io.bosonnetwork.service; -import java.util.List; -import java.util.Map; -import java.util.Objects; import java.util.concurrent.CompletableFuture; import io.bosonnetwork.Id; -import io.bosonnetwork.vertx.VertxFuture; /** * Interface for authenticating nodes and peers within a federation. *

    - * Implementations of this interface provide mechanisms to verify the identity of other nodes - * in the federation using cryptographic challenges and signatures. + * Implementations verify the identity of other nodes in the federation using cryptographic + * challenges and signatures. + *

    + * Nonce/signature contract. The {@code (id, nonce, signature)} overloads accept + * three argument shapes: + *

      + *
    • Both {@code nonce} and {@code signature} non-null — the implementation MUST verify the + * signature against the nonce using the id's signing key, and return the verification result.
    • + *
    • Both {@code nonce} and {@code signature} null — "pre-authenticated" mode: the caller has + * already verified the identity out of band (typically at the transport layer) and is asking + * only whether the id is admissible. The implementation MUST NOT treat the absence of a + * signature as a failure; it should apply its admission policy (membership, allow-list, etc.) + * and return that. The no-nonce default overloads delegate to this mode.
    • + *
    • Exactly one of {@code nonce} / {@code signature} is null — caller bug; the implementation + * MUST return {@code false}.
    • + *
    */ public interface FederationAuthenticator { /** - * Authenticates a node in the federation. + * Authenticates a node in the federation. See the + * {@linkplain FederationAuthenticator interface Javadoc} for the nonce/signature contract. * * @param nodeId the unique identifier of the node to be authenticated - * @param nonce the random challenge data (nonce) used for authentication - * @param signature the digital signature of the nonce, generated using the node's private key - * @return a {@link CompletableFuture} that completes with {@code true} if the node is successfully authenticated, + * @param nonce the challenge data, or {@code null} for pre-authenticated mode + * @param signature the signature over {@code nonce}, or {@code null} for pre-authenticated mode + * @return a {@link CompletableFuture} that completes with {@code true} if the node is admitted, * or {@code false} otherwise */ CompletableFuture authenticateNode(Id nodeId, byte[] nonce, byte[] signature); /** - * Authenticates a node in the federation. + * Convenience for pre-authenticated mode — equivalent to + * {@link #authenticateNode(Id, byte[], byte[]) authenticateNode(nodeId, null, null)}. * - * @param nodeId the unique identifier of the node to be authenticated - * @return a {@link CompletableFuture} that completes with {@code true} if the node is successfully authenticated, + * @param nodeId the unique identifier of the node to be authenticated + * @return a {@link CompletableFuture} that completes with {@code true} if the node is admitted, * or {@code false} otherwise */ default CompletableFuture authenticateNode(Id nodeId) { @@ -60,98 +72,28 @@ default CompletableFuture authenticateNode(Id nodeId) { } /** - * Authenticates a peer associated with a node in the federation. + * Authenticates a peer associated with a node in the federation. See the + * {@linkplain FederationAuthenticator interface Javadoc} for the nonce/signature contract. * * @param nodeId the unique identifier of the node managing the peer * @param peerId the unique identifier of the peer to be authenticated - * @param nonce the random challenge data (nonce) used for authentication - * @param signature the digital signature of the nonce, generated using the peer's private key - * @return a {@link CompletableFuture} that completes with {@code true} if the peer is successfully authenticated, + * @param nonce the challenge data, or {@code null} for pre-authenticated mode + * @param signature the signature over {@code nonce}, or {@code null} for pre-authenticated mode + * @return a {@link CompletableFuture} that completes with {@code true} if the peer is admitted, * or {@code false} otherwise */ CompletableFuture authenticatePeer(Id nodeId, Id peerId, byte[] nonce, byte[] signature); /** - * Authenticates a peer associated with a node in the federation. + * Convenience for pre-authenticated mode — equivalent to + * {@link #authenticatePeer(Id, Id, byte[], byte[]) authenticatePeer(nodeId, peerId, null, null)}. * - * @param nodeId the unique identifier of the node managing the peer - * @param peerId the unique identifier of the peer to be authenticated - * @return a {@link CompletableFuture} that completes with {@code true} if the peer is successfully authenticated, + * @param nodeId the unique identifier of the node managing the peer + * @param peerId the unique identifier of the peer to be authenticated + * @return a {@link CompletableFuture} that completes with {@code true} if the peer is admitted, * or {@code false} otherwise */ default CompletableFuture authenticatePeer(Id nodeId, Id peerId) { return authenticatePeer(nodeId, peerId, null, null); } - - /** - * Provides a FederationAuthenticator implementation that allows all authentication attempts. - * The returned authenticator verifies the provided signature against the corresponding - * signature key derived from the node or peer ID, enabling universal authentication - * acceptance when the signature is valid. - * - * @return a FederationAuthenticator instance that performs authentication by signature verification - */ - static FederationAuthenticator allowAll() { - return new FederationAuthenticator() { - @Override - public CompletableFuture authenticateNode(Id nodeId, byte[] nonce, byte[] signature) { - Objects.requireNonNull(nodeId, "nodeId"); - - boolean valid = nonce == null || signature == null || nodeId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); - } - - @Override - public CompletableFuture authenticatePeer(Id nodeId, Id peerId, byte[] nonce, byte[] signature) { - Objects.requireNonNull(nodeId, "nodeId"); - Objects.requireNonNull(peerId, "peerId"); - - boolean valid = nonce == null || signature == null || peerId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); - } - }; - } - - /** - * Provides a FederationAuthenticator implementation that restricts successful - * authentication based on the supplied nodeServicesMap. The returned - * FederationAuthenticator validates that the provided node or peer ID is - * included in the nodeServicesMap and verifies the associated digital signature. - * - * @param nodeServicesMap a map where each key is a node ID, and each value is - * a list of peer IDs associated with that node. This - * map is used to determine whether a given node or - * peer is authorized for authentication. - * @return a FederationAuthenticator instance that authenticates nodes and peers - * according to the provided nodeServicesMap and performs signature - * verification. - */ - static FederationAuthenticator allow(Map> nodeServicesMap) { - Objects.requireNonNull(nodeServicesMap, "nodeServicesMap"); - - return new FederationAuthenticator() { - @Override - public CompletableFuture authenticateNode(Id nodeId, byte[] nonce, byte[] signature) { - Objects.requireNonNull(nodeId, "nodeId"); - - if (!nodeServicesMap.containsKey(nodeId)) - return VertxFuture.succeededFuture(false); - - boolean valid = nonce == null || signature == null || nodeId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); - } - - @Override - public CompletableFuture authenticatePeer(Id nodeId, Id peerId, byte[] nonce, byte[] signature) { - Objects.requireNonNull(nodeId, "nodeId"); - Objects.requireNonNull(peerId, "peerId"); - - if (!nodeServicesMap.containsKey(nodeId) || !nodeServicesMap.get(nodeId).contains(peerId)) - return VertxFuture.succeededFuture(false); - - boolean valid = nonce == null || signature == null || peerId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); - } - }; - } } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/FederationContext.java b/api/src/main/java/io/bosonnetwork/service/FederationContext.java index 47123c47..8dcd86b2 100644 --- a/api/src/main/java/io/bosonnetwork/service/FederationContext.java +++ b/api/src/main/java/io/bosonnetwork/service/FederationContext.java @@ -89,12 +89,12 @@ default CompletableFuture getNode(Id nodeId) { CompletableFuture existsNode(Id nodeId); /** - * Retrieves information about a specific service hosted by a federated node. + * Retrieves the services hosted by a specific federated node for the given peer. * - * @param nodeId the unique identifier of the node hosting the service * @param peerId the unique identifier of the service peer + * @param nodeId the unique identifier of the federated node hosting the service * @return a {@link CompletableFuture} that completes with the list of {@link ServiceInfo} if found, - * or completes exceptionally/with null if the service cannot be located + * an empty list if no services match, or completes exceptionally on error */ CompletableFuture> getServices(Id peerId, Id nodeId); @@ -154,38 +154,50 @@ default CompletableFuture> getServices(Id peerId) { CwtAuth getWebAuthenticator(); /** - * Creates and returns a disabled instance of FederationContext. - * This method is used to obtain a context object that represents - * a disabled federation state. + * Returns a federation context that reports the federation feature as turned off — for use + * by services that do not federate. Lookup methods complete with empty/{@code null} results + * (no node or service is ever found); {@link #getAuthenticator()} rejects every challenge; and + * {@link #getWebAuthenticator()} returns {@code null}. * - * @return a disabled FederationContext instance + * @return a disabled {@link FederationContext} */ static FederationContext disabled() { return new DisabledFederationContext(); } /** - * Creates and returns a {@link FederationContext} that allows all operations - * without requiring web token authentication. This method is intended for use - * in scenarios where unrestricted access is permitted, bypassing authentication mechanisms. + * Returns an "allow-all" federation context — intended for development, smoke tests, and + * bring-up where peer/service discovery is faked. Concretely: + *
      + *
    • {@link #getNode(Id, boolean)} synthesizes a {@code SuperNodeInfo} for any requested id, + * and {@link #existsNode(Id)} always returns {@code true};
    • + *
    • {@link #getServices(Id, Id)} returns a single synthesized {@code ServiceInfo} for the + * requested peer/node pair; the federation-aware overloads behave the same;
    • + *
    • {@link #reportIncident(Id, Id, IncidentType, String) reportIncident} is a no-op;
    • + *
    • {@link #getAuthenticator()} verifies any non-null nonce/signature against the id's key + * and accepts the pre-authenticated mode;
    • + *
    • {@link #getWebAuthenticator()} returns a {@link CwtAuth} backed by the same synthesized + * provider; {@code nodeIdentity} must be non-null when this is called.
    • + *
    * - * @param nodeIdentity the {@link Identity} representing the node's identity in the federation context - * @return a {@link FederationContext} instance that allows all operations while bypassing web token authentication + * @param nodeIdentity the identity that will sign issued web tokens (required if + * {@link #getWebAuthenticator()} will be called) + * @return a permissive {@link FederationContext} */ static FederationContext allowAll(Identity nodeIdentity) { return new AllowAllFederationContext(nodeIdentity); } /** - * Creates and returns a static {@link FederationContext} instance that is based - * on a specific node identity. This method is suitable for scenarios requiring - * static federation configuration for a particular node without the need for - * token-based authentication mechanisms. + * Returns an in-memory federation context whose node and service registries are populated + * imperatively at test/bring-up time + * ({@link io.bosonnetwork.service.impl.StaticFederationContext} exposes + * {@code addNode(...)}, {@code addService(...)}, etc.). {@link #getWebAuthenticator()} returns + * a real {@link CwtAuth} backed by the registry and requires a non-null {@code nodeIdentity}. * - * @param nodeIdentity the {@link Identity} representing the identity of the node - * within the federation context. - * @return a {@link FederationContext} instance configured to use the specified - * node identity and operate with a static federation configuration. + * @param nodeIdentity the identity that will sign issued web tokens (required if + * {@link #getWebAuthenticator()} will be called) + * @return an in-memory {@link FederationContext} */ static FederationContext staticContext(Identity nodeIdentity) { return new StaticFederationContext(nodeIdentity); diff --git a/api/src/main/java/io/bosonnetwork/service/Role.java b/api/src/main/java/io/bosonnetwork/service/Role.java new file mode 100644 index 00000000..45871f77 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/service/Role.java @@ -0,0 +1,61 @@ +/* + * 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; + +/** + * Enum representing the authorization roles assigned to an authenticated principal. + *

    + * A role describes what kind of principal is authenticated, derived on the server + * from the resolved client identity. Roles are populated into the Vert.x + * {@code User.authorizations()} (as {@code RoleBasedAuthorization}s) and enforced by route + * {@code AuthorizationHandler}s. They are a server-internal vocabulary, distinct from the + * over-the-wire {@link AccessScope} token scope values. + */ +public enum Role { + /** + * Role for a standard client principal (a user or one of the user's devices). Grants access to + * non-administrative client APIs. + */ + CLIENT("client"), + /** + * Role for an administrative user. Granted only when the resolved {@link ClientUser} reports + * {@link ClientUser#isAdmin()}; grants access to administrative APIs. + */ + ADMIN("admin"), + /** + * Role for a federation peer (a super node or a federated service). Grants access to + * inter-node / inter-service federation APIs. + */ + FEDERATION("federation"); + + private final String value; + + Role(String value) { + this.value = value; + } + + @Override + public String toString() { + return value; + } +} diff --git a/api/src/main/java/io/bosonnetwork/service/ServiceContext.java b/api/src/main/java/io/bosonnetwork/service/ServiceContext.java index c6bdfc71..3ab10b20 100644 --- a/api/src/main/java/io/bosonnetwork/service/ServiceContext.java +++ b/api/src/main/java/io/bosonnetwork/service/ServiceContext.java @@ -26,8 +26,6 @@ import java.nio.file.Path; import java.util.Map; -import io.vertx.core.Vertx; - import io.bosonnetwork.Id; import io.bosonnetwork.Node; @@ -37,11 +35,14 @@ */ public interface ServiceContext { /** - * Get the Vert.x instance to be used by the current DHT node. + * Unwraps the context to provide the underlying infrastructure instance. * - * @return the {@link Vertx} instance. + * @param clazz the type of the infrastructure component (e.g. io.vertx.core.Vertx) + * @return the component instance, or null if not available or not supported + * @param the type parameter */ - Vertx getVertx(); + T unwrap(Class clazz); + /** * Gets the host Boson node object. * @@ -87,22 +88,23 @@ public interface ServiceContext { Map getConfiguration(); /** - * Set the service runtime property + * Sets a service runtime property. Properties form an in-memory key/value side-channel that + * outlives a single request but does not persist across restarts. Implementations should be + * thread-safe. * - * @param key the property key. - * @param value the new value to be associated with the property name. - * @return the previous value associated with {@code key}. + * @param key the property name (non-null) + * @param value the new value, or {@code null} to remove the mapping + * @return the previous value associated with {@code key}, or {@code null} if there was none */ - Object setProperty(Object key, Object value); + Object setProperty(String key, Object value); /** - * Returns the value to which the specified key is mapped, or {@code null} if the service - * contains no property value for the key. + * Returns the value associated with the specified property key, or {@code null} if no mapping + * exists. The caller is responsible for the cast; this is a convenience for typed read. * - * @param key the property key. - * @param the type of the property value. - * @return the value of the specified property, or - * {@code null} if the service contains no mapping for the property. + * @param key the property name (non-null) + * @param the expected type of the property value + * @return the value of the specified property, or {@code null} if no mapping exists */ - T getProperty(Object key); + T getProperty(String key); } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java b/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java index 18118e8d..6a13af89 100644 --- a/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java +++ b/api/src/main/java/io/bosonnetwork/service/ServiceInfo.java @@ -69,16 +69,21 @@ public interface ServiceInfo { boolean hasExtra(); /** - * Gets the extra data. + * Gets the extra data as a raw byte array. + *

    + * Implementations MUST return a defensive copy — mutating the returned array MUST NOT affect + * the internal state of this {@code ServiceInfo}. * - * @return the extra data + * @return a defensive copy of the extra data, or {@code null} if no extra data is present */ byte[] getExtraData(); /** - * Gets the extra data as a map. + * Gets the extra data parsed as a map. + *

    + * The returned map is immutable; modification attempts result in {@link UnsupportedOperationException}. * - * @return the extra data map + * @return an immutable map of extra data, or an empty map if no extra data is present */ Map getExtra(); diff --git a/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java b/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java index a5332a90..7e2edec2 100644 --- a/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java @@ -34,14 +34,14 @@ public interface SuperNodeInfo { /** * Gets the unique identifier of the federated node. * - * @return the node {@link Id} + * @return the node {@link Id}, never {@code null} */ Id getId(); /** * Gets the hostname or IP address of the node. * - * @return the host string + * @return the host string, never {@code null} */ String getHost(); @@ -55,56 +55,56 @@ public interface SuperNodeInfo { /** * Gets the API endpoint URL for the node. * - * @return the API endpoint string + * @return the API endpoint string, or {@code null} if not set */ String getApiEndpoint(); /** * Gets the name of the software running on the node. * - * @return the software name + * @return the software name, or {@code null} if not advertised */ String getSoftware(); /** * Gets the version of the software running on the node. * - * @return the software version + * @return the software version, or {@code null} if not advertised */ String getVersion(); /** * Gets the display name of the node. * - * @return the node name + * @return the node name, or {@code null} if not set */ String getName(); /** * Gets the URL or identifier for the node's logo. * - * @return the logo string + * @return the logo string, or {@code null} if not set */ String getLogo(); /** * Gets the website URL associated with the node. * - * @return the website URL + * @return the website URL, or {@code null} if not set */ String getWebsite(); /** * Gets the contact information for the node administrator. * - * @return the contact string + * @return the contact string, or {@code null} if not set */ String getContact(); /** * Gets the description of the node. * - * @return the node description + * @return the node description, or {@code null} if not set */ String getDescription(); @@ -116,23 +116,25 @@ public interface SuperNodeInfo { boolean isFederated(); /** - * Gets the reputation score of the node. + * Gets the reputation score of the node. Higher is better; the floor is zero, with no fixed + * upper bound — implementations choose the scale (typical scoring functions accumulate + * positive events and clamp negatives at zero). * - * @return the reputation score as an integer + * @return the reputation score, always {@code >= 0} */ int getReputation(); /** * Gets the timestamp when the node was added to the federation. * - * @return the creation timestamp in milliseconds + * @return the creation timestamp in milliseconds since the Unix epoch */ long getCreatedAt(); /** * Gets the timestamp when the node information was last updated. * - * @return the last update timestamp in milliseconds + * @return the last update timestamp in milliseconds since the Unix epoch */ long getUpdatedAt(); } \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/service/SuperNodeProfile.java b/api/src/main/java/io/bosonnetwork/service/SuperNodeProfile.java index a6329329..90993d1e 100644 --- a/api/src/main/java/io/bosonnetwork/service/SuperNodeProfile.java +++ b/api/src/main/java/io/bosonnetwork/service/SuperNodeProfile.java @@ -22,10 +22,8 @@ package io.bosonnetwork.service; -import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; -import java.net.URL; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -98,10 +96,10 @@ public static SuperNodeProfile fromCard(Card card) { Credential profile = card.getCredential(DEFAULT_PROFILE_CREDENTIAL_ID); if (profile != null && profile.getTypes().contains(DEFAULT_PROFILE_CREDENTIAL_TYPE)) { Map claims = profile.getSubject().getClaims(); - name = String.valueOf(claims.get("name")); - logo = String.valueOf(claims.get("logo")); - website = String.valueOf(claims.get("website")); - contact = String.valueOf(claims.get("contact")); + name = (String) claims.get("name"); + logo = (String) claims.get("logo"); + website = (String) claims.get("website"); + contact = (String) claims.get("contact"); } return new SuperNodeProfile(id, name, logo, website, contact, card); @@ -247,6 +245,7 @@ public Card getCard() { * @return a new builder instance */ public static Builder builder(Identity identity) { + Objects.requireNonNull(identity, "Identity cannot be null"); return new Builder(identity); } @@ -267,6 +266,36 @@ private Builder(Identity identity) { this.cardBuilder = Card.builder(identity); } + /** + * Validates that the endpoint URI is well-formed (has both a scheme and a host). Service + * types in a {@code SuperNodeProfile} may use different protocols (e.g. {@code http(s)} for + * the API and web gateway, {@code mqtts} for photon messaging), so this only enforces basic + * URI structure — individual callers may impose stricter rules on top. + * + * @param endpoint the endpoint URI to validate + * @throws IllegalArgumentException if the endpoint URI is missing a scheme or host + */ + private static void validateEndpoint(URI endpoint) { + if (endpoint.getScheme() == null || endpoint.getScheme().isEmpty() + || endpoint.getHost() == null || endpoint.getHost().isEmpty()) + throw new IllegalArgumentException("Invalid endpoint URI (missing scheme or host): " + endpoint); + } + + /** + * Validates that the endpoint URI is a well-formed {@code http} or {@code https} URL. Used + * for service types that speak HTTP over the wire (super-node API, web gateway, ion store). + * + * @param endpoint the endpoint URI to validate + * @throws IllegalArgumentException if the endpoint URI is missing a scheme or host, or its + * scheme is not {@code http} / {@code https} + */ + private static void validateHttpEndpoint(URI endpoint) { + validateEndpoint(endpoint); + String scheme = endpoint.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) + throw new IllegalArgumentException("Invalid HTTP endpoint scheme (must be http or https): " + endpoint); + } + /** * Sets the super node's name. * @@ -321,13 +350,7 @@ public Builder contact(String contact) { */ public Builder apiService(URI endpoint) { Objects.requireNonNull(endpoint); - try { - URL url = endpoint.toURL(); - if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https")) - throw new IllegalArgumentException("Invalid endpoint protocol"); - } catch (MalformedURLException e) { - throw new IllegalArgumentException("Invalid endpoint URL", e); - } + validateHttpEndpoint(endpoint); cardBuilder.addService(identity.getId().toBase58String(), SUPER_NODE_API_SERVICE_TYPE, endpoint.toString()); return this; } @@ -361,6 +384,7 @@ public Builder apiService(String endpoint) { public Builder webGatewayService(Id peerId, URI endpoint) { Objects.requireNonNull(peerId); Objects.requireNonNull(endpoint); + validateHttpEndpoint(endpoint); cardBuilder.addService(peerId.toBase58String(), WEB_GATEWAY_SERVICE_TYPE, endpoint.toString()); return this; } @@ -376,6 +400,7 @@ public Builder webGatewayService(Id peerId, URI endpoint) { public Builder ionStoreService(Id peerId, URI endpoint) { Objects.requireNonNull(peerId); Objects.requireNonNull(endpoint); + validateHttpEndpoint(endpoint); cardBuilder.addService(peerId.toBase58String(), ION_STORE_SERVICE_TYPE, endpoint.toString()); return this; } @@ -391,6 +416,7 @@ public Builder ionStoreService(Id peerId, URI endpoint) { public Builder photonMessagingService(Id peerId, URI endpoint) { Objects.requireNonNull(peerId); Objects.requireNonNull(endpoint); + validateEndpoint(endpoint); cardBuilder.addService(peerId.toBase58String(), PHOTON_MESSAGING_SERVICE_TYPE, endpoint.toString()); return this; } @@ -406,6 +432,7 @@ public Builder photonMessagingService(Id peerId, URI endpoint) { public Builder activeProxyService(Id peerId, URI endpoint) { Objects.requireNonNull(peerId); Objects.requireNonNull(endpoint); + validateEndpoint(endpoint); cardBuilder.addService(peerId.toBase58String(), ACTIVE_PROXY_SERVICE_TYPE, endpoint.toString()); return this; } @@ -423,6 +450,7 @@ public Builder service(Id peerId, String type, URI endpoint) { Objects.requireNonNull(peerId); Objects.requireNonNull(type); Objects.requireNonNull(endpoint); + validateEndpoint(endpoint); cardBuilder.addService(peerId.toBase58String(), type, endpoint.toString()); return this; } @@ -441,20 +469,27 @@ public Builder service(Id peerId, String type, URI endpoint, Map Objects.requireNonNull(peerId); Objects.requireNonNull(type); Objects.requireNonNull(endpoint); + validateEndpoint(endpoint); cardBuilder.addService(peerId.toBase58String(), type, endpoint.toString(), properties); return this; } /** * Builds a {@link SuperNodeProfile} instance. + *

    + * A display {@link #name(String) name} is mandatory — operators publishing a profile must + * declare who they are. The {@code logo}, {@code website}, and {@code contact} fields are + * optional and only emitted into the profile credential when set. * * @return a new super node profile - * @throws IllegalStateException if no profile metadata (name, logo, website, or contact) was provided + * @throws IllegalStateException if {@code name} has not been set */ public SuperNodeProfile build() { + if (name == null || name.isEmpty()) + throw new IllegalStateException("Super node profile name is required"); + Map claims = new LinkedHashMap<>(); - if (name != null) - claims.put("name", name); + claims.put("name", name); if (logo != null) claims.put("logo", logo); if (website != null) @@ -462,9 +497,6 @@ public SuperNodeProfile build() { if (contact != null) claims.put("contact", contact); - if (claims.isEmpty()) - throw new IllegalStateException("No profile data provided"); - cardBuilder.addCredential(DEFAULT_PROFILE_CREDENTIAL_ID, DEFAULT_PROFILE_CREDENTIAL_TYPE, claims); Card card = cardBuilder.build(); diff --git a/api/src/main/java/io/bosonnetwork/service/impl/AllowAllClientContext.java b/api/src/main/java/io/bosonnetwork/service/impl/AllowAllClientContext.java index c7435c46..e943e758 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/AllowAllClientContext.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/AllowAllClientContext.java @@ -22,7 +22,6 @@ package io.bosonnetwork.service.impl; -import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -35,7 +34,7 @@ import io.bosonnetwork.service.ClientContext; import io.bosonnetwork.service.ClientDevice; import io.bosonnetwork.service.ClientUser; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; import io.bosonnetwork.web.ClientProvider; import io.bosonnetwork.web.CwtAuth; import io.bosonnetwork.web.CwtAuthOptions; @@ -62,9 +61,9 @@ public class AllowAllClientContext implements ClientContext { * Constructs an instance of {@code AllowAllClientContext} with the provided node identity. * * @param nodeIdentity the {@link Identity} associated with this client context. - * This identity represents the cryptographic entity - * used within the context, enabling signing, verification, - * encryption, and decryption operations. + * This identity represents the cryptographic entity + * used within the context, enabling signing, verification, + * encryption, and decryption operations. */ public AllowAllClientContext(Identity nodeIdentity) { this.nodeIdentity = nodeIdentity; @@ -72,35 +71,22 @@ public AllowAllClientContext(Identity nodeIdentity) { @Override public CompletableFuture getUser(Id userId) { - return VertxFuture.succeededFuture(new PlainUser(userId)); + return ContextualFuture.succeededFuture(new PlainUser(userId)); } @Override public CompletableFuture existsUser(Id userId) { - return VertxFuture.succeededFuture(true); + return ContextualFuture.succeededFuture(true); } @Override - public CompletableFuture> getDevices(Id userId) { - throw new UnsupportedOperationException("getDevices is not supported"); - // return VertxFuture.succeededFuture(List.of()); - } - - @Override - public CompletableFuture getDevice(Id deviceId) { - throw new UnsupportedOperationException("getDevice is not supported"); - // return VertxFuture.succeededFuture(); - } - - @Override - public CompletableFuture existsDevice(Id deviceId) { - throw new UnsupportedOperationException("existsDevice is not supported"); - //return VertxFuture.succeededFuture(true); + public CompletableFuture getDevice(Id userId, Id deviceId) { + return ContextualFuture.succeededFuture(new PlainDevice(deviceId, userId)); } @Override public CompletableFuture existsDevice(Id userId, Id deviceId) { - return VertxFuture.succeededFuture(true); + return ContextualFuture.succeededFuture(true); } @Override @@ -108,13 +94,15 @@ public ClientAuthenticator getAuthenticator() { return new ClientAuthenticator() { @Override public CompletableFuture authenticateUser(Id userId, byte[] nonce, byte[] signature) { - boolean isValid = nonce == null || signature == null || userId.toSignatureKey().verify(nonce, signature); + boolean isValid = (nonce == null && signature == null) || + (nonce != null && signature != null && userId.toSignatureKey().verify(nonce, signature)); return CompletableFuture.completedFuture(isValid); } @Override public CompletableFuture authenticateDevice(Id userId, Id deviceId, byte[] nonce, byte[] signature, String address) { - boolean isValid = nonce == null || signature == null || deviceId.toSignatureKey().verify(nonce, signature); + boolean isValid = (nonce == null && signature == null) || + (nonce != null && signature != null && deviceId.toSignatureKey().verify(nonce, signature)); return CompletableFuture.completedFuture(isValid); } }; @@ -122,7 +110,7 @@ public CompletableFuture authenticateDevice(Id userId, Id deviceId, byt @Override public ClientAuthorizer getAuthorizer() { - return (userId, deviceId, serviceType) -> VertxFuture.succeededFuture(Map.of()); + return (userId, deviceId, serviceType) -> ContextualFuture.succeededFuture(Map.of()); } @Override diff --git a/api/src/main/java/io/bosonnetwork/service/impl/AllowAllFederationContext.java b/api/src/main/java/io/bosonnetwork/service/impl/AllowAllFederationContext.java index f88cc391..251c9079 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/AllowAllFederationContext.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/AllowAllFederationContext.java @@ -33,7 +33,7 @@ import io.bosonnetwork.service.FederationAuthenticator; import io.bosonnetwork.service.FederationContext; import io.bosonnetwork.service.ServiceInfo; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; import io.bosonnetwork.web.CwtAuth; import io.bosonnetwork.web.ClientProvider; import io.bosonnetwork.web.CwtAuthOptions; @@ -71,27 +71,27 @@ private ServiceInfo _getService(Id peerId, Id nodeId) { @Override public CompletableFuture getNode(Id nodeId, boolean tryFederateIfNotExists) { - return VertxFuture.succeededFuture(_getNode(nodeId)); + return ContextualFuture.succeededFuture(_getNode(nodeId)); } @Override public CompletableFuture existsNode(Id nodeId) { - return VertxFuture.succeededFuture(true); + return ContextualFuture.succeededFuture(true); } @Override public CompletableFuture> getServices(Id peerId, Id nodeId) { - return VertxFuture.succeededFuture(List.of(_getService(peerId, nodeId))); + return ContextualFuture.succeededFuture(List.of(_getService(peerId, nodeId))); } @Override public CompletableFuture> getServices(Id peerId, boolean tryFederateIfNotExists) { - return VertxFuture.succeededFuture(List.of()); + return ContextualFuture.succeededFuture(List.of()); } @Override public CompletableFuture reportIncident(Id nodeId, Id peerId, IncidentType incident, String details) { - return VertxFuture.succeededFuture(); + return ContextualFuture.succeededFuture(); } @Override @@ -99,14 +99,16 @@ public FederationAuthenticator getAuthenticator() { return new FederationAuthenticator() { @Override public CompletableFuture authenticateNode(Id nodeId, byte[] nonce, byte[] signature) { - boolean valid = nonce == null || signature == null || nodeId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); + boolean valid = (nonce == null && signature == null) || + (nonce != null && signature != null && nodeId.toSignatureKey().verify(nonce, signature)); + return ContextualFuture.succeededFuture(valid); } @Override public CompletableFuture authenticatePeer(Id nodeId, Id peerId, byte[] nonce, byte[] signature) { - boolean valid = nonce == null || signature == null || peerId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); + boolean valid = (nonce == null && signature == null) || + (nonce != null && signature != null && peerId.toSignatureKey().verify(nonce, signature)); + return ContextualFuture.succeededFuture(valid); } }; } diff --git a/api/src/main/java/io/bosonnetwork/service/impl/DisabledFederationContext.java b/api/src/main/java/io/bosonnetwork/service/impl/DisabledFederationContext.java index 052b1995..c2a67a0c 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/DisabledFederationContext.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/DisabledFederationContext.java @@ -33,7 +33,7 @@ import io.bosonnetwork.service.FederationAuthenticator; import io.bosonnetwork.service.FederationContext; import io.bosonnetwork.service.ServiceInfo; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; import io.bosonnetwork.web.CwtAuth; import io.bosonnetwork.web.ClientProvider; import io.bosonnetwork.web.CwtAuthOptions; @@ -54,27 +54,27 @@ public class DisabledFederationContext implements FederationContext { @Override public CompletableFuture getNode(Id nodeId, boolean tryFederateIfNotExists) { - return VertxFuture.succeededFuture(null); + return ContextualFuture.succeededFuture(null); } @Override public CompletableFuture existsNode(Id nodeId) { - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); } @Override public CompletableFuture> getServices(Id peerId, Id nodeId) { - return VertxFuture.succeededFuture(List.of()); + return ContextualFuture.succeededFuture(List.of()); } @Override public CompletableFuture> getServices(Id peerId, boolean tryFederateIfNotExists) { - return VertxFuture.succeededFuture(List.of()); + return ContextualFuture.succeededFuture(List.of()); } @Override public CompletableFuture reportIncident(Id nodeId, Id peerId, IncidentType incident, String details) { - return VertxFuture.succeededFuture(); + return ContextualFuture.succeededFuture(); } @Override @@ -82,12 +82,12 @@ public FederationAuthenticator getAuthenticator() { return new FederationAuthenticator() { @Override public CompletableFuture authenticateNode(Id nodeId, byte[] nonce, byte[] signature) { - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); } @Override public CompletableFuture authenticatePeer(Id nodeId, Id peerId, byte[] nonce, byte[] signature) { - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); } }; } diff --git a/api/src/main/java/io/bosonnetwork/service/impl/PlainServiceInfo.java b/api/src/main/java/io/bosonnetwork/service/impl/PlainServiceInfo.java index a4823aeb..73e1f696 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/PlainServiceInfo.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/PlainServiceInfo.java @@ -38,19 +38,19 @@ public class PlainServiceInfo implements ServiceInfo { private final long fingerprint; private final Id nodeId; private final String endpoint; - private final String serviceId; + private final String serviceType; private final String serviceName; PlainServiceInfo(Id peerId, long fingerprint, Id nodeId, String endpoint) { this(peerId, fingerprint, nodeId, endpoint, null, null); } - PlainServiceInfo(Id peerId, long fingerprint, Id nodeId, String endpoint, String serviceId, String serviceName) { + PlainServiceInfo(Id peerId, long fingerprint, Id nodeId, String endpoint, String serviceType, String serviceName) { this.peerId = Objects.requireNonNull(peerId); this.fingerprint = fingerprint; this.nodeId = Objects.requireNonNull(nodeId); this.endpoint = Objects.requireNonNull(endpoint); - this.serviceId = serviceId == null || serviceId.isEmpty() ? peerId.toString() : serviceId; + this.serviceType = serviceType == null || serviceType.isEmpty() ? peerId.toString() : serviceType; this.serviceName = serviceName == null || serviceName.isEmpty() ? peerId.toAbbrBase58String() : serviceName; } @@ -91,7 +91,7 @@ public Map getExtra() { @Override public String getServiceType() { - return serviceId; + return serviceType; } @Override diff --git a/api/src/main/java/io/bosonnetwork/service/impl/StaticClientContext.java b/api/src/main/java/io/bosonnetwork/service/impl/StaticClientContext.java index 0ded1c99..9268b63b 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/StaticClientContext.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/StaticClientContext.java @@ -40,7 +40,7 @@ import io.bosonnetwork.service.ClientUser; import io.bosonnetwork.utils.Pair; import io.bosonnetwork.utils.Variable; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; import io.bosonnetwork.web.ClientProvider; import io.bosonnetwork.web.CwtAuth; import io.bosonnetwork.web.CwtAuthOptions; @@ -71,6 +71,14 @@ public StaticClientContext(Identity nodeIdentity) { /** * Adds a new user to the user registry if they do not already exist. + *

    + * Concurrency note: the up-front existence check and the + * {@code computeIfAbsent} insert are not a single atomic step. Two threads calling + * {@code addUser(sameId, ...)} concurrently can both observe "not present" and both return + * {@code true}; the {@code computeIfAbsent} call still atomizes the actual write, so only one + * {@link PlainUser} entry is inserted — the race is benign with respect to map state but the + * boolean return may over-report success. This is a test/bring-up helper; this trade-off is + * acceptable for that use. * * @param userId The unique identifier of the user to be added. Must not be null. * @param name The name of the user to be added. @@ -130,6 +138,12 @@ public boolean removeUser(Id userId) { /** * Adds a new device to the user registry for a specified user. If the device already exists * globally, the addition will fail and return false. The user must already exist in the registry. + *

    + * Concurrency note: as with {@link #addUser(Id, String, String) addUser}, + * the up-front {@code existsDeviceSync} check and the subsequent {@code compute} insert are + * not a single atomic step; concurrent {@code addDevice} calls for the same {@code deviceId} + * can both return {@code true} even though only one entry is actually inserted. The race is + * benign with respect to map state and acceptable for this test/bring-up helper. * * @param userId The unique identifier of the user to which the device will be added. Must not be null. * @param deviceId The global unique identifier of the device to be added. Must not be null. @@ -291,35 +305,22 @@ public void clear() { @Override public CompletableFuture getUser(Id userId) { - return VertxFuture.succeededFuture(getUserSync(userId)); + return ContextualFuture.succeededFuture(getUserSync(userId)); } @Override public CompletableFuture existsUser(Id userId) { - return VertxFuture.succeededFuture(existsUserSync(userId)); + return ContextualFuture.succeededFuture(existsUserSync(userId)); } @Override - public CompletableFuture> getDevices(Id userId) { - throw new UnsupportedOperationException("getDevices is not supported"); - // return VertxFuture.succeededFuture(_getDevices(userId)); - } - - @Override - public CompletableFuture getDevice(Id deviceId) { - throw new UnsupportedOperationException("getDevice is not supported"); - // return VertxFuture.succeededFuture(_getDevice(deviceId)); - } - - @Override - public CompletableFuture existsDevice(Id deviceId) { - throw new UnsupportedOperationException("existsDevice is not supported"); - //return VertxFuture.completedFuture(_existsDevice(deviceId)); + public CompletableFuture getDevice(Id userId, Id deviceId) { + return ContextualFuture.succeededFuture(getDeviceSync(userId, deviceId)); } @Override public CompletableFuture existsDevice(Id userId, Id deviceId) { - return VertxFuture.completedFuture(existsDeviceSync(userId, deviceId)); + return ContextualFuture.succeededFuture(existsDeviceSync(userId, deviceId)); } @Override @@ -328,26 +329,28 @@ public ClientAuthenticator getAuthenticator() { @Override public CompletableFuture authenticateUser(Id userId, byte[] nonce, byte[] signature) { if (!existsUserSync(userId)) - return CompletableFuture.completedFuture(false); + return ContextualFuture.succeededFuture(false); - boolean isValid = nonce == null || signature == null || userId.toSignatureKey().verify(nonce, signature); - return CompletableFuture.completedFuture(isValid); + boolean isValid = (nonce == null && signature == null) || + (nonce != null && signature != null && userId.toSignatureKey().verify(nonce, signature)); + return ContextualFuture.succeededFuture(isValid); } @Override public CompletableFuture authenticateDevice(Id userId, Id deviceId, byte[] nonce, byte[] signature, String address) { if (!existsDeviceSync(userId, deviceId)) - return CompletableFuture.completedFuture(false); + return ContextualFuture.succeededFuture(false); - boolean isValid = nonce == null || signature == null || deviceId.toSignatureKey().verify(nonce, signature); - return CompletableFuture.completedFuture(isValid); + boolean isValid = (nonce == null && signature == null) || + (nonce != null && signature != null && deviceId.toSignatureKey().verify(nonce, signature)); + return ContextualFuture.succeededFuture(isValid); } }; } @Override public ClientAuthorizer getAuthorizer() { - return (userId, deviceId, serviceType) -> VertxFuture.succeededFuture(Map.of()); + return (userId, deviceId, serviceType) -> ContextualFuture.succeededFuture(Map.of()); } @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 d37c8d50..48a076d1 100644 --- a/api/src/main/java/io/bosonnetwork/service/impl/StaticFederationContext.java +++ b/api/src/main/java/io/bosonnetwork/service/impl/StaticFederationContext.java @@ -39,7 +39,7 @@ import io.bosonnetwork.service.ServiceInfo; import io.bosonnetwork.utils.Pair; import io.bosonnetwork.utils.Variable; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; import io.bosonnetwork.web.CwtAuth; import io.bosonnetwork.web.ClientProvider; import io.bosonnetwork.web.CwtAuthOptions; @@ -348,27 +348,27 @@ public boolean removeServices(Id peerId, Id nodeId) { @Override public CompletableFuture getNode(Id nodeId, boolean tryFederateIfNotExists) { - return VertxFuture.succeededFuture(getNodeSync(nodeId)); + return ContextualFuture.succeededFuture(getNodeSync(nodeId)); } @Override public CompletableFuture existsNode(Id nodeId) { - return VertxFuture.succeededFuture(existsNodeSync(nodeId)); + return ContextualFuture.succeededFuture(existsNodeSync(nodeId)); } @Override public CompletableFuture> getServices(Id peerId, Id nodeId) { - return VertxFuture.succeededFuture(getServicesSync(peerId, nodeId)); + return ContextualFuture.succeededFuture(getServicesSync(peerId, nodeId)); } @Override public CompletableFuture> getServices(Id peerId, boolean tryFederateIfNotExists) { - return VertxFuture.succeededFuture(getServicesSync(peerId)); + return ContextualFuture.succeededFuture(getServicesSync(peerId)); } @Override public CompletableFuture reportIncident(Id nodeId, Id peerId, IncidentType incident, String details) { - return VertxFuture.succeededFuture(); + return ContextualFuture.succeededFuture(); } @Override @@ -377,19 +377,21 @@ public FederationAuthenticator getAuthenticator() { @Override public CompletableFuture authenticateNode(Id nodeId, byte[] nonce, byte[] signature) { if (!existsNodeSync(nodeId)) - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); - boolean valid = nonce == null || signature == null || nodeId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); + boolean valid = (nonce == null && signature == null) || + (nonce != null && signature != null && nodeId.toSignatureKey().verify(nonce, signature)); + return ContextualFuture.succeededFuture(valid); } @Override public CompletableFuture authenticatePeer(Id nodeId, Id peerId, byte[] nonce, byte[] signature) { if (!existsServiceSync(peerId, nodeId)) - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); - boolean valid = nonce == null || signature == null || peerId.toSignatureKey().verify(nonce, signature); - return VertxFuture.succeededFuture(valid); + boolean valid = (nonce == null && signature == null) || + (nonce != null && signature != null && peerId.toSignatureKey().verify(nonce, signature)); + return ContextualFuture.succeededFuture(valid); } }; } diff --git a/api/src/main/java/io/bosonnetwork/service/package-info.java b/api/src/main/java/io/bosonnetwork/service/package-info.java index 8e555d5c..09c08d9b 100644 --- a/api/src/main/java/io/bosonnetwork/service/package-info.java +++ b/api/src/main/java/io/bosonnetwork/service/package-info.java @@ -22,6 +22,66 @@ */ /** - * The abstraction layer and APIs for the Boson services. + * Public APIs for building and hosting layer-2 services on top of a Boson super node. + *

    + * A layer-2 service (web gateway, ion store, photon messaging, custom services, …) is published + * by an operator's super node and consumed by end users (clients) or by other super nodes + * (federation peers). This package defines the contracts on both sides of that boundary. + * + *

    Authoring a service: {@link io.bosonnetwork.service.BosonService} + + * {@link io.bosonnetwork.service.BosonServiceFactory}

    + * A service implements {@link io.bosonnetwork.service.BosonService} (identity, public endpoint, + * lifecycle: {@code init} → {@code start} → {@code stop}) and ships a + * {@link io.bosonnetwork.service.BosonServiceFactory} discovered via Java + * {@link java.util.ServiceLoader} (a {@code META-INF/services/} entry, or a {@code provides} + * declaration on the module path). The hosting super node loads factories by + * {@link io.bosonnetwork.service.BosonServiceFactory#getType() type} and instantiates the + * configured ones. + * + *

    Runtime: the three contexts

    + * On {@code init}, the host hands the service a + * {@link io.bosonnetwork.service.ServiceContext}, a one-stop runtime handle: + *
      + *
    • {@link io.bosonnetwork.service.ServiceContext#getNode() node}, + * {@link io.bosonnetwork.service.ServiceContext#getDataDir() data dir}, + * {@link io.bosonnetwork.service.ServiceContext#getConfiguration() configuration}, and + * an {@link io.bosonnetwork.service.ServiceContext#unwrap(Class) unwrap} hook for + * infrastructure components (e.g. Vert.x);
    • + *
    • a {@link io.bosonnetwork.service.ClientContext} for looking up end-user identities + * (users + their devices) — see below;
    • + *
    • a {@link io.bosonnetwork.service.FederationContext} for talking to other super nodes + * and the services they host.
    • + *
    + * + *

    Client vs. federation

    + * The two "sides" each carry their own auth surface: + *
      + *
    • Client side ({@link io.bosonnetwork.service.ClientContext}) — read-only + * lookups of {@link io.bosonnetwork.service.ClientUser} / {@link io.bosonnetwork.service.ClientDevice}, + * plus a {@link io.bosonnetwork.service.ClientAuthenticator} for verifying who they are and + * a {@link io.bosonnetwork.service.ClientAuthorizer} for deciding what they can do.
    • + *
    • Federation side ({@link io.bosonnetwork.service.FederationContext}) — + * lookup/probe of {@link io.bosonnetwork.service.SuperNodeInfo} / + * {@link io.bosonnetwork.service.ServiceInfo}, plus a + * {@link io.bosonnetwork.service.FederationAuthenticator} for verifying peer node/service + * identity.
    • + *
    + * Both authenticator interfaces share a "nonce/signature contract" (see their interface Javadoc): + * non-null nonce+signature ⇒ verify; both null ⇒ pre-authenticated mode; exactly one null ⇒ caller bug. + * + *

    Identity and access scopes

    + * Principal entitlements are exposed through {@link io.bosonnetwork.service.Role} (server-derived + * tiers: client / admin / federation) and {@link io.bosonnetwork.service.AccessScope} + * (the over-the-wire {@code api:*} scope strings). See + * {@link io.bosonnetwork.web.CwtAuth} for how the web layer maps a resolved principal into + * Vert.x {@code Authorizations}. + * + *

    Test/demo helpers

    + * The {@code impl/} subpackage ships ready-made contexts for testing and bring-up: + * {@link io.bosonnetwork.service.ClientContext#allowAll(io.bosonnetwork.Identity) allowAll}, + * {@link io.bosonnetwork.service.ClientContext#staticContext(io.bosonnetwork.Identity) staticContext}, + * {@link io.bosonnetwork.service.FederationContext#disabled() disabled}, + * {@link io.bosonnetwork.service.FederationContext#allowAll(io.bosonnetwork.Identity) allowAll}, + * and {@link io.bosonnetwork.service.FederationContext#staticContext(io.bosonnetwork.Identity) staticContext}. */ package io.bosonnetwork.service; \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java b/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java index 05145fd5..89a2e7a6 100644 --- a/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java +++ b/api/src/main/java/io/bosonnetwork/utils/AddressUtils.java @@ -23,9 +23,6 @@ package io.bosonnetwork.utils; -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; import java.net.DatagramSocket; import java.net.Inet4Address; import java.net.Inet6Address; @@ -33,7 +30,6 @@ import java.net.InetSocketAddress; import java.net.NetworkInterface; import java.net.SocketException; -import java.net.URL; import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; @@ -52,29 +48,37 @@ * @see Reserved IP addresses * @see Martian packet */ -public class AddressUtils { - // IPv4 Bogon ranges (excludes ranges covered by InetAddress methods) +public final class AddressUtils { + private AddressUtils() { + } + + // IPv4 Bogon ranges. Some entries overlap with InetAddress.isSiteLocalAddress() / + // isLoopbackAddress() / isLinkLocalAddress() / isMulticastAddress() — kept here explicitly + // so the table reads as a self-contained list of RFC-classified non-routable ranges. // Reference: RFC 1918, RFC 6890 private static final String[] IPV4_BOGON_RANGES = { - // "0.0.0.0/8", // Any local - // "10.0.0.0/8", // Site local + "0.0.0.0/8", // Any local + "10.0.0.0/8", // Site local "100.64.0.0/10", // Private network - shared address space (RFC 6598) - // "127.0.0.0/8", // Loopback - // "169.254.0.0/16", // Link local - // "172.16.0.0/12", // Site local + "127.0.0.0/8", // Loopback + "169.254.0.0/16", // Link local + "172.16.0.0/12", // Site local "192.0.0.0/24", // Reserved (IANA) "192.0.2.0/24", // Documentation (TEST-NET-1) - // "192.168.0.0/16", // Site local + "192.168.0.0/16", // Site local "198.18.0.0/15", // Benchmarking (RFC 2544) "198.51.100.0/24", // Documentation (TEST-NET-2) "203.0.113.0/24", // Documentation (TEST-NET-3) - // "224.0.0.0/4", // Multicast + "224.0.0.0/4", // Multicast "233.252.0.0/24", // Documentation "240.0.0.0/4", // Reserved (partially allocated) - "255.255.255.255/32" // Broadcast + "255.255.255.255/32" // Broadcast }; - // IPv6 Bogon ranges (excludes ranges covered by InetAddress methods) + // IPv6 Bogon ranges. Some entries overlap with InetAddress.isLinkLocalAddress() / + // isSiteLocalAddress() / isMulticastAddress(), and some are subsets of broader entries in this + // table (e.g. ::ffff:0:0/96 ⊂ ::/8; Teredo / Benchmarking / ORCHID ⊂ 2001::/23) — kept here + // explicitly so the table reads as a self-contained list of RFC-classified non-routable ranges. // Reference: RFC 4291, RFC 6890 private static final String[] IPV6_BOGON_RANGES = { "::/8", // Reserved @@ -90,14 +94,14 @@ public class AddressUtils { "3fff::/20", // Documentation "3ffe::/16", // 6bone testing "5f00::/16", // Segment Routing (SRv6) SIDs - "fc00::/7" // Unique local address (RFC 4193) - // "fe80::/10", // Link local - // "fec0::/10", // Site local - // "ff00::/8" // Multicast + "fc00::/7", // Unique local address (RFC 4193) + "fe80::/10", // Link local + "fec0::/10", // Site local + "ff00::/8" // Multicast }; - private static List bogonSubnetsIpv4; - private static List bogonSubnetsIpv6; + private static final List bogonSubnetsIpv4; + private static final List bogonSubnetsIpv6; static { // Initialize Bogon subnets @@ -181,7 +185,8 @@ public static Subnet of(InetAddress network, int maskBits) { } private Subnet(byte[] network, int maskBits) { - this.network = network; + // defensive copy: callers should not be able to mutate our network bytes post-construction + this.network = network.clone(); this.maskBits = maskBits; } @@ -222,46 +227,6 @@ public String toString() { } } - /** - * Updates Bogon ranges from external sources (e.g., Team Cymru). - * Fetches the latest IPv4 and IPv6 Bogon lists and updates the internal subnet lists. - * Should be called periodically to keep Bogon ranges current. - * - * @throws RuntimeException if the update fails due to network or parsing errors - */ - public static void updateBogonRanges() { - List ipv4Subnets = new ArrayList<>(); - List ipv6Subnets = new ArrayList<>(); - - try { - // Load IPv4 Bogon list - URL ipv4BogonUrl = new URL("https://www.team-cymru.org/Services/Bogons/fullbogons-ipv4.txt"); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(ipv4BogonUrl.openStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (!line.startsWith("#") && !line.trim().isEmpty()) - ipv4Subnets.add(Subnet.of(line.trim())); - } - } - - // Load IPv6 Bogon list - URL ipv6BogonUrl = new URL("https://www.team-cymru.org/Services/Bogons/fullbogons-ipv6.txt"); - try (BufferedReader reader = new BufferedReader(new InputStreamReader(ipv6BogonUrl.openStream()))) { - String line; - while ((line = reader.readLine()) != null) { - if (!line.startsWith("#") && !line.trim().isEmpty()) - ipv6Subnets.add(Subnet.of(line.trim())); - } - } - - // Update static lists (thread-safe) - bogonSubnetsIpv4 = List.copyOf(ipv4Subnets); - bogonSubnetsIpv6 = List.copyOf(ipv6Subnets); - } catch (IOException e) { - throw new RuntimeException("Failed to update Bogon ranges", e); - } - } - /** * Checks if the socket address is a Bogon address or has an invalid port. * A Bogon address is an IP address that should not appear in public Internet routing tables. @@ -281,7 +246,7 @@ public static boolean isBogon(InetSocketAddress addr) { * @param addr the Vert.x socket address to check * @return true if the address is a Bogon address or the port is invalid (≤ 0 or > 65535), false otherwise * @throws IllegalArgumentException if the address is invalid - * @throws NullPointerException if addr is null + * @throws NullPointerException if addr is null */ public static boolean isBogon(SocketAddress addr) { Objects.requireNonNull(addr, "Socket address cannot be null"); @@ -319,37 +284,17 @@ public static boolean isBogon(SocketAddress addr) { public static boolean isBogon(InetAddress addr) { Objects.requireNonNull(addr, "Address cannot be null"); - // Check common Bogon types using InetAddress - if (addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() || - addr.isMulticastAddress() || addr.isSiteLocalAddress() || addr.isMCLinkLocal() || - addr.isMCNodeLocal() || addr.isMCOrgLocal() || addr.isMCSiteLocal()) + if (isSpecialUseAddress(addr)) return true; - // Handle IPv4-mapped addresses (::ffff:0:0/96) - if (addr instanceof Inet6Address) { - byte[] bytes = addr.getAddress(); - if (bytes.length == 16 && - bytes[0] == 0 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0 && - bytes[4] == 0 && bytes[5] == 0 && bytes[6] == 0 && bytes[7] == 0 && - bytes[8] == 0 && bytes[9] == 0 && bytes[10] == (byte) 0xff && bytes[11] == (byte) 0xff) { - try { - // Extract IPv4 part - byte[] ipv4Bytes = new byte[4]; - System.arraycopy(bytes, 12, ipv4Bytes, 0, 4); - InetAddress ipv4Addr = InetAddress.getByAddress(ipv4Bytes); - // Check IPv4 Bogon ranges and properties - if (ipv4Addr.isAnyLocalAddress() || ipv4Addr.isLoopbackAddress() || - ipv4Addr.isLinkLocalAddress() || ipv4Addr.isMulticastAddress() || - ipv4Addr.isSiteLocalAddress()) - return true; - - for (Subnet subnet : bogonSubnetsIpv4) { - if (subnet.contains(ipv4Addr)) - return true; - } - } catch (UnknownHostException e) { - return false; // Should not happen - } + // Handle IPv4-mapped addresses (::ffff:0:0/96) — check the embedded IPv4 surface too + InetAddress unmapped = unmapIPv4MappedIPv6(addr); + if (unmapped != null) { + if (isSpecialUseAddress(unmapped)) + return true; + for (Subnet subnet : bogonSubnetsIpv4) { + if (subnet.contains(unmapped)) + return true; } } @@ -363,6 +308,44 @@ public static boolean isBogon(InetAddress addr) { return false; } + /** + * Returns {@code true} if the address falls into one of the InetAddress-classified + * "special-use" categories: any-local, loopback, link-local, multicast (including all + * MC scopes), and site-local. Shared between {@link #isBogon(InetAddress)} and + * {@link #isMartian(InetAddress)} so the two stay in lockstep. + */ + private static boolean isSpecialUseAddress(InetAddress addr) { + return addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() || + addr.isMulticastAddress() || addr.isSiteLocalAddress() || addr.isMCLinkLocal() || + addr.isMCNodeLocal() || addr.isMCOrgLocal() || addr.isMCSiteLocal(); + } + + /** + * If {@code addr} is an IPv4-mapped IPv6 address ({@code ::ffff:0:0/96}), returns the + * embedded IPv4 address; otherwise returns {@code null}. Lets classification routines + * apply IPv4 rules to the mapped form. + */ + private static InetAddress unmapIPv4MappedIPv6(InetAddress addr) { + if (!(addr instanceof Inet6Address)) + return null; + byte[] bytes = addr.getAddress(); + if (bytes.length != 16) + return null; + for (int i = 0; i < 10; i++) { + if (bytes[i] != 0) + return null; + } + if (bytes[10] != (byte) 0xff || bytes[11] != (byte) 0xff) + return null; + try { + byte[] ipv4 = new byte[4]; + System.arraycopy(bytes, 12, ipv4, 0, 4); + return InetAddress.getByAddress(ipv4); + } catch (UnknownHostException e) { + return null; // Should not happen + } + } + /** * Checks if the IP address is a Martian address, a subset of Bogon addresses. * Martian addresses are private, reserved, or multicast addresses that should not appear @@ -380,9 +363,12 @@ public static boolean isBogon(InetAddress addr) { */ public static boolean isMartian(InetAddress addr) { Objects.requireNonNull(addr, "Address cannot be null"); - return addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress() || - addr.isMulticastAddress() || addr.isSiteLocalAddress() || addr.isMCLinkLocal() || - addr.isMCNodeLocal() || addr.isMCOrgLocal() || addr.isMCSiteLocal(); + if (isSpecialUseAddress(addr)) + return true; + // Apply the same check on the IPv4 surface of an IPv4-mapped IPv6 address, so an address + // like ::ffff:127.0.0.1 is classified the same way isBogon() classifies it. + InetAddress unmapped = unmapIPv4MappedIPv6(addr); + return unmapped != null && isSpecialUseAddress(unmapped); } /** @@ -454,6 +440,37 @@ public static boolean isGlobalUnicast(InetAddress addr) { return !isBogon(addr); } + /** + * Checks if the IP address is a private (non-globally-routable) address. + *

    + * Returns true for: + *

      + *
    • IPv4 site-local (RFC 1918: {@code 10/8}, {@code 172.16/12}, {@code 192.168/16}) via + * {@link InetAddress#isSiteLocalAddress()};
    • + *
    • IPv4 shared address space (RFC 6598 / CGN, {@code 100.64.0.0/10}) — not covered by + * {@code isSiteLocalAddress()};
    • + *
    • IPv6 site-local ({@code fec0::/10}, deprecated) via {@code isSiteLocalAddress()};
    • + *
    • IPv6 Unique Local Addresses (RFC 4193, {@code fc00::/7}).
    • + *
    + * + * @param addr the IP address to check + * @return true if the address is private, false otherwise + * @throws NullPointerException if addr is null + */ + public static boolean isPrivate(InetAddress addr) { + Objects.requireNonNull(addr, "Address cannot be null"); + if (addr.isSiteLocalAddress()) + return true; + byte[] b = addr.getAddress(); + if (addr instanceof Inet4Address) + // RFC 6598: 100.64.0.0/10 — first byte 0x64 (100), top 2 bits of second byte = 01 + return (b[0] & 0xff) == 100 && (b[1] & 0xc0) == 0x40; + if (addr instanceof Inet6Address) + // RFC 4193: fc00::/7 — top 7 bits = 1111110 (0xfc or 0xfd in the leading byte) + return (b[0] & 0xfe) == 0xfc; + return false; + } + /** * Checks if the IP address is a unicast address. *

    @@ -500,7 +517,7 @@ public static Stream getAllAddresses() { public static Stream getNonlocalAddresses() { return getAllAddresses().filter(addr -> !addr.isAnyLocalAddress() && !addr.isLoopbackAddress() && - !addr.isLinkLocalAddress() && !addr.isMulticastAddress()); + !addr.isLinkLocalAddress() && !addr.isMulticastAddress()); } /** @@ -546,22 +563,29 @@ else if (type == Inet6Address.class) /** * Gets the IP address of the default routing interface for the specified address type. - * Uses a test connection to a public address (e.g., 8.8.8.8 for IPv4, 2001:4860:4860::8888 for IPv6). + *

    + * Uses the well-known UDP-{@code connect} trick: opening an unbound {@link DatagramSocket} + * and {@code connect}ing it to a public address forces the kernel to pick the local address + * the OS would use to reach that destination — without actually sending any packets. The + * targets are Google DNS ({@code 8.8.8.8} for IPv4, {@code 2001:4860:4860::8888} for IPv6), + * which the kernel only needs to be able to route to, not to talk to. * * @param type the address class (Inet4Address or Inet6Address) - * @return the address of the default routing interface, or null if not found + * @return the address of the default routing interface, or {@code null} if no such route + * exists (e.g. IPv6 unconfigured, host offline) or the resolved local address is the + * wildcard * @throws IllegalArgumentException if the type is not supported */ public static InetAddress getDefaultRouteAddress(Class type) { + if (type != Inet4Address.class && type != Inet6Address.class) + throw new IllegalArgumentException("Unsupported type: " + type); + try (DatagramSocket socket = new DatagramSocket()) { InetAddress target; - if (type == Inet4Address.class) target = InetAddress.getByAddress(new byte[]{8, 8, 8, 8}); - else if (type == Inet6Address.class) - target = InetAddress.getByName("2001:4860:4860::8888"); else - throw new IllegalArgumentException("Unsupported type: " + type); + target = InetAddress.getByName("2001:4860:4860::8888"); socket.connect(new InetSocketAddress(target, 53)); InetAddress local = socket.getLocalAddress(); @@ -570,39 +594,42 @@ else if (type == Inet6Address.class) return local; return null; - } catch (IOException e) { + } catch (SocketException | UnknownHostException e) { + // "No route", "address family not supported", offline, etc. — not a programming error. + return null; + } catch (Exception e) { throw new RuntimeException("Failed to get default route address", e); } } - /*/ - public static InetAddress getDefaultRouteAddress(Class type) { - try { - for (NetworkInterface nif : Collections.list(NetworkInterface.getNetworkInterfaces())) { - if (!nif.isUp() || nif.isLoopback() || nif.isVirtual()) - continue; - - for (InetAddress addr : Collections.list(nif.getInetAddresses())) { - if (!type.isInstance(addr)) - continue; - if (addr.isAnyLocalAddress() || addr.isLoopbackAddress() || addr.isLinkLocalAddress()) - continue; - - return addr; - } - } + /** + * Retrieves the default network interface associated with the specified address type. + * The default network interface is determined by resolving the default routing + * address for the provided address type. + * + * @param type the address class (Inet4Address or Inet6Address) used to determine + * the default network interface. + * @return the default {@code NetworkInterface} for the specified address type, + * or {@code null} if no default interface is found. + * @throws RuntimeException if there is an error retrieving the network interface. + */ + public static NetworkInterface getDefaultNetworkInterface(Class type) { + InetAddress defaultAddress = getDefaultRouteAddress(type); + if (defaultAddress == null) return null; + + try { + return NetworkInterface.getByInetAddress(defaultAddress); } catch (SocketException e) { - throw new RuntimeException("Failed to get default router address", e); + throw new RuntimeException("Failed to get default network interface", e); } } - */ /** * Converts a socket address to a readable string, with optional alignment. * IPv6 addresses are enclosed in square brackets. * - * @param addr the socket address to convert + * @param addr the socket address to convert * @param align whether to align the output (e.g., fixed width for IPv4/IPv6) * @return the formatted string representation of the socket address * @throws NullPointerException if sockAddr is null diff --git a/api/src/main/java/io/bosonnetwork/utils/ApplicationLock.java b/api/src/main/java/io/bosonnetwork/utils/ApplicationLock.java index 0f864be2..0e43f51c 100644 --- a/api/src/main/java/io/bosonnetwork/utils/ApplicationLock.java +++ b/api/src/main/java/io/bosonnetwork/utils/ApplicationLock.java @@ -24,11 +24,14 @@ import java.io.File; import java.io.IOException; +import java.nio.ByteBuffer; import java.nio.channels.FileChannel; import java.nio.channels.FileLock; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.time.Instant; /** * File based application instance exclusive lock, guarantee the application can only run @@ -86,6 +89,16 @@ private void tryLock() throws IOException, IllegalStateException { lock = fc.tryLock(0, Long.MAX_VALUE, false); if (lock == null) throw new IllegalStateException("Already locked by another instance."); + // Write owner metadata so a reader can answer "who is holding this?". Best-effort — + // failure to write the marker does not affect the lock itself. + try { + String marker = ProcessHandle.current().pid() + " " + Instant.now() + System.lineSeparator(); + fc.truncate(0); + fc.write(ByteBuffer.wrap(marker.getBytes(StandardCharsets.UTF_8))); + fc.force(true); + } catch (IOException ignore) { + // Owner marker is informational only; the lock itself is held by the file lock. + } } catch (IOException | RuntimeException | Error e) { fc.close(); fc = null; @@ -94,6 +107,10 @@ private void tryLock() throws IOException, IllegalStateException { } private void unlock() { + // Only delete the lock file if we are confident we still own it. Without this guard, a + // late-running close() after the OS released our file lock can end up deleting a successor + // process's lock file (the file name is the same but the OS-level lock has moved on). + boolean stillOwnsLock = lock != null && lock.isValid(); try { if (lock != null) { lock.close(); @@ -105,7 +122,8 @@ private void unlock() { fc = null; } - Files.deleteIfExists(lockFile); + if (stillOwnsLock) + Files.deleteIfExists(lockFile); } catch (IOException ignore) { // Ignore cleanup errors } finally { diff --git a/api/src/main/java/io/bosonnetwork/utils/ByteBufferInputStream.java b/api/src/main/java/io/bosonnetwork/utils/ByteBufferInputStream.java index 1e45f3e0..667579d8 100644 --- a/api/src/main/java/io/bosonnetwork/utils/ByteBufferInputStream.java +++ b/api/src/main/java/io/bosonnetwork/utils/ByteBufferInputStream.java @@ -24,12 +24,13 @@ package io.bosonnetwork.utils; import java.io.IOException; +import java.io.InputStream; import java.nio.ByteBuffer; /** * Utility to present ByteBuffer data as an InputStream. */ -public class ByteBufferInputStream { +public class ByteBufferInputStream extends InputStream { /** * The ByteBuffer object from which data is read. */ diff --git a/api/src/main/java/io/bosonnetwork/utils/Bytes.java b/api/src/main/java/io/bosonnetwork/utils/Bytes.java new file mode 100644 index 00000000..f4134d1e --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/utils/Bytes.java @@ -0,0 +1,151 @@ +/* + * 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.utils; + +/** + * Utility class for efficient conversion between primitive types and byte arrays. + */ +public final class Bytes { + private Bytes() {} + + /** + * Converts an integer value to a byte array. + * + * @param value the integer value to convert + * @return a byte array of length 4 representing the integer + */ + public static byte[] fromInteger(int value) { + byte[] b = new byte[4]; + b[0] = (byte) (value >> 24); + b[1] = (byte) (value >> 16); + b[2] = (byte) (value >> 8); + b[3] = (byte) value; + return b; + } + + /** + * Converts a byte array to an integer value. + * + * @param bytes the byte array to convert + * @return the integer value + */ + public static int toInteger(byte[] bytes) { + return toInteger(bytes, 0); + } + + /** + * Converts a byte array to an integer value starting from the specified offset. + * + * @param bytes the byte array to convert + * @param offset the offset in the byte array to start from + * @return the integer value + */ + public static int toInteger(byte[] bytes, int offset) { + return ((bytes[offset] & 0xFF) << 24) | + ((bytes[offset + 1] & 0xFF) << 16) | + ((bytes[offset + 2] & 0xFF) << 8) | + (bytes[offset + 3] & 0xFF); + } + + /** + * Converts a short value to a byte array. + * + * @param value the short value to convert + * @return a byte array of length 2 representing the short + */ + public static byte[] fromShort(short value) { + byte[] b = new byte[2]; + b[0] = (byte) (value >> 8); + b[1] = (byte) value; + return b; + } + + /** + * Converts a byte array to a short value. + * + * @param bytes the byte array to convert + * @return the short value + */ + public static short toShort(byte[] bytes) { + return toShort(bytes, 0); + } + + /** + * Converts a byte array to a short value starting from the specified offset. + * + * @param bytes the byte array to convert + * @param offset the offset in the byte array to start from + * @return the short value + */ + public static short toShort(byte[] bytes, int offset) { + return (short) (((bytes[offset] & 0xFF) << 8) | + (bytes[offset + 1] & 0xFF)); + } + + /** + * Converts a long value to a byte array. + * + * @param value the long value to convert + * @return a byte array of length 8 representing the long + */ + public static byte[] fromLong(long value) { + byte[] b = new byte[8]; + b[0] = (byte) (value >> 56); + b[1] = (byte) (value >> 48); + b[2] = (byte) (value >> 40); + b[3] = (byte) (value >> 32); + b[4] = (byte) (value >> 24); + b[5] = (byte) (value >> 16); + b[6] = (byte) (value >> 8); + b[7] = (byte) value; + return b; + } + + /** + * Converts a byte array to a long value. + * + * @param bytes the byte array to convert + * @return the long value + */ + public static long toLong(byte[] bytes) { + return toLong(bytes, 0); + } + + /** + * Converts a byte array to a long value starting from the specified offset. + * + * @param bytes the byte array to convert + * @param offset the offset in the byte array to start from + * @return the long value + */ + public static long toLong(byte[] bytes, int offset) { + return (((long) bytes[offset] & 0xFF) << 56) | + (((long) bytes[offset + 1] & 0xFF) << 48) | + (((long) bytes[offset + 2] & 0xFF) << 40) | + (((long) bytes[offset + 3] & 0xFF) << 32) | + (((long) bytes[offset + 4] & 0xFF) << 24) | + (((long) bytes[offset + 5] & 0xFF) << 16) | + (((long) bytes[offset + 6] & 0xFF) << 8) | + ((long) bytes[offset + 7] & 0xFF); + } +} \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/utils/ConfigMap.java b/api/src/main/java/io/bosonnetwork/utils/ConfigMap.java index ca1e3972..fb6deb1d 100644 --- a/api/src/main/java/io/bosonnetwork/utils/ConfigMap.java +++ b/api/src/main/java/io/bosonnetwork/utils/ConfigMap.java @@ -116,9 +116,15 @@ else if (val instanceof Boolean b) return b ? 1 : 0; else if (val instanceof String s) try { - return Double.parseDouble(s); - } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid number value - " + key + ": " + val); + // Try long first to preserve precision for integer-valued strings; fall back to + // double for true floating-point values. + return Long.parseLong(s); + } catch (NumberFormatException ignoreLong) { + try { + return Double.parseDouble(s); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid number value - " + key + ": " + val); + } } else throw new IllegalArgumentException("Invalid number value - " + key + ": " + val); @@ -362,7 +368,11 @@ public long getSize(String key) { try { long size = Long.parseLong(s, 0, idx, 10); - return size * weight; + try { + return Math.multiplyExact(size, (long) weight); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("Size value out of range for long - " + key + ": " + s, e); + } } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid size value - " + key + ": " + s, e); } @@ -435,8 +445,14 @@ public Duration getDuration(String key) { try { long number = Long.parseLong(s, 0, idx, 10); - return Duration.ofMillis(number * unit.getDuration().toMillis()); - } catch (Exception e) { + long unitMillis = unit.getDuration().toMillis(); + try { + return Duration.ofMillis(Math.multiplyExact(number, unitMillis)); + } catch (ArithmeticException e) { + throw new IllegalArgumentException("Duration value out of range for long milliseconds - " + + key + ": " + s, e); + } + } catch (NumberFormatException e) { throw new IllegalArgumentException("Invalid duration value - " + key + ": " + s, e); } } else { @@ -558,9 +574,14 @@ public Id getId(String key, Id def) { *

    * The value must be a Map, which will be wrapped in a new ConfigMap instance. *

    + *

    + * Note: unlike the scalar getters, this returns {@code null} (rather than + * throwing) when the key is absent. Nested sections are commonly optional, and callers can + * idiomatically write {@code if (m.getObject("section") == null)} to detect that. Use + * {@link #containsKey(Object)} first if you want to distinguish "absent" from "explicitly null." * * @param key the configuration key, key must not be null - * @return a ConfigMap wrapping the nested configuration, or null if the key is not present + * @return a ConfigMap wrapping the nested configuration, or {@code null} if the key is not present * @throws NullPointerException if the key is null * @throws IllegalArgumentException if the value is not a Map */ @@ -583,10 +604,13 @@ public ConfigMap getObject(String key) { *

    * The value must be a List. The returned list is cast to the specified type parameter. *

    + *

    + * Note: like {@link #getObject(String)}, this returns {@code null} (rather + * than throwing) when the key is absent — list configuration sections are commonly optional. * * @param the type of elements in the list * @param key the configuration key, key must not be null - * @return the list value, or null if the key is not present + * @return the list value, or {@code null} if the key is not present * @throws NullPointerException if the key is null * @throws IllegalArgumentException if the value is not a List */ diff --git a/api/src/main/java/io/bosonnetwork/utils/FileUtils.java b/api/src/main/java/io/bosonnetwork/utils/FileUtils.java index 2a6af7cd..1999c78c 100644 --- a/api/src/main/java/io/bosonnetwork/utils/FileUtils.java +++ b/api/src/main/java/io/bosonnetwork/utils/FileUtils.java @@ -49,7 +49,25 @@ *

    * All methods in this class are static and the class cannot be instantiated. */ -public class FileUtils { +public final class FileUtils { + private FileUtils() { + } + + /** + * Returns the resolved XDG base directory for the given env var name, falling back to + * {@code ~/relativeFallback} when the env var is absent or empty. + * + * @param envVar the XDG environment variable name (e.g. {@code XDG_CONFIG_HOME}) + * @param relativeFallback the fallback path under the user home if the env var is unset + * @return the resolved path + */ + private static Path xdgPath(String envVar, String relativeFallback) { + String xdg = System.getenv(envVar); + if (xdg != null && !xdg.isEmpty()) + return Path.of(xdg); + return Path.of(System.getProperty("user.home"), relativeFallback); + } + /** * Deletes the specified file or directory from the file system. *

    @@ -177,15 +195,10 @@ public static Path getSiteConfigDir() { */ public static Path getUserConfigDir() { String osName = System.getProperty("os.name").toLowerCase(); - if (osName.startsWith("windows")) { + if (osName.startsWith("windows")) return Path.of(System.getenv("APPDATA")); - } else if (osName.startsWith("mac")) { - // return Path.of(System.getProperty("user.home"), "Library/Preferences"); - return Path.of(System.getProperty("user.home"), ".config"); - } else { - // Unix like OS - return Path.of(System.getProperty("user.home"), ".config"); - } + // macOS uses XDG style here (intentionally — see class doc) + return xdgPath("XDG_CONFIG_HOME", ".config"); } /** @@ -236,15 +249,9 @@ public static Path getSystemDataDir() { */ public static Path getUserDataDir() { String osName = System.getProperty("os.name").toLowerCase(); - if (osName.startsWith("windows")) { + if (osName.startsWith("windows")) return Path.of(System.getenv("LOCALAPPDATA")); - } else if (osName.startsWith("mac")) { - //return Path.of(System.getProperty("user.home"), "Library/Application Support"); - return Path.of(System.getProperty("user.home"), ".local/share"); - } else { - // Unix like OS - return Path.of(System.getProperty("user.home"), ".local/share"); - } + return xdgPath("XDG_DATA_HOME", ".local/share"); } /** @@ -293,15 +300,9 @@ public static Path getSystemCacheDir() { */ public static Path getUserCacheDir() { String osName = System.getProperty("os.name").toLowerCase(); - if (osName.startsWith("windows")) { + if (osName.startsWith("windows")) return Path.of(System.getenv("LOCALAPPDATA")); - } else if (osName.startsWith("mac")) { - // return Path.of(System.getProperty("user.home"), "Library/Caches"); - return Path.of(System.getProperty("user.home"), ".cache"); - } else { - // Unix like OS - return Path.of(System.getProperty("user.home"), ".cache"); - } + return xdgPath("XDG_CACHE_HOME", ".cache"); } /** @@ -350,21 +351,22 @@ public static Path getSystemLogDir() { */ public static Path getUserLogDir() { String osName = System.getProperty("os.name").toLowerCase(); - if (osName.startsWith("windows")) { + if (osName.startsWith("windows")) return Path.of(System.getenv("LOCALAPPDATA")); - } else if (osName.startsWith("mac")) { - // return Path.of(System.getProperty("user.home"), "Library/Logs"); - return Path.of(System.getProperty("user.home"), ".local/state"); - } else { - // Unix like OS - return Path.of(System.getProperty("user.home"), ".local/state"); - } + return xdgPath("XDG_STATE_HOME", ".local/state"); } /** - * Converts a {@link URL} to a {@link Path}. - * This method handles URLs with "jar" schemes and properly retrieves the corresponding - * filesystem path for entries within JAR files. + * Converts a {@link URL} to a {@link Path}, handling both regular file URLs and entries + * inside JAR archives (the {@code jar:} scheme). + *

    + * JAR FileSystem caching: for a {@code jar:} URL this method opens a JAR + * {@link java.nio.file.FileSystem} lazily and caches it process-wide (subsequent calls for the + * same archive return the same FileSystem via the {@code FileSystemAlreadyExistsException} + * branch). The cached FileSystem is intentionally not closed — callers should not close the + * returned Path's FileSystem either, since other callers may still hold derived Paths into the + * same archive. In practice the number of distinct JARs is small (resource lookups), so this + * is the desired behavior. * * @param url the {@link URL} to be converted to a {@link Path}, must not be null * @return the {@link Path} corresponding to the given {@link URL} diff --git a/api/src/main/java/io/bosonnetwork/utils/Functional.java b/api/src/main/java/io/bosonnetwork/utils/Functional.java index b1bed671..5917ca2b 100644 --- a/api/src/main/java/io/bosonnetwork/utils/Functional.java +++ b/api/src/main/java/io/bosonnetwork/utils/Functional.java @@ -28,7 +28,10 @@ /** * Some functional helper methods. */ -public class Functional { +public final class Functional { + private Functional() { + } + /** * Feeds the object to the {@code Consumer} and return the object. * @@ -95,10 +98,18 @@ public interface ThrowingFunction { } /** - * Wrap the checked exception to unchecked exception. + * Invokes {@code f} and returns its result; if {@code f} throws a checked exception, that + * exception is rethrown without being declared. + *

    + * This uses the "sneaky-throw" idiom — the original exception is rethrown + * as-is using a generic-erasure trick, not wrapped in a {@code RuntimeException}. A caller's + * {@code catch (IOException e)} clause will still match an {@code IOException} thrown from + * {@code f}, even though this method's signature does not declare it. Use this only where + * declaring the checked type is impossible (e.g. inside a {@code Function} / {@code Supplier}) + * and the caller is prepared for the actual exception type to surface. * * @param the return type. - * @param f the supplier function with throw exceptions. + * @param f the supplier function, which may throw any {@link Throwable}. * @return the result from the supplier. */ public static T unchecked(ThrowingSupplier f) { diff --git a/api/src/main/java/io/bosonnetwork/utils/Hex.java b/api/src/main/java/io/bosonnetwork/utils/Hex.java index 8d3506da..f5aecb53 100644 --- a/api/src/main/java/io/bosonnetwork/utils/Hex.java +++ b/api/src/main/java/io/bosonnetwork/utils/Hex.java @@ -129,7 +129,6 @@ public static String encode(byte[] bytes, int offset, int length) { for (int i = 0; i < length; i++) { int v = bytes[offset + i] & 0xFF; - //int v = bytes[offset + i]; chars[i << 1] = HEX_CHARS[(v >>> 4) & 0x0F]; chars[(i << 1) + 1] = HEX_CHARS[v & 0x0F]; } diff --git a/api/src/main/java/io/bosonnetwork/utils/Quadruple.java b/api/src/main/java/io/bosonnetwork/utils/Quadruple.java index 386c83a2..870e1d0b 100644 --- a/api/src/main/java/io/bosonnetwork/utils/Quadruple.java +++ b/api/src/main/java/io/bosonnetwork/utils/Quadruple.java @@ -36,6 +36,8 @@ * @param type for value d. */ public class Quadruple { + private static final Quadruple EMPTY = new Quadruple<>(null, null, null, null); + private final A a; private final B b; private final C c; @@ -73,6 +75,20 @@ public static Quadruple of(A1 a, B1 b, C1 c, D1 return new Quadruple<>(a, b, c, d); } + /** + * Returns an immutable, empty Quadruple instance in which all four values are null. + * + * @param the type of the first value in the quadruple. + * @param the type of the second value in the quadruple. + * @param the type of the third value in the quadruple. + * @param the type of the fourth value in the quadruple. + * @return an empty Quadruple instance with null values. + */ + @SuppressWarnings("unchecked") + public static Quadruple empty() { + return (Quadruple) EMPTY; + } + /** * Gets the value a from the quadruple object. * diff --git a/api/src/main/java/io/bosonnetwork/utils/StringUtils.java b/api/src/main/java/io/bosonnetwork/utils/StringUtils.java index 0910157f..8a8d2fb1 100644 --- a/api/src/main/java/io/bosonnetwork/utils/StringUtils.java +++ b/api/src/main/java/io/bosonnetwork/utils/StringUtils.java @@ -29,7 +29,10 @@ /** * Common String related utility functions */ -public class StringUtils { +public final class StringUtils { + private StringUtils() { + } + private static final Random rnd = new SecureRandom(); /** diff --git a/api/src/main/java/io/bosonnetwork/utils/Triple.java b/api/src/main/java/io/bosonnetwork/utils/Triple.java index 5969aea9..4f4594a1 100644 --- a/api/src/main/java/io/bosonnetwork/utils/Triple.java +++ b/api/src/main/java/io/bosonnetwork/utils/Triple.java @@ -35,6 +35,8 @@ * @param type for value c. */ public class Triple { + private static final Triple EMPTY = new Triple<>(null, null, null); + private final A a; private final B b; private final C c; @@ -67,6 +69,19 @@ public static Triple of(A1 a, B1 b, C1 c) { return new Triple<>(a, b, c); } + /** + * Returns an immutable, empty Triple instance in which all three values are null. + * + * @param the type of the first value in the triple. + * @param the type of the second value in the triple. + * @param the type of the third value in the triple. + * @return an empty Triple instance with null values. + */ + @SuppressWarnings("unchecked") + public static Triple empty() { + return (Triple) EMPTY; + } + /** * Gets the value a from the triple object. * diff --git a/api/src/main/java/io/bosonnetwork/utils/Variable.java b/api/src/main/java/io/bosonnetwork/utils/Variable.java index 3cdac343..d4818209 100644 --- a/api/src/main/java/io/bosonnetwork/utils/Variable.java +++ b/api/src/main/java/io/bosonnetwork/utils/Variable.java @@ -34,7 +34,7 @@ /** * A mutable container object which may or may not contain a value. * Unlike {@link Optional}, {@code Variable} allows the contained value to be - * changed after creation using {@link #set} or {@link #setNullable}. + * changed after creation using {@link #set} or {@link #setIfAbsent}. * If a value is present, it can be retrieved with {@link #get} or processed * using methods like {@link #ifPresent}, {@link #map}, or {@link #flatMap}. * If no value is present, certain methods provide default values or actions. @@ -111,18 +111,16 @@ public void set(T value) { } /** - * Sets the value contained by this {@code Variable} to the specified value, - * which may be {@code null}, only if no value is currently present. - * + * Sets the value to {@code value} only if no value is currently present. If a value is + * already held, this method does nothing — the existing value is not replaced. *

    - * This method is useful for conditionally setting a value when the - * {@code Variable} is empty. To unconditionally set a nullable value, use - * the constructor or {@link #ofNullable}. - *

    + * Equivalent to {@code Map.putIfAbsent} semantics, but applied to a single slot. The + * argument may be {@code null}; passing {@code null} when the variable is already empty + * is a no-op as well (the variable remains empty). * * @param value the new value, which may be {@code null} */ - public void setNullable(T value) { + public void setIfAbsent(T value) { if (this.value == null) this.value = value; } diff --git a/api/src/main/java/io/bosonnetwork/utils/logging/package-info.java b/api/src/main/java/io/bosonnetwork/utils/logging/package-info.java new file mode 100644 index 00000000..cc012eae --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/utils/logging/package-info.java @@ -0,0 +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. + */ + +/** + * Logging support classes for Boson. + *

    + * {@link io.bosonnetwork.utils.logging.HighlightingCompositeConverter} is a Logback converter that + * colors console output by log level (ERROR / WARN / INFO / TRACE) using ANSI codes; reference it + * from a Logback pattern layout to get colored logs in supported terminals. + */ +package io.bosonnetwork.utils.logging; diff --git a/api/src/main/java/io/bosonnetwork/utils/package-info.java b/api/src/main/java/io/bosonnetwork/utils/package-info.java index 6d8813e6..c74face1 100644 --- a/api/src/main/java/io/bosonnetwork/utils/package-info.java +++ b/api/src/main/java/io/bosonnetwork/utils/package-info.java @@ -22,6 +22,24 @@ */ /** - * Contains the basic utility and support classes. + * Common, dependency-light utility and support classes used across Boson. + * + *

      + *
    • Encoding: {@link io.bosonnetwork.utils.Hex}, + * {@link io.bosonnetwork.utils.Base58}, {@link io.bosonnetwork.utils.Bytes} and + * {@link io.bosonnetwork.utils.Sha256Hash} for byte/string conversion and hashing;
    • + *
    • Networking: {@link io.bosonnetwork.utils.AddressUtils} for classifying IP + * addresses (bogon / martian / private / global-unicast) relevant to DHT routing;
    • + *
    • Configuration & files: {@link io.bosonnetwork.utils.ConfigMap} (typed + * config access), {@link io.bosonnetwork.utils.FileUtils} (XDG-aware paths) and + * {@link io.bosonnetwork.utils.ApplicationLock} (single-instance file lock);
    • + *
    • Functional & data holders: {@link io.bosonnetwork.utils.Functional} + * (checked-exception lambda helpers), {@link io.bosonnetwork.utils.Variable} (a mutable + * {@code Optional}-like cell), and the {@link io.bosonnetwork.utils.Pair} / + * {@link io.bosonnetwork.utils.Triple} / {@link io.bosonnetwork.utils.Quadruple} tuples;
    • + *
    • Streams: {@link io.bosonnetwork.utils.ByteBufferInputStream} / + * {@link io.bosonnetwork.utils.ByteBufferOutputStream} adapters, and string helpers in + * {@link io.bosonnetwork.utils.StringUtils}.
    • + *
    */ package io.bosonnetwork.utils; \ No newline at end of file diff --git a/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java b/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java index 9aea2f0d..9869d31f 100644 --- a/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java +++ b/api/src/main/java/io/bosonnetwork/vertx/BosonVerticle.java @@ -28,9 +28,7 @@ import io.vertx.core.Deployable; import io.vertx.core.Future; import io.vertx.core.Handler; -import io.vertx.core.Promise; import io.vertx.core.Vertx; -import io.vertx.core.internal.ContextInternal; import io.vertx.core.json.JsonObject; /** @@ -153,15 +151,13 @@ protected void prepare(Vertx vertx, Context context) { */ public final Future deploy(Context context) throws Exception { prepare(context.owner(), context); - ContextInternal internal = (ContextInternal) context; - Promise promise = internal.promise(); try { - deploy().onComplete(promise); + return deploy(); } catch (Throwable t) { - if (!promise.tryFail(t)) - internal.reportException(t); + // Translate a synchronous failure from deploy() into a failed future; an asynchronous + // failure is already carried by the returned future. + return Future.failedFuture(t); } - return promise.future(); } /** @@ -174,15 +170,11 @@ public final Future deploy(Context context) throws Exception { * @throws Exception if undeployment fails */ public final Future undeploy(Context context) throws Exception { - ContextInternal internal = (ContextInternal) context; - Promise promise = internal.promise(); try { - undeploy().onComplete(promise); + return undeploy(); } catch (Throwable t) { - if (!promise.tryFail(t)) - internal.reportException(t); + return Future.failedFuture(t); } - return promise.future(); } /** diff --git a/api/src/main/java/io/bosonnetwork/vertx/BufferInputStream.java b/api/src/main/java/io/bosonnetwork/vertx/BufferInputStream.java index fdffa320..7a12b10b 100644 --- a/api/src/main/java/io/bosonnetwork/vertx/BufferInputStream.java +++ b/api/src/main/java/io/bosonnetwork/vertx/BufferInputStream.java @@ -23,6 +23,7 @@ package io.bosonnetwork.vertx; import java.io.InputStream; +import java.util.Objects; import io.vertx.core.buffer.Buffer; @@ -60,6 +61,10 @@ public int read() { /** {@inheritDoc} */ @Override public int read(byte[] b, int off, int len) { + Objects.checkFromIndexSize(off, len, b.length); + if (len == 0) + return 0; + if (pos >= limit) return -1; diff --git a/api/src/main/java/io/bosonnetwork/vertx/VertxFuture.java b/api/src/main/java/io/bosonnetwork/vertx/ContextualFuture.java similarity index 64% rename from api/src/main/java/io/bosonnetwork/vertx/VertxFuture.java rename to api/src/main/java/io/bosonnetwork/vertx/ContextualFuture.java index 18c30466..0e968d0d 100644 --- a/api/src/main/java/io/bosonnetwork/vertx/VertxFuture.java +++ b/api/src/main/java/io/bosonnetwork/vertx/ContextualFuture.java @@ -46,7 +46,7 @@ import io.vertx.core.Vertx; /** - * VertxFuture is a {@link CompletableFuture}-compatible wrapper around Vert.x's {@link io.vertx.core.Future}. + * ContextualFuture is a {@link CompletableFuture}-compatible wrapper around Vert.x's {@link io.vertx.core.Future}. *

    * It provides interoperability between the Vert.x asynchronous programming model and the Java * {@link CompletableFuture}/{@link CompletionStage} APIs. This allows developers to: @@ -61,18 +61,21 @@ * * @param the result type */ -public class VertxFuture extends CompletableFuture implements java.util.concurrent.Future, java.util.concurrent.CompletionStage { +public class ContextualFuture extends CompletableFuture implements java.util.concurrent.Future, java.util.concurrent.CompletionStage { /** The underlying Vert.x Future being wrapped. */ - Future future; + final Future future; /** - * Wraps an existing Vert.x {@link Future} into a VertxFuture. + * Wraps an existing Vert.x {@link Future} into a ContextualFuture. * Updates the internal state of this CompletableFuture whenever the Vert.x Future completes. * * @param future the Vert.x Future to wrap */ - protected VertxFuture(Future future) { - this.future = future.andThen(ar -> { + protected ContextualFuture(Future future) { + // Keep the original future (so a Promise-backed one stays completable via complete()); + // register the state-sync handler for the inherited CompletableFuture machinery. + this.future = future; + future.andThen(ar -> { // update the internal state of CompletableFuture if (ar.succeeded()) super.complete(ar.result()); @@ -82,75 +85,75 @@ protected VertxFuture(Future future) { } /** - * Creates a VertxFuture wrapper around an existing Vert.x Future. + * Creates a ContextualFuture wrapper around an existing Vert.x Future. * * @param future the Vert.x Future - * @return a new VertxFuture wrapping the given future - * @param the type of the VertxFuture result + * @return a new ContextualFuture wrapping the given future + * @param the type of the ContextualFuture result */ - public static VertxFuture of(Future future) { - return new VertxFuture<>(future); + public static ContextualFuture of(Future future) { + return new ContextualFuture<>(future); } /** - * Converts a {@link CompletableFuture} into a {@link VertxFuture}. - * If the provided {@link CompletableFuture} is already an instance of {@link VertxFuture}, + * Converts a {@link CompletableFuture} into a {@link ContextualFuture}. + * If the provided {@link CompletableFuture} is already an instance of {@link ContextualFuture}, * it is directly returned after being cast to the appropriate type. - * Otherwise, a new {@link VertxFuture} is created. + * Otherwise, a new {@link ContextualFuture} is created. * * @param the type of the result in the future * @param future the {@link CompletableFuture} to be converted - * @return a {@link VertxFuture} representing the same computation or result as the provided {@link CompletableFuture} + * @return a {@link ContextualFuture} representing the same computation or result as the provided {@link CompletableFuture} */ @SuppressWarnings("unchecked") - public static VertxFuture of(CompletableFuture future) { - if (future instanceof VertxFuture vf) - return (VertxFuture) vf; + public static ContextualFuture of(CompletableFuture future) { + if (future instanceof ContextualFuture vf) + return (ContextualFuture) vf; - return new VertxFuture<>(Future.fromCompletionStage(future)); + return new ContextualFuture<>(Future.fromCompletionStage(future)); } /** - * Creates a failed VertxFuture from a Throwable. + * Creates a failed ContextualFuture from a Throwable. * * @param cause the cause of the failure - * @return a new VertxFuture with a failure cause - * @param the type of the VertxFuture Future result + * @return a new ContextualFuture with a failure cause + * @param the type of the ContextualFuture Future result */ - public static VertxFuture failedFuture(Throwable cause) { - return new VertxFuture<>(Future.failedFuture(cause)); + public static ContextualFuture failedFuture(Throwable cause) { + return new ContextualFuture<>(Future.failedFuture(cause)); } /** - * Creates a failed VertxFuture from an error message. + * Creates a failed ContextualFuture from an error message. * * @param cause the error message of the failure - * @return a new VertxFuture with a failure cause as a String. - * @param the type of the VertxFuture Future result + * @return a new ContextualFuture with a failure cause as a String. + * @param the type of the ContextualFuture Future result */ - public static VertxFuture failedFuture(String cause) { - return new VertxFuture<>(Future.failedFuture(cause)); + public static ContextualFuture failedFuture(String cause) { + return new ContextualFuture<>(Future.failedFuture(cause)); } /** - * Creates a successfully completed VertxFuture with a {@code null} result. + * Creates a successfully completed ContextualFuture with a {@code null} result. * - * @return a new VertxFuture with a {@code null} result. - * @param the type of the VertxFuture Future result + * @return a new ContextualFuture with a {@code null} result. + * @param the type of the ContextualFuture Future result */ - public static VertxFuture succeededFuture() { - return new VertxFuture<>(Future.succeededFuture()); + public static ContextualFuture succeededFuture() { + return new ContextualFuture<>(Future.succeededFuture()); } /** - * Creates a successfully completed VertxFuture with the given result. + * Creates a successfully completed ContextualFuture with the given result. * * @param result the result of the future - * @return a new VertxFuture with the given result - * @param the type of the VertxFuture Future result + * @return a new ContextualFuture with the given result + * @param the type of the ContextualFuture Future result */ - public static VertxFuture succeededFuture(U result) { - return new VertxFuture<>(Future.succeededFuture(result)); + public static ContextualFuture succeededFuture(U result) { + return new ContextualFuture<>(Future.succeededFuture(result)); } @Override @@ -167,18 +170,18 @@ public Executor defaultExecutor() { } @Override - public VertxFuture thenApply(Function fn) { + public ContextualFuture thenApply(Function fn) { Future mapper = future.map(fn::apply); return of(mapper); } @Override - public VertxFuture thenApplyAsync(Function fn) { + public ContextualFuture thenApplyAsync(Function fn) { return thenApplyAsync(fn, defaultExecutor()); } @Override - public VertxFuture thenApplyAsync(Function fn, Executor executor) { + public ContextualFuture thenApplyAsync(Function fn, Executor executor) { Future composer = future.compose(t -> { Promise promise = Promise.promise(); executor.execute(() -> { @@ -196,7 +199,7 @@ public VertxFuture thenApplyAsync(Function fn, Ex } @Override - public VertxFuture thenAccept(Consumer action) { + public ContextualFuture thenAccept(Consumer action) { return thenApply(t -> { action.accept(t); return null; @@ -204,12 +207,12 @@ public VertxFuture thenAccept(Consumer action) { } @Override - public VertxFuture thenAcceptAsync(Consumer action) { + public ContextualFuture thenAcceptAsync(Consumer action) { return thenAcceptAsync(action, defaultExecutor()); } @Override - public VertxFuture thenAcceptAsync(Consumer action, Executor executor) { + public ContextualFuture thenAcceptAsync(Consumer action, Executor executor) { return thenApplyAsync(t -> { action.accept(t); return null; @@ -217,7 +220,7 @@ public VertxFuture thenAcceptAsync(Consumer action, Executor ex } @Override - public VertxFuture thenRun(Runnable action) { + public ContextualFuture thenRun(Runnable action) { return thenApply(t -> { action.run(); return null; @@ -225,12 +228,12 @@ public VertxFuture thenRun(Runnable action) { } @Override - public VertxFuture thenRunAsync(Runnable action) { + public ContextualFuture thenRunAsync(Runnable action) { return thenRunAsync(action, defaultExecutor()); } @Override - public VertxFuture thenRunAsync(Runnable action, Executor executor) { + public ContextualFuture thenRunAsync(Runnable action, Executor executor) { return thenApplyAsync(t -> { action.run(); return null; @@ -238,8 +241,8 @@ public VertxFuture thenRunAsync(Runnable action, Executor executor) { } @Override - public VertxFuture thenCombine(CompletionStage other, - BiFunction fn) { + public ContextualFuture thenCombine(CompletionStage other, + BiFunction fn) { Future otherFuture = Future.fromCompletionStage(other); // The behavior of Future.all is similar to CompletableFuture.thenCombine... Future mapper = Future.all(future, otherFuture).map(cf -> { @@ -252,14 +255,14 @@ public VertxFuture thenCombine(CompletionStage other, } @Override - public VertxFuture thenCombineAsync(CompletionStage other, - BiFunction fn) { + public ContextualFuture thenCombineAsync(CompletionStage other, + BiFunction fn) { return thenCombineAsync(other, fn, defaultExecutor()); } @Override - public VertxFuture thenCombineAsync(CompletionStage other, - BiFunction fn, Executor executor) { + public ContextualFuture thenCombineAsync(CompletionStage other, + BiFunction fn, Executor executor) { Future otherFuture = Future.fromCompletionStage(other); // The behavior of Future.all is similar to CompletableFuture.thenCombine... Future composer = Future.all(future, otherFuture).compose(cf -> { @@ -281,8 +284,8 @@ public VertxFuture thenCombineAsync(CompletionStage other } @Override - public VertxFuture thenAcceptBoth(CompletionStage other, - BiConsumer action) { + public ContextualFuture thenAcceptBoth(CompletionStage other, + BiConsumer action) { return thenCombine(other, (t, u) -> { action.accept(t, u); return null; @@ -290,14 +293,14 @@ public VertxFuture thenAcceptBoth(CompletionStage other, } @Override - public VertxFuture thenAcceptBothAsync(CompletionStage other, - BiConsumer action) { + public ContextualFuture thenAcceptBothAsync(CompletionStage other, + BiConsumer action) { return thenAcceptBothAsync(other, action, defaultExecutor()); } @Override - public VertxFuture thenAcceptBothAsync(CompletionStage other, - BiConsumer action, Executor executor) { + public ContextualFuture thenAcceptBothAsync(CompletionStage other, + BiConsumer action, Executor executor) { return thenCombineAsync(other, (t, u) -> { action.accept(t, u); return null; @@ -305,7 +308,7 @@ public VertxFuture thenAcceptBothAsync(CompletionStage ot } @Override - public VertxFuture runAfterBoth(CompletionStage other, Runnable action) { + public ContextualFuture runAfterBoth(CompletionStage other, Runnable action) { Future otherFuture = Future.fromCompletionStage(other); // The behavior of Future.all is similar to CompletableFuture.thenCombine... Future mapper = Future.all(future, otherFuture).map(cf -> { @@ -317,12 +320,12 @@ public VertxFuture runAfterBoth(CompletionStage other, Runnable action) } @Override - public VertxFuture runAfterBothAsync(CompletionStage other, Runnable action) { + public ContextualFuture runAfterBothAsync(CompletionStage other, Runnable action) { return runAfterBothAsync(other, action, defaultExecutor()); } @Override - public VertxFuture runAfterBothAsync(CompletionStage other, Runnable action, Executor executor) { + public ContextualFuture runAfterBothAsync(CompletionStage other, Runnable action, Executor executor) { Future otherFuture = Future.fromCompletionStage(other); // The behavior of Future.all is similar to CompletableFuture.thenCombine... Future composer = Future.all(future, otherFuture).compose(cf -> { @@ -341,48 +344,67 @@ public VertxFuture runAfterBothAsync(CompletionStage other, Runnable ac return of(composer); } - @Override - public VertxFuture applyToEither(CompletionStage other, Function fn) { - Future otherFuture = Future.fromCompletionStage(other); - Future mapper = Future.any(future, otherFuture).map(cf -> { - for (int i = 0; i < cf.size(); i++) { - if (cf.succeeded(i)) { - T t = cf.resultAt(i); - return fn.apply(t); - } - } - - // This should never happen - throw new IllegalStateException("No successful result"); + /** + * Completes with the outcome of whichever of {@code this}/{@code other} settles first — normally + * or exceptionally — matching {@link CompletableFuture}'s "either" semantics. (Note this + * differs from {@link Future#any} which waits for the first success.) + */ + private Future either(Future other) { + Promise settled = Promise.promise(); + future.onComplete(ar -> { + if (ar.succeeded()) + settled.tryComplete(ar.result()); + else + settled.tryFail(ar.cause()); + }); + other.onComplete(ar -> { + if (ar.succeeded()) + settled.tryComplete(ar.result()); + else + settled.tryFail(ar.cause()); + }); + return settled.future(); + } + + /** Like {@link #either(Future)} but value-agnostic: settles on the first of the two to complete. */ + private static Future eitherSettled(Future a, Future b) { + Promise settled = Promise.promise(); + a.onComplete(ar -> { + if (ar.succeeded()) + settled.tryComplete(); + else + settled.tryFail(ar.cause()); + }); + b.onComplete(ar -> { + if (ar.succeeded()) + settled.tryComplete(); + else + settled.tryFail(ar.cause()); }); + return settled.future(); + } + @Override + public ContextualFuture applyToEither(CompletionStage other, Function fn) { + Future otherFuture = Future.fromCompletionStage(other); + Future mapper = either(otherFuture).map(fn::apply); return of(mapper); } @Override - public VertxFuture applyToEitherAsync(CompletionStage other, Function fn) { + public ContextualFuture applyToEitherAsync(CompletionStage other, Function fn) { return applyToEitherAsync(other, fn, defaultExecutor()); } @Override - public VertxFuture applyToEitherAsync(CompletionStage other, - Function fn, Executor executor) { + public ContextualFuture applyToEitherAsync(CompletionStage other, + Function fn, Executor executor) { Future otherFuture = Future.fromCompletionStage(other); - Future composer = Future.any(future, otherFuture).compose(cf -> { + Future composer = either(otherFuture).compose(t -> { Promise promise = Promise.promise(); executor.execute(() -> { try { - for (int i = 0; i < cf.size(); i++) { - if (cf.succeeded(i)) { - T t = cf.resultAt(i); - U u = fn.apply(t); - promise.complete(u); - return; - } - } - - // This should never happen - promise.fail(new IllegalStateException("No successful result")); + promise.complete(fn.apply(t)); } catch (Throwable e) { promise.fail(e); } @@ -394,7 +416,7 @@ public VertxFuture applyToEitherAsync(CompletionStage other, } @Override - public VertxFuture acceptEither(CompletionStage other, Consumer action) { + public ContextualFuture acceptEither(CompletionStage other, Consumer action) { return applyToEither(other, (t) -> { action.accept(t); return null; @@ -402,13 +424,13 @@ public VertxFuture acceptEither(CompletionStage other, Consum } @Override - public VertxFuture acceptEitherAsync(CompletionStage other, Consumer action) { + public ContextualFuture acceptEitherAsync(CompletionStage other, Consumer action) { return acceptEitherAsync(other, action, defaultExecutor()); } @Override - public VertxFuture acceptEitherAsync(CompletionStage other, Consumer action, - Executor executor) { + public ContextualFuture acceptEitherAsync(CompletionStage other, Consumer action, + Executor executor) { return applyToEitherAsync(other, (t) -> { action.accept(t); return null; @@ -416,10 +438,9 @@ public VertxFuture acceptEitherAsync(CompletionStage other, C } @Override - public VertxFuture runAfterEither(CompletionStage other, Runnable action) { + public ContextualFuture runAfterEither(CompletionStage other, Runnable action) { Future otherFuture = Future.fromCompletionStage(other); - // The behavior of Future.any is similar to CompletableFuture.thenCombine... - Future mapper = Future.any(future, otherFuture).map(cf -> { + Future mapper = eitherSettled(future, otherFuture).map(v -> { action.run(); return null; }); @@ -428,14 +449,14 @@ public VertxFuture runAfterEither(CompletionStage other, Runnable actio } @Override - public VertxFuture runAfterEitherAsync(CompletionStage other, Runnable action) { + public ContextualFuture runAfterEitherAsync(CompletionStage other, Runnable action) { return runAfterEitherAsync(other, action, defaultExecutor()); } @Override - public VertxFuture runAfterEitherAsync(CompletionStage other, Runnable action, Executor executor) { + public ContextualFuture runAfterEitherAsync(CompletionStage other, Runnable action, Executor executor) { Future otherFuture = Future.fromCompletionStage(other); - Future composer = Future.any(future, otherFuture).compose(cf -> { + Future composer = eitherSettled(future, otherFuture).compose(v -> { Promise promise = Promise.promise(); executor.execute(() -> { try { @@ -452,19 +473,19 @@ public VertxFuture runAfterEitherAsync(CompletionStage other, Runnable } @Override - public VertxFuture thenCompose(Function> fn) { + public ContextualFuture thenCompose(Function> fn) { Future composer = future.compose(t -> Future.fromCompletionStage(fn.apply(t))); return of(composer); } @Override - public VertxFuture thenComposeAsync(Function> fn) { + public ContextualFuture thenComposeAsync(Function> fn) { return thenComposeAsync(fn, defaultExecutor()); } @Override - public VertxFuture thenComposeAsync(Function> fn, - Executor executor) { + public ContextualFuture thenComposeAsync(Function> fn, + Executor executor) { Future composer = future.compose(t -> { Promise promise = Promise.promise(); executor.execute(() -> { @@ -487,7 +508,7 @@ public VertxFuture thenComposeAsync(Function VertxFuture handle(BiFunction fn) { + public ContextualFuture handle(BiFunction fn) { Future handle = future.transform(ar -> { U u = fn.apply(ar.result(), ar.cause()); return Future.succeededFuture(u); @@ -497,12 +518,12 @@ public VertxFuture handle(BiFunction f } @Override - public VertxFuture handleAsync(BiFunction fn) { + public ContextualFuture handleAsync(BiFunction fn) { return handleAsync(fn, defaultExecutor()); } @Override - public VertxFuture handleAsync(BiFunction fn, Executor executor) { + public ContextualFuture handleAsync(BiFunction fn, Executor executor) { Future handle = future.transform(ar -> { Promise promise = Promise.promise(); executor.execute(() -> { @@ -520,7 +541,7 @@ public VertxFuture handleAsync(BiFunction whenComplete(BiConsumer action) { + public ContextualFuture whenComplete(BiConsumer action) { // Reference: API doc of CompletableFuture.whenComplete // // Unlike method handle, this method is not designed to translate completion @@ -548,12 +569,12 @@ else if (cause instanceof RuntimeException re) } @Override - public VertxFuture whenCompleteAsync(BiConsumer action) { + public ContextualFuture whenCompleteAsync(BiConsumer action) { return whenCompleteAsync(action, defaultExecutor()); } @Override - public VertxFuture whenCompleteAsync(BiConsumer action, Executor executor) { + public ContextualFuture whenCompleteAsync(BiConsumer action, Executor executor) { // Reference: API doc of CompletableFuture.whenCompleteAsync // // Unlike method handle, this method is not designed to translate completion @@ -584,19 +605,19 @@ public VertxFuture whenCompleteAsync(BiConsumer } @Override - public VertxFuture exceptionally(Function fn) { + public ContextualFuture exceptionally(Function fn) { Future otherwise = future.otherwise(fn::apply); return of(otherwise); } @Override - public VertxFuture exceptionallyAsync(Function fn) { + public ContextualFuture exceptionallyAsync(Function fn) { return exceptionallyAsync(fn, defaultExecutor()); } @Override - public VertxFuture exceptionallyAsync(Function fn, Executor executor) { + public ContextualFuture exceptionallyAsync(Function fn, Executor executor) { Future mapper = future.recover(e -> { Promise promise = Promise.promise(); executor.execute(() -> { @@ -614,20 +635,20 @@ public VertxFuture exceptionallyAsync(Function fn, Ex } @Override - public VertxFuture exceptionallyCompose(Function> fn) { + public ContextualFuture exceptionallyCompose(Function> fn) { Future mapper = future.recover(e -> Future.fromCompletionStage(fn.apply(e))); return of(mapper); } @Override - public VertxFuture exceptionallyComposeAsync(Function> fn) { + public ContextualFuture exceptionallyComposeAsync(Function> fn) { return exceptionallyComposeAsync(fn, defaultExecutor()); } @Override - public VertxFuture exceptionallyComposeAsync(Function> fn, - Executor executor) { + public ContextualFuture exceptionallyComposeAsync(Function> fn, + Executor executor) { Future mapper = future.recover(e -> { Promise promise = Promise.promise(); executor.execute(() -> { @@ -650,7 +671,7 @@ public VertxFuture exceptionallyComposeAsync(Function VertxFuture newIncompleteFuture() { + public ContextualFuture newIncompleteFuture() { Promise promise = Promise.promise(); return of(promise.future()); } @@ -703,30 +724,27 @@ public boolean isCompletedExceptionally() { */ @Override public T get() throws InterruptedException, ExecutionException { - if (future.isComplete()) { - if (future.succeeded()) - return future.result(); - else if (future.failed()) - throw new ExecutionException(future.cause()); - else - throw new InterruptedException("Context closed"); - } - - if (Context.isOnVertxThread() || Context.isOnEventLoopThread()) - throw new IllegalStateException("Cannot not be called on vertx thread or event loop thread"); - if (!future.isComplete()) { + if (Context.isOnVertxThread() || Context.isOnEventLoopThread()) + throw new IllegalStateException("Cannot be called on a vertx thread or event loop thread"); + final CountDownLatch latch = new CountDownLatch(1); future.andThen(ar -> latch.countDown()); latch.await(); } + return resultOrThrow(); + } + + /** + * Returns the result of the now-complete future, or throws its failure wrapped in an + * {@link ExecutionException}. A complete Vert.x future is always either succeeded or failed. + */ + private T resultOrThrow() throws ExecutionException { if (future.succeeded()) return future.result(); - else if (future.failed()) - throw new ExecutionException(future.cause()); else - throw new InterruptedException("Context closed"); + throw new ExecutionException(future.cause()); } /** @@ -744,31 +762,17 @@ else if (future.failed()) */ @Override public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { - if (future.isComplete()) { - if (future.succeeded()) - return future.result(); - else if (future.failed()) - throw new ExecutionException(future.cause()); - else - throw new InterruptedException("Context closed"); - } - - if (Context.isOnVertxThread() || Context.isOnEventLoopThread()) - throw new IllegalStateException("Cannot not be called on vertx thread or event loop thread"); - if (!future.isComplete()) { + if (Context.isOnVertxThread() || Context.isOnEventLoopThread()) + throw new IllegalStateException("Cannot be called on a vertx thread or event loop thread"); + final CountDownLatch latch = new CountDownLatch(1); future.andThen(ar -> latch.countDown()); if (!latch.await(timeout, unit)) throw new TimeoutException(); } - if (future.succeeded()) - return future.result(); - else if (future.failed()) - throw new ExecutionException(future.cause()); - else - throw new InterruptedException("Context closed"); + return resultOrThrow(); } @Override @@ -803,15 +807,20 @@ public T getNow(T valueIfAbsent) { @SuppressWarnings("unchecked") @Override public boolean complete(T value) { + // Only a Promise-backed (incomplete) future can be completed here; otherwise the underlying + // Vert.x future is controlled elsewhere, so report "not completed" rather than throwing. + // This relies on the Vert.x contract that Promise.promise().future() returns an object that + // is itself a Promise (true on Vert.x 4.x/5.x), so the instanceof check identifies the + // wrappers we created from a Promise (see succeededFuture/newIncompleteFuture/copy). if (future instanceof Promise) { Promise promise = (Promise) future; return promise.tryComplete(value); } else - throw new IllegalStateException(); + return false; } @Override - public VertxFuture completeAsync(Supplier supplier, Executor executor) { + public ContextualFuture completeAsync(Supplier supplier, Executor executor) { if (supplier == null || executor == null) throw new NullPointerException(); executor.execute(() -> complete(supplier.get())); @@ -819,7 +828,7 @@ public VertxFuture completeAsync(Supplier supplier, Executor exe } @Override - public VertxFuture completeAsync(Supplier supplier) { + public ContextualFuture completeAsync(Supplier supplier) { return completeAsync(supplier, defaultExecutor()); } @@ -831,11 +840,11 @@ public boolean completeExceptionally(Throwable ex) { if (future instanceof Promise promise) return promise.tryFail(ex); else - throw new IllegalStateException(); + return false; } @Override - public VertxFuture orTimeout(long timeout, TimeUnit unit) { + public ContextualFuture orTimeout(long timeout, TimeUnit unit) { if (unit == null) throw new NullPointerException(); Future f = future.timeout(timeout, unit); @@ -843,7 +852,7 @@ public VertxFuture orTimeout(long timeout, TimeUnit unit) { } @Override - public VertxFuture completeOnTimeout(T value, long timeout, TimeUnit unit) { + public ContextualFuture completeOnTimeout(T value, long timeout, TimeUnit unit) { if (unit == null) throw new NullPointerException(); @@ -868,7 +877,7 @@ public void obtrudeException(Throwable ex) { } @Override - public VertxFuture copy() { + public ContextualFuture copy() { Promise promise = Promise.promise(); future.andThen(promise); return of(promise.future()); @@ -880,61 +889,61 @@ public CompletionStage minimalCompletionStage() { } /** - * Returns a new VertxFuture that is completed when all the given futures complete. + * Returns a new ContextualFuture that is completed when all the given futures complete. * * @param futures the futures to wait for - * @return a new VertxFuture that is completed when all the given futures complete + * @return a new ContextualFuture that is completed when all the given futures complete */ - public static VertxFuture allOf(VertxFuture... futures) { + public static ContextualFuture allOf(ContextualFuture... futures) { List> vfs = Arrays.stream(futures).map(f -> f.future).toList(); Future cf = Future.all(vfs).mapEmpty(); return of(cf); } /** - * Returns a new VertxFuture that is completed when all the given futures complete. + * Returns a new ContextualFuture that is completed when all the given futures complete. * * @param futures the collection of futures to wait for - * @return a new VertxFuture that is completed when all the given futures complete + * @return a new ContextualFuture that is completed when all the given futures complete */ - public static VertxFuture allOf(Collection> futures) { + public static ContextualFuture allOf(Collection> futures) { List> vfs = futures.stream().map(f -> f.future).toList(); Future cf = Future.all(vfs).mapEmpty(); return of(cf); } /** - * Returns a new VertxFuture that is completed when any of the given futures succeed. + * Returns a new ContextualFuture that is completed when any of the given futures succeed. * * @param futures the futures to wait for - * @return a new VertxFuture that is completed when any of the given futures succeed + * @return a new ContextualFuture that is completed when any of the given futures succeed */ - public static VertxFuture anyOf(VertxFuture... futures) { + public static ContextualFuture anyOf(ContextualFuture... futures) { List> vfs = Arrays.stream(futures).map(f -> f.future).toList(); Future cf = Future.any(vfs).mapEmpty(); return of(cf); } /** - * Returns a new VertxFuture that is completed when any of the given futures succeed. + * Returns a new ContextualFuture that is completed when any of the given futures succeed. * * @param futures the collection of futures to wait for - * @return a new VertxFuture that is completed when any of the given futures succeed + * @return a new ContextualFuture that is completed when any of the given futures succeed */ - public static VertxFuture anyOf(Collection> futures) { + public static ContextualFuture anyOf(Collection> futures) { List> vfs = futures.stream().map(f -> f.future).toList(); Future cf = Future.any(vfs).mapEmpty(); return of(cf); } /** - * A reduced view of VertxFuture that exposes only {@link CompletionStage} operations, + * A reduced view of ContextualFuture that exposes only {@link CompletionStage} operations, * disabling mutation methods such as {@code complete()}, {@code cancel()}, etc. *

    * This is used by {@link #minimalCompletionStage()} to comply with the * {@link CompletableFuture#minimalCompletionStage()} contract. */ - static final class MinimalStage extends VertxFuture { + static final class MinimalStage extends ContextualFuture { MinimalStage(Future future) { super(future); } diff --git a/api/src/main/java/io/bosonnetwork/vertx/ObservableReadStream.java b/api/src/main/java/io/bosonnetwork/vertx/ObservableReadStream.java index 771d5178..2c72eb96 100644 --- a/api/src/main/java/io/bosonnetwork/vertx/ObservableReadStream.java +++ b/api/src/main/java/io/bosonnetwork/vertx/ObservableReadStream.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.vertx; import io.vertx.core.Handler; @@ -88,7 +110,8 @@ public ObservableReadStream resume() { @Override public ObservableReadStream fetch(long amount) { - delegate.fetch(amount); + if (!terminated) + delegate.fetch(amount); return this; } diff --git a/api/src/main/java/io/bosonnetwork/vertx/VertxCaffeine.java b/api/src/main/java/io/bosonnetwork/vertx/VertxCaffeine.java index 7643aa70..c8f6562d 100644 --- a/api/src/main/java/io/bosonnetwork/vertx/VertxCaffeine.java +++ b/api/src/main/java/io/bosonnetwork/vertx/VertxCaffeine.java @@ -63,7 +63,7 @@ public static Caffeine newBuilder(Vertx vertx) { * Custom Caffeine Scheduler that schedules tasks using Vert.x timers. * * The scheduled task is executed on the provided executor after the specified delay. - * Completion is signaled via a {@link VertxFuture}, which is completed when the task finishes + * Completion is signaled via a {@link ContextualFuture}, which is completed when the task finishes * or completed exceptionally if an error occurs. */ Scheduler vertxScheduler = (executor, runnable, delay, unit) -> { @@ -82,7 +82,7 @@ public static Caffeine newBuilder(Vertx vertx) { }); }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); }; return Caffeine.newBuilder() @@ -119,7 +119,7 @@ public static Caffeine newBuilder() { * Custom Caffeine Scheduler that schedules tasks using Vert.x timers from the current context. * * The scheduled task is executed on the provided executor after the specified delay. - * Completion is signaled via a {@link VertxFuture}, which is completed when the task finishes + * Completion is signaled via a {@link ContextualFuture}, which is completed when the task finishes * or completed exceptionally if an error occurs. */ Scheduler vertxScheduler = (executor, runnable, delay, unit) -> { @@ -141,7 +141,7 @@ public static Caffeine newBuilder() { }); }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); }; return Caffeine.newBuilder() diff --git a/api/src/main/java/io/bosonnetwork/vertx/package-info.java b/api/src/main/java/io/bosonnetwork/vertx/package-info.java new file mode 100644 index 00000000..eca0cbcf --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/vertx/package-info.java @@ -0,0 +1,45 @@ +/* + * 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. + */ + +/** + * Integration helpers that make Vert.x easier and safer to use within Boson modules. + *

      + *
    • {@link io.bosonnetwork.vertx.BosonVerticle} — an abstract verticle base that hides the + * Vert.x 4.x/5.x {@code Verticle}/{@code Deployable} API differences behind + * {@code deploy()}/{@code undeploy()};
    • + *
    • {@link io.bosonnetwork.vertx.ContextualFuture} — a {@link java.util.concurrent.CompletableFuture} + * compatible wrapper around a Vert.x {@code Future}, bridging the reactive and + * {@code CompletionStage} programming models;
    • + *
    • {@link io.bosonnetwork.vertx.BufferInputStream} / {@link io.bosonnetwork.vertx.BufferOutputStream} + * — zero-copy {@code InputStream}/{@code OutputStream} views over a Vert.x {@code Buffer} + * (e.g. for Jackson);
    • + *
    • {@link io.bosonnetwork.vertx.ObservableReadStream} — a {@code ReadStream} wrapper that + * observes each element (and treats an observer error as the authoritative failure signal);
    • + *
    • {@link io.bosonnetwork.vertx.VertxCaffeine} — an {@link java.util.concurrent.Executor} and + * scheduler that let a Caffeine cache run cooperatively on the Vert.x event loop.
    • + *
    + * + *

    Threading: blocking accessors (such as + * {@link io.bosonnetwork.vertx.ContextualFuture#get()}) must never be called on a Vert.x event-loop + * or worker thread. + */ +package io.bosonnetwork.vertx; diff --git a/api/src/main/java/io/bosonnetwork/web/CwtAuth.java b/api/src/main/java/io/bosonnetwork/web/CwtAuth.java index b6c0678f..21e5a0e9 100644 --- a/api/src/main/java/io/bosonnetwork/web/CwtAuth.java +++ b/api/src/main/java/io/bosonnetwork/web/CwtAuth.java @@ -23,9 +23,11 @@ package io.bosonnetwork.web; import java.time.Duration; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; +import java.util.Set; import io.vertx.core.Future; import io.vertx.core.json.JsonObject; @@ -34,6 +36,10 @@ import io.vertx.ext.auth.authentication.CredentialValidationException; import io.vertx.ext.auth.authentication.Credentials; import io.vertx.ext.auth.authentication.TokenCredentials; +import io.vertx.ext.auth.authorization.Authorization; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.bosonnetwork.Id; import io.bosonnetwork.Identity; @@ -42,8 +48,9 @@ import io.bosonnetwork.cwt.SignedCwt; import io.bosonnetwork.service.ClientDevice; import io.bosonnetwork.service.ClientUser; -import io.bosonnetwork.service.SuperNodeInfo; +import io.bosonnetwork.service.Role; import io.bosonnetwork.service.ServiceInfo; +import io.bosonnetwork.service.SuperNodeInfo; /** * A CBOR Web Token (CWT) authentication provider. @@ -53,6 +60,8 @@ * using CBOR and Base64Url encoding. */ public class CwtAuth implements AuthenticationProvider { + private static final Logger log = LoggerFactory.getLogger(CwtAuth.class); + private final Identity identity; private final ClientProvider clientProvider; private final int defaultTtl; @@ -66,8 +75,13 @@ private CwtAuth(CwtAuthOptions options) { this.defaultScope = options.getDefaultScope(); this.cwtParser = SignedCwt.parser().setLeeway(options.getLeeway()); - if (options.getExpectedAudience() != null) + if (options.getExpectedAudience() != null) { this.cwtParser.requireAudience(options.getExpectedAudience()); + } else { + log.warn("CwtAuth: no expected audience configured - tokens are accepted regardless of their " + + "'aud' claim, so a token minted for another server can be replayed against this one. " + + "Set CwtAuthOptions.setExpectedAudience(localNodeId) to restrict tokens to this server."); + } } /** @@ -173,19 +187,26 @@ public Future authenticate(Credentials credentials) { private User createUser(Object client, String scope, SignedCwt cwt, String accessToken) { final Id userId; final Id clientId; + final Set authorizations = new HashSet<>(); if (client instanceof ClientUser u) { userId = u.getId(); clientId = null; + authorizations.add(RoleBasedAuthorization.create(Role.CLIENT.toString())); + if (u.isAdmin()) + authorizations.add(RoleBasedAuthorization.create(Role.ADMIN.toString())); } else if (client instanceof ClientDevice d) { userId = d.getUserId(); clientId = d.getId(); + authorizations.add(RoleBasedAuthorization.create(Role.CLIENT.toString())); } else if (client instanceof SuperNodeInfo n) { userId = n.getId(); clientId = null; + authorizations.add(RoleBasedAuthorization.create(Role.FEDERATION.toString())); } else if (client instanceof ServiceInfo s) { userId = s.getNodeId(); clientId = s.getPeerId(); + authorizations.add(RoleBasedAuthorization.create(Role.FEDERATION.toString())); } else { throw new IllegalStateException("Invalid client type: " + client.getClass().getName()); } @@ -217,7 +238,10 @@ private User createUser(Object client, String scope, SignedCwt cwt, String acces map.put("sub", userId); JsonObject attributes = new JsonObject(Map.copyOf(map)); - return User.create(principal, attributes); + User user = User.create(principal, attributes); + user.authorizations().put("boson", authorizations); + + return user; } /** @@ -250,7 +274,7 @@ public String generateToken(Id userId, Id sessionId, Id clientId, String scope, if (clientId != null) cwtBuilder.clientId(clientId); if (sessionId != null) - cwtBuilder.claim(Claim.SESSION_ID.getValue(), sessionId.bytes()); + cwtBuilder.claim(Claim.SESSION_ID.getValue(), sessionId.bytesUnsafe()); return cwtBuilder.buildToString(); } diff --git a/api/src/main/java/io/bosonnetwork/web/CwtAuthHandler.java b/api/src/main/java/io/bosonnetwork/web/CwtAuthHandler.java index 6cdcef12..2995d4d1 100644 --- a/api/src/main/java/io/bosonnetwork/web/CwtAuthHandler.java +++ b/api/src/main/java/io/bosonnetwork/web/CwtAuthHandler.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Objects; import java.util.Set; +import java.util.regex.Pattern; import io.vertx.core.Future; import io.vertx.ext.auth.User; @@ -60,7 +61,7 @@ private CwtAuthHandler(CwtAuth authProvider, String realm) { private CwtAuthHandler(CwtAuthHandler base, List scopes, String delimiter) { super(base.authProvider, Type.BEARER, base.realm); - this.scopes = Objects.requireNonNull(scopes, "scopes cannot be null");; + this.scopes = Objects.requireNonNull(scopes, "scopes cannot be null"); this.delimiter = Objects.requireNonNull(delimiter, "delimiter cannot be null"); } @@ -103,7 +104,7 @@ public Future authenticate(RoutingContext context) { return authProvider.authenticate(credentials) .andThen(op -> audit.audit(Marker.AUTHENTICATION, op.succeeded())) - .recover(err -> Future.failedFuture(new HttpException(401, err.getMessage()))); + .recover(err -> Future.failedFuture(new HttpException(401, "Unauthorized", err))); }); } @@ -132,7 +133,7 @@ public void postAuthentication(RoutingContext ctx) { } // Use a Set for faster lookups - String[] ss = scope.split(delimiter); + String[] ss = scope.split(Pattern.quote(delimiter)); if (ss.length == 0) { ctx.fail(403, new HttpException(403, "Invalid authorization token: scope undefined")); return; @@ -199,7 +200,7 @@ public CwtAuthHandler withScopes(List scopes) { /** * Sets the delimiter used to split the scope claim string. * Default is space " ". - * + * * @param delimiter the delimiter string * @return self */ diff --git a/api/src/main/java/io/bosonnetwork/web/CwtAuthOptions.java b/api/src/main/java/io/bosonnetwork/web/CwtAuthOptions.java index 0cb36204..9a2e5587 100644 --- a/api/src/main/java/io/bosonnetwork/web/CwtAuthOptions.java +++ b/api/src/main/java/io/bosonnetwork/web/CwtAuthOptions.java @@ -111,8 +111,14 @@ public Id getExpectedAudience() { /** * Sets the expected audience ID to enforce on received tokens. + *

    + * Audience validation is optional and opt-in. When set, only tokens whose {@code aud} + * claim equals this ID are accepted. When left {@code null} (the default), the {@code aud} claim + * is not checked, so a token minted for another server can be replayed against this one; + * {@link CwtAuth} logs a warning at construction time in that case. Production deployments should + * set this to the local server (node or service) {@link Id}. * - * @param expectedAudience the expected audience ID; if null, audience validation is bypassed + * @param expectedAudience the expected audience ID; if null, audience validation is disabled * @return this CwtAuthOptions instance for method chaining */ public CwtAuthOptions setExpectedAudience(Id expectedAudience) { diff --git a/api/src/main/java/io/bosonnetwork/web/package-info.java b/api/src/main/java/io/bosonnetwork/web/package-info.java new file mode 100644 index 00000000..93a7ca06 --- /dev/null +++ b/api/src/main/java/io/bosonnetwork/web/package-info.java @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/** + * CWT-based HTTP authentication for Boson web services, built on Vert.x Web. + *

    + * This package adapts the {@link io.bosonnetwork.cwt.SignedCwt} token format into the Vert.x + * authentication/authorization model so that super-node HTTP services can authenticate clients and + * federation peers from a bearer token. + *

      + *
    • {@link io.bosonnetwork.web.CwtAuth} — a Vert.x {@code AuthenticationProvider} that parses + * and verifies a CWT, validates the issuer against the accepted set (this node, the subject + * for self-issued tokens, or the client), and resolves the principal;
    • + *
    • {@link io.bosonnetwork.web.CwtAuthHandler} — the Vert.x Web handler that extracts the bearer + * token, drives {@code CwtAuth}, and enforces required scopes;
    • + *
    • {@link io.bosonnetwork.web.CwtAuthOptions} — configuration (issuing identity, expected + * audience, clock-skew leeway, default scope/TTL, client provider);
    • + *
    • {@link io.bosonnetwork.web.ClientProvider} — the lookup callback the auth layer uses to + * resolve a client/principal referenced by a token.
    • + *
    + * Principal entitlements map onto {@link io.bosonnetwork.service.Role} / + * {@link io.bosonnetwork.service.AccessScope} from the service package. + */ +package io.bosonnetwork.web; diff --git a/api/src/test/java/io/bosonnetwork/IdTests.java b/api/src/test/java/io/bosonnetwork/IdTests.java index 6c5f9b40..1d945cee 100644 --- a/api/src/test/java/io/bosonnetwork/IdTests.java +++ b/api/src/test/java/io/bosonnetwork/IdTests.java @@ -70,7 +70,7 @@ void testOfHex() { } @Test - void testOfBytes() { + void testOfBytesUnsafe() { var binId = new byte[Id.BYTES]; new Random().nextBytes(binId); @@ -544,7 +544,7 @@ void testCbor() { assertEquals(0x58, cborValue[0]); // length of the byte string assertEquals(32, cborValue[1]); - assertArrayEquals(id.bytes(), Arrays.copyOfRange(cborValue, 2, 34)); + assertArrayEquals(id.bytesUnsafe(), Arrays.copyOfRange(cborValue, 2, 34)); Id id1 = Json.parse(cborValue, Id.class); assertEquals(id, id1); diff --git a/api/src/test/java/io/bosonnetwork/NodeInfoTests.java b/api/src/test/java/io/bosonnetwork/NodeInfoTests.java index 8e4d144c..e910cec4 100644 --- a/api/src/test/java/io/bosonnetwork/NodeInfoTests.java +++ b/api/src/test/java/io/bosonnetwork/NodeInfoTests.java @@ -59,13 +59,13 @@ void testInvalidConstructors() { Id id = Id.random(); InetAddress addr = InetAddress.getLoopbackAddress(); - assertThrows(IllegalArgumentException.class, () -> new NodeInfo(null, addr, 1234)); - assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, (InetAddress) null, 1234)); + assertThrows(NullPointerException.class, () -> new NodeInfo(null, addr, 1234)); + assertThrows(NullPointerException.class, () -> new NodeInfo(id, (InetAddress) null, 1234)); assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, addr, 0)); assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, addr, 65536)); - assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, (String) null, 1234)); - assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, (byte[]) null, 1234)); + assertThrows(NullPointerException.class, () -> new NodeInfo(id, (String) null, 1234)); + assertThrows(NullPointerException.class, () -> new NodeInfo(id, (byte[]) null, 1234)); assertThrows(IllegalArgumentException.class, () -> new NodeInfo(id, new byte[3], 1234)); // Invalid IP length } diff --git a/api/src/test/java/io/bosonnetwork/PeerInfoTests.java b/api/src/test/java/io/bosonnetwork/PeerInfoTests.java index a3a995cc..cab8a47f 100644 --- a/api/src/test/java/io/bosonnetwork/PeerInfoTests.java +++ b/api/src/test/java/io/bosonnetwork/PeerInfoTests.java @@ -104,7 +104,7 @@ void testPeerInfo() { assertThrows(IllegalStateException.class, () -> peer4.update().endpoint("tcp://hostname:2345").build()); peer.getNonce()[0] = (byte) (peer.getNonce()[0] + 1); - assertFalse(peer.isValid()); + assertTrue(peer.isValid()); } @Test @@ -198,7 +198,7 @@ void testPeerInfoWithExtraData() { assertThrows(IllegalStateException.class, () -> peer4.update().endpoint("tcp://hostname:2345").build()); peer.getExtraData()[0] = (byte) (peer.getExtraData()[0] + 1); - assertFalse(peer.isValid()); + assertTrue(peer.isValid()); } @Test @@ -283,7 +283,7 @@ void testAuthenticatedPeerInfo() { assertThrows(IllegalArgumentException.class, () -> peer3.update().identity(new CryptoIdentity()).node(node).endpoint(endpoint2).build()); peer.getNonce()[0] = (byte) (peer.getNonce()[0] + 1); - assertFalse(peer.isValid()); + assertTrue(peer.isValid()); } @Test @@ -387,7 +387,7 @@ void testAuthenticatedPeerInfoWithExtraData() { assertThrows(IllegalArgumentException.class, () -> peer3.update().identity(new CryptoIdentity()).node(node).endpoint(endpoint2).build()); peer.getExtraData()[0] = (byte) (peer.getExtraData()[0] + 1); - assertFalse(peer.isValid()); + assertTrue(peer.isValid()); } @Test diff --git a/api/src/test/java/io/bosonnetwork/ValueTests.java b/api/src/test/java/io/bosonnetwork/ValueTests.java index 8fe58954..207df794 100644 --- a/api/src/test/java/io/bosonnetwork/ValueTests.java +++ b/api/src/test/java/io/bosonnetwork/ValueTests.java @@ -45,7 +45,7 @@ void testImmutableValue() { assertThrows(UnsupportedOperationException.class, () -> value.update().data(data).build()); value.getData()[0] = (byte) (value.getData()[0] + 1); - assertFalse(value.isValid()); + assertTrue(value.isValid()); Value value2 = value.withoutPrivateKey(); assertSame(value, value2); @@ -105,7 +105,7 @@ void testSignedValue() { Value value3 = value2.update().data(data2).build(); assertNotSame(value2, value3); - assertEquals(data2, value3.getData()); + assertArrayEquals(data2, value3.getData()); assertFalse(Arrays.equals(value2.getNonce(), value3.getNonce())); assertEquals(3, value3.getSequenceNumber()); @@ -116,7 +116,7 @@ void testSignedValue() { assertThrows(UnsupportedOperationException.class, () -> value4.decryptData(Signature.KeyPair.random().privateKey())); value.getData()[0] = (byte) (value.getData()[0] + 1); - assertFalse(value.isValid()); + assertTrue(value.isValid()); } @Test @@ -198,8 +198,8 @@ void testEncryptedValue() throws Exception { assertThrows(IllegalArgumentException.class, () -> value4.decryptData(Signature.KeyPair.random().privateKey())); value4.getData()[0] = (byte) (value4.getData()[0] + 1); - assertFalse(value4.isValid()); - assertThrows(IllegalStateException.class, () -> value4.decryptData(recipientKp.privateKey())); + assertTrue(value4.isValid()); + assertArrayEquals(data2, value4.decryptData(recipientKp.privateKey())); } @Test diff --git a/api/src/main/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java b/api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java similarity index 84% rename from api/src/main/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java rename to api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java index 60fc3235..6000c508 100644 --- a/api/src/main/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CertUtilBouncyCastle.java @@ -69,15 +69,6 @@ * using the Bouncy Castle library. */ public class CertUtilBouncyCastle { - /** - * 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) { - } - /** * Initializes the security provider. * Adds {@link BouncyCastleProvider} to the security providers. @@ -93,12 +84,12 @@ public static void init() { * @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 PemCertificateAndKey} containing the PEM-encoded certificate and private key - * @throws KeyConvertException if an error occurs during key conversion or certificate generation + * @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 PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey, + public static CryptoUtil.PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey, String ipAddress, String hostName, boolean enableWildcard) - throws KeyConvertException { + throws CryptoUtil.KeyConvertException { try { // Extract the 32-byte seed and public key from libsodium 64-byte SK byte[] sodiumSecretKey = signaturePrivateKey.bytes(); @@ -155,7 +146,7 @@ public static PemCertificateAndKey certificateFromSignatureKey(Signature.Private if (ipAddress != null) subjectAltNames.add(new GeneralName(GeneralName.iPAddress, ipAddress)); if (subjectAltNames.isEmpty()) - throw new KeyConvertException("At least one SAN (hostname or IP) must be provided"); + 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") @@ -185,11 +176,11 @@ public static PemCertificateAndKey certificateFromSignatureKey(Signature.Private String keyPem = toPemString(privateKey); String certPem = toPemString(certHolder); - return new PemCertificateAndKey(certPem, keyPem); - } catch (KeyConvertException e) { + return new CryptoUtil.PemCertificateAndKey(certPem, keyPem); + } catch (CryptoUtil.KeyConvertException e) { throw e; } catch (Exception e) { - throw new KeyConvertException("Failed to convert key to PEM format key and certificate", e); + throw new CryptoUtil.KeyConvertException("Failed to convert key to PEM format key and certificate", e); } } @@ -200,30 +191,4 @@ private static String toPemString(Object obj) throws IOException { } return sw.toString(); } - - /** - * Exception thrown when an error occurs during key conversion or certificate generation. - */ - public static class KeyConvertException extends Exception { - 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/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java b/api/src/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java index 8cf2f29b..aaa80851 100644 --- a/api/src/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java +++ b/api/src/test/java/io/bosonnetwork/crypto/CryptoUtilTests.java @@ -29,7 +29,7 @@ public void testCertificateFromSignatureKeyBCWithIP() throws Exception { Signature.KeyPair kp = Signature.KeyPair.random(); String ipAddress = "127.0.0.1"; - CertUtilBouncyCastle.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, null, false); + CryptoUtil.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, null, false); assertNotNull(result); assertNotNull(result.cert()); @@ -49,7 +49,7 @@ public void testCertificateFromSignatureKeyBCWithHostName() throws Exception { Signature.KeyPair kp = Signature.KeyPair.random(); String hostName = "localhost"; - CertUtilBouncyCastle.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), null, hostName, true); + CryptoUtil.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), null, hostName, true); assertNotNull(result); assertNotNull(result.cert()); @@ -70,7 +70,7 @@ public void testCertificateFromSignatureKeyBCWithBoth() throws Exception { String ipAddress = "127.0.0.1"; String hostName = "localhost"; - CertUtilBouncyCastle.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); + CryptoUtil.PemCertificateAndKey result = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); assertNotNull(result); assertNotNull(result.cert()); @@ -87,7 +87,7 @@ public void testCertificateFromSignatureKeyBCWithBoth() throws Exception { public void testCertificateFromSignatureKeyBCNoSAN() { Signature.KeyPair kp = Signature.KeyPair.random(); - assertThrows(CertUtilBouncyCastle.KeyConvertException.class, () -> + assertThrows(CryptoUtil.KeyConvertException.class, () -> CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), null, null, false) ); } @@ -112,7 +112,7 @@ public void testCertificateFromSignatureKey() throws Exception { assertTrue(result.privateKey().contains("-----BEGIN PRIVATE KEY-----")); // Compare with reference implementation - CertUtilBouncyCastle.PemCertificateAndKey ref = CertUtilBouncyCastle.certificateFromSignatureKey(kp.privateKey(), ipAddress, hostName, true); + CryptoUtil.PemCertificateAndKey ref = CertUtilBouncyCastle.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/cwt/SignedCwtTests.java b/api/src/test/java/io/bosonnetwork/cwt/SignedCwtTests.java index 8443b3fe..d822a293 100644 --- a/api/src/test/java/io/bosonnetwork/cwt/SignedCwtTests.java +++ b/api/src/test/java/io/bosonnetwork/cwt/SignedCwtTests.java @@ -71,7 +71,7 @@ public void testSignedCwt() throws Exception { .notBeforeNow() .expiration(Duration.ofDays(7)) .tokenId("test#01") - .scope(scope.bytes()) + .scope(scope.bytesUnsafe()) .build(); System.out.println(Hex.encode(token)); @@ -80,9 +80,9 @@ public void testSignedCwt() throws Exception { assertNotNull(cwt); System.out.println(cwt.toString()); - assertArrayEquals(subject.bytes(), cwt.getClaim(Claim.SUBJECT.getValue())); - assertArrayEquals(audience.bytes(), cwt.getClaim(Claim.AUDIENCE.getValue())); - assertArrayEquals(scope.bytes(), cwt.getClaim(Claim.SCOPE.getValue())); + assertArrayEquals(subject.bytesUnsafe(), cwt.getClaim(Claim.SUBJECT.getValue())); + assertArrayEquals(audience.bytesUnsafe(), cwt.getClaim(Claim.AUDIENCE.getValue())); + assertArrayEquals(scope.bytesUnsafe(), cwt.getClaim(Claim.SCOPE.getValue())); int notBefore = cwt.getClaim(Claim.NOT_BEFORE.getValue()); assertTrue(notBefore <= (System.currentTimeMillis() / 1000)); int expiration = cwt.getClaim(Claim.EXPIRATION.getValue()); @@ -304,4 +304,29 @@ public void testInvalidCoseStructure() { byte[] wrongTag = new byte[]{(byte) 0xd9, 0x01, 0x01}; // CBOR tag 257 instead of 18 assertThrows(InvalidCborTagException.class, () -> SignedCwt.parse(wrongTag)); } + + @Test + public void testEmptyAndMalformedToken() { + // Empty byte[] must surface as CwtException, not ArrayIndexOutOfBoundsException + assertThrows(InvalidCborTagException.class, () -> SignedCwt.parse(new byte[0])); + + // Null base64 string must surface as CwtException, not NullPointerException + assertThrows(InvalidCoseStructureException.class, () -> SignedCwt.parse((String) null)); + + // Malformed base64 must surface as CwtException, not IllegalArgumentException + assertThrows(InvalidCoseStructureException.class, () -> SignedCwt.parse("not valid base64 !!!")); + } + + @Test + public void testCriticalHeadersRejected() throws Exception { + CryptoIdentity identity = new CryptoIdentity(); + + // A token that marks an (unknown) header as critical must be rejected per RFC 8152 §3.1 + byte[] token = SignedCwt.builder(identity) + .subject(Id.random()) + .protectedHeader(Header.CRIT.getValue(), java.util.List.of(99)) + .build(); + + assertThrows(InvalidCoseStructureException.class, () -> SignedCwt.parse(token)); + } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/database/FilterTests.java b/api/src/test/java/io/bosonnetwork/database/FilterTests.java index 1092fb06..d839de02 100644 --- a/api/src/test/java/io/bosonnetwork/database/FilterTests.java +++ b/api/src/test/java/io/bosonnetwork/database/FilterTests.java @@ -1,6 +1,7 @@ package io.bosonnetwork.database; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.LinkedHashMap; @@ -21,50 +22,66 @@ void testNone() { @Test void testEqual() { Filter filter = Filter.eq("foo", "hello"); - assertEquals(" foo = #{foo}", filter.toSqlTemplate()); - assertEquals("hello", filter.getParams().get("foo")); + assertEquals(" foo = #{foo_eq}", filter.toSqlTemplate()); + assertEquals("hello", filter.getParams().get("foo_eq")); } @Test void testNotEqual() { Filter filter = Filter.ne("foo", "world"); - assertEquals(" foo <> #{foo}", filter.toSqlTemplate()); - assertEquals("world", filter.getParams().get("foo")); + assertEquals(" foo <> #{foo_ne}", filter.toSqlTemplate()); + assertEquals("world", filter.getParams().get("foo_ne")); } @Test void testLessThan() { Filter filter = Filter.lt("foo", 10); - assertEquals(" foo < #{foo}", filter.toSqlTemplate()); - assertEquals(10, filter.getParams().get("foo")); + assertEquals(" foo < #{foo_lt}", filter.toSqlTemplate()); + assertEquals(10, filter.getParams().get("foo_lt")); } @Test void testLessThanOrEqual() { Filter filter = Filter.lte("foo", 15); - assertEquals(" foo <= #{foo}", filter.toSqlTemplate()); - assertEquals(15, filter.getParams().get("foo")); + assertEquals(" foo <= #{foo_lte}", filter.toSqlTemplate()); + assertEquals(15, filter.getParams().get("foo_lte")); } @Test void testGreaterThan() { Filter filter = Filter.gt("foo", 20); - assertEquals(" foo > #{foo}", filter.toSqlTemplate()); - assertEquals(20, filter.getParams().get("foo")); + assertEquals(" foo > #{foo_gt}", filter.toSqlTemplate()); + assertEquals(20, filter.getParams().get("foo_gt")); } @Test void testGreaterThanOrEqual() { Filter filter = Filter.gte("foo", 25); - assertEquals(" foo >= #{foo}", filter.toSqlTemplate()); - assertEquals(25, filter.getParams().get("foo")); + assertEquals(" foo >= #{foo_gte}", filter.toSqlTemplate()); + assertEquals(25, filter.getParams().get("foo_gte")); } @Test void testLike() { Filter filter = Filter.like("foo", "ABC%"); - assertEquals(" foo LIKE #{foo}", filter.toSqlTemplate()); - assertEquals("ABC%", filter.getParams().get("foo")); + assertEquals(" foo LIKE #{foo_like}", filter.toSqlTemplate()); + assertEquals("ABC%", filter.getParams().get("foo_like")); + } + + @Test + void testRangeOnSameColumn() { + // A range query on a single column must not collide on the default param name. + Filter filter = Filter.and(Filter.gte("ts", 100), Filter.lte("ts", 200)); + assertEquals(" ( ts >= #{ts_gte} AND ts <= #{ts_lte})", filter.toSqlTemplate()); + + Map params = filter.getParams(); + assertEquals(2, params.size()); + assertEquals(100, params.get("ts_gte")); + assertEquals(200, params.get("ts_lte")); + + // Same column AND same operator still collides — surfaced with a clear message. + Filter colliding = Filter.and(Filter.eq("x", 1), Filter.eq("x", 2)); + assertThrows(IllegalStateException.class, colliding::getParams); } @Test @@ -101,7 +118,7 @@ void testAnd() { assertEquals(" 1 = 1", filter.toSqlTemplate()); filter = Filter.and(Filter.eq("foo", 10)); - assertEquals(" foo = #{foo}", filter.toSqlTemplate()); + assertEquals(" foo = #{foo_eq}", filter.toSqlTemplate()); Map inParams = new LinkedHashMap<>(); inParams.put("qux1", "QUX1"); @@ -114,13 +131,13 @@ void testAnd() { Filter.isNull("baz"), Filter.in("qux", inParams)); - assertEquals(" ( foo = #{foo} AND bar <= #{bar} AND baz IS NULL AND qux IN (#{qux1}, #{qux2}, #{qux3}))", filter.toSqlTemplate()); + assertEquals(" ( foo = #{foo_eq} AND bar <= #{bar_lte} AND baz IS NULL AND qux IN (#{qux1}, #{qux2}, #{qux3}))", filter.toSqlTemplate()); Map params = filter.getParams(); System.out.println(Json.toPrettyString(params)); assertEquals(5, params.size()); - assertEquals(10, params.get("foo")); - assertEquals(20, params.get("bar")); + assertEquals(10, params.get("foo_eq")); + assertEquals(20, params.get("bar_lte")); assertEquals("QUX1", params.get("qux1")); assertEquals("QUX2", params.get("qux2")); assertEquals("QUX3", params.get("qux3")); @@ -132,7 +149,7 @@ void testOr() { assertEquals(" 1 = 1", filter.toSqlTemplate()); filter = Filter.and(Filter.eq("foo", "foobar")); - assertEquals(" foo = #{foo}", filter.toSqlTemplate()); + assertEquals(" foo = #{foo_eq}", filter.toSqlTemplate()); Map inParams = new LinkedHashMap<>(); inParams.put("qux1", "QUX1"); @@ -145,13 +162,13 @@ void testOr() { Filter.isNull("baz"), Filter.in("qux", inParams)); - assertEquals(" ( foo = #{foo} OR bar <= #{bar} OR baz IS NULL OR qux IN (#{qux1}, #{qux2}, #{qux3}))", filter.toSqlTemplate()); + assertEquals(" ( foo = #{foo_eq} OR bar <= #{bar_lte} OR baz IS NULL OR qux IN (#{qux1}, #{qux2}, #{qux3}))", filter.toSqlTemplate()); Map params = filter.getParams(); System.out.println(Json.toPrettyString(params)); assertEquals(5, params.size()); - assertEquals(10, params.get("foo")); - assertEquals(20, params.get("bar")); + assertEquals(10, params.get("foo_eq")); + assertEquals(20, params.get("bar_lte")); assertEquals("QUX1", params.get("qux1")); assertEquals("QUX2", params.get("qux2")); assertEquals("QUX3", params.get("qux3")); diff --git a/api/src/test/java/io/bosonnetwork/identifier/CardTests.java b/api/src/test/java/io/bosonnetwork/identifier/CardTests.java index 771bc8e6..94febc7a 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/CardTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/CardTests.java @@ -222,8 +222,26 @@ void invalidSignatureTest() { assertEquals(identity.getId(), card.getId()); assertTrue(card.isGenuine()); - card.getSignature()[0] = (byte) (card.getSignature()[0] + 1); - assertFalse(card.isGenuine()); - assertThrows(InvalidSignatureException.class, card::validate); + // Corrupt the signature within the serialized form, then re-parse + // (getSignature() returns a defensive copy, so mutating it does not affect the card). + byte[] tampered = card.toBytes(); + byte[] sig = card.getSignature(); + int idx = -1; + for (int i = 0; idx < 0 && i <= tampered.length - sig.length; i++) { + boolean match = true; + for (int j = 0; j < sig.length; j++) { + if (tampered[i + j] != sig[j]) { + match = false; + break; + } + } + if (match) + idx = i; + } + assertTrue(idx >= 0); + tampered[idx] ^= 0x01; + var tamperedCard = Card.parse(tampered); + assertFalse(tamperedCard.isGenuine()); + assertThrows(InvalidSignatureException.class, tamperedCard::validate); } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java b/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java index c0f51fec..bc21d8d5 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java +++ b/api/src/test/java/io/bosonnetwork/identifier/DHTRegistryTest.java @@ -31,7 +31,7 @@ import io.bosonnetwork.Result; import io.bosonnetwork.Value; import io.bosonnetwork.crypto.CryptoIdentity; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; @TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class DHTRegistryTest { @@ -82,12 +82,12 @@ public CompletableFuture bootstrap(Collection bootstrapNodes){ @Override public CompletableFuture start() { - return VertxFuture.succeededFuture(); + return ContextualFuture.succeededFuture(); } @Override public CompletableFuture stop() { - return VertxFuture.succeededFuture(); + return ContextualFuture.succeededFuture(); } @Override @@ -137,7 +137,7 @@ public CompletableFuture> findNode(Id id, LookupOption option) @Override public CompletableFuture findValue(Id id, int expectedSequenceNumber, LookupOption option) { - return VertxFuture.succeededFuture(values.get(id)); + return ContextualFuture.succeededFuture(values.get(id)); } @Override @@ -158,22 +158,22 @@ public CompletableFuture storeValue(Value value, int expectedSequenceNumbe @Override public CompletableFuture> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { - return VertxFuture.succeededFuture(List.of()); + return ContextualFuture.succeededFuture(List.of()); } @Override public CompletableFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, boolean persistent) { - return VertxFuture.failedFuture(new UnsupportedOperationException()); + return ContextualFuture.failedFuture(new UnsupportedOperationException()); } @Override public CompletableFuture getValue(Id id) { - return VertxFuture.succeededFuture(values.get(id)); + return ContextualFuture.succeededFuture(values.get(id)); } @Override public CompletableFuture removeValue(Id valueId) { - return VertxFuture.succeededFuture(values.remove(valueId) != null); + return ContextualFuture.succeededFuture(values.remove(valueId) != null); } @Override @@ -183,17 +183,17 @@ public CompletableFuture> getPeers(Id peerId) { @Override public CompletableFuture removePeers(Id peerId) { - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); } @Override public CompletableFuture getPeer(Id peerId, long fingerprint) { - return VertxFuture.succeededFuture(null); + return ContextualFuture.succeededFuture(null); } @Override public CompletableFuture removePeer(Id peerId, long fingerprint) { - return VertxFuture.succeededFuture(false); + return ContextualFuture.succeededFuture(false); } @Override @@ -202,7 +202,7 @@ public String getVersion() { } }; - registry = Registry.DHTRegistry(node, vertx, ResolverCache.fileSystem()); + registry = Registry.DHTRegistry(node, vertx, ResolutionCache.fileSystem()); } @AfterAll diff --git a/api/src/test/java/io/bosonnetwork/identifier/DIDDocumentTests.java b/api/src/test/java/io/bosonnetwork/identifier/DIDDocumentTests.java index eb3da5c0..759f2f92 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/DIDDocumentTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/DIDDocumentTests.java @@ -122,8 +122,8 @@ void simpleDocumentTest() { assertEquals(1, card.getCredentials().size()); assertEquals(1, card.getServices().size()); - assertInstanceOf(DIDDocument.BosonCard.class, card); - assertSame(doc, ((DIDDocument.BosonCard) card).getDocument()); + assertInstanceOf(DIDDocument.CardView.class, card); + assertSame(doc, ((DIDDocument.CardView) card).getDocument()); assertSame(doc, DIDDocument.fromCard(card, List.of(), Map.of("BosonProfile", List.of("https://example.com/credentials/profile/v1")))); @@ -246,8 +246,8 @@ void complexDocumentTest() { assertEquals(3, card.getCredentials().size()); assertEquals(3, card.getServices().size()); - assertInstanceOf(DIDDocument.BosonCard.class, card); - assertSame(doc, ((DIDDocument.BosonCard) card).getDocument()); + assertInstanceOf(DIDDocument.CardView.class, card); + assertSame(doc, ((DIDDocument.CardView) card).getDocument()); assertSame(doc, DIDDocument.fromCard(card, List.of(), Map.of("BosonProfile", List.of("https://example.com/credentials/profile/v1"), "Passport", List.of("https://example.com/credentials/passport/v1"), @@ -315,8 +315,8 @@ void emptyDocTest() { assertTrue(card.isGenuine()); assertDoesNotThrow(card::validate); - assertInstanceOf(DIDDocument.BosonCard.class, card); - assertSame(doc, ((DIDDocument.BosonCard) card).getDocument()); + assertInstanceOf(DIDDocument.CardView.class, card); + assertSame(doc, ((DIDDocument.CardView) card).getDocument()); assertSame(doc, DIDDocument.fromCard(card, null, null)); var card2 = Card.parse(card.toBytes()); @@ -376,9 +376,26 @@ void invalidSignatureTest() { assertEquals(identity.getId(), doc.getId()); assertTrue(doc.isGenuine()); - doc.getProof().getProofValue()[0] = (byte) (doc.getProof().getProofValue()[0] + 1); - assertFalse(doc.isGenuine()); - assertThrows(InvalidSignatureException.class, doc::validate); + // Corrupt the proof signature within the serialized form, then re-parse + byte[] tampered = doc.toBytes(); + byte[] sig = doc.getProof().getProofValue(); + int idx = -1; + for (int i = 0; idx < 0 && i <= tampered.length - sig.length; i++) { + boolean match = true; + for (int j = 0; j < sig.length; j++) { + if (tampered[i + j] != sig[j]) { + match = false; + break; + } + } + if (match) + idx = i; + } + assertTrue(idx >= 0); + tampered[idx] ^= 0x01; + var tamperedDoc = DIDDocument.parse(tampered); + assertFalse(tamperedDoc.isGenuine()); + assertThrows(InvalidSignatureException.class, tamperedDoc::validate); } @Test @@ -419,4 +436,4 @@ void equalityIncludesContextsAndCredentials() throws Exception { assertNotEquals(doc, noContexts); assertNotEquals(doc, noCredentials); } -} +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/identifier/VerifiableCredentialTests.java b/api/src/test/java/io/bosonnetwork/identifier/VerifiableCredentialTests.java index fe4512db..42953688 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/VerifiableCredentialTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/VerifiableCredentialTests.java @@ -99,8 +99,8 @@ void simpleVCTest() { System.out.println(bc.toPrettyString()); System.out.println(Hex.encode(bc.toBytes())); - assertInstanceOf(VerifiableCredential.BosonCredential.class, bc); - assertSame(vc, ((VerifiableCredential.BosonCredential) bc).getVerifiableCredential()); + assertInstanceOf(VerifiableCredential.CredentialView.class, bc); + assertSame(vc, ((VerifiableCredential.CredentialView) bc).getVerifiableCredential()); assertSame(vc, VerifiableCredential.fromCredential(bc, Map.of())); assertEquals(canonicalId.getFragment(), bc.getId()); @@ -224,8 +224,8 @@ void complexVCTest() { System.out.println(bc.toPrettyString()); System.out.println(Hex.encode(bc.toBytes())); - assertInstanceOf(VerifiableCredential.BosonCredential.class, bc); - assertSame(vc, ((VerifiableCredential.BosonCredential) bc).getVerifiableCredential()); + assertInstanceOf(VerifiableCredential.CredentialView.class, bc); + assertSame(vc, ((VerifiableCredential.CredentialView) bc).getVerifiableCredential()); assertSame(vc, VerifiableCredential.fromCredential(bc, Map.of())); assertEquals(canonicalId.getFragment(), bc.getId()); @@ -423,4 +423,4 @@ void builderValidationMessagesArePrecise() { () -> new VerifiableCredentialBuilder(identity).id("profile").build()); assertEquals("Credential must contain at least one claim", missingClaim.getMessage()); } -} +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/identifier/VerifiablePresentationTests.java b/api/src/test/java/io/bosonnetwork/identifier/VerifiablePresentationTests.java index 0ae8a933..59cc1b82 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/VerifiablePresentationTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/VerifiablePresentationTests.java @@ -73,8 +73,8 @@ void simpleVPTest() { assertEquals(1, vouch.getCredentials().size()); - assertInstanceOf(VerifiablePresentation.BosonVouch.class, vouch); - assertSame(vp, ((VerifiablePresentation.BosonVouch) vouch).getVerifiablePresentation()); + assertInstanceOf(VerifiablePresentation.VouchView.class, vouch); + assertSame(vp, ((VerifiablePresentation.VouchView) vouch).getVerifiablePresentation()); assertSame(vp, VerifiablePresentation.fromVouch(vouch, Map.of("BosonProfile", List.of("https://example.com/credentials/profile/v1")))); @@ -179,8 +179,8 @@ void complexVPTest() { assertEquals(3, vouch.getCredentials().size()); - assertInstanceOf(VerifiablePresentation.BosonVouch.class, vouch); - assertSame(vp, ((VerifiablePresentation.BosonVouch) vouch).getVerifiablePresentation()); + assertInstanceOf(VerifiablePresentation.VouchView.class, vouch); + assertSame(vp, ((VerifiablePresentation.VouchView) vouch).getVerifiablePresentation()); assertSame(vp, VerifiablePresentation.fromVouch(vouch, Map.of("TestPresentation", List.of("https://example.com/presentations/test/v1"), "BosonProfile", List.of("https://example.com/credentials/profile/v1"), @@ -259,9 +259,26 @@ void invalidSignatureTest() { assertEquals(identity.getId(), vp.getHolder()); assertTrue(vp.isGenuine()); - vp.getProof().getProofValue()[0] = (byte) (vp.getProof().getProofValue()[0] + 1); - assertFalse(vp.isGenuine()); - assertThrows(InvalidSignatureException.class, vp::validate); + // Corrupt the proof signature within the serialized form, then re-parse + byte[] tampered = vp.toBytes(); + byte[] sig = vp.getProof().getProofValue(); + int idx = -1; + for (int i = 0; idx < 0 && i <= tampered.length - sig.length; i++) { + boolean match = true; + for (int j = 0; j < sig.length; j++) { + if (tampered[i + j] != sig[j]) { + match = false; + break; + } + } + if (match) + idx = i; + } + assertTrue(idx >= 0); + tampered[idx] ^= 0x01; + var tamperedVp = VerifiablePresentation.parse(tampered); + assertFalse(tamperedVp.isGenuine()); + assertThrows(InvalidSignatureException.class, tamperedVp::validate); } @Test @@ -301,4 +318,4 @@ void holderMayPresentCredentialsAboutAnotherSubject() { assertTrue(vp.isGenuine()); assertTrue(vp.getCredentials().get(0).isGenuine()); } -} +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/identifier/VouchTests.java b/api/src/test/java/io/bosonnetwork/identifier/VouchTests.java index f542ecc7..22613672 100644 --- a/api/src/test/java/io/bosonnetwork/identifier/VouchTests.java +++ b/api/src/test/java/io/bosonnetwork/identifier/VouchTests.java @@ -61,7 +61,7 @@ void complexVouchTest() { var vb = new VouchBuilder(identity) .id("testVouch") - .type("BosonVouch", "TestVouch"); + .type("VouchView", "TestVouch"); vb.addCredential().id("profile") .type("BosonProfile", "TestProfile") @@ -86,7 +86,7 @@ void complexVouchTest() { assertEquals("testVouch", vouch.getId()); assertEquals(2, vouch.getTypes().size()); - assertEquals("BosonVouch", vouch.getTypes().get(0)); + assertEquals("VouchView", vouch.getTypes().get(0)); assertEquals("TestVouch", vouch.getTypes().get(1)); assertEquals(identity.getId(), vouch.getHolder()); @@ -164,8 +164,26 @@ void invalidSignatureTest() { assertEquals(identity.getId(), vouch.getHolder()); assertTrue(vouch.isGenuine()); - vouch.getSignature()[0] = (byte) (vouch.getSignature()[0] + 1); - assertFalse(vouch.isGenuine()); - assertThrows(InvalidSignatureException.class, vouch::validate); + // Corrupt the signature within the serialized form, then re-parse + // (getSignature() returns a defensive copy, so mutating it does not affect the vouch). + byte[] tampered = vouch.toBytes(); + byte[] sig = vouch.getSignature(); + int idx = -1; + for (int i = 0; idx < 0 && i <= tampered.length - sig.length; i++) { + boolean match = true; + for (int j = 0; j < sig.length; j++) { + if (tampered[i + j] != sig[j]) { + match = false; + break; + } + } + if (match) + idx = i; + } + assertTrue(idx >= 0); + tampered[idx] ^= 0x01; + var tamperedVouch = Vouch.parse(tampered); + assertFalse(tamperedVouch.isGenuine()); + assertThrows(InvalidSignatureException.class, tamperedVouch::validate); } -} +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/json/JsonTests.java b/api/src/test/java/io/bosonnetwork/json/JsonTests.java index d225d801..d061ac29 100644 --- a/api/src/test/java/io/bosonnetwork/json/JsonTests.java +++ b/api/src/test/java/io/bosonnetwork/json/JsonTests.java @@ -5,7 +5,6 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import java.net.InetAddress; -import java.nio.ByteBuffer; import java.util.Arrays; import java.util.Base64; import java.util.Calendar; @@ -21,6 +20,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.json.internal.DateFormat; +import io.bosonnetwork.utils.Bytes; import io.bosonnetwork.utils.Hex; public class JsonTests { @@ -81,7 +81,7 @@ void dateTest() { var bytes = Json.toBytes(date); System.out.println(Hex.encode(bytes)); assertEquals(Long.BYTES + 1, bytes.length); - assertEquals(date.getTime(), ByteBuffer.wrap(bytes).getLong(1)); + assertEquals(date.getTime(), Bytes.toLong(bytes, 1)); date2 = Json.parse(bytes, Date.class); assertEquals(date, date2); @@ -245,7 +245,7 @@ void mapWithBinaryTest() throws Exception { assertArrayEquals(bytes, (byte[])map3.get("bytes")); assertEquals(now.getTime(), map3.get("date")); - assertArrayEquals(id.bytes(), (byte[])map3.get("id")); + assertArrayEquals(id.bytesUnsafe(), (byte[])map3.get("id")); assertArrayEquals(ip4.getAddress(), (byte[])map3.get("ip4")); assertArrayEquals(ip6.getAddress(), (byte[])map3.get("ip6")); assertEquals(true, map3.get("bool")); diff --git a/api/src/test/java/io/bosonnetwork/utils/AddressUtilsTests.java b/api/src/test/java/io/bosonnetwork/utils/AddressUtilsTests.java index fe7ec05b..a39284b6 100644 --- a/api/src/test/java/io/bosonnetwork/utils/AddressUtilsTests.java +++ b/api/src/test/java/io/bosonnetwork/utils/AddressUtilsTests.java @@ -29,16 +29,13 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; 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 java.lang.reflect.Field; import java.net.Inet4Address; import java.net.Inet6Address; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.List; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; public class AddressUtilsTests { @@ -247,59 +244,6 @@ void testGetDefaultRouteAddress() { "Unsupported type should throw IllegalArgumentException"); } - @Disabled - @Test - void testUpdateBogonRanges() { - // Store original subnets to restore after test - List originalIpv4Subnets = null; - List originalIpv6Subnets = null; - - try { - Field ipv4Field = AddressUtils.class.getDeclaredField("bogonSubnetsIpv4"); - Field ipv6Field = AddressUtils.class.getDeclaredField("bogonSubnetsIpv6"); - ipv4Field.setAccessible(true); - ipv6Field.setAccessible(true); - originalIpv4Subnets = (List) ipv4Field.get(null); - originalIpv6Subnets = (List) ipv6Field.get(null); - - // Mock network calls - AddressUtils.updateBogonRanges(); - - // Verify updated Bogon lists using reflection - ipv4Field = AddressUtils.class.getDeclaredField("bogonSubnetsIpv4"); - ipv6Field = AddressUtils.class.getDeclaredField("bogonSubnetsIpv6"); - ipv4Field.setAccessible(true); - ipv6Field.setAccessible(true); - List ipv4Subnets = (List) ipv4Field.get(null); - List ipv6Subnets = (List) ipv6Field.get(null); - - // Verify updated Bogon lists - assertNotNull(ipv4Subnets, "IPv4 Bogon subnets should not be null"); - assertNotNull(ipv6Subnets, "IPv6 Bogon subnets should not be null"); - assertFalse(ipv4Subnets.isEmpty(), "IPv4 Bogon subnets should not be empty"); - assertFalse(ipv6Subnets.isEmpty(), "IPv6 Bogon subnets should not be empty"); - - ipv4Subnets.forEach(System.out::println); - ipv6Subnets.forEach(System.out::println); - } catch (NoSuchFieldException | IllegalAccessException e) { - fail(e); - } finally { - // Restore original subnets - try { - Field ipv4Field = AddressUtils.class.getDeclaredField("bogonSubnetsIpv4"); - Field ipv6Field = AddressUtils.class.getDeclaredField("bogonSubnetsIpv6"); - ipv4Field.setAccessible(true); - ipv6Field.setAccessible(true); - if (originalIpv4Subnets != null) - ipv4Field.set(null, originalIpv4Subnets); - if (originalIpv6Subnets != null) - ipv6Field.set(null, originalIpv6Subnets); - } catch (NoSuchFieldException | IllegalAccessException e) { - fail(e); - } - } - } - @Test void testSubnetMatching() throws UnknownHostException { // Test IPv4 subnet diff --git a/api/src/test/java/io/bosonnetwork/utils/BytesTests.java b/api/src/test/java/io/bosonnetwork/utils/BytesTests.java new file mode 100644 index 00000000..712153b0 --- /dev/null +++ b/api/src/test/java/io/bosonnetwork/utils/BytesTests.java @@ -0,0 +1,82 @@ +package io.bosonnetwork.utils; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.ByteBuffer; + +import org.junit.jupiter.api.Test; + +public class BytesTests { + @Test + public void testIntegerConversion() { + int[] values = {0, 1, -1, Integer.MAX_VALUE, Integer.MIN_VALUE, 123456789, -987654321}; + for (int value : values) { + byte[] bytes = Bytes.fromInteger(value); + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.putInt(value); + assertArrayEquals(buffer.array(), bytes, "fromInteger failed for value: " + value); + + assertEquals(value, Bytes.toInteger(bytes), "toInteger failed for value: " + value); + } + } + + @Test + public void testLongConversion() { + long[] values = {0L, 1L, -1L, Long.MAX_VALUE, Long.MIN_VALUE, 1234567890123456789L, -987654321098765432L}; + for (long value : values) { + byte[] bytes = Bytes.fromLong(value); + ByteBuffer buffer = ByteBuffer.allocate(8); + buffer.putLong(value); + assertArrayEquals(buffer.array(), bytes, "fromLong failed for value: " + value); + + assertEquals(value, Bytes.toLong(bytes), "toLong failed for value: " + value); + } + } + + @Test + public void testShortConversion() { + short[] values = {0, 1, -1, Short.MAX_VALUE, Short.MIN_VALUE, 12345, -12345}; + for (short value : values) { + byte[] bytes = Bytes.fromShort(value); + ByteBuffer buffer = ByteBuffer.allocate(2); + buffer.putShort(value); + assertArrayEquals(buffer.array(), bytes, "fromShort failed for value: " + value); + + assertEquals(value, Bytes.toShort(bytes), "toShort failed for value: " + value); + } + } + + @Test + public void testIntegerOffset() { + byte[] bytes = new byte[10]; + int value = 0x12345678; + ByteBuffer buffer = ByteBuffer.allocate(4); + buffer.putInt(value); + System.arraycopy(buffer.array(), 0, bytes, 3, 4); + + assertEquals(value, Bytes.toInteger(bytes, 3), "toInteger with offset failed"); + } + + @Test + public void testLongOffset() { + byte[] bytes = new byte[20]; + long value = 0x1234567890ABCDEFL; + ByteBuffer buffer = ByteBuffer.allocate(8); + buffer.putLong(value); + System.arraycopy(buffer.array(), 0, bytes, 5, 8); + + assertEquals(value, Bytes.toLong(bytes, 5), "toLong with offset failed"); + } + + @Test + public void testShortOffset() { + byte[] bytes = new byte[10]; + short value = 0x1234; + ByteBuffer buffer = ByteBuffer.allocate(2); + buffer.putShort(value); + System.arraycopy(buffer.array(), 0, bytes, 4, 2); + + assertEquals(value, Bytes.toShort(bytes, 4), "toShort with offset failed"); + } +} \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/vertx/VertxFutureTests.java b/api/src/test/java/io/bosonnetwork/vertx/ContextualFutureTests.java similarity index 78% rename from api/src/test/java/io/bosonnetwork/vertx/VertxFutureTests.java rename to api/src/test/java/io/bosonnetwork/vertx/ContextualFutureTests.java index 17c18f8b..874caabc 100644 --- a/api/src/test/java/io/bosonnetwork/vertx/VertxFutureTests.java +++ b/api/src/test/java/io/bosonnetwork/vertx/ContextualFutureTests.java @@ -21,7 +21,7 @@ import io.bosonnetwork.utils.Variable; @ExtendWith(VertxExtension.class) -public class VertxFutureTests { +public class ContextualFutureTests { private static void printThreadContext(String prefix) { System.out.printf("%s: %s:%d\n", prefix, Thread.currentThread().getName(), Thread.currentThread().getId()); } @@ -145,7 +145,7 @@ void testVertxCompletableFuture(Vertx vertx, VertxTestContext context) { return p.future(); }); - CompletableFuture cf = VertxFuture.of(future); + CompletableFuture cf = ContextualFuture.of(future); cf.thenApply(s -> { printThreadContext("CompletableFuture.thenApply"); @@ -154,7 +154,7 @@ void testVertxCompletableFuture(Vertx vertx, VertxTestContext context) { }).thenCompose(v -> { printThreadContext("CompletableFuture.thenCompose"); context.verify(() -> assertEquals(tid.get(), Thread.currentThread().getId())); - return VertxFuture.succeededFuture(v); + return ContextualFuture.succeededFuture(v); }).thenAcceptAsync(s -> { printThreadContext("CompletableFuture.thenAcceptAsync"); context.verify(() -> { @@ -167,7 +167,7 @@ void testVertxCompletableFuture(Vertx vertx, VertxTestContext context) { assertNotEquals(tid.get(), Thread.currentThread().getId()); assertTrue(Thread.currentThread().getName().startsWith("vert.x-worker-thread-")); }); - return VertxFuture.succeededFuture(v); + return ContextualFuture.succeededFuture(v); }).thenComposeAsync(v -> { printThreadContext("CompletableFuture.thenComposeAsync"); context.verify(() -> { @@ -181,7 +181,7 @@ void testVertxCompletableFuture(Vertx vertx, VertxTestContext context) { assertNotEquals(tid.get(), Thread.currentThread().getId()); assertTrue(Thread.currentThread().getName().startsWith("vert.x-worker-thread-")); }); - return VertxFuture.succeededFuture(v); + return ContextualFuture.succeededFuture(v); }); future.onComplete(context.succeedingThenComplete()); @@ -189,7 +189,7 @@ void testVertxCompletableFuture(Vertx vertx, VertxTestContext context) { @Test void testVertxCompletableFutureGetCompleted() throws Exception { - VertxFuture future = VertxFuture.succeededFuture("Foo bar"); + ContextualFuture future = ContextualFuture.succeededFuture("Foo bar"); assertEquals("Foo bar", future.get()); } @@ -207,11 +207,46 @@ void testVertxCompletableFutureGet(Vertx vertx, VertxTestContext context) throws } }); - VertxFuture future = VertxFuture.of(promise.future()); + ContextualFuture future = ContextualFuture.of(promise.future()); assertEquals("Foo bar", future.get()); context.completeNow(); } + @Test + void testWhenCompleteContract() { + // (1) success + action does not throw -> value passes through; action observes (value, null) + var observed = new java.util.concurrent.atomic.AtomicReference(); + Future ok = ContextualFuture.succeededFuture("v") + .whenComplete((val, err) -> observed.set(val + "/" + err)) + .toVertxFuture(); + assertTrue(ok.succeeded()); + assertEquals("v", ok.result()); + assertEquals("v/null", observed.get()); + + // (2) failure + action does not throw -> original cause propagates + RuntimeException boom = new RuntimeException("boom"); + Future failed = ContextualFuture.failedFuture(boom) + .whenComplete((val, err) -> { }) + .toVertxFuture(); + assertTrue(failed.failed()); + assertEquals(boom, failed.cause()); + + // (3) success + action throws -> result fails with the action's exception + RuntimeException actionEx = new RuntimeException("action"); + Future successActionThrows = ContextualFuture.succeededFuture("v") + .whenComplete((val, err) -> { throw actionEx; }) + .toVertxFuture(); + assertTrue(successActionThrows.failed()); + assertEquals(actionEx, successActionThrows.cause()); + + // (4) failure + action throws -> result keeps the ORIGINAL cause, not the action's + Future failureActionThrows = ContextualFuture.failedFuture(boom) + .whenComplete((val, err) -> { throw actionEx; }) + .toVertxFuture(); + assertTrue(failureActionThrows.failed()); + assertEquals(boom, failureActionThrows.cause()); + } + @Test void testVertxCompletableFutureGetInVertxContext(Vertx vertx, VertxTestContext context) { var ctx = vertx.getOrCreateContext(); @@ -219,18 +254,18 @@ void testVertxCompletableFutureGetInVertxContext(Vertx vertx, VertxTestContext c Promise promise = Promise.promise(); vertx.setTimer(2000, id -> promise.complete("Foo bar")); - VertxFuture future = VertxFuture.of(promise.future()); + ContextualFuture future = ContextualFuture.of(promise.future()); ctx.runOnContext(v -> { printThreadContext("context.verify"); context.verify(() -> { IllegalStateException exception = assertThrows(IllegalStateException.class, future::get); - assertEquals("Cannot not be called on vertx thread or event loop thread", exception.getMessage()); + assertEquals("Cannot be called on a vertx thread or event loop thread", exception.getMessage()); context.completeNow(); }); }); - VertxFuture completedFuture = VertxFuture.succeededFuture("Foo bar"); + ContextualFuture completedFuture = ContextualFuture.succeededFuture("Foo bar"); ctx.runOnContext(v -> { context.verify(() -> assertEquals("Foo bar", completedFuture.join())); }); diff --git a/api/src/test/java/io/bosonnetwork/vertx/VertxCaffeineTests.java b/api/src/test/java/io/bosonnetwork/vertx/VertxCaffeineTests.java index 47564f97..68c9446d 100644 --- a/api/src/test/java/io/bosonnetwork/vertx/VertxCaffeineTests.java +++ b/api/src/test/java/io/bosonnetwork/vertx/VertxCaffeineTests.java @@ -47,10 +47,10 @@ public void testAsyncCache(Vertx vertx, VertxTestContext context) { promise.fail(e); } }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); }); - assertInstanceOf(VertxFuture.class, future); + assertInstanceOf(ContextualFuture.class, future); future.thenAccept(s -> { System.out.println("Future::thenAccept thread: " + Thread.currentThread().getName()); @@ -86,12 +86,12 @@ public void testAsyncLoadingCache(Vertx vertx, VertxTestContext context) { } }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); }); var future = cache.get("foo"); - assertInstanceOf(VertxFuture.class, future); + assertInstanceOf(ContextualFuture.class, future); future.thenAccept(s -> { System.out.println("Future::thenAccept thread: " + Thread.currentThread().getName()); diff --git a/api/src/test/java/io/bosonnetwork/web/CwtAuthTest.java b/api/src/test/java/io/bosonnetwork/web/CwtAuthTest.java index f3d59561..011add29 100644 --- a/api/src/test/java/io/bosonnetwork/web/CwtAuthTest.java +++ b/api/src/test/java/io/bosonnetwork/web/CwtAuthTest.java @@ -15,6 +15,7 @@ import io.vertx.core.Vertx; import io.vertx.ext.auth.User; import io.vertx.ext.auth.authentication.TokenCredentials; +import io.vertx.ext.auth.authorization.RoleBasedAuthorization; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -34,6 +35,7 @@ import io.bosonnetwork.json.Json; import io.bosonnetwork.service.ClientDevice; import io.bosonnetwork.service.ClientUser; +import io.bosonnetwork.service.Role; @ExtendWith(VertxExtension.class) @SuppressWarnings("CodeBlock2Expr") @@ -444,4 +446,55 @@ void testInvalidCredentialsType(VertxTestContext context) { }); }); } + + @Test + void testTierAuthorizationNotEscalatedFromToken(VertxTestContext context) throws Exception { + // Alice self-issues a token that claims api:admin, but she is only entitled to api:client. + // The server must grant api:client and refuse api:admin, regardless of the token's claim. + String token = generateClientToken(aliceIdentity, alice.getId(), null, + superNodeIdentity.getId(), 0, "api:admin"); + + auth.authenticate(new TokenCredentials(token)).onComplete(context.succeeding(user -> { + context.verify(() -> { + assertTrue(user.authorizations().verify(RoleBasedAuthorization.create(Role.CLIENT.toString()))); + assertFalse(user.authorizations().verify(RoleBasedAuthorization.create(Role.ADMIN.toString()))); + assertFalse(user.authorizations().verify(RoleBasedAuthorization.create(Role.FEDERATION.toString()))); + context.completeNow(); + }); + })); + } + + @Test + void testAdminUserGrantedAdminAuthorization(VertxTestContext context) throws Exception { + final Identity adminIdentity = new CryptoIdentity(); + final ClientUser admin = new TestClientUser(adminIdentity.getId(), "Admin", null, null, null, true); + + CwtAuthOptions adminOptions = new CwtAuthOptions() + .setIdentity(superNodeIdentity) + .setExpectedAudience(superNodeIdentity.getId()) + .setLeeway(0) + .setClientProvider(new ClientProvider() { + @Override + public Future getUser(Id userId) { + return userId.equals(admin.getId()) ? + Future.succeededFuture(admin) : Future.succeededFuture(null); + } + + @Override + public Future getClient(Id userId, Id clientId) { + return Future.succeededFuture(null); + } + }); + CwtAuth adminAuth = CwtAuth.create(adminOptions); + + // Server-issued token; the admin user is entitled to both client and admin tiers. + String token = adminAuth.generateToken(admin.getId(), "api:admin"); + adminAuth.authenticate(new TokenCredentials(token)).onComplete(context.succeeding(user -> { + context.verify(() -> { + assertTrue(user.authorizations().verify(RoleBasedAuthorization.create(Role.CLIENT.toString()))); + assertTrue(user.authorizations().verify(RoleBasedAuthorization.create(Role.ADMIN.toString()))); + context.completeNow(); + }); + })); + } } \ No newline at end of file diff --git a/api/src/test/java/io/bosonnetwork/web/TestClientUser.java b/api/src/test/java/io/bosonnetwork/web/TestClientUser.java index 3b23fe4d..c6876d31 100644 --- a/api/src/test/java/io/bosonnetwork/web/TestClientUser.java +++ b/api/src/test/java/io/bosonnetwork/web/TestClientUser.java @@ -11,8 +11,9 @@ public class TestClientUser implements ClientUser { private final String bio; private final long created; private final long updated; + private final boolean admin; - public TestClientUser(Id id, String name, String avatar, String email, String bio) { + public TestClientUser(Id id, String name, String avatar, String email, String bio, boolean admin) { this.id = id; this.name = name; this.avatar = avatar; @@ -20,6 +21,11 @@ public TestClientUser(Id id, String name, String avatar, String email, String bi this.bio = bio; this.created = System.currentTimeMillis(); this.updated = created; + this.admin = admin; + } + + public TestClientUser(Id id, String name, String avatar, String email, String bio) { + this(id, name, avatar, email, bio, false); } @Override @@ -66,4 +72,9 @@ public long getUpdatedAt() { public String getPlanName() { return "Free"; } + + @Override + public boolean isAdmin() { + return admin; + } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java index f5d668c4..ef8255d1 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java @@ -46,8 +46,8 @@ import io.bosonnetwork.kademlia.tasks.EligibleValue; import io.bosonnetwork.utils.Variable; import io.bosonnetwork.vertx.BosonVerticle; +import io.bosonnetwork.vertx.ContextualFuture; import io.bosonnetwork.vertx.VertxCaffeine; -import io.bosonnetwork.vertx.VertxFuture; public class KadNode extends BosonVerticle implements Node { public static final String NAME = "Orca"; @@ -194,18 +194,18 @@ else if (current instanceof ListenerArray listeners) } @Override - public VertxFuture start() { + public ContextualFuture start() { if (this.vertx != null) - return VertxFuture.failedFuture(new IllegalStateException("Already started")); + return ContextualFuture.failedFuture(new IllegalStateException("Already started")); Future future = config.vertx().deployVerticle(this).mapEmpty(); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override - public VertxFuture stop() { + public ContextualFuture stop() { if (!isRunning()) - return VertxFuture.failedFuture(new IllegalStateException("Not started")); + return ContextualFuture.failedFuture(new IllegalStateException("Not started")); Promise promise = Promise.promise(); runOnContext(v -> { @@ -216,7 +216,7 @@ public VertxFuture stop() { vertx.undeploy(deploymentId).onComplete(promise); }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } @Override @@ -375,7 +375,7 @@ private void checkRunning() { } @Override - public VertxFuture bootstrap(Collection bootstrapNodes) { + public ContextualFuture bootstrap(Collection bootstrapNodes) { Objects.requireNonNull(bootstrapNodes, "Invalid bootstrap nodes"); checkRunning(); @@ -398,11 +398,11 @@ public VertxFuture bootstrap(Collection bootstrapNodes) { } }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } @Override - public VertxFuture> findNode(Id id, LookupOption option) { + public ContextualFuture> findNode(Id id, LookupOption option) { Objects.requireNonNull(id, "Invalid node id"); checkRunning(); @@ -410,7 +410,7 @@ public VertxFuture> findNode(Id id, LookupOption option) { Promise> promise = Promise.promise(); runOnContext(v -> doFindNode(id, lookupOption).onComplete(promise)); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } private Future> doFindNode(Id id, LookupOption option) { @@ -440,7 +440,7 @@ private Future> doFindNode(Id id, LookupOption option) { } @Override - public VertxFuture findValue(Id id, int expectedSequenceNumber, LookupOption option) { + public ContextualFuture findValue(Id id, int expectedSequenceNumber, LookupOption option) { Objects.requireNonNull(id, "Invalid value id"); checkRunning(); @@ -473,7 +473,7 @@ public VertxFuture findValue(Id id, int expectedSequenceNumber, LookupOpt }).onComplete(promise); }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } private Future doFindValue(Id id, int expectedSequenceNumber, LookupOption option, EligibleValue result) { @@ -537,7 +537,7 @@ private Future checkValue(Value value, int expectedSequenceNumber) { } @Override - public VertxFuture storeValue(Value value, int expectedSequenceNumber, boolean persistent) { + public ContextualFuture storeValue(Value value, int expectedSequenceNumber, boolean persistent) { Objects.requireNonNull(value, "Invalid value"); checkRunning(); @@ -551,7 +551,7 @@ public VertxFuture storeValue(Value value, int expectedSequenceNumber, boo .onComplete(promise) ); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } private Future doStoreValue(Value value, int expectedSequenceNumber) { @@ -566,7 +566,7 @@ private Future doStoreValue(Value value, int expectedSequenceNumber) { } @Override - public VertxFuture> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { + public ContextualFuture> findPeer(Id id, int expectedSequenceNumber, int expectedCount, LookupOption option) { Objects.requireNonNull(id, "Invalid peer id"); if (expectedSequenceNumber < -1) throw new IllegalArgumentException("Invalid sequence number"); @@ -605,7 +605,7 @@ public VertxFuture> findPeer(Id id, int expectedSequenceNumber, i }).onComplete(promise); }); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } private Future doFindPeer(Id id, int expectedSequenceNumber, int expectedCount, @@ -664,7 +664,7 @@ private Future checkPeer(PeerInfo peer, int expectedSequenceNumber) { } @Override - public VertxFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, boolean persistent) { + public ContextualFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, boolean persistent) { Objects.requireNonNull(peer, "Invalid value"); checkRunning(); @@ -678,7 +678,7 @@ public VertxFuture announcePeer(PeerInfo peer, int expectedSequenceNumber, .onComplete(promise) ); - return VertxFuture.of(promise.future()); + return ContextualFuture.of(promise.future()); } private Future doAnnouncePeer(PeerInfo peer, int expectedSequenceNumber) { @@ -756,51 +756,51 @@ private void persistentAnnounce() { } @Override - public VertxFuture getValue(Id valueId) { + public ContextualFuture getValue(Id valueId) { Objects.requireNonNull(valueId, "valueId"); checkRunning(); Future future = storage.getValue(valueId); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override - public VertxFuture removeValue(Id valueId) { + public ContextualFuture removeValue(Id valueId) { Objects.requireNonNull(valueId, "valueId"); checkRunning(); Future future = storage.removeValue(valueId); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override - public VertxFuture> getPeers(Id peerId) { + public ContextualFuture> getPeers(Id peerId) { Objects.requireNonNull(peerId, "peerId"); checkRunning(); Future> future = storage.getPeers(peerId); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override - public VertxFuture removePeers(Id peerId) { + public ContextualFuture removePeers(Id peerId) { Objects.requireNonNull(peerId, "peerId"); checkRunning(); Future future = storage.removePeers(peerId); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override - public VertxFuture getPeer(Id peerId, long fingerprint) { + public ContextualFuture getPeer(Id peerId, long fingerprint) { Objects.requireNonNull(peerId, "peerId"); checkRunning(); Future future = storage.getPeer(peerId, fingerprint); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override - public VertxFuture removePeer(Id peerId, long fingerprint) { + public ContextualFuture removePeer(Id peerId, long fingerprint) { Objects.requireNonNull(peerId, "peerId"); checkRunning(); Future future = storage.removePeer(peerId, fingerprint); - return VertxFuture.of(future); + return ContextualFuture.of(future); } @Override diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNodeFactory.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNodeFactory.java new file mode 100644 index 00000000..fd426ee5 --- /dev/null +++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNodeFactory.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2022 - 2023 trinity-tech.io + * 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.kademlia; + +import io.bosonnetwork.BosonException; +import io.bosonnetwork.Node; +import io.bosonnetwork.NodeConfiguration; +import io.bosonnetwork.NodeFactory; + +/** + * {@link NodeFactory} implementation that creates Kademlia DHT {@link KadNode} instances. + *

    + * Registered as a service provider so that {@link Node#kadNode(NodeConfiguration)} can + * discover it via {@link java.util.ServiceLoader} without a compile-time dependency from + * {@code boson-api} on this module. + */ +public class KadNodeFactory implements NodeFactory { + @Override + public Node create(NodeConfiguration config) throws BosonException { + return new KadNode(config); + } +} diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/Launcher.java b/dht/src/main/java/io/bosonnetwork/kademlia/Launcher.java index c25f4c07..2d213b9e 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/Launcher.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/Launcher.java @@ -35,7 +35,7 @@ import io.bosonnetwork.NodeConfiguration; import io.bosonnetwork.json.Json; import io.bosonnetwork.utils.ApplicationLock; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; /** * Launcher is the entry point for the Boson DHT Node application. @@ -183,7 +183,7 @@ public static void main(String[] args) { return; System.out.println("Shutting down Boson DHT node..."); - node.stop().thenCompose(v -> VertxFuture.of(vertx.close())).get(); + node.stop().thenCompose(v -> ContextualFuture.of(vertx.close())).get(); System.out.println("Node stopped."); } catch (Exception e) { System.err.println("Error during shutdown: " + e.getMessage()); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/impl/TokenManager.java b/dht/src/main/java/io/bosonnetwork/kademlia/impl/TokenManager.java index 1ac37f0e..3541a432 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/impl/TokenManager.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/impl/TokenManager.java @@ -26,7 +26,6 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; -import java.nio.ByteBuffer; import java.security.MessageDigest; import java.util.concurrent.atomic.AtomicLong; @@ -35,6 +34,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.crypto.Hash; import io.bosonnetwork.crypto.Random; +import io.bosonnetwork.utils.Bytes; /** * @hidden @@ -66,11 +66,11 @@ public void updateTokenTimestamps() { private int generateToken(Id nodeId, InetAddress address, int port, Id targetId, long timestamp) { MessageDigest sha256 = Hash.sha256(); - sha256.update(nodeId.bytes()); + sha256.update(nodeId.bytesUnsafe()); sha256.update(address.getAddress()); - sha256.update(ByteBuffer.allocate(Short.BYTES).putShort((short)port).array()); - sha256.update(targetId.bytes()); - sha256.update(ByteBuffer.allocate(Long.BYTES).putLong(timestamp).array()); + sha256.update(Bytes.fromShort((short)port)); + sha256.update(targetId.bytesUnsafe()); + sha256.update(Bytes.fromLong(timestamp)); sha256.update(sessionSecret); byte[] digest = sha256.digest(); int pos = (digest[0] & 0xff) & 0x1f; // mod 32 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 de2f2292..c9c2e877 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/routing/KBucketEntry.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/routing/KBucketEntry.java @@ -479,7 +479,7 @@ public boolean matches(KBucketEntry entry) { Map toMap() { Map map = new LinkedHashMap<>(); - map.put("id", getId().bytes()); + map.put("id", getId().bytesUnsafe()); map.put("addr", getIpAddress().getAddress()); map.put("port", getPort()); if (created > 0) diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/routing/Prefix.java b/dht/src/main/java/io/bosonnetwork/kademlia/routing/Prefix.java index b036b1ea..624e42d7 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/routing/Prefix.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/routing/Prefix.java @@ -189,9 +189,9 @@ public Prefix splitBranch(boolean highBranch) { final int branchDepth = depth + 1; Prefix branch = new Prefix(this, branchDepth); if (highBranch) - branch.bytes()[branchDepth / 8] |= (byte) (0x80 >> (branchDepth % 8)); + branch.bytesUnsafe()[branchDepth / 8] |= (byte) (0x80 >> (branchDepth % 8)); else - branch.bytes()[branchDepth / 8] &= (byte) ~(0x80 >> (branchDepth % 8)); + branch.bytesUnsafe()[branchDepth / 8] &= (byte) ~(0x80 >> (branchDepth % 8)); return branch; } @@ -240,8 +240,8 @@ public static Prefix getCommonPrefix(Collection ids) { if (ids == null || ids.isEmpty()) throw new IllegalArgumentException("ids cannot be null or empty"); - final byte[] first = Collections.min(ids).bytes(); - final byte[] last = Collections.max(ids).bytes(); + final byte[] first = Collections.min(ids).bytesUnsafe(); + final byte[] last = Collections.max(ids).bytesUnsafe(); byte[] prefixBytes = new byte[Id.BYTES]; int depth = -1; @@ -289,7 +289,7 @@ public boolean equals(Object o) { if (this.depth != that.depth) return false; - return Arrays.equals(this.bytes(), that.bytes()); + return Arrays.equals(this.bytesUnsafe(), that.bytesUnsafe()); } return false; } @@ -322,7 +322,7 @@ public String toBinaryString(boolean withSpaces) { capacity += 4; // for "..." suffix StringBuilder repr = new StringBuilder(capacity); - final byte[] bytes = bytes(); + final byte[] bytes = bytesUnsafe(); final char[] bits = new char[8]; final int prefixBytes = (depth + 1) >>> 3; @@ -365,6 +365,6 @@ public String toString() { if (depth == -1) return "all"; - return Hex.encode(bytes(), 0, (depth + 8) >>> 3) + "/" + depth; + return Hex.encode(bytesUnsafe(), 0, (depth + 8) >>> 3) + "/" + depth; } } \ No newline at end of file diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java b/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java index e2ed9456..416a5a6d 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/routing/RoutingTable.java @@ -562,7 +562,7 @@ public void save(Path file) throws IOException { try (OutputStream out = Files.newOutputStream(tempFile)) { CBORGenerator gen = Json.cborFactory().createGenerator(out); gen.writeStartObject(); - gen.writeBinaryField("nodeId", localId.bytes()); + gen.writeBinaryField("nodeId", localId.bytesUnsafe()); gen.writeNumberField("timestamp", now); gen.writeFieldName("entries"); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/rpc/RpcServer.java b/dht/src/main/java/io/bosonnetwork/kademlia/rpc/RpcServer.java index 681100c0..974b040c 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/rpc/RpcServer.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/rpc/RpcServer.java @@ -676,7 +676,7 @@ public Future sendMessage(Message message) { try { byte[] encryptedMsg = identity.encrypt(message.getRemoteId(), message.toBytes()); buffer = Buffer.buffer(encryptedMsg.length + Id.BYTES); - buffer.appendBytes(message.getId().bytes()); + buffer.appendBytes(message.getId().bytesUnsafe()); buffer.appendBytes(encryptedMsg); } catch (CryptoException e) { log.error("!!!INTERNAL ERROR: Failed to encrypt message", e); diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java b/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java index 0a14df1a..e34acd48 100644 --- a/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java +++ b/dht/src/main/java/io/bosonnetwork/kademlia/storage/DatabaseStorage.java @@ -125,7 +125,7 @@ public Future getValue(Id id) { getLogger().debug("Getting value with id: {}", id); return withConnection(c -> SqlTemplate.forQuery(c, getDialect().selectValue()) - .execute(Map.of("id", id.bytes())) + .execute(Map.of("id", id.bytesUnsafe())) .map(rows -> findUnique(rows, DatabaseStorage::rowToValue)) ).recover(cause -> Future.failedFuture(new DataStorageException("getValue failed", cause)) @@ -186,7 +186,7 @@ public Future updateValueAnnouncedTime(Id id) { long now = System.currentTimeMillis(); return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().updateValueAnnounced()) - .execute(Map.of("id", id.bytes(), "updated", now)) + .execute(Map.of("id", id.bytesUnsafe(), "updated", now)) .map(r -> r.rowCount() > 0 ? now : 0L) ).recover(cause -> Future.failedFuture(new DataStorageException("updateValueAnnouncedTime failed", cause)) @@ -198,7 +198,7 @@ public Future removeValue(Id id) { getLogger().debug("Removing value with id: {}", id); return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().deleteValue()) - .execute(Map.of("id", id.bytes())) + .execute(Map.of("id", id.bytesUnsafe())) .map(this::hasAffectedRows) ).recover(cause -> Future.failedFuture(new DataStorageException("removeValue failed", cause)) @@ -243,7 +243,7 @@ public Future> getPeers(Id id, Id nodeId) { getLogger().debug("Getting peer with id: {} @ {}", id, nodeId); return withConnection(c -> SqlTemplate.forQuery(c, getDialect().selectPeersByIdAndNodeId()) - .execute(Map.of("id", id.bytes(), "nodeId", nodeId.bytes())) + .execute(Map.of("id", id.bytesUnsafe(), "nodeId", nodeId.bytesUnsafe())) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> Future.failedFuture(new DataStorageException("getPeers/id&nodeId failed", cause)) @@ -255,7 +255,7 @@ public Future> getPeers(Id id) { getLogger().debug("Getting peers with id: {}", id); return withConnection(c -> SqlTemplate.forQuery(c, getDialect().selectPeersById()) - .execute(Map.of("id", id.bytes())) + .execute(Map.of("id", id.bytesUnsafe())) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> Future.failedFuture(new DataStorageException("getPeers/id failed", cause)) @@ -267,7 +267,7 @@ public Future> getPeers(Id id, int expectedSequenceNumber, int li getLogger().debug("Getting peers with id: {}, expectedSequenceNumber: {}, limit{}", id, expectedSequenceNumber, limit); return withConnection(c -> SqlTemplate.forQuery(c, getDialect().selectPeersByIdAndSequenceNumberWithLimit()) - .execute(Map.of("id", id.bytes(), + .execute(Map.of("id", id.bytesUnsafe(), "expectedSequenceNumber", expectedSequenceNumber, "limit", limit)) .map(rows -> findMany(rows, DatabaseStorage::rowToPeer)) @@ -330,7 +330,7 @@ public Future updatePeerAnnouncedTime(Id id, long fingerprint) { long now = System.currentTimeMillis(); return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().updatePeerAnnounced()) - .execute(Map.of("id", id.bytes(), "fingerprint", fingerprint, "updated", now)) + .execute(Map.of("id", id.bytesUnsafe(), "fingerprint", fingerprint, "updated", now)) .map(r -> r.rowCount() > 0 ? now : 0L) ).recover(cause -> Future.failedFuture(new DataStorageException("updatePeerAnnouncedTime failed", cause)) @@ -341,7 +341,7 @@ public Future updatePeerAnnouncedTime(Id id, long fingerprint) { public Future getPeer(Id id, long fingerprint) { return withConnection(c -> SqlTemplate.forQuery(c, getDialect().selectPeer()) - .execute(Map.of("id", id.bytes(), "fingerprint", fingerprint)) + .execute(Map.of("id", id.bytesUnsafe(), "fingerprint", fingerprint)) .map(rows -> findUnique(rows, DatabaseStorage::rowToPeer)) ).recover(cause -> Future.failedFuture(new DataStorageException("getPeer failed", cause)) @@ -353,7 +353,7 @@ public Future removePeer(Id id, long fingerprint) { getLogger().debug("Removing peer with id: {}:{}", id, fingerprint); return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().deletePeer()) - .execute(Map.of("id", id.bytes(), "fingerprint", fingerprint)) + .execute(Map.of("id", id.bytesUnsafe(), "fingerprint", fingerprint)) .map(this::hasAffectedRows) ).recover(cause -> Future.failedFuture(new DataStorageException("removePeer failed", cause)) @@ -365,7 +365,7 @@ public Future removePeers(Id id) { getLogger().debug("Removing peers with id: {}", id); return withTransaction(c -> SqlTemplate.forUpdate(c, getDialect().deletePeersById()) - .execute(Map.of("id", id.bytes())) + .execute(Map.of("id", id.bytesUnsafe())) .map(this::hasAffectedRows) ).recover(cause -> Future.failedFuture(new DataStorageException("removePeers/id failed", cause)) @@ -374,10 +374,10 @@ public Future removePeers(Id id) { protected static Map valueToMap(Value value, boolean persistent) { Map map = new HashMap<>(); - map.put("id", value.getId().bytes()); - map.put("publicKey", value.getPublicKey() != null ? value.getPublicKey().bytes() : null); + map.put("id", value.getId().bytesUnsafe()); + map.put("publicKey", value.getPublicKey() != null ? value.getPublicKey().bytesUnsafe() : null); map.put("privateKey", value.getPrivateKey()); - map.put("recipient", value.getRecipient() != null ? value.getRecipient().bytes() : null); + map.put("recipient", value.getRecipient() != null ? value.getRecipient().bytesUnsafe() : null); map.put("nonce", value.getNonce()); map.put("sequenceNumber", value.getSequenceNumber()); map.put("signature", value.getSignature()); @@ -403,13 +403,13 @@ protected static Value rowToValue(Row row) { protected static Map peerToMap(PeerInfo peerInfo, boolean persistent) { Map map = new HashMap<>(); - map.put("id", peerInfo.getId().bytes()); + map.put("id", peerInfo.getId().bytesUnsafe()); map.put("fingerprint", peerInfo.getFingerprint()); map.put("privateKey", peerInfo.getPrivateKey()); map.put("nonce", peerInfo.getNonce()); map.put("sequenceNumber", peerInfo.getSequenceNumber()); if (peerInfo.isAuthenticated()) { - map.put("nodeId", peerInfo.getNodeId().bytes()); + map.put("nodeId", peerInfo.getNodeId().bytesUnsafe()); map.put("nodeSignature", peerInfo.getNodeSignature()); } else { map.put("nodeId", null); diff --git a/dht/src/main/resources/META-INF/services/io.bosonnetwork.NodeFactory b/dht/src/main/resources/META-INF/services/io.bosonnetwork.NodeFactory new file mode 100644 index 00000000..aef99631 --- /dev/null +++ b/dht/src/main/resources/META-INF/services/io.bosonnetwork.NodeFactory @@ -0,0 +1 @@ +io.bosonnetwork.kademlia.KadNodeFactory diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java index 027147d0..c710cecf 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeAsyncTests.java @@ -46,7 +46,7 @@ import io.bosonnetwork.crypto.Signature.KeyPair; import io.bosonnetwork.utils.AddressUtils; import io.bosonnetwork.utils.FileUtils; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; @ExtendWith(VertxExtension.class) @TestInstance(TestInstance.Lifecycle.PER_CLASS) @@ -68,7 +68,7 @@ public class NodeAsyncTests { private static KadNode bootstrap; private static final List testNodes = new ArrayList<>(TEST_NODES); - private static VertxFuture startBootstrap() { + private static ContextualFuture startBootstrap() { System.out.println("\n\n\007🟢 Starting the bootstrap node ..."); var config = NodeConfiguration.builder() @@ -84,13 +84,13 @@ private static VertxFuture startBootstrap() { return bootstrap.start(); } - private static VertxFuture stopBootstrap() { + private static ContextualFuture stopBootstrap() { System.out.println("\n\n\007🟢 Stopping the bootstrap nodes ...\n"); return bootstrap.stop(); } - private static VertxFuture executeSequentially(int count, Function> action) { - VertxFuture chain = VertxFuture.succeededFuture(); + private static ContextualFuture executeSequentially(int count, Function> action) { + ContextualFuture chain = ContextualFuture.succeededFuture(); for (int i = 0; i < count; i++) { final int index = i; chain = chain.thenCompose(v -> action.apply(index).whenComplete((r, e) -> { @@ -105,8 +105,8 @@ private static VertxFuture executeSequentially(int count, Function null); } - protected static VertxFuture executeSequentially(List nodes, Function> action) { - VertxFuture chain = VertxFuture.succeededFuture(); + protected static ContextualFuture executeSequentially(List nodes, Function> action) { + ContextualFuture chain = ContextualFuture.succeededFuture(); for (final KadNode node : nodes) { chain = chain.thenCompose(v -> action.apply(node).whenComplete((r, e) -> { @@ -121,7 +121,7 @@ protected static VertxFuture executeSequentially(List nodes, Func return chain; } - private static VertxFuture createTestNode(int index) { + private static ContextualFuture createTestNode(int index) { System.out.format("\n\n\007🟢 Starting the node %d ...\n", index); var config = NodeConfiguration.builder() @@ -151,13 +151,13 @@ public void connected(Network network) { // The root cause is still unknown and needs further investigation. /*/ node.start(); - return VertxFuture.of(promise.future()).thenApply(v -> node); + return ContextualFuture.of(promise.future()).thenApply(v -> node); */ // But this will work well. - return node.start().thenCompose(v -> VertxFuture.of(promise.future())).thenApply(v -> node); + return node.start().thenCompose(v -> ContextualFuture.of(promise.future())).thenApply(v -> node); } - private static VertxFuture startTestNodes() { + private static ContextualFuture startTestNodes() { return executeSequentially(TEST_NODES, NodeAsyncTests::createTestNode) .whenComplete((v, e) -> { if (e == null) @@ -167,7 +167,7 @@ private static VertxFuture startTestNodes() { }); } - private static VertxFuture stopTestNodes() { + 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. return executeSequentially(testNodes, n -> { @@ -176,25 +176,25 @@ private static VertxFuture stopTestNodes() { }); } - private static VertxFuture dumpRoutingTable(String name, KadNode node) { + private static ContextualFuture dumpRoutingTable(String name, KadNode node) { System.out.format("\007🟢 Dumping the routing table of %s %s ...\n", name, node.getId()); var file = testDir.resolve("nodes" + File.separator + name + File.separator + "routingtable"); try { var out = new PrintStream(Files.newOutputStream(file)); - return VertxFuture.of(bootstrap.getDHT(Network.IPv4).dumpRoutingTable(out).andThen(ar -> out.close())); + return ContextualFuture.of(bootstrap.getDHT(Network.IPv4).dumpRoutingTable(out).andThen(ar -> out.close())); } catch (IOException e) { - return VertxFuture.failedFuture(e); + return ContextualFuture.failedFuture(e); } } - private static VertxFuture dumpRoutingTables() { - List> futures = new ArrayList<>(testNodes.size() + 1); + private static ContextualFuture dumpRoutingTables() { + List> futures = new ArrayList<>(testNodes.size() + 1); futures.add(dumpRoutingTable("node-bootstrap", bootstrap)); for (int i = 0; i < testNodes.size(); i++) futures.add(dumpRoutingTable("node-" + i, testNodes.get(i))); - return VertxFuture.allOf(futures); + return ContextualFuture.allOf(futures); } // in Vert.x 4.5.x, not support asynchronous lifecycle on static @BeforeAll and @AfterAll methods. @@ -267,7 +267,7 @@ void testFindNode(VertxTestContext context) { return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up node %s ...\n", node.getId(), target.getId()); - var future = (VertxFuture>) node.findNode(target.getId()); + var future = (ContextualFuture>) node.findNode(target.getId()); return future.thenAccept(result -> { System.out.format("\007🟢 %s lookup node %s finished\n", node.getId(), target.getId()); context.verify(() -> { @@ -295,12 +295,12 @@ void testUpdateAndFindPeer(VertxTestContext context) { peers.add(p); System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); - return ((VertxFuture)announcer.announcePeer(p)).thenCompose(v -> { + return ((ContextualFuture)announcer.announcePeer(p)).thenCompose(v -> { System.out.format("\n\n\007🟢 Looking up peer %s ...\n", p.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up peer %s ...\n", node.getId(), p.getId()); - var future = (VertxFuture) node.findPeer(p.getId()); + var future = (ContextualFuture) node.findPeer(p.getId()); return future.thenAccept(result -> { System.out.format("\007🟢 %s lookup peer %s finished\n", node.getId(), p.getId()); context.verify(() -> { @@ -317,12 +317,12 @@ void testUpdateAndFindPeer(VertxTestContext context) { final PeerInfo p = peers.get(index).update().node(announcer).endpoint(faker.internet().url()).build(); System.out.format("\n\n\007🟢 %s announce peer %s ...\n", announcer.getId(), p.getId()); - return ((VertxFuture) announcer.announcePeer(p)).thenCompose(v -> { + return ((ContextualFuture) announcer.announcePeer(p)).thenCompose(v -> { System.out.format("\n\n\007🟢 Looking up peer %s ...\n", p.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up peer %s ...\n", node.getId(), p.getId()); - var future = (VertxFuture) node.findPeer(p.getId()); + var future = (ContextualFuture) node.findPeer(p.getId()); return future.thenAccept(result -> { System.out.format("\007🟢 %s lookup peer %s finished\n", node.getId(), p.getId()); context.verify(() -> { @@ -344,11 +344,11 @@ void testStoreAndFindValue(VertxTestContext context) { System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); - return ((VertxFuture) announcer.storeValue(v)).thenCompose(na -> { + return ((ContextualFuture) announcer.storeValue(v)).thenCompose(na -> { System.out.format("\n\n\007🟢 Looking up value %s ...\n", v.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up value %s ...\n", node.getId(), v.getId()); - var future = (VertxFuture) node.findValue(v.getId()); + var future = (ContextualFuture) node.findValue(v.getId()); return future.thenAccept(result -> { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { @@ -373,16 +373,16 @@ void testUpdateAndFindSignedValue(VertxTestContext context) { values.add(v); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); - return ((VertxFuture)announcer.storeValue(v)).thenCompose(na -> { + return ((ContextualFuture)announcer.storeValue(v)).thenCompose(na -> { System.out.format("\n\n\007🟢 Looking up value %s ...\n", v.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up value %s ...\n", node.getId(), v.getId()); - var future = (VertxFuture) node.findValue(v.getId()); + var future = (ContextualFuture) node.findValue(v.getId()); return future.thenAccept(result -> { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { assertNotNull(result); - assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytesUnsafe()); assertTrue(v.isMutable()); assertTrue(v.isValid()); assertEquals(v, result); @@ -400,15 +400,15 @@ void testUpdateAndFindSignedValue(VertxTestContext context) { values.set(index, v); } catch (Exception e) { context.failNow(e); - return VertxFuture.failedFuture(e); // make compiler happy + return ContextualFuture.failedFuture(e); // make compiler happy } System.out.format("\n\n\007🟢 %s update value %s ...\n", announcer.getId(), v.getId()); - return ((VertxFuture) announcer.storeValue(v)).thenCompose(unused1 -> { + return ((ContextualFuture) announcer.storeValue(v)).thenCompose(unused1 -> { System.out.format("\n\n\007🟢 Looking up value %s ...\n", v.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up value %s ...\n", node.getId(), v.getId()); - return ((VertxFuture) node.findValue(v.getId())).thenAccept(result -> { + return ((ContextualFuture) node.findValue(v.getId())).thenAccept(result -> { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { assertNotNull(result); @@ -440,15 +440,15 @@ void testUpdateAndFindEncryptedValue(VertxTestContext context) { values.add(v); System.out.format("\n\n\007🟢 %s store value %s ...\n", announcer.getId(), v.getId()); - return ((VertxFuture) announcer.storeValue(v)).thenCompose(unused -> { + return ((ContextualFuture) announcer.storeValue(v)).thenCompose(unused -> { System.out.format("\n\n\007🟢 Looking up value %s ...\n", v.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up value %s ...\n", node.getId(), v.getId()); - return ((VertxFuture) node.findValue(v.getId())).thenAccept(result -> { + return ((ContextualFuture) node.findValue(v.getId())).thenAccept(result -> { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { assertNotNull(result); - assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytesUnsafe()); assertTrue(v.isMutable()); assertTrue(v.isEncrypted()); assertTrue(v.isValid()); @@ -472,15 +472,15 @@ void testUpdateAndFindEncryptedValue(VertxTestContext context) { values.set(index, v); } catch (Exception e) { context.failNow(e); - return VertxFuture.failedFuture(e); + return ContextualFuture.failedFuture(e); } System.out.format("\n\n\007🟢 %s update value %s ...\n", announcer.getId(), v.getId()); - return ((VertxFuture) announcer.storeValue(v)).thenCompose(unused1 -> { + return ((ContextualFuture) announcer.storeValue(v)).thenCompose(unused1 -> { System.out.format("\n\n\007🟢 Looking up value %s ...\n", v.getId()); return executeSequentially(testNodes, node -> { System.out.format("\n\n\007⌛ %s looking up value %s ...\n", node.getId(), v.getId()); - return ((VertxFuture) node.findValue(v.getId())).thenAccept(result -> { + return ((ContextualFuture) node.findValue(v.getId())).thenAccept(result -> { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); context.verify(() -> { assertNotNull(result); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java index b5b206b1..f353cb7e 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/NodeSyncTests.java @@ -39,7 +39,7 @@ import io.bosonnetwork.crypto.Signature.KeyPair; import io.bosonnetwork.utils.AddressUtils; import io.bosonnetwork.utils.FileUtils; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; public class NodeSyncTests { private static Vertx vertx; @@ -123,7 +123,7 @@ private static void dumpRoutingTables() throws Exception { System.out.format("\007🟢 Dumping the routing table of bootstrap node %s ...\n", bootstrap.getId()); var file = testDir.resolve("nodes" + File.separator + "node-bootstrap" + File.separator + "routingtable"); try (var out = new PrintStream(Files.newOutputStream(file))) { - VertxFuture.of(bootstrap.getDHT(Network.IPv4).dumpRoutingTable(out)).get(); + ContextualFuture.of(bootstrap.getDHT(Network.IPv4).dumpRoutingTable(out)).get(); } for (int i = 0; i < testNodes.size(); i++) { @@ -133,7 +133,7 @@ private static void dumpRoutingTables() throws Exception { //noinspection SpellCheckingInspection file = testDir.resolve("nodes" + File.separator + "node-" + i + File.separator + "routingtable"); try (var out = new PrintStream(Files.newOutputStream(file))) { - VertxFuture.of(dht.dumpRoutingTable(out)).get(); + ContextualFuture.of(dht.dumpRoutingTable(out)).get(); } } } @@ -171,7 +171,7 @@ static void teardown() throws Exception { stopTestNodes(); stopBootstrap(); - VertxFuture.of(vertx.close()).get(); + ContextualFuture.of(vertx.close()).get(); FileUtils.deleteFile(testDir); } @@ -317,7 +317,7 @@ void testUpdateAndFindSignedValue() throws Exception { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); assertNotNull(result); - assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytesUnsafe()); assertTrue(v.isMutable()); assertTrue(v.isValid()); assertEquals(v, result); @@ -377,7 +377,7 @@ void testUpdateAndFindEncryptedValue() throws Exception { System.out.format("\007🟢 %s lookup value %s finished\n", node.getId(), v.getId()); assertNotNull(result); - assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytes()); + assertArrayEquals(keyPair.publicKey().bytes(), v.getPublicKey().bytesUnsafe()); assertTrue(v.isMutable()); assertTrue(v.isEncrypted()); assertTrue(v.isValid()); diff --git a/dht/src/test/java/io/bosonnetwork/kademlia/SybilTests.java b/dht/src/test/java/io/bosonnetwork/kademlia/SybilTests.java index da2e53e3..289b4cbd 100644 --- a/dht/src/test/java/io/bosonnetwork/kademlia/SybilTests.java +++ b/dht/src/test/java/io/bosonnetwork/kademlia/SybilTests.java @@ -52,7 +52,7 @@ import io.bosonnetwork.utils.AddressUtils; import io.bosonnetwork.utils.Base58; import io.bosonnetwork.utils.FileUtils; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; public class SybilTests { private static final Path testDir = Path.of(System.getProperty("java.io.tmpdir"), "boson", "SybilTests"); @@ -91,7 +91,7 @@ void setUp() throws Exception { void tearDown() throws Exception { target.stop().get(); - VertxFuture.of(vertx.close()).get(); + ContextualFuture.of(vertx.close()).get(); FileUtils.deleteFile(testDir); } diff --git a/shell/src/main/java/io/bosonnetwork/kademlia/shell/AnnouncePeerCommand.java b/shell/src/main/java/io/bosonnetwork/kademlia/shell/AnnouncePeerCommand.java index 9b6e638a..94da26fe 100644 --- a/shell/src/main/java/io/bosonnetwork/kademlia/shell/AnnouncePeerCommand.java +++ b/shell/src/main/java/io/bosonnetwork/kademlia/shell/AnnouncePeerCommand.java @@ -33,7 +33,7 @@ import io.bosonnetwork.crypto.Signature; import io.bosonnetwork.json.Json; import io.bosonnetwork.utils.Hex; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; /** * @hidden @@ -96,7 +96,7 @@ public Integer call() throws Exception { PeerInfo peer = pb.build(); if (localOnly) - VertxFuture.of(Main.getBosonNode().getStorage().putPeer(peer)).get(); + ContextualFuture.of(Main.getBosonNode().getStorage().putPeer(peer)).get(); else Main.getBosonNode().announcePeer(peer, persistent).get(); diff --git a/shell/src/main/java/io/bosonnetwork/kademlia/shell/RoutingTableCommand.java b/shell/src/main/java/io/bosonnetwork/kademlia/shell/RoutingTableCommand.java index 2042d519..9afb517a 100644 --- a/shell/src/main/java/io/bosonnetwork/kademlia/shell/RoutingTableCommand.java +++ b/shell/src/main/java/io/bosonnetwork/kademlia/shell/RoutingTableCommand.java @@ -29,7 +29,7 @@ import io.bosonnetwork.Network; import io.bosonnetwork.kademlia.impl.DHT; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; /** * @hidden @@ -42,14 +42,14 @@ public Integer call() throws Exception { DHT dht4 = Main.getBosonNode().getDHT(Network.IPv4); if (dht4 != null) { System.out.println("Routing table for IPv4: "); - VertxFuture.of(dht4.dumpRoutingTable(System.out)).get(); + ContextualFuture.of(dht4.dumpRoutingTable(System.out)).get(); System.out.println(); } DHT dht6 = Main.getBosonNode().getDHT(Network.IPv6); if (dht6 != null) { System.out.println("Routing table for IPv6: "); - VertxFuture.of(dht6.dumpRoutingTable(System.out)).get(); + ContextualFuture.of(dht6.dumpRoutingTable(System.out)).get(); System.out.println(); } diff --git a/shell/src/main/java/io/bosonnetwork/kademlia/shell/StorageCommand.java b/shell/src/main/java/io/bosonnetwork/kademlia/shell/StorageCommand.java index 16fd6059..b0ef376a 100644 --- a/shell/src/main/java/io/bosonnetwork/kademlia/shell/StorageCommand.java +++ b/shell/src/main/java/io/bosonnetwork/kademlia/shell/StorageCommand.java @@ -34,7 +34,7 @@ import io.bosonnetwork.kademlia.shell.StorageCommand.ListValueCommand; import io.bosonnetwork.kademlia.shell.StorageCommand.PeerCommand; import io.bosonnetwork.kademlia.shell.StorageCommand.ValueCommand; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; /** * @hidden @@ -58,7 +58,7 @@ public static class ListValueCommand implements Callable { public Integer call() throws Exception { DataStorage storage = Main.getBosonNode().getStorage(); - VertxFuture.of(storage.getValues().map(values -> { + ContextualFuture.of(storage.getValues().map(values -> { values.forEach(v -> { System.out.printf("%44s, %s\n", v.getId(), v.isMutable() ? "mutable" : "immutable"); }); @@ -90,7 +90,7 @@ public Integer call() throws Exception { } DataStorage storage = Main.getBosonNode().getStorage(); - VertxFuture.of(storage.getValue(valueId).map(value -> { + ContextualFuture.of(storage.getValue(valueId).map(value -> { if (value != null) System.out.println(value); else @@ -114,7 +114,7 @@ public static class ListPeerCommand implements Callable { public Integer call() throws Exception { DataStorage storage = Main.getBosonNode().getStorage(); - VertxFuture.of(storage.getPeers().map(peers -> { + ContextualFuture.of(storage.getPeers().map(peers -> { peers.forEach(p -> { System.out.printf("%s:%s\n", p.getId(), p.getNodeId()); }); @@ -147,7 +147,7 @@ public Integer call() throws Exception { DataStorage storage = Main.getBosonNode().getStorage(); - VertxFuture.of(storage.getPeers(peerId).map(peers -> { + ContextualFuture.of(storage.getPeers(peerId).map(peers -> { peers.forEach(System.out::println); System.out.println("Total " + peers.size() + " peers."); return null; diff --git a/shell/src/main/java/io/bosonnetwork/kademlia/shell/StoreValueCommand.java b/shell/src/main/java/io/bosonnetwork/kademlia/shell/StoreValueCommand.java index 27a5ccce..dc2d0037 100644 --- a/shell/src/main/java/io/bosonnetwork/kademlia/shell/StoreValueCommand.java +++ b/shell/src/main/java/io/bosonnetwork/kademlia/shell/StoreValueCommand.java @@ -32,7 +32,7 @@ import io.bosonnetwork.Id; import io.bosonnetwork.Node; import io.bosonnetwork.Value; -import io.bosonnetwork.vertx.VertxFuture; +import io.bosonnetwork.vertx.ContextualFuture; /** * @hidden @@ -113,7 +113,7 @@ public Integer call() throws Exception { } if (localOnly) - VertxFuture.of(Main.getBosonNode().getStorage().putValue(value, persistent)).get(); + ContextualFuture.of(Main.getBosonNode().getStorage().putValue(value, persistent)).get(); else Main.getBosonNode().storeValue(value, persistent).get();