Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 132 additions & 14 deletions at_client/src/main/java/org/atsign/client/impl/util/KeysUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@
import java.io.IOException;
import java.nio.file.Files;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;

import org.atsign.client.api.AtKeys;
import org.atsign.client.api.AtSign;
Expand All @@ -31,7 +34,7 @@ public class KeysUtils {

static private final String EMPTY_IV = Base64.getEncoder().encodeToString(new byte[16]);

private static final TypeReference<Map<String, String>> STRING_MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<Map<String, Object>> JSON_OBJECT_TYPE = new TypeReference<>() {};

public static final String ATSIGN_KEYS_DIR = "ATSIGN_KEYS_DIR";
public static final String ATSIGN_KEYS_SUFFIX = "ATSIGN_KEYS_SUFFIX";
Expand Down Expand Up @@ -62,6 +65,21 @@ public class KeysUtils {
private static final String APKAM_SYMMETRIC_KEY = "apkamSymmetricKey";
private static final String ENROLLMENT_ID = "enrollmentId";

private static final List<String> FLAT_FIELDS = List.of(SELF_ENCRYPT_KEY, ENROLLMENT_ID, APKAM_SYMMETRIC_KEY,
PKAM_PUBLIC_KEY, PKAM_PRIVATE_KEY, ENCRYPT_PUBLIC_KEY,
ENCRYPT_PRIVATE_KEY);

/**
* NB: These values are the typed keys document at_auth writes, and MUST match its field names
*/
private static final String TYPED_ENROLLMENTS = "enrollments";
private static final String TYPED_KEYS = "keys";
private static final String TYPED_MATERIAL = "material";
private static final String TYPED_ROLE = "role";
private static final String TYPED_STATUS = "status";
private static final String ROLE_PRIVATE_AUTHENTICATION = "privateAuthentication";
private static final String STATUS_ACTIVE = "active";

/**
* Persists {@link AtKeys} to the default file for the {@link AtSign}.
*
Expand All @@ -74,13 +92,15 @@ public static void saveKeys(AtSign atSign, AtKeys keys) throws Exception {
}

/**
* Persists {@link AtKeys} to a file.
* Persists {@link AtKeys} to a file, in the legacy flat shape.
*
* @param keys The {@link AtKeys} to persist.
* @param file The file to write / overwrite.
* @throws IOException If anything fails.
* @throws IOException If anything fails, or the file is a typed keys document: this class cannot
* carry its typed key material, so rewriting it would lose that material.
*/
public static void saveKeys(AtKeys keys, File file) throws IOException {
refuseToOverwriteTypedDocument(file);
if (file.getParentFile() != null && !file.getParentFile().exists()) {
Files.createDirectories(file.getParentFile().toPath());
}
Expand All @@ -102,11 +122,14 @@ public static AtKeys loadKeys(AtSign atSign) throws AtClientConfigException {
}

/**
* Instantiates a {@link AtKeys} loaded with the contents of a keys file.
* Instantiates a {@link AtKeys} loaded with the contents of a keys file: the legacy flat shape, or
* the typed keys document at_auth writes, whose flat fields are read and typed containers ignored.
*
* @param file The file to read.
* @return A populated {@link AtKeys} instance.
* @throws AtClientConfigException If anything fails or the keys file does not exist.
* @throws AtClientConfigException If anything fails, the keys file does not exist, or it is a
* typed document that authenticates through typed key material, which this class cannot
* read.
*/
public static AtKeys loadKeys(File file) throws AtClientConfigException {
try {
Expand All @@ -116,6 +139,22 @@ public static AtKeys loadKeys(File file) throws AtClientConfigException {
}
}

private static void refuseToOverwriteTypedDocument(File file) throws IOException {
if (!file.exists()) {
return;
}
Object version;
try {
version = JsonUtils.readValue(Files.readString(file.toPath()), JSON_OBJECT_TYPE).get(VERSION_KEY);
} catch (RuntimeException e) {
return;
}
if (version instanceof Number) {
throw new IOException("refusing to overwrite " + file + ": it is a typed keys document, and rewriting it"
+ " would lose the typed key material it holds");
}
}

private static File getKeysFileFallbackToLegacyLocation(AtSign atSign) throws AtClientConfigException {
// check first if file exists at canonical location ~/.atsign/keys/$atSign_key.atKeys
File file = getKeysFile(atSign, expectedKeysFilesLocation);
Expand Down Expand Up @@ -176,28 +215,107 @@ private static String getAsJson(AtKeys keys) {
mapPutEncrypted(map, ENCRYPT_PUBLIC_KEY, keys.getEncryptPublicKey(), keys.getSelfEncryptKey());
mapPutEncrypted(map, ENCRYPT_PRIVATE_KEY, keys.getEncryptPrivateKey(), keys.getSelfEncryptKey());

map.put(VERSION_KEY, VERSION_1);

return JsonUtils.writeValueAsString(map, true);
} catch (Exception e) {
throw new RuntimeException(e);
}
}

private static AtKeys createAtKeysFromJson(String json) throws AtClientConfigException {
Map<String, Object> document = readDocument(json);
if (isTypedDocument(document)) {
refuseTypedAuthentication(document);
}
try {
Map<String, String> map = JsonUtils.readValue(json, STRING_MAP_TYPE);
String version = map.getOrDefault(VERSION_KEY, VERSION_1);
if (version.equals(VERSION_1)) {
return createAtKeysVersion1(map);
} else {
throw new AtClientConfigException("unsupported version of AtKeys json : " + version);
}
return createAtKeysVersion1(flatFields(document));
} catch (AtDecryptionException e) {
throw new AtClientConfigException("failed to create AtKeys from json", e);
}
}

private static Map<String, Object> readDocument(String json) throws AtClientConfigException {
try {
return JsonUtils.readValue(json, JSON_OBJECT_TYPE);
} catch (RuntimeException e) {
throw new AtClientConfigException("AtKeys json is not a JSON object", e);
}
}

/**
* An integer version 1 marks the typed keys document at_auth writes. No version, or the string
* "1" that earlier releases of this class wrote, marks the legacy flat shape.
*/
private static boolean isTypedDocument(Map<String, Object> document) throws AtClientConfigException {
if (!document.containsKey(VERSION_KEY)) {
return false;
}
Object version = document.get(VERSION_KEY);
if (VERSION_1.equals(version)) {
return false;
}
if (Integer.valueOf(1).equals(version)) {
return true;
}
throw new AtClientConfigException("unsupported version of AtKeys json : " + version);
}

/**
* A typed document authenticates as the enrollment holding active typed authentication material,
* and its flat fields then belong to a different enrollment, so reading them would authenticate
* as the wrong principal.
*/
private static void refuseTypedAuthentication(Map<String, Object> document) throws AtClientConfigException {
Set<String> enrollmentIds = new TreeSet<>();
for (Object enrollment : listOf(document.get(TYPED_ENROLLMENTS), TYPED_ENROLLMENTS)) {
Map<?, ?> enrollmentEntry = mapOf(enrollment, TYPED_ENROLLMENTS);
for (Object key : listOf(enrollmentEntry.get(TYPED_KEYS), TYPED_ENROLLMENTS + "[].keys")) {
Map<?, ?> keyEntry = mapOf(key, TYPED_ENROLLMENTS + "[].keys");
for (Object material : listOf(keyEntry.get(TYPED_MATERIAL), TYPED_ENROLLMENTS + "[].keys[].material")) {
Map<?, ?> materialEntry = mapOf(material, TYPED_ENROLLMENTS + "[].keys[].material");
Object status = materialEntry.get(TYPED_STATUS);
if (ROLE_PRIVATE_AUTHENTICATION.equals(materialEntry.get(TYPED_ROLE))
&& (status == null || STATUS_ACTIVE.equals(status))) {
enrollmentIds.add(String.valueOf(enrollmentEntry.get(ENROLLMENT_ID)));
}
}
}
}
if (!enrollmentIds.isEmpty()) {
throw new AtClientConfigException("AtKeys json authenticates as enrollment " + String.join(", ", enrollmentIds)
+ " through typed key material, which this client cannot read");
}
}

private static List<?> listOf(Object value, String field) throws AtClientConfigException {
if (value == null) {
return List.of();
}
if (value instanceof List) {
return (List<?>) value;
}
throw new AtClientConfigException("AtKeys json field " + field + " is not a list");
}

private static Map<?, ?> mapOf(Object value, String field) throws AtClientConfigException {
if (value instanceof Map) {
return (Map<?, ?>) value;
}
throw new AtClientConfigException("AtKeys json field " + field + " holds an entry that is not an object");
}

private static Map<String, String> flatFields(Map<String, Object> document) throws AtClientConfigException {
Map<String, String> map = new TreeMap<>();
for (String field : FLAT_FIELDS) {
Object value = document.get(field);
if (value instanceof String) {
map.put(field, (String) value);
} else if (value != null) {
throw new AtClientConfigException("AtKeys json field " + field + " is not a string");
}
}
return map;
}

private static AtKeys createAtKeysVersion1(Map<String, String> map) throws AtDecryptionException {
String selfEncryptKey = mapGet(map, SELF_ENCRYPT_KEY);
return AtKeys.builder()
Expand Down
Loading
Loading