mirror of
https://github.com/Qortal/qortal.git
synced 2025-05-03 08:17:50 +00:00
Refactored to standard Maven layout: src/main/java src/main/resources src/test/java etc. New translation code that uses locale-specific ResourceBundles to load translations on demand. Reworked API error/exceptions code to a shorter, simpler @ApiErrors annotation. Processing of @ApiErrors annotations produces an example for each possible API error and includes API error string in HTTP response code, e.g. 400 INVALID_SIGNATURE Missing API error cases added to each API call. Translation of openAPI.json removed (for now). block-explorer.html and BIP39 wordlists now read as resources instead of direct from disk. Java compile warnings fixed. Some runtime warnings remain: WARNING: A provider api.resource.ApiDefinition registered in SERVER runtime does not implement any provider interfaces applicable in the SERVER runtime. WARNING: A provider api.resource.AnnotationPostProcessor registered in SERVER runtime does not implement any provider interfaces applicable in the SERVER runtime. WARN org.reflections.Reflections - given scan urls are empty. set urls in the configuration
375 lines
8.8 KiB
Java
375 lines
8.8 KiB
Java
package api.resource;
|
|
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
import io.swagger.v3.oas.annotations.media.Content;
|
|
import io.swagger.v3.oas.annotations.media.Schema;
|
|
import io.swagger.v3.oas.annotations.parameters.RequestBody;
|
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
import qora.account.PrivateKeyAccount;
|
|
import qora.crypto.Crypto;
|
|
import utils.BIP39;
|
|
import utils.Base58;
|
|
import utils.NTP;
|
|
|
|
import java.security.SecureRandom;
|
|
import java.util.Arrays;
|
|
import java.util.Base64;
|
|
import java.util.UUID;
|
|
|
|
import javax.servlet.http.HttpServletRequest;
|
|
import javax.ws.rs.GET;
|
|
import javax.ws.rs.POST;
|
|
import javax.ws.rs.Path;
|
|
import javax.ws.rs.PathParam;
|
|
import javax.ws.rs.Produces;
|
|
import javax.ws.rs.QueryParam;
|
|
import javax.ws.rs.core.Context;
|
|
import javax.ws.rs.core.MediaType;
|
|
|
|
import com.google.common.hash.HashCode;
|
|
import com.google.common.primitives.Bytes;
|
|
import com.google.common.primitives.Longs;
|
|
|
|
import api.ApiError;
|
|
import api.ApiErrors;
|
|
import api.ApiExceptionFactory;
|
|
|
|
@Path("/utils")
|
|
@Produces({
|
|
MediaType.TEXT_PLAIN
|
|
})
|
|
@Tag(
|
|
name = "Utilities"
|
|
)
|
|
public class UtilsResource {
|
|
|
|
@Context
|
|
HttpServletRequest request;
|
|
|
|
@POST
|
|
@Path("/fromBase64")
|
|
@Operation(
|
|
summary = "Convert base64 data to hex",
|
|
requestBody = @RequestBody(
|
|
required = true,
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
),
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "hex string",
|
|
content = @Content(
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
@ApiErrors({ApiError.INVALID_DATA})
|
|
public String fromBase64(String base64) {
|
|
try {
|
|
return HashCode.fromBytes(Base64.getDecoder().decode(base64.trim())).toString();
|
|
} catch (IllegalArgumentException e) {
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
}
|
|
}
|
|
|
|
@POST
|
|
@Path("/fromBase58")
|
|
@Operation(
|
|
summary = "Convert base58 data to hex",
|
|
requestBody = @RequestBody(
|
|
required = true,
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
),
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "hex string",
|
|
content = @Content(
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
@ApiErrors({ApiError.INVALID_DATA})
|
|
public String base64from58(String base58) {
|
|
try {
|
|
return HashCode.fromBytes(Base58.decode(base58.trim())).toString();
|
|
} catch (NumberFormatException e) {
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
}
|
|
}
|
|
|
|
@GET
|
|
@Path("/toBase64/{hex}")
|
|
@Operation(
|
|
summary = "Convert hex to base64",
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "base64",
|
|
content = @Content(
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
public String toBase64(@PathParam("hex") String hex) {
|
|
return Base64.getEncoder().encodeToString(HashCode.fromString(hex).asBytes());
|
|
}
|
|
|
|
@GET
|
|
@Path("/toBase58/{hex}")
|
|
@Operation(
|
|
summary = "Convert hex to base58",
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "base58",
|
|
content = @Content(
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
public String toBase58(@PathParam("hex") String hex) {
|
|
return Base58.encode(HashCode.fromString(hex).asBytes());
|
|
}
|
|
|
|
@GET
|
|
@Path("/random")
|
|
@Operation(
|
|
summary = "Generate random data",
|
|
description = "Optionally pass data length, defaults to 32 bytes.",
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "base58 data",
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
public String random(@QueryParam("length") Integer length) {
|
|
if (length == null)
|
|
length = 32;
|
|
|
|
byte[] random = new byte[length];
|
|
new SecureRandom().nextBytes(random);
|
|
return Base58.encode(random);
|
|
}
|
|
|
|
@GET
|
|
@Path("/mnemonic")
|
|
@Operation(
|
|
summary = "Generate 12-word BIP39 mnemonic",
|
|
description = "Optionally pass 16-byte, base58-encoded entropy or entropy will be internally generated.<br>"
|
|
+ "Example entropy input: YcVfxkQb6JRzqk5kF2tNLv",
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "mnemonic",
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
@ApiErrors({ApiError.INVALID_DATA})
|
|
public String getMnemonic(@QueryParam("entropy") String suppliedEntropy) {
|
|
/*
|
|
* BIP39 word lists have 2048 entries so can be represented by 11 bits.
|
|
* UUID (128bits) and another 4 bits gives 132 bits.
|
|
* 132 bits, divided by 11, gives 12 words.
|
|
*/
|
|
byte[] entropy;
|
|
if (suppliedEntropy != null) {
|
|
// Use caller-supplied entropy input
|
|
try {
|
|
entropy = Base58.decode(suppliedEntropy);
|
|
} catch (NumberFormatException e) {
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
}
|
|
|
|
// Must be 16-bytes
|
|
if (entropy.length != 16)
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
} else {
|
|
// Generate entropy internally
|
|
UUID uuid = UUID.randomUUID();
|
|
|
|
byte[] uuidMSB = Longs.toByteArray(uuid.getMostSignificantBits());
|
|
byte[] uuidLSB = Longs.toByteArray(uuid.getLeastSignificantBits());
|
|
entropy = Bytes.concat(uuidMSB, uuidLSB);
|
|
}
|
|
|
|
// Use SHA256 to generate more bits
|
|
byte[] hash = Crypto.digest(entropy);
|
|
|
|
// Append first 4 bits from hash to end. (Actually 8 bits but we only use 4).
|
|
byte checksum = (byte) (hash[0] & 0xf0);
|
|
entropy = Bytes.concat(entropy, new byte[] {
|
|
checksum
|
|
});
|
|
|
|
return BIP39.encode(entropy, "en");
|
|
}
|
|
|
|
@POST
|
|
@Path("/mnemonic")
|
|
@Operation(
|
|
summary = "Calculate binary entropy from 12-word BIP39 mnemonic",
|
|
description = "Returns the base58-encoded binary form, or \"false\" if mnemonic is invalid.",
|
|
requestBody = @RequestBody(
|
|
required = true,
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
),
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "entropy in base58",
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
public String fromMnemonic(String mnemonic) {
|
|
if (mnemonic.isEmpty())
|
|
return "false";
|
|
|
|
// Strip leading/trailing whitespace if any
|
|
mnemonic = mnemonic.trim();
|
|
|
|
String[] phraseWords = mnemonic.split(" ");
|
|
if (phraseWords.length != 12)
|
|
return "false";
|
|
|
|
// Convert BIP39 mnemonic to binary
|
|
byte[] binary = BIP39.decode(phraseWords, "en");
|
|
if (binary == null)
|
|
return "false";
|
|
|
|
byte[] entropy = Arrays.copyOf(binary, 16); // 132 bits is 16.5 bytes, but we're discarding checksum nybble
|
|
|
|
byte checksumNybble = (byte) (binary[16] & 0xf0);
|
|
byte[] checksum = Crypto.digest(entropy);
|
|
if (checksumNybble != (byte) (checksum[0] & 0xf0))
|
|
return "false";
|
|
|
|
return Base58.encode(entropy);
|
|
}
|
|
|
|
@GET
|
|
@Path("/privateKey/{entropy}")
|
|
@Operation(
|
|
summary = "Calculate private key from supplied 16-byte entropy",
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "private key in base58",
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
@ApiErrors({ApiError.INVALID_DATA})
|
|
public String privateKey(@PathParam("entropy") String entropy58) {
|
|
byte[] entropy;
|
|
try {
|
|
entropy = Base58.decode(entropy58);
|
|
} catch (NumberFormatException e) {
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
}
|
|
|
|
if (entropy.length != 16)
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
|
|
byte[] privateKey = Crypto.digest(entropy);
|
|
|
|
return Base58.encode(privateKey);
|
|
}
|
|
|
|
@GET
|
|
@Path("/publicKey/{privateKey}")
|
|
@Operation(
|
|
summary = "Calculate public key from supplied 32-byte private key",
|
|
responses = {
|
|
@ApiResponse(
|
|
description = "public key in base58",
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "string"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
@ApiErrors({ApiError.INVALID_DATA})
|
|
public String publicKey(@PathParam("privateKey") String privateKey58) {
|
|
byte[] privateKey;
|
|
try {
|
|
privateKey = Base58.decode(privateKey58);
|
|
} catch (NumberFormatException e) {
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
}
|
|
|
|
if (privateKey.length != 32)
|
|
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.INVALID_DATA);
|
|
|
|
byte[] publicKey = new PrivateKeyAccount(null, privateKey).getPublicKey();
|
|
|
|
return Base58.encode(publicKey);
|
|
}
|
|
|
|
@GET
|
|
@Path("/timestamp")
|
|
@Operation(
|
|
summary = "Returns current timestamp as milliseconds from unix epoch",
|
|
responses = {
|
|
@ApiResponse(
|
|
content = @Content(
|
|
mediaType = MediaType.TEXT_PLAIN,
|
|
schema = @Schema(
|
|
type = "number"
|
|
)
|
|
)
|
|
)
|
|
}
|
|
)
|
|
public long getTimestamp() {
|
|
return NTP.getTime();
|
|
}
|
|
|
|
} |