+ * When present, the returned {@link NodeInfo} records which address families answered the lookup: + * {@link NodeInfo#hasAddress4()} and {@link NodeInfo#hasAddress6()} are true only for the families + * that contributed a result. A dual-stack node that responded over a single family therefore yields + * a single-address {@link NodeInfo} (a {@link LookupOption#CONSERVATIVE} lookup queries both families + * and still succeeds with a partial result if only one responds). * * @param id the {@link Id} of the node to find * @param option the {@link LookupOption} to use diff --git a/api/src/main/java/io/bosonnetwork/NodeInfo.java b/api/src/main/java/io/bosonnetwork/NodeInfo.java index c5f67bf0..96c70daf 100644 --- a/api/src/main/java/io/bosonnetwork/NodeInfo.java +++ b/api/src/main/java/io/bosonnetwork/NodeInfo.java @@ -29,6 +29,7 @@ import java.net.InetSocketAddress; import java.net.StandardProtocolFamily; import java.net.UnknownHostException; +import java.util.List; import java.util.Objects; import org.jspecify.annotations.Nullable; @@ -42,17 +43,15 @@ * {@link #getIpAddress()}) prefer the IPv4 address and fall back to IPv6; use the family-specific * accessors to target a particular protocol family. *
- * The id and addresses are immutable and define {@link #equals(Object)}/{@link #hashCode()}; the
- * version and the default protocol family ({@link #narrowDown(StandardProtocolFamily)}) are mutable
- * and excluded from equality. Instances are not thread-safe for the mutable fields; callers that
- * share an instance across threads should treat it as effectively immutable.
+ * Instances are immutable: the id and addresses define {@link #equals(Object)}/{@link #hashCode()},
+ * and the preferred protocol family is fixed at construction. {@link #narrowDown(StandardProtocolFamily)}
+ * returns a new instance rather than mutating. Immutable instances are safe to share across threads.
*/
public class NodeInfo {
private final Id id;
private final @Nullable InetSocketAddress addr4;
private final @Nullable InetSocketAddress addr6;
- private int version;
- private @Nullable StandardProtocolFamily defaultProtocolFamily;
+ private final StandardProtocolFamily defaultProtocolFamily;
private NodeInfo(Id id, @Nullable InetSocketAddress sockAddr4, @Nullable InetSocketAddress sockAddr6) {
Objects.requireNonNull(id, "id");
@@ -84,6 +83,12 @@ private NodeInfo(Id id, @Nullable InetSocketAddress sockAddr4, @Nullable InetSoc
this.defaultProtocolFamily = sockAddr4 != null ? StandardProtocolFamily.INET : StandardProtocolFamily.INET6;
}
+ /**
+ * Construct a {@code NodeInfo} object from a single socket address.
+ *
+ * @param id the node id.
+ * @param sockAddr the node socket address, can be IPv4 or IPv6.
+ */
protected NodeInfo(Id id, InetSocketAddress sockAddr) {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(sockAddr, "sockAddr");
@@ -115,7 +120,6 @@ protected NodeInfo(NodeInfo ni) {
this.id = ni.id;
this.addr4 = ni.addr4;
this.addr6 = ni.addr6;
- this.version = ni.version;
this.defaultProtocolFamily = ni.defaultProtocolFamily;
}
@@ -124,6 +128,7 @@ protected NodeInfo(NodeInfo ni) {
*
* @param id the node id.
* @param sockAddr the node socket address, can be IPv4 or IPv6.
+ * @return the constructed {@code NodeInfo}.
*/
public static NodeInfo of(Id id, InetSocketAddress sockAddr) {
return new NodeInfo(id, sockAddr);
@@ -135,6 +140,7 @@ public static NodeInfo of(Id id, InetSocketAddress sockAddr) {
* @param id the node id.
* @param inetAddr the node IP address, can be IPv4 or IPv6.
* @param port the node port number.
+ * @return the constructed {@code NodeInfo}.
*/
public static NodeInfo of(Id id, InetAddress inetAddr, int port) {
Objects.requireNonNull(id, "id");
@@ -148,6 +154,7 @@ public static NodeInfo of(Id id, InetAddress inetAddr, int port) {
* @param id the node id.
* @param host the node host name or address string.
* @param port the node port number.
+ * @return the constructed {@code NodeInfo}.
*/
public static NodeInfo of(Id id, String host, int port) {
Objects.requireNonNull(id, "id");
@@ -161,6 +168,7 @@ public static NodeInfo of(Id id, String host, int port) {
* @param id the node id.
* @param inetAddr the node raw IP address, can be IPv4 or IPv6.
* @param port the node port number.
+ * @return the constructed {@code NodeInfo}.
*/
public static NodeInfo of(Id id, byte[] inetAddr, int port) {
Objects.requireNonNull(id, "id");
@@ -178,6 +186,7 @@ public static NodeInfo of(Id id, byte[] inetAddr, int port) {
* @param id the node id.
* @param sockAddr4 the IPv4 socket address, can be null.
* @param sockAddr6 the IPv6 socket address, can be null.
+ * @return the constructed {@code NodeInfo}.
* @throws IllegalArgumentException if both addresses are null, or if the port is invalid.
*/
public static NodeInfo of(Id id, @Nullable InetSocketAddress sockAddr4, @Nullable InetSocketAddress sockAddr6) {
@@ -192,6 +201,7 @@ public static NodeInfo of(Id id, @Nullable InetSocketAddress sockAddr4, @Nullabl
* @param port4 the IPv4 port number, ignored if {@code inetAddr4} is null.
* @param inetAddr6 the IPv6 address, can be null.
* @param port6 the IPv6 port number, ignored if {@code inetAddr6} is null.
+ * @return the constructed {@code NodeInfo}.
* @throws IllegalArgumentException if both addresses are null, or if an address/port is invalid.
*/
public static NodeInfo of(Id id, @Nullable InetAddress inetAddr4, int port4, @Nullable InetAddress inetAddr6, int port6) {
@@ -230,6 +240,7 @@ public static NodeInfo of(Id id, @Nullable InetAddress inetAddr4, int port4, @Nu
* @param port4 the IPv4 port number, ignored if {@code host4} is null.
* @param host6 the IPv6 host name or address string, can be null.
* @param port6 the IPv6 port number, ignored if {@code host6} is null.
+ * @return the constructed {@code NodeInfo}.
* @throws IllegalArgumentException if both hosts are null, or if an address/port is invalid.
*/
public static NodeInfo of(Id id, @Nullable String host4, int port4, @Nullable String host6, int port6) {
@@ -268,6 +279,7 @@ public static NodeInfo of(Id id, @Nullable String host4, int port4, @Nullable St
* @param port4 the IPv4 port number, ignored if {@code inetAddr4} is null.
* @param inetAddr6 the raw IPv6 address bytes, can be null.
* @param port6 the IPv6 port number, ignored if {@code inetAddr6} is null.
+ * @return the constructed {@code NodeInfo}.
* @throws IllegalArgumentException if both addresses are null, or if an address/port is invalid.
*/
public static NodeInfo of(Id id, byte @Nullable [] inetAddr4, int port4, byte @Nullable [] inetAddr6, int port6) {
@@ -322,27 +334,33 @@ public Id getId() {
}
/**
- * Narrow the node down to a single protocol family, making the given family the one returned by
- * the generic accessors ({@link #getAddress()}, {@link #getHost()}, {@link #getPort()}, etc.).
+ * Returns a view of this node narrowed to a single protocol family, dropping any address of the
+ * other family. The returned node carries only the requested family's address, so its generic
+ * accessors unambiguously refer to that family and it compares equal only to other single-family
+ * nodes with the same id and address. If this node already has only the requested family, it is
+ * returned unchanged.
*
- * @param family the protocol family to make default; the node must have an address for it.
+ * @param family the protocol family to keep (INET or INET6); the node must have an address for it.
+ * @return a single-address {@code NodeInfo} for the requested family.
* @throws IllegalStateException if no address of the requested family is available.
* @throws IllegalArgumentException if the family is not INET or INET6.
*/
- public void narrowDown(StandardProtocolFamily family) {
- switch (family) {
- case INET -> {
- if (addr4 == null)
- throw new IllegalStateException("No IPv4 address is available");
- }
- case INET6 -> {
- if (addr6 == null)
- throw new IllegalStateException("No IPv6 address is available");
- }
+ public NodeInfo narrowDown(StandardProtocolFamily family) {
+ InetSocketAddress addr = switch (family) {
+ case INET -> addr4;
+ case INET6 -> addr6;
default -> throw new IllegalArgumentException("Unsupported protocol family: " + family);
- }
+ };
+
+ if (addr == null)
+ throw new IllegalStateException("No " +
+ (family == StandardProtocolFamily.INET ? "IPv4" : "IPv6") + " address is available");
- this.defaultProtocolFamily = family;
+ // Already single-family (of the requested family, since its address is present): share it.
+ if (!hasMultiAddresses())
+ return this;
+
+ return new NodeInfo(id, addr);
}
/**
@@ -388,15 +406,43 @@ public boolean hasMultiAddresses() {
}
/**
- * Gets the socket address of the node.
- * Returns the IPv4 address if available, otherwise returns the IPv6 address.
+ * Returns the protocol family used by the generic accessors ({@link #getAddress()},
+ * {@link #getHost()}, {@link #getPort()}, {@link #getIpAddress()}). For a dual-stack node this is
+ * IPv4 by default; for a single-stack node it is the only available family.
+ *
+ * @return the preferred protocol family (INET or INET6).
+ */
+ public StandardProtocolFamily getPreferredFamily() {
+ return defaultProtocolFamily;
+ }
+
+ /**
+ * Retrieves a list of network addresses, including both IPv4 and IPv6 addresses, if available.
+ *
+ * @return a list of InetSocketAddress objects containing the available network addresses.
+ * The list may include both IPv4 and IPv6 addresses, only IPv4 addresses,
+ * only IPv6 addresses, or be empty if no addresses are available.
+ */
+ public List
+ * Every construction is byte-for-byte compatible with libsodium. Where Bouncy Castle does not
+ * expose a libsodium building block directly, it is implemented here against verified test
+ * vectors (see the crypto compatibility test): the HSalsa20 core used by {@code crypto_box}
+ * key derivation, the Ed25519 to Curve25519 birational map, the NaCl secretbox layout, and the
+ * Argon2 PHC string format produced by {@code crypto_pwhash_str}.
+ */
+public class BouncyCastleCryptoProvider implements CryptoProvider {
+ // "expand 32-byte k" - the Salsa20/HSalsa20 sigma constant.
+ private static final byte[] SIGMA = "expand 32-byte k".getBytes(StandardCharsets.US_ASCII);
+ // Curve25519 field prime: 2^255 - 19.
+ private static final BigInteger P = BigInteger.TWO.pow(255).subtract(BigInteger.valueOf(19));
+
+ @Override
+ public String name() {
+ return "bc";
+ }
+
+ // ---- Ed25519 ----------------------------------------------------------
+
+ private static final class Ed25519SecretKey implements Signature.PrivateKey {
+ // The 32-byte seed is the authoritative material; the BC parameter object is rebuilt on
+ // demand so destroy() can actually wipe the secret.
+ private byte @Nullable [] seed;
+
+ private Ed25519SecretKey(byte[] seed) {
+ this.seed = seed.clone();
+ }
+
+ private byte[] seedOrThrow() {
+ if (seed == null)
+ throw new IllegalStateException("Private key has been destroyed");
+ return seed;
+ }
+
+ private Ed25519PrivateKeyParameters params() {
+ return new Ed25519PrivateKeyParameters(seedOrThrow(), 0);
+ }
+
+ @Override
+ public byte[] seed() {
+ return seedOrThrow().clone();
+ }
+
+ @Override
+ public byte[] bytes() {
+ byte[] pub = params().generatePublicKey().getEncoded();
+ byte[] out = new byte[SIGN_SECRET_KEY_BYTES];
+ System.arraycopy(seedOrThrow(), 0, out, 0, SIGN_SEED_BYTES);
+ System.arraycopy(pub, 0, out, SIGN_SEED_BYTES, SIGN_PUBLIC_KEY_BYTES);
+ return out;
+ }
+
+ @Override
+ public void destroy() {
+ if (seed != null) {
+ Arrays.fill(seed, (byte) 0);
+ seed = null;
+ }
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return seed == null;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof Signature.PrivateKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return constantTimeAreEqual(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ private static final class Ed25519PublicKey implements Signature.PublicKey {
+ private @Nullable Ed25519PublicKeyParameters key;
+
+ private Ed25519PublicKey(Ed25519PublicKeyParameters key) {
+ this.key = key;
+ }
+
+ private Ed25519PublicKeyParameters params() {
+ if (key == null)
+ throw new IllegalStateException("Public key has been destroyed");
+ return key;
+ }
+
+ @Override
+ public byte[] bytes() {
+ return params().getEncoded();
+ }
+
+ @Override
+ public void destroy() {
+ key = null;
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key == null;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof Signature.PublicKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ @Override
+ public Signature.PrivateKey ed25519SecretKeyFromSeed(byte[] seed) {
+ return new Ed25519SecretKey(seed);
+ }
+
+ @Override
+ public Signature.PrivateKey ed25519SecretKeyFromBytes(byte[] key) {
+ // libsodium secret key is seed || public key; the seed is the first 32 bytes.
+ return new Ed25519SecretKey(Arrays.copyOfRange(key, 0, SIGN_SEED_BYTES));
+ }
+
+ private static Ed25519PrivateKeyParameters keyOf(Signature.PrivateKey secretKey) {
+ return secretKey instanceof Ed25519SecretKey k ? k.params() :
+ new Ed25519PrivateKeyParameters(secretKey.seed(), 0);
+ }
+
+ private static Ed25519PublicKeyParameters keyOf(Signature.PublicKey publicKey) {
+ return publicKey instanceof Ed25519PublicKey k ? k.params() :
+ new Ed25519PublicKeyParameters(publicKey.bytes(), 0);
+ }
+
+ @Override
+ public Signature.PublicKey ed25519PublicKeyFromSecretKey(Signature.PrivateKey secretKey) {
+ Ed25519PublicKeyParameters pk = keyOf(secretKey).generatePublicKey();
+ return new Ed25519PublicKey(pk);
+ }
+
+ @Override
+ public Signature.PublicKey ed25519PublicKeyFromBytes(byte[] key) {
+ return new Ed25519PublicKey(new Ed25519PublicKeyParameters(key, 0));
+ }
+
+ @Override
+ public byte[] ed25519Sign(byte[] message, Signature.PrivateKey secretKey) {
+ Ed25519Signer signer = new Ed25519Signer();
+ signer.init(true, keyOf(secretKey));
+ signer.update(message, 0, message.length);
+ return signer.generateSignature();
+ }
+
+ @Override
+ public boolean ed25519Verify(byte[] message, byte[] signature, Signature.PublicKey publicKey) {
+ Ed25519Signer verifier = new Ed25519Signer();
+ verifier.init(false, keyOf(publicKey));
+ verifier.update(message, 0, message.length);
+ return verifier.verifySignature(signature);
+ }
+
+ // ---- crypto_kdf (keyed BLAKE2b) ---------------------------------------
+
+ @Override
+ public byte[] kdfDeriveFromKey(byte[] masterKey, long subKeyId, byte[] context, int subKeyLength) {
+ // salt[16] = LE64(subKeyId) || zeros; personal[16] = context[0..8] || zeros
+ byte[] salt = new byte[16];
+ for (int i = 0; i < 8; i++)
+ salt[i] = (byte) (subKeyId >>> (8 * i));
+ byte[] personal = new byte[16];
+ System.arraycopy(context, 0, personal, 0, KDF_CONTEXT_BYTES);
+
+ Blake2bDigest digest = new Blake2bDigest(masterKey, subKeyLength, salt, personal);
+ byte[] out = new byte[subKeyLength];
+ digest.doFinal(out, 0); // no input bytes
+ return out;
+ }
+
+ // ---- Ed25519 -> Curve25519 conversions --------------------------------
+
+ @Override
+ public CryptoBox.PublicKey signPublicKeyToBoxPublicKey(Signature.PublicKey publicKey) {
+ return new BcBoxPublicKey(edPublicKeyToCurve(publicKey.bytes()));
+ }
+
+ @Override
+ public CryptoBox.PrivateKey signSecretKeyToBoxSecretKey(Signature.PrivateKey secretKey) {
+ // Curve25519 secret key = clamp(SHA-512(seed)[0..32]).
+ byte[] h = sha512(secretKey.seed());
+ byte[] sk = Arrays.copyOfRange(h, 0, BOX_SECRET_KEY_BYTES);
+ sk[0] &= (byte) 248;
+ sk[31] &= (byte) 127;
+ sk[31] |= (byte) 64;
+ return new BcBoxSecretKey(sk);
+ }
+
+ // Curve25519 u = (1 + y) / (1 - y) (mod p), where y is the Edwards y-coordinate.
+ private static byte[] edPublicKeyToCurve(byte[] ed25519PublicKey) {
+ byte[] yle = ed25519PublicKey.clone();
+ yle[31] &= 0x7f; // clear the x sign bit
+ BigInteger y = decodeLittleEndian(yle);
+ BigInteger oneMinusY = BigInteger.ONE.subtract(y).mod(P);
+ BigInteger onePlusY = BigInteger.ONE.add(y).mod(P);
+ BigInteger u = onePlusY.multiply(oneMinusY.modInverse(P)).mod(P);
+ return encodeLittleEndian(u, BOX_PUBLIC_KEY_BYTES);
+ }
+
+ // ---- crypto_box -------------------------------------------------------
+
+ private static final class BcBoxPublicKey implements CryptoBox.PublicKey {
+ private byte @Nullable [] key;
+
+ private BcBoxPublicKey(byte[] key) {
+ this.key = key.clone();
+ }
+
+ private byte[] keyOrThrow() {
+ if (key == null)
+ throw new IllegalStateException("Public key has been destroyed");
+ return key;
+ }
+
+ @Override
+ public byte[] bytes() {
+ return keyOrThrow().clone();
+ }
+
+ @Override
+ public void destroy() {
+ if (key != null) {
+ Arrays.fill(key, (byte) 0);
+ key = null;
+ }
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key == null;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof CryptoBox.PublicKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ private static final class BcBoxSecretKey implements CryptoBox.PrivateKey {
+ private byte @Nullable [] key;
+
+ private BcBoxSecretKey(byte[] key) {
+ this.key = key.clone();
+ }
+
+ private byte[] keyOrThrow() {
+ if (key == null)
+ throw new IllegalStateException("Private key has been destroyed");
+ return key;
+ }
+
+ @Override
+ public byte[] bytes() {
+ return keyOrThrow().clone();
+ }
+
+ @Override
+ public void destroy() {
+ if (key != null) {
+ Arrays.fill(key, (byte) 0);
+ key = null;
+ }
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key == null;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof CryptoBox.PrivateKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return constantTimeAreEqual(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ private static final class BcBoxNonce implements CryptoBox.Nonce {
+ private final byte[] nonce;
+
+ private BcBoxNonce(byte[] nonce) {
+ this.nonce = nonce.clone();
+ }
+
+ @Override
+ public CryptoBox.Nonce increment() {
+ byte[] next = nonce.clone();
+ int c = 1;
+ for (int i = 0; i < next.length; i++) {
+ c += next[i] & 0xff;
+ next[i] = (byte) c;
+ c >>>= 8;
+ }
+ return new BcBoxNonce(next);
+ }
+
+ @Override
+ public byte[] bytes() {
+ return nonce.clone();
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(nonce);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof CryptoBox.Nonce that))
+ return false;
+ return Arrays.equals(nonce, that.bytes());
+ }
+ }
+
+ private static final class BcCryptoBox implements CryptoBox {
+ private byte @Nullable [] sharedKey;
+
+ private BcCryptoBox(byte[] sharedKey) {
+ this.sharedKey = sharedKey;
+ }
+
+ private byte[] sharedKeyOrThrow() {
+ if (sharedKey == null)
+ throw new IllegalStateException("CryptoBox has been closed");
+ return sharedKey;
+ }
+
+ @Override
+ public void close() {
+ destroy();
+ }
+
+ @Override
+ public void destroy() {
+ if (sharedKey != null) {
+ Arrays.fill(sharedKey, (byte) 0);
+ sharedKey = null;
+ }
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return sharedKey == null;
+ }
+ }
+
+ private static byte[] boxKeyOf(CryptoBox.PublicKey publicKey) {
+ return publicKey instanceof BcBoxPublicKey k ? k.keyOrThrow() : publicKey.bytes();
+ }
+
+ private static byte[] boxKeyOf(CryptoBox.PrivateKey secretKey) {
+ return secretKey instanceof BcBoxSecretKey k ? k.keyOrThrow() : secretKey.bytes();
+ }
+
+ // shared = HSalsa20(X25519(sk, pk), nonce=0^16, sigma)
+ private static byte[] sharedKey(byte[] boxPublicKey, byte[] boxSecretKey) {
+ byte[] s = new byte[BOX_SHARED_KEY_BYTES];
+ X25519.calculateAgreement(boxSecretKey, 0, boxPublicKey, 0, s, 0);
+ return hsalsa20(s, new byte[16], SIGMA);
+ }
+
+ @Override
+ public CryptoBox.PublicKey boxPublicKeyFromBytes(byte[] bytes) {
+ return new BcBoxPublicKey(bytes);
+ }
+
+ @Override
+ public CryptoBox.PrivateKey boxSecretKeyFromSeed(byte[] seed) {
+ // crypto_box_seed_keypair: secret key = SHA-512(seed)[0..32]
+ byte[] sk = Arrays.copyOfRange(sha512(seed), 0, BOX_SEED_BYTES);
+ return new BcBoxSecretKey(sk);
+ }
+
+ @Override
+ public CryptoBox.PrivateKey boxSecretKeyFromBytes(byte[] bytes) {
+ return new BcBoxSecretKey(bytes);
+ }
+
+ @Override
+ public CryptoBox.PublicKey boxPublicKeyFromSecretKey(CryptoBox.PrivateKey secretKey) {
+ byte[] pk = new byte[BOX_PUBLIC_KEY_BYTES];
+ X25519.scalarMultBase(boxKeyOf(secretKey), 0, pk, 0);
+ return new BcBoxPublicKey(pk);
+ }
+
+ @Override
+ public CryptoBox.Nonce boxNonceFromBytes(byte[] bytes) {
+ return new BcBoxNonce(bytes);
+ }
+
+ @Override
+ public CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return new BcCryptoBox(sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey)));
+ }
+
+ private static byte[] sharedKeyOf(CryptoBox box) {
+ if (box instanceof BcCryptoBox c)
+ return c.sharedKeyOrThrow();
+
+ throw new IllegalStateException("Not a BcCryptoBox: " + box.getClass().getName());
+ }
+
+ @Override
+ public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox box) {
+ return secretboxSeal(message, nonceOf(nonce), sharedKeyOf(box));
+ }
+
+ @Override
+ public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox box) {
+ return secretboxOpen(cipher, nonceOf(nonce), sharedKeyOf(box));
+ }
+
+ private static byte[] nonceOf(CryptoBox.Nonce nonce) {
+ return nonce instanceof BcBoxNonce n ? n.nonce : nonce.bytes();
+ }
+
+ @Override
+ public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return secretboxSeal(message, nonceOf(nonce), sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey)));
+ }
+
+ @Override
+ public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return secretboxOpen(cipher, nonceOf(nonce), sharedKey(boxKeyOf(publicKey), boxKeyOf(secretKey)));
+ }
+
+ @Override
+ public byte[] boxSeal(byte[] message, CryptoBox.PublicKey publicKey) {
+ byte[] recipientPk = boxKeyOf(publicKey);
+ byte[] esk = Random.randomBytesSecure(BOX_SECRET_KEY_BYTES);
+ byte[] epk = new byte[BOX_PUBLIC_KEY_BYTES];
+ X25519.scalarMultBase(esk, 0, epk, 0);
+ byte[] nonce = sealNonce(epk, recipientPk);
+ byte[] cipher = secretboxSeal(message, nonce, sharedKey(recipientPk, esk));
+
+ byte[] out = new byte[BOX_PUBLIC_KEY_BYTES + cipher.length];
+ System.arraycopy(epk, 0, out, 0, BOX_PUBLIC_KEY_BYTES);
+ System.arraycopy(cipher, 0, out, BOX_PUBLIC_KEY_BYTES, cipher.length);
+ return out;
+ }
+
+ @Override
+ public byte @Nullable [] boxSealOpen(byte[] cipher, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ if (cipher.length < BOX_PUBLIC_KEY_BYTES + BOX_MAC_BYTES)
+ return null;
+
+ byte[] epk = Arrays.copyOfRange(cipher, 0, BOX_PUBLIC_KEY_BYTES);
+ byte[] nonce = sealNonce(epk, boxKeyOf(publicKey));
+ byte[] boxed = Arrays.copyOfRange(cipher, BOX_PUBLIC_KEY_BYTES, cipher.length);
+ return secretboxOpen(boxed, nonce, sharedKey(epk, boxKeyOf(secretKey)));
+ }
+
+ // crypto_box_seal nonce = BLAKE2b-192(ephemeralPublicKey || recipientPublicKey)
+ private static byte[] sealNonce(byte[] ephemeralPublicKey, byte[] recipientPublicKey) {
+ Blake2bDigest digest = new Blake2bDigest(BOX_NONCE_BYTES * 8); // bit length
+ digest.update(ephemeralPublicKey, 0, ephemeralPublicKey.length);
+ digest.update(recipientPublicKey, 0, recipientPublicKey.length);
+ byte[] nonce = new byte[BOX_NONCE_BYTES];
+ digest.doFinal(nonce, 0);
+ return nonce;
+ }
+
+ // ---- crypto_secretbox: XSalsa20-Poly1305 (NaCl easy layout) -----------
+
+ private static byte[] secretboxSeal(byte[] message, byte[] nonce, byte[] key) {
+ XSalsa20Engine cipher = new XSalsa20Engine();
+ cipher.init(true, new ParametersWithIV(new KeyParameter(key), nonce));
+
+ byte[] subkey = new byte[32];
+ cipher.processBytes(new byte[32], 0, 32, subkey, 0); // first 32 keystream bytes -> Poly1305 key
+
+ byte[] out = new byte[BOX_MAC_BYTES + message.length];
+ cipher.processBytes(message, 0, message.length, out, BOX_MAC_BYTES);
+
+ Poly1305 mac = new Poly1305();
+ mac.init(new KeyParameter(subkey));
+ mac.update(out, BOX_MAC_BYTES, message.length);
+ mac.doFinal(out, 0);
+ return out;
+ }
+
+ private static byte @Nullable [] secretboxOpen(byte[] boxed, byte[] nonce, byte[] key) {
+ if (boxed.length < BOX_MAC_BYTES)
+ return null;
+ int clen = boxed.length - BOX_MAC_BYTES;
+
+ XSalsa20Engine cipher = new XSalsa20Engine();
+ cipher.init(true, new ParametersWithIV(new KeyParameter(key), nonce));
+
+ byte[] subkey = new byte[32];
+ cipher.processBytes(new byte[32], 0, 32, subkey, 0);
+
+ Poly1305 mac = new Poly1305();
+ mac.init(new KeyParameter(subkey));
+ mac.update(boxed, BOX_MAC_BYTES, clen);
+ byte[] tag = new byte[BOX_MAC_BYTES];
+ mac.doFinal(tag, 0);
+
+ if (!constantTimeAreEqual(BOX_MAC_BYTES, tag, 0, boxed, 0))
+ return null;
+
+ byte[] message = new byte[clen];
+ cipher.processBytes(boxed, BOX_MAC_BYTES, clen, message, 0);
+ return message;
+ }
+
+ // ---- HSalsa20 core (crypto_core_hsalsa20) -----------------------------
+ // Salsa20 core run for 20 rounds, emitting the constant/input diagonal words without the
+ // final feed-forward add. Used by crypto_box to derive the shared key from the X25519 output.
+
+ @SuppressWarnings("SameParameterValue")
+ private static byte[] hsalsa20(byte[] key, byte[] in, byte[] c) {
+ int x0 = load(c, 0), x5 = load(c, 4), x10 = load(c, 8), x15 = load(c, 12);
+ int x1 = load(key, 0), x2 = load(key, 4), x3 = load(key, 8), x4 = load(key, 12);
+ int x11 = load(key, 16), x12 = load(key, 20), x13 = load(key, 24), x14 = load(key, 28);
+ int x6 = load(in, 0), x7 = load(in, 4), x8 = load(in, 8), x9 = load(in, 12);
+
+ for (int i = 0; i < 10; i++) {
+ x4 ^= Integer.rotateLeft(x0 + x12, 7);
+ x8 ^= Integer.rotateLeft(x4 + x0, 9);
+ x12 ^= Integer.rotateLeft(x8 + x4, 13);
+ x0 ^= Integer.rotateLeft(x12 + x8, 18);
+ x9 ^= Integer.rotateLeft(x5 + x1, 7);
+ x13 ^= Integer.rotateLeft(x9 + x5, 9);
+ x1 ^= Integer.rotateLeft(x13 + x9, 13);
+ x5 ^= Integer.rotateLeft(x1 + x13, 18);
+ x14 ^= Integer.rotateLeft(x10 + x6, 7);
+ x2 ^= Integer.rotateLeft(x14 + x10, 9);
+ x6 ^= Integer.rotateLeft(x2 + x14, 13);
+ x10 ^= Integer.rotateLeft(x6 + x2, 18);
+ x3 ^= Integer.rotateLeft(x15 + x11, 7);
+ x7 ^= Integer.rotateLeft(x3 + x15, 9);
+ x11 ^= Integer.rotateLeft(x7 + x3, 13);
+ x15 ^= Integer.rotateLeft(x11 + x7, 18);
+
+ x1 ^= Integer.rotateLeft(x0 + x3, 7);
+ x2 ^= Integer.rotateLeft(x1 + x0, 9);
+ x3 ^= Integer.rotateLeft(x2 + x1, 13);
+ x0 ^= Integer.rotateLeft(x3 + x2, 18);
+ x6 ^= Integer.rotateLeft(x5 + x4, 7);
+ x7 ^= Integer.rotateLeft(x6 + x5, 9);
+ x4 ^= Integer.rotateLeft(x7 + x6, 13);
+ x5 ^= Integer.rotateLeft(x4 + x7, 18);
+ x11 ^= Integer.rotateLeft(x10 + x9, 7);
+ x8 ^= Integer.rotateLeft(x11 + x10, 9);
+ x9 ^= Integer.rotateLeft(x8 + x11, 13);
+ x10 ^= Integer.rotateLeft(x9 + x8, 18);
+ x12 ^= Integer.rotateLeft(x15 + x14, 7);
+ x13 ^= Integer.rotateLeft(x12 + x15, 9);
+ x14 ^= Integer.rotateLeft(x13 + x12, 13);
+ x15 ^= Integer.rotateLeft(x14 + x13, 18);
+ }
+
+ byte[] out = new byte[32];
+ store(out, 0, x0);
+ store(out, 4, x5);
+ store(out, 8, x10);
+ store(out, 12, x15);
+ store(out, 16, x6);
+ store(out, 20, x7);
+ store(out, 24, x8);
+ store(out, 28, x9);
+ return out;
+ }
+
+ private static int load(byte[] b, int off) {
+ return (b[off] & 0xff) | ((b[off + 1] & 0xff) << 8)
+ | ((b[off + 2] & 0xff) << 16) | ((b[off + 3] & 0xff) << 24);
+ }
+
+ private static void store(byte[] b, int off, int v) {
+ b[off] = (byte) v;
+ b[off + 1] = (byte) (v >>> 8);
+ b[off + 2] = (byte) (v >>> 16);
+ b[off + 3] = (byte) (v >>> 24);
+ }
+
+ // ---- crypto_pwhash (Argon2) -------------------------------------------
+
+ @Override
+ public byte[] pwHash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, int algorithm) {
+ return argon2(password, salt, length, opsLimit, memLimit, algorithm);
+ }
+
+ @Override
+ public String pwHashString(byte[] password, long opsLimit, long memLimit, int algorithm) {
+ byte[] salt = Random.randomBytesSecure(PWHASH_SALT_BYTES);
+ int memKiB = (int) (memLimit / 1024);
+ int ops = (int) opsLimit;
+ byte[] hash = argon2(password, salt, 32, opsLimit, memLimit, algorithm);
+
+ Base64.Encoder b64 = Base64.getEncoder().withoutPadding();
+ return "$" + argon2Name(algorithm) + "$v=19$m=" + memKiB + ",t=" + ops + ",p=1$"
+ + b64.encodeToString(salt) + "$" + b64.encodeToString(hash);
+ }
+
+ @Override
+ public boolean pwHashVerify(String hash, byte[] password) {
+ Phc phc = Phc.parse(hash);
+ if (phc == null)
+ return false;
+ byte[] expected = phc.hash;
+ byte[] actual = argon2(password, phc.salt, expected.length, phc.t,
+ (long) phc.m * 1024L, phc.algorithm);
+ return constantTimeAreEqual(actual, expected);
+ }
+
+ @Override
+ public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) {
+ Phc phc = Phc.parse(hash);
+ if (phc == null)
+ return true;
+ int memKiB = (int) (memLimit / 1024);
+ return phc.algorithm != PWHASH_ALG_ARGON2ID13 || phc.t != opsLimit || phc.m != memKiB || phc.p != 1;
+ }
+
+ @Override
+ public PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey secretKey,
+ @Nullable String ipAddress, @Nullable String hostName,
+ boolean enableWildcard) throws CryptoException {
+ try {
+ // Extract the 32-byte seed and public key from libsodium 64-byte SK
+ byte[] sk = secretKey.bytes();
+ byte[] seed = new byte[32];
+ System.arraycopy(sk, 0, seed, 0, 32);
+ byte[] pk = new byte[32];
+ System.arraycopy(sk, 32, pk, 0, 32);
+ String keyId = Base58.encode(pk);
+
+ // Build Bouncy Castle Ed25519 key parameters. The whole certificate is produced with the
+ // Bouncy Castle low-level API (no JCA provider), so callers do not have to register the BC
+ // JCE provider via Security.addProvider().
+ Ed25519PrivateKeyParameters privateKeyParams = new Ed25519PrivateKeyParameters(seed);
+ Ed25519PublicKeyParameters publicKeyParams = new Ed25519PublicKeyParameters(pk);
+
+ /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410)
+ // Convert to JCA PrivateKey / PublicKey via PKCS#8 v2 DER encoding (version=1, include public key)
+ // Encode to PKCS#8 DER, then load via JCA KeyFactory
+ byte[] pkcs8Bytes = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams).getEncoded();
+ */
+
+ // Encode the private key as PKCS#8 v1 DER (version=0, no public key).
+ // BC defaults to v2 (RFC 5958) for Ed25519 which Vert.x (Netty) rejects.
+ PrivateKeyInfo v2PrivateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams);
+ PrivateKeyInfo v1PrivateKeyInfo = new PrivateKeyInfo(
+ v2PrivateKeyInfo.getPrivateKeyAlgorithm(),
+ v2PrivateKeyInfo.parsePrivateKey()
+ );
+ byte[] pkcs8Bytes = v1PrivateKeyInfo.getEncoded();
+
+ SubjectPublicKeyInfo spki = SubjectPublicKeyInfoFactory.createSubjectPublicKeyInfo(publicKeyParams);
+
+ // Build a self-signed X.509 certificate
+ X500Name subject = new X500Name("CN=" + keyId);
+ BigInteger serial = new BigInteger(128, new SecureRandom());
+
+ // Subtract 10 minutes to handle clock skew
+ Instant now = Instant.now();
+ Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES));
+ Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS));
+
+ // Without SAN, modern browsers and most TLS clients REJECT the cert
+ // Chrome/Firefox dropped CN-only matching in 2017
+ List
+ * A {@code CryptoBox} instance is a precomputed shared key for a sender/receiver pair
+ * (libsodium {@code crypto_box_beforenm}); its {@link #encrypt} / {@link #decrypt} methods are
+ * the per-message {@code afternm} operations. Keys are provider-specific objects produced and
+ * consumed by the active {@link CryptoProvider}; callers obtain them through the static
+ * factories and treat them as opaque handles.
*/
-public class CryptoBox implements AutoCloseable, Destroyable {
+public interface CryptoBox extends AutoCloseable, Destroyable {
/**
* The Message Authentication Code size of the encrypted data in bytes.
*/
- public static final int MAC_BYTES = 16;
-
- private final Box box;
- private boolean destroyed = false;
+ int MAC_BYTES = CryptoProvider.BOX_MAC_BYTES;
/**
* The crypto box public key object.
*/
- public static class PublicKey implements Destroyable {
+ interface PublicKey extends Destroyable {
/**
* The number of bytes used to represent a public key.
*/
- public static final int BYTES = Box.PublicKey.length();
-
- private final Box.PublicKey key;
- private byte @Nullable [] bytes;
-
- private PublicKey(Box.PublicKey key) {
- this.key = key;
- }
+ int BYTES = CryptoProvider.BOX_PUBLIC_KEY_BYTES;
/**
* Create a {@link PublicKey} from an array of bytes.
- * The byte array must be of length {@link #BYTES}.
*
* @param key the bytes for the public key.
* @return the public key object.
+ * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long.
*/
- public static PublicKey fromBytes(byte[] key) {
- // no SodiumException raised
- return new PublicKey(Box.PublicKey.fromBytes(key));
+ static PublicKey fromBytes(byte[] key) {
+ if (Objects.requireNonNull(key, "key").length != BYTES)
+ throw new IllegalArgumentException("Invalid public key size: expected " + BYTES + " bytes, got " + key.length);
+
+ return provider().boxPublicKeyFromBytes(key);
}
/**
- * Transforms the Ed25519 signature public key to a Curve25519 public key. See
- * Libsodium documentation
+ * Transforms the Ed25519 signature public key to a Curve25519 public key.
*
* @param key the signature public key.
* @return the public key as a Curve25519 public key.
*/
- public static PublicKey fromSignatureKey(Signature.PublicKey key) {
- return new PublicKey(Box.PublicKey.forSignaturePublicKey(key.raw()));
- }
-
- Box.PublicKey raw() {
- return key;
+ static PublicKey fromSignatureKey(Signature.PublicKey key) {
+ return provider().signPublicKeyToBoxPublicKey(Objects.requireNonNull(key));
}
/**
@@ -93,99 +79,60 @@ Box.PublicKey raw() {
*
* @return the raw bytes of this key.
*/
- public byte[] bytes() {
- if (bytes == null)
- bytes = key.bytesArray();
-
- return bytes.clone();
- }
-
- @Override
- public boolean equals(Object obj) {
- if (obj == this)
- return true;
+ byte[] bytes();
- if (obj instanceof PublicKey that)
- return key.equals(that.key);
-
- return false;
- }
-
- @Override
- public int hashCode() {
- return 0x6030A + key.hashCode();
- }
-
- /**
- * Destroy this {@code PublicKey}.
- * Sensitive information associated with this {@code PublicKey}
- * is destroyed or cleared.
- */
@Override
- public void destroy() {
- if (!key.isDestroyed()) {
- key.destroy();
-
- if (bytes != null) {
- Arrays.fill(bytes, (byte)0);
- bytes = null;
- }
- }
- }
+ void destroy();
- /**
- * Determine if this {@code PublicKey} has been destroyed.
- *
- * @return true if this {@code PublicKey} has been destroyed,
- * false otherwise.
- */
@Override
- public boolean isDestroyed() {
- return key.isDestroyed();
- }
+ boolean isDestroyed();
}
/**
* The crypto box private key object.
*/
- public static class PrivateKey implements Destroyable {
+ interface PrivateKey extends Destroyable {
/**
- * The number of bytes used to represent a public key.
+ * The number of bytes used to represent a private key.
*/
- public static final int BYTES = Box.SecretKey.length();
+ int BYTES = CryptoProvider.BOX_SECRET_KEY_BYTES;
- private final Box.SecretKey key;
- private byte @Nullable [] bytes;
+ /**
+ * Generate a {@link PrivateKey} from a seed (libsodium {@code crypto_box_seed_keypair}).
+ *
+ * @param seed the {@link KeyPair#SEED_BYTES}-byte seed.
+ * @return the private key.
+ * @throws IllegalArgumentException if {@code seed} is not {@link KeyPair#SEED_BYTES} bytes long.
+ */
+ static PrivateKey fromSeed(byte[] seed) {
+ if (Objects.requireNonNull(seed, "seed").length != KeyPair.SEED_BYTES)
+ throw new IllegalArgumentException("Invalid seed size: expected " + KeyPair.SEED_BYTES + " bytes, got " + seed.length);
- private PrivateKey(Box.SecretKey key) {
- this.key = key;
+ return provider().boxSecretKeyFromSeed(seed);
}
/**
* Create a {@link PrivateKey} from an array of bytes.
- * The byte array must be of length {@link #BYTES}.
*
* @param key the bytes for the private key.
* @return the private key.
+ * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long.
*/
- public static PrivateKey fromBytes(byte[] key) {
- // no SodiumException raised
- return new PrivateKey(Box.SecretKey.fromBytes(key));
+ static PrivateKey fromBytes(byte[] key) {
+ if (Objects.requireNonNull(key, "key").length != BYTES)
+ throw new IllegalArgumentException("Invalid private key size: expected " + BYTES + " bytes, got " + key.length);
+
+ return provider().boxSecretKeyFromBytes(key);
}
/**
- * Transforms the Ed25519 private key to a Curve25519 private key. See
- * Libsodium documentation
+ * Transforms the Ed25519 private key to a Curve25519 private key.
*
* @param key the signature secret key
* @return the secret key as a Curve25519 private key
*/
- public static PrivateKey fromSignatureKey(Signature.PrivateKey key) {
- return new PrivateKey(Box.SecretKey.forSignatureSecretKey(key.raw()));
- }
-
- Box.SecretKey raw() {
- return key;
+ static PrivateKey fromSignatureKey(Signature.PrivateKey key) {
+ return provider().signSecretKeyToBoxSecretKey(Objects.requireNonNull(key));
}
/**
@@ -193,74 +140,30 @@ Box.SecretKey raw() {
*
* @return the raw bytes of this secret key.
*/
- public byte[] bytes() {
- if (bytes == null)
- bytes = key.bytesArray();
-
- return bytes.clone();
- }
-
- @Override
- public boolean equals(Object obj) {
- if (obj == this)
- return true;
+ byte[] bytes();
- if (obj instanceof PrivateKey that)
- return key.equals(that.key);
-
- return false;
- }
-
- @Override
- public int hashCode() {
- return 0x6030A + key.hashCode();
- }
-
- /**
- * Destroy this {@code PrivateKey}.
- * Sensitive information associated with this {@code PrivateKey}
- * is destroyed or cleared.
- */
@Override
- public void destroy() {
- if (!key.isDestroyed()) {
- key.destroy();
-
- if (bytes != null) {
- Arrays.fill(bytes, (byte)0);
- bytes = null;
- }
- }
- }
+ void destroy();
- /**
- * Determine if this {@code PrivateKey} has been destroyed.
- *
- * @return true if this {@code PrivateKey} has been destroyed,
- * false otherwise.
- */
@Override
- public boolean isDestroyed() {
- return key.isDestroyed();
- }
+ boolean isDestroyed();
}
/**
* The crypto box key pair.
*/
- public static class KeyPair implements Destroyable {
+ class KeyPair implements Destroyable {
/**
* The seed length in bytes.
*/
- public static final int SEED_BYTES = Seed.length();
+ public static final int SEED_BYTES = CryptoProvider.BOX_SEED_BYTES;
- private final Box.KeyPair keyPair;
- private @Nullable PublicKey pk;
- private @Nullable PrivateKey sk;
- private boolean destroyed = false;
+ private final PublicKey pk;
+ private final PrivateKey sk;
- private KeyPair(Box.KeyPair keyPair) {
- this.keyPair = keyPair;
+ private KeyPair(PrivateKey sk) {
+ this.sk = sk;
+ this.pk = provider().boxPublicKeyFromSecretKey(sk);
}
/**
@@ -270,9 +173,7 @@ private KeyPair(Box.KeyPair keyPair) {
* @return the key pair object.
*/
public static KeyPair fromPrivateKey(byte[] privateKey) {
- Box.SecretKey sk = Box.SecretKey.fromBytes(privateKey);
- // Normally, should never raise Exception
- return new KeyPair(Box.KeyPair.forSecretKey(sk));
+ return new KeyPair(PrivateKey.fromBytes(privateKey));
}
/**
@@ -282,35 +183,31 @@ public static KeyPair fromPrivateKey(byte[] privateKey) {
* @return the key pair object.
*/
public static KeyPair fromPrivateKey(PrivateKey key) {
- // Normally, should never raise Exception
- return new KeyPair(Box.KeyPair.forSecretKey(key.raw()));
+ return new KeyPair(key);
}
/**
- * Generate a new key pair using a seed.
- * The seed must be of length {@link #SEED_BYTES}.
+ * Generate a new key pair using a seed (libsodium {@code crypto_box_seed_keypair}).
*
- * @param seed the seed bytes.
+ * @param seed the {@link #SEED_BYTES}-byte seed.
* @return the new generated key pair.
+ * @throws IllegalArgumentException if {@code seed} is not {@link #SEED_BYTES} bytes long.
*/
public static KeyPair fromSeed(byte[] seed) {
- Box.Seed sd = Box.Seed.fromBytes(seed);
- // Normally, should never raise Exception
- return new KeyPair(Box.KeyPair.fromSeed(sd));
+ if (Objects.requireNonNull(seed, "seed").length != SEED_BYTES)
+ throw new IllegalArgumentException("Invalid seed size: expected " + SEED_BYTES + " bytes, got " + seed.length);
+
+ return new KeyPair(PrivateKey.fromSeed(seed));
}
/**
- * Converts signature key pair (Ed25519) to a box key pair (Curve25519)
- * so that the same key pair can be used both for authenticated encryption
- * and for signatures. See
- * Libsodium documentation
+ * Converts a signature key pair (Ed25519) to a box key pair (Curve25519).
*
- * @param keyPair A {@link Signature.KeyPair}.
+ * @param keyPair a {@link Signature.KeyPair}.
* @return the new generated box key pair.
*/
- public static KeyPair fromSignatureKeyPair(Signature.KeyPair keyPair) {
- // Normally, should never raise Exception
- return new KeyPair(Box.KeyPair.forSignatureKeyPair(keyPair.raw()));
+ public static KeyPair fromSignatureKeyPair(Signature.KeyPair keyPair) {
+ return new KeyPair(PrivateKey.fromSignatureKey(keyPair.privateKey()));
}
/**
@@ -319,12 +216,7 @@ public static KeyPair fromSignatureKeyPair(Signature.KeyPair keyPair) {
* @return a randomly generated key pair.
*/
public static KeyPair random() {
- // Normally, should never raise Exception
- return new KeyPair(Box.KeyPair.random());
- }
-
- Box.KeyPair raw() {
- return keyPair;
+ return new KeyPair(PrivateKey.fromBytes(Random.randomBytesSecure(SEED_BYTES)));
}
/**
@@ -333,9 +225,6 @@ Box.KeyPair raw() {
* @return the public key of the key pair.
*/
public PublicKey publicKey() {
- if (pk == null)
- pk = new PublicKey(keyPair.publicKey());
-
return pk;
}
@@ -345,9 +234,6 @@ public PublicKey publicKey() {
* @return the private key of the key pair.
*/
public PrivateKey privateKey() {
- if (sk == null)
- sk = new PrivateKey(keyPair.secretKey());
-
return sk;
}
@@ -357,14 +243,14 @@ public boolean equals(Object obj) {
return true;
if (obj instanceof KeyPair that)
- return keyPair.equals(that.keyPair);
+ return sk.equals(that.sk) && pk.equals(that.pk);
return false;
}
@Override
public int hashCode() {
- return 0x6030A + keyPair.hashCode();
+ return Objects.hash(sk, pk);
}
/**
@@ -372,11 +258,8 @@ public int hashCode() {
*/
@Override
public void destroy() {
- if (!destroyed) {
- publicKey().destroy();
- privateKey().destroy();
- destroyed = true;
- }
+ pk.destroy();
+ sk.destroy();
}
/**
@@ -386,35 +269,31 @@ public void destroy() {
*/
@Override
public boolean isDestroyed() {
- return destroyed;
+ return sk.isDestroyed();
}
}
/**
* The nonce object for the crypto box encryption.
*/
- public static class Nonce {
+ interface Nonce {
/**
- * The number of bytes used to represent a public key.
+ * The number of bytes used to represent a nonce.
*/
- public static final int BYTES = Box.Nonce.length();
-
- private final Box.Nonce nonce;
- private byte @Nullable [] bytes;
-
- private Nonce(Box.Nonce nonce) {
- this.nonce = nonce;
- }
+ public static final int BYTES = CryptoProvider.BOX_NONCE_BYTES;
/**
* Create a Nonce object from an array of bytes.
- * The byte array must be of length {@link #BYTES}.
*
* @param nonce the bytes for the nonce.
* @return a nonce object based on these bytes.
+ * @throws IllegalArgumentException if {@code nonce} is not {@link #BYTES} bytes long.
*/
- public static Nonce fromBytes(byte[] nonce) {
- return new Nonce(Box.Nonce.fromBytes(nonce));
+ static Nonce fromBytes(byte[] nonce) {
+ if (Objects.requireNonNull(nonce, "nonce").length != BYTES)
+ throw new IllegalArgumentException("Invalid nonce size: expected " + BYTES + " bytes, got " + nonce.length);
+
+ return provider().boxNonceFromBytes(nonce);
}
/**
@@ -422,8 +301,8 @@ public static Nonce fromBytes(byte[] nonce) {
*
* @return a randomly generated nonce.
*/
- public static Nonce random() {
- return new Nonce(Box.Nonce.random());
+ static Nonce random() {
+ return provider().boxNonceFromBytes(Random.randomBytesSecure(BYTES));
}
/**
@@ -431,198 +310,169 @@ public static Nonce random() {
*
* @return a zero nonce object.
*/
- public static Nonce zero() {
- return new Nonce(Box.Nonce.zero());
+ static Nonce zero() {
+ return provider().boxNonceFromBytes(new byte[BYTES]);
}
- Box.Nonce raw() {
- return nonce;
- }
/**
* Increment this nonce.
*
*
- * Note that this is not synchronized. If multiple threads are creating
- * encrypted messages and incrementing this nonce, then external synchronization
- * is required to ensure no two encrypt operations use the same nonce.
+ * The nonce is treated as a little-endian integer and incremented by one, matching
+ * libsodium's {@code sodium_increment}.
*
* @return A new nonce object.
*/
- public Nonce increment() {
- return new Nonce(nonce.increment());
- }
+ Nonce increment();
/**
* Provides the bytes of this nonce object.
*
* @return The bytes of this nonce.
*/
- public byte[] bytes() {
- if (bytes == null)
- bytes = nonce.bytesArray();
-
- return bytes.clone();
- }
-
- @Override
- public boolean equals(Object obj) {
- if (obj == this)
- return true;
-
- if (obj instanceof Nonce that)
- return nonce.equals(that.nonce);
-
- return false;
- }
-
- @Override
- public int hashCode() {
- return 0x6030A + nonce.hashCode();
- }
- }
-
- private CryptoBox(Box box) {
- this.box = box;
+ byte[] bytes();
}
/**
* Precompute the shared key for a given sender and receiver.
*
*
- * Note that the returned instance of CryptoBox should be closed using
- * {@link #close()} (or try-with-resources) to ensure timely release of the shared key,
- * which is held in native memory.
+ * Note that the returned instance should be closed using {@link #close()} (or
+ * try-with-resources) to release the shared key.
*
* @param pk the public key of the receiver.
* @param sk the secret key of the sender.
* @return a precomputed crypto box instance.
*/
- public static CryptoBox fromKeys(PublicKey pk, PrivateKey sk) {
+ static CryptoBox fromKeys(PublicKey pk, PrivateKey sk) {
Objects.requireNonNull(pk);
Objects.requireNonNull(sk);
- return new CryptoBox(Box.forKeys(pk.raw(), sk.raw()));
+ return provider().boxBeforeNm(pk, sk);
}
/**
- * Encrypt a message with this precomputed box.
+ * Encrypt a message with the given keys.
*
- * @param message the message to encrypt.
- * @param nonce a unique nonce object.
+ * @param message the message to encrypt. Must not be null.
+ * @param receiver the public key of the receiver. Must not be null.
+ * @param sender the private key of the sender. Must not be null.
+ * @param nonce a unique nonce object. Must not be null.
* @return the encrypted data.
+ * @throws NullPointerException if any argument is null.
*/
- public byte[] encrypt(byte[] message, Nonce nonce) {
- return box.encrypt(message, nonce.raw());
+ static byte[] encrypt(byte[] message, PublicKey receiver, PrivateKey sender, Nonce nonce) {
+ Objects.requireNonNull(message, "message");
+ Objects.requireNonNull(receiver, "receiver");
+ Objects.requireNonNull(sender, "sender");
+ Objects.requireNonNull(nonce, "nonce");
+ return provider().boxEncrypt(message, nonce, receiver, sender);
}
/**
- * Encrypt a message with the given keys
+ * Decrypt a message using the given keys.
*
- * @param message the message to encrypt.
- * @param receiver the public key of the receiver.
- * @param sender the private key of the sender.
- * @param nonce a unique nonce object.
- * @return the encrypted data.
+ * @param cipher the cipher text to decrypt. Must not be null.
+ * @param sender the public key of the sender. Must not be null.
+ * @param receiver the private key of the receiver. Must not be null.
+ * @param nonce the nonce that was used for encryption. Must not be null.
+ * @return the decrypted data.
+ * @throws NullPointerException if any argument is null.
+ * @throws CryptoException if the verification or decryption failed.
*/
- public static byte[] encrypt(byte[] message, PublicKey receiver, PrivateKey sender, Nonce nonce) {
- return Box.encrypt(message, receiver.raw(), sender.raw(), nonce.raw());
+ static byte[] decrypt(byte[] cipher, PublicKey sender, PrivateKey receiver, Nonce nonce) throws CryptoException {
+ Objects.requireNonNull(cipher, "cipher");
+ Objects.requireNonNull(sender, "sender");
+ Objects.requireNonNull(receiver, "receiver");
+ Objects.requireNonNull(nonce, "nonce");
+ byte[] plain = provider().boxDecrypt(cipher, nonce, sender, receiver);
+ if (plain == null)
+ throw new CryptoException("Decryption failed: invalid ciphertext or authentication failure");
+
+ return plain;
}
/**
* Encrypt a sealed message for a given key.
*
* Sealed boxes are designed to anonymously send messages to a recipient given its public key.
- * Only the recipient can decrypt these messages, using its private key. While
- * the recipient can verify the integrity of the message, it cannot verify
- * the identity of the sender.
- *
- * A message is encrypted using an ephemeral key pair, whose secret part is destroyed
- * right after the encryption process. Without knowing the secret key used for a given
- * message, the sender cannot decrypt its own message later. And without additional data,
- * a message cannot be correlated with the identity of its sender.
- *
+ * Keys and nonces are represented as provider-specific objects ({@link Signature.PublicKey},
+ * {@link Signature.PrivateKey}, {@link CryptoBox.PublicKey}, {@link CryptoBox.PrivateKey},
+ * {@link CryptoBox.Nonce}, and the precomputed {@link CryptoBox} itself), so a backend can keep
+ * its native representation across calls; messages, ciphertexts, hashes and salts are plain
+ * {@code byte[]}. The public wrapper classes ({@link Signature}, {@link CryptoBox},
+ * {@link PasswordHash}) delegate to the active provider without exposing any implementation type.
+ * The default backend is the pure-Java {@link BouncyCastleCryptoProvider}; an alternative backend
+ * (for example a future JNI binding to libsodium) can be supplied through the
+ * {@link java.util.ServiceLoader} mechanism, discovered by {@link CryptoProviders}.
+ *
+ * A key object is owned by the provider that created it. A provider that is handed a foreign key
+ * object (for example after the active provider was swapped) must still accept it by reconstructing
+ * from its raw {@link Signature.PublicKey#bytes() bytes}. Once a key object has been destroyed it
+ * must reject further use rather than read freed or zeroed material. The one exception to the
+ * foreign-object fallback is the precomputed {@link CryptoBox}: it exposes no shared-key bytes (a
+ * native backend may keep the key only in native memory), so it cannot be reconstructed from a
+ * foreign instance - {@link #boxEncrypt(byte[], CryptoBox.Nonce, CryptoBox)} and
+ * {@link #boxDecrypt(byte[], CryptoBox.Nonce, CryptoBox)} require a box created by the same provider
+ * and reject one from another.
+ *
+ * Every implementation must be byte-for-byte compatible with the libsodium constructions:
+ * Ed25519 detached signatures, {@code crypto_kdf} (keyed BLAKE2b), {@code crypto_box}
+ * (X25519 + HSalsa20 key derivation + XSalsa20-Poly1305), the Ed25519 to Curve25519 key
+ * conversions, sealed boxes, and {@code crypto_pwhash} (Argon2). Secret keys use the libsodium
+ * 64-byte layout (32-byte seed followed by the 32-byte public key).
+ *
+ * Side-channels: implementations MUST compare secret material - private keys,
+ * MAC tags and password hashes - in constant time (for example
+ * {@code org.bouncycastle.util.Arrays.constantTimeAreEqual}). Public values such as public keys
+ * and nonces may use ordinary equality.
+ *
+ * Argument validation and errors: the public wrapper layer ({@link Signature},
+ * {@link CryptoBox}, {@link PasswordHash}) validates every caller-supplied argument - null checks,
+ * key/nonce/salt sizes and value ranges - before dispatching to this interface. Implementations may
+ * therefore assume non-null, correctly-sized inputs and are not expected to re-check them.
+ * Implementations also never throw a checked exception: an authentication or decryption failure is
+ * reported by returning {@code null} (see {@link #boxDecrypt} and {@link #boxSealOpen}), which the
+ * wrapper translates into a checked {@link CryptoException}. Keeping providers free of the Boson
+ * exception hierarchy keeps a backend a pure cryptographic mechanism.
+ */
+public interface CryptoProvider {
+ /** Length in bytes of an Ed25519 seed. */
+ int SIGN_SEED_BYTES = 32;
+ /** Length in bytes of an Ed25519 secret key (seed || public key). */
+ int SIGN_SECRET_KEY_BYTES = 64;
+ /** Length in bytes of an Ed25519 public key. */
+ int SIGN_PUBLIC_KEY_BYTES = 32;
+ /** Length in bytes of an Ed25519 signature. */
+ int SIGN_BYTES = 64;
+ /** Length in bytes of the {@code crypto_kdf} derivation context. */
+ int KDF_CONTEXT_BYTES = 8;
+ /** Length in bytes of a Curve25519 (crypto_box) seed. */
+ int BOX_SEED_BYTES = 32;
+ /** Length in bytes of a Curve25519 (crypto_box) public key. */
+ int BOX_PUBLIC_KEY_BYTES = 32;
+ /** Length in bytes of a Curve25519 (crypto_box) secret key. */
+ int BOX_SECRET_KEY_BYTES = 32;
+ /** Length in bytes of a precomputed crypto_box shared key. */
+ int BOX_SHARED_KEY_BYTES = 32;
+ /** Length in bytes of a crypto_box nonce. */
+ int BOX_NONCE_BYTES = 24;
+ /** Length in bytes of the crypto_box message authentication code. */
+ int BOX_MAC_BYTES = 16;
+ /** Length in bytes of a crypto_pwhash salt. */
+ int PWHASH_SALT_BYTES = 16;
+
+ /** Argon2i (version 1.3) algorithm id, matching {@code crypto_pwhash_ALG_ARGON2I13}. */
+ int PWHASH_ALG_ARGON2I13 = 1;
+ /** Argon2id (version 1.3) algorithm id, matching {@code crypto_pwhash_ALG_ARGON2ID13}. */
+ int PWHASH_ALG_ARGON2ID13 = 2;
+
+ /**
+ * A short, human-readable identifier for this provider (for example {@code "bc"} or
+ * {@code "libsodium"}).
+ *
+ * @return the provider name.
+ */
+ String name();
+
+ // ---- Ed25519 ----------------------------------------------------------
+
+ /**
+ * Creates an Ed25519 secret key from a 32-byte seed (libsodium {@code crypto_sign_seed_keypair}).
+ *
+ * @param seed the {@value #SIGN_SEED_BYTES}-byte seed.
+ * @return the secret key.
+ */
+ Signature.PrivateKey ed25519SecretKeyFromSeed(byte[] seed);
+
+ /**
+ * Creates an Ed25519 secret key from its {@value #SIGN_SECRET_KEY_BYTES}-byte encoding
+ * (seed followed by public key).
+ *
+ * @param secretKey the {@value #SIGN_SECRET_KEY_BYTES}-byte secret key.
+ * @return the secret key.
+ */
+ Signature.PrivateKey ed25519SecretKeyFromBytes(byte[] secretKey);
+
+ /**
+ * Derives the Ed25519 public key for the given secret key.
+ *
+ * @param secretKey the secret key.
+ * @return the public key.
+ */
+ Signature.PublicKey ed25519PublicKeyFromSecretKey(Signature.PrivateKey secretKey);
+
+ /**
+ * Creates an Ed25519 public key from its {@value #SIGN_PUBLIC_KEY_BYTES}-byte encoding.
+ *
+ * @param bytes the {@value #SIGN_PUBLIC_KEY_BYTES}-byte public key.
+ * @return the public key.
+ */
+ Signature.PublicKey ed25519PublicKeyFromBytes(byte[] bytes);
+
+ /**
+ * Computes a detached Ed25519 signature.
+ *
+ * @param message the message to sign.
+ * @param secretKey the secret key.
+ * @return the {@value #SIGN_BYTES}-byte signature.
+ */
+ byte[] ed25519Sign(byte[] message, Signature.PrivateKey secretKey);
+
+ /**
+ * Verifies a detached Ed25519 signature.
+ *
+ * @param message the message.
+ * @param signature the {@value #SIGN_BYTES}-byte signature.
+ * @param publicKey the public key.
+ * @return true if the signature is valid.
+ */
+ boolean ed25519Verify(byte[] message, byte[] signature, Signature.PublicKey publicKey);
+
+ // ---- crypto_kdf (keyed BLAKE2b) ---------------------------------------
+
+ /**
+ * Derives a sub-key from a master key using libsodium's {@code crypto_kdf} construction.
+ *
+ * @param masterKey the 32-byte master key.
+ * @param subKeyId the sub-key identifier.
+ * @param context the {@value #KDF_CONTEXT_BYTES}-byte context.
+ * @param subKeyLength the length of the derived sub-key.
+ * @return the derived sub-key.
+ */
+ byte[] kdfDeriveFromKey(byte[] masterKey, long subKeyId, byte[] context, int subKeyLength);
+
+ // ---- Ed25519 -> Curve25519 conversions --------------------------------
+
+ /**
+ * Converts an Ed25519 public key to a Curve25519 (crypto_box) public key.
+ *
+ * @param publicKey the Ed25519 public key.
+ * @return the Curve25519 public key.
+ */
+ CryptoBox.PublicKey signPublicKeyToBoxPublicKey(Signature.PublicKey publicKey);
+
+ /**
+ * Converts an Ed25519 secret key to a Curve25519 (crypto_box) secret key.
+ *
+ * @param secretKey the Ed25519 secret key.
+ * @return the Curve25519 secret key.
+ */
+ CryptoBox.PrivateKey signSecretKeyToBoxSecretKey(Signature.PrivateKey secretKey);
+
+ // ---- crypto_box -------------------------------------------------------
+
+ /**
+ * Creates a Curve25519 (crypto_box) secret key from a seed (libsodium
+ * {@code crypto_box_seed_keypair}).
+ *
+ * @param seed the {@value #BOX_SEED_BYTES}-byte seed.
+ * @return the secret key object.
+ */
+ CryptoBox.PrivateKey boxSecretKeyFromSeed(byte[] seed);
+
+ /**
+ * Creates a Curve25519 (crypto_box) public key from raw bytes.
+ *
+ * @param bytes the 32-byte public key.
+ * @return the public key object.
+ */
+ CryptoBox.PublicKey boxPublicKeyFromBytes(byte[] bytes);
+
+ /**
+ * Creates a Curve25519 (crypto_box) secret key from raw bytes.
+ *
+ * @param bytes the 32-byte secret key.
+ * @return the secret key object.
+ */
+ CryptoBox.PrivateKey boxSecretKeyFromBytes(byte[] bytes);
+
+ /**
+ * Derives the Curve25519 public key for a given Curve25519 secret key.
+ *
+ * @param secretKey the secret key.
+ * @return the public key.
+ */
+ CryptoBox.PublicKey boxPublicKeyFromSecretKey(CryptoBox.PrivateKey secretKey);
+
+ /**
+ * Creates a crypto_box nonce from its {@value #BOX_NONCE_BYTES}-byte value.
+ *
+ * @param bytes the {@value #BOX_NONCE_BYTES}-byte nonce.
+ * @return the nonce object.
+ */
+ CryptoBox.Nonce boxNonceFromBytes(byte[] bytes);
+
+ /**
+ * Precomputes the shared key for a sender/receiver key pair (libsodium {@code beforenm}),
+ * returning a {@link CryptoBox} whose {@link CryptoBox#encrypt}/{@link CryptoBox#decrypt}
+ * are the per-message {@code afternm} operations.
+ *
+ * @param publicKey the peer public key.
+ * @param secretKey the own secret key.
+ * @return the precomputed crypto box.
+ */
+ CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey);
+
+ /**
+ * Encrypts a message with a precomputed shared key (libsodium {@code crypto_box_easy_afternm}).
+ *
+ * Unlike a key object, a {@link CryptoBox} does not expose its shared key as bytes, so it cannot
+ * be reconstructed from a foreign instance: {@code box} must be one returned by this provider's
+ * {@link #boxBeforeNm}. Implementations should reject a box from another provider.
+ *
+ * @param message the plaintext.
+ * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce.
+ * @param box a precomputed crypto box created by this provider's {@link #boxBeforeNm}.
+ * @return the ciphertext (MAC prepended).
+ */
+ byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox box);
+
+ /**
+ * Decrypts a message with a precomputed shared key (libsodium {@code crypto_box_open_easy_afternm}).
+ *
+ * As with {@link #boxEncrypt(byte[], CryptoBox.Nonce, CryptoBox)}, {@code box} must be one returned
+ * by this provider's {@link #boxBeforeNm}; implementations should reject a box from another provider.
+ *
+ * @param cipher the ciphertext.
+ * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce.
+ * @param box a precomputed crypto box created by this provider's {@link #boxBeforeNm}.
+ * @return the plaintext, or {@code null} if authentication failed.
+ */
+ byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox box);
+
+ /**
+ * Encrypts a message with explicit keys (libsodium {@code crypto_box_easy}).
+ *
+ * @param message the plaintext.
+ * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce.
+ * @param publicKey the receiver's public key.
+ * @param secretKey the sender's secret key.
+ * @return the ciphertext (MAC prepended).
+ */
+ byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey);
+
+ /**
+ * Decrypts a message with explicit keys (libsodium {@code crypto_box_open_easy}).
+ *
+ * @param cipher the ciphertext.
+ * @param nonce the {@value #BOX_NONCE_BYTES}-byte nonce.
+ * @param publicKey the sender's public key.
+ * @param secretKey the receiver's secret key.
+ * @return the plaintext, or {@code null} if authentication failed.
+ */
+ byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey);
+
+ /**
+ * Encrypts an anonymous sealed box for a recipient (libsodium {@code crypto_box_seal}).
+ *
+ * @param message the plaintext.
+ * @param publicKey the recipient's public key.
+ * @return the sealed ciphertext (ephemeral public key prepended).
+ */
+ byte[] boxSeal(byte[] message, CryptoBox.PublicKey publicKey);
+
+ /**
+ * Opens an anonymous sealed box (libsodium {@code crypto_box_seal_open}).
+ *
+ * @param cipher the sealed ciphertext.
+ * @param publicKey the recipient's public key.
+ * @param secretKey the recipient's secret key.
+ * @return the plaintext, or {@code null} if authentication failed.
+ */
+ byte @Nullable [] boxSealOpen(byte[] cipher, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey);
+
+ // ---- crypto_pwhash (Argon2) -------------------------------------------
+
+ /**
+ * Derives a key from a password (libsodium {@code crypto_pwhash}).
+ *
+ * @param password the password bytes.
+ * @param length the derived key length.
+ * @param salt the {@value #PWHASH_SALT_BYTES}-byte salt.
+ * @param opsLimit the operations limit.
+ * @param memLimit the memory limit in bytes.
+ * @param algorithm the algorithm id ({@link #PWHASH_ALG_ARGON2I13} or {@link #PWHASH_ALG_ARGON2ID13}).
+ * @return the derived key.
+ */
+ byte[] pwHash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, int algorithm);
+
+ /**
+ * Hashes a password into an encoded, self-describing PHC string (libsodium
+ * {@code crypto_pwhash_str}). The string embeds the algorithm, parameters and salt.
+ *
+ * @param password the password bytes.
+ * @param opsLimit the operations limit.
+ * @param memLimit the memory limit in bytes.
+ * @param algorithm the algorithm id.
+ * @return the encoded PHC hash string.
+ */
+ String pwHashString(byte[] password, long opsLimit, long memLimit, int algorithm);
+
+ /**
+ * Verifies a password against an encoded PHC hash string (libsodium {@code crypto_pwhash_str_verify}).
+ *
+ * @param hash the encoded PHC hash string.
+ * @param password the password bytes.
+ * @return true if the password matches.
+ */
+ boolean pwHashVerify(String hash, byte[] password);
+
+ /**
+ * Determines whether an encoded PHC hash string should be recomputed for the given limits
+ * (libsodium {@code crypto_pwhash_str_needs_rehash}).
+ *
+ * @param hash the encoded PHC hash string.
+ * @param opsLimit the target operations limit.
+ * @param memLimit the target memory limit in bytes.
+ * @return true if the hash should be regenerated.
+ */
+ boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit);
+
+ // ---- PEM certification -------------------------------------------
+
+ /**
+ * Generates a self-signed Ed25519 X.509 certificate and private key from a signature private key.
+ *
+ * At least one Subject Alternative Name (SAN) entry must be produced: if both {@code ipAddress}
+ * and {@code hostName} are {@code null} the implementation throws {@link IllegalArgumentException}.
+ *
+ * @param privateKey the signature private key
+ * @param ipAddress the IP address to include in the Subject Alternative Name (SAN), or
+ * {@code null} to omit an IP SAN entry
+ * @param hostName the host name to include in the Subject Alternative Name (SAN), or
+ * {@code null} to omit a DNS SAN entry
+ * @param enableWildcard whether to include a wildcard host name in the SAN
+ * @return a {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key
+ * @throws CryptoException if an error occurs during key conversion or certificate generation
+ */
+ PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey privateKey, @Nullable String ipAddress,
+ @Nullable String hostName, boolean enableWildcard) throws CryptoException;
+}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java
new file mode 100644
index 00000000..b72baac2
--- /dev/null
+++ b/api/src/main/java/io/bosonnetwork/crypto/CryptoProviders.java
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2023 - bosonnetwork.io
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.bosonnetwork.crypto;
+
+import java.util.Objects;
+import java.util.ServiceLoader;
+
+/**
+ * Resolves and holds the active {@link CryptoProvider}.
+ *
+ * The provider is discovered once via the {@link ServiceLoader} mechanism (allowing an
+ * alternative backend, such as a future JNI binding, to register through a
+ * {@code META-INF/services/io.bosonnetwork.crypto.CryptoProvider} entry). When no provider is
+ * registered, the built-in pure-Java {@link BouncyCastleCryptoProvider} is used.
+ */
+public final class CryptoProviders {
+ private static volatile CryptoProvider current = resolve();
+
+ private CryptoProviders() {
+ }
+
+ /**
+ * Returns the active crypto provider.
+ *
+ * @return the active {@link CryptoProvider}.
+ */
+ public static CryptoProvider getDefault() {
+ return current;
+ }
+
+ /**
+ * Overrides the active crypto provider. Package-private: intended for the compatibility
+ * test suite to run the wrapper classes against an alternative backend.
+ *
+ * @param provider the provider to activate.
+ */
+ static void setDefault(CryptoProvider provider) {
+ current = Objects.requireNonNull(provider, "provider");
+ }
+
+ private static CryptoProvider resolve() {
+ return ServiceLoader.load(CryptoProvider.class)
+ .findFirst()
+ .orElseGet(BouncyCastleCryptoProvider::new);
+ }
+}
diff --git a/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java b/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java
deleted file mode 100644
index 51b926c3..00000000
--- a/api/src/main/java/io/bosonnetwork/crypto/CryptoUtil.java
+++ /dev/null
@@ -1,443 +0,0 @@
-/*
- * Copyright (c) 2023 - bosonnetwork.io
- *
- * Permission is hereby granted, free of charge, to any person obtaining a copy
- * of this software and associated documentation files (the "Software"), to deal
- * in the Software without restriction, including without limitation the rights
- * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
- * copies of the Software, and to permit persons to whom the Software is
- * furnished to do so, subject to the following conditions:
- *
- * The above copyright notice and this permission notice shall be included in all
- * copies or substantial portions of the Software.
- *
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
- * SOFTWARE.
- */
-
-package io.bosonnetwork.crypto;
-
-import java.io.ByteArrayInputStream;
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.math.BigInteger;
-import java.net.InetAddress;
-import java.nio.charset.StandardCharsets;
-import java.security.KeyFactory;
-import java.security.KeyStore;
-import java.security.KeyStoreException;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
-import java.security.PrivateKey;
-import java.security.SecureRandom;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateException;
-import java.security.cert.CertificateFactory;
-import java.security.cert.X509Certificate;
-import java.security.spec.InvalidKeySpecException;
-import java.security.spec.PKCS8EncodedKeySpec;
-import java.text.SimpleDateFormat;
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.time.temporal.ChronoUnit;
-import java.util.Base64;
-import java.util.Date;
-import java.util.TimeZone;
-
-import io.vertx.core.buffer.Buffer;
-import io.vertx.core.net.PfxOptions;
-
-import org.jspecify.annotations.Nullable;
-
-import io.bosonnetwork.BosonException;
-import io.bosonnetwork.utils.Base58;
-
-/**
- * Utility class for certificate and key management.
- */
-public class CryptoUtil {
- /**
- * Represents a pair of PEM-encoded certificate and private key.
- *
- * @param cert the PEM-encoded certificate
- * @param privateKey the PEM-encoded private key
- */
- public record PemCertificateAndKey(String cert, String privateKey) {
- }
-
- /**
- * Generates a self-signed X.509 certificate and private key from a signature private key without Bouncy Castle.
- *
- * @param signaturePrivateKey the signature private key
- * @param ipAddress the IP address to include in the Subject Alternative Name (SAN), or
- * {@code null} to omit an IP SAN entry
- * @param hostName the host name to include in the Subject Alternative Name (SAN), or
- * {@code null} to omit a DNS SAN entry
- * @param enableWildcard whether to include a wildcard host name in the SAN
- * @return a {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key
- * @throws KeyConvertException if an error occurs during key conversion or certificate generation
- */
- public static PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey,
- @Nullable String ipAddress, @Nullable String hostName, boolean enableWildcard)
- throws KeyConvertException {
- try {
- // Extract the 32-byte seed and public key from libsodium 64-byte SK
- byte[] sodiumSecretKey = signaturePrivateKey.bytes();
- byte[] sodiumSeed = new byte[32];
- System.arraycopy(sodiumSecretKey, 0, sodiumSeed, 0, 32);
- byte[] sodiumPublicKey = new byte[32];
- System.arraycopy(sodiumSecretKey, 32, sodiumPublicKey, 0, 32);
- String keyId = Base58.encode(sodiumPublicKey);
-
- /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410)
- // Use standard JDK 15+ Ed25519 support
- // PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410)
- // Version v2 (1) because we include the public key
- // AlgorithmIdentifier: 1.3.101.112
- // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed)
- // PublicKey: [1] IMPLICIT BIT STRING (32 bytes)
- byte[] pkcs8Bytes = new byte[83];
- System.arraycopy(new byte[]{
- 0x30, 0x51, // SEQUENCE (81 bytes)
- 0x02, 0x01, 0x01, // Version v2 (1)
- 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112)
- 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes)
- }, 0, pkcs8Bytes, 0, 16);
- System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32);
- System.arraycopy(new byte[]{
- (byte) 0x81, 0x21, 0x00 // [1] IMPLICIT BIT STRING (33 bytes: 0 padding + 32 bytes)
- }, 0, pkcs8Bytes, 48, 3);
- System.arraycopy(sodiumPublicKey, 0, pkcs8Bytes, 51, 32);
- */
-
- // Use standard JDK 15+ Ed25519 support
- // PKCS#8 v1 OneAsymmetricKey for Ed25519 (RFC 8410)
- // Version v1 (0) because we don't include the public key
- // AlgorithmIdentifier: 1.3.101.112
- // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed)
- byte[] pkcs8Bytes = new byte[48];
- System.arraycopy(new byte[]{
- 0x30, 0x2e, // SEQUENCE (46 bytes)
- 0x02, 0x01, 0x00, // Version v1 (0)
- 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112)
- 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes)
- }, 0, pkcs8Bytes, 0, 16);
- System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32);
-
- KeyFactory kf = KeyFactory.getInstance("Ed25519");
- PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Bytes));
-
- // Build TBSCertificate
- BigInteger serial = new BigInteger(128, new SecureRandom());
- Instant now = Instant.now();
- Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES));
- Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS));
-
- // Construct manual ASN.1/DER certificate
- byte[] tbs = encodeTBS(serial, keyId, notBefore, notAfter, sodiumPublicKey, ipAddress, hostName, enableWildcard);
-
- java.security.Signature sig = java.security.Signature.getInstance("Ed25519");
- sig.initSign(privateKey);
- sig.update(tbs);
- byte[] signatureValue = sig.sign();
-
- byte[] certDer = encodeCert(tbs, signatureValue);
-
- String keyPem = "-----BEGIN PRIVATE KEY-----\n" +
- Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(pkcs8Bytes) +
- "\n-----END PRIVATE KEY-----\n";
-
- String certPem = "-----BEGIN CERTIFICATE-----\n" +
- Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(certDer) +
- "\n-----END CERTIFICATE-----\n";
-
- return new PemCertificateAndKey(certPem, keyPem);
- } catch (Exception e) {
- throw new KeyConvertException("Failed to convert key using simple implementation", e);
- }
- }
-
- private static byte[] encodeTBS(BigInteger serial, String cn, Date notBefore, Date notAfter, byte[] pubKey,
- @Nullable String ip, @Nullable String host, boolean wildcard) throws IOException {
- DerBuilder tbs = new DerBuilder();
- tbs.addTag((byte) 0xA0, new DerBuilder().addInt(2).build()); // Version v3
- tbs.addInt(serial);
- tbs.addSeq(new DerBuilder().addOid("1.3.101.112")); // Algorithm: Ed25519
- tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Issuer
- tbs.addSeq(new DerBuilder().addTime(notBefore).addTime(notAfter)); // Validity
- tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Subject
- tbs.addSeq(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.101.112")).addBitString(pubKey)); // SubjectPublicKeyInfo
-
- // Extensions
- DerBuilder exts = new DerBuilder();
-
- // Subject Key Identifier (critical=false)
- try {
- MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
- // SKI = SHA-1 (public key bytes).
- // For Ed25519, BouncyCastle calculates SHA-1 over the raw 32-byte public key.
- // The extension value is an OCTET STRING containing the key identifier (which is also an OCTET STRING per RFC 5280).
- // However, in X.509, the extension value field is ALREADY an OCTET STRING, so we just wrap the hash in an OCTET STRING.
- byte[] ski = sha1.digest(pubKey);
- exts.addSeq(new DerBuilder().addOid("2.5.29.14").addOctetString(new DerBuilder().addOctetString(ski).build()));
- } catch (NoSuchAlgorithmException e) {
- throw new IOException("SHA-1 not found", e);
- }
-
- // KeyUsage (critical=true, digitalSignature=bit 0)
- exts.addSeq(new DerBuilder().addOid("2.5.29.15").addBool(true).addOctetString(new DerBuilder().addBitString(new byte[]{(byte) 0x80}, 7).build()));
-
- // SAN (critical=false)
- DerBuilder san = new DerBuilder();
- if (host != null) san.addTag((byte) 0x82, host.getBytes(StandardCharsets.US_ASCII));
- if (wildcard && host != null) san.addTag((byte) 0x82, ("*." + host).getBytes(StandardCharsets.US_ASCII));
- if (ip != null) {
- byte[] ipBytes = InetAddress.getByName(ip).getAddress(); // 16 bytes
- san.addTag((byte) 0x87, ipBytes);
- }
- if (ip != null || host != null)
- exts.addSeq(new DerBuilder().addOid("2.5.29.17").addOctetString(new DerBuilder().addSeq(san).build()));
-
- // BasicConstraints (critical=true, CA=false)
- exts.addSeq(new DerBuilder().addOid("2.5.29.19").addBool(true).addOctetString(new DerBuilder().addSeq(new DerBuilder()).build()));
-
- // ExtendedKeyUsage (critical=false, serverAuth, clientAuth)
- exts.addSeq(new DerBuilder().addOid("2.5.29.37").addOctetString(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.6.1.5.5.7.3.1").addOid("1.3.6.1.5.5.7.3.2")).build()));
-
- tbs.addTag((byte) 0xA3, new DerBuilder().addSeq(exts).build());
-
- return tbs.buildSeq();
- }
-
- private static byte[] encodeCert(byte[] tbs, byte[] signature) throws IOException {
- return new DerBuilder()
- .addRaw(tbs)
- .addSeq(new DerBuilder().addOid("1.3.101.112"))
- .addBitString(signature)
- .buildSeq();
- }
-
- private static class DerBuilder {
- private final ByteArrayOutputStream out = new ByteArrayOutputStream();
-
- public DerBuilder addRaw(byte[] raw) throws IOException {
- out.write(raw);
- return this;
- }
-
- public DerBuilder addTag(byte tag, byte[] val) throws IOException {
- out.write(tag);
- writeLen(val.length);
- out.write(val);
- return this;
- }
-
- public DerBuilder addInt(long v) throws IOException {
- return addInt(BigInteger.valueOf(v));
- }
-
- public DerBuilder addInt(BigInteger v) throws IOException {
- return addTag((byte) 0x02, v.toByteArray());
- }
-
- public DerBuilder addOid(String oid) throws IOException {
- String[] parts = oid.split("\\.");
- ByteArrayOutputStream b = new ByteArrayOutputStream();
- b.write(Integer.parseInt(parts[0]) * 40 + Integer.parseInt(parts[1]));
- for (int i = 2; i < parts.length; i++) {
- long v = Long.parseLong(parts[i]);
- if (v == 0) b.write(0);
- else {
- byte[] buf = new byte[10];
- int pos = 10;
- buf[--pos] = (byte) (v & 0x7F);
- while ((v >>= 7) > 0) buf[--pos] = (byte) ((v & 0x7F) | 0x80);
- b.write(buf, pos, 10 - pos);
- }
- }
- return addTag((byte) 0x06, b.toByteArray());
- }
-
- public DerBuilder addPrintableString(String s) throws IOException {
- return addTag((byte) 0x13, s.getBytes(StandardCharsets.US_ASCII));
- }
-
- // RFC 5280: encode dates before 2050 as UTCTime, and 2050 or later as GeneralizedTime.
- public DerBuilder addTime(Date d) throws IOException {
- int year = d.toInstant().atZone(ZoneOffset.UTC).getYear();
- if (year < 2050) {
- SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss'Z'");
- sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
- return addTag((byte) 0x17, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // UTCTime
- } else {
- SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss'Z'");
- sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
- return addTag((byte) 0x18, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // GeneralizedTime
- }
- }
-
- public DerBuilder addBitString(byte[] b) throws IOException {
- return addBitString(b, 0);
- }
-
- public DerBuilder addBitString(byte[] b, int pad) throws IOException {
- byte[] val = new byte[b.length + 1];
- val[0] = (byte) pad;
- System.arraycopy(b, 0, val, 1, b.length);
- return addTag((byte) 0x03, val);
- }
-
- public DerBuilder addOctetString(byte[] b) throws IOException {
- return addTag((byte) 0x04, b);
- }
-
- public DerBuilder addBool(boolean v) throws IOException {
- return addTag((byte) 0x01, new byte[]{(byte) (v ? 0xFF : 0x00)});
- }
-
- public DerBuilder addSeq(DerBuilder b) throws IOException {
- return addTag((byte) 0x30, b.build());
- }
-
- public DerBuilder addSet(DerBuilder b) throws IOException {
- return addTag((byte) 0x31, b.build());
- }
-
- public byte[] build() {
- return out.toByteArray();
- }
-
- public byte[] buildSeq() throws IOException {
- return new DerBuilder().addSeq(this).build();
- }
-
- private void writeLen(int len) {
- if (len < 128) out.write(len);
- else {
- byte[] b = BigInteger.valueOf(len).toByteArray();
- int skip = (b.length > 1 && b[0] == 0) ? 1 : 0;
- out.write(0x80 | (b.length - skip));
- out.write(b, skip, b.length - skip);
- }
- }
- }
-
- /**
- * Generates a random password containing a mix of uppercase and lowercase letters, digits, and special characters.
- *
- * @param length the length of the password to generate; must be a positive integer
- * @return a randomly generated password as a String
- * @throws IllegalArgumentException if the specified length is not positive
- */
- public static String randomPassword(int length) {
- if (length <= 0)
- throw new IllegalArgumentException("Length must be positive");
-
- String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_-+=<>?/|";
- StringBuilder sb = new StringBuilder(length);
- SecureRandom random = new SecureRandom();
- for (int i = 0; i < length; i++) {
- int index = random.nextInt(characters.length());
- sb.append(characters.charAt(index));
- }
-
- return sb.toString();
- }
-
- /*
- * Although using Vert.x PemKeyCertOptions is more direct:
- *
- * PemKeyCertOptions keyCertOptions = new PemKeyCertOptions()
- * .setKeyValue(Buffer.buffer(certAndKey.privateKey()))
- * .setCertValue(Buffer.buffer(certAndKey.cert()));
- * options.setKeyCertOptions(keyCertOptions);
- *
- * Vert.x (Netty) does not currently support PEM-encoded PKCS#8 Ed25519 private keys.
- * Therefore, we must package them into a PKCS#12 keystore and use PfxOptions instead.
- */
-
- /**
- * Creates a {@link PfxOptions} instance from a pair of PEM-encoded certificate and private key.
- *
- * @param certAndKey the {@link PemCertificateAndKey} containing the PEM-encoded certificate and private key
- * @return a {@link PfxOptions} containing a PKCS#12 keystore created from the provided certificate and private key
- * @throws InvalidKeySpecException if the private key could not be parsed correctly
- * @throws NoSuchAlgorithmException if the "Ed25519" algorithm required for the private key is not available
- * @throws CertificateException if the certificate could not be parsed correctly
- * @throws KeyStoreException if an error occurs while accessing or modifying the keystore
- */
- public static PfxOptions pfxOptionsFromCertAndPrivateKey(PemCertificateAndKey certAndKey)
- throws InvalidKeySpecException, NoSuchAlgorithmException, CertificateException, KeyStoreException {
- // Remove PEM headers
- String normalized = certAndKey.privateKey()
- .replace("-----BEGIN PRIVATE KEY-----", "")
- .replace("-----END PRIVATE KEY-----", "")
- .replaceAll("\\s", "");
-
- // Decode DER
- byte[] der = Base64.getDecoder().decode(normalized);
- // PKCS#8 -> PrivateKey
- PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(der);
- // Note: PKCS8EncodedKeySpec#getAlgorithm() returns null since it doesn't parse the DER,
- // so we must specify "Ed25519" explicitly for the KeyFactory.
- KeyFactory kf = KeyFactory.getInstance("Ed25519");
- PrivateKey privateKey = kf.generatePrivate(spec);
-
- CertificateFactory cf = CertificateFactory.getInstance("X.509");
- X509Certificate cert = (X509Certificate) cf.generateCertificate(
- new ByteArrayInputStream(certAndKey.cert().getBytes(StandardCharsets.US_ASCII)));
- KeyStore ks = KeyStore.getInstance("PKCS12");
- try {
- ks.load(null, null);
- } catch (IOException e) {
- throw new KeyStoreException("Failed to load empty KeyStore", e);
- }
- String password = randomPassword(16);
- ks.setKeyEntry(
- "server",
- privateKey,
- password.toCharArray(),
- new Certificate[]{cert});
-
- ByteArrayOutputStream bos = new ByteArrayOutputStream();
- try {
- ks.store(bos, password.toCharArray());
- } catch (IOException e) {
- throw new KeyStoreException("Failed to store KeyStore", e);
- }
- return new PfxOptions()
- .setValue(Buffer.buffer(bos.toByteArray()))
- .setPassword(password);
- }
-
- /**
- * Exception thrown when an error occurs during key conversion or certificate generation.
- */
- public static class KeyConvertException extends BosonException {
- private static final long serialVersionUID = -5975318365528633648L;
-
- /**
- * Constructs a new KeyConvertException with the specified detail message.
- *
- * @param message the detail message
- */
- public KeyConvertException(String message) {
- super(message);
- }
-
- /**
- * Constructs a new KeyConvertException with the specified detail message and cause.
- *
- * @param message the detail message
- * @param cause the cause
- */
- public KeyConvertException(String message, Throwable cause) {
- super(message, cause);
- }
- }
-}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java b/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java
index 0085130e..49e62fe6 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/HybridTrustManager.java
@@ -29,8 +29,6 @@
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Arrays;
-import javax.naming.InvalidNameException;
-import javax.naming.ldap.LdapName;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
@@ -139,16 +137,9 @@ private void checkTrusted(X509Certificate[] chain, String authType, boolean clie
// 3. Validate CN
String dn = cert.getSubjectX500Principal().getName();
- LdapName ldapName;
- try {
- ldapName = new LdapName(dn);
- } catch (InvalidNameException e) {
- throw new CertificateException(e);
- }
- String cn = ldapName.getRdns().stream()
- .filter(r -> r.getType().equalsIgnoreCase("CN"))
- .map(r -> r.getValue().toString())
- .findFirst().orElseThrow(() -> new CertificateException("No CN in certificate"));
+ String cn = extractCn(dn);
+ if (cn == null)
+ throw new CertificateException("No CN in certificate");
if (!cn.equals(expectedCn))
throw new CertificateException("CN mismatch");
@@ -168,6 +159,57 @@ private void checkTrusted(X509Certificate[] chain, String authType, boolean clie
}
}
+ /**
+ * Extracts the Common Name (CN) value from an RFC 2253 distinguished name, as returned by
+ * {@link javax.security.auth.x500.X500Principal#getName()}.
+ *
+ * This intentionally avoids {@code javax.naming.ldap.LdapName}, which is unavailable on Android.
+ * It handles backslash escapes and double-quoted values, and stops an attribute value at an
+ * unescaped RDN separator ({@code ,} or {@code +}).
+ *
+ * @param dn the RFC 2253 distinguished name
+ * @return the first CN value found, or {@code null} if the DN has no CN attribute
+ */
+ private static @Nullable String extractCn(String dn) {
+ int i = 0;
+ final int n = dn.length();
+ while (i < n) {
+ int eq = i;
+ while (eq < n && dn.charAt(eq) != '=')
+ eq++;
+ if (eq >= n)
+ break;
+
+ String type = dn.substring(i, eq).trim();
+ StringBuilder value = new StringBuilder();
+ int j = eq + 1;
+ boolean quoted = false;
+ while (j < n) {
+ char c = dn.charAt(j);
+ if (c == '\\' && j + 1 < n) {
+ value.append(dn.charAt(j + 1));
+ j += 2;
+ continue;
+ }
+ if (c == '"') {
+ quoted = !quoted;
+ j++;
+ continue;
+ }
+ if (!quoted && (c == ',' || c == '+'))
+ break;
+ value.append(c);
+ j++;
+ }
+
+ if (type.equalsIgnoreCase("CN"))
+ return value.toString().trim();
+
+ i = j + 1;
+ }
+ return null;
+ }
+
/**
* Returns the list of certificate issuer authorities which are trusted for
* authenticating peers.
diff --git a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java
index 5006fdef..dc913f02 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/PasswordHash.java
@@ -24,54 +24,91 @@
import static java.nio.charset.StandardCharsets.UTF_8;
-import org.jspecify.annotations.Nullable;
+import java.util.Objects;
/**
* Utility class for hashing passwords using different security levels and Argon2 algorithms.
*
* This class provides static methods for hashing passwords using interactive, moderate, or sensitive security levels,
* as well as direct parameterized hashing. It supports Argon2i and Argon2id algorithms, and delegates cryptographic
- * operations to Tuweni Sodium.
- *
- * Provides Argon2i (version 1.3) and Argon2id (version 1.3) algorithms. The {@link #DEFAULT} selection checks
- * if Argon2id is supported in the native library and uses it; otherwise, falls back to Argon2i.
+ * Provides Argon2i (version 1.3) and Argon2id (version 1.3) algorithms. The {@link #DEFAULT} selection uses
+ * Argon2id.
*
- * If Argon2id is supported by the loaded sodium library, it is selected; otherwise, Argon2i is used.
- *
- * Provides methods to generate a random salt or create a salt from an existing byte array.
- *
- * Note: only supported when the sodium native library version >= 10.0.14 is
- * available.
- *
* @param hash The hash.
* @return {@code true} if the hash should be regenerated.
*/
public static boolean needsRehashForInteractive(String hash) {
- return org.apache.tuweni.crypto.sodium.PasswordHash.needsRehashForInteractive(hash);
+ Objects.requireNonNull(hash, "Hash must not be null");
+ return provider().pwHashNeedsRehash(hash, INTERACTIVE_OPS, INTERACTIVE_MEM);
}
/**
* Check if a hash needs to be regenerated using limits on operations and memory
* that are suitable for most moderate use-cases.
*
- *
- * Note: only supported when the sodium native library version >= 10.0.14 is
- * available.
- *
* @param hash The hash.
* @return {@code true} if the hash should be regenerated.
*/
public static boolean needsRehashForModerate(String hash) {
- return org.apache.tuweni.crypto.sodium.PasswordHash.needsRehash(hash);
+ Objects.requireNonNull(hash, "Hash must not be null");
+ return provider().pwHashNeedsRehash(hash, MODERATE_OPS, MODERATE_MEM);
}
/**
* Check if a hash needs to be regenerated using limits on operations and memory
* that are suitable for sensitive use-cases.
*
- *
- * Note: only supported when the sodium native library version >= 10.0.14 is
- * available.
- *
* @param hash The hash.
* @return {@code true} if the hash should be regenerated.
*/
public static boolean needsRehashForSensitive(String hash) {
- return org.apache.tuweni.crypto.sodium.PasswordHash.needsRehashForSensitive(hash);
+ Objects.requireNonNull(hash, "Hash must not be null");
+ return provider().pwHashNeedsRehash(hash, SENSITIVE_OPS, SENSITIVE_MEM);
}
}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java b/api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java
new file mode 100644
index 00000000..534739e5
--- /dev/null
+++ b/api/src/main/java/io/bosonnetwork/crypto/PemCertificateAndKey.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 - bosonnetwork.io
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package io.bosonnetwork.crypto;
+
+/**
+ * Represents a pair of PEM-encoded certificate and private key.
+ *
+ * @param cert the PEM-encoded certificate
+ * @param privateKey the PEM-encoded private key
+ */
+public record PemCertificateAndKey(String cert, String privateKey) {
+}
diff --git a/api/src/main/java/io/bosonnetwork/crypto/Signature.java b/api/src/main/java/io/bosonnetwork/crypto/Signature.java
index 7597964f..e739e678 100644
--- a/api/src/main/java/io/bosonnetwork/crypto/Signature.java
+++ b/api/src/main/java/io/bosonnetwork/crypto/Signature.java
@@ -26,34 +26,21 @@
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
-import java.util.Arrays;
import java.util.Objects;
import javax.security.auth.Destroyable;
-import org.apache.tuweni.crypto.sodium.KeyDerivation;
-import org.apache.tuweni.crypto.sodium.Signature.Seed;
-import org.apache.tuweni.crypto.sodium.Sodium;
-import org.jspecify.annotations.Nullable;
-
/**
* Public-key(Ed25519) signatures.
*/
-public class Signature {
+public interface Signature {
/**
* The signing(Ed25519) public key object.
*/
- public static class PublicKey implements Destroyable {
+ interface PublicKey extends Destroyable {
/**
* The number of bytes used to represent a public key.
*/
- public static final int BYTES = org.apache.tuweni.crypto.sodium.Signature.PublicKey.length();
-
- private final org.apache.tuweni.crypto.sodium.Signature.PublicKey key;
- private byte @Nullable [] bytes;
-
- private PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey key) {
- this.key = key;
- }
+ int BYTES = CryptoProvider.SIGN_PUBLIC_KEY_BYTES;
/**
* Create a PublicKey from an array of bytes. The byte array must be of
@@ -61,14 +48,24 @@ private PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey key) {
*
* @param key the bytes for the public key.
* @return the created public key object.
+ * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long.
*/
- public static PublicKey fromBytes(byte[] key) {
- // No SodiumException raised
- return new PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey.fromBytes(key));
+ static PublicKey fromBytes(byte[] key) {
+ if (Objects.requireNonNull(key, "key").length != BYTES)
+ throw new IllegalArgumentException("Invalid public key size: expected " + BYTES + " bytes, got " + key.length);
+
+ return provider().ed25519PublicKeyFromBytes(key);
}
- org.apache.tuweni.crypto.sodium.Signature.PublicKey raw() {
- return key;
+ /**
+ * Derive the public key that corresponds to the given private key.
+ *
+ * @param key the private key.
+ * @return the matching public key.
+ */
+ static PublicKey fromPrivateKey(PrivateKey key) {
+ Objects.requireNonNull(key, "key");
+ return provider().ed25519PublicKeyFromSecretKey(key);
}
/**
@@ -76,81 +73,55 @@ org.apache.tuweni.crypto.sodium.Signature.PublicKey raw() {
*
* @return the bytes of this key.
*/
- public byte[] bytes() {
- if (bytes == null)
- bytes = key.bytesArray();
-
- return bytes.clone();
- }
+ byte[] bytes();
/**
* Verifies the signature of a message.
*
- * @param message the message to verify.
- * @param signature the signature of the message.
- * @return true if the signature matches the message according to this public key.
+ * @param message the message to verify. Must not be null.
+ * @param signature the signature of the message. Must not be null.
+ * @return true if the signature matches the message according to this public key; false if the
+ * signature is not {@link Signature#BYTES} bytes long or does not verify.
+ * @throws NullPointerException if {@code message} or {@code signature} is null.
*/
- public boolean verify(byte[] message, byte[] signature) {
- return Signature.verify(message, signature, this);
- }
-
- @Override
- public boolean equals(Object obj) {
- if (obj == this)
- return true;
-
- if (obj instanceof PublicKey that)
- return key.equals(that.key);
+ default boolean verify(byte[] message, byte[] signature) {
+ Objects.requireNonNull(message, "message");
+ // A wrong-length signature is simply not a valid signature (verify is routinely called on
+ // untrusted input), so reject it with a false result rather than an exception.
+ if (Objects.requireNonNull(signature, "signature").length != Signature.BYTES)
+ return false;
- return false;
+ return provider().ed25519Verify(message, signature, this);
}
@Override
- public int hashCode() {
- return 0x6030A + key.hashCode();
- }
+ void destroy();
- /**
- * Destroy this PublicKey object.
- * Sensitive information associated with this object is destroyed or cleared.
- */
- @Override
- public void destroy() {
- if (!key.isDestroyed()) {
- key.destroy();
-
- if (bytes != null) {
- Arrays.fill(bytes, (byte) 0);
- bytes = null;
- }
- }
- }
-
- /**
- * Determine if this object has been destroyed.
- *
- * @return true if this object has been destroyed, false otherwise.
- */
@Override
- public boolean isDestroyed() {
- return key.isDestroyed();
- }
+ boolean isDestroyed();
}
/**
* The signing(Ed25519) private key object.
*/
- public static class PrivateKey implements Destroyable {
+ interface PrivateKey extends Destroyable {
/**
- * The number of bytes used to represent a public key.
+ * The number of bytes used to represent a private key (seed followed by public key).
*/
- public static final int BYTES = org.apache.tuweni.crypto.sodium.Signature.SecretKey.length();
+ int BYTES = CryptoProvider.SIGN_SECRET_KEY_BYTES;
- private final org.apache.tuweni.crypto.sodium.Signature.SecretKey key;
- private byte @Nullable [] bytes;
+ /**
+ * Creates a new {@code PrivateKey} object from the specified seed.
+ *
+ * @param seed the {@link KeyPair#SEED_BYTES}-byte seed for the private key. Must not be null.
+ * @return a new {@code PrivateKey} created from the given seed.
+ * @throws IllegalArgumentException if {@code seed} is not {@link KeyPair#SEED_BYTES} bytes long.
+ */
+ static PrivateKey fromSeed(byte[] seed) {
+ if (Objects.requireNonNull(seed, "seed").length != KeyPair.SEED_BYTES)
+ throw new IllegalArgumentException("Invalid seed size: expected " + KeyPair.SEED_BYTES + " bytes, got " + seed.length);
- private PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey key) {
- this.key = key;
+ return provider().ed25519SecretKeyFromSeed(seed);
}
/**
@@ -159,38 +130,28 @@ private PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey key) {
*
* @param key the bytes for the secret key.
* @return the created private key object.
+ * @throws IllegalArgumentException if {@code key} is not {@link #BYTES} bytes long.
*/
- public static PrivateKey fromBytes(byte[] key) {
- // no SodiumException raised
- return new PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(key));
+ static PrivateKey fromBytes(byte[] key) {
+ if (Objects.requireNonNull(key, "key").length != BYTES)
+ throw new IllegalArgumentException("Invalid private key size: expected " + BYTES + " bytes, got " + key.length);
+
+ return provider().ed25519SecretKeyFromBytes(key);
}
/**
- * Creates a new {@code PrivateKey} object from the specified seed.
+ * Provides the {@link KeyPair#SEED_BYTES}-byte seed of this secret key.
*
- * @param seed the byte array representing the seed for the private key. Must not be null.
- * @return a new {@code PrivateKey} created from the given seed.
+ * @return the seed bytes.
*/
- public static PrivateKey fromSeed(byte[] seed) {
- return new PrivateKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromSeed(Seed.fromBytes(seed)));
- }
-
- org.apache.tuweni.crypto.sodium.Signature.SecretKey raw() {
- return key;
- }
+ byte[] seed();
/**
* Provides the bytes of this secret key.
*
* @return the bytes of this secret key.
*/
- public byte[] bytes() {
- if (bytes == null)
- bytes = key.bytesArray();
-
- return bytes.clone();
- }
-
+ byte[] bytes();
/**
* Derives a new {@code PrivateKey} based on the provided subkey ID and context string.
@@ -200,11 +161,8 @@ public byte[] bytes() {
* @param context the context string used during the key derivation process. Must not be null.
* @return a newly derived {@code PrivateKey} created using the specified subkey ID and context.
*/
- public PrivateKey derive(long subKeyId, String context) {
- byte[] contextBytes = deriveContextBytes(context);
- KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(bytes(), 0, 32));
- byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, contextBytes);
- return PrivateKey.fromSeed(subSeed);
+ default PrivateKey derive(long subKeyId, String context) {
+ return derive(subKeyId, deriveContextBytes(context));
}
/**
@@ -216,83 +174,50 @@ public PrivateKey derive(long subKeyId, String context) {
* array and cannot be null.
* @return a new {@code PrivateKey} derived using the specified subkey ID and context.
*/
- public PrivateKey derive(long subKeyId, byte[] context) {
- Objects.requireNonNull(context, "context");
- KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(bytes(), 0, 32));
- byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, context);
+ default PrivateKey derive(long subKeyId, byte[] context) {
+ if (Objects.requireNonNull(context, "context").length != CryptoProvider.KDF_CONTEXT_BYTES)
+ throw new IllegalArgumentException("Invalid context size: expected "
+ + CryptoProvider.KDF_CONTEXT_BYTES + " bytes, got " + context.length);
+
+ byte[] master = seed();
+ byte[] subSeed = provider().kdfDeriveFromKey(master, subKeyId, context, KeyPair.SEED_BYTES);
return PrivateKey.fromSeed(subSeed);
}
/**
* Signs a message with this private key.
*
- * @param message the message to sign.
+ * @param message the message to sign. Must not be null.
* @return the signature of the message.
+ * @throws NullPointerException if {@code message} is null.
*/
- public byte[] sign(byte[] message) {
- return Signature.sign(message, this);
- }
-
- @Override
- public boolean equals(Object obj) {
- if (obj == this)
- return true;
-
- if (obj instanceof PrivateKey that)
- return key.equals(that.key);
-
- return false;
- }
-
- @Override
- public int hashCode() {
- return 0x6030A + key.hashCode();
+ default byte[] sign(byte[] message) {
+ Objects.requireNonNull(message, "message");
+ return provider().ed25519Sign(message, this);
}
- /**
- * Destroy this private key.
- * Sensitive information associated with this private key
- * is destroyed or cleared.
- */
@Override
- public void destroy() {
- if (!key.isDestroyed()) {
- key.destroy();
-
- if (bytes != null) {
- Arrays.fill(bytes, (byte) 0);
- bytes = null;
- }
- }
- }
+ void destroy();
- /**
- * Determine if this object has been destroyed.
- *
- * @return true if this object has been destroyed, false otherwise.
- */
@Override
- public boolean isDestroyed() {
- return key.isDestroyed();
- }
+ boolean isDestroyed();
}
/**
* The signing(Ed25519) key pair.
*/
- public static class KeyPair implements Destroyable {
+ class KeyPair implements Destroyable {
/**
* The seed length in bytes.
*/
- public static final int SEED_BYTES = Seed.length();
+ public static final int SEED_BYTES = CryptoProvider.SIGN_SEED_BYTES;
- private final org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair;
- private @Nullable PublicKey pk;
- private @Nullable PrivateKey sk;
- private boolean destroyed = false;
+ private final PublicKey pk;
+ private final PrivateKey sk;
- private KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair) {
- this.keyPair = keyPair;
+ private KeyPair(PrivateKey sk) {
+ this.sk = sk;
+ this.pk = PublicKey.fromPrivateKey(sk);
}
/**
@@ -303,9 +228,7 @@ private KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair keyPair) {
* @return the created key pair object.
*/
public static KeyPair fromPrivateKey(byte[] privateKey) {
- org.apache.tuweni.crypto.sodium.Signature.SecretKey sk = org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(privateKey);
- // Normally, should never raise Exception
- return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.forSecretKey(sk));
+ return new KeyPair(PrivateKey.fromBytes(privateKey));
}
/**
@@ -315,8 +238,7 @@ public static KeyPair fromPrivateKey(byte[] privateKey) {
* @return the created key pair object.
*/
public static KeyPair fromPrivateKey(PrivateKey privateKey) {
- // Normally, should never raise Exception
- return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.forSecretKey(privateKey.raw()));
+ return new KeyPair(privateKey);
}
/**
@@ -327,9 +249,7 @@ public static KeyPair fromPrivateKey(PrivateKey privateKey) {
* @return the created key pair object.
*/
public static KeyPair fromSeed(byte[] seed) {
- org.apache.tuweni.crypto.sodium.Signature.Seed sd = org.apache.tuweni.crypto.sodium.Signature.Seed.fromBytes(seed);
- // Normally, should never raise Exception
- return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.fromSeed(sd));
+ return new KeyPair(PrivateKey.fromSeed(seed));
}
/**
@@ -338,12 +258,7 @@ public static KeyPair fromSeed(byte[] seed) {
* @return a randomly generated key pair.
*/
public static KeyPair random() {
- // Normally, should never raise Exception
- return new KeyPair(org.apache.tuweni.crypto.sodium.Signature.KeyPair.random());
- }
-
- org.apache.tuweni.crypto.sodium.Signature.KeyPair raw() {
- return keyPair;
+ return fromSeed(Random.randomBytesSecure(SEED_BYTES));
}
/**
@@ -352,9 +267,6 @@ org.apache.tuweni.crypto.sodium.Signature.KeyPair raw() {
* @return the public key of the key pair.
*/
public PublicKey publicKey() {
- if (pk == null)
- pk = new PublicKey(keyPair.publicKey());
-
return pk;
}
@@ -364,9 +276,6 @@ public PublicKey publicKey() {
* @return the private key of the key pair.
*/
public PrivateKey privateKey() {
- if (sk == null)
- sk = new PrivateKey(keyPair.secretKey());
-
return sk;
}
@@ -379,10 +288,7 @@ public PrivateKey privateKey() {
* @return the derived {@code KeyPair} instance.
*/
public KeyPair derive(long subKeyId, String context) {
- byte[] contextBytes = deriveContextBytes(context);
- KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(privateKey().bytes(), 0, 32));
- byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, contextBytes);
- return KeyPair.fromSeed(subSeed);
+ return new KeyPair(sk.derive(subKeyId, context));
}
/**
@@ -390,15 +296,12 @@ public KeyPair derive(long subKeyId, String context) {
*
* @param subKeyId the identifier for the derived subkey. This is used to ensure the generated key
* is unique per subkey ID.
- * @param context the context-specific data used during key derivation. Must be provided as a byte
- * array and cannot be null.
+ * @param context the context-specific data used during the key derivation process. Must be provided
+ * as a byte array and cannot be null.
* @return the derived {@code KeyPair} instance.
*/
public KeyPair derive(long subKeyId, byte[] context) {
- Objects.requireNonNull(context, "context");
- KeyDerivation.MasterKey master = KeyDerivation.MasterKey.fromBytes(Arrays.copyOfRange(privateKey().bytes(), 0, 32));
- byte[] subSeed = master.deriveKeyArray(KeyPair.SEED_BYTES, subKeyId, context);
- return KeyPair.fromSeed(subSeed);
+ return new KeyPair(sk.derive(subKeyId, context));
}
@Override
@@ -407,14 +310,14 @@ public boolean equals(Object obj) {
return true;
if (obj instanceof KeyPair that)
- return keyPair.equals(that.keyPair);
+ return sk.equals(that.sk) && pk.equals(that.pk);
return false;
}
@Override
public int hashCode() {
- return 0x6030A + keyPair.hashCode();
+ return Objects.hash(sk, pk);
}
/**
@@ -422,11 +325,8 @@ public int hashCode() {
*/
@Override
public void destroy() {
- if (!destroyed) {
- publicKey().destroy();
- privateKey().destroy();
- destroyed = true;
- }
+ pk.destroy();
+ sk.destroy();
}
/**
@@ -436,24 +336,22 @@ public void destroy() {
*/
@Override
public boolean isDestroyed() {
- return destroyed;
+ return sk.isDestroyed();
}
}
- // Can not access internal method
- // should be (int)Sodium.crypto_sign_bytes();
/**
* The number of bytes used to represent a signature.
*/
- public static final int BYTES = 64;
+ public static final int BYTES = CryptoProvider.SIGN_BYTES;
/**
- * Derives the fixed-length (8-byte) libsodium key-derivation context from a context string.
+ * Derives the fixed-length (8-byte) key-derivation context from a context string.
*
* The string is hashed with SHA-256 and the 32-byte digest is folded down to the 8 bytes
- * required by {@link KeyDerivation#contextLength()}.
+ * required by the {@code crypto_kdf} context.
*
- * Note: the 8-byte context is a lossy reduction (libsodium's fixed context
+ * Note: the 8-byte context is a lossy reduction (the fixed context
* size), so distinct context strings can still collide and, for the same sub-key id, derive the
* same key. Use distinct sub-key ids when strong domain separation is required.
*
@@ -465,7 +363,7 @@ private static byte[] deriveContextBytes(String context) {
if (context.isEmpty())
throw new IllegalArgumentException("context must not be empty");
- final int len = KeyDerivation.contextLength(); // 8 bytes
+ final int len = CryptoProvider.KDF_CONTEXT_BYTES; // 8 bytes
byte[] contextBytes = new byte[len];
try {
MessageDigest sha = MessageDigest.getInstance("SHA-256");
@@ -481,31 +379,31 @@ private static byte[] deriveContextBytes(String context) {
/**
* Signs a message with a given key.
*
- * @param message the message to sign.
- * @param key the private key to sign the message with.
+ * @param message the message to sign. Must not be null.
+ * @param key the private key to sign the message with. Must not be null.
* @return the signature of the message.
+ * @throws NullPointerException if {@code message} or {@code key} is null.
*/
- public static byte[] sign(byte[] message, PrivateKey key) {
- // Normally, should never raise SodiumException
- return org.apache.tuweni.crypto.sodium.Signature.signDetached(message, key.raw());
+ static byte[] sign(byte[] message, PrivateKey key) {
+ Objects.requireNonNull(key, "key");
+ return key.sign(message);
}
/**
* Verifies the signature of a message.
*
- * @param message the message to verify.
- * @param signature the signature of the message.
- * @param key the public key to verify the message with.
+ * @param message the message to verify. Must not be null.
+ * @param signature the signature of the message. Must not be null.
+ * @param key the public key to verify the message with. Must not be null.
* @return true if the signature matches the message according to this public key.
+ * @throws NullPointerException if {@code message}, {@code signature} or {@code key} is null.
*/
- public static boolean verify(byte[] message, byte[] signature, PublicKey key) {
- // Normally, should never raise SodiumException
- return org.apache.tuweni.crypto.sodium.Signature.verifyDetached(message, signature, key.raw());
+ static boolean verify(byte[] message, byte[] signature, PublicKey key) {
+ Objects.requireNonNull(key, "key");
+ return key.verify(message, signature);
}
- static {
- if (!Sodium.isAvailable()) {
- throw new RuntimeException("Sodium native library is not available!");
- }
+ private static CryptoProvider provider() {
+ return CryptoProviders.getDefault();
}
}
\ No newline at end of file
diff --git a/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java b/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java
index 3e20488e..2ff39101 100644
--- a/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java
+++ b/api/src/main/java/io/bosonnetwork/service/SuperNodeInfo.java
@@ -22,6 +22,7 @@
package io.bosonnetwork.service;
+import java.util.List;
import java.util.Optional;
import io.bosonnetwork.Id;
@@ -41,18 +42,11 @@ public non-sealed interface SuperNodeInfo extends Principal {
Id getId();
/**
- * Gets the hostname or IP address of the node.
+ * Retrieves a list of addresses associated with the node.
*
- * @return the host string, never {@code null}
+ * @return a list of address strings; the list may be empty but will never be null
*/
- String getHost();
-
- /**
- * Gets the port number on which the node accepts connections.
- *
- * @return the port number
- */
- int getPort();
+ List
+ * Key, nonce and precomputed-box objects wrap the corresponding native Tuweni handles directly:
+ * {@link #boxBeforeNm} returns a {@link CryptoBox} backed by a real Tuweni {@link Box} (from
+ * {@link Box#forKeys}) whose native shared key is released on {@code close()}. A foreign key
+ * object created by another provider is accepted by reconstructing the Tuweni handle from its
+ * raw bytes.
+ */
+@NullMarked
+public class SodiumCryptoProvider implements CryptoProvider {
+ @Override
+ public String name() {
+ return "libsodium";
+ }
+
+ private static class Ed25519SecretKey implements Signature.PrivateKey {
+ private final org.apache.tuweni.crypto.sodium.Signature.SecretKey key;
+
+ private Ed25519SecretKey(org.apache.tuweni.crypto.sodium.Signature.SecretKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public byte[] seed() {
+ // guard before touching native memory: bytesArray() after destroy() is a use-after-free
+ if (isDestroyed())
+ throw new IllegalStateException("Private key has been destroyed");
+ return Arrays.copyOfRange(key.bytesArray(), 0, SIGN_SEED_BYTES);
+ }
+
+ @Override
+ public byte[] bytes() {
+ if (isDestroyed())
+ throw new IllegalStateException("Private key has been destroyed");
+ return key.bytesArray();
+ }
+
+ @Override
+ public void destroy() {
+ key.destroy();
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key.isDestroyed();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof Signature.PrivateKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ private static class Ed25519PublicKey implements Signature.PublicKey {
+ private final org.apache.tuweni.crypto.sodium.Signature.PublicKey key;
+
+ private Ed25519PublicKey(org.apache.tuweni.crypto.sodium.Signature.PublicKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public byte[] bytes() {
+ if (isDestroyed())
+ throw new IllegalStateException("Public key has been destroyed");
+ return key.bytesArray();
+ }
+
+ @Override
+ public void destroy() {
+ key.destroy();
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key.isDestroyed();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof Signature.PublicKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ @Override
+ public Signature.PrivateKey ed25519SecretKeyFromSeed(byte[] seed) {
+ // Use KeyPair.fromSeed to obtain the full 64-byte secret key (seed || public key);
+ // SecretKey.fromSeed alone does not expand it, which corrupts later sk_to_pk reads.
+ org.apache.tuweni.crypto.sodium.Signature.KeyPair kp =
+ org.apache.tuweni.crypto.sodium.Signature.KeyPair.fromSeed(
+ org.apache.tuweni.crypto.sodium.Signature.Seed.fromBytes(seed));
+ return new Ed25519SecretKey(kp.secretKey());
+ }
+
+ @Override
+ public Signature.PrivateKey ed25519SecretKeyFromBytes(byte[] key) {
+ org.apache.tuweni.crypto.sodium.Signature.SecretKey sk =
+ org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(key);
+ return new Ed25519SecretKey(sk);
+ }
+
+ private static org.apache.tuweni.crypto.sodium.Signature.SecretKey keyOf(Signature.PrivateKey secretKey) {
+ return secretKey instanceof Ed25519SecretKey k ? k.key :
+ org.apache.tuweni.crypto.sodium.Signature.SecretKey.fromBytes(secretKey.bytes());
+ }
+
+ private static org.apache.tuweni.crypto.sodium.Signature.PublicKey keyOf(Signature.PublicKey publicKey) {
+ return publicKey instanceof Ed25519PublicKey k ? k.key :
+ org.apache.tuweni.crypto.sodium.Signature.PublicKey.fromBytes(publicKey.bytes());
+ }
+
+ @Override
+ public Signature.PublicKey ed25519PublicKeyFromSecretKey(Signature.PrivateKey secretKey) {
+ org.apache.tuweni.crypto.sodium.Signature.PublicKey pk =
+ org.apache.tuweni.crypto.sodium.Signature.KeyPair.forSecretKey(keyOf(secretKey)).publicKey();
+ return new Ed25519PublicKey(pk);
+ }
+
+ @Override
+ public Signature.PublicKey ed25519PublicKeyFromBytes(byte[] key) {
+ org.apache.tuweni.crypto.sodium.Signature.PublicKey pk =
+ org.apache.tuweni.crypto.sodium.Signature.PublicKey.fromBytes(key);
+ return new Ed25519PublicKey(pk);
+ }
+
+ @Override
+ public byte[] ed25519Sign(byte[] message, Signature.PrivateKey secretKey) {
+ return org.apache.tuweni.crypto.sodium.Signature.signDetached(message, keyOf(secretKey));
+ }
+
+ @Override
+ public boolean ed25519Verify(byte[] message, byte[] signature, Signature.PublicKey publicKey) {
+ return org.apache.tuweni.crypto.sodium.Signature.verifyDetached(message, signature, keyOf(publicKey));
+ }
+
+ @Override
+ public byte[] kdfDeriveFromKey(byte[] masterKey, long subKeyId, byte[] context, int subKeyLength) {
+ return KeyDerivation.MasterKey.fromBytes(masterKey).deriveKeyArray(subKeyLength, subKeyId, context);
+ }
+
+ private static class SodiumBoxPublicKey implements CryptoBox.PublicKey {
+ private final Box.PublicKey key;
+
+ private SodiumBoxPublicKey(Box.PublicKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public byte[] bytes() {
+ if (isDestroyed())
+ throw new IllegalStateException("Public key has been destroyed");
+ return key.bytesArray();
+ }
+
+ @Override
+ public void destroy() {
+ key.destroy();
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key.isDestroyed();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof CryptoBox.PublicKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ private static class SodiumBoxSecretKey implements CryptoBox.PrivateKey {
+ private final Box.SecretKey key;
+
+ private SodiumBoxSecretKey(Box.SecretKey key) {
+ this.key = key;
+ }
+
+ @Override
+ public byte[] bytes() {
+ if (isDestroyed())
+ throw new IllegalStateException("Private key has been destroyed");
+ return key.bytesArray();
+ }
+
+ @Override
+ public void destroy() {
+ key.destroy();
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return key.isDestroyed();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof CryptoBox.PrivateKey that) || isDestroyed() || that.isDestroyed())
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+
+ @Override
+ public int hashCode() {
+ return isDestroyed() ? 0 : Arrays.hashCode(bytes());
+ }
+ }
+
+ private static class SodiumBoxNonce implements CryptoBox.Nonce {
+ private final Box.Nonce nonce;
+
+ private SodiumBoxNonce(Box.Nonce nonce) {
+ this.nonce = nonce;
+ }
+
+ @Override
+ public CryptoBox.Nonce increment() {
+ return new SodiumBoxNonce(nonce.increment());
+ }
+
+ @Override
+ public byte[] bytes() {
+ return nonce.bytesArray();
+ }
+
+ @Override
+ public int hashCode() {
+ return nonce.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this)
+ return true;
+ if (!(obj instanceof CryptoBox.Nonce that))
+ return false;
+ return Arrays.equals(bytes(), that.bytes());
+ }
+ }
+
+ // Holds the real precomputed Tuweni Box (crypto_box_beforenm), released on close().
+ private static class SodiumCryptoBox implements CryptoBox {
+ private final Box box;
+ private boolean destroyed = false;
+
+ private SodiumCryptoBox(Box box) {
+ this.box = box;
+ }
+
+ @Override
+ public void close() {
+ destroy();
+ }
+
+ @Override
+ public void destroy() {
+ if (!destroyed) {
+ box.close();
+ destroyed = true;
+ }
+ }
+
+ @Override
+ public boolean isDestroyed() {
+ return destroyed;
+ }
+ }
+
+ private static Box.PublicKey keyOf(CryptoBox.PublicKey publicKey) {
+ return publicKey instanceof SodiumBoxPublicKey k ? k.key : Box.PublicKey.fromBytes(publicKey.bytes());
+ }
+
+ private static Box.SecretKey keyOf(CryptoBox.PrivateKey secretKey) {
+ return secretKey instanceof SodiumBoxSecretKey k ? k.key : Box.SecretKey.fromBytes(secretKey.bytes());
+ }
+
+ @Override
+ public CryptoBox.PublicKey signPublicKeyToBoxPublicKey(Signature.PublicKey publicKey) {
+ return new SodiumBoxPublicKey(Box.PublicKey.forSignaturePublicKey(keyOf(publicKey)));
+ }
+
+ @Override
+ public CryptoBox.PrivateKey signSecretKeyToBoxSecretKey(Signature.PrivateKey secretKey) {
+ return new SodiumBoxSecretKey(Box.SecretKey.forSignatureSecretKey(keyOf(secretKey)));
+ }
+
+ @Override
+ public CryptoBox.PrivateKey boxSecretKeyFromSeed(byte[] seed) {
+ return new SodiumBoxSecretKey(Box.KeyPair.fromSeed(Box.Seed.fromBytes(seed)).secretKey());
+ }
+
+ @Override
+ public CryptoBox.PublicKey boxPublicKeyFromBytes(byte[] bytes) {
+ return new SodiumBoxPublicKey(Box.PublicKey.fromBytes(bytes));
+ }
+
+ @Override
+ public CryptoBox.PrivateKey boxSecretKeyFromBytes(byte[] bytes) {
+ return new SodiumBoxSecretKey(Box.SecretKey.fromBytes(bytes));
+ }
+
+ @Override
+ public CryptoBox.PublicKey boxPublicKeyFromSecretKey(CryptoBox.PrivateKey secretKey) {
+ return new SodiumBoxPublicKey(Box.KeyPair.forSecretKey(keyOf(secretKey)).publicKey());
+ }
+
+ @Override
+ public CryptoBox.Nonce boxNonceFromBytes(byte[] bytes) {
+ return new SodiumBoxNonce(Box.Nonce.fromBytes(bytes));
+ }
+
+ @Override
+ public CryptoBox boxBeforeNm(CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return new SodiumCryptoBox(Box.forKeys(keyOf(publicKey), keyOf(secretKey)));
+ }
+
+ private static Box boxOf(CryptoBox box) {
+ if (box instanceof SodiumCryptoBox b)
+ return b.box;
+ else
+ throw new IllegalStateException("Not a SodiumCryptoBox: " + box.getClass().getName());
+ }
+
+ @Override
+ public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox box) {
+ return boxOf(box).encrypt(message, nonceOf(nonce));
+ }
+
+ @Override
+ public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox box) {
+ return boxOf(box).decrypt(cipher, nonceOf(nonce));
+ }
+
+ private static Box.Nonce nonceOf(CryptoBox.Nonce nonce) {
+ return nonce instanceof SodiumBoxNonce n ? n.nonce : Box.Nonce.fromBytes(nonce.bytes());
+ }
+
+ @Override
+ public byte[] boxEncrypt(byte[] message, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return Box.encrypt(message, keyOf(publicKey), keyOf(secretKey), nonceOf(nonce));
+ }
+
+ @Override
+ public byte @Nullable [] boxDecrypt(byte[] cipher, CryptoBox.Nonce nonce, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return Box.decrypt(cipher, keyOf(publicKey), keyOf(secretKey), nonceOf(nonce));
+ }
+
+ @Override
+ public byte[] boxSeal(byte[] message, CryptoBox.PublicKey publicKey) {
+ return Box.encryptSealed(message, keyOf(publicKey));
+ }
+
+ @Override
+ public byte @Nullable [] boxSealOpen(byte[] cipher, CryptoBox.PublicKey publicKey, CryptoBox.PrivateKey secretKey) {
+ return Box.decryptSealed(cipher, keyOf(publicKey), keyOf(secretKey));
+ }
+
+ @Override
+ public byte[] pwHash(byte[] password, int length, byte[] salt, long opsLimit, long memLimit, int algorithm) {
+ return PasswordHash.hash(password, length, PasswordHash.Salt.fromBytes(salt), opsLimit, memLimit,
+ algorithm == PWHASH_ALG_ARGON2I13 ? PasswordHash.Algorithm.argon2i13()
+ : PasswordHash.Algorithm.argon2id13());
+ }
+
+ @Override
+ public String pwHashString(byte[] password, long opsLimit, long memLimit, int algorithm) {
+ return PasswordHash.hash(new String(password, StandardCharsets.UTF_8), opsLimit, memLimit);
+ }
+
+ @Override
+ public boolean pwHashVerify(String hash, byte[] password) {
+ return PasswordHash.verify(hash, new String(password, StandardCharsets.UTF_8));
+ }
+
+ @Override
+ public boolean pwHashNeedsRehash(String hash, long opsLimit, long memLimit) {
+ // Honour the requested limits (matches libsodium crypto_pwhash_str_needs_rehash);
+ // the no-arg needsRehash(hash) would compare against the MODERATE defaults instead.
+ return PasswordHash.needsRehash(hash, opsLimit, memLimit);
+ }
+
+ @Override
+ public PemCertificateAndKey certificateFromSignatureKey(Signature.PrivateKey signaturePrivateKey,
+ @Nullable String ipAddress, @Nullable String hostName,
+ boolean enableWildcard) throws CryptoException {
+ // Mirror BouncyCastleCryptoProvider: at least one SAN entry is required.
+ if (ipAddress == null && hostName == null)
+ throw new IllegalArgumentException("At least one SAN (hostname or IP) must be provided");
+
+ try {
+ // Extract the 32-byte seed and public key from libsodium 64-byte SK
+ byte[] sodiumSecretKey = signaturePrivateKey.bytes();
+ byte[] sodiumSeed = new byte[32];
+ System.arraycopy(sodiumSecretKey, 0, sodiumSeed, 0, 32);
+ byte[] sodiumPublicKey = new byte[32];
+ System.arraycopy(sodiumSecretKey, 32, sodiumPublicKey, 0, 32);
+ String keyId = Base58.encode(sodiumPublicKey);
+
+ /*/ PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410)
+ // Use standard JDK 15+ Ed25519 support
+ // PKCS#8 v2 OneAsymmetricKey for Ed25519 (RFC 8410)
+ // Version v2 (1) because we include the public key
+ // AlgorithmIdentifier: 1.3.101.112
+ // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed)
+ // PublicKey: [1] IMPLICIT BIT STRING (32 bytes)
+ byte[] pkcs8Bytes = new byte[83];
+ System.arraycopy(new byte[]{
+ 0x30, 0x51, // SEQUENCE (81 bytes)
+ 0x02, 0x01, 0x01, // Version v2 (1)
+ 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112)
+ 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes)
+ }, 0, pkcs8Bytes, 0, 16);
+ System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32);
+ System.arraycopy(new byte[]{
+ (byte) 0x81, 0x21, 0x00 // [1] IMPLICIT BIT STRING (33 bytes: 0 padding + 32 bytes)
+ }, 0, pkcs8Bytes, 48, 3);
+ System.arraycopy(sodiumPublicKey, 0, pkcs8Bytes, 51, 32);
+ */
+
+ // Use standard JDK 15+ Ed25519 support
+ // PKCS#8 v1 OneAsymmetricKey for Ed25519 (RFC 8410)
+ // Version v1 (0) because we don't include the public key
+ // AlgorithmIdentifier: 1.3.101.112
+ // PrivateKey: OCTET STRING containing OCTET STRING (32 bytes seed)
+ byte[] pkcs8Bytes = new byte[48];
+ System.arraycopy(new byte[]{
+ 0x30, 0x2e, // SEQUENCE (46 bytes)
+ 0x02, 0x01, 0x00, // Version v1 (0)
+ 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // Algorithm (Ed25519: 1.3.101.112)
+ 0x04, 0x22, 0x04, 0x20 // PrivateKey OCTET STRING (34 bytes)
+ }, 0, pkcs8Bytes, 0, 16);
+ System.arraycopy(sodiumSeed, 0, pkcs8Bytes, 16, 32);
+
+ KeyFactory kf = KeyFactory.getInstance("Ed25519");
+ PrivateKey privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Bytes));
+
+ // Build TBSCertificate
+ BigInteger serial = new BigInteger(128, new SecureRandom());
+ Instant now = Instant.now();
+ Date notBefore = Date.from(now.minus(10, ChronoUnit.MINUTES));
+ Date notAfter = Date.from(now.plus(3650, ChronoUnit.DAYS));
+
+ // Construct manual ASN.1/DER certificate
+ byte[] tbs = encodeTBS(serial, keyId, notBefore, notAfter, sodiumPublicKey, ipAddress, hostName, enableWildcard);
+
+ java.security.Signature sig = java.security.Signature.getInstance("Ed25519");
+ sig.initSign(privateKey);
+ sig.update(tbs);
+ byte[] signatureValue = sig.sign();
+
+ byte[] certDer = encodeCert(tbs, signatureValue);
+
+ String keyPem = "-----BEGIN PRIVATE KEY-----\n" +
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(pkcs8Bytes) +
+ "\n-----END PRIVATE KEY-----\n";
+
+ String certPem = "-----BEGIN CERTIFICATE-----\n" +
+ Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(certDer) +
+ "\n-----END CERTIFICATE-----\n";
+
+ return new PemCertificateAndKey(certPem, keyPem);
+ } catch (IOException | InvalidKeyException | SignatureException | NoSuchAlgorithmException |
+ InvalidKeySpecException e) {
+ throw new CryptoException("Failed to convert key using simple implementation", e);
+ }
+ }
+
+ private static byte[] encodeTBS(BigInteger serial, String cn, Date notBefore, Date notAfter, byte[] pubKey,
+ @Nullable String ip, @Nullable String host, boolean wildcard) throws IOException {
+ DerBuilder tbs = new DerBuilder();
+ tbs.addTag((byte) 0xA0, new DerBuilder().addInt(2).build()); // Version v3
+ tbs.addInt(serial);
+ tbs.addSeq(new DerBuilder().addOid("1.3.101.112")); // Algorithm: Ed25519
+ tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Issuer
+ tbs.addSeq(new DerBuilder().addTime(notBefore).addTime(notAfter)); // Validity
+ tbs.addSeq(new DerBuilder().addSet(new DerBuilder().addSeq(new DerBuilder().addOid("2.5.4.3").addPrintableString(cn)))); // Subject
+ tbs.addSeq(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.101.112")).addBitString(pubKey)); // SubjectPublicKeyInfo
+
+ // Extensions
+ DerBuilder exts = new DerBuilder();
+
+ // Subject Key Identifier (critical=false)
+ try {
+ MessageDigest sha1 = MessageDigest.getInstance("SHA-1");
+ // SKI = SHA-1 (public key bytes).
+ // For Ed25519, BouncyCastle calculates SHA-1 over the raw 32-byte public key.
+ // The extension value is an OCTET STRING containing the key identifier (which is also an OCTET STRING per RFC 5280).
+ // However, in X.509, the extension value field is ALREADY an OCTET STRING, so we just wrap the hash in an OCTET STRING.
+ byte[] ski = sha1.digest(pubKey);
+ exts.addSeq(new DerBuilder().addOid("2.5.29.14").addOctetString(new DerBuilder().addOctetString(ski).build()));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IOException("SHA-1 not found", e);
+ }
+
+ // KeyUsage (critical=true, digitalSignature=bit 0)
+ exts.addSeq(new DerBuilder().addOid("2.5.29.15").addBool(true).addOctetString(new DerBuilder().addBitString(new byte[]{(byte) 0x80}, 7).build()));
+
+ // SAN (critical=false)
+ DerBuilder san = new DerBuilder();
+ if (host != null) san.addTag((byte) 0x82, host.getBytes(StandardCharsets.US_ASCII));
+ if (wildcard && host != null) san.addTag((byte) 0x82, ("*." + host).getBytes(StandardCharsets.US_ASCII));
+ if (ip != null) {
+ byte[] ipBytes = InetAddress.getByName(ip).getAddress(); // 4 bytes for IPv4, 16 for IPv6
+ san.addTag((byte) 0x87, ipBytes);
+ }
+ if (ip != null || host != null)
+ exts.addSeq(new DerBuilder().addOid("2.5.29.17").addOctetString(new DerBuilder().addSeq(san).build()));
+
+ // BasicConstraints (critical=true, CA=false)
+ exts.addSeq(new DerBuilder().addOid("2.5.29.19").addBool(true).addOctetString(new DerBuilder().addSeq(new DerBuilder()).build()));
+
+ // ExtendedKeyUsage (critical=false, serverAuth, clientAuth)
+ exts.addSeq(new DerBuilder().addOid("2.5.29.37").addOctetString(new DerBuilder().addSeq(new DerBuilder().addOid("1.3.6.1.5.5.7.3.1").addOid("1.3.6.1.5.5.7.3.2")).build()));
+
+ tbs.addTag((byte) 0xA3, new DerBuilder().addSeq(exts).build());
+
+ return tbs.buildSeq();
+ }
+
+ private static byte[] encodeCert(byte[] tbs, byte[] signature) throws IOException {
+ return new DerBuilder()
+ .addRaw(tbs)
+ .addSeq(new DerBuilder().addOid("1.3.101.112"))
+ .addBitString(signature)
+ .buildSeq();
+ }
+
+ private static class DerBuilder {
+ private final ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ public DerBuilder addRaw(byte[] raw) throws IOException {
+ out.write(raw);
+ return this;
+ }
+
+ public DerBuilder addTag(byte tag, byte[] val) throws IOException {
+ out.write(tag);
+ writeLen(val.length);
+ out.write(val);
+ return this;
+ }
+
+ public DerBuilder addInt(long v) throws IOException {
+ return addInt(BigInteger.valueOf(v));
+ }
+
+ public DerBuilder addInt(BigInteger v) throws IOException {
+ return addTag((byte) 0x02, v.toByteArray());
+ }
+
+ public DerBuilder addOid(String oid) throws IOException {
+ String[] parts = oid.split("\\.");
+ ByteArrayOutputStream b = new ByteArrayOutputStream();
+ b.write(Integer.parseInt(parts[0]) * 40 + Integer.parseInt(parts[1]));
+ for (int i = 2; i < parts.length; i++) {
+ long v = Long.parseLong(parts[i]);
+ if (v == 0) b.write(0);
+ else {
+ byte[] buf = new byte[10];
+ int pos = 10;
+ buf[--pos] = (byte) (v & 0x7F);
+ while ((v >>= 7) > 0) buf[--pos] = (byte) ((v & 0x7F) | 0x80);
+ b.write(buf, pos, 10 - pos);
+ }
+ }
+ return addTag((byte) 0x06, b.toByteArray());
+ }
+
+ public DerBuilder addPrintableString(String s) throws IOException {
+ return addTag((byte) 0x13, s.getBytes(StandardCharsets.US_ASCII));
+ }
+
+ // RFC 5280: encode dates before 2050 as UTCTime, and 2050 or later as GeneralizedTime.
+ public DerBuilder addTime(Date d) throws IOException {
+ int year = d.toInstant().atZone(ZoneOffset.UTC).getYear();
+ if (year < 2050) {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyMMddHHmmss'Z'");
+ sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return addTag((byte) 0x17, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // UTCTime
+ } else {
+ SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMddHHmmss'Z'");
+ sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return addTag((byte) 0x18, sdf.format(d).getBytes(StandardCharsets.US_ASCII)); // GeneralizedTime
+ }
+ }
+
+ public DerBuilder addBitString(byte[] b) throws IOException {
+ return addBitString(b, 0);
+ }
+
+ public DerBuilder addBitString(byte[] b, int pad) throws IOException {
+ byte[] val = new byte[b.length + 1];
+ val[0] = (byte) pad;
+ System.arraycopy(b, 0, val, 1, b.length);
+ return addTag((byte) 0x03, val);
+ }
+
+ public DerBuilder addOctetString(byte[] b) throws IOException {
+ return addTag((byte) 0x04, b);
+ }
+
+ public DerBuilder addBool(boolean v) throws IOException {
+ return addTag((byte) 0x01, new byte[]{(byte) (v ? 0xFF : 0x00)});
+ }
+
+ public DerBuilder addSeq(DerBuilder b) throws IOException {
+ return addTag((byte) 0x30, b.build());
+ }
+
+ public DerBuilder addSet(DerBuilder b) throws IOException {
+ return addTag((byte) 0x31, b.build());
+ }
+
+ public byte[] build() {
+ return out.toByteArray();
+ }
+
+ public byte[] buildSeq() throws IOException {
+ return new DerBuilder().addSeq(this).build();
+ }
+
+ private void writeLen(int len) {
+ if (len < 128) out.write(len);
+ else {
+ byte[] b = BigInteger.valueOf(len).toByteArray();
+ int skip = (b.length > 1 && b[0] == 0) ? 1 : 0;
+ out.write(0x80 | (b.length - skip));
+ out.write(b, skip, b.length - skip);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java b/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java
index ed4b2b2d..a87ef17e 100644
--- a/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java
+++ b/api/src/test/java/io/bosonnetwork/service/impl/PlainSuperNodeInfoTests.java
@@ -5,6 +5,8 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.util.List;
+
import org.junit.jupiter.api.Test;
import io.bosonnetwork.Id;
@@ -16,8 +18,7 @@ public void testBasicProperties() {
PlainSuperNodeInfo node = new PlainSuperNodeInfo(id, "127.0.0.1", 8080);
assertEquals(id, node.getId());
- assertEquals("127.0.0.1", node.getHost());
- assertEquals(8080, node.getPort());
+ assertEquals(List.of("127.0.0.1:8080"), node.getAddresses());
assertEquals("http://127.0.0.1:8080", node.getApiEndpoint());
assertFalse(node.getSoftware().isPresent());
assertFalse(node.getVersion().isPresent());
@@ -35,7 +36,7 @@ public void testBasicProperties() {
@Test
public void testCustomApiEndpoint() {
Id id = Id.random();
- PlainSuperNodeInfo node = new PlainSuperNodeInfo(id, "127.0.0.1", 8080, "https://api.example.com");
+ PlainSuperNodeInfo node = new PlainSuperNodeInfo(id, List.of("127.0.0.1:8080"), "https://api.example.com");
assertEquals("https://api.example.com", node.getApiEndpoint());
}
diff --git a/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java b/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java
index 7c53b051..125ebe26 100644
--- a/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java
+++ b/api/src/test/java/io/bosonnetwork/service/impl/StaticFederationContextTests.java
@@ -3,7 +3,6 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
@@ -16,8 +15,8 @@
import io.bosonnetwork.Id;
import io.bosonnetwork.Identity;
import io.bosonnetwork.crypto.CryptoIdentity;
-import io.bosonnetwork.service.SuperNodeInfo;
import io.bosonnetwork.service.ServiceInfo;
+import io.bosonnetwork.service.SuperNodeInfo;
public class StaticFederationContextTests {
private StaticFederationContext context;
@@ -41,7 +40,7 @@ public void testAddAndGetNode() throws ExecutionException, InterruptedException
SuperNodeInfo node = context.getNode(nodeId, true).get().orElseThrow();
assertNotNull(node);
assertEquals(nodeId, node.getId());
- assertEquals("localhost", node.getHost());
+ assertEquals(List.of("localhost:8080"), node.getAddresses());
assertTrue(context.getNode(Id.random(), true).get().isEmpty());
}
diff --git a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java
index e713b271..15297eae 100644
--- a/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java
+++ b/dht/src/main/java/io/bosonnetwork/kademlia/KadNode.java
@@ -161,6 +161,11 @@ public Optional
+ * The presence of each address records which family answered: on the returned node,
+ * {@link NodeInfo#hasAddress4()}/{@link NodeInfo#hasAddress6()} are true only for the families that
+ * contributed a result. A dual-stack node that responded on only one family therefore yields a
+ * single-address {@link NodeInfo}.
*/
private static @Nullable NodeInfo mergeNodeInfo(Id id, @Nullable NodeInfo n4, @Nullable NodeInfo n6) {
if (n4 == null && n6 == null)
@@ -171,6 +176,36 @@ public Optional