Implement local-first remote sync flow
This commit is contained in:
@@ -1,2 +1,8 @@
|
||||
build/
|
||||
*.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.
|
||||
- The stacked phone layout is the current preferred phone direction.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ ANDROID_MIN_SDK ?= 28
|
||||
ANDROID_TARGET_SDK ?= 35
|
||||
SIGNKEY ?=
|
||||
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 :=
|
||||
ifneq ($(strip $(SIGNKEY)),)
|
||||
@@ -20,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||
endif
|
||||
|
||||
.PHONY: apk
|
||||
.PHONY: apk archlinux-pkgbuild
|
||||
apk: android/keepassgo-android.jar
|
||||
@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; }
|
||||
@@ -56,3 +61,10 @@ android/keepassgo-android.jar: $(shell find androidsrc -type f | sort)
|
||||
-d "$$tmpdir" \
|
||||
$$(find androidsrc -name '\''*.java'\'' | sort); \
|
||||
"$(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:resource="@xml/keepassgo_accessibility_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)
|
||||
}
|
||||
|
||||
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) {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
@@ -834,6 +880,66 @@ func (s *State) OpenRemoteVault(client webdav.Client, path string, key vault.Mas
|
||||
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 {
|
||||
session, ok := s.Session.(MutableSession)
|
||||
if !ok {
|
||||
|
||||
+277
-30
@@ -22,7 +22,7 @@ func TestVisibleEntriesFollowsCurrentPathWithoutSearch(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{ID: "bellagio", Title: "Bellagio", 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: "bellagio", Title: "Bellagio", 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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -173,7 +238,7 @@ func TestVisibleEntriesUsesGlobalSearchWhenQueryPresent(t *testing.T) {
|
||||
Entries: []vault.Entry{
|
||||
{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: "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" {
|
||||
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{
|
||||
{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: "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{
|
||||
RecycleBin: []vault.Entry{
|
||||
{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{
|
||||
Entries: []vault.Entry{
|
||||
{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"}},
|
||||
},
|
||||
},
|
||||
@@ -432,8 +497,8 @@ func TestChildGroupsUsesCurrentModelAndCurrentPath(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -603,7 +668,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
ID: "vault-console",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
}
|
||||
@@ -619,7 +684,7 @@ func TestUpsertEntryPersistsEntryAndSelectsIt(t *testing.T) {
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1149,10 +1393,10 @@ func TestNavigateToPathReplacesPathAndClearsSelection(t *testing.T) {
|
||||
SelectedEntryID: "vault-console",
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Home Assistant"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Home Assistant]", state.CurrentPath)
|
||||
if !slices.Equal(state.CurrentPath, []string{"Root", "Security Office"}) {
|
||||
t.Fatalf("CurrentPath = %v, want [Root Security Office]", state.CurrentPath)
|
||||
}
|
||||
if got := state.SelectedEntryID; got != "" {
|
||||
t.Fatalf("SelectedEntryID = %q, want empty", got)
|
||||
@@ -1185,8 +1429,8 @@ func TestDeleteCurrentGroupMovesToParentAndMarksDirty(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1208,8 +1452,8 @@ func TestCreateGroupPersistsGroupAndMarksDirty(t *testing.T) {
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Finance", "Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Home Assistant, Internet", got)
|
||||
if !slices.Equal(got, []string{"Finance", "Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want Finance, Internet, Security Office", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after CreateGroup")
|
||||
@@ -1282,8 +1526,8 @@ func TestDeleteCurrentGroupRemovesItNavigatesToParentAndMarksDirty(t *testing.T)
|
||||
t.Fatalf("ChildGroups() error = %v", err)
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"Home Assistant", "Internet"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", got)
|
||||
if !slices.Equal(got, []string{"Internet", "Security Office"}) {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", got)
|
||||
}
|
||||
if !state.Dirty {
|
||||
t.Fatal("Dirty = false, want true after DeleteCurrentGroup")
|
||||
@@ -1300,11 +1544,11 @@ func TestMoveSelectedEntryPersistsPathChangeAndMarksDirty(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
state.NavigateToPath([]string{"Root", "Home Assistant"})
|
||||
state.NavigateToPath([]string{"Root", "Security Office"})
|
||||
got, err := state.VisibleEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("VisibleEntries() error = %v", err)
|
||||
@@ -1512,7 +1756,7 @@ func testVaultModel() vault.Model {
|
||||
return vault.Model{
|
||||
Entries: []vault.Entry{
|
||||
{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 {
|
||||
createCalls int
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
createCalls int
|
||||
model vault.Model
|
||||
openPath string
|
||||
saveAsPath string
|
||||
remoteClient webdav.Client
|
||||
remotePath string
|
||||
changedKey vault.MasterKey
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Current() (vault.Model, error) {
|
||||
return vault.Model{}, nil
|
||||
return s.model, nil
|
||||
}
|
||||
|
||||
func (s *lifecycleStubSession) Create(_ vault.Model, _ vault.MasterKey) error {
|
||||
@@ -1579,7 +1825,8 @@ func (s *lifecycleStubSession) SaveAs(path string) error {
|
||||
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
|
||||
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
+2
-8
@@ -1,5 +1,5 @@
|
||||
pkgname=keepassgo-git
|
||||
pkgver=r165.1c72a50
|
||||
pkgver=@PKGVER@
|
||||
pkgrel=1
|
||||
pkgdesc='KeePass-compatible password manager written in Go'
|
||||
arch=('x86_64' 'aarch64')
|
||||
@@ -27,13 +27,7 @@ source=('git+https://git.julianfamily.org/joejulian/keepassgo.git')
|
||||
sha256sums=('SKIP')
|
||||
|
||||
_repo_dir() {
|
||||
if [[ -d "${srcdir}/keepassgo/.git" ]]; then
|
||||
printf '%s\n' "${srcdir}/keepassgo"
|
||||
return
|
||||
fi
|
||||
|
||||
cd "${startdir}/../../.." || exit 1
|
||||
pwd
|
||||
printf '%s\n' "@REPO_DIR@"
|
||||
}
|
||||
|
||||
pkgver() {
|
||||
+82
-216
@@ -21,7 +21,6 @@ import (
|
||||
func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
busy := u.lifecycleBusy()
|
||||
showLocalChooser := u.showLocalVaultChooser()
|
||||
showRemoteChooser := u.showRemoteConnectionChooser()
|
||||
selectedLocalPath := strings.TrimSpace(u.vaultPath.Text())
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
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(func(gtx layout.Context) layout.Dimensions {
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it."
|
||||
if u.lifecycleMode == "remote" {
|
||||
message = "Connect to a remote vault, then unlock it with the KeePass master key."
|
||||
}
|
||||
message := "Choose a recent vault or enter a .kdbx path, then unlock it. Remote sync attaches to that local vault after it opens."
|
||||
lbl := material.Label(u.theme, unit.Sp(14), message)
|
||||
lbl.Color = accentColor
|
||||
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(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,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if !showLocalChooser {
|
||||
@@ -200,6 +58,18 @@ func (u *ui) lifecycleControls(gtx layout.Context) layout.Dimensions {
|
||||
}
|
||||
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 {
|
||||
if !showLocalChooser {
|
||||
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(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,
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
record := u.currentRemoteRecord()
|
||||
lastGroup := u.recentRemoteGroup(record.BaseURL, record.Path)
|
||||
heading := u.selectedRemoteCardHeading()
|
||||
primary := u.selectedRemoteCardPrimaryText()
|
||||
details := u.selectedRemoteCardDetailLines()
|
||||
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.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
children := []layout.FlexChild{
|
||||
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
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
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
|
||||
return lbl.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(2)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
lbl := material.Label(u.theme, unit.Sp(11), "Path: "+strings.TrimSpace(record.Path))
|
||||
}
|
||||
for _, line := range details {
|
||||
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
|
||||
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))
|
||||
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)
|
||||
}),
|
||||
}))
|
||||
}
|
||||
children = append(children,
|
||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
lastGroup := u.recentVaultGroup(path)
|
||||
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
|
||||
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(func(gtx layout.Context) layout.Dimensions {
|
||||
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 {
|
||||
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
|
||||
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 {
|
||||
if len(record.LastGroup) == 0 {
|
||||
return layout.Dimensions{}
|
||||
@@ -750,21 +631,6 @@ func normalizedRemoteHost(baseURL string) string {
|
||||
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 {
|
||||
items := u.selectedAttachmentItems()
|
||||
if len(items) == 0 {
|
||||
|
||||
+40
-3
@@ -3,6 +3,7 @@ package vault
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -23,9 +24,10 @@ type KDBXConfig struct {
|
||||
var ErrInvalidMasterKey = errors.New("invalid master key")
|
||||
|
||||
const (
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
templatesRoot = "Templates"
|
||||
recycleBinRoot = "Recycle Bin"
|
||||
keepassGOIDField = "KeePassGO-ID"
|
||||
remoteProfilesKey = "keepassgo.remoteProfiles"
|
||||
)
|
||||
|
||||
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.Credentials = credentials
|
||||
db.Content.Meta = gokeepasslib.NewMetaData()
|
||||
db.Content.Meta.CustomData = customDataForModel(model)
|
||||
db.Content.Root = &gokeepasslib.RootData{}
|
||||
if config != nil && config.Header != nil {
|
||||
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 {
|
||||
appendGroupEntries(&model, db, group, nil)
|
||||
}
|
||||
model.RemoteProfiles = remoteProfilesFromMeta(db.Content.Meta)
|
||||
|
||||
return model, &KDBXConfig{
|
||||
Header: cloneHeader(db.Header),
|
||||
@@ -332,6 +336,39 @@ func LoadKDBXWithConfig(r io.Reader, key MasterKey) (Model, *KDBXConfig, error)
|
||||
}, 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) {
|
||||
switch {
|
||||
case key.Password != "" && len(key.KeyFileData) > 0:
|
||||
|
||||
+85
-41
@@ -23,10 +23,10 @@ func TestLoadKDBXBuildsModelFromNestedGroups(t *testing.T) {
|
||||
mustGroup("Root",
|
||||
mustGroup("Internet",
|
||||
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",
|
||||
mustEntry("Surveillance Console", "codex", "https://surveillance.crew.example.invalid", "token-2"),
|
||||
mustGroup("Security Office",
|
||||
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"})
|
||||
if len(groups) != 2 || groups[0] != "Home Assistant" || groups[1] != "Internet" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Home Assistant Internet]", groups)
|
||||
if len(groups) != 2 || groups[0] != "Internet" || groups[1] != "Security Office" {
|
||||
t.Fatalf("ChildGroups() = %v, want [Internet Security Office]", groups)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
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.Values = append(entry.Values,
|
||||
mkValue("Notes", "Long-lived token used by Codex for home automation tasks."),
|
||||
@@ -84,7 +84,7 @@ func TestLoadKDBXPreservesEntryDetails(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("len(EntriesInPath()) = %d, want 1", len(got))
|
||||
}
|
||||
|
||||
if got[0].Password != "token-2" {
|
||||
t.Fatalf("Entry.Password = %q, want %q", got[0].Password, "token-2")
|
||||
if got[0].Password != "bellagio-pass-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." {
|
||||
@@ -135,7 +135,7 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Personal git server token entry used for automation and CLI auth.",
|
||||
Tags: []string{"git", "infra"},
|
||||
@@ -147,12 +147,12 @@ func TestSaveKDBXRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "entry-2",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://surveillance.crew.example.invalid",
|
||||
Notes: "Long-lived token used by Codex for home automation tasks.",
|
||||
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")
|
||||
}
|
||||
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
homeAssistant := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
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" {
|
||||
t.Fatalf("Home Assistant password = %q, want %q", homeAssistant[0].Password, "token-2")
|
||||
if homeAssistant[0].Password != "bellagio-pass-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) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -297,10 +341,10 @@ func TestSaveKDBXRoundTripsRecycleBinEntries(t *testing.T) {
|
||||
{
|
||||
ID: "entry-1",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
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")
|
||||
}
|
||||
|
||||
if len(loaded.RecycleBin[0].Path) != 2 || loaded.RecycleBin[0].Path[0] != "Root" || loaded.RecycleBin[0].Path[1] != "Home Assistant" {
|
||||
t.Fatalf("RecycleBin[0].Path = %v, want [Root Home Assistant]", loaded.RecycleBin[0].Path)
|
||||
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 Security Office]", loaded.RecycleBin[0].Path)
|
||||
}
|
||||
|
||||
if len(loaded.Entries) != 0 {
|
||||
@@ -358,7 +402,7 @@ func TestLoadKDBXWithKeyFileCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -413,7 +457,7 @@ func TestLoadKDBXWithCompositeCredentials(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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)
|
||||
}
|
||||
|
||||
got := model.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Home Assistant entry with password", got)
|
||||
got := model.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
t.Fatalf("LoadKDBXWithKey() = %#v, want Security Office entry with password", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,7 +496,7 @@ func TestLoadKDBXReturnsInvalidCredentialsError(t *testing.T) {
|
||||
Meta: gokeepasslib.NewMetaData(),
|
||||
Root: &gokeepasslib.RootData{
|
||||
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",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
},
|
||||
@@ -511,7 +555,7 @@ func TestSaveKDBXWithKeyRoundTripsModel(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -535,10 +579,10 @@ func TestSaveKDBXWithCompositeKeyRoundTripsModel(t *testing.T) {
|
||||
{
|
||||
ID: "surveillance-console",
|
||||
Title: "Surveillance Console",
|
||||
Username: "codex",
|
||||
Password: "token-2",
|
||||
Username: "bashertarr",
|
||||
Password: "bellagio-pass-2",
|
||||
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)
|
||||
}
|
||||
|
||||
got := loaded.EntriesInPath([]string{"Root", "Home Assistant"})
|
||||
if len(got) != 1 || got[0].Password != "token-2" {
|
||||
t.Fatalf("composite key round-trip = %#v, want Home Assistant entry with password", got)
|
||||
got := loaded.EntriesInPath([]string{"Root", "Security Office"})
|
||||
if len(got) != 1 || got[0].Password != "bellagio-pass-2" {
|
||||
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",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Path: []string{"Root", "Internet"},
|
||||
Attachments: map[string][]byte{
|
||||
@@ -612,7 +656,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-2",
|
||||
Password: "bellagio-pass-2",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Current credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
@@ -624,7 +668,7 @@ func TestKDBXReopenCyclesPreserveStableIDsAndCrossFeatureState(t *testing.T) {
|
||||
ID: "entry-1-history-1",
|
||||
Title: "Vault Console",
|
||||
Username: "dannyocean",
|
||||
Password: "token-1",
|
||||
Password: "bellagio-pass-1",
|
||||
URL: "https://vault.crew.example.invalid",
|
||||
Notes: "Original credential",
|
||||
Path: []string{"Root", "Internet"},
|
||||
|
||||
+71
-4
@@ -8,6 +8,21 @@ import (
|
||||
|
||||
var ErrEntryNotFound = errors.New("entry not found")
|
||||
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 {
|
||||
ID string
|
||||
@@ -29,10 +44,11 @@ type SearchResult struct {
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
Entries []Entry
|
||||
Templates []Entry
|
||||
RecycleBin []Entry
|
||||
Groups [][]string
|
||||
RemoteProfiles []RemoteProfile
|
||||
}
|
||||
|
||||
func (m Model) ChildGroups(path []string) []string {
|
||||
@@ -168,6 +184,57 @@ func (m *Model) UpsertEntry(entry 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) {
|
||||
for i := range m.Templates {
|
||||
if m.Templates[i].ID != entry.ID {
|
||||
|
||||
Reference in New Issue
Block a user