Rework of API keys

An API key is now _required_ for sensitive API calls that would previously have allowed local loopback authentication.

Previously, a request would have been considered authenticated if it originated from the same machine, however this creates a security issue when running third party code (particularly javascript) via the data network.

The solution is to now require an API key to authenticate sensitive API calls no matter where the request originates from.

It works as follows:

- When the core is first installed, it has no API key generated and will block sensitive calls until generated.
- A new POST /admin/apikey/generate API endpoint has been added, which can be used the generate an API key for a newly installed node. The UI will ultimately call this automatically.
- This API returns the generated key so that it can be stored by the requesting app (most likely the UI).
- From then on, the generate API requires authentication via the existing API key in order to regenerate a key. It can be used as a security measure if the existing key is compromised.
- The API key must be passed to all sensitive API endpoints from then on, even when calling it from the same local machine.
- If the core already has a legacy API key specified via the 'apiKey' setting, this will be automatically copied to the new format so that a new one doesn't need to be generated.
- The API key itself is stored in a flat file in the qortal directory (the path can be customized using the `apiKeyPath` setting). Deleting this file and restarting the core will allow a new one to be regenerated.
This commit is contained in:
CalDescent 2021-11-14 15:14:37 +00:00
parent 97ca414fc0
commit f062acfd7c
6 changed files with 182 additions and 22 deletions

1
.gitignore vendored
View File

@ -30,3 +30,4 @@
/tmp
/data*
/src/test/resources/arbitrary/*/.qortal/cache
apikey

View File

@ -0,0 +1,93 @@
package org.qortal.api;
import org.qortal.settings.Settings;
import org.qortal.utils.Base58;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
public class ApiKey {
private String apiKey;
public ApiKey() throws IOException {
this.load();
}
public void generate() throws IOException {
byte[] apiKey = new byte[16];
new SecureRandom().nextBytes(apiKey);
this.apiKey = Base58.encode(apiKey);
this.save();
}
/* Filesystem */
private Path getFilePath() {
return Paths.get(Settings.getInstance().getApiKeyPath(), "apikey");
}
private boolean load() throws IOException {
Path path = this.getFilePath();
File apiKeyFile = new File(path.toString());
if (!apiKeyFile.exists()) {
// Try settings - to allow legacy API keys to be supported
return this.loadLegacyApiKey();
}
try {
this.apiKey = new String(Files.readAllBytes(path));
} catch (IOException e) {
throw new IOException(String.format("Couldn't read contents from file %s", path.toString()));
}
return true;
}
private boolean loadLegacyApiKey() {
String legacyApiKey = Settings.getInstance().getApiKey();
if (legacyApiKey != null && !legacyApiKey.isEmpty()) {
this.apiKey = Settings.getInstance().getApiKey();
try {
// Save it to the apikey file
this.save();
} catch (IOException e) {
// Ignore failures as it will be reloaded from settings next time
}
return true;
}
return false;
}
public void save() throws IOException {
if (this.apiKey == null || this.apiKey.isEmpty()) {
throw new IllegalStateException("Unable to save a blank API key");
}
Path filePath = this.getFilePath();
BufferedWriter writer = new BufferedWriter(new FileWriter(filePath.toString()));
writer.write(this.apiKey);
writer.close();
}
public boolean generated() {
return (this.apiKey != null);
}
public String getApiKey() {
return this.apiKey;
}
}

View File

@ -14,6 +14,7 @@ import java.security.SecureRandom;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import org.checkerframework.checker.units.qual.A;
import org.eclipse.jetty.http.HttpVersion;
import org.eclipse.jetty.rewrite.handler.RedirectPatternRule;
import org.eclipse.jetty.rewrite.handler.RewriteHandler;
@ -54,6 +55,7 @@ public class ApiService {
private final ResourceConfig config;
private Server server;
private ApiKey apiKey;
private ApiService() {
this.config = new ResourceConfig();
@ -74,6 +76,15 @@ public class ApiService {
return this.config.getClasses();
}
public void setApiKey(ApiKey apiKey) {
this.apiKey = apiKey;
}
public ApiKey getApiKey() {
return this.apiKey;
}
public void start() {
try {
// Create API server

View File

@ -1,33 +1,45 @@
package org.qortal.api;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import org.qortal.settings.Settings;
public abstract class Security {
public static final String API_KEY_HEADER = "X-API-KEY";
public static void checkApiCallAllowed(HttpServletRequest request) {
String expectedApiKey = Settings.getInstance().getApiKey();
String passedApiKey = request.getHeader(API_KEY_HEADER);
ApiKey apiKey = Security.getApiKey(request);
if ((expectedApiKey != null && !expectedApiKey.equals(passedApiKey)) ||
(passedApiKey != null && !passedApiKey.equals(expectedApiKey)))
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
InetAddress remoteAddr;
try {
remoteAddr = InetAddress.getByName(request.getRemoteAddr());
} catch (UnknownHostException e) {
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
if (!apiKey.generated()) {
// Not generated an API key yet, so disallow sensitive API calls
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "API key not generated");
}
if (!remoteAddr.isLoopbackAddress())
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
String passedApiKey = request.getHeader(API_KEY_HEADER);
if (passedApiKey == null) {
// We require an API key to be passed
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Missing 'X-API-KEY' header");
}
if (!apiKey.equals(passedApiKey)) {
// The API keys must match
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "API key invalid");
}
}
public static ApiKey getApiKey(HttpServletRequest request) {
ApiKey apiKey = ApiService.getInstance().getApiKey();
if (apiKey == null) {
try {
apiKey = new ApiKey();
} catch (IOException e) {
// Couldn't load API key - so we need to treat it as not generated, and therefore unauthorized
throw ApiExceptionFactory.INSTANCE.createException(request, ApiError.UNAUTHORIZED);
}
ApiService.getInstance().setApiKey(apiKey);
}
return apiKey;
}
}

View File

@ -39,12 +39,10 @@ import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.RollingFileAppender;
import org.checkerframework.checker.units.qual.A;
import org.qortal.account.Account;
import org.qortal.account.PrivateKeyAccount;
import org.qortal.api.ApiError;
import org.qortal.api.ApiErrors;
import org.qortal.api.ApiExceptionFactory;
import org.qortal.api.Security;
import org.qortal.api.*;
import org.qortal.api.model.ActivitySummary;
import org.qortal.api.model.NodeInfo;
import org.qortal.api.model.NodeStatus;
@ -717,4 +715,40 @@ public class AdminResource {
}
}
@POST
@Path("/apikey/generate")
@Operation(
summary = "Generate an API key",
description = "This request is unauthenticated if no API key has been generated yet. " +
"If an API key already exists, it needs to be passed as a header and this endpoint " +
"will then generate a new key which replaces the existing one.",
responses = {
@ApiResponse(
description = "API key string",
content = @Content(mediaType = MediaType.TEXT_PLAIN, schema = @Schema(type = "string"))
)
}
)
@SecurityRequirement(name = "apiKey")
public String generateApiKey() {
ApiKey apiKey = Security.getApiKey(request);
// If the API key is already generated, we need to authenticate this request
if (apiKey.generated()) {
Security.checkApiCallAllowed(request);
}
// Not generated yet - so we are safe to generate one
// FUTURE: we may want to restrict this to local/loopback only?
try {
apiKey.generate();
} catch (IOException e) {
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.UNAUTHORIZED, "Unable to generate API key");
}
return apiKey.getApiKey();
}
}

View File

@ -72,8 +72,13 @@ public class Settings {
private String[] apiWhitelist = new String[] {
"::1", "127.0.0.1"
};
private Boolean apiRestricted;
/** Legacy API key (deprecated Nov 2021). Use /admin/apikey/generate API endpoint instead */
private String apiKey = null;
/** Storage location for API key generated by API (Nov 2021 onwards) */
private String apiKeyPath = "";
private Boolean apiRestricted;
private boolean apiLoggingEnabled = false;
private boolean apiDocumentationEnabled = false;
// Both of these need to be set for API to use SSL
@ -479,6 +484,10 @@ public class Settings {
return this.apiKey;
}
public String getApiKeyPath() {
return this.apiKeyPath;
}
public boolean isApiLoggingEnabled() {
return this.apiLoggingEnabled;
}