Compare commits
3 Commits
0.1.0
...
332ab58f58
| Author | SHA1 | Date | |
|---|---|---|---|
| 332ab58f58 | |||
| 8433d536f6 | |||
| 21b2e60df4 |
@@ -1,2 +1,8 @@
|
|||||||
build/
|
build/
|
||||||
*.apk
|
*.apk
|
||||||
|
keepassgo
|
||||||
|
packaging/archlinux/keepassgo-git/*.pkg.tar.zst
|
||||||
|
packaging/archlinux/keepassgo-git/PKGBUILD
|
||||||
|
packaging/archlinux/keepassgo-git/pkg/
|
||||||
|
packaging/archlinux/keepassgo-git/src/
|
||||||
|
packaging/archlinux/keepassgo-git/keepassgo/
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ These features are product requirements, not “nice to have” ideas.
|
|||||||
- Phone should optimize for low tap count, not purity of mobile patterns.
|
- Phone should optimize for low tap count, not purity of mobile patterns.
|
||||||
- The stacked phone layout is the current preferred phone direction.
|
- The stacked phone layout is the current preferred phone direction.
|
||||||
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
- Do not reintroduce the abandoned phone flow mode unless explicitly requested.
|
||||||
|
- Make all test strings `Heist Movie` themed. Use characters, crews, casinos,
|
||||||
|
vaults, and locations from heist movies so test fixtures stay obviously fake
|
||||||
|
and consistent with the product theme.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ ANDROID_MIN_SDK ?= 28
|
|||||||
ANDROID_TARGET_SDK ?= 35
|
ANDROID_TARGET_SDK ?= 35
|
||||||
SIGNKEY ?=
|
SIGNKEY ?=
|
||||||
SIGNPASS ?=
|
SIGNPASS ?=
|
||||||
|
ARCH_PKG_DIR ?= packaging/archlinux/keepassgo-git
|
||||||
|
ARCH_PKG_TMPL ?= $(ARCH_PKG_DIR)/PKGBUILD.tmpl
|
||||||
|
ARCH_PKGBUILD ?= $(ARCH_PKG_DIR)/PKGBUILD
|
||||||
|
ARCH_PKGVER ?= $(shell printf 'r%s.%s' "$$(git rev-list --count HEAD 2>/dev/null || echo 0)" "$$(git rev-parse --short HEAD 2>/dev/null || echo dev)")
|
||||||
|
ARCH_REPO_DIR ?= $(CURDIR)
|
||||||
|
|
||||||
GOGIO_SIGN_FLAGS :=
|
GOGIO_SIGN_FLAGS :=
|
||||||
ifneq ($(strip $(SIGNKEY)),)
|
ifneq ($(strip $(SIGNKEY)),)
|
||||||
@@ -20,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
|||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk
|
.PHONY: apk archlinux-pkgbuild
|
||||||
apk: android/keepassgo-android.jar
|
apk: android/keepassgo-android.jar
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@@ -56,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
|||||||
-d "$$tmpdir" \
|
-d "$$tmpdir" \
|
||||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||||
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
"$(JAVA_HOME)/bin/jar" --create --file "$$(pwd)/android/keepassgo-android.jar" -C "$$tmpdir" .'
|
||||||
|
|
||||||
|
archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
||||||
|
@mkdir -p "$(ARCH_PKG_DIR)"
|
||||||
|
@sed \
|
||||||
|
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||||
|
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||||
|
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||||
|
|||||||
@@ -22,3 +22,26 @@
|
|||||||
android:name="android.accessibilityservice"
|
android:name="android.accessibilityservice"
|
||||||
android:resource="@xml/keepassgo_accessibility_service" />
|
android:resource="@xml/keepassgo_accessibility_service" />
|
||||||
</service>
|
</service>
|
||||||
|
<provider
|
||||||
|
android:name="org.julianfamily.keepassgo.SharedVaultProvider"
|
||||||
|
android:authorities="org.julianfamily.keepassgo.share"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true" />
|
||||||
|
<activity
|
||||||
|
android:name="org.julianfamily.keepassgo.SharedVaultImportActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@android:style/Theme.Translucent.NoTitleBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="application/octet-stream" />
|
||||||
|
<data android:mimeType="application/x-keepass2" />
|
||||||
|
<data android:mimeType="application/vnd.keepass" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:scheme="content" android:pathPattern=".*\\.kdbx" />
|
||||||
|
<data android:scheme="file" android:pathPattern=".*\\.kdbx" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,184 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -Werror
|
||||||
|
#cgo LDFLAGS: -landroid
|
||||||
|
|
||||||
|
#include <jni.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
static jclass jni_GetObjectClass(JNIEnv *env, jobject obj) {
|
||||||
|
return (*env)->GetObjectClass(env, obj);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jmethodID jni_GetMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||||
|
return (*env)->GetMethodID(env, clazz, name, sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jmethodID jni_GetStaticMethodID(JNIEnv *env, jclass clazz, const char *name, const char *sig) {
|
||||||
|
return (*env)->GetStaticMethodID(env, clazz, name, sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jobject jni_CallObjectMethodA(JNIEnv *env, jobject obj, jmethodID method, jvalue *args) {
|
||||||
|
return (*env)->CallObjectMethodA(env, obj, method, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void jni_CallStaticVoidMethodA(JNIEnv *env, jclass cls, jmethodID methodID, const jvalue *args) {
|
||||||
|
(*env)->CallStaticVoidMethodA(env, cls, methodID, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jvalue jni_ValueObject(jobject obj) {
|
||||||
|
jvalue value;
|
||||||
|
value.l = obj;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static jthrowable jni_ExceptionOccurred(JNIEnv *env) {
|
||||||
|
return (*env)->ExceptionOccurred(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void jni_ExceptionClear(JNIEnv *env) {
|
||||||
|
(*env)->ExceptionClear(env);
|
||||||
|
}
|
||||||
|
|
||||||
|
static jstring jni_NewString(JNIEnv *env, const jchar *unicodeChars, jsize len) {
|
||||||
|
return (*env)->NewString(env, unicodeChars, len);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int jni_IsNull(jobject obj) {
|
||||||
|
return obj == NULL;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf16"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
|
_ "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
type androidVaultSharer struct{}
|
||||||
|
|
||||||
|
//go:linkname gioJavaVM gioui.org/app.javaVM
|
||||||
|
func gioJavaVM() *C.JavaVM
|
||||||
|
|
||||||
|
//go:linkname gioRunInJVM gioui.org/app.runInJVM
|
||||||
|
func gioRunInJVM(jvm *C.JavaVM, f func(env *C.JNIEnv))
|
||||||
|
|
||||||
|
func newPlatformVaultSharer(goos string) vaultSharer {
|
||||||
|
return androidVaultSharer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (androidVaultSharer) ShareVault(path, title string) error {
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return fmt.Errorf("vault path is required")
|
||||||
|
}
|
||||||
|
ctx := C.jobject(unsafe.Pointer(app.AppContext()))
|
||||||
|
if C.jni_IsNull(ctx) != 0 {
|
||||||
|
return fmt.Errorf("android app context is not available")
|
||||||
|
}
|
||||||
|
var callErr error
|
||||||
|
gioRunInJVM(gioJavaVM(), func(env *C.JNIEnv) {
|
||||||
|
sharerClass, err := androidLoadClass(env, ctx, "org.julianfamily.keepassgo.AndroidShare")
|
||||||
|
if err != nil {
|
||||||
|
callErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
methodName := cString("shareVault")
|
||||||
|
defer C.free(unsafe.Pointer(methodName))
|
||||||
|
methodSig := cString("(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)V")
|
||||||
|
defer C.free(unsafe.Pointer(methodSig))
|
||||||
|
method := C.jni_GetStaticMethodID(env, sharerClass, methodName, methodSig)
|
||||||
|
if method == nil {
|
||||||
|
callErr = androidJNIError(env, "resolve shareVault method")
|
||||||
|
if callErr == nil {
|
||||||
|
callErr = fmt.Errorf("resolve shareVault method")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
jPath := androidJavaString(env, path)
|
||||||
|
jTitle := androidJavaString(env, title)
|
||||||
|
args := [3]C.jvalue{}
|
||||||
|
args[0] = C.jni_ValueObject(ctx)
|
||||||
|
args[1] = C.jni_ValueObject(C.jobject(jPath))
|
||||||
|
args[2] = C.jni_ValueObject(C.jobject(jTitle))
|
||||||
|
C.jni_CallStaticVoidMethodA(env, sharerClass, method, &args[0])
|
||||||
|
callErr = androidJNIError(env, "share vault")
|
||||||
|
})
|
||||||
|
return callErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidLoadClass(env *C.JNIEnv, ctx C.jobject, name string) (C.jclass, error) {
|
||||||
|
var zeroClass C.jclass
|
||||||
|
contextClass := C.jni_GetObjectClass(env, ctx)
|
||||||
|
getClassLoaderName := cString("getClassLoader")
|
||||||
|
defer C.free(unsafe.Pointer(getClassLoaderName))
|
||||||
|
getClassLoaderSig := cString("()Ljava/lang/ClassLoader;")
|
||||||
|
defer C.free(unsafe.Pointer(getClassLoaderSig))
|
||||||
|
getClassLoader := C.jni_GetMethodID(env, contextClass, getClassLoaderName, getClassLoaderSig)
|
||||||
|
if getClassLoader == nil {
|
||||||
|
if err := androidJNIError(env, "resolve getClassLoader"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
return zeroClass, fmt.Errorf("resolve getClassLoader")
|
||||||
|
}
|
||||||
|
classLoader := C.jni_CallObjectMethodA(env, ctx, getClassLoader, nil)
|
||||||
|
if err := androidJNIError(env, "load class loader"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
if C.jni_IsNull(classLoader) != 0 {
|
||||||
|
return zeroClass, fmt.Errorf("android class loader is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
classLoaderClass := C.jni_GetObjectClass(env, classLoader)
|
||||||
|
loadClassName := cString("loadClass")
|
||||||
|
defer C.free(unsafe.Pointer(loadClassName))
|
||||||
|
loadClassSig := cString("(Ljava/lang/String;)Ljava/lang/Class;")
|
||||||
|
defer C.free(unsafe.Pointer(loadClassSig))
|
||||||
|
loadClass := C.jni_GetMethodID(env, classLoaderClass, loadClassName, loadClassSig)
|
||||||
|
if loadClass == nil {
|
||||||
|
if err := androidJNIError(env, "resolve loadClass"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
return zeroClass, fmt.Errorf("resolve loadClass")
|
||||||
|
}
|
||||||
|
|
||||||
|
jClassName := androidJavaString(env, name)
|
||||||
|
args := [1]C.jvalue{}
|
||||||
|
args[0] = C.jni_ValueObject(C.jobject(jClassName))
|
||||||
|
loaded := C.jni_CallObjectMethodA(env, classLoader, loadClass, &args[0])
|
||||||
|
if err := androidJNIError(env, "load AndroidShare class"); err != nil {
|
||||||
|
return zeroClass, err
|
||||||
|
}
|
||||||
|
if C.jni_IsNull(loaded) != 0 {
|
||||||
|
return zeroClass, fmt.Errorf("load AndroidShare class returned nil")
|
||||||
|
}
|
||||||
|
return C.jclass(loaded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidJNIError(env *C.JNIEnv, action string) error {
|
||||||
|
if thr := C.jni_ExceptionOccurred(env); C.jni_IsNull(C.jobject(thr)) == 0 {
|
||||||
|
C.jni_ExceptionClear(env)
|
||||||
|
return fmt.Errorf("%s: Java exception", action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func androidJavaString(env *C.JNIEnv, s string) C.jstring {
|
||||||
|
chars := utf16.Encode([]rune(s))
|
||||||
|
if len(chars) == 0 {
|
||||||
|
return C.jni_NewString(env, nil, 0)
|
||||||
|
}
|
||||||
|
return C.jni_NewString(env, (*C.jchar)(unsafe.Pointer(unsafe.SliceData(chars))), C.jsize(len(chars)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func cString(value string) *C.char {
|
||||||
|
return C.CString(value)
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !android
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
func newPlatformVaultSharer(goos string) vaultSharer {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
public final class AndroidShare {
|
||||||
|
private static final String DEFAULT_TITLE = "KeePassGO Vault";
|
||||||
|
|
||||||
|
private AndroidShare() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void shareVault(Context context, String path, String title) throws IOException {
|
||||||
|
File source = new File(path);
|
||||||
|
if (!source.isFile()) {
|
||||||
|
throw new IOException("vault file not found: " + path);
|
||||||
|
}
|
||||||
|
File shared = copyToSharedExport(context, source);
|
||||||
|
Uri uri = SharedVaultProvider.uriForFile(shared.getName());
|
||||||
|
|
||||||
|
Intent send = new Intent(Intent.ACTION_SEND);
|
||||||
|
send.setType("application/x-keepass2");
|
||||||
|
send.putExtra(Intent.EXTRA_STREAM, uri);
|
||||||
|
send.putExtra(Intent.EXTRA_TITLE, sanitizeTitle(title, source.getName()));
|
||||||
|
send.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
|
||||||
|
Intent chooser = Intent.createChooser(send, "Share vault");
|
||||||
|
chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
chooser.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||||
|
context.startActivity(chooser);
|
||||||
|
}
|
||||||
|
|
||||||
|
static File sharedDirectory(Context context) {
|
||||||
|
return new File(new File(context.getFilesDir(), "keepassgo"), "shared");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File copyToSharedExport(Context context, File source) throws IOException {
|
||||||
|
File dir = sharedDirectory(context);
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
File target = new File(dir, sanitizeFilename(source.getName()));
|
||||||
|
try (FileInputStream in = new FileInputStream(source);
|
||||||
|
FileOutputStream out = new FileOutputStream(target, false)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) >= 0) {
|
||||||
|
out.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeFilename(String name) {
|
||||||
|
String trimmed = name == null ? "" : name.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return "shared-vault.kdbx";
|
||||||
|
}
|
||||||
|
if (trimmed.endsWith(".kdbx")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed + ".kdbx";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeTitle(String title, String fallbackName) {
|
||||||
|
String trimmed = title == null ? "" : title.trim();
|
||||||
|
if (!trimmed.isEmpty()) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
String fallback = fallbackName == null ? "" : fallbackName.trim();
|
||||||
|
if (!fallback.isEmpty()) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return DEFAULT_TITLE;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
public final class SharedVaultImportActivity extends Activity {
|
||||||
|
private static final String TAG = "KeePassGOImport";
|
||||||
|
private static final String DEFAULT_NAME = "shared-vault.kdbx";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle state) {
|
||||||
|
super.onCreate(state);
|
||||||
|
handleIntent(getIntent());
|
||||||
|
launchMainActivity();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
handleIntent(intent);
|
||||||
|
launchMainActivity();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleIntent(Intent intent) {
|
||||||
|
Uri uri = resolveSharedUri(intent);
|
||||||
|
if (uri == null) {
|
||||||
|
Log.i(TAG, "no shared vault URI on intent");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
persistPendingImport(uri);
|
||||||
|
Log.i(TAG, "queued shared vault import from " + uri);
|
||||||
|
} catch (IOException | RuntimeException err) {
|
||||||
|
Log.e(TAG, "failed to queue shared vault import", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Uri resolveSharedUri(Intent intent) {
|
||||||
|
if (intent == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String action = intent.getAction();
|
||||||
|
if (Intent.ACTION_SEND.equals(action)) {
|
||||||
|
return intent.getParcelableExtra(Intent.EXTRA_STREAM);
|
||||||
|
}
|
||||||
|
if (Intent.ACTION_VIEW.equals(action)) {
|
||||||
|
return intent.getData();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistPendingImport(Uri uri) throws IOException {
|
||||||
|
File dir = new File(getFilesDir(), "keepassgo");
|
||||||
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
|
throw new IOException("failed to create " + dir.getAbsolutePath());
|
||||||
|
}
|
||||||
|
File pendingFile = new File(dir, "pending-shared-vault.kdbx");
|
||||||
|
try (InputStream in = getContentResolver().openInputStream(uri)) {
|
||||||
|
if (in == null) {
|
||||||
|
throw new IOException("failed to open shared vault stream");
|
||||||
|
}
|
||||||
|
try (FileOutputStream out = new FileOutputStream(pendingFile, false)) {
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) >= 0) {
|
||||||
|
out.write(buffer, 0, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
File nameFile = new File(dir, "pending-shared-vault-name.txt");
|
||||||
|
try (FileOutputStream out = new FileOutputStream(nameFile, false)) {
|
||||||
|
out.write(resolveDisplayName(uri).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveDisplayName(Uri uri) {
|
||||||
|
String displayName = queryDisplayName(uri);
|
||||||
|
if (!displayName.isEmpty()) {
|
||||||
|
return displayName;
|
||||||
|
}
|
||||||
|
String lastSegment = uri.getLastPathSegment();
|
||||||
|
if (lastSegment != null && !lastSegment.trim().isEmpty()) {
|
||||||
|
return lastSegment.trim();
|
||||||
|
}
|
||||||
|
return DEFAULT_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String queryDisplayName(Uri uri) {
|
||||||
|
Cursor cursor = null;
|
||||||
|
try {
|
||||||
|
cursor = getContentResolver().query(uri, new String[]{OpenableColumns.DISPLAY_NAME}, null, null, null);
|
||||||
|
if (cursor != null && cursor.moveToFirst()) {
|
||||||
|
int index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||||
|
if (index >= 0) {
|
||||||
|
String value = cursor.getString(index);
|
||||||
|
if (value != null) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (RuntimeException err) {
|
||||||
|
Log.w(TAG, "failed to query display name", err);
|
||||||
|
} finally {
|
||||||
|
if (cursor != null) {
|
||||||
|
cursor.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void launchMainActivity() {
|
||||||
|
Intent launch = new Intent();
|
||||||
|
launch.setClassName(this, "org.gioui.GioActivity");
|
||||||
|
startActivity(launch);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package org.julianfamily.keepassgo;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
|
||||||
|
public final class SharedVaultProvider extends ContentProvider {
|
||||||
|
private static final String AUTHORITY = "org.julianfamily.keepassgo.share";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
|
||||||
|
File file = resolveSharedFile(uri);
|
||||||
|
String[] columns = projection;
|
||||||
|
if (columns == null || columns.length == 0) {
|
||||||
|
columns = new String[]{OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE};
|
||||||
|
}
|
||||||
|
MatrixCursor cursor = new MatrixCursor(columns, 1);
|
||||||
|
Object[] row = new Object[columns.length];
|
||||||
|
for (int i = 0; i < columns.length; i++) {
|
||||||
|
switch (columns[i]) {
|
||||||
|
case OpenableColumns.DISPLAY_NAME:
|
||||||
|
row[i] = file.getName();
|
||||||
|
break;
|
||||||
|
case OpenableColumns.SIZE:
|
||||||
|
row[i] = file.length();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
row[i] = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.addRow(row);
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri) {
|
||||||
|
return "application/x-keepass2";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values) {
|
||||||
|
throw new UnsupportedOperationException("insert is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("delete is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
|
||||||
|
throw new UnsupportedOperationException("update is not supported");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
|
||||||
|
File file = resolveSharedFile(uri);
|
||||||
|
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Uri uriForFile(String name) {
|
||||||
|
return new Uri.Builder()
|
||||||
|
.scheme("content")
|
||||||
|
.authority(AUTHORITY)
|
||||||
|
.appendPath(name)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private File resolveSharedFile(Uri uri) {
|
||||||
|
if (getContext() == null) {
|
||||||
|
throw new IllegalStateException("provider context is unavailable");
|
||||||
|
}
|
||||||
|
String name = sanitizeFilename(uri.getLastPathSegment());
|
||||||
|
return new File(AndroidShare.sharedDirectory(getContext()), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String sanitizeFilename(String name) {
|
||||||
|
if (name == null) {
|
||||||
|
return "shared-vault.kdbx";
|
||||||
|
}
|
||||||
|
String trimmed = name.trim();
|
||||||
|
if (trimmed.isEmpty()) {
|
||||||
|
return "shared-vault.kdbx";
|
||||||
|
}
|
||||||
|
return new File(trimmed).getName();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SyncMode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SyncModeManual SyncMode = "manual"
|
||||||
|
SyncModeAutomaticOnOpenSave SyncMode = "automatic_on_open_save"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteBinding struct {
|
||||||
|
LocalVaultPath string `json:"localVaultPath"`
|
||||||
|
RemoteProfileID string `json:"remoteProfileId"`
|
||||||
|
CredentialEntryID string `json:"credentialEntryId"`
|
||||||
|
SyncMode SyncMode `json:"syncMode,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedRemoteBinding struct {
|
||||||
|
Profile vault.RemoteProfile
|
||||||
|
Credentials vault.Entry
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoteBindingInput struct {
|
||||||
|
LocalVaultPath string
|
||||||
|
RemoteProfileID string
|
||||||
|
RemoteProfileName string
|
||||||
|
BaseURL string
|
||||||
|
RemotePath string
|
||||||
|
CredentialEntryID string
|
||||||
|
CredentialTitle string
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
CredentialPath []string
|
||||||
|
SyncMode SyncMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b RemoteBinding) Resolve(model vault.Model) (ResolvedRemoteBinding, error) {
|
||||||
|
profile, err := model.RemoteProfileByID(b.RemoteProfileID)
|
||||||
|
if err != nil {
|
||||||
|
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote profile: %w", err)
|
||||||
|
}
|
||||||
|
credentials, err := model.EntryByID(b.CredentialEntryID)
|
||||||
|
if err != nil {
|
||||||
|
return ResolvedRemoteBinding{}, fmt.Errorf("resolve remote credentials: %w", err)
|
||||||
|
}
|
||||||
|
return ResolvedRemoteBinding{
|
||||||
|
Profile: profile,
|
||||||
|
Credentials: credentials,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigureRemoteBinding(model *vault.Model, input RemoteBindingInput) (RemoteBinding, error) {
|
||||||
|
if model == nil {
|
||||||
|
return RemoteBinding{}, fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
input.LocalVaultPath = strings.TrimSpace(input.LocalVaultPath)
|
||||||
|
input.RemoteProfileID = strings.TrimSpace(input.RemoteProfileID)
|
||||||
|
input.RemoteProfileName = strings.TrimSpace(input.RemoteProfileName)
|
||||||
|
input.BaseURL = strings.TrimSpace(input.BaseURL)
|
||||||
|
input.RemotePath = strings.TrimSpace(input.RemotePath)
|
||||||
|
input.CredentialEntryID = strings.TrimSpace(input.CredentialEntryID)
|
||||||
|
input.CredentialTitle = strings.TrimSpace(input.CredentialTitle)
|
||||||
|
input.Username = strings.TrimSpace(input.Username)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case input.LocalVaultPath == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("local vault path is required")
|
||||||
|
case input.RemoteProfileID == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("remote profile id is required")
|
||||||
|
case input.BaseURL == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("remote base URL is required")
|
||||||
|
case input.RemotePath == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("remote path is required")
|
||||||
|
case input.CredentialEntryID == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("credential entry id is required")
|
||||||
|
case input.Password == "":
|
||||||
|
return RemoteBinding{}, fmt.Errorf("credential password is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.RemoteProfileName == "" {
|
||||||
|
input.RemoteProfileName = input.RemoteProfileID
|
||||||
|
}
|
||||||
|
if input.CredentialTitle == "" {
|
||||||
|
input.CredentialTitle = "Remote Sign-In"
|
||||||
|
}
|
||||||
|
|
||||||
|
model.UpsertRemoteProfile(vault.RemoteProfile{
|
||||||
|
ID: input.RemoteProfileID,
|
||||||
|
Name: input.RemoteProfileName,
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: input.BaseURL,
|
||||||
|
Path: input.RemotePath,
|
||||||
|
})
|
||||||
|
model.UpsertEntry(vault.Entry{
|
||||||
|
ID: input.CredentialEntryID,
|
||||||
|
Title: input.CredentialTitle,
|
||||||
|
Username: input.Username,
|
||||||
|
Password: input.Password,
|
||||||
|
URL: input.BaseURL,
|
||||||
|
Path: append([]string(nil), input.CredentialPath...),
|
||||||
|
})
|
||||||
|
|
||||||
|
return RemoteBinding{
|
||||||
|
LocalVaultPath: input.LocalVaultPath,
|
||||||
|
RemoteProfileID: input.RemoteProfileID,
|
||||||
|
CredentialEntryID: input.CredentialEntryID,
|
||||||
|
SyncMode: normalizeSyncMode(input.SyncMode),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RemoveRemoteBinding(model *vault.Model, binding RemoteBinding) error {
|
||||||
|
if model == nil {
|
||||||
|
return fmt.Errorf("model is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(binding.RemoteProfileID) == "" {
|
||||||
|
return fmt.Errorf("remote profile id is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(binding.CredentialEntryID) == "" {
|
||||||
|
return fmt.Errorf("credential entry id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
model.RemoveRemoteProfileByID(binding.RemoteProfileID)
|
||||||
|
model.RemoveEntryByID(binding.CredentialEntryID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeSyncMode(mode SyncMode) SyncMode {
|
||||||
|
switch mode {
|
||||||
|
case SyncModeAutomaticOnOpenSave:
|
||||||
|
return SyncModeAutomaticOnOpenSave
|
||||||
|
default:
|
||||||
|
return SyncModeManual
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package appstate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoteBindingResolveUsesVaultProfileAndCredentialEntry(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "linuscaldwell-webdav",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
binding := RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "linuscaldwell-webdav",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := binding.Resolve(model)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := resolved.Profile.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("resolved profile base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Profile.Path; got != "files/family/keepass.kdbx" {
|
||||||
|
t.Fatalf("resolved profile path = %q, want files/family/keepass.kdbx", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Credentials.Username; got != "linuscaldwell" {
|
||||||
|
t.Fatalf("resolved credentials username = %q, want linuscaldwell", got)
|
||||||
|
}
|
||||||
|
if got := resolved.Credentials.Password; got != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("resolved credentials password = %q, want bellagio-pass-1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteBindingResolveFailsWhenVaultReferenceIsMissing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "linuscaldwell-webdav", Title: "Bellagio WebDAV Sign-In"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := (RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "missing-creds",
|
||||||
|
}).Resolve(model)
|
||||||
|
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrRemoteProfileNotFound first", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
model.RemoteProfiles = []vault.RemoteProfile{{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err = (RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "missing-creds",
|
||||||
|
}).Resolve(model)
|
||||||
|
if !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("Resolve() error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteBindingJSONStoresOnlyNonSecretReferences(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
content, err := json.Marshal(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("json.Marshal(RemoteBinding) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
text := string(content)
|
||||||
|
for _, disallowed := range []string{"bellagio-pass-1", "password", "username", "baseUrl"} {
|
||||||
|
if strings.Contains(text, disallowed) {
|
||||||
|
t.Fatalf("binding JSON %q unexpectedly contains %q", text, disallowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingStoresProfileAndCredentialsInVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var model vault.Model
|
||||||
|
|
||||||
|
binding, err := ConfigureRemoteBinding(&model, RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
RemoteProfileName: "Family Vault",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
CredentialPath: []string{"Crew", "Internet"},
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(model.RemoteProfiles) != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(model.RemoteProfiles))
|
||||||
|
}
|
||||||
|
if got := model.RemoteProfiles[0].BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("stored remote profile base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials, err := model.EntryByID("remote-creds-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||||
|
}
|
||||||
|
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||||
|
}
|
||||||
|
if credentials.URL != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("stored credential entry URL = %q, want remote.php/dav URL", credentials.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if binding.LocalVaultPath != "/tmp/family.kdbx" {
|
||||||
|
t.Fatalf("binding LocalVaultPath = %q, want /tmp/family.kdbx", binding.LocalVaultPath)
|
||||||
|
}
|
||||||
|
if binding.RemoteProfileID != "family-webdav" || binding.CredentialEntryID != "remote-creds-1" {
|
||||||
|
t.Fatalf("binding = %#v, want only vault references", binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingRejectsIncompleteInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
input RemoteBindingInput
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing_local_vault_path",
|
||||||
|
input: RemoteBindingInput{
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_remote_base_url",
|
||||||
|
input: RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing_credential_entry_id",
|
||||||
|
input: RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
tc := tc
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var model vault.Model
|
||||||
|
if _, err := ConfigureRemoteBinding(&model, tc.input); err == nil {
|
||||||
|
t.Fatalf("ConfigureRemoteBinding(%#v) error = nil, want validation error", tc.input)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRemoteBindingRemovesProfileAndCredentialsFromVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := RemoveRemoteBinding(&model, RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(model.RemoteProfiles); got != 0 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if _, err := model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,6 +133,52 @@ func (s *State) APITokens() ([]apitokens.Token, error) {
|
|||||||
return apitokens.Entries(model)
|
return apitokens.Entries(model)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) RemoteProfiles() ([]vault.RemoteProfile, error) {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profiles := slices.Clone(model.RemoteProfiles)
|
||||||
|
slices.SortFunc(profiles, func(a, b vault.RemoteProfile) int {
|
||||||
|
switch {
|
||||||
|
case a.Name < b.Name:
|
||||||
|
return -1
|
||||||
|
case a.Name > b.Name:
|
||||||
|
return 1
|
||||||
|
case a.ID < b.ID:
|
||||||
|
return -1
|
||||||
|
case a.ID > b.ID:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RemoteCredentialEntries() ([]vault.Entry, error) {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entries := slices.Clone(model.Entries)
|
||||||
|
slices.SortFunc(entries, func(a, b vault.Entry) int {
|
||||||
|
switch {
|
||||||
|
case a.Title < b.Title:
|
||||||
|
return -1
|
||||||
|
case a.Title > b.Title:
|
||||||
|
return 1
|
||||||
|
case a.ID < b.ID:
|
||||||
|
return -1
|
||||||
|
case a.ID > b.ID:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now time.Time) (apitokens.Token, string, error) {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -834,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) OpenBoundRemoteVault(binding RemoteBinding, key vault.MasterKey) error {
|
||||||
|
model, err := s.currentModel()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := binding.Resolve(model)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
client := webdav.Client{
|
||||||
|
BaseURL: resolved.Profile.BaseURL,
|
||||||
|
Username: resolved.Credentials.Username,
|
||||||
|
Password: resolved.Credentials.Password,
|
||||||
|
}
|
||||||
|
return s.OpenRemoteVault(client, resolved.Profile.Path, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) ConfigureRemoteBinding(input RemoteBindingInput) (RemoteBinding, error) {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return RemoteBinding{}, fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return RemoteBinding{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
binding, err := ConfigureRemoteBinding(&model, input)
|
||||||
|
if err != nil {
|
||||||
|
return RemoteBinding{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return binding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *State) RemoveRemoteBinding(binding RemoteBinding) error {
|
||||||
|
session, ok := s.Session.(MutableSession)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("session is not mutable")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, err := session.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := RemoveRemoteBinding(&model, binding); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session.Replace(model)
|
||||||
|
s.Dirty = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) CreateGroup(name string) error {
|
func (s *State) CreateGroup(name string) error {
|
||||||
session, ok := s.Session.(MutableSession)
|
session, ok := s.Session.(MutableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
+277
-30
@@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -54,7 +54,7 @@ func TestVisibleEntriesAtParentGroupOnlyShowsDirectEntries(t *testing.T) {
|
|||||||
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
{ID: "joe-note", Title: "Crew Note", Path: []string{"Crew"}},
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -164,6 +164,71 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "archive-webdav",
|
||||||
|
Name: "Archive Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/archive.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.RemoteProfiles()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteProfiles() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len(RemoteProfiles()) = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "archive-webdav" || got[1].ID != "family-webdav" {
|
||||||
|
t.Fatalf("RemoteProfiles() = %#v, want sorted by name/id", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteCredentialEntriesReturnsSortedVaultEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{
|
||||||
|
Session: stubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{ID: "cred-2", Title: "Zulu Sign-In", Username: "zuser", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "cred-1", Title: "Alpha Sign-In", Username: "auser", Path: []string{"Crew", "Internet"}},
|
||||||
|
{ID: "cred-3", Title: "Mint Sign-In", Username: "frankcatton", Path: []string{"Crew", "Safe House"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := state.RemoteCredentialEntries()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteCredentialEntries() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len(RemoteCredentialEntries()) = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
if got[0].ID != "cred-1" || got[1].ID != "cred-3" || got[2].ID != "cred-2" {
|
||||||
|
t.Fatalf("RemoteCredentialEntries() = %#v, want entries sorted by title", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -187,7 +252,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
if len(got) != 1 || got[0].Title != "Surveillance Console" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want Home Assistant search match", got)
|
t.Fatalf("VisibleEntries() = %#v, want Security Office search match", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +265,7 @@ func TestVisibleEntriesReturnsDescendantsAfterClearingSearch(t *testing.T) {
|
|||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
{ID: "vault-console", Title: "Vault Console", URL: "https://vault.crew.example.invalid", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", URL: "https://surveillance.crew.example.invalid", Path: []string{"Crew", "Security Office"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -350,7 +415,7 @@ func TestVisibleEntriesUsesGlobalSearchWithinRecycleBin(t *testing.T) {
|
|||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
RecycleBin: []vault.Entry{
|
RecycleBin: []vault.Entry{
|
||||||
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
{ID: "deleted-1", Title: "Deleted Bellagio", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "deleted-2", Title: "Deleted HVAC", URL: "https://climate.example.com", Path: []string{"Root", "Home"}},
|
{ID: "deleted-2", Title: "Deleted Vault Vent", URL: "https://climate.example.com", Path: []string{"Root", "Safe House"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -419,7 +484,7 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
|||||||
model: vault.Model{
|
model: vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Crew", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Crew", "Security Office"}},
|
||||||
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
{ID: "alma", Title: "Alma (WA Prep)", Path: []string{"Tricia", "School"}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
|||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
|||||||
ID: "vault-console",
|
ID: "vault-console",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
}
|
}
|
||||||
@@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
}
|
}
|
||||||
if len(got) != 1 || got[0].Password != "token-1" {
|
if len(got) != 1 || got[0].Password != "bellagio-pass-1" {
|
||||||
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
t.Fatalf("VisibleEntries() = %#v, want persisted vault-console entry", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +1029,185 @@ func TestOpenRemoteVaultResetsSelectionPathAndDirtyState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestOpenBoundRemoteVaultResolvesClientFromVaultBinding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{
|
||||||
|
model: vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
Path: []string{"Crew", "Internet"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
state := State{
|
||||||
|
Session: sess,
|
||||||
|
CurrentPath: []string{"Root", "Internet"},
|
||||||
|
SelectedEntryID: "vault-console",
|
||||||
|
Dirty: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("OpenBoundRemoteVault() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := sess.remoteClient.BaseURL; got != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("remote client base URL = %q, want remote.php/dav URL", got)
|
||||||
|
}
|
||||||
|
if got := sess.remoteClient.Username; got != "linuscaldwell" {
|
||||||
|
t.Fatalf("remote client username = %q, want linuscaldwell", got)
|
||||||
|
}
|
||||||
|
if got := sess.remoteClient.Password; got != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("remote client password = %q, want bellagio-pass-1", got)
|
||||||
|
}
|
||||||
|
if got := sess.remotePath; got != "files/family/keepass.kdbx" {
|
||||||
|
t.Fatalf("remotePath = %q, want files/family/keepass.kdbx", got)
|
||||||
|
}
|
||||||
|
if len(state.CurrentPath) != 0 {
|
||||||
|
t.Fatalf("CurrentPath = %v, want empty", state.CurrentPath)
|
||||||
|
}
|
||||||
|
if state.SelectedEntryID != "" {
|
||||||
|
t.Fatalf("SelectedEntryID = %q, want empty", state.SelectedEntryID)
|
||||||
|
}
|
||||||
|
if state.Dirty {
|
||||||
|
t.Fatal("Dirty = true, want false after bound remote open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenBoundRemoteVaultReturnsResolutionErrors(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &lifecycleStubSession{model: vault.Model{}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
err := state.OpenBoundRemoteVault(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "missing-profile",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"})
|
||||||
|
if !errors.Is(err, vault.ErrRemoteProfileNotFound) {
|
||||||
|
t.Fatalf("OpenBoundRemoteVault() error = %v, want ErrRemoteProfileNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingPersistsIntoCurrentVaultAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
binding, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
RemoteProfileName: "Family Vault",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
CredentialTitle: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
CredentialPath: []string{"Crew", "Internet"},
|
||||||
|
SyncMode: SyncModeAutomaticOnOpenSave,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConfigureRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after ConfigureRemoteBinding")
|
||||||
|
}
|
||||||
|
if got := binding.RemoteProfileID; got != "family-webdav" {
|
||||||
|
t.Fatalf("binding.RemoteProfileID = %q, want family-webdav", got)
|
||||||
|
}
|
||||||
|
if got := len(sess.model.RemoteProfiles); got != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", got)
|
||||||
|
}
|
||||||
|
credentials, err := sess.model.EntryByID("remote-creds-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v", err)
|
||||||
|
}
|
||||||
|
if credentials.Username != "linuscaldwell" || credentials.Password != "bellagio-pass-1" {
|
||||||
|
t.Fatalf("stored credential entry = %#v, want linuscaldwell/bellagio-pass-1", credentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigureRemoteBindingRequiresMutableSession(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
state := State{Session: stubSession{model: vault.Model{}}}
|
||||||
|
_, err := state.ConfigureRemoteBinding(RemoteBindingInput{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
RemotePath: "files/family/keepass.kdbx",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ConfigureRemoteBinding() error = nil, want mutability error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveRemoteBindingRemovesVaultDataAndMarksDirty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
sess := &mutableStubSession{model: vault.Model{
|
||||||
|
Entries: []vault.Entry{{
|
||||||
|
ID: "remote-creds-1",
|
||||||
|
Title: "Bellagio WebDAV Sign-In",
|
||||||
|
Username: "linuscaldwell",
|
||||||
|
Password: "bellagio-pass-1",
|
||||||
|
}},
|
||||||
|
RemoteProfiles: []vault.RemoteProfile{{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: vault.RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
state := State{Session: sess}
|
||||||
|
|
||||||
|
err := state.RemoveRemoteBinding(RemoteBinding{
|
||||||
|
LocalVaultPath: "/tmp/family.kdbx",
|
||||||
|
RemoteProfileID: "family-webdav",
|
||||||
|
CredentialEntryID: "remote-creds-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveRemoteBinding() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !state.Dirty {
|
||||||
|
t.Fatal("Dirty = false, want true after RemoveRemoteBinding")
|
||||||
|
}
|
||||||
|
if got := len(sess.model.RemoteProfiles); got != 0 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 0", got)
|
||||||
|
}
|
||||||
|
if _, err := sess.model.EntryByID("remote-creds-1"); !errors.Is(err, vault.ErrEntryNotFound) {
|
||||||
|
t.Fatalf("EntryByID(remote-creds-1) error = %v, want ErrEntryNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
func TestLockClearsSelectionAndMakesVaultUnavailable(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
|||||||
SelectedEntryID: "vault-console",
|
SelectedEntryID: "vault-console",
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||||
|
|
||||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
||||||
}
|
}
|
||||||
if got := state.SelectedEntryID; got != "" {
|
if got := state.SelectedEntryID; got != "" {
|
||||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||||
@@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
|||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||||
@@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
|||||||
t.Fatalf("ChildGroups() error = %v", err)
|
t.Fatalf("ChildGroups() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||||
}
|
}
|
||||||
if !state.Dirty {
|
if !state.Dirty {
|
||||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||||
@@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
|||||||
SelectedEntryID: "bellagio",
|
SelectedEntryID: "bellagio",
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := state.MoveSelectedEntry([]string{"Root", "Home Assistant"}); err != nil {
|
if err := state.MoveSelectedEntry([]string{"Root", "Security Office"}); err != nil {
|
||||||
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
t.Fatalf("MoveSelectedEntry() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||||
got, err := state.VisibleEntries()
|
got, err := state.VisibleEntries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("VisibleEntries() error = %v", err)
|
t.Fatalf("VisibleEntries() error = %v", err)
|
||||||
@@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model {
|
|||||||
return vault.Model{
|
return vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
{ID: "bellagio", Title: "Bellagio", Path: []string{"Root", "Internet"}},
|
||||||
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Home Assistant"}},
|
{ID: "surveillance-console", Title: "Surveillance Console", Path: []string{"Root", "Security Office"}},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1553,15 +1797,17 @@ func (s *saveableStubSession) Save() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type lifecycleStubSession struct {
|
type lifecycleStubSession struct {
|
||||||
createCalls int
|
createCalls int
|
||||||
openPath string
|
model vault.Model
|
||||||
saveAsPath string
|
openPath string
|
||||||
remotePath string
|
saveAsPath string
|
||||||
changedKey vault.MasterKey
|
remoteClient webdav.Client
|
||||||
|
remotePath string
|
||||||
|
changedKey vault.MasterKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||||
return vault.Model{}, nil
|
return s.model, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||||
@@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *lifecycleStubSession) OpenRemote(_ webdav.Client, path string, _ vault.MasterKey) error {
|
func (s *lifecycleStubSession) OpenRemote(client webdav.Client, path string, _ vault.MasterKey) error {
|
||||||
|
s.remoteClient = client
|
||||||
s.remotePath = path
|
s.remotePath = path
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Local-First Remote Sync Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Redesign remote-backed vault handling so every platform uses the same local-first model:
|
||||||
|
|
||||||
|
- every vault is a local KDBX file first
|
||||||
|
- remote sync is an optional binding on top of that local file
|
||||||
|
- shared remote configuration lives in the vault
|
||||||
|
- user-specific remote credentials live in the vault
|
||||||
|
- app-local state stores only non-secret binding metadata
|
||||||
|
|
||||||
|
Android adds only one platform-specific capability on top of that model:
|
||||||
|
|
||||||
|
- share/import the initial local KDBX file between devices
|
||||||
|
|
||||||
|
## Product Rules
|
||||||
|
|
||||||
|
1. A remote-backed vault must always have a local cache KDBX file.
|
||||||
|
2. Opening a remote-backed vault should open the local KDBX first.
|
||||||
|
3. Shared remote configuration must be stored in the vault, not only in app state.
|
||||||
|
4. Remote credentials must not be stored in plaintext app-local state.
|
||||||
|
5. Remote credentials should be stored in the vault and resolved by a stable reference.
|
||||||
|
6. The app state file should keep only the metadata needed to reopen the local vault and find the remote binding.
|
||||||
|
7. Sync must support both manual and automatic modes.
|
||||||
|
8. Android-specific sharing should transfer the KDBX file, not a bespoke remote-secret bundle.
|
||||||
|
|
||||||
|
## Target Data Model
|
||||||
|
|
||||||
|
### In-Vault Shared Remote Profile
|
||||||
|
|
||||||
|
Store a reusable remote profile in the vault with fields such as:
|
||||||
|
|
||||||
|
- profile ID
|
||||||
|
- profile name
|
||||||
|
- backend type, initially WebDAV
|
||||||
|
- base URL
|
||||||
|
- remote object path
|
||||||
|
- optional notes or labels
|
||||||
|
- default sync policy, if shared defaults are desirable
|
||||||
|
|
||||||
|
### In-Vault User Credential Binding
|
||||||
|
|
||||||
|
Store user-specific credentials in the vault as normal vault data, referenced by:
|
||||||
|
|
||||||
|
- remote profile ID
|
||||||
|
- credential entry UUID, or another stable internal reference
|
||||||
|
- optional username field override if needed
|
||||||
|
|
||||||
|
The credential entry should contain the actual username/password or token.
|
||||||
|
|
||||||
|
### Local App State
|
||||||
|
|
||||||
|
Persist only non-secret binding state such as:
|
||||||
|
|
||||||
|
- local vault path
|
||||||
|
- selected remote profile ID
|
||||||
|
- selected credential entry reference
|
||||||
|
- sync policy override
|
||||||
|
- last sync metadata
|
||||||
|
- conflict or recovery markers
|
||||||
|
|
||||||
|
## Core Flows
|
||||||
|
|
||||||
|
### Create Or Configure Remote Sync
|
||||||
|
|
||||||
|
1. Open or create a local vault.
|
||||||
|
2. Create or edit a shared remote profile in that vault.
|
||||||
|
3. Create or select a credential entry in that vault.
|
||||||
|
4. Bind the local vault to the selected remote profile and credential reference.
|
||||||
|
5. Choose manual or automatic sync behavior.
|
||||||
|
|
||||||
|
### Reopen Existing Remote-Backed Vault
|
||||||
|
|
||||||
|
1. Open the local vault file from app state.
|
||||||
|
2. Resolve the selected remote profile from vault contents.
|
||||||
|
3. Resolve the credential entry from vault contents.
|
||||||
|
4. Offer or perform sync based on the binding policy.
|
||||||
|
|
||||||
|
### Bootstrap A New Android Device
|
||||||
|
|
||||||
|
1. Share the local KDBX file through Android Sharesheet.
|
||||||
|
2. Import and open that KDBX locally on the new device.
|
||||||
|
3. Select a remote profile stored in the vault.
|
||||||
|
4. Select or create that user’s credential entry in the vault.
|
||||||
|
5. Bind the local vault as the cache for the remote-backed setup.
|
||||||
|
|
||||||
|
## Migration Requirements
|
||||||
|
|
||||||
|
1. Migrate existing remote connections that save credentials in app state.
|
||||||
|
2. On first open after upgrade, move any recoverable remote credentials into the vault.
|
||||||
|
3. Replace saved plaintext credential state with a vault credential reference.
|
||||||
|
4. If migration cannot write into the vault yet, hold the old state only long enough to prompt the user to complete migration.
|
||||||
|
5. Remove legacy local plaintext credential persistence after migration is complete.
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Domain Model
|
||||||
|
|
||||||
|
- define remote profile structures independent of Gio UI
|
||||||
|
- define credential reference structures independent of Gio UI
|
||||||
|
- define sync binding state independent of Gio UI
|
||||||
|
- add behavior tests for local-first remote-backed vaults
|
||||||
|
|
||||||
|
### Phase 2: Vault Storage
|
||||||
|
|
||||||
|
- persist remote profiles in the vault
|
||||||
|
- persist credential references in the vault
|
||||||
|
- resolve credentials from normal vault entries
|
||||||
|
- add behavior tests for read/write and lookup semantics
|
||||||
|
|
||||||
|
### Phase 3: State And Open Flow
|
||||||
|
|
||||||
|
- shrink app state to non-secret metadata only
|
||||||
|
- update open flows to always prefer the local cache vault
|
||||||
|
- update reopen behavior on all platforms to use the same model
|
||||||
|
- add migration coverage for old remote state
|
||||||
|
|
||||||
|
### Phase 4: Sync Binding
|
||||||
|
|
||||||
|
- bind a local vault to a selected remote profile
|
||||||
|
- support manual sync
|
||||||
|
- support automatic sync on open/save
|
||||||
|
- define conflict and remote-failure handling for the local cache model
|
||||||
|
|
||||||
|
### Phase 5: Android Bootstrap
|
||||||
|
|
||||||
|
- add Android Sharesheet export of the current local KDBX
|
||||||
|
- add Android import flow for a shared KDBX
|
||||||
|
- keep the remote pivot flow consistent with desktop after local open
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. Where in the vault should remote profiles live: custom metadata, dedicated entries, or another KDBX-compatible structure?
|
||||||
|
2. Should credential references point to entry UUIDs directly, or should KeePassGO maintain an additional logical identifier?
|
||||||
|
3. Should automatic sync run only on open/save initially, or also on app resume?
|
||||||
|
4. How should multiple remote profiles per vault be presented in the UI?
|
||||||
|
5. What should happen when the credential entry reference no longer resolves?
|
||||||
|
|
||||||
|
## Recommended First Slice
|
||||||
|
|
||||||
|
Implement the shared domain model and tests first:
|
||||||
|
|
||||||
|
- model a local vault plus optional remote binding
|
||||||
|
- define in-vault remote profile and credential reference semantics
|
||||||
|
- add tests proving app state no longer needs plaintext remote credentials
|
||||||
|
|
||||||
|
That slice standardizes the architecture before any Android-specific sharing work begins.
|
||||||
+2892
-218
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
pkgbase = keepassgo-git
|
pkgbase = keepassgo-git
|
||||||
pkgdesc = KeePass-compatible password manager written in Go
|
pkgdesc = KeePass-compatible password manager written in Go
|
||||||
pkgver = r160.5fa79bd
|
pkgver = r165.1c72a50
|
||||||
pkgrel = 1
|
pkgrel = 1
|
||||||
url = https://git.julianfamily.org/joejulian/keepassgo
|
url = https://git.julianfamily.org/joejulian/keepassgo
|
||||||
arch = x86_64
|
arch = x86_64
|
||||||
|
|||||||
+2
-8
@@ -1,5 +1,5 @@
|
|||||||
pkgname=keepassgo-git
|
pkgname=keepassgo-git
|
||||||
pkgver=r0.0000000
|
pkgver=@PKGVER@
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc='KeePass-compatible password manager written in Go'
|
pkgdesc='KeePass-compatible password manager written in Go'
|
||||||
arch=('x86_64' 'aarch64')
|
arch=('x86_64' 'aarch64')
|
||||||
@@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
|
|||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
|
||||||
_repo_dir() {
|
_repo_dir() {
|
||||||
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
|
printf '%s\n' "@REPO_DIR@"
|
||||||
printf '%s\n' "${srcdir}/keepassgo"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd "${startdir}/../../.." || exit 1
|
|
||||||
pwd
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pkgver() {
|
pkgver() {
|
||||||
+82
-216
@@ -21,7 +21,6 @@ import (
|
|||||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||||
busy := u.lifecycleBusy()
|
busy := u.lifecycleBusy()
|
||||||
showLocalChooser := u.showLocalVaultChooser()
|
showLocalChooser := u.showLocalVaultChooser()
|
||||||
showRemoteChooser := u.showRemoteConnectionChooser()
|
|
||||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -31,154 +30,13 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
|
message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
message = "Connect to a remote vault, then unlock it with the KeePass master key."
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), message)
|
lbl := material.Label(u.theme, unit.Sp(14), message)
|
||||||
lbl.Color = accentColor
|
lbl.Color = accentColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return passiveSectionTab(gtx, u.theme, "Local Vault", u.lifecycleMode == "local")
|
|
||||||
}
|
|
||||||
return sectionTabButton(gtx, u.theme, &u.showLocalLifecycle, "Local Vault", u.lifecycleMode == "local")
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return passiveSectionTab(gtx, u.theme, "Remote Vault", u.lifecycleMode == "remote")
|
|
||||||
}
|
|
||||||
return sectionTabButton(gtx, u.theme, &u.showRemoteLifecycle, "Remote Vault", u.lifecycleMode == "remote")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "LOCATION")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Base URL", "Base WebDAV endpoint, for example https://server/remote.php/webdav.", &u.remoteBaseURL, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Path", "Path to the remote .kdbx file under the WebDAV base URL.", &u.remotePath, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if showRemoteChooser || !u.hasSelectedRemoteTarget() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if showRemoteChooser && !busy {
|
|
||||||
return u.recentRemoteList(gtx)
|
|
||||||
}
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(10)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "AUTHENTICATION")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(4)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Username", "Username used to authenticate to the WebDAV server.", &u.remoteUsername, false)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return labeledEditorHelp(u.theme, "Remote Password", "Password or app token used to authenticate to the WebDAV server.", &u.remotePassword, true)(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
box := material.CheckBox(u.theme, &u.rememberRemoteAuth, "Remember sign-in on this device")
|
|
||||||
box.Color = accentColor
|
|
||||||
return box.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Inset{Top: unit.Dp(4)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openRemotePrefsHelp, "Settings & Help")
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if !showRemoteChooser {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if !showLocalChooser {
|
if !showLocalChooser {
|
||||||
@@ -200,6 +58,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return u.recentVaultList(gtx)
|
return u.recentVaultList(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Spacer{Height: unit.Dp(6)}.Layout(gtx)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if !showLocalChooser || busy || !supportsSharedVaultImport(runtime.GOOS) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.importSharedVault, "Import Shared Vault")
|
||||||
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if !showLocalChooser {
|
if !showLocalChooser {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
@@ -296,29 +166,6 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.lifecycleMode == "remote" {
|
|
||||||
label := u.remoteOpenButtonLabel()
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy {
|
|
||||||
return passiveTonedButton(gtx, u.theme, label)
|
|
||||||
}
|
|
||||||
return tonedButton(gtx, u.theme, &u.openRemote, label)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || !u.hasSelectedRemoteTarget() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return layout.Spacer{Height: unit.Dp(8)}.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if busy || !u.hasSelectedRemoteTarget() {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
return u.selectedRemoteConnectionCard(gtx)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
label := "Open Vault"
|
label := "Open Vault"
|
||||||
@@ -361,58 +208,81 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
func (u *ui) selectedRemoteConnectionCard(gtx layout.Context) layout.Dimensions {
|
||||||
record := u.currentRemoteRecord()
|
heading := u.selectedRemoteCardHeading()
|
||||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
primary := u.selectedRemoteCardPrimaryText()
|
||||||
|
details := u.selectedRemoteCardDetailLines()
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return layout.UniformInset(unit.Dp(10)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
children := []layout.FlexChild{
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "SELECTED CONNECTION")
|
lbl := material.Label(u.theme, unit.Sp(12), heading)
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), friendlyRecentRemoteLabel(record))
|
lbl := material.Label(u.theme, unit.Sp(14), primary)
|
||||||
lbl.Color = accentColor
|
lbl.Color = accentColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
}
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
for _, line := range details {
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
line := line
|
||||||
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout))
|
||||||
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), line)
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}))
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
}
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Server: "+strings.TrimSpace(record.BaseURL))
|
children = append(children,
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(recentRemoteRecord{
|
|
||||||
Username: strings.TrimSpace(u.remoteUsername.Text()),
|
|
||||||
Password: u.remotePassword.Text(),
|
|
||||||
}))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
if len(lastGroup) == 0 {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
|
return tonedButton(gtx, u.theme, &u.clearRemoteSelection, "Open Different Connection")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteCardHeading() string {
|
||||||
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
|
return "CACHED VAULT"
|
||||||
|
}
|
||||||
|
return "SELECTED CONNECTION"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteCardPrimaryText() string {
|
||||||
|
record := u.currentRemoteRecord()
|
||||||
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
|
path := strings.TrimSpace(u.vaultPath.Text())
|
||||||
|
if label := friendlyRecentVaultLabel(path); label != "" {
|
||||||
|
return label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return friendlyRecentRemoteLabel(record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedRemoteCardDetailLines() []string {
|
||||||
|
record := u.currentRemoteRecord()
|
||||||
|
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||||
|
lines := make([]string, 0, 3)
|
||||||
|
if u.selectedRemoteUsesLocalCache() {
|
||||||
|
if dir := compactPathDirectorySummary(strings.TrimSpace(u.vaultPath.Text())); dir != "" {
|
||||||
|
lines = append(lines, dir)
|
||||||
|
}
|
||||||
|
lines = append(lines, "Sync target: "+friendlyRecentRemoteLabel(record))
|
||||||
|
} else {
|
||||||
|
lines = append(lines, "Path: "+strings.TrimSpace(record.Path))
|
||||||
|
lines = append(lines, "Server: "+strings.TrimSpace(record.BaseURL))
|
||||||
|
}
|
||||||
|
if len(lastGroup) > 0 {
|
||||||
|
lines = append(lines, "Last group: "+strings.Join(u.displayEntryPath(lastGroup), " / "))
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dimensions {
|
||||||
lastGroup := u.recentVaultGroup(path)
|
lastGroup := u.recentVaultGroup(path)
|
||||||
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
return compactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -446,6 +316,11 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
|||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(11), u.selectedLocalVaultRemoteSyncSummary(path))
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
return tonedButton(gtx, u.theme, &u.clearVaultSelection, "Open Different Vault")
|
||||||
@@ -455,6 +330,17 @@ func (u *ui) selectedLocalVaultCard(gtx layout.Context, path string) layout.Dime
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) selectedLocalVaultRemoteSyncSummary(path string) string {
|
||||||
|
if record, ok := u.boundRecentRemoteForLocalVault(path); ok {
|
||||||
|
summary := "Saved remote sync target: " + friendlyRecentRemoteLabel(record)
|
||||||
|
if normalizeUISyncMode(appstate.SyncMode(record.SyncMode)) == appstate.SyncModeAutomaticOnOpenSave {
|
||||||
|
return summary + " · Syncs automatically on open and save."
|
||||||
|
}
|
||||||
|
return summary + " · Sync manually when you choose Use Remote Sync."
|
||||||
|
}
|
||||||
|
return "Open this vault to set up a WebDAV sync target for it."
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
func (u *ui) lifecycleSecuritySettingsSummary() string {
|
||||||
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
return "Cipher and KDF now live in Vault Settings so opening and creating a vault stays focused on the file, key material, and sync choices."
|
||||||
}
|
}
|
||||||
@@ -617,11 +503,6 @@ func (u *ui) recentRemoteList(gtx layout.Context) layout.Dimensions {
|
|||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Auth: "+recentRemoteStoredAuthSummary(record))
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
if len(record.LastGroup) == 0 {
|
if len(record.LastGroup) == 0 {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
@@ -750,21 +631,6 @@ func normalizedRemoteHost(baseURL string) string {
|
|||||||
return strings.TrimSuffix(host, "/")
|
return strings.TrimSuffix(host, "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
func recentRemoteStoredAuthSummary(record recentRemoteRecord) string {
|
|
||||||
username := strings.TrimSpace(record.Username)
|
|
||||||
hasPassword := record.Password != ""
|
|
||||||
switch {
|
|
||||||
case username != "" && hasPassword:
|
|
||||||
return "saved username and password"
|
|
||||||
case username != "":
|
|
||||||
return "saved username"
|
|
||||||
case hasPassword:
|
|
||||||
return "saved password"
|
|
||||||
default:
|
|
||||||
return "location only"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
func (u *ui) attachmentList(gtx layout.Context) layout.Dimensions {
|
||||||
items := u.selectedAttachmentItems()
|
items := u.selectedAttachmentItems()
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
|
|||||||
+40
-3
@@ -3,6 +3,7 @@ package vault
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
|||||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||||
|
|
||||||
const (
|
const (
|
||||||
templatesRoot = "Templates"
|
templatesRoot = "Templates"
|
||||||
recycleBinRoot = "Recycle Bin"
|
recycleBinRoot = "Recycle Bin"
|
||||||
keepassGOIDField = "KeePassGO-ID"
|
keepassGOIDField = "KeePassGO-ID"
|
||||||
|
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
func LoadKDBX(r io.Reader, password string) (Model, error) {
|
||||||
@@ -49,6 +51,7 @@ func SaveKDBXWithConfigAndKey(wr io.Writer, model Model, key MasterKey, config *
|
|||||||
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
db := gokeepasslib.NewDatabase(gokeepasslib.WithDatabaseKDBXVersion4())
|
||||||
db.Credentials = credentials
|
db.Credentials = credentials
|
||||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||||
|
db.Content.Meta.CustomData = customDataForModel(model)
|
||||||
db.Content.Root = &gokeepasslib.RootData{}
|
db.Content.Root = &gokeepasslib.RootData{}
|
||||||
if config != nil && config.Header != nil {
|
if config != nil && config.Header != nil {
|
||||||
db.Header = cloneHeader(config.Header)
|
db.Header = cloneHeader(config.Header)
|
||||||
@@ -325,6 +328,7 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
|||||||
for _, group := range db.Content.Root.Groups {
|
for _, group := range db.Content.Root.Groups {
|
||||||
appendGroupEntries(&model, db, group, nil)
|
appendGroupEntries(&model, db, group, nil)
|
||||||
}
|
}
|
||||||
|
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||||
|
|
||||||
return model, &KDBXConfig{
|
return model, &KDBXConfig{
|
||||||
Header: cloneHeader(db.Header),
|
Header: cloneHeader(db.Header),
|
||||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func customDataForModel(model Model) []gokeepasslib.CustomData {
|
||||||
|
if len(model.RemoteProfiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := json.Marshal(model.RemoteProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return []gokeepasslib.CustomData{{
|
||||||
|
Key: remoteProfilesKey,
|
||||||
|
Value: string(content),
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteProfilesFromMeta(meta *gokeepasslib.MetaData) []RemoteProfile {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, item := range meta.CustomData {
|
||||||
|
if item.Key != remoteProfilesKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var profiles []RemoteProfile
|
||||||
|
if err := json.Unmarshal([]byte(item.Value), &profiles); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return profiles
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
func newCredentials(key MasterKey) (*gokeepasslib.DBCredentials, error) {
|
||||||
switch {
|
switch {
|
||||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||||
|
|||||||
+85
-41
@@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
mustGroup("Root",
|
mustGroup("Root",
|
||||||
mustGroup("Internet",
|
mustGroup("Internet",
|
||||||
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
mustEntry("Bellagio", "rustyryan", "https://bellagio.example.invalid", "hunter2"),
|
||||||
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"),
|
mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"),
|
||||||
),
|
),
|
||||||
mustGroup("Home Assistant",
|
mustGroup("Security Office",
|
||||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -62,15 +62,15 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
groups := model.ChildGroups([]string{"Root"})
|
groups := model.ChildGroups([]string{"Root"})
|
||||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
entry := mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2")
|
entry := mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2")
|
||||||
entry.Tags = "automation; home"
|
entry.Tags = "automation; home"
|
||||||
entry.Values = append(entry.Values,
|
entry.Values = append(entry.Values,
|
||||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Home Assistant", entry)),
|
mustGroup("Root", mustGroup("Security Office", entry)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -104,13 +104,13 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBX failed: %v", err)
|
t.Fatalf("LoadKDBX failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(got) != 1 {
|
if len(got) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Password != "token-2" {
|
if got[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "bellagio-pass-2")
|
||||||
}
|
}
|
||||||
|
|
||||||
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
if got[0].Notes != "Long-lived token used by Codex for home automation tasks." {
|
||||||
@@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||||
Tags: []string{"git", "infra"},
|
Tags: []string{"git", "infra"},
|
||||||
@@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "entry-2",
|
ID: "entry-2",
|
||||||
Title: "Surveillance Console",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "bashertarr",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://surveillance.crew.example.invalid",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||||
Tags: []string{"automation", "home"},
|
Tags: []string{"automation", "home"},
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Security Office"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -180,13 +180,13 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
|||||||
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
t.Fatalf("Search(\"git\") X-Role = %q, want %q", got[0].Entry.Fields["X-Role"], "automation")
|
||||||
}
|
}
|
||||||
|
|
||||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(homeAssistant) != 1 {
|
if len(homeAssistant) != 1 {
|
||||||
t.Fatalf("len(EntriesInPath(Home Assistant)) = %d, want 1", len(homeAssistant))
|
t.Fatalf("len(EntriesInPath(Security Office)) = %d, want 1", len(homeAssistant))
|
||||||
}
|
}
|
||||||
|
|
||||||
if homeAssistant[0].Password != "token-2" {
|
if homeAssistant[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
t.Fatalf("Security Office password = %q, want %q", homeAssistant[0].Password, "bellagio-pass-2")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,6 +238,50 @@ func TestSaveKDBXRoundTripsTemplates(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveKDBXRoundTripsRemoteProfiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
model := Model{
|
||||||
|
RemoteProfiles: []RemoteProfile{
|
||||||
|
{
|
||||||
|
ID: "family-webdav",
|
||||||
|
Name: "Family Vault",
|
||||||
|
Backend: RemoteBackendWebDAV,
|
||||||
|
BaseURL: "https://dav.example.invalid/remote.php/dav",
|
||||||
|
Path: "files/family/keepass.kdbx",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var encoded bytes.Buffer
|
||||||
|
if err := SaveKDBX(&encoded, model, "correct horse battery staple"); err != nil {
|
||||||
|
t.Fatalf("SaveKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
loaded, err := LoadKDBX(bytes.NewReader(encoded.Bytes()), "correct horse battery staple")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadKDBX() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(loaded.RemoteProfiles) != 1 {
|
||||||
|
t.Fatalf("len(RemoteProfiles) = %d, want 1", len(loaded.RemoteProfiles))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := loaded.RemoteProfiles[0]
|
||||||
|
if got.ID != "family-webdav" || got.Name != "Family Vault" {
|
||||||
|
t.Fatalf("loaded remote profile = %#v, want family-webdav Family Vault", got)
|
||||||
|
}
|
||||||
|
if got.Backend != RemoteBackendWebDAV {
|
||||||
|
t.Fatalf("remote backend = %q, want %q", got.Backend, RemoteBackendWebDAV)
|
||||||
|
}
|
||||||
|
if got.BaseURL != "https://dav.example.invalid/remote.php/dav" {
|
||||||
|
t.Fatalf("remote base URL = %q, want remote.php/dav URL", got.BaseURL)
|
||||||
|
}
|
||||||
|
if got.Path != "files/family/keepass.kdbx" {
|
||||||
|
t.Fatalf("remote path = %q, want files/family/keepass.kdbx", got.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
func TestSaveKDBXRoundTripsEntryHistory(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Surveillance Console",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "bashertarr",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://surveillance.crew.example.invalid",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Security Office"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -323,8 +367,8 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
|||||||
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
t.Fatalf("RecycleBin[0].Title = %q, want %q", loaded.RecycleBin[0].Title, "Surveillance Console")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Security Office" {
|
||||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
t.Fatalf("RecycleBin[0].Path = %v, want [Root Security Office]", loaded.RecycleBin[0].Path)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(loaded.Entries) != 0 {
|
if len(loaded.Entries) != 0 {
|
||||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -379,7 +423,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := model.Search("vault")
|
got := model.Search("vault")
|
||||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||||
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
t.Fatalf("LoadKDBXWithKey() = %#v, want password-preserving vault entry", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Home Assistant", mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"))),
|
mustGroup("Root", mustGroup("Security Office", mustEntry("Surveillance Console", "bashertarr", "https://surveillance.crew.example.invalid", "bellagio-pass-2"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -436,9 +480,9 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(got) != 1 || got[0].Password != "token-2" {
|
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
|||||||
Meta: gokeepasslib.NewMetaData(),
|
Meta: gokeepasslib.NewMetaData(),
|
||||||
Root: &gokeepasslib.RootData{
|
Root: &gokeepasslib.RootData{
|
||||||
Groups: []gokeepasslib.Group{
|
Groups: []gokeepasslib.Group{
|
||||||
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "token-1"))),
|
mustGroup("Root", mustGroup("Internet", mustEntry("Vault Console", "dannyocean", "https://vault.crew.example.invalid", "bellagio-pass-1"))),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -493,7 +537,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
|||||||
ID: "vault-console",
|
ID: "vault-console",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
},
|
},
|
||||||
@@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
got := loaded.Search("vault")
|
got := loaded.Search("vault")
|
||||||
if len(got) != 1 || got[0].Entry.Password != "token-1" {
|
if len(got) != 1 || got[0].Entry.Password != "bellagio-pass-1" {
|
||||||
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
t.Fatalf("round-trip with key file = %#v, want vault entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
|||||||
{
|
{
|
||||||
ID: "surveillance-console",
|
ID: "surveillance-console",
|
||||||
Title: "Surveillance Console",
|
Title: "Surveillance Console",
|
||||||
Username: "codex",
|
Username: "bashertarr",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://surveillance.crew.example.invalid",
|
URL: "https://surveillance.crew.example.invalid",
|
||||||
Path: []string{"Root", "Home Assistant"},
|
Path: []string{"Root", "Security Office"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -558,9 +602,9 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
|||||||
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
t.Fatalf("LoadKDBXWithKey() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||||
if len(got) != 1 || got[0].Password != "token-2" {
|
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
t.Fatalf("composite key round-trip = %#v, want Security Office entry with password", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,7 +617,7 @@ func TestKDBXRoundTripsEntryAttachments(t *testing.T) {
|
|||||||
ID: "vault-console",
|
ID: "vault-console",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
Attachments: map[string][]byte{
|
Attachments: map[string][]byte{
|
||||||
@@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
|||||||
ID: "entry-1",
|
ID: "entry-1",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-2",
|
Password: "bellagio-pass-2",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Current credential",
|
Notes: "Current credential",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
@@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
|||||||
ID: "entry-1-history-1",
|
ID: "entry-1-history-1",
|
||||||
Title: "Vault Console",
|
Title: "Vault Console",
|
||||||
Username: "dannyocean",
|
Username: "dannyocean",
|
||||||
Password: "token-1",
|
Password: "bellagio-pass-1",
|
||||||
URL: "https://vault.crew.example.invalid",
|
URL: "https://vault.crew.example.invalid",
|
||||||
Notes: "Original credential",
|
Notes: "Original credential",
|
||||||
Path: []string{"Root", "Internet"},
|
Path: []string{"Root", "Internet"},
|
||||||
|
|||||||
+71
-4
@@ -8,6 +8,21 @@ import (
|
|||||||
|
|
||||||
var ErrEntryNotFound = errors.New("entry not found")
|
var ErrEntryNotFound = errors.New("entry not found")
|
||||||
var ErrGroupNotEmpty = errors.New("group is not empty")
|
var ErrGroupNotEmpty = errors.New("group is not empty")
|
||||||
|
var ErrRemoteProfileNotFound = errors.New("remote profile not found")
|
||||||
|
|
||||||
|
type RemoteBackend string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RemoteBackendWebDAV RemoteBackend = "webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RemoteProfile struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Backend RemoteBackend
|
||||||
|
BaseURL string
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string
|
ID string
|
||||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
Entries []Entry
|
Entries []Entry
|
||||||
Templates []Entry
|
Templates []Entry
|
||||||
RecycleBin []Entry
|
RecycleBin []Entry
|
||||||
Groups [][]string
|
Groups [][]string
|
||||||
|
RemoteProfiles []RemoteProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) ChildGroups(path []string) []string {
|
func (m Model) ChildGroups(path []string) []string {
|
||||||
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry Entry) {
|
|||||||
m.Entries = append(m.Entries, cloneEntry(entry))
|
m.Entries = append(m.Entries, cloneEntry(entry))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) RemoveEntryByID(id string) bool {
|
||||||
|
for i := range m.Entries {
|
||||||
|
if m.Entries[i].ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.Entries = append(m.Entries[:i], m.Entries[i+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) EntryByID(id string) (Entry, error) {
|
||||||
|
for _, entry := range m.Entries {
|
||||||
|
if entry.ID == id {
|
||||||
|
return cloneEntry(entry), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Entry{}, ErrEntryNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) UpsertRemoteProfile(profile RemoteProfile) {
|
||||||
|
for i := range m.RemoteProfiles {
|
||||||
|
if m.RemoteProfiles[i].ID != profile.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.RemoteProfiles[i] = profile
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.RemoteProfiles = append(m.RemoteProfiles, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) RemoveRemoteProfileByID(id string) bool {
|
||||||
|
for i := range m.RemoteProfiles {
|
||||||
|
if m.RemoteProfiles[i].ID != id {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.RemoteProfiles = append(m.RemoteProfiles[:i], m.RemoteProfiles[i+1:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) RemoteProfileByID(id string) (RemoteProfile, error) {
|
||||||
|
for _, profile := range m.RemoteProfiles {
|
||||||
|
if profile.ID == id {
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RemoteProfile{}, ErrRemoteProfileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Model) UpsertTemplate(entry Entry) {
|
func (m *Model) UpsertTemplate(entry Entry) {
|
||||||
for i := range m.Templates {
|
for i := range m.Templates {
|
||||||
if m.Templates[i].ID != entry.ID {
|
if m.Templates[i].ID != entry.ID {
|
||||||
|
|||||||
Reference in New Issue
Block a user