From 33f08e094fe7d8966b207e899c78f4dc69a09ada Mon Sep 17 00:00:00 2001 From: Hatter Jiang Date: Fri, 9 May 2025 23:11:58 +0800 Subject: [PATCH] feat: card-cryptmator plugin --- .gitignore | 2 + LICENSE | 22 ++ README.md | 58 +++- build.json | 7 + dependency-reduced-pom.xml | 275 +++++++++++++++++ keepassxc-cryptomator.png | Bin 0 -> 22780 bytes keepassxc-cryptomator.svg | 258 ++++++++++++++++ pom.xml | 286 ++++++++++++++++++ .../integrations/card/CardAccessProvider.java | 80 +++++ .../hatter/integrations/card/CardConfig.java | 21 ++ .../card/CardHmacDecryptResult.java | 13 + .../card/CardHmacEncryptResult.java | 13 + .../me/hatter/integrations/card/Utils.java | 285 +++++++++++++++++ .../integrations/card/UtilsCommandResult.java | 40 +++ ...tegrations.keychain.KeychainAccessProvider | 1 + .../card/KeePassXCAccessTest.java | 20 ++ 16 files changed, 1380 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 build.json create mode 100644 dependency-reduced-pom.xml create mode 100644 keepassxc-cryptomator.png create mode 100644 keepassxc-cryptomator.svg create mode 100644 pom.xml create mode 100644 src/main/java/me/hatter/integrations/card/CardAccessProvider.java create mode 100644 src/main/java/me/hatter/integrations/card/CardConfig.java create mode 100644 src/main/java/me/hatter/integrations/card/CardHmacDecryptResult.java create mode 100644 src/main/java/me/hatter/integrations/card/CardHmacEncryptResult.java create mode 100644 src/main/java/me/hatter/integrations/card/Utils.java create mode 100644 src/main/java/me/hatter/integrations/card/UtilsCommandResult.java create mode 100644 src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider create mode 100644 src/test/java/me/hatter/integrations/card/KeePassXCAccessTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92322c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +target/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..971ad2d --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2021-2024 Ralph Plawetzki +Copyright (c) 2024-2024 Hatter Jiang + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1e8211f..0e6ae47 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,58 @@ -# card-cryptomator +# tinyencrypt-cryptomator +> This project is fork from https://github.com/purejava/keepassxc-cryptomator , add GnuPG support + +[![GitHub Release](https://img.shields.io/github/v/release/jht5945/gnupg-cryptomator)](https://github.com/jht5945/gnupg-cryptomator/releases) +[![License](https://img.shields.io/github/license/jht5945/gnupg-cryptomator.svg)](https://github.com/jht5945/gnupg-cryptomator/blob/master/LICENSE) + +Plug-in for Cryptomator to store vault passwords with card-cli encryption. + +# Build Project + +Requirement: + +* JDK 17 or later +* Maven 3.8.4 or later + +```shell +mvn package +``` + +Copy packaged plugin from `target/card-cryptomator-$VERSION.jar` to Cryptomator plugins directory. +> Usage reference [Wiki](https://github.com/purejava/keepassxc-cryptomator/wiki) + +# Configuration + +Configure file location: + +* `/etc/cryptomator/card_config.json` +* `~/.config/cryptomator/card_config.json` + +```json +{ +} +``` + +# Documentation + +For documentation please take a look at the [Wiki](https://github.com/purejava/keepassxc-cryptomator/wiki). + +Plugin location: + +| OS | Default Dir | +| ---- | ---- | +| Mac | `~/Library/Application Support/Cryptomator/Plugins` | +| Linux | `~/.local/share/Cryptomator/plugins` | +| Windows | `%homepath%\AppData\Roaming\Cryptomator\Plugins` | + +# How it works? + +This plugin use card-cli encrypt and decrypt passwords. + +# Copyright + +Copyright (C) 2021-2024 Ralph Plawetzki
+Copyright (C) 2024-2024 Hatter Jiang + +The Cryptomator logo is Copyright (C) of https://cryptomator.org/
+The KeePassXC logo is Copyright (C) of https://keepassxc.org/ diff --git a/build.json b/build.json new file mode 100644 index 0000000..e07661d --- /dev/null +++ b/build.json @@ -0,0 +1,7 @@ +{ + "java": "17", + "builder": { + "name": "maven", + "version": "3.8.4" + } +} diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml new file mode 100644 index 0000000..8443f3a --- /dev/null +++ b/dependency-reduced-pom.xml @@ -0,0 +1,275 @@ + + + 4.0.0 + me.hatter + card-cryptomator + card-cryptomator + 1.0.0 + Plug-in for Cryptomator to store vault passwords with card-cli encryption. + https://git.hatter.ink/hatter/card-cryptomator + + + Ralph Plawetzki + ralph@purejava.org + https://github.com/purejava + +1 + + + Hatter Jiang + jht5945@gmail.com + https://github.com/jht5945 + +8 + + + + + MIT License + https://opensource.org/licenses/MIT + + + + scm:git:git@github.com:jht5945/tinyencrypt-cryptomator.git + scm:git:git@github.com:jht5945/tinyencrypt-cryptomator.git + git@github.com:jht5945/tinyencrypt-cryptomator.git + + + + + + maven-clean-plugin + 3.3.2 + + + maven-resources-plugin + 3.3.1 + + false + + + + maven-compiler-plugin + 3.13.0 + + + maven-surefire-plugin + 3.3.0 + + + maven-jar-plugin + 3.4.1 + + true + + + + maven-install-plugin + 3.1.2 + + + maven-deploy-plugin + 3.1.2 + + + maven-site-plugin + 4.0.0-M15 + + + maven-project-info-reports-plugin + 3.6.0 + + + + + + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + maven-javadoc-plugin + 3.7.0 + + + attach-javadocs + + jar + + + false + + + + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.7.0 + true + + ossrh + https://oss.sonatype.org/ + false + + + + maven-surefire-plugin + 3.3.0 + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + + + + + + + + sign + + + + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + + + + + + org.slf4j + slf4j-simple + 2.0.13 + test + + + org.junit.jupiter + junit-jupiter-api + 5.10.2 + test + + + opentest4j + org.opentest4j + + + junit-platform-commons + org.junit.platform + + + apiguardian-api + org.apiguardian + + + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + test + + + junit-platform-engine + org.junit.platform + + + apiguardian-api + org.apiguardian + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + junit-jupiter-params + org.junit.jupiter + + + + + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + + 1.3.1 + 2.0.13 + 2.11.0 + UTF-8 + 5.10.2 + 1.2.5 + + diff --git a/keepassxc-cryptomator.png b/keepassxc-cryptomator.png new file mode 100644 index 0000000000000000000000000000000000000000..03b1e699c15107e034345fbf040c6f279be8646b GIT binary patch literal 22780 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 literal 0 HcmV?d00001 diff --git a/keepassxc-cryptomator.svg b/keepassxc-cryptomator.svg new file mode 100644 index 0000000..78e6796 --- /dev/null +++ b/keepassxc-cryptomator.svg @@ -0,0 +1,258 @@ + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..6260d78 --- /dev/null +++ b/pom.xml @@ -0,0 +1,286 @@ + + + 4.0.0 + me.hatter + card-cryptomator + 1.0.0 + + card-cryptomator + Plug-in for Cryptomator to store vault passwords with card-cli encryption. + https://git.hatter.ink/hatter/card-cryptomator + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + Ralph Plawetzki + ralph@purejava.org + +1 + + https://github.com/purejava + + + Hatter Jiang + jht5945@gmail.com + +8 + + https://github.com/jht5945 + + + + + scm:git:git@github.com:jht5945/tinyencrypt-cryptomator.git + scm:git:git@github.com:jht5945/tinyencrypt-cryptomator.git + git@github.com:jht5945/tinyencrypt-cryptomator.git + + + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + + + UTF-8 + + + 1.3.1 + 1.2.5 + 2.11.0 + 2.0.13 + 5.10.2 + + + + + org.cryptomator + integrations-api + ${api.version} + + + org.slf4j + slf4j-api + ${slf4j.version} + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + com.google.code.gson + gson + ${gson.version} + + + org.purejava + keepassxc-proxy-access + ${keepassxc-proxy.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + + maven-clean-plugin + 3.3.2 + + + maven-resources-plugin + 3.3.1 + + false + + + + maven-compiler-plugin + 3.13.0 + + + maven-surefire-plugin + 3.3.0 + + + maven-jar-plugin + 3.4.1 + + true + + + + maven-install-plugin + 3.1.2 + + + maven-deploy-plugin + 3.1.2 + + + maven-site-plugin + 4.0.0-M15 + + + maven-project-info-reports-plugin + 3.6.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 17 + 17 + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.7.0 + + + attach-javadocs + + jar + + + false + + + + + + maven-shade-plugin + 3.6.0 + + + package + + shade + + + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + org.sonatype.plugins + nexus-staging-maven-plugin + 1.7.0 + true + + ossrh + https://oss.sonatype.org/ + false + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.0 + + + org.junit.jupiter + junit-jupiter-engine + 5.10.2 + + + + + + + + sign + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.4 + + + sign-artifacts + verify + + sign + + + + --pinentry-mode + loopback + + + + + + + + + + diff --git a/src/main/java/me/hatter/integrations/card/CardAccessProvider.java b/src/main/java/me/hatter/integrations/card/CardAccessProvider.java new file mode 100644 index 0000000..55e22ab --- /dev/null +++ b/src/main/java/me/hatter/integrations/card/CardAccessProvider.java @@ -0,0 +1,80 @@ +package me.hatter.integrations.card; + +import org.cryptomator.integrations.keychain.KeychainAccessException; +import org.cryptomator.integrations.keychain.KeychainAccessProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * @author hatterjiang + */ +public class CardAccessProvider implements KeychainAccessProvider { + + private static final Logger LOG = LoggerFactory.getLogger(CardAccessProvider.class); + + private CardConfig cardConfig; + + public CardAccessProvider() { + try { + cardConfig = Utils.loadCardConfig(); + if (!Utils.checkCardCliReady(cardConfig)) { + LOG.error("Check card-cli command failed"); + cardConfig = null; + } + } catch (KeychainAccessException e) { + cardConfig = null; + LOG.error("Load card-cli config failed", e); + } + } + + @Override + public String displayName() { + return "CardCli"; + } + + @Override + public boolean isSupported() { + return cardConfig != null; + } + + @Override + public boolean isLocked() { + // No lock status + return false; + } + + @Override + public void storePassphrase(String vault, CharSequence password) throws KeychainAccessException { + storePassphrase(vault, "Vault", password); + } + + @Override + public void storePassphrase(String vault, String name, CharSequence password) throws KeychainAccessException { + LOG.info("Store password for: " + vault + " / " + name); + Utils.storePassword(cardConfig, vault, name, password); + } + + @Override + public char[] loadPassphrase(String vault) throws KeychainAccessException { + LOG.info("Load password for: " + vault); + final String password = Utils.loadPassword(cardConfig, vault); + return password.toCharArray(); + } + + @Override + public void deletePassphrase(String vault) throws KeychainAccessException { + LOG.info("Delete password for: " + vault); + Utils.deletePassword(cardConfig, vault); + } + + @Override + public void changePassphrase(String vault, CharSequence password) throws KeychainAccessException { + changePassphrase(vault, "Vault", password); + } + + @Override + public void changePassphrase(String vault, String name, CharSequence password) throws KeychainAccessException { + LOG.info("Change password for: " + vault + " / " + name); + Utils.storePassword(cardConfig, vault, name, password); + } +} diff --git a/src/main/java/me/hatter/integrations/card/CardConfig.java b/src/main/java/me/hatter/integrations/card/CardConfig.java new file mode 100644 index 0000000..c8e1e29 --- /dev/null +++ b/src/main/java/me/hatter/integrations/card/CardConfig.java @@ -0,0 +1,21 @@ +package me.hatter.integrations.card; + +/** + * card-cli config + * + * @author hatterjiang + */ +public class CardConfig { + /** + * OPTIONAL, Encrypt key base path, default "~/.config/cryptomator/card_keys/" + */ + private String encryptKeyBasePath; + + public String getEncryptKeyBasePath() { + return encryptKeyBasePath; + } + + public void setEncryptKeyBasePath(String encryptKeyBasePath) { + this.encryptKeyBasePath = encryptKeyBasePath; + } +} diff --git a/src/main/java/me/hatter/integrations/card/CardHmacDecryptResult.java b/src/main/java/me/hatter/integrations/card/CardHmacDecryptResult.java new file mode 100644 index 0000000..8bed4cf --- /dev/null +++ b/src/main/java/me/hatter/integrations/card/CardHmacDecryptResult.java @@ -0,0 +1,13 @@ +package me.hatter.integrations.card; + +public class CardHmacDecryptResult { + private String plaintext; + + public String getPlaintext() { + return plaintext; + } + + public void setPlaintext(String plaintext) { + this.plaintext = plaintext; + } +} diff --git a/src/main/java/me/hatter/integrations/card/CardHmacEncryptResult.java b/src/main/java/me/hatter/integrations/card/CardHmacEncryptResult.java new file mode 100644 index 0000000..e0be3ed --- /dev/null +++ b/src/main/java/me/hatter/integrations/card/CardHmacEncryptResult.java @@ -0,0 +1,13 @@ +package me.hatter.integrations.card; + +public class CardHmacEncryptResult { + private String ciphertext; + + public String getCiphertext() { + return ciphertext; + } + + public void setCiphertext(String ciphertext) { + this.ciphertext = ciphertext; + } +} diff --git a/src/main/java/me/hatter/integrations/card/Utils.java b/src/main/java/me/hatter/integrations/card/Utils.java new file mode 100644 index 0000000..97efd04 --- /dev/null +++ b/src/main/java/me/hatter/integrations/card/Utils.java @@ -0,0 +1,285 @@ +package me.hatter.integrations.card; + +import com.google.gson.Gson; +import org.apache.commons.lang3.StringUtils; +import org.cryptomator.integrations.keychain.KeychainAccessException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +/** + * @author hatterjiang + */ +public class Utils { + private static final Logger LOG = LoggerFactory.getLogger(Utils.class); + private static final String USER_HOME = System.getProperty("user.home"); + private static final String DEFAULT_CARD_COMMAND = new File(USER_HOME, "bin/card-cli").getAbsolutePath(); + private static final File CARD_CONFIG_FILE1 = new File("/etc/cryptomator/card_config.json"); + private static final File CARD_CONFIG_FILE2 = new File(USER_HOME, ".config/cryptomator/card_config.json"); + private static final File DEFAULT_ENCRYPTION_KEY_BASE_PATH = new File(USER_HOME, ".config/cryptomator/card_keys/"); + + public static boolean isCheckPassphraseStored() { + final StackTraceElement stack = getCallerStackTrace(); + if (stack != null) { + return "isPassphraseStored".equals(stack.getMethodName()); + } + return false; + } + + public static StackTraceElement getCallerStackTrace() { + // org.cryptomator.common.keychain.KeychainManager :: isPassphraseStored + final StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace(); + for (int i = 0; i < stackTraceElements.length; i++) { + final StackTraceElement stack = stackTraceElements[i]; + if ("org.cryptomator.common.keychain.KeychainManager".equals(stack.getClassName())) { + return stack; + } + } + return null; + } + + public static boolean checkCardCliReady(CardConfig cardConfig) { + if (cardConfig == null) { + return false; + } + try { + final UtilsCommandResult versionResult = runCardCli(cardConfig, null, "-V"); + if (versionResult.getExitValue() == 0) { + return true; + } + LOG.warn("Check card-cli not success: " + versionResult); + return false; + } catch (KeychainAccessException e) { + LOG.warn("Check card-cli failed", e); + return false; + } + } + + public static CardConfig loadCardConfig() throws KeychainAccessException { + final File configFile = getCardConfigFile(); + if (configFile == null) { + return new CardConfig(); + } + final String configJson = readFile(configFile); + final CardConfig cardConfig; + try { + cardConfig = new Gson().fromJson(configJson, CardConfig.class); + } catch (Exception e) { + throw new KeychainAccessException("Parse card-cli config file: " + configFile + " failed", e); + } + return cardConfig; + } + + public static void deletePassword(CardConfig cardConfig, String vault) { + final File keyFile = getKeyFile(cardConfig, vault); + if (keyFile.exists() && keyFile.isFile()) { + keyFile.delete(); + } + } + + public static String loadPassword(CardConfig cardConfig, String vault) throws KeychainAccessException { + final File keyFile = getKeyFile(cardConfig, vault); + if (isCheckPassphraseStored()) { + LOG.info("Check passphrase stored: " + vault + ", exists: " + keyFile.exists()); + if (keyFile.exists()) { + // this is only for check passphrase stored + return "123456"; + } + } + if (!keyFile.isFile()) { + throw new KeychainAccessException("Password key file: " + keyFile + " not found"); + } + final String encryptedKey = readFile(keyFile); + final byte[] password = decrypt(cardConfig, encryptedKey); + return new String(password, StandardCharsets.UTF_8); + } + + public static void storePassword(CardConfig cardConfig, String vault, String name, CharSequence password) throws KeychainAccessException { + final String encryptedPassword = encrypt(cardConfig, password.toString().getBytes(StandardCharsets.UTF_8), name); + final File keyFile = getKeyFile(cardConfig, vault); + writeFile(keyFile, encryptedPassword); + } + + private static File getKeyFile(CardConfig cardConfig, String vault) { + final StringBuilder sb = new StringBuilder(vault.length()); + for (char c : vault.toCharArray()) { + if ((c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || (c == '-' || c == '.')) { + sb.append(c); + } else if (c == '_') { + sb.append("__"); + } else { + sb.append('_'); + final String hex = Integer.toHexString(c); + if (hex.length() % 2 != 0) { + sb.append('0'); + } + sb.append(hex); + } + } + return new File(getEncryptKeyBasePath(cardConfig), sb.toString()); + } + + private static String readFile(File file) throws KeychainAccessException { + final StringBuilder sb = new StringBuilder((int) file.length()); + try (final BufferedReader reader = new BufferedReader(new FileReader(file, StandardCharsets.UTF_8))) { + for (int b; ((b = reader.read()) != -1); ) { + sb.append((char) b); + } + return sb.toString(); + } catch (IOException e) { + throw new KeychainAccessException("Read file: " + file + " failed", e); + } + } + + private static void writeFile(File file, String content) throws KeychainAccessException { + try (FileOutputStream fos = new FileOutputStream(file)) { + fos.write(content.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new KeychainAccessException("Write file: " + file + " failed", e); + } + } + + private static byte[] decrypt(CardConfig cardConfig, String input) throws KeychainAccessException { + final UtilsCommandResult decryptResult = runCardCli( + cardConfig, + null, + "hmac-decrypt", + "--ciphertext", input, + "--auto-pbe", + "--json" + ); + if (decryptResult.getExitValue() != 0) { + throw new KeychainAccessException("card-cli decrypt failed: " + decryptResult); + } + final String resultString = new String(decryptResult.getStdout(), StandardCharsets.UTF_8); + final CardHmacDecryptResult result = new Gson().fromJson(resultString, CardHmacDecryptResult.class); + return Base64.getDecoder().decode(result.getPlaintext()); + } + + private static String encrypt(CardConfig cardConfig, byte[] input, String name) throws KeychainAccessException { + final UtilsCommandResult encryptResult = runCardCli( + cardConfig, + null, + "hmac-encrypt", + "--plaintext", Base64.getEncoder().encodeToString(input), + "--with-pbe-encrypt", + "--pbe-iteration", "1000000", + "--json" + ); + if (encryptResult.getExitValue() != 0) { + throw new KeychainAccessException("card-cli encrypt failed: " + encryptResult); + } + final String resultString = new String(encryptResult.getStdout(), StandardCharsets.UTF_8); + final CardHmacEncryptResult result = new Gson().fromJson(resultString, CardHmacEncryptResult.class); + return result.getCiphertext(); + } + + private static UtilsCommandResult runCardCli(CardConfig cardConfig, byte[] input, String... arguments) throws KeychainAccessException { + final List commands = new ArrayList<>(); + commands.add(DEFAULT_CARD_COMMAND); + if ((arguments == null) || (arguments.length == 0)) { + throw new KeychainAccessException("card-cli not arguments"); + } + commands.addAll(Arrays.asList(arguments)); + try { + final ProcessBuilder processBuilder = new ProcessBuilder(commands); + final Process process = processBuilder.start(); + + // ----- STD IN ----- + final AtomicReference inThreadException = new AtomicReference<>(); + final Thread inThread = new Thread(() -> { + if ((input != null) && (input.length > 0)) { + try (OutputStream processIn = process.getOutputStream()) { + processIn.write(input); + } catch (IOException e) { + inThreadException.set(e); + } + } + }); + inThread.setDaemon(true); + inThread.setName("card-cli-stdin"); + + // ----- STD OUT ----- + final AtomicReference outThreadException = new AtomicReference<>(); + final ByteArrayOutputStream outBaos = new ByteArrayOutputStream(); + final Thread outThread = getThread(process.getInputStream(), outBaos, outThreadException, "card-cli-stdout"); + // ----- STD ERR ----- + final AtomicReference errThreadException = new AtomicReference<>(); + final ByteArrayOutputStream errBaos = new ByteArrayOutputStream(); + final Thread errThread = getThread(process.getErrorStream(), errBaos, errThreadException, "card-cli-stderr"); + + inThread.start(); + outThread.start(); + errThread.start(); + + inThread.join(); + if (inThreadException.get() != null) { + throw inThreadException.get(); + } + outThread.join(); + if (outThreadException.get() != null) { + throw outThreadException.get(); + } + errThread.join(); + if (errThreadException.get() != null) { + throw errThreadException.get(); + } + final int exitValue = process.waitFor(); + + return new UtilsCommandResult(exitValue, outBaos.toByteArray(), errBaos.toByteArray()); + } catch (Exception e) { + throw new KeychainAccessException("Run card-cli command failed: " + commands, e); + } + } + + private static Thread getThread(InputStream is, ByteArrayOutputStream outBaos, AtomicReference outThreadException, String name) { + final Thread outThread = new Thread(() -> { + int b; + try { + while ((b = is.read()) != -1) { + outBaos.write(b); + } + } catch (IOException e) { + outThreadException.set(e); + } + }); + outThread.setDaemon(true); + outThread.setName(name); + return outThread; + } + + private static File getEncryptKeyBasePath(CardConfig cardConfig) { + final File encryptKeyBase; + if ((cardConfig != null) && StringUtils.isNoneEmpty(cardConfig.getEncryptKeyBasePath())) { + encryptKeyBase = new File(cardConfig.getEncryptKeyBasePath()); + } else { + encryptKeyBase = DEFAULT_ENCRYPTION_KEY_BASE_PATH; + } + if (encryptKeyBase.isDirectory()) { + return encryptKeyBase; + } + LOG.info("Make dirs: " + encryptKeyBase); + encryptKeyBase.mkdirs(); + return encryptKeyBase; + } + + private static File getCardConfigFile() throws KeychainAccessException { + for (File configFile : Arrays.asList(CARD_CONFIG_FILE1, CARD_CONFIG_FILE2)) { + LOG.info("Check config file: " + configFile + ": " + Arrays.asList(configFile.exists(), configFile.isFile())); + if (configFile.exists() && configFile.isFile()) { + return configFile; + } + } + return null; + } +} diff --git a/src/main/java/me/hatter/integrations/card/UtilsCommandResult.java b/src/main/java/me/hatter/integrations/card/UtilsCommandResult.java new file mode 100644 index 0000000..c168f03 --- /dev/null +++ b/src/main/java/me/hatter/integrations/card/UtilsCommandResult.java @@ -0,0 +1,40 @@ +package me.hatter.integrations.card; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +/** + * @author hatterjiang + */ +public class UtilsCommandResult { + private final int exitValue; + private final byte[] stdout; + private final byte[] stderr; + + public UtilsCommandResult(int exitValue, byte[] stdout, byte[] stderr) { + this.exitValue = exitValue; + this.stdout = stdout; + this.stderr = stderr; + } + + public int getExitValue() { + return exitValue; + } + + public byte[] getStdout() { + return stdout; + } + + public byte[] getStderr() { + return stderr; + } + + @Override + public String toString() { + return "CommandResult{" + + "exitValue=" + exitValue + + ", stdout=" + Arrays.toString(stdout) + " (" + new String(stdout, StandardCharsets.UTF_8) + ")" + + ", stderr=" + Arrays.toString(stderr) + " (" + new String(stderr, StandardCharsets.UTF_8) + ")" + + '}'; + } +} diff --git a/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider b/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider new file mode 100644 index 0000000..14c758c --- /dev/null +++ b/src/main/resources/META-INF/services/org.cryptomator.integrations.keychain.KeychainAccessProvider @@ -0,0 +1 @@ +me.hatter.integrations.card.CardAccessProvider \ No newline at end of file diff --git a/src/test/java/me/hatter/integrations/card/KeePassXCAccessTest.java b/src/test/java/me/hatter/integrations/card/KeePassXCAccessTest.java new file mode 100644 index 0000000..0c14b61 --- /dev/null +++ b/src/test/java/me/hatter/integrations/card/KeePassXCAccessTest.java @@ -0,0 +1,20 @@ +package me.hatter.integrations.card; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit test for simple KeePassXCAccess. + */ +public class KeePassXCAccessTest +{ + /** + * Rigorous Test :-) + */ + @Test + public void shouldAnswerWithTrue() + { + assertTrue( true ); + } +}