|
@@ -0,0 +1,188 @@
|
|
|
+package org.cryptomator.keychain;
|
|
|
+
|
|
|
+import static java.nio.charset.StandardCharsets.UTF_8;
|
|
|
+
|
|
|
+import java.io.IOException;
|
|
|
+import java.io.InputStream;
|
|
|
+import java.io.InputStreamReader;
|
|
|
+import java.io.OutputStream;
|
|
|
+import java.io.OutputStreamWriter;
|
|
|
+import java.io.Reader;
|
|
|
+import java.io.UncheckedIOException;
|
|
|
+import java.io.Writer;
|
|
|
+import java.lang.reflect.Type;
|
|
|
+import java.nio.ByteBuffer;
|
|
|
+import java.nio.CharBuffer;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.FileSystems;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.NoSuchFileException;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.StandardOpenOption;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.HashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.Optional;
|
|
|
+import java.util.UUID;
|
|
|
+
|
|
|
+import javax.inject.Inject;
|
|
|
+
|
|
|
+import org.apache.commons.codec.binary.Base64;
|
|
|
+import org.apache.commons.lang3.SystemUtils;
|
|
|
+import org.cryptomator.jni.WinDataProtection;
|
|
|
+import org.cryptomator.jni.WinFunctions;
|
|
|
+import org.slf4j.Logger;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import com.google.gson.Gson;
|
|
|
+import com.google.gson.GsonBuilder;
|
|
|
+import com.google.gson.JsonDeserializationContext;
|
|
|
+import com.google.gson.JsonDeserializer;
|
|
|
+import com.google.gson.JsonElement;
|
|
|
+import com.google.gson.JsonParseException;
|
|
|
+import com.google.gson.JsonPrimitive;
|
|
|
+import com.google.gson.JsonSerializationContext;
|
|
|
+import com.google.gson.JsonSerializer;
|
|
|
+import com.google.gson.annotations.SerializedName;
|
|
|
+import com.google.gson.reflect.TypeToken;
|
|
|
+
|
|
|
+class WindowsProtectedKeychainAccess implements KeychainAccessStrategy {
|
|
|
+
|
|
|
+ private static final Logger LOG = LoggerFactory.getLogger(WindowsProtectedKeychainAccess.class);
|
|
|
+ private static final Gson GSON = new GsonBuilder().setPrettyPrinting() //
|
|
|
+ .registerTypeHierarchyAdapter(byte[].class, new ByteArrayJsonAdapter()) //
|
|
|
+ .disableHtmlEscaping().create();
|
|
|
+
|
|
|
+ private final WinDataProtection dataProtection;
|
|
|
+ private final Path keychainPath;
|
|
|
+ private Map<String, KeychainEntry> keychainEntries;
|
|
|
+
|
|
|
+ @Inject
|
|
|
+ public WindowsProtectedKeychainAccess(Optional<WinFunctions> winFunctions) {
|
|
|
+ if (winFunctions.isPresent()) {
|
|
|
+ this.dataProtection = winFunctions.get().dataProtection();
|
|
|
+ } else {
|
|
|
+ this.dataProtection = null;
|
|
|
+ }
|
|
|
+ final String keychainPathProperty = System.getProperty("cryptomator.keychainPath");
|
|
|
+ if (dataProtection != null && keychainPathProperty == null) {
|
|
|
+ LOG.warn("Windows DataProtection module loaded, but no keychainPath configured.");
|
|
|
+ }
|
|
|
+ if (keychainPathProperty != null) {
|
|
|
+ this.keychainPath = FileSystems.getDefault().getPath(keychainPathProperty);
|
|
|
+ } else {
|
|
|
+ this.keychainPath = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void storePassphrase(String key, CharSequence passphrase) {
|
|
|
+ loadKeychainEntriesIfNeeded();
|
|
|
+ ByteBuffer buf = UTF_8.encode(CharBuffer.wrap(passphrase));
|
|
|
+ byte[] cleartext = new byte[buf.remaining()];
|
|
|
+ buf.get(cleartext);
|
|
|
+ KeychainEntry entry = new KeychainEntry();
|
|
|
+ entry.salt = generateSalt();
|
|
|
+ entry.ciphertext = dataProtection.protect(cleartext, entry.salt);
|
|
|
+ Arrays.fill(buf.array(), (byte) 0x00);
|
|
|
+ Arrays.fill(cleartext, (byte) 0x00);
|
|
|
+ keychainEntries.put(key, entry);
|
|
|
+ saveKeychainEntries();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public char[] loadPassphrase(String key) {
|
|
|
+ loadKeychainEntriesIfNeeded();
|
|
|
+ KeychainEntry entry = keychainEntries.get(key);
|
|
|
+ if (entry == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ byte[] cleartext = dataProtection.unprotect(entry.ciphertext, entry.salt);
|
|
|
+ if (cleartext == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ CharBuffer buf = UTF_8.decode(ByteBuffer.wrap(cleartext));
|
|
|
+ char[] passphrase = new char[buf.remaining()];
|
|
|
+ buf.get(passphrase);
|
|
|
+ Arrays.fill(cleartext, (byte) 0x00);
|
|
|
+ Arrays.fill(buf.array(), (char) 0x00);
|
|
|
+ return passphrase;
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public void deletePassphrase(String key) {
|
|
|
+ loadKeychainEntriesIfNeeded();
|
|
|
+ keychainEntries.remove(key);
|
|
|
+ saveKeychainEntries();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public boolean isSupported() {
|
|
|
+ return SystemUtils.IS_OS_WINDOWS && dataProtection != null && keychainPath != null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private byte[] generateSalt() {
|
|
|
+ byte[] result = new byte[2 * Long.BYTES];
|
|
|
+ UUID uuid = UUID.randomUUID();
|
|
|
+ ByteBuffer buf = ByteBuffer.wrap(result);
|
|
|
+ buf.putLong(uuid.getMostSignificantBits());
|
|
|
+ buf.putLong(uuid.getLeastSignificantBits());
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void loadKeychainEntriesIfNeeded() {
|
|
|
+ if (keychainEntries == null) {
|
|
|
+ loadKeychainEntries();
|
|
|
+ }
|
|
|
+ assert keychainEntries != null;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void loadKeychainEntries() {
|
|
|
+ Type type = new TypeToken<Map<String, KeychainEntry>>() {
|
|
|
+ }.getType();
|
|
|
+ try (InputStream in = Files.newInputStream(keychainPath, StandardOpenOption.READ); //
|
|
|
+ Reader reader = new InputStreamReader(in, UTF_8)) {
|
|
|
+ keychainEntries = GSON.fromJson(reader, type);
|
|
|
+ } catch (JsonParseException | NoSuchFileException e) {
|
|
|
+ LOG.info("Creating new keychain at path {}", keychainPath);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new UncheckedIOException("Could not read keychain from path " + keychainPath, e);
|
|
|
+ }
|
|
|
+ if (keychainEntries == null) {
|
|
|
+ keychainEntries = new HashMap<>();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private void saveKeychainEntries() {
|
|
|
+ try (OutputStream out = Files.newOutputStream(keychainPath, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); //
|
|
|
+ Writer writer = new OutputStreamWriter(out, UTF_8)) {
|
|
|
+ GSON.toJson(keychainEntries, writer);
|
|
|
+ } catch (IOException e) {
|
|
|
+ throw new UncheckedIOException("Could not read keychain from path " + keychainPath, e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class KeychainEntry {
|
|
|
+ @SerializedName("ciphertext")
|
|
|
+ byte[] ciphertext;
|
|
|
+ @SerializedName("salt")
|
|
|
+ byte[] salt;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static class ByteArrayJsonAdapter implements JsonSerializer<byte[]>, JsonDeserializer<byte[]> {
|
|
|
+
|
|
|
+ private static final Base64 BASE64 = new Base64();
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public byte[] deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
|
|
|
+ return BASE64.decode(json.getAsString().getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Override
|
|
|
+ public JsonElement serialize(byte[] src, Type typeOfSrc, JsonSerializationContext context) {
|
|
|
+ return new JsonPrimitive(new String(BASE64.encode(src), StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+}
|