Implement local-first remote sync flow

This commit is contained in:
Joe Julian
2026-04-06 21:47:44 -07:00
parent 8433d536f6
commit 332ab58f58
22 changed files with 6428 additions and 918 deletions
+6
View File
@@ -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/
+3
View File
@@ -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
+13 -1
View File
@@ -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)"
+23
View File
@@ -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.
+184
View File
@@ -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)
}
+7
View File
@@ -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();
}
}
+141
View File
@@ -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
}
}
+250
View File
@@ -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)
}
}
+106
View File
@@ -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
View File
@@ -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
}
+148
View File
@@ -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 users 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.
+1786 -397
View File
File diff suppressed because it is too large Load Diff
+2892 -218
View File
File diff suppressed because it is too large Load Diff
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 {