diff --git a/build.gradle b/build.gradle index 11264eb..79e2d3a 100644 --- a/build.gradle +++ b/build.gradle @@ -113,6 +113,10 @@ dependencies { implementation('org.hibernate.validator:hibernate-validator:8.0.0.Final') implementation 'jakarta.validation:jakarta.validation-api:3.0.0' implementation('org.glassfish:jakarta.el:4.0.2') + // Jakarta XML Binding API + implementation 'jakarta.xml.bind:jakarta.xml.bind-api:4.0.0' + // Jakarta XML Binding Implementation + implementation 'org.glassfish.jaxb:jaxb-runtime:4.0.3' api("org.web3j:utils:${web3jVersion}") api("org.web3j:core:${web3jVersion}") diff --git a/src/main/java/org/apro/sdk/auth/Auth.java b/src/main/java/org/apro/sdk/auth/Auth.java new file mode 100644 index 0000000..56eeb91 --- /dev/null +++ b/src/main/java/org/apro/sdk/auth/Auth.java @@ -0,0 +1,14 @@ +package org.apro.sdk.auth; + +/** + * Auth interface for signing requests + */ +public interface Auth { + /** + * Generate signature for the given content + * @param content Content to sign + * @return Generated signature + * @throws Exception if signing fails + */ + String sign(String content) throws Exception; +} diff --git a/src/main/java/org/apro/sdk/auth/Credential.java b/src/main/java/org/apro/sdk/auth/Credential.java new file mode 100644 index 0000000..2b4ab04 --- /dev/null +++ b/src/main/java/org/apro/sdk/auth/Credential.java @@ -0,0 +1,24 @@ +package org.apro.sdk.auth; + +import lombok.AllArgsConstructor; + +/** + * Credential class for authentication + */ +@AllArgsConstructor +public class Credential { + private final Auth signer; + + /** + * Generate authorization header value + * @param content Content to generate authorization for + * @return Authorization header value + * @throws Exception if authorization generation fails + */ + public String generateAuthorization(String content) throws Exception { + if (signer == null) { + return ""; + } + return signer.sign(content); + } +} diff --git a/src/main/java/org/apro/sdk/auth/DefaultSigner.java b/src/main/java/org/apro/sdk/auth/DefaultSigner.java new file mode 100644 index 0000000..f291852 --- /dev/null +++ b/src/main/java/org/apro/sdk/auth/DefaultSigner.java @@ -0,0 +1,35 @@ +package org.apro.sdk.auth; + +import lombok.AllArgsConstructor; +import lombok.Builder; + +/** + * Default implementation of Auth interface + */ +@Builder +@AllArgsConstructor +public class DefaultSigner implements Auth { + private final String accessKey; + private final String secretKey; + + @Override + public String sign(String content) throws Exception { + // Implement your signing logic here + // This is just a placeholder implementation + if (content == null || content.isEmpty()) { + return ""; + } + + // Example format: "AccessKey=xxx,Signature=yyy" + return String.format("AccessKey=%s,Signature=%s", + accessKey, + calculateSignature(content) + ); + } + + private String calculateSignature(String content) { + // Implement your actual signature calculation logic here + // This might involve HMAC-SHA256 or other cryptographic functions + return "signature-placeholder"; + } +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/models/APIError.java b/src/main/java/org/apro/sdk/models/APIError.java new file mode 100644 index 0000000..41a2ec5 --- /dev/null +++ b/src/main/java/org/apro/sdk/models/APIError.java @@ -0,0 +1,29 @@ +package org.apro.sdk.models; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import lombok.Getter; +import java.io.IOException; +import java.util.List; +import java.util.Map; + +@Getter +public class APIError extends IOException { + @JsonIgnore + private final int statusCode; + + @JsonIgnore + private final Map> header; + + @JsonIgnore + private final String body; + + private String code; + private String message; + + public APIError(int statusCode, Map> header, String body) { + super(String.format("Status Code: %d, Body: %s", statusCode, body)); + this.statusCode = statusCode; + this.header = header; + this.body = body; + } +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/models/APIResult.java b/src/main/java/org/apro/sdk/models/APIResult.java new file mode 100644 index 0000000..6217d52 --- /dev/null +++ b/src/main/java/org/apro/sdk/models/APIResult.java @@ -0,0 +1,56 @@ +package org.apro.sdk.models; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.Getter; +import okhttp3.Request; +import okhttp3.Response; + +import java.io.IOException; + +/** + * API request result containing both request and response + */ +@Getter +@AllArgsConstructor +public class APIResult { + /** + * The HTTP request used for this API call + */ + private final Request request; + + /** + * The HTTP response received from this API call + */ + private final Response response; + + /** + * Get response body as string. This will consume the response body. + * + * @return Response body as string + * @throws IOException if reading the response body fails + */ + public String getBodyAsString() throws IOException { + if (response.body() == null) { + return ""; + } + return response.body().string(); + } + + /** + * Parse response body into the specified type + * + * @param responseType Class of the response type + * @return Parsed response object + * @throws IOException if parsing fails + */ + public T parseBody(Class responseType) throws IOException { + if (response.body() == null) { + return null; + } + + String bodyString = response.body().string(); + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(bodyString, responseType); + } +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/util/HttpClient.java b/src/main/java/org/apro/sdk/util/HttpClient.java new file mode 100644 index 0000000..97e47e5 --- /dev/null +++ b/src/main/java/org/apro/sdk/util/HttpClient.java @@ -0,0 +1,158 @@ +package org.apro.sdk.util; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import jakarta.xml.bind.Marshaller; +import okhttp3.*; +import okio.Buffer; +import org.apro.sdk.auth.Credential; +import org.apro.sdk.vrf.constant.Constants; +import org.apro.sdk.models.APIError; +import org.apro.sdk.models.APIResult; + +import java.io.*; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.regex.Pattern; + +public class HttpClient { + private static final Pattern JSON_TYPE_PATTERN = + Pattern.compile("(?i:(?:application|text)/(?:vnd\\.[^;]+\\+)?json)"); + private static final Pattern XML_TYPE_PATTERN = + Pattern.compile("(?i:(?:application|text)/xml)"); + + private final OkHttpClient httpClient; + private final Credential credential; + private final ObjectMapper objectMapper; + + public static HttpClient newDefaultHttpClient() { + return new HttpClient(null, null); + } + + public HttpClient(Credential credential, OkHttpClient httpClient) { + this.credential = credential; + this.httpClient = httpClient != null ? httpClient : createDefaultHttpClient(); + this.objectMapper = new ObjectMapper(); + } + + private OkHttpClient createDefaultHttpClient() { + return new OkHttpClient.Builder() + .connectTimeout(Constants.DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS) + .readTimeout(Constants.DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS) + .writeTimeout(Constants.DEFAULT_TIMEOUT, TimeUnit.MILLISECONDS) + .build(); + } + + /** + * Send request with full control over HTTP method, path, headers, query parameters and body + */ + public APIResult request( + String method, + String requestPath, + Map headers, + Map queryParams, + Object postBody, + String contentType + ) throws Exception { + // Build URL with query parameters + HttpUrl.Builder urlBuilder = HttpUrl.parse(requestPath).newBuilder(); + if (queryParams != null) { + for (Map.Entry entry : queryParams.entrySet()) { + urlBuilder.addQueryParameter(entry.getKey(), entry.getValue()); + } + } + + // Build request body + RequestBody body = null; + String signBody = ""; + if (postBody != null) { + if (contentType == null || contentType.isEmpty()) { + contentType = Constants.APPLICATION_JSON; + } + body = buildRequestBody(postBody, contentType); + signBody = bodyToString(body); + } + + // Build request + Request.Builder requestBuilder = new Request.Builder() + .url(urlBuilder.build()) + .method(method, body); + + // Add headers + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + requestBuilder.addHeader(entry.getKey(), entry.getValue()); + } + } + + // Add fixed headers + requestBuilder.addHeader(Constants.ACCEPT, "*/*"); + requestBuilder.addHeader(Constants.CONTENT_TYPE, contentType); + String userAgent = String.format(Constants.USER_AGENT_FORMAT, + Constants.VERSION, System.getProperty("os.name"), System.getProperty("java.version")); + requestBuilder.addHeader(Constants.USER_AGENT, userAgent); + + // Add auth header + if (credential != null) { + String authHeader = credential.generateAuthorization(signBody); + if (authHeader != null && !authHeader.isEmpty()) { + requestBuilder.addHeader(Constants.AUTHORIZATION, authHeader); + } + } + + // Execute request + Request request = requestBuilder.build(); + + Response response = httpClient.newCall(request).execute(); + + // Check response + checkResponse(response); + + return new APIResult(request, response); + } + + private RequestBody buildRequestBody(Object body, String contentType) throws IOException, JAXBException { + if (body instanceof String) { + return RequestBody.create(MediaType.parse(contentType), (String) body); + } else if (body instanceof byte[]) { + return RequestBody.create(MediaType.parse(contentType), (byte[]) body); + } else if (body instanceof File) { + return RequestBody.create(MediaType.parse(contentType), (File) body); + } else if (JSON_TYPE_PATTERN.matcher(contentType).matches()) { + String json = objectMapper.writeValueAsString(body); + return RequestBody.create(MediaType.parse(contentType), json); + } else if (XML_TYPE_PATTERN.matcher(contentType).matches()) { + StringWriter writer = new StringWriter(); + JAXBContext context = JAXBContext.newInstance(body.getClass()); + Marshaller marshaller = context.createMarshaller(); + marshaller.marshal(body, writer); + return RequestBody.create(MediaType.parse(contentType), writer.toString()); + } + throw new IllegalArgumentException("Unsupported body type: " + body.getClass()); + } + + private String bodyToString(RequestBody body) throws IOException { + if (body == null) return ""; + Buffer buffer = new Buffer(); + body.writeTo(buffer); + return buffer.readUtf8(); + } + + private void checkResponse(Response response) throws IOException { + if (!response.isSuccessful()) { + String responseBody = response.body().string(); + APIError apiError = new APIError( + response.code(), + response.headers().toMultimap(), + responseBody + ); + try { + objectMapper.readerForUpdating(apiError).readValue(responseBody); + } catch (IOException ignored) { + // Ignore JSON parsing errors + } + throw apiError; + } + } +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/util/Utils.java b/src/main/java/org/apro/sdk/util/Utils.java index abb194d..6d1a799 100644 --- a/src/main/java/org/apro/sdk/util/Utils.java +++ b/src/main/java/org/apro/sdk/util/Utils.java @@ -11,6 +11,7 @@ import org.web3j.crypto.Sign; import org.web3j.utils.Numeric; +import java.security.SecureRandom; import java.util.Arrays; import java.util.List; import java.util.UUID; @@ -21,6 +22,10 @@ public class Utils { private static final Pattern ETH_ADDRESS_PATTERN = Pattern.compile("^0x[a-fA-F0-9]{40}$"); public static final Pattern UUID_REGEX = Pattern.compile("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"); + private static final Pattern HEX_PATTERN = Pattern.compile("^0x[0-9a-fA-F]*$"); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final String ALPHANUMERIC = "0123456789ABCDEF"; + public static byte[] toBytes(String hex) { String hexWithoutPrefix = Numeric.cleanHexPrefix(hex); @@ -73,4 +78,55 @@ public static String encodeSignaturesToString(List signature List parameters = Arrays.asList(rsArray, ssArray, vArray); return FunctionEncoder.encodeConstructor(parameters); } + + public static byte[] longToBytes(long x) { + byte[] result = new byte[8]; + for (int i = 7; i >= 0; i--) { + result[i] = (byte)(x & 0xFF); + x >>= 8; + } + return result; + } + + public static byte[] concat(byte[] a, byte[] b) { + byte[] result = Arrays.copyOf(a, a.length + b.length); + System.arraycopy(b, 0, result, a.length, b.length); + return result; + } + + public static String bytesToHex(byte[] bytes) { + StringBuilder result = new StringBuilder(); + for (byte b : bytes) { + result.append(String.format("%02x", b)); + } + return result.toString(); + } + + public static byte[] hexStringToBytes(String hex) { + if (hex.startsWith("0x")) { + hex = hex.substring(2); + } + int len = hex.length(); + byte[] result = new byte[len / 2]; + for (int i = 0; i < len; i += 2) { + result[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16); + } + return result; + } + + public static boolean isHexString(String str) { + return str != null && HEX_PATTERN.matcher(str).matches(); + } + + /** + * Generate a secure random string of specified length + */ + public static String secureRandomString(int length) { + StringBuilder sb = new StringBuilder(length); + for (int i = 0; i < length; i++) { + sb.append(ALPHANUMERIC.charAt(SECURE_RANDOM.nextInt(ALPHANUMERIC.length()))); + } + return sb.toString(); + } + } diff --git a/src/main/java/org/apro/sdk/vrf/ErrCGammaEqualsSHash.java b/src/main/java/org/apro/sdk/vrf/ErrCGammaEqualsSHash.java new file mode 100644 index 0000000..ec0a165 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/ErrCGammaEqualsSHash.java @@ -0,0 +1,8 @@ +package org.apro.sdk.vrf; + +public class ErrCGammaEqualsSHash extends Exception { + + public ErrCGammaEqualsSHash(String msg) { + super(msg); + } +} diff --git a/src/main/java/org/apro/sdk/vrf/Service.java b/src/main/java/org/apro/sdk/vrf/Service.java new file mode 100644 index 0000000..83fb188 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/Service.java @@ -0,0 +1,18 @@ +package org.apro.sdk.vrf; + +import lombok.Getter; +import lombok.Setter; +import org.apro.sdk.util.HttpClient; + +/** + * ATTPs API v1 Java SDK Service Type + */ +@Getter +@Setter +public class Service { + protected HttpClient client; + + public Service(HttpClient client) { + this.client = client; + } +} diff --git a/src/main/java/org/apro/sdk/vrf/VRF.java b/src/main/java/org/apro/sdk/vrf/VRF.java new file mode 100644 index 0000000..65649b7 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/VRF.java @@ -0,0 +1,319 @@ +package org.apro.sdk.vrf; + +import lombok.extern.slf4j.Slf4j; +import org.apro.sdk.vrf.models.Proof; +import org.bouncycastle.asn1.sec.SECNamedCurves; +import org.bouncycastle.asn1.x9.X9ECParameters; +import org.bouncycastle.crypto.params.ECDomainParameters; +import org.bouncycastle.jce.spec.ECParameterSpec; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.BigIntegers; +import org.web3j.crypto.Hash; + +import java.math.BigInteger; +import java.util.Arrays; + +@Slf4j +public class VRF { + + public static final X9ECParameters params = SECNamedCurves.getByName("secp256k1"); + public static final ECDomainParameters CURVE = new ECDomainParameters(params.getCurve(), params.getG(), params.getN(), params.getH()); + public static final ECParameterSpec CURVE_SPEC = new ECParameterSpec(params.getCurve(), params.getG(), params.getN(), params.getH()); + + public static final ECPoint generator = CURVE_SPEC.getG(); + // field num on Fp + public static final BigInteger fieldSize = + CURVE_SPEC.getCurve().getField().getCharacteristic(); + // point num on Eclipse Curve + public static final BigInteger groupOrder = CURVE_SPEC.getN(); + + //some predefine constant + public final static int HashLength = 32; + private final static BigInteger zero, one, two, three, four, seven, eulerCriterionPower, sqrtPower; + // some prefix, byte[32] + private final static byte[] hashToCurveHashPrefix; //1 + private final static byte[] scalarFromCurveHashPrefix; //2 + public final static byte[] vrfRandomOutputHashPrefix; //3 + + public static final int ProofLength = 64 + // PublicKey + 64 + // Gamma + 32 + // C + 32 + // S + 32 + // Seed + 32 + // uWitness (gets padded to 256 bits, even though it's only 160) + 64 + // cGammaWitness + 64 + // sHashWitness + 32; // zInv (Leave Output out, because that can be efficiently calculated) + + + static { + zero = BigInteger.valueOf(0); + one = BigInteger.valueOf(1); + two = BigInteger.valueOf(2); + three = BigInteger.valueOf(3); + four = BigInteger.valueOf(4); + seven = BigInteger.valueOf(7); + + eulerCriterionPower = fieldSize.subtract(one).divide(two); // (p-1)/2 + sqrtPower = fieldSize.add(one).divide(four); // (p+1)/4 + + hashToCurveHashPrefix = bytesToHash(one.toByteArray()); + scalarFromCurveHashPrefix = bytesToHash(two.toByteArray()); + vrfRandomOutputHashPrefix = bytesToHash(three.toByteArray()); + } + + + public static ECPoint createPoint(BigInteger x, BigInteger y) { + return CURVE_SPEC.getCurve().createPoint(x, y); + } + + /** + * get last HashLength byte of b; if len(b) < HashLength, padding with 0 before. + * equal to function BigToHash in go module. + */ + public static byte[] bytesToHash(byte[] b) { + byte[] hash = new byte[HashLength]; + if (b.length > HashLength) { + hash = Arrays.copyOfRange(b, b.length - HashLength, b.length); + } else { + System.arraycopy(b, 0, hash, HashLength - b.length, b.length); + } + return hash; + } + + /** + * linearCombination of scalar and EcPoint, [c]·p1 + [s]·p2 + */ + public ECPoint linearCombination(BigInteger c, ECPoint p1, BigInteger s, ECPoint p2) { + ECPoint p11 = p1.multiply(c.mod(groupOrder)).normalize(); + ECPoint p22 = p2.multiply(s.mod(groupOrder)).normalize(); + return p11.add(p22).normalize(); + } + + /** + * represent one ECPoint with byte array. ECPoint must be normalized already. + */ + public static byte[] longMarshal(ECPoint p1) { + byte[] x = BigIntegers.asUnsignedByteArray(32, p1.getRawXCoord().toBigInteger()); + byte[] y = BigIntegers.asUnsignedByteArray(32, p1.getRawYCoord().toBigInteger()); + byte[] merged = new byte[x.length + y.length]; + System.arraycopy(x, 0, merged, 0, x.length); + System.arraycopy(y, 0, merged, x.length, y.length); + return merged; + } + + /** + * MustHash returns the keccak256 hash, or panics on failure, 32 byte + */ + public static byte[] mustHash(byte[] in) { + return Hash.sha3(in); + } + + /** + * concat the 1,2,3,5th point which has the form of p.x||p.y, join uWitness, at last sha3 the result + */ + public BigInteger scalarFromCurvePoints(ECPoint hash, ECPoint pk, ECPoint gamma, byte[] uWitness, + ECPoint v) throws VRFException { + if (!(hash.isValid() && pk.isValid() && gamma.isValid() && v.isValid())) { + throw new VRFException("bad arguments to vrf.ScalarFromCurvePoints"); + } + + byte[] merged = new byte[32 + 64 + 64 + 64 + 64 + 20]; + System.arraycopy(scalarFromCurveHashPrefix, 0, merged, 0, 32); + System.arraycopy(longMarshal(hash), 0, merged, 32, 64); + System.arraycopy(longMarshal(pk), 0, merged, 96, 64); + System.arraycopy(longMarshal(gamma), 0, merged, 160, 64); + System.arraycopy(longMarshal(v), 0, merged, 224, 64); + System.arraycopy(uWitness, 0, merged, 288, 20); + byte[] mustHash = mustHash(merged); + + return new BigInteger(1, mustHash); + } + + /** + * convert sha3(message) to the field element on Fp + */ + public BigInteger fieldHash(byte[] message) { + byte[] hashResult = mustHash(message); + BigInteger rv = new BigInteger(1, bytesToHash(hashResult)); + + while (rv.compareTo(fieldSize) >= 0) { + byte[] shortRV = bytesToHash(BigIntegers.asUnsignedByteArray(rv)); + rv = new BigInteger(1, mustHash(shortRV)); + } + return rv; + } + + /** + * left pad byte 0 of slice to length l + */ + public byte[] leftPadBytes(byte[] slice, int l) { + if (slice.length >= l) { + return slice; + } + + byte[] newSlice = new byte[l]; + System.arraycopy(slice, 0, newSlice, l - slice.length, slice.length); + return newSlice; + } + + /** + * convert uint256 to byte array, without sign byte + */ + public byte[] uint256ToBytes32(BigInteger uint256) throws VRFException { + if (BigIntegers.asUnsignedByteArray(uint256).length > HashLength) { //256=HashLength*8 + throw new VRFException("vrf.uint256ToBytes32: too big to marshal to uint256"); + } + return leftPadBytes(BigIntegers.asUnsignedByteArray(uint256), HashLength); + } + + /** + * x => x^3 + 7, + */ + public BigInteger ySquare(BigInteger x) { + return x.modPow(three, fieldSize).add(seven).mod(fieldSize); + } + + /** + * check whether a BigInteger is the square of some element on Fp. + */ + public boolean isSquare(BigInteger x) { + return x.modPow(eulerCriterionPower, fieldSize).compareTo(one) == 0; + } + + /** + * check whether one BigInteger can be the x coordinate of Curve + */ + public boolean isCurveXOrdinate(BigInteger x) { + return isSquare(ySquare(x)); + } + + /** + * SquareRoot returns a s.t. a^2=x, as long as x is a square + */ + public BigInteger squareRoot(BigInteger x) { + return x.modPow(sqrtPower, fieldSize); + } + + public BigInteger neg(BigInteger f) { + return fieldSize.subtract(f); + } + + // projectiveSub(x1, z1, x2, z2) is the projective coordinates of x1/z1 - x2/z2 + public BigInteger[] projectiveSub(BigInteger x1, BigInteger z1, BigInteger x2, BigInteger z2) { + BigInteger num1 = z2.multiply(x1); + BigInteger num2 = neg(z1.multiply(x2)); + return new BigInteger[] {num1.add(num2).mod(fieldSize), z1.multiply(z2).mod(fieldSize)}; + } + + // projectiveMul(x1, z1, x2, z2) is projective coordinates of (x1/z1)×(x2/z2) + public BigInteger[] projectiveMul(BigInteger x1, BigInteger z1, BigInteger x2, BigInteger z2) { + return new BigInteger[] {x1.multiply(x2), z1.multiply(z2)}; + } + + /** + * create an uncompressed ECPoint with coordinate x,y on Curve + */ + public ECPoint setCoordinates(BigInteger x, BigInteger y) throws VRFException { +// ECPoint rv = ECKey.CURVE_SPEC.getCurve().createPoint(x, y); + ECPoint rv = CURVE_SPEC.getCurve().createPoint(x, y); + if (!rv.isValid()) { + throw new VRFException("point requested from invalid coordinates"); + } + return rv; + } + + /** + * get a ECPoint whose coordinate x = hash(p.x||p.y||seed) + */ + public ECPoint hashToCurve(ECPoint p, BigInteger seed) throws VRFException { + if (!(p.isValid() && seed.toByteArray().length <= 256 && seed.compareTo(zero) >= 0)) { + throw new VRFException("bad input to vrf.HashToCurve"); + } + byte[] inputTo32Byte = uint256ToBytes32(seed); + + byte[] merged = new byte[32 + 64 + 32]; + System.arraycopy(hashToCurveHashPrefix, 0, merged, 0, 32); + System.arraycopy(longMarshal(p), 0, merged, 32, 64); + System.arraycopy(inputTo32Byte, 0, merged, 96, 32); + + BigInteger x = fieldHash(merged); + + while (!isCurveXOrdinate(x)) { // Hash recursively until x^3+7 is a square + x = fieldHash(bytesToHash(BigIntegers.asUnsignedByteArray(x))); + } + BigInteger y_2 = ySquare(x); + BigInteger y = squareRoot(y_2); + ECPoint rv = setCoordinates(x, y); + + // Negate response if y odd + if (y.mod(two).compareTo(one) == 0) { + rv = rv.negate(); + } + return rv; + } + + /** + * check if [c]·gamma ≠ [s]·hash as required by solidity verifier + * + * @return false if [c]·gamma ≠ [s]·hash else true + */ + public boolean checkCGammaNotEqualToSHash(BigInteger c, ECPoint gamma, BigInteger s, + ECPoint hash) { + ECPoint p1 = gamma.multiply(c.mod(groupOrder)).normalize(); + ECPoint p2 = hash.multiply(s.mod(groupOrder)).normalize(); + return !p1.equals(p2); + } + + /** + * get last 160 bit of sha3(p.x||p.y),equal to function EthereumAddress in go module + */ + public byte[] getLast160BitOfPoint(ECPoint point) { + byte[] sha3Result = mustHash(longMarshal(point)); + + byte[] cv = new byte[20]; + System.arraycopy(sha3Result, 12, cv, 0, 20); + return cv; + } + + /** + * VerifyVRFProof is true iff gamma was generated in the mandated way from the + * given publicKey and seed, and no error was encountered + */ + public boolean verifyVRFProof(Proof proof) throws ErrCGammaEqualsSHash, VRFException { + if (!proof.wellFormed()) { + throw new VRFException("badly-formatted proof"); + } + ECPoint h = hashToCurve(proof.PublicKey, proof.Seed).normalize(); + + boolean notEqual = checkCGammaNotEqualToSHash(proof.C, proof.getGamma(), proof.S, h); + if (!notEqual) { + throw new ErrCGammaEqualsSHash("c*γ = s*hash (disallowed in solidity verifier)"); + } + + ECPoint uPrime = linearCombination(proof.C, proof.PublicKey, proof.S, generator); + ECPoint vPrime = linearCombination(proof.C, proof.getGamma(), proof.S, h); + + byte[] uWitness = getLast160BitOfPoint(uPrime); + BigInteger cPrime = scalarFromCurvePoints(h, proof.PublicKey, proof.getGamma(), uWitness, + vPrime); + byte[] gammaRepresent = longMarshal(proof.getGamma()); + + byte[] prefixAndGamma = new byte[vrfRandomOutputHashPrefix.length + gammaRepresent.length]; + System.arraycopy(vrfRandomOutputHashPrefix, 0, prefixAndGamma, 0, + vrfRandomOutputHashPrefix.length); + System.arraycopy(gammaRepresent, 0, prefixAndGamma, vrfRandomOutputHashPrefix.length, + gammaRepresent.length); + byte[] output = mustHash(prefixAndGamma); + + // check if point proof.β == point cPrime + if (!(proof.C.compareTo(cPrime) == 0)) { + return false; + } + // check if proof.Output == output + if (!(proof.Output.compareTo(new BigInteger(1, output)) == 0)) { + return false; + } + return true; + } +} diff --git a/src/main/java/org/apro/sdk/vrf/VRFClient.java b/src/main/java/org/apro/sdk/vrf/VRFClient.java new file mode 100644 index 0000000..a787095 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/VRFClient.java @@ -0,0 +1,182 @@ +package org.apro.sdk.vrf; + +import lombok.extern.slf4j.Slf4j; +import org.apro.sdk.util.HttpClient; +import org.apro.sdk.util.Utils; +import org.apro.sdk.vrf.constant.Constants; +import org.apro.sdk.models.APIResult; +import org.apro.sdk.vrf.models.*; +import org.web3j.crypto.Hash; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +public class VRFClient extends Service { + + VRF vrf = new VRF(); + + public VRFClient(HttpClient client) { + super(client); + } + + /** + * Get a list of VRF random providers + * + * @return List of providers + * @throws Exception if the request fails + */ + public List getProviders() throws Exception { + // Setup HTTP method and path + String httpMethod = "GET"; + String path = Constants.API_BASE_SERVER + "/api/vrf/provider"; + + // Initialize headers + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + // Perform HTTP request + APIResult result = getClient().request( + httpMethod, + path, + headers, + null, // no query params + null, // no body + "application/json" + ); + + // Parse response + ProviderResponse response = result.parseBody(ProviderResponse.class); + + // Check response status + if (response.getCode() != 0) { + throw new IllegalStateException( + response.getMessage() != null ? response.getMessage() : "Unknown error" + ); + } + + // Return provider list + return response.getResult(); + } + + /** + * Query VRF random proof by request ID + * + * @param requestId The request ID to query + * @return VRF proof data + * @throws Exception if the query fails or proof verification fails + */ + public VRFProof queryProof(String requestId) throws Exception { + // Setup HTTP method and path + String httpMethod = "GET"; + String path = Constants.API_BASE_SERVER + "/api/vrf/query"; + + // Initialize headers + Map headers = new HashMap<>(); + headers.put("Content-Type", "application/json"); + + // Setup query parameters + Map queryParams = new HashMap<>(); + queryParams.put("request_id", requestId); + + // Perform HTTP request + APIResult result = getClient().request( + httpMethod, + path, + headers, + queryParams, + null, // no body + "application/json" + ); + + // Parse response + VRFProofResponse response = result.parseBody(VRFProofResponse.class); + + // Check response status + if (response.getCode() != 0) { + throw new IllegalStateException( + response.getMessage() != null ? response.getMessage() : "Unknown error" + ); + } + + // Verify proof + boolean status = this.vrf.verifyVRFProof(response.getResult().convert()); + if (!status) { + throw new IllegalStateException("Invalid proof"); + } + + return response.getResult(); + } + + + /** + * Request a VRFClient random number and verify the returned proof data + */ + public VRFResponse request(VRFRequest req) throws Exception { + String httpMethod = "POST"; + String path = Constants.API_BASE_SERVER + "/api/vrf/request"; + Map headers = new HashMap<>(); + + // Check request parameters + checkRequestParams(req); + + // Set content type + headers.put("Content-Type", "application/json"); + + // Perform HTTP request + APIResult result = getClient().request( + httpMethod, + path, + headers, + null, // no query params + req, + "application/json" + ); + + return result.parseBody(VRFResponse.class); + } + + /** + * Calculate request ID based on input parameters + */ + public String calculateRequestId(long version, String targetAgentId, String customerFeed, + long requestTimestamp, String callbackUri) { + // Concatenate byte arrays + byte[] combined = Utils.longToBytes(version); + combined = Utils.concat(combined, targetAgentId.getBytes(StandardCharsets.UTF_8)); + combined = Utils.concat(combined, Utils.hexStringToBytes(customerFeed)); + combined = Utils.concat(combined, Utils.longToBytes(requestTimestamp)); + combined = Utils.concat(combined, callbackUri.getBytes(StandardCharsets.UTF_8)); + + // Compute Keccak256 hash + byte[] hash = Hash.sha3(combined); + return Utils.bytesToHex(hash); + } + + private void checkRequestParams(VRFRequest req) throws IllegalArgumentException { + if (req.getVersion() != Constants.VRF_VERSION) { + throw new IllegalArgumentException( + String.format("VRFClient version mismatch, must be %d", Constants.VRF_VERSION)); + } + + if (!Utils.checkUUID(req.getTargetAgentId())) { + throw new IllegalArgumentException( + String.format("Invalid target agent id: %s", req.getTargetAgentId())); + } + + String requestId = calculateRequestId( + req.getVersion(), + req.getTargetAgentId(), + req.getClientSeed(), + req.getRequestTimestamp(), + req.getCallbackUri() + ); + + if (!req.getRequestId().equals(requestId)) { + throw new IllegalArgumentException( + String.format("Invalid request ID: %s", req.getRequestId())); + } + } +} diff --git a/src/main/java/org/apro/sdk/vrf/VRFException.java b/src/main/java/org/apro/sdk/vrf/VRFException.java new file mode 100644 index 0000000..b57939d --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/VRFException.java @@ -0,0 +1,8 @@ +package org.apro.sdk.vrf; + +public class VRFException extends Exception { + + public VRFException(String msg) { + super(msg); + } +} diff --git a/src/main/java/org/apro/sdk/vrf/constant/Constants.java b/src/main/java/org/apro/sdk/vrf/constant/Constants.java new file mode 100644 index 0000000..7472eb0 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/constant/Constants.java @@ -0,0 +1,36 @@ +package org.apro.sdk.vrf.constant; + +/** + * Constants used in the SDK + */ +public class Constants { + // SDK related information + public static final String VERSION = "0.0.1"; + public static final String USER_AGENT_FORMAT = "ATTPs-Java/%s (%s) JAVA/%s"; + public static final String API_BASE_SERVER = "http://10.0.54.95:8888"; + + // HTTP request message Header related constants + public static final String ACCEPT = "Accept"; + public static final String CONTENT_TYPE = "Content-Type"; + public static final String CONTENT_LENGTH = "Content-Length"; + public static final String USER_AGENT = "User-Agent"; + public static final String AUTHORIZATION = "Authorization"; + + // Common ContentType + public static final String APPLICATION_JSON = "application/json"; + public static final String IMAGE_JPG = "image/jpg"; + public static final String IMAGE_PNG = "image/png"; + public static final String VIDEO_MP4 = "video/mp4"; + + // Time related constants + public static final int FIVE_MINUTE = 5 * 60; + public static final long DEFAULT_TIMEOUT = 30_000; // 30 seconds in milliseconds + + // VRFClient related constants + public static final int VRF_VERSION = 1; + + // Private constructor to prevent instantiation + private Constants() { + throw new UnsupportedOperationException("Utility class"); + } +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/BaseResponse.java b/src/main/java/org/apro/sdk/vrf/models/BaseResponse.java new file mode 100644 index 0000000..dc80f0e --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/BaseResponse.java @@ -0,0 +1,19 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +/** + * Base Response Details + */ +@Data +public class BaseResponse { + @JsonProperty("message") + private String message; + + @JsonProperty("code") + private Long code; + + @JsonProperty("responseEnum") + private String responseEnum; +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/Proof.java b/src/main/java/org/apro/sdk/vrf/models/Proof.java new file mode 100644 index 0000000..bf8416c --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/Proof.java @@ -0,0 +1,62 @@ +package org.apro.sdk.vrf.models; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.apro.sdk.vrf.VRF; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.util.BigIntegers; + +import java.math.BigInteger; + +/** + * Proof represents a proof that Gamma was constructed from the Seed + * according to the process mandated by the PublicKey. + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +public class Proof { + + public ECPoint PublicKey; + public ECPoint Gamma; + public BigInteger C; + public BigInteger S; + public BigInteger Seed; + public BigInteger Output; + + @Override + public String toString() { + return String.format( + "vrf.Proof{PublicKey: {x:%x, y:%x}, Gamma: {x:%x, y:%x}, C:%x, S:%x, Seed:%x, Output:%x}", + PublicKey.getRawXCoord().toBigInteger(), + PublicKey.getRawYCoord().toBigInteger(), + Gamma.getRawXCoord().toBigInteger(), + Gamma.getRawYCoord().toBigInteger(), + C, + S, + Seed, + Output); + } + + //WellFormed is true if proof's attributes satisfy basic domain checks + public boolean wellFormed() { + BigInteger groupOrder = VRF.groupOrder; + if (!PublicKey.isValid()) { + return false; + } + if (!Gamma.isValid()) { + return false; + } + if (!(C.compareTo(groupOrder) < 0)) { + return false; + } + if (!(S.compareTo(groupOrder) < 0)) { + return false; + } + if (!(BigIntegers.asUnsignedByteArray(Output).length <= VRF.HashLength)) { + return false; + } + return true; + } +} diff --git a/src/main/java/org/apro/sdk/vrf/models/Provider.java b/src/main/java/org/apro/sdk/vrf/models/Provider.java new file mode 100644 index 0000000..d85d952 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/Provider.java @@ -0,0 +1,13 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class Provider { + @JsonProperty("address") + private String address; + + @JsonProperty("keyHash") + private String keyHash; +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/ProviderResponse.java b/src/main/java/org/apro/sdk/vrf/models/ProviderResponse.java new file mode 100644 index 0000000..4e1a5de --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/ProviderResponse.java @@ -0,0 +1,14 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ProviderResponse extends BaseResponse { + @JsonProperty("result") + private List result; +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/VRFProof.java b/src/main/java/org/apro/sdk/vrf/models/VRFProof.java new file mode 100644 index 0000000..f016e94 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/VRFProof.java @@ -0,0 +1,84 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import org.apro.sdk.vrf.VRF; + +import java.math.BigInteger; + +@Data +public class VRFProof { + @JsonProperty("requestId") + private String requestId; + + @JsonProperty("proof") + private ProofOrigin proof; + + public Proof convert() { + return new Proof( + VRF.createPoint(this.proof.getPublicXBigInteger(), this.proof.getPublicYBigInteger()), + VRF.createPoint(this.proof.getGammaXBigInteger(), this.proof.getGammaYBigInteger()), + this.proof.getCBigInteger(), + this.proof.getSBigInteger(), + this.proof.getSeedBigInteger(), + this.proof.getOutputBigInteger() + ); + } + + /** + * Convert the proof to JSON string + * + * @return JSON string representation of the proof + * @throws JsonProcessingException if JSON serialization fails + */ + public String marshal() throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + return mapper.writeValueAsString(this); + } + + @Data + public static class ProofOrigin { + private String publicX; + private String publicY; + private String gammaX; + private String gammaY; + private String c; + private String s; + private String seed; + private String output; + + public BigInteger getPublicXBigInteger() { + return new BigInteger(publicX.replace("0x", ""), 16); + } + + public BigInteger getPublicYBigInteger() { + return new BigInteger(publicY.replace("0x", ""), 16); + } + + public BigInteger getGammaXBigInteger() { + return new BigInteger(gammaX.replace("0x", ""), 16); + } + + public BigInteger getGammaYBigInteger() { + return new BigInteger(gammaY.replace("0x", ""), 16); + } + + public BigInteger getCBigInteger() { + return new BigInteger(c.replace("0x", ""), 16); + } + + public BigInteger getSBigInteger() { + return new BigInteger(s.replace("0x", ""), 16); + } + + public BigInteger getSeedBigInteger() { + return new BigInteger(seed.replace("0x", ""), 16); + } + + public BigInteger getOutputBigInteger() { + return new BigInteger(output.replace("0x", ""), 16); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/VRFProofResponse.java b/src/main/java/org/apro/sdk/vrf/models/VRFProofResponse.java new file mode 100644 index 0000000..26c4160 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/VRFProofResponse.java @@ -0,0 +1,12 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class VRFProofResponse extends BaseResponse { + @JsonProperty("result") + private VRFProof result; +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/VRFRequest.java b/src/main/java/org/apro/sdk/vrf/models/VRFRequest.java new file mode 100644 index 0000000..a53670a --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/VRFRequest.java @@ -0,0 +1,28 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; + +@Data +public class VRFRequest { + @JsonProperty("version") + private Long version; + + @JsonProperty("target_agent_id") + private String targetAgentId; + + @JsonProperty("client_seed") + private String clientSeed; + + @JsonProperty("key_hash") + private String keyHash; + + @JsonProperty("request_timestamp") + private Long requestTimestamp; + + @JsonProperty("request_id") + private String requestId; + + @JsonProperty("callback_uri") + private String callbackUri; +} \ No newline at end of file diff --git a/src/main/java/org/apro/sdk/vrf/models/VRFResponse.java b/src/main/java/org/apro/sdk/vrf/models/VRFResponse.java new file mode 100644 index 0000000..90e9f14 --- /dev/null +++ b/src/main/java/org/apro/sdk/vrf/models/VRFResponse.java @@ -0,0 +1,12 @@ +package org.apro.sdk.vrf.models; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class VRFResponse extends BaseResponse { + @JsonProperty("result") + private String result; +} \ No newline at end of file diff --git a/src/test/java/org/apro/sdk/VRFClientTest.java b/src/test/java/org/apro/sdk/VRFClientTest.java new file mode 100644 index 0000000..36c36cd --- /dev/null +++ b/src/test/java/org/apro/sdk/VRFClientTest.java @@ -0,0 +1,121 @@ +package org.apro.sdk; + +import org.apro.sdk.util.HttpClient; +import org.apro.sdk.util.Utils; +import org.apro.sdk.vrf.models.Proof; +import org.apro.sdk.vrf.VRF; +import org.apro.sdk.vrf.VRFClient; +import org.apro.sdk.vrf.models.VRFProof; +import org.apro.sdk.vrf.models.VRFRequest; +import org.apro.sdk.vrf.models.VRFResponse; +import org.bouncycastle.math.ec.ECPoint; +import org.junit.jupiter.api.Test; +import lombok.extern.slf4j.Slf4j; + +import java.math.BigInteger; + +import static org.apro.sdk.vrf.VRF.CURVE_SPEC; +import static org.junit.jupiter.api.Assertions.*; + +@Slf4j +public class VRFClientTest { + + @Test + void testVRFRequest() throws Exception { + VRFClient client = new VRFClient(HttpClient.newDefaultHttpClient()); + + var providers = client.getProviders(); + assertFalse(providers.isEmpty(), "No providers available"); + + // Prepare request parameters + long version = 1L; + String targetAgentId = Utils.generateUUID(); + String customerSeed = Utils.secureRandomString(4); + long requestTimestamp = System.currentTimeMillis() / 1000; + String keyHash = providers.get(0).getKeyHash(); + String callbackUri = "http://127.0.0.1:8888/api/vrf/proof"; + + // Calculate request ID + String requestId = client.calculateRequestId( + version, + targetAgentId, + customerSeed, + requestTimestamp, + callbackUri + ); + System.out.println(requestId); + + // Create and send request + VRFRequest request = new VRFRequest(); + request.setVersion(version); + request.setTargetAgentId(targetAgentId); + request.setClientSeed(customerSeed); + request.setKeyHash(keyHash); + request.setRequestTimestamp(requestTimestamp); + request.setRequestId(requestId); + request.setCallbackUri(callbackUri); + + VRFResponse random = client.request(request); + assertNotNull(random, "Response should not be null"); + + + // Query proof + VRFProof proof = client.queryProof(requestId); + assertNotNull(proof, "Proof should not be null"); + System.out.printf("Proof: %s", proof.marshal()); + } + + @Test + void testVerifyVRFProof() throws Exception { + VRF vrf = new VRF(); + + // Create test proof + Proof proof = new Proof(); + ECPoint publicK = CURVE_SPEC.getCurve().createPoint( + new BigInteger("ed3bace23c5e17652e174c835fb72bf53ee306b3406a26890221b4cef7500f88", 16), + new BigInteger("e57a6f571288ccffdcda5e8a7a1f87bf97bd17be084895d0fce17ad5e335286e", 16) + ); + ECPoint gamma = CURVE_SPEC.getCurve().createPoint( + new BigInteger("7ce22e7667f955f5dcc805a5bae7f78d21d0cb04eb5190f3b8e20b68a45d0b87", 16), + new BigInteger("c8f9d9e8d5e4eb22adf379df733a8b1ce4edf26a2ca9a4a3d8a07cb3e3dffd9", 16) + ); +// proof.setPublicX("0xed3bace23c5e17652e174c835fb72bf53ee306b3406a26890221b4cef7500f88"); +// proof.setPublicY("0xe57a6f571288ccffdcda5e8a7a1f87bf97bd17be084895d0fce17ad5e335286e"); +// proof.setGammaX("0x7ce22e7667f955f5dcc805a5bae7f78d21d0cb04eb5190f3b8e20b68a45d0b87"); +// proof.setGammaY("0xc8f9d9e8d5e4eb22adf379df733a8b1ce4edf26a2ca9a4a3d8a07cb3e3dffd9"); + proof.setPublicKey(publicK); + proof.setGamma(gamma); + proof.setC(new BigInteger("45945e1b7362a7026df893d39496eb838b6d85264f56899182269be4d53d6fe", 16)); + proof.setS(new BigInteger("7ebf871ad068ce4bbe04cd726e359334581881b4e78da352b7ac413ebcf90a2", 16)); + proof.setSeed(new BigInteger("d3ea21873da2909f9f732966278cc022d523006ea574a58b324b83c0c08a5346", 16)); + proof.setOutput(new BigInteger("11449014f7e3fb46f190149f5c147242300ccdb0e77a58fa53e01972939a3f14", 16)); + + boolean status = vrf.verifyVRFProof(proof); + assertTrue(status, "Proof verification should succeed"); + } + + @Test + void testCalculateRequestId() throws Exception { + VRFClient client = new VRFClient(HttpClient.newDefaultHttpClient()); + + // Test parameters + String benchmarkRequestId = "6f71619f1e6ea42616c9bbdc8fe001511e0c37b72373dc259857b29c1e61597c"; + long version = 1L; + String targetAgentId = "f2464336-fbcf-4603-bda5-ce65c0318fb6"; + String customerSeed = "0x1234"; + String callbackUri = "http://127.0.0.1:8888/api/vrf/proof"; + long requestTimestamp = 1739265192L; + + String requestId = client.calculateRequestId( + version, + targetAgentId, + customerSeed, + requestTimestamp, + callbackUri + ); + + assertEquals(benchmarkRequestId, requestId, + String.format("Request ID mismatch. Expected: %s, Got: %s", + benchmarkRequestId, requestId)); + } +} \ No newline at end of file