Compare commits
9 Commits
v0.2.0
...
852c115b2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 852c115b2a | |||
| 2ef571c241 | |||
| c017308aa1 | |||
| 885d599db1 | |||
| e757be66d9 | |||
| bc226647e1 | |||
| 533fb2d550 | |||
| 8dfba6e94f | |||
| 6cc86bb944 |
@@ -25,7 +25,7 @@ ifneq ($(strip $(SIGNPASS)),)
|
|||||||
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
GOGIO_SIGN_FLAGS += -signpass $(SIGNPASS)
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: apk archlinux-pkgbuild
|
.PHONY: apk archlinux-pkgbuild browser-bridge
|
||||||
apk: android/keepassgo-android.jar
|
apk: android/keepassgo-android.jar
|
||||||
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
@test -x "$(JAVA_HOME)/bin/java" || { echo "JAVA_HOME must point to a working JDK install"; exit 1; }
|
||||||
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
@test -d "$(ANDROID_SDK_ROOT)" || { echo "ANDROID_SDK_ROOT must point to an Android SDK install"; exit 1; }
|
||||||
@@ -68,3 +68,6 @@ archlinux-pkgbuild: $(ARCH_PKG_TMPL) Makefile
|
|||||||
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
-e 's|@PKGVER@|$(ARCH_PKGVER)|g' \
|
||||||
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
-e 's|@REPO_DIR@|$(ARCH_REPO_DIR)|g' \
|
||||||
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
"$(ARCH_PKG_TMPL)" > "$(ARCH_PKGBUILD)"
|
||||||
|
|
||||||
|
browser-bridge:
|
||||||
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ makepkg -si
|
|||||||
The package installs:
|
The package installs:
|
||||||
|
|
||||||
- `/usr/bin/keepassgo`
|
- `/usr/bin/keepassgo`
|
||||||
|
- `/usr/bin/keepassgo-browser-bridge`
|
||||||
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
- a desktop entry at `/usr/share/applications/keepassgo.desktop`
|
||||||
- application icons under the hicolor theme
|
- application icons under the hicolor theme
|
||||||
|
|
||||||
@@ -98,3 +99,11 @@ You will need the Android SDK and NDK installed and configured for real device o
|
|||||||
|
|
||||||
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
Desktop automation is resolved through the secure gRPC API rather than synthetic auto-type.
|
||||||
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
See [`docs/desktop-automation.md`](./docs/desktop-automation.md).
|
||||||
|
|
||||||
|
On desktop, KeePassGO now listens on a Unix socket by default under the user runtime directory.
|
||||||
|
Set `KEEPASSGO_GRPC_ADDR` or `-grpc-addr` to override it, for example `tcp://127.0.0.1:47777`.
|
||||||
|
|
||||||
|
## Browser Extension
|
||||||
|
|
||||||
|
Firefox and Chromium browser integration is available through the local gRPC API plus a native messaging bridge.
|
||||||
|
See [`docs/browser-extension.md`](./docs/browser-extension.md).
|
||||||
|
|||||||
@@ -132,195 +132,6 @@ These are important, but they should likely move behind a dedicated settings gea
|
|||||||
- Phone and desktop layouts both present a clear information hierarchy.
|
- Phone and desktop layouts both present a clear information hierarchy.
|
||||||
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
||||||
|
|
||||||
## API Token And gRPC Authorization Parallel Segments
|
|
||||||
|
|
||||||
These segments define the work for programmatic access control over gRPC.
|
|
||||||
They are designed to be independently landable wherever file overlap permits.
|
|
||||||
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
|
|
||||||
|
|
||||||
### API Segment A: Token Domain Model
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Represent API tokens as first-class vault-backed records.
|
|
||||||
- Mark token entries explicitly as API credentials rather than generic passwords.
|
|
||||||
- Store token metadata:
|
|
||||||
token id,
|
|
||||||
hashed secret or verifier,
|
|
||||||
display name,
|
|
||||||
client name,
|
|
||||||
created at,
|
|
||||||
expires at,
|
|
||||||
disabled state.
|
|
||||||
- Keep the persisted representation compatible with KDBX entry fields.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A domain type exists for API tokens and round-trips through the persisted vault model.
|
|
||||||
- Generic entry listing can distinguish API token entries from ordinary secrets.
|
|
||||||
- Tests cover create, load, save, and parse behavior for API token entries.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment B: Token Issuance And Rotation
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Generate new API tokens for external tools.
|
|
||||||
- Return the cleartext token only at creation or explicit rotation time.
|
|
||||||
- Rotate an existing token while preserving its identity and policy linkage.
|
|
||||||
- Revoke or disable a token without deleting policy history.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
|
|
||||||
- Cleartext token material is only exposed on creation or rotation paths.
|
|
||||||
- Tests cover generation, rotation, and disable/revoke semantics.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment C: Token Expiration
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Allow tokens to have optional expiration timestamps.
|
|
||||||
- Treat expired tokens as unauthenticated.
|
|
||||||
- Surface expiration in UI and gRPC management views.
|
|
||||||
- Support non-expiring tokens explicitly.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Expired tokens are rejected by the gRPC authentication path.
|
|
||||||
- Token expiration can be created, edited, and removed through the service layer.
|
|
||||||
- Tests cover valid, expired, and non-expiring token behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment D: Authorization Policy Model
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Define an authorization model for token-scoped access.
|
|
||||||
- Support allow and deny rules over:
|
|
||||||
folders/groups,
|
|
||||||
specific entries,
|
|
||||||
entry fields where needed,
|
|
||||||
and operation types.
|
|
||||||
- Keep specific deny rules higher priority than broad allow rules.
|
|
||||||
- Model “not yet decided” separately from “denied”.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A policy evaluator exists for token, resource, and operation tuples.
|
|
||||||
- Explicit deny overrides allow.
|
|
||||||
- Unspecified access is distinguishable from denied access.
|
|
||||||
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment E: gRPC Authentication And Authorization Enforcement
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Replace the current single static bearer-token interceptor with token-backed auth.
|
|
||||||
- Authenticate callers using issued KeePassGO API tokens.
|
|
||||||
- Authorize every gRPC method against token policy.
|
|
||||||
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
|
|
||||||
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
|
|
||||||
- Unauthorized requests return the correct authz/authn gRPC status.
|
|
||||||
- Integration tests cover permitted, denied, expired, and revoked token behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment F: Approval Queue And Pending Access Requests
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- When a token requests access to a resource that is neither explicitly allowed nor denied:
|
|
||||||
create a pending approval request.
|
|
||||||
- Include:
|
|
||||||
token identity,
|
|
||||||
client name,
|
|
||||||
requested operation,
|
|
||||||
requested group/entry scope,
|
|
||||||
requested time,
|
|
||||||
and permanence choice.
|
|
||||||
- Allow the request to be accepted, denied, or canceled by the user.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Unspecified access creates a pending approval instead of silently denying or allowing.
|
|
||||||
- Pending approvals are queryable from the application layer.
|
|
||||||
- Canceling the prompt results in the API request failing without granting access.
|
|
||||||
- Tests cover pending creation, approval, denial, and cancellation.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment G: Approval UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
|
|
||||||
- Provide actions:
|
|
||||||
allow once,
|
|
||||||
deny once,
|
|
||||||
allow permanently,
|
|
||||||
deny permanently,
|
|
||||||
cancel.
|
|
||||||
- Make the requested scope and operation clear to the user.
|
|
||||||
- Ensure the dialog appears only for requests not already decided.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A pending request triggers a visible approval surface in the app.
|
|
||||||
- The user can allow, deny, or cancel from the UI.
|
|
||||||
- Permanent decisions become persisted policy rules.
|
|
||||||
- UI tests cover each approval outcome.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment H: gRPC Request Blocking And Resume Behavior
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Define how an in-flight gRPC call waits for or fails on user approval.
|
|
||||||
- Hold the request while approval is pending within a bounded timeout.
|
|
||||||
- Return unauthenticated or permission-denied when denied/canceled/expired.
|
|
||||||
- Resume the original call automatically when approval is granted.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Pending requests block safely without leaking goroutines.
|
|
||||||
- Allowed requests resume and complete without the client reissuing the call where practical.
|
|
||||||
- Denied and canceled requests return a consistent gRPC status code and message.
|
|
||||||
- Tests cover timeout, allow, deny, and cancel paths.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment I: Token Management UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Add UI for listing API tokens.
|
|
||||||
- Create token flow with one-time secret display.
|
|
||||||
- Edit token display metadata and expiration.
|
|
||||||
- Disable, revoke, and rotate tokens.
|
|
||||||
- Show effective policy summary per token.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Users can manage API tokens from the app UI end to end.
|
|
||||||
- One-time token display is explicit and not re-shown later.
|
|
||||||
- Expiration and disable state are visible.
|
|
||||||
- UI tests cover create, rotate, disable, revoke, and edit flows.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment J: Policy Management UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Let users define folder, entry, and operation scopes for each token.
|
|
||||||
- Show explicit allow and deny rules.
|
|
||||||
- Show inherited implications of a folder-level rule.
|
|
||||||
- Let users review prior permanent decisions created from approval prompts.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Users can inspect and edit token policy from the UI.
|
|
||||||
- Folder-level and entry-level rules are distinguishable and editable.
|
|
||||||
- Permanent prompt decisions are visible as policy.
|
|
||||||
- UI tests cover rule creation, update, and deletion.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment K: Audit And Event History
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
|
|
||||||
- Record authorization failures and expirations without logging secret material.
|
|
||||||
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Security-relevant API token events are captured without secret leakage.
|
|
||||||
- Approval outcomes and policy changes are auditable.
|
|
||||||
- Tests cover audit generation for the main token lifecycle and approval actions.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 1: Application State Ownership
|
### Segment 1: Application State Ownership
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -389,19 +200,6 @@ Exit criteria:
|
|||||||
- Validation and visible error states exist for missing or invalid key material.
|
- Validation and visible error states exist for missing or invalid key material.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 5: KDBX Security Settings Preservation
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Preserve supported cipher and KDF settings when reopening and saving.
|
|
||||||
- Surface relevant settings in product-facing docs or UI where appropriate.
|
|
||||||
- Document unsupported settings explicitly.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Reopen-and-save cycles preserve supported KDBX security settings.
|
|
||||||
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
|
|
||||||
- Tests cover settings preservation across save cycles.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 6: Entry CRUD UI
|
### Segment 6: Entry CRUD UI
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -607,33 +405,6 @@ Exit criteria:
|
|||||||
- UI tests or controller-integrated tests cover these states.
|
- UI tests or controller-integrated tests cover these states.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 18: Desktop Automation Resolution
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
|
|
||||||
- or explicitly finalize the design that secure gRPC supersedes auto-type.
|
|
||||||
- Keep the decision documented in-repo.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- The desktop automation requirement is explicitly resolved in code or docs.
|
|
||||||
- The chosen approach is documented in `docs/desktop-automation.md`.
|
|
||||||
- Any implemented behavior is tested.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 19: Packaging And Runbook
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Keep the app runnable from source.
|
|
||||||
- Document desktop build and run steps.
|
|
||||||
- Document Android packaging with `gogio`.
|
|
||||||
- Add icon and metadata placeholders if missing.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- `README.md` is accurate for local build, run, and Android packaging guidance.
|
|
||||||
- Placeholder metadata exists where needed for packaging.
|
|
||||||
- The app still builds from the repo.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 20: Regression And Integration Coverage
|
### Segment 20: Regression And Integration Coverage
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -651,11 +422,10 @@ Exit criteria:
|
|||||||
|
|
||||||
Do not treat the product as complete until all of the following are true:
|
Do not treat the product as complete until all of the following are true:
|
||||||
|
|
||||||
- Segment 1 through Segment 20 are all complete.
|
- All remaining numbered segments, API segments, and UI review follow-ups are complete.
|
||||||
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
||||||
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
||||||
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
||||||
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
|
|
||||||
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
||||||
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
||||||
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
||||||
@@ -665,17 +435,7 @@ Do not treat the product as complete until all of the following are true:
|
|||||||
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
||||||
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
||||||
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
||||||
- The desktop automation requirement is explicitly resolved.
|
|
||||||
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
||||||
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
||||||
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
|
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
- `go tool golangci-lint run ./...` passes.
|
- `go tool golangci-lint run ./...` passes.
|
||||||
|
|
||||||
## Remaining Gaps Against AGENTS.md
|
|
||||||
|
|
||||||
None currently identified.
|
|
||||||
|
|
||||||
The last explicitly tracked gaps are now closed:
|
|
||||||
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
|
|
||||||
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# KeePassGO Browser Extension
|
||||||
|
|
||||||
|
Shared extension assets for Firefox and Chromium-based browsers live here.
|
||||||
|
|
||||||
|
- `manifest.firefox.json` uses the fixed Firefox extension id `browser@keepassgo.com`
|
||||||
|
- `manifest.chromium.json` is the Chromium/Chrome manifest template
|
||||||
|
- `background.js` talks to the native messaging host `com.keepassgo.browser`
|
||||||
|
- `content.js` fills username and password fields on the current page
|
||||||
|
- `options.html` stores the local gRPC address and API token in browser extension storage
|
||||||
|
|
||||||
|
The extension sends the API token to the native host on each request. The bridge does not store the token on disk.
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
const ext = globalThis.browser ?? globalThis.chrome;
|
||||||
|
const nativeHost = "com.keepassgo.browser";
|
||||||
|
const defaultSettings = {
|
||||||
|
grpcAddress: "",
|
||||||
|
bearerToken: ""
|
||||||
|
};
|
||||||
|
|
||||||
|
function storageGet(keys) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.storage.local.get(keys, (value) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageSet(value) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.storage.local.set(value, () => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsQuery(query) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.tabs.query(query, (tabs) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(tabs);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabsSendMessage(tabId, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.tabs.sendMessage(tabId, message, (response) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectNative(message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ext.runtime.sendNativeMessage(nativeHost, message, (response) => {
|
||||||
|
const error = ext.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const stored = await storageGet(["grpcAddress", "bearerToken"]);
|
||||||
|
return {
|
||||||
|
grpcAddress: (stored.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||||
|
bearerToken: (stored.bearerToken || "").trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activePageContext() {
|
||||||
|
const [tab] = await tabsQuery({ active: true, currentWindow: true });
|
||||||
|
return {
|
||||||
|
tabId: tab?.id ?? null,
|
||||||
|
url: typeof tab?.url === "string" ? tab.url : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statusForPage() {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const page = await activePageContext();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
configured: false,
|
||||||
|
status: null,
|
||||||
|
pageUrl: page.url,
|
||||||
|
matches: [],
|
||||||
|
error: "Set an API token in extension settings."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await connectNative({
|
||||||
|
action: "status",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken
|
||||||
|
});
|
||||||
|
if (!status.success || status.status?.locked || !page.url.startsWith("http")) {
|
||||||
|
return {
|
||||||
|
success: status.success,
|
||||||
|
configured: true,
|
||||||
|
status: status.status ?? null,
|
||||||
|
pageUrl: page.url,
|
||||||
|
matches: [],
|
||||||
|
error: status.error ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = await connectNative({
|
||||||
|
action: "find-logins",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
url: page.url
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: matches.success,
|
||||||
|
configured: true,
|
||||||
|
status: matches.status ?? status.status ?? null,
|
||||||
|
pageUrl: page.url,
|
||||||
|
matches: matches.matches ?? [],
|
||||||
|
error: matches.error ?? ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fillLogin(entryId) {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
const page = await activePageContext();
|
||||||
|
if (!settings.bearerToken) {
|
||||||
|
throw new Error("API token is not configured.");
|
||||||
|
}
|
||||||
|
if (page.tabId == null) {
|
||||||
|
throw new Error("No active tab is available.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await connectNative({
|
||||||
|
action: "get-login",
|
||||||
|
grpcAddress: settings.grpcAddress,
|
||||||
|
bearerToken: settings.bearerToken,
|
||||||
|
entryId,
|
||||||
|
url: page.url
|
||||||
|
});
|
||||||
|
if (!response.success || !response.credential) {
|
||||||
|
throw new Error(response.error || "KeePassGO did not return a credential.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillResponse = await tabsSendMessage(page.tabId, {
|
||||||
|
type: "keepassgo-fill-credential",
|
||||||
|
credential: response.credential
|
||||||
|
});
|
||||||
|
if (!fillResponse?.ok) {
|
||||||
|
throw new Error(fillResponse?.error || "The current page could not be filled.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
credential: response.credential,
|
||||||
|
pageUrl: page.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ext.runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
(async () => {
|
||||||
|
switch (message?.type) {
|
||||||
|
case "keepassgo-popup-state":
|
||||||
|
sendResponse(await statusForPage());
|
||||||
|
return;
|
||||||
|
case "keepassgo-fill-entry":
|
||||||
|
sendResponse({ success: true, ...(await fillLogin(message.entryId)) });
|
||||||
|
return;
|
||||||
|
case "keepassgo-load-settings":
|
||||||
|
sendResponse({ success: true, settings: await loadSettings() });
|
||||||
|
return;
|
||||||
|
case "keepassgo-save-settings":
|
||||||
|
await storageSet({
|
||||||
|
grpcAddress: String(message.settings?.grpcAddress || defaultSettings.grpcAddress).trim(),
|
||||||
|
bearerToken: String(message.settings?.bearerToken || "").trim()
|
||||||
|
});
|
||||||
|
sendResponse({ success: true });
|
||||||
|
return;
|
||||||
|
default:
|
||||||
|
sendResponse({ success: false, error: `Unsupported message ${message?.type || ""}`.trim() });
|
||||||
|
}
|
||||||
|
})().catch((error) => {
|
||||||
|
sendResponse({ success: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
function isVisibleInput(input) {
|
||||||
|
if (!(input instanceof HTMLInputElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (input.disabled || input.readOnly) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const style = window.getComputedStyle(input);
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return input.offsetParent !== null || style.position === "fixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFillEvents(input) {
|
||||||
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPasswordInput() {
|
||||||
|
return Array.from(document.querySelectorAll('input[type="password"]')).find(isVisibleInput) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findUsernameInput(passwordInput) {
|
||||||
|
const form = passwordInput?.form || null;
|
||||||
|
const scope = form || document;
|
||||||
|
const candidates = Array.from(scope.querySelectorAll('input[type="text"], input[type="email"], input:not([type])'))
|
||||||
|
.filter(isVisibleInput);
|
||||||
|
if (passwordInput) {
|
||||||
|
const sameForm = candidates.find((input) => input.form === passwordInput.form);
|
||||||
|
if (sameForm) {
|
||||||
|
return sameForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillCredential(credential) {
|
||||||
|
const passwordInput = findPasswordInput();
|
||||||
|
const usernameInput = findUsernameInput(passwordInput);
|
||||||
|
|
||||||
|
if (usernameInput && credential.username) {
|
||||||
|
usernameInput.focus();
|
||||||
|
usernameInput.value = credential.username;
|
||||||
|
dispatchFillEvents(usernameInput);
|
||||||
|
}
|
||||||
|
if (passwordInput && credential.password) {
|
||||||
|
passwordInput.focus();
|
||||||
|
passwordInput.value = credential.password;
|
||||||
|
dispatchFillEvents(passwordInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!usernameInput && !passwordInput) {
|
||||||
|
return { ok: false, error: "No fillable username or password fields were found." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
(globalThis.browser ?? globalThis.chrome).runtime.onMessage.addListener((message, _sender, sendResponse) => {
|
||||||
|
if (message?.type !== "keepassgo-fill-credential") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sendResponse(fillCredential(message.credential || {}));
|
||||||
|
} catch (error) {
|
||||||
|
sendResponse({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "KeePassGO Browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||||
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "KeePassGO Browser",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["http://*/*", "https://*/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "KeePassGO Browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Fill credentials from KeePassGO over the local gRPC API.",
|
||||||
|
"permissions": ["activeTab", "nativeMessaging", "storage", "tabs"],
|
||||||
|
"host_permissions": ["http://*/*", "https://*/*"],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "background.js"
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"default_title": "KeePassGO Browser",
|
||||||
|
"default_popup": "popup.html"
|
||||||
|
},
|
||||||
|
"options_ui": {
|
||||||
|
"page": "options.html",
|
||||||
|
"open_in_tab": true
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["http://*/*", "https://*/*"],
|
||||||
|
"js": ["content.js"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "browser@keepassgo.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeePassGO Browser Settings</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="surface settings">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>Browser Settings</h1>
|
||||||
|
<p class="subtle">Configure how the extension reaches KeePassGO.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<form id="settings-form" class="settings-form">
|
||||||
|
<label>
|
||||||
|
<span>gRPC address</span>
|
||||||
|
<input id="grpc-address" name="grpc-address" type="text" value="" placeholder="Leave blank for the local default socket" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>API token</span>
|
||||||
|
<textarea id="bearer-token" name="bearer-token" rows="6" spellcheck="false"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
<p id="settings-status" class="subtle"></p>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
<script src="options.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const extOptions = globalThis.browser ?? globalThis.chrome;
|
||||||
|
|
||||||
|
function runtimeSend(message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extOptions.runtime.sendMessage(message, (response) => {
|
||||||
|
const error = extOptions.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const response = await runtimeSend({ type: "keepassgo-load-settings" });
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Could not load settings.");
|
||||||
|
}
|
||||||
|
document.getElementById("grpc-address").value = response.settings.grpcAddress || "";
|
||||||
|
document.getElementById("bearer-token").value = response.settings.bearerToken || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const status = document.getElementById("settings-status");
|
||||||
|
status.textContent = "Saving…";
|
||||||
|
try {
|
||||||
|
const response = await runtimeSend({
|
||||||
|
type: "keepassgo-save-settings",
|
||||||
|
settings: {
|
||||||
|
grpcAddress: document.getElementById("grpc-address").value,
|
||||||
|
bearerToken: document.getElementById("bearer-token").value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response?.success) {
|
||||||
|
throw new Error(response?.error || "Could not save settings.");
|
||||||
|
}
|
||||||
|
status.textContent = "Saved.";
|
||||||
|
} catch (error) {
|
||||||
|
status.textContent = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("settings-form").addEventListener("submit", saveSettings);
|
||||||
|
void loadSettings();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>KeePassGO Browser</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body class="popup">
|
||||||
|
<main class="surface">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h1>KeePassGO</h1>
|
||||||
|
<p id="page-host" class="subtle">Checking current page</p>
|
||||||
|
</div>
|
||||||
|
<a href="options.html" target="_blank" rel="noreferrer" class="link-button">Settings</a>
|
||||||
|
</header>
|
||||||
|
<section id="status-card" class="status-card">
|
||||||
|
<strong id="status-title">Loading</strong>
|
||||||
|
<p id="status-message" class="subtle">Checking KeePassGO.</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Matches</h2>
|
||||||
|
<div id="matches" class="match-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const extPopup = globalThis.browser ?? globalThis.chrome;
|
||||||
|
|
||||||
|
function runtimeSend(message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
extPopup.runtime.sendMessage(message, (response) => {
|
||||||
|
const error = extPopup.runtime.lastError;
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(response);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hostFromURL(rawURL) {
|
||||||
|
try {
|
||||||
|
return new URL(rawURL).host || rawURL;
|
||||||
|
} catch (_error) {
|
||||||
|
return rawURL || "Current page";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(title, message, tone) {
|
||||||
|
const card = document.getElementById("status-card");
|
||||||
|
card.dataset.tone = tone || "neutral";
|
||||||
|
document.getElementById("status-title").textContent = title;
|
||||||
|
document.getElementById("status-message").textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMatches(state) {
|
||||||
|
const root = document.getElementById("matches");
|
||||||
|
root.textContent = "";
|
||||||
|
if (!Array.isArray(state.matches) || state.matches.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "subtle";
|
||||||
|
empty.textContent = "No matching entries for this page.";
|
||||||
|
root.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of state.matches) {
|
||||||
|
const row = document.createElement("button");
|
||||||
|
row.type = "button";
|
||||||
|
row.className = "match-row";
|
||||||
|
row.innerHTML = `
|
||||||
|
<span class="match-main">
|
||||||
|
<strong>${match.title}</strong>
|
||||||
|
<span class="subtle">${match.username || "No username"}</span>
|
||||||
|
</span>
|
||||||
|
<span class="quality">${match.quality || ""}</span>
|
||||||
|
`;
|
||||||
|
row.addEventListener("click", async () => {
|
||||||
|
row.disabled = true;
|
||||||
|
try {
|
||||||
|
const result = await runtimeSend({ type: "keepassgo-fill-entry", entryId: match.id });
|
||||||
|
if (!result?.success) {
|
||||||
|
throw new Error(result?.error || "Fill failed.");
|
||||||
|
}
|
||||||
|
setStatus("Filled", `${match.title} was sent to the current page.`, "ready");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Fill failed", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
} finally {
|
||||||
|
row.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const state = await runtimeSend({ type: "keepassgo-popup-state" });
|
||||||
|
document.getElementById("page-host").textContent = hostFromURL(state.pageUrl || "");
|
||||||
|
|
||||||
|
if (!state.configured) {
|
||||||
|
setStatus("Configure access", state.error || "Set the API token in extension settings.", "warning");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!state.success) {
|
||||||
|
setStatus("KeePassGO unavailable", state.error || "The native host could not reach KeePassGO.", "error");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.status?.locked) {
|
||||||
|
setStatus("Vault locked", "Unlock KeePassGO, then open the popup again.", "warning");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const count = Array.isArray(state.matches) ? state.matches.length : 0;
|
||||||
|
setStatus("Ready", count === 0 ? "KeePassGO is connected." : `${count} matching entr${count === 1 ? "y" : "ies"} found.`, "ready");
|
||||||
|
renderMatches(state);
|
||||||
|
} catch (error) {
|
||||||
|
setStatus("Error", error instanceof Error ? error.message : String(error), "error");
|
||||||
|
renderMatches({ matches: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--ink: #214f44;
|
||||||
|
--ink-soft: #4d6d66;
|
||||||
|
--surface: #fffdfa;
|
||||||
|
--surface-2: #f2f7f3;
|
||||||
|
--line: #d7e3dc;
|
||||||
|
--accent: #255f4a;
|
||||||
|
--accent-soft: #dfeee6;
|
||||||
|
--warn: #9f5f0e;
|
||||||
|
--error: #9f2f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.4 "Noto Sans", "Liberation Sans", sans-serif;
|
||||||
|
color: var(--ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top right, #ecf5ef, transparent 38%),
|
||||||
|
linear-gradient(180deg, #f8fbf8, #eef4f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.popup {
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.surface {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card[data-tone="ready"] {
|
||||||
|
border-color: #c5dccf;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card[data-tone="warning"] {
|
||||||
|
border-color: #e4d0ae;
|
||||||
|
background: #fbf4e7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card[data-tone="error"] {
|
||||||
|
border-color: #e4bcbc;
|
||||||
|
background: #fcf1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row,
|
||||||
|
button,
|
||||||
|
.link-button {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-row:hover,
|
||||||
|
button:hover,
|
||||||
|
.link-button:hover {
|
||||||
|
background: var(--surface-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-form {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.link-button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/browserbridge"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
switch strings.TrimSpace(os.Args[1]) {
|
||||||
|
case "install-native-host":
|
||||||
|
if err := runInstallNativeHost(os.Args[2:]); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case "status":
|
||||||
|
if err := runStatus(os.Args[2:]); err != nil {
|
||||||
|
fail(err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := runNativeMessage(); err != nil {
|
||||||
|
_ = browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runInstallNativeHost(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("install-native-host", flag.ContinueOnError)
|
||||||
|
browserName := fs.String("browser", string(browserbridge.BrowserFirefox), "target browser: firefox, chrome, chromium")
|
||||||
|
binaryPath := fs.String("binary", "", "path to keepassgo-browser-bridge binary")
|
||||||
|
extensionID := fs.String("extension-id", "", "browser extension id (required for chrome/chromium)")
|
||||||
|
extensionKey := fs.String("extension-key", "", "Chromium manifest public key used to derive a fixed extension id")
|
||||||
|
extensionKeyFile := fs.String("extension-key-file", "", "path to a Chromium manifest public key file")
|
||||||
|
outputPath := fs.String("output", "", "native host manifest output path")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(*binaryPath)
|
||||||
|
if path == "" {
|
||||||
|
resolved, err := defaultBinaryPath()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path = resolved
|
||||||
|
}
|
||||||
|
resolvedExtensionID := strings.TrimSpace(*extensionID)
|
||||||
|
if resolvedExtensionID == "" {
|
||||||
|
keyValue := strings.TrimSpace(*extensionKey)
|
||||||
|
if keyValue == "" && strings.TrimSpace(*extensionKeyFile) != "" {
|
||||||
|
data, err := os.ReadFile(strings.TrimSpace(*extensionKeyFile))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyValue = string(data)
|
||||||
|
}
|
||||||
|
if keyValue != "" {
|
||||||
|
derivedID, err := browserbridge.ChromiumExtensionIDFromManifestKey(keyValue)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolvedExtensionID = derivedID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installed, err := browserbridge.InstallManifest(browserbridge.Browser(strings.TrimSpace(*browserName)), path, resolvedExtensionID, strings.TrimSpace(*outputPath))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintln(os.Stdout, installed)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runStatus(args []string) error {
|
||||||
|
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
||||||
|
grpcAddr := fs.String("grpc-addr", grpcaddr.Default(runtime.GOOS), "KeePassGO local gRPC address")
|
||||||
|
token := fs.String("token", "", "KeePassGO API bearer token")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req := browserbridge.Request{
|
||||||
|
Action: "status",
|
||||||
|
GRPCAddress: strings.TrimSpace(*grpcAddr),
|
||||||
|
BearerToken: strings.TrimSpace(*token),
|
||||||
|
}
|
||||||
|
connCfg, err := req.Connection()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
resp := browserbridge.HandleRequest(ctx, req, client)
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNativeMessage() error {
|
||||||
|
req, err := browserbridge.ReadRequest(os.Stdin)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
connCfg, err := req.Connection()
|
||||||
|
if err != nil {
|
||||||
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
conn, client, ctx, err := browserbridge.Dial(context.Background(), connCfg)
|
||||||
|
if err != nil {
|
||||||
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.Response{Success: false, Error: err.Error()})
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
return browserbridge.WriteResponse(os.Stdout, browserbridge.HandleRequest(ctx, req, client))
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultBinaryPath() (string, error) {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err == nil && strings.TrimSpace(self) != "" {
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
self, err = exec.LookPath("keepassgo-browser-bridge")
|
||||||
|
if err == nil {
|
||||||
|
return self, nil
|
||||||
|
}
|
||||||
|
return filepath.Abs("./keepassgo-browser-bridge")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fail(err error) {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Browser Extension
|
||||||
|
|
||||||
|
KeePassGO browser integration uses:
|
||||||
|
|
||||||
|
- the existing local gRPC API in KeePassGO
|
||||||
|
- API tokens for authorization
|
||||||
|
- a tiny native messaging host for browser-to-gRPC transport adaptation
|
||||||
|
|
||||||
|
The browser extension does **not** talk to vault files directly.
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
- KeePassGO remains the source of truth for authentication, authorization, approvals, and audit events.
|
||||||
|
- The browser extension stores the gRPC address and API token in browser extension storage.
|
||||||
|
- The native messaging host receives the token on each request from the extension.
|
||||||
|
- The native messaging host uses the token only to attach `authorization: Bearer ...` metadata to the local gRPC request.
|
||||||
|
- The native messaging host does not persist the token to disk.
|
||||||
|
|
||||||
|
The native messaging host is therefore part of the trusted client for that browser profile. Scope the API token accordingly.
|
||||||
|
|
||||||
|
## RPCs Used
|
||||||
|
|
||||||
|
The browser integration uses:
|
||||||
|
|
||||||
|
- `GetSessionStatus`
|
||||||
|
- `FindBrowserLogins`
|
||||||
|
- `GetBrowserCredential`
|
||||||
|
|
||||||
|
The browser feature intentionally stays on the same secure gRPC surface used by other trusted automation.
|
||||||
|
|
||||||
|
## Default Listener
|
||||||
|
|
||||||
|
On desktop KeePassGO listens on a Unix socket by default:
|
||||||
|
|
||||||
|
- primary location: under the user runtime directory
|
||||||
|
- fallback: `/run/user/<uid>` if present
|
||||||
|
- final fallback: a private directory under the system temp directory
|
||||||
|
|
||||||
|
Override the listener with `-grpc-addr` or `KEEPASSGO_GRPC_ADDR`, for example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
KEEPASSGO_GRPC_ADDR=tcp://127.0.0.1:47777 ./keepassgo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Native Host
|
||||||
|
|
||||||
|
Build the bridge:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go build ./cmd/keepassgo-browser-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Install a Firefox native messaging manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./keepassgo-browser-bridge install-native-host --browser firefox --binary /absolute/path/to/keepassgo-browser-bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
Install a Chromium native messaging manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./keepassgo-browser-bridge install-native-host --browser chromium --binary /absolute/path/to/keepassgo-browser-bridge --extension-key-file /path/to/chromium-extension-public-key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Chrome and Chromium require the actual extension id in the native host manifest. KeePassGO can derive that id from the Chromium manifest public key so you do not have to type it separately.
|
||||||
|
|
||||||
|
For a fixed Chromium ID:
|
||||||
|
|
||||||
|
1. Keep a stable Chromium extension signing key outside the repo.
|
||||||
|
2. Add the corresponding public key to the Chromium manifest as `"key": "<base64-public-key>"`.
|
||||||
|
3. Use the same public key with `install-native-host --extension-key-file ...` so the native host manifest is locked to that stable extension ID.
|
||||||
|
|
||||||
|
## Extension Setup
|
||||||
|
|
||||||
|
Firefox:
|
||||||
|
|
||||||
|
1. Load `browser/extension/manifest.firefox.json` as a temporary add-on or package it as an extension.
|
||||||
|
2. Open the extension settings page.
|
||||||
|
3. Leave the gRPC address blank to use the local default Unix socket, or set an explicit address if you overrode the listener.
|
||||||
|
4. Paste an API token scoped for browser login lookup and credential copy.
|
||||||
|
|
||||||
|
Chromium / Chrome:
|
||||||
|
|
||||||
|
1. Load `browser/extension/` with `manifest.chromium.json`.
|
||||||
|
2. Note the extension id the browser assigns.
|
||||||
|
3. Install the native host manifest with that extension id.
|
||||||
|
4. Configure the gRPC address and API token in the extension settings page.
|
||||||
|
|
||||||
|
## Required Token Scope
|
||||||
|
|
||||||
|
At minimum, the browser token should have policy rules allowing:
|
||||||
|
|
||||||
|
- `list_entries` for the groups you want the browser to search
|
||||||
|
- `copy_username` for entries the browser may fill
|
||||||
|
- `copy_password` for entries the browser may fill
|
||||||
|
- `copy_url` for entries the browser may confirm against page URL
|
||||||
+51
-4
@@ -4,10 +4,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -27,6 +30,7 @@ type Host struct {
|
|||||||
lastModel vault.Model
|
lastModel vault.Model
|
||||||
started bool
|
started bool
|
||||||
listenAddr string
|
listenAddr string
|
||||||
|
socketPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]passwords.Profile, clipboardWriter clipboard.Writer, dirty DirtyProvider) (*Host, error) {
|
||||||
@@ -35,13 +39,17 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := net.Listen("tcp", addr)
|
network, endpoint, err := grpcaddr.Parse(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
listener, socketPath, err := listen(network, endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
return nil, fmt.Errorf("listen gRPC host %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
host := &Host{
|
host := &Host{
|
||||||
@@ -50,7 +58,8 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
|||||||
listener: listener,
|
listener: listener,
|
||||||
lifecycle: lifecycle,
|
lifecycle: lifecycle,
|
||||||
dirty: dirty,
|
dirty: dirty,
|
||||||
listenAddr: listener.Addr().String(),
|
listenAddr: formatListenAddress(network, listener.Addr().String(), socketPath),
|
||||||
|
socketPath: socketPath,
|
||||||
started: true,
|
started: true,
|
||||||
}
|
}
|
||||||
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
if err := host.SyncFromLifecycle(); err != nil && !errors.Is(err, session.ErrLocked) {
|
||||||
@@ -91,7 +100,13 @@ func (h *Host) Stop() error {
|
|||||||
}
|
}
|
||||||
h.started = false
|
h.started = false
|
||||||
h.grpcServer.Stop()
|
h.grpcServer.Stop()
|
||||||
return h.listener.Close()
|
err := h.listener.Close()
|
||||||
|
if h.socketPath != "" {
|
||||||
|
if removeErr := os.Remove(h.socketPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) && err == nil {
|
||||||
|
err = removeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Host) SyncFromLifecycle() error {
|
func (h *Host) SyncFromLifecycle() error {
|
||||||
@@ -120,3 +135,35 @@ func (h *Host) SyncFromLifecycle() error {
|
|||||||
h.server.SetSessionState(h.lastModel, locked, dirty)
|
h.server.SetSessionState(h.lastModel, locked, dirty)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listen(network, endpoint string) (net.Listener, string, error) {
|
||||||
|
if network == "unix" {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(endpoint), 0o700); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := os.Remove(endpoint); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("unix", endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(endpoint, 0o600); err != nil {
|
||||||
|
_ = listener.Close()
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return listener, endpoint, nil
|
||||||
|
}
|
||||||
|
listener, err := net.Listen(network, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return listener, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatListenAddress(network, listenerAddr, socketPath string) string {
|
||||||
|
if network == "unix" {
|
||||||
|
return "unix://" + socketPath
|
||||||
|
}
|
||||||
|
return listenerAddr
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -19,7 +23,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
lifecycle := &session.Manager{}
|
lifecycle := &session.Manager{}
|
||||||
if err := lifecycle.Create(vault.Model{
|
if err := lifecycle.Create(vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{
|
||||||
|
Effect: apitokens.EffectAllow,
|
||||||
|
Operation: apitokens.OperationManageVault,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceGroup,
|
||||||
|
Path: []string{"Root"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
@@ -32,10 +45,14 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
|
|
||||||
|
network, endpoint, err := grpcaddr.Parse(host.Address())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse(host.Address()) error = %v", err)
|
||||||
|
}
|
||||||
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
conn, err := grpc.NewClient("passthrough:///"+host.Address(),
|
||||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
return net.Dial("tcp", host.Address())
|
return net.Dial(network, endpoint)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,3 +87,37 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
t.Fatal("GetSessionStatus().Locked = false, want true after lifecycle lock")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStartHostServesOverUnixSocket(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
socketDir := t.TempDir()
|
||||||
|
socketPath := socketDir + "/keepassgo.sock"
|
||||||
|
lifecycle := &session.Manager{}
|
||||||
|
if err := lifecycle.Create(vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
t.Fatalf("Create() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := StartHost("unix://"+socketPath, lifecycle, passwords.DefaultProfiles(), nil, func() bool { return false })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("StartHost() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := host.Address(); got != "unix://"+socketPath {
|
||||||
|
t.Fatalf("host.Address() = %q, want %q", got, "unix://"+socketPath)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(socketPath); err != nil {
|
||||||
|
t.Fatalf("Stat(socketPath) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := host.Stop(); err != nil {
|
||||||
|
t.Fatalf("Stop() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(socketPath); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("socket exists after Stop(), err = %v, want not-exist", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+233
-37
@@ -3,7 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,7 +21,6 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -89,7 +90,10 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
|||||||
s.dirty = dirty
|
s.dirty = dirty
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -100,7 +104,10 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -124,7 +131,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
|
|||||||
return &keepassgov1.OpenVaultResponse{}, nil
|
return &keepassgov1.OpenVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -153,7 +163,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
|
|||||||
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -169,7 +182,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
|||||||
return &keepassgov1.SaveVaultResponse{}, nil
|
return &keepassgov1.SaveVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -185,7 +201,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (
|
|||||||
return &keepassgov1.LockVaultResponse{}, nil
|
return &keepassgov1.LockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -208,6 +227,133 @@ func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequ
|
|||||||
return &keepassgov1.UnlockVaultResponse{}, nil
|
return &keepassgov1.UnlockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) FindBrowserLogins(ctx context.Context, req *keepassgov1.FindBrowserLoginsRequest) (*keepassgov1.FindBrowserLoginsResponse, error) {
|
||||||
|
model, locked := s.snapshotModel()
|
||||||
|
if locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
token, err := s.authorizeVaultRequest(ctx, apitokens.OperationListEntries)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pageHost, err := normalizedBrowserHost(req.GetPageUrl())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
type rankedMatch struct {
|
||||||
|
match *keepassgov1.BrowserLoginMatch
|
||||||
|
score int
|
||||||
|
}
|
||||||
|
var matches []rankedMatch
|
||||||
|
for _, entry := range visibleModel(model).Entries {
|
||||||
|
quality, score := classifyBrowserEntryMatch(pageHost, entry.URL)
|
||||||
|
if score == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, rankedMatch{
|
||||||
|
match: &keepassgov1.BrowserLoginMatch{
|
||||||
|
Id: entry.ID,
|
||||||
|
Title: entry.Title,
|
||||||
|
Username: entry.Username,
|
||||||
|
Url: entry.URL,
|
||||||
|
Path: append([]string(nil), entry.Path...),
|
||||||
|
Quality: quality,
|
||||||
|
},
|
||||||
|
score: score,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.SortFunc(matches, func(a, b rankedMatch) int {
|
||||||
|
switch {
|
||||||
|
case a.score != b.score:
|
||||||
|
return b.score - a.score
|
||||||
|
case a.match.GetTitle() != b.match.GetTitle():
|
||||||
|
return strings.Compare(a.match.GetTitle(), b.match.GetTitle())
|
||||||
|
case a.match.GetUsername() != b.match.GetUsername():
|
||||||
|
return strings.Compare(a.match.GetUsername(), b.match.GetUsername())
|
||||||
|
default:
|
||||||
|
return strings.Compare(a.match.GetId(), b.match.GetId())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
out := make([]*keepassgov1.BrowserLoginMatch, 0, len(matches))
|
||||||
|
for _, match := range matches {
|
||||||
|
out = append(out, match.match)
|
||||||
|
}
|
||||||
|
switch len(out) {
|
||||||
|
case 1:
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillFound,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: apitokens.OperationListEntries,
|
||||||
|
Message: "browser login match found for " + pageHost,
|
||||||
|
})
|
||||||
|
case 2, 3, 4, 5:
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillAmbiguous,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: apitokens.OperationListEntries,
|
||||||
|
Message: "browser login search returned multiple matches for " + pageHost,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return &keepassgov1.FindBrowserLoginsResponse{Matches: out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) GetBrowserCredential(ctx context.Context, req *keepassgov1.GetBrowserCredentialRequest) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
model, locked := s.snapshotModel()
|
||||||
|
if locked {
|
||||||
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
|
}
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
entry, err := findEntryByID(model, req.GetId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
if pageURL := strings.TrimSpace(req.GetPageUrl()); pageURL != "" {
|
||||||
|
pageHost, err := normalizedBrowserHost(pageURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, err.Error())
|
||||||
|
}
|
||||||
|
if _, score := classifyBrowserEntryMatch(pageHost, entry.URL); score == 0 {
|
||||||
|
return nil, status.Error(codes.InvalidArgument, "entry url does not match requested page")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(entry.Username) != "" {
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyUsername, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyPassword, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(entry.URL) != "" {
|
||||||
|
if _, err := s.authorizeResourceRequest(ctx, token, apitokens.OperationCopyURL, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.audit.Record(apiaudit.Event{
|
||||||
|
Type: apiaudit.EventAutofillFound,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Operation: apitokens.OperationCopyPassword,
|
||||||
|
Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path},
|
||||||
|
Message: "browser credential returned for " + entry.ID,
|
||||||
|
})
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{
|
||||||
|
Id: entry.ID,
|
||||||
|
Username: entry.Username,
|
||||||
|
Password: entry.Password,
|
||||||
|
Url: entry.URL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func mapLifecycleError(operation string, err error) error {
|
func mapLifecycleError(operation string, err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, os.ErrNotExist):
|
case errors.Is(err, os.ErrNotExist):
|
||||||
@@ -465,7 +611,10 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
|
|||||||
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
||||||
|
if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -483,10 +632,13 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
||||||
if req.GetTemplate() == nil {
|
if req.GetTemplate() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing template")
|
return nil, status.Error(codes.InvalidArgument, "missing template")
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -502,7 +654,10 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla
|
|||||||
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
||||||
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -521,10 +676,16 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla
|
|||||||
return &keepassgov1.DeleteTemplateResponse{}, nil
|
return &keepassgov1.DeleteTemplateResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
||||||
if req.GetOverrides() == nil {
|
if req.GetOverrides() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -685,12 +846,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||||
s.mu.RLock()
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
if _, err := s.authenticateRequest(ctx); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
@@ -756,6 +916,39 @@ func findMutableEntryByID(model *vault.Model, id string) (vault.Entry, int, erro
|
|||||||
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
return vault.Entry{}, -1, vault.ErrEntryNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizedBrowserHost(raw string) (string, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse page url: %w", err)
|
||||||
|
}
|
||||||
|
host := strings.ToLower(parsed.Hostname())
|
||||||
|
if host == "" {
|
||||||
|
return "", fmt.Errorf("page url must include a hostname")
|
||||||
|
}
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyBrowserEntryMatch(pageHost, rawEntryURL string) (string, int) {
|
||||||
|
parsed, err := url.Parse(strings.TrimSpace(rawEntryURL))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
entryHost := strings.ToLower(parsed.Hostname())
|
||||||
|
if entryHost == "" {
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case pageHost == entryHost:
|
||||||
|
return "exact-host", 3
|
||||||
|
case strings.HasSuffix(pageHost, "."+entryHost):
|
||||||
|
return "subdomain", 2
|
||||||
|
case strings.HasSuffix(entryHost, "."+pageHost):
|
||||||
|
return "parent-domain", 1
|
||||||
|
default:
|
||||||
|
return "", 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func visibleModel(model vault.Model) vault.Model {
|
func visibleModel(model vault.Model) vault.Model {
|
||||||
out := model
|
out := model
|
||||||
out.Entries = nil
|
out.Entries = nil
|
||||||
@@ -784,6 +977,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) {
|
|||||||
|
|
||||||
var timeNow = func() time.Time { return time.Now().UTC() }
|
var timeNow = func() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
|
var (
|
||||||
|
vaultPolicyPath = []string{"Root"}
|
||||||
|
templatePolicyPath = []string{"Root", "Templates"}
|
||||||
|
)
|
||||||
|
|
||||||
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
||||||
md, ok := metadata.FromIncomingContext(ctx)
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -826,6 +1024,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio
|
|||||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
|
||||||
|
return s.authorizePathRequest(ctx, op, vaultPolicyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
|
||||||
|
return s.authorizePathRequest(ctx, op, templatePolicyPath)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
||||||
token, err := s.authenticateRequest(ctx)
|
token, err := s.authenticateRequest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -834,6 +1040,18 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
|
|||||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
Path: templatePolicyPath,
|
||||||
|
EntryID: templateID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
||||||
switch apitokens.Evaluate(token, op, resource) {
|
switch apitokens.Evaluate(token, op, resource) {
|
||||||
case apitokens.DecisionAllow:
|
case apitokens.DecisionAllow:
|
||||||
@@ -955,25 +1173,3 @@ func copyOperation(target string) apitokens.Operation {
|
|||||||
return apitokens.OperationCopyPassword
|
return apitokens.OperationCopyPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
|
|
||||||
return func(
|
|
||||||
ctx context.Context,
|
|
||||||
req any,
|
|
||||||
info *grpc.UnaryServerInfo,
|
|
||||||
handler grpc.UnaryHandler,
|
|
||||||
) (any, error) {
|
|
||||||
switch info.FullMethod {
|
|
||||||
case "/keepassgo.v1.VaultService/GetSessionStatus",
|
|
||||||
"/keepassgo.v1.VaultService/OpenVault",
|
|
||||||
"/keepassgo.v1.VaultService/OpenRemoteVault",
|
|
||||||
"/keepassgo.v1.VaultService/SaveVault",
|
|
||||||
"/keepassgo.v1.VaultService/LockVault",
|
|
||||||
"/keepassgo.v1.VaultService/UnlockVault":
|
|
||||||
if _, err := server.authenticateRequest(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return handler(ctx, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+143
-2
@@ -99,6 +99,144 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "website-login", Title: "Website Login", Path: []string{"Templates"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{
|
||||||
|
Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"},
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceFindsBrowserLoginsForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
|
resp, err := client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: "https://vault.crew.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindBrowserLogins() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 {
|
||||||
|
t.Fatalf("len(FindBrowserLogins().Matches) = %d, want 1", len(resp.Matches))
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Id != "vault-console" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Id = %q, want vault-console", resp.Matches[0].Id)
|
||||||
|
}
|
||||||
|
if resp.Matches[0].Quality != "exact-host" {
|
||||||
|
t.Fatalf("FindBrowserLogins().Matches[0].Quality = %q, want exact-host", resp.Matches[0].Quality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceGetsBrowserCredentialForAuthorizedClients(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClient(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := tokenContext(defaultTestTokenSecret)
|
||||||
|
resp, err := client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "vault-console",
|
||||||
|
PageUrl: "https://vault.crew.example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBrowserCredential() error = %v", err)
|
||||||
|
}
|
||||||
|
if resp.Id != "vault-console" {
|
||||||
|
t.Fatalf("GetBrowserCredential().Id = %q, want vault-console", resp.Id)
|
||||||
|
}
|
||||||
|
if resp.Password != "token-1" {
|
||||||
|
t.Fatalf("GetBrowserCredential().Password = %q, want token-1", resp.Password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedBrowserCredentialAccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
{
|
||||||
|
ID: "vault-console",
|
||||||
|
Title: "Vault Console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
URL: "https://vault.crew.example.invalid",
|
||||||
|
Path: []string{"Root", "Internet"},
|
||||||
|
},
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.GetBrowserCredential(tokenContext(defaultTestTokenSecret), &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: "vault-console",
|
||||||
|
PageUrl: "https://vault.crew.example.invalid/login",
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("GetBrowserCredential() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1087,9 +1225,12 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
@@ -1125,7 +1266,7 @@ func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultS
|
|||||||
listener := bufconn.Listen(1024 * 1024)
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1168,7 +1309,7 @@ func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepas
|
|||||||
))
|
))
|
||||||
lifecycle.model = model
|
lifecycle.model = model
|
||||||
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type Broker struct {
|
|||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
nextID func() string
|
nextID func() string
|
||||||
|
notify func()
|
||||||
}
|
}
|
||||||
|
|
||||||
type pendingRequest struct {
|
type pendingRequest struct {
|
||||||
@@ -108,6 +109,15 @@ func (b *Broker) Pending() []Request {
|
|||||||
return requests
|
return requests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *Broker) SetChangeNotifier(notify func()) {
|
||||||
|
if b == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.notify = notify
|
||||||
|
}
|
||||||
|
|
||||||
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (Result, error) {
|
||||||
if b == nil {
|
if b == nil {
|
||||||
return Result{}, ErrRequestTimedOut
|
return Result{}, ErrRequestTimedOut
|
||||||
@@ -128,12 +138,20 @@ func (b *Broker) Request(ctx context.Context, token apitokens.Token, op apitoken
|
|||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
b.pending[pending.request.ID] = pending
|
b.pending[pending.request.ID] = pending
|
||||||
|
notify := b.notify
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if notify != nil {
|
||||||
|
notify()
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
delete(b.pending, pending.request.ID)
|
delete(b.pending, pending.request.ID)
|
||||||
|
notify := b.notify
|
||||||
b.mu.Unlock()
|
b.mu.Unlock()
|
||||||
|
if notify != nil {
|
||||||
|
notify()
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
timer := time.NewTimer(b.timeout)
|
timer := time.NewTimer(b.timeout)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package apiapproval
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,6 +121,36 @@ func TestBrokerTimesOutPendingRequests(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBrokerNotifiesWhenPendingRequestsChange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
broker := NewBroker(time.Minute)
|
||||||
|
changes := make(chan int, 4)
|
||||||
|
broker.SetChangeNotifier(func() {
|
||||||
|
changes <- len(broker.Pending())
|
||||||
|
})
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := broker.Request(context.Background(), apitokens.Token{ID: "token-1", Name: "CLI"}, apitokens.OperationListGroups, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}})
|
||||||
|
errCh <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitForPending(t, broker, 1)
|
||||||
|
if _, _, err := broker.Resolve(broker.Pending()[0].ID, OutcomeAllowOnce); err != nil {
|
||||||
|
t.Fatalf("Resolve(allow once) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := <-errCh; err != nil {
|
||||||
|
t.Fatalf("Request() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := []int{<-changes, <-changes}
|
||||||
|
slices.Sort(got)
|
||||||
|
if !slices.Equal(got, []int{0, 1}) {
|
||||||
|
t.Fatalf("change notifications = %v, want [0 1]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func waitForPending(t *testing.T, broker *Broker, want int) {
|
func waitForPending(t *testing.T, broker *Broker, want int) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const (
|
|||||||
EventApprovalDenied EventType = "approval_denied"
|
EventApprovalDenied EventType = "approval_denied"
|
||||||
EventApprovalCanceled EventType = "approval_canceled"
|
EventApprovalCanceled EventType = "approval_canceled"
|
||||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
EventApprovalTimedOut EventType = "approval_timed_out"
|
||||||
|
EventTokenIssued EventType = "token_issued"
|
||||||
|
EventTokenUpdated EventType = "token_updated"
|
||||||
|
EventTokenRotated EventType = "token_rotated"
|
||||||
|
EventTokenDisabled EventType = "token_disabled"
|
||||||
|
EventTokenRevoked EventType = "token_revoked"
|
||||||
|
EventTokenDeleted EventType = "token_deleted"
|
||||||
EventAutofillFound EventType = "autofill_found"
|
EventAutofillFound EventType = "autofill_found"
|
||||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||||
EventAutofillBlocked EventType = "autofill_blocked"
|
EventAutofillBlocked EventType = "autofill_blocked"
|
||||||
|
|||||||
@@ -55,15 +55,18 @@ const (
|
|||||||
DecisionDeny Decision = "deny"
|
DecisionDeny Decision = "deny"
|
||||||
DecisionPrompt Decision = "prompt"
|
DecisionPrompt Decision = "prompt"
|
||||||
|
|
||||||
OperationListEntries Operation = "list_entries"
|
OperationListEntries Operation = "list_entries"
|
||||||
OperationListGroups Operation = "list_groups"
|
OperationListGroups Operation = "list_groups"
|
||||||
OperationReadEntry Operation = "read_entry"
|
OperationListTemplates Operation = "list_templates"
|
||||||
OperationCopyPassword Operation = "copy_password"
|
OperationReadEntry Operation = "read_entry"
|
||||||
OperationCopyUsername Operation = "copy_username"
|
OperationCopyPassword Operation = "copy_password"
|
||||||
OperationCopyURL Operation = "copy_url"
|
OperationCopyUsername Operation = "copy_username"
|
||||||
OperationMutateEntry Operation = "mutate_entry"
|
OperationCopyURL Operation = "copy_url"
|
||||||
OperationMutateGroup Operation = "mutate_group"
|
OperationMutateEntry Operation = "mutate_entry"
|
||||||
OperationManageVault Operation = "manage_vault"
|
OperationMutateGroup Operation = "mutate_group"
|
||||||
|
OperationMutateTemplate Operation = "mutate_template"
|
||||||
|
OperationGeneratePassword Operation = "generate_password"
|
||||||
|
OperationManageVault Operation = "manage_vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
@@ -101,6 +102,7 @@ type ApprovalManager interface {
|
|||||||
type State struct {
|
type State struct {
|
||||||
Session CurrentSession
|
Session CurrentSession
|
||||||
Approvals ApprovalManager
|
Approvals ApprovalManager
|
||||||
|
AuditLog *apiaudit.Log
|
||||||
Section Section
|
Section Section
|
||||||
CurrentPath []string
|
CurrentPath []string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
@@ -195,6 +197,7 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||||
return token, secret, nil
|
return token, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +221,7 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||||
return token, secret, nil
|
return token, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +254,11 @@ func (s *State) DisableAPIToken(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, apitokens.Disable(token))
|
token = apitokens.Disable(token)
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,9 +275,11 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
token = apitokens.Revoke(token, when)
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,14 +292,37 @@ func (s *State) DeleteAPIToken(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
token, err := apitokens.Find(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := apitokens.Delete(&model, id); err != nil {
|
if err := apitokens.Delete(&model, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||||
|
if s.AuditLog == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.AuditLog.Record(apiaudit.Event{
|
||||||
|
Type: eventType,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
Path: apitokens.EntryPath,
|
||||||
|
EntryID: token.ID,
|
||||||
|
},
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||||
security, ok := s.Session.(SecurityConfigurableSession)
|
security, ok := s.Session.(SecurityConfigurableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
session := &mutableStubSession{model: vault.Model{}}
|
session := &mutableStubSession{model: vault.Model{}}
|
||||||
state := State{Session: session}
|
auditLog := apiaudit.New(10)
|
||||||
|
state := State{Session: session, AuditLog: auditLog}
|
||||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
expiresAt := now.Add(24 * time.Hour)
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
@@ -162,6 +164,24 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
if len(tokens) != 0 {
|
if len(tokens) != 0 {
|
||||||
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
events := auditLog.Events()
|
||||||
|
if len(events) != 5 {
|
||||||
|
t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != apiaudit.EventTokenDeleted ||
|
||||||
|
events[1].Type != apiaudit.EventTokenRevoked ||
|
||||||
|
events[2].Type != apiaudit.EventTokenDisabled ||
|
||||||
|
events[3].Type != apiaudit.EventTokenRotated ||
|
||||||
|
events[4].Type != apiaudit.EventTokenIssued {
|
||||||
|
t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events)
|
||||||
|
}
|
||||||
|
if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID {
|
||||||
|
t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID)
|
||||||
|
}
|
||||||
|
if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" {
|
||||||
|
t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ func Operations() []apitokens.Operation {
|
|||||||
return []apitokens.Operation{
|
return []apitokens.Operation{
|
||||||
apitokens.OperationListEntries,
|
apitokens.OperationListEntries,
|
||||||
apitokens.OperationListGroups,
|
apitokens.OperationListGroups,
|
||||||
|
apitokens.OperationListTemplates,
|
||||||
apitokens.OperationReadEntry,
|
apitokens.OperationReadEntry,
|
||||||
apitokens.OperationCopyPassword,
|
apitokens.OperationCopyPassword,
|
||||||
apitokens.OperationCopyUsername,
|
apitokens.OperationCopyUsername,
|
||||||
apitokens.OperationCopyURL,
|
apitokens.OperationCopyURL,
|
||||||
apitokens.OperationMutateEntry,
|
apitokens.OperationMutateEntry,
|
||||||
apitokens.OperationMutateGroup,
|
apitokens.OperationMutateGroup,
|
||||||
|
apitokens.OperationMutateTemplate,
|
||||||
|
apitokens.OperationGeneratePassword,
|
||||||
apitokens.OperationManageVault,
|
apitokens.OperationManageVault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-11
@@ -129,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
|
|||||||
return clicks
|
return clicks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
|
||||||
|
if count <= 0 {
|
||||||
|
u.apiPolicyEdits = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(u.apiPolicyEdits) == count {
|
||||||
|
return u.apiPolicyEdits
|
||||||
|
}
|
||||||
|
clicks := make([]widget.Clickable, count)
|
||||||
|
copy(clicks, u.apiPolicyEdits)
|
||||||
|
u.apiPolicyEdits = clicks
|
||||||
|
return clicks
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
||||||
|
u.selectedAPIPolicyIndex = -1
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
if !ok {
|
if !ok {
|
||||||
u.apiTokenSecret = ""
|
u.apiTokenSecret = ""
|
||||||
@@ -143,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|||||||
u.apiPolicyAllow.Value = true
|
u.apiPolicyAllow.Value = true
|
||||||
u.apiPolicyGroupScope = true
|
u.apiPolicyGroupScope = true
|
||||||
u.apiPolicyGroupScopeW.Value = true
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.ensureAPIPolicyEditClickables(0)
|
||||||
u.ensureAPIPolicyRemoveClickables(0)
|
u.ensureAPIPolicyRemoveClickables(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|||||||
u.apiTokenExpiresAt.SetText("")
|
u.apiTokenExpiresAt.SetText("")
|
||||||
}
|
}
|
||||||
u.apiTokenDisabled.Value = token.Disabled
|
u.apiTokenDisabled.Value = token.Disabled
|
||||||
|
u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
|||||||
return "", fmt.Errorf("unknown API operation %q", text)
|
return "", fmt.Errorf("unknown API operation %q", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) addAPIPolicyRuleAction() error {
|
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
|
||||||
token, ok := u.selectedAPIToken()
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("no API token selected")
|
|
||||||
}
|
|
||||||
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return apitokens.PolicyRule{}, err
|
||||||
}
|
}
|
||||||
rule := apitokens.PolicyRule{
|
rule := apitokens.PolicyRule{
|
||||||
Operation: operation,
|
Operation: operation,
|
||||||
@@ -270,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
if u.apiPolicyGroupScope {
|
if u.apiPolicyGroupScope {
|
||||||
path := parsePath(u.apiPolicyPath.Text())
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return fmt.Errorf("policy path is required for group scope")
|
return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
|
||||||
}
|
}
|
||||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
||||||
} else {
|
} else {
|
||||||
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
||||||
if entryID == "" {
|
if entryID == "" {
|
||||||
return fmt.Errorf("entry id is required for entry scope")
|
return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
|
||||||
}
|
}
|
||||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
||||||
}
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) addAPIPolicyRuleAction() error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
rule, err := u.apiPolicyRuleFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !uiHasPolicyRule(token.Policies, rule) {
|
if !uiHasPolicyRule(token.Policies, rule) {
|
||||||
token.Policies = append(token.Policies, rule)
|
token.Policies = append(token.Policies, rule)
|
||||||
}
|
}
|
||||||
@@ -290,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) editAPIPolicyRuleAction(index int) error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(token.Policies) {
|
||||||
|
return fmt.Errorf("policy index %d out of range", index)
|
||||||
|
}
|
||||||
|
rule := token.Policies[index]
|
||||||
|
u.selectedAPIPolicyIndex = index
|
||||||
|
u.apiPolicyOperation.SetText(string(rule.Operation))
|
||||||
|
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
|
||||||
|
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||||
|
u.apiPolicyGroupScope = false
|
||||||
|
u.apiPolicyGroupScopeW.Value = false
|
||||||
|
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
|
||||||
|
u.apiPolicyPath.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u.apiPolicyGroupScope = true
|
||||||
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
|
||||||
|
u.apiPolicyEntryID.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAPIPolicyRuleAction() error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
index := u.selectedAPIPolicyIndex
|
||||||
|
if index < 0 || index >= len(token.Policies) {
|
||||||
|
return fmt.Errorf("no API policy rule selected")
|
||||||
|
}
|
||||||
|
rule, err := u.apiPolicyRuleFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, existing := range token.Policies {
|
||||||
|
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
|
||||||
|
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
|
||||||
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.Policies[index] = rule
|
||||||
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) apiPolicyGroupPathSummary() string {
|
func (u *ui) apiPolicyGroupPathSummary() string {
|
||||||
path := parsePath(u.apiPolicyPath.Text())
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
@@ -357,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) cancelAPIPolicyEditAction() error {
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||||
if u.auditLog == nil {
|
if u.auditLog == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -749,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
|
|||||||
|
|
||||||
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
|
editClicks := u.ensureAPIPolicyEditClickables(0)
|
||||||
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
||||||
if ok {
|
if ok {
|
||||||
|
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
}
|
}
|
||||||
rows := []layout.Widget{
|
rows := []layout.Widget{
|
||||||
@@ -918,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
||||||
}),
|
}),
|
||||||
@@ -951,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
rows = append(rows,
|
rows = append(rows,
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
actionLabel := "Add Rule"
|
||||||
|
title := "Policy Composer"
|
||||||
|
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
|
||||||
|
if 0 <= u.selectedAPIPolicyIndex {
|
||||||
|
actionLabel = "Save Rule"
|
||||||
|
title = "Policy Editor"
|
||||||
|
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
|
||||||
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
|
lbl := material.Label(u.theme, unit.Sp(14), title)
|
||||||
lbl.Color = accentColor
|
lbl.Color = accentColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
|
lbl := material.Label(u.theme, unit.Sp(12), description)
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
@@ -1014,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if 0 <= u.selectedAPIPolicyIndex {
|
||||||
|
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.selectedAPIPolicyIndex < 0 {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+30
-14
@@ -364,12 +364,13 @@ type ui struct {
|
|||||||
showAutofillApprovalAsk widget.Clickable
|
showAutofillApprovalAsk widget.Clickable
|
||||||
showAutofillApprovalAllow widget.Clickable
|
showAutofillApprovalAllow widget.Clickable
|
||||||
showAutofillApprovalBlock widget.Clickable
|
showAutofillApprovalBlock widget.Clickable
|
||||||
allowApproval widget.Clickable
|
allowApprovalOnce widget.Clickable
|
||||||
denyApproval widget.Clickable
|
allowApprovalPermanent widget.Clickable
|
||||||
|
denyApprovalOnce widget.Clickable
|
||||||
|
denyApprovalPermanent widget.Clickable
|
||||||
cancelApproval widget.Clickable
|
cancelApproval widget.Clickable
|
||||||
cancelLifecycleProgress widget.Clickable
|
cancelLifecycleProgress widget.Clickable
|
||||||
retryLifecycleOpen widget.Clickable
|
retryLifecycleOpen widget.Clickable
|
||||||
approvalPermanent widget.Bool
|
|
||||||
syncSetupAutomatic widget.Bool
|
syncSetupAutomatic widget.Bool
|
||||||
apiPolicyAllow widget.Bool
|
apiPolicyAllow widget.Bool
|
||||||
apiPolicyGroupScopeW widget.Bool
|
apiPolicyGroupScopeW widget.Bool
|
||||||
@@ -381,6 +382,7 @@ type ui struct {
|
|||||||
settingsDebugHeaderBounds widget.Bool
|
settingsDebugHeaderBounds widget.Bool
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
apiTokenClicks []widget.Clickable
|
apiTokenClicks []widget.Clickable
|
||||||
|
apiPolicyEdits []widget.Clickable
|
||||||
apiPolicyRemoves []widget.Clickable
|
apiPolicyRemoves []widget.Clickable
|
||||||
apiAuditClicks []widget.Clickable
|
apiAuditClicks []widget.Clickable
|
||||||
apiAuditTokenFilters []widget.Clickable
|
apiAuditTokenFilters []widget.Clickable
|
||||||
@@ -416,6 +418,8 @@ type ui struct {
|
|||||||
useSelectedEntryForPolicy widget.Clickable
|
useSelectedEntryForPolicy widget.Clickable
|
||||||
clearAPIPolicyTarget widget.Clickable
|
clearAPIPolicyTarget widget.Clickable
|
||||||
addAPIPolicyRule widget.Clickable
|
addAPIPolicyRule widget.Clickable
|
||||||
|
saveAPIPolicyRule widget.Clickable
|
||||||
|
cancelAPIPolicyEdit widget.Clickable
|
||||||
phoneSplit widget.Float
|
phoneSplit widget.Float
|
||||||
splitDrag gesture.Drag
|
splitDrag gesture.Drag
|
||||||
splitBase float32
|
splitBase float32
|
||||||
@@ -488,6 +492,7 @@ type ui struct {
|
|||||||
entriesState entriesSectionState
|
entriesState entriesSectionState
|
||||||
deleteGroupPath []string
|
deleteGroupPath []string
|
||||||
apiPolicyGroupScope bool
|
apiPolicyGroupScope bool
|
||||||
|
selectedAPIPolicyIndex int
|
||||||
apiTokenSecret string
|
apiTokenSecret string
|
||||||
phoneSyncMenuOrigin image.Point
|
phoneSyncMenuOrigin image.Point
|
||||||
phoneMainMenuOrigin image.Point
|
phoneMainMenuOrigin image.Point
|
||||||
@@ -665,6 +670,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
||||||
backgroundResults: make(chan backgroundActionResult, 8),
|
backgroundResults: make(chan backgroundActionResult, 8),
|
||||||
phoneGroupBrowserExpanded: true,
|
phoneGroupBrowserExpanded: true,
|
||||||
|
selectedAPIPolicyIndex: -1,
|
||||||
}
|
}
|
||||||
if mode == "phone" {
|
if mode == "phone" {
|
||||||
u.groupControlsHidden = true
|
u.groupControlsHidden = true
|
||||||
@@ -1431,23 +1437,33 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
|
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent")
|
|
||||||
check.Color = accentColor
|
|
||||||
return check.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
||||||
}),
|
}),
|
||||||
|
|||||||
+27
-19
@@ -928,33 +928,29 @@ func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) handleApprovalClicks(gtx layout.Context) {
|
func (u *ui) handleApprovalClicks(gtx layout.Context) {
|
||||||
for u.allowApproval.Clicked(gtx) {
|
for u.allowApprovalOnce.Clicked(gtx) {
|
||||||
u.runAction("allow API request", func() error {
|
u.runAction("allow API request", func() error {
|
||||||
outcome := apiapproval.OutcomeAllowOnce
|
return u.resolvePendingApproval(apiapproval.OutcomeAllowOnce)
|
||||||
if u.approvalPermanent.Value {
|
|
||||||
outcome = apiapproval.OutcomeAllowPermanent
|
|
||||||
}
|
|
||||||
err := u.resolvePendingApproval(outcome)
|
|
||||||
u.approvalPermanent.Value = false
|
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for u.denyApproval.Clicked(gtx) {
|
for u.allowApprovalPermanent.Clicked(gtx) {
|
||||||
|
u.runAction("allow API request permanently", func() error {
|
||||||
|
return u.resolvePendingApproval(apiapproval.OutcomeAllowPermanent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for u.denyApprovalOnce.Clicked(gtx) {
|
||||||
u.runAction("deny API request", func() error {
|
u.runAction("deny API request", func() error {
|
||||||
outcome := apiapproval.OutcomeDenyOnce
|
return u.resolvePendingApproval(apiapproval.OutcomeDenyOnce)
|
||||||
if u.approvalPermanent.Value {
|
})
|
||||||
outcome = apiapproval.OutcomeDenyPermanent
|
}
|
||||||
}
|
for u.denyApprovalPermanent.Clicked(gtx) {
|
||||||
err := u.resolvePendingApproval(outcome)
|
u.runAction("deny API request permanently", func() error {
|
||||||
u.approvalPermanent.Value = false
|
return u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent)
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for u.cancelApproval.Clicked(gtx) {
|
for u.cancelApproval.Clicked(gtx) {
|
||||||
u.runAction("cancel API request", func() error {
|
u.runAction("cancel API request", func() error {
|
||||||
err := u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
return u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
||||||
u.approvalPermanent.Value = false
|
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -996,6 +992,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.addAPIPolicyRule.Clicked(gtx) {
|
for u.addAPIPolicyRule.Clicked(gtx) {
|
||||||
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
||||||
}
|
}
|
||||||
|
for u.saveAPIPolicyRule.Clicked(gtx) {
|
||||||
|
u.runAction("save API policy rule", u.saveAPIPolicyRuleAction)
|
||||||
|
}
|
||||||
|
for u.cancelAPIPolicyEdit.Clicked(gtx) {
|
||||||
|
u.runAction("cancel API policy edit", u.cancelAPIPolicyEditAction)
|
||||||
|
}
|
||||||
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
||||||
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
||||||
}
|
}
|
||||||
@@ -1005,6 +1007,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
||||||
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
||||||
}
|
}
|
||||||
|
for i := range u.apiPolicyEdits {
|
||||||
|
for u.apiPolicyEdits[i].Clicked(gtx) {
|
||||||
|
index := i
|
||||||
|
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := range u.apiPolicyRemoves {
|
for i := range u.apiPolicyRemoves {
|
||||||
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
||||||
index := i
|
index := i
|
||||||
|
|||||||
+159
-1
@@ -754,6 +754,23 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|||||||
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u.apiTokenName.SetText("Browser Extension Updated")
|
||||||
|
u.apiTokenClientName.SetText("firefox-desktop")
|
||||||
|
u.apiTokenExpiresAt.SetText("2026-05-01T00:00:00Z")
|
||||||
|
if err := u.saveAPITokenAction(); err != nil {
|
||||||
|
t.Fatalf("saveAPITokenAction() error = %v", err)
|
||||||
|
}
|
||||||
|
updated, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedAPIToken() ok = false, want true after save")
|
||||||
|
}
|
||||||
|
if updated.Name != "Browser Extension Updated" || updated.ClientName != "firefox-desktop" {
|
||||||
|
t.Fatalf("updated token = %#v, want renamed/firefox-desktop", updated)
|
||||||
|
}
|
||||||
|
if updated.ExpiresAt == nil || updated.ExpiresAt.UTC().Format(time.RFC3339) != "2026-05-01T00:00:00Z" {
|
||||||
|
t.Fatalf("updated.ExpiresAt = %#v, want 2026-05-01T00:00:00Z", updated.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
if err := u.disableAPITokenAction(); err != nil {
|
if err := u.disableAPITokenAction(); err != nil {
|
||||||
t.Fatalf("disableAPITokenAction() error = %v", err)
|
t.Fatalf("disableAPITokenAction() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -761,9 +778,17 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|||||||
if !ok || !disabled.Disabled {
|
if !ok || !disabled.Disabled {
|
||||||
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := u.revokeAPITokenAction(); err != nil {
|
||||||
|
t.Fatalf("revokeAPITokenAction() error = %v", err)
|
||||||
|
}
|
||||||
|
revoked, ok := u.selectedAPIToken()
|
||||||
|
if !ok || revoked.RevokedAt == nil {
|
||||||
|
t.Fatalf("selectedAPIToken() = %#v, want revoked token", revoked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
@@ -799,6 +824,33 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
|||||||
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
||||||
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
||||||
}
|
}
|
||||||
|
if len(u.apiPolicyEdits) != 1 {
|
||||||
|
t.Fatalf("len(apiPolicyEdits) = %d, want 1", len(u.apiPolicyEdits))
|
||||||
|
}
|
||||||
|
|
||||||
|
u.apiPolicyEdits[0].Click()
|
||||||
|
u.handleAPIPolicyClicks(layout.Context{})
|
||||||
|
if u.selectedAPIPolicyIndex != 0 {
|
||||||
|
t.Fatalf("selectedAPIPolicyIndex = %d, want 0 after edit click", u.selectedAPIPolicyIndex)
|
||||||
|
}
|
||||||
|
if got := u.apiPolicyPath.Text(); got != "Root / Internet" {
|
||||||
|
t.Fatalf("apiPolicyPath = %q, want Root / Internet after edit load", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.apiPolicyPath.SetText("Root / Security")
|
||||||
|
u.saveAPIPolicyRule.Click()
|
||||||
|
u.handleAPIPolicyClicks(layout.Context{})
|
||||||
|
|
||||||
|
token, ok = u.selectedAPIToken()
|
||||||
|
if !ok || len(token.Policies) != 1 {
|
||||||
|
t.Fatalf("selectedAPIToken().Policies after save = %#v, want 1 rule", token.Policies)
|
||||||
|
}
|
||||||
|
if got := strings.Join(token.Policies[0].Resource.Path, " / "); got != "Root / Security" {
|
||||||
|
t.Fatalf("updated policy path = %q, want Root / Security", got)
|
||||||
|
}
|
||||||
|
if u.selectedAPIPolicyIndex != -1 {
|
||||||
|
t.Fatalf("selectedAPIPolicyIndex after save = %d, want -1", u.selectedAPIPolicyIndex)
|
||||||
|
}
|
||||||
|
|
||||||
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
||||||
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
||||||
@@ -4858,6 +4910,112 @@ func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIApprovalDialogVisibleForPendingRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationReadEntry,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
EntryID: "vault-console",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
dims := u.approvalDialogContent(layout.Context{
|
||||||
|
Ops: new(op.Ops),
|
||||||
|
Constraints: layout.Exact(image.Pt(800, 600)),
|
||||||
|
})
|
||||||
|
if dims.Size.X == 0 || dims.Size.Y == 0 {
|
||||||
|
t.Fatalf("approvalDialogContent() = %v, want visible dimensions for pending approval", dims.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIApprovalButtonsResolveAllOutcomes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
click func(*ui)
|
||||||
|
want apiapproval.Outcome
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "allow once",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.allowApprovalOnce.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeAllowOnce,
|
||||||
|
wantMsg: "allow API request complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow permanently",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.allowApprovalPermanent.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeAllowPermanent,
|
||||||
|
wantMsg: "allow API request permanently complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny once",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.denyApprovalOnce.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeDenyOnce,
|
||||||
|
wantMsg: "deny API request complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny permanently",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.denyApprovalPermanent.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeDenyPermanent,
|
||||||
|
wantMsg: "deny API request permanently complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.cancelApproval.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeCancel,
|
||||||
|
wantMsg: "cancel API request complete",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationReadEntry,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = manager
|
||||||
|
|
||||||
|
tt.click(u)
|
||||||
|
u.handleApprovalClicks(layout.Context{})
|
||||||
|
|
||||||
|
if manager.lastID != "approval-1" || manager.lastOutcome != tt.want {
|
||||||
|
t.Fatalf("handleApprovalClicks() delegated (%q, %q), want (approval-1, %q)", manager.lastID, manager.lastOutcome, tt.want)
|
||||||
|
}
|
||||||
|
if got := u.state.StatusMessage; got != tt.wantMsg {
|
||||||
|
t.Fatalf("state.StatusMessage = %q, want %q", got, tt.wantMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -56,10 +57,7 @@ func Main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultGRPCAddr(goos string) string {
|
func defaultGRPCAddr(goos string) string {
|
||||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
return grpcaddr.Default(goos)
|
||||||
return "off"
|
|
||||||
}
|
|
||||||
return "127.0.0.1:47777"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||||
@@ -75,8 +73,10 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
} else if host != nil {
|
} else if host != nil {
|
||||||
ui.apiHost = host
|
ui.apiHost = host
|
||||||
ui.auditLog = host.Server().AuditLog()
|
ui.auditLog = host.Server().AuditLog()
|
||||||
|
ui.state.AuditLog = ui.auditLog
|
||||||
ui.grpcAddress = host.Address()
|
ui.grpcAddress = host.Address()
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
|
host.Server().ApprovalBroker().SetChangeNotifier(ui.invalidate)
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NativeHostName = "com.keepassgo.browser"
|
||||||
|
defaultFirefoxID = "browser@keepassgo.com"
|
||||||
|
maxNativeMessageSize = 1024 * 1024
|
||||||
|
chromiumIDBytes = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
type Request struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||||
|
BearerToken string `json:"bearerToken,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
EntryID string `json:"entryId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Status *Status `json:"status,omitempty"`
|
||||||
|
Matches []Match `json:"matches,omitempty"`
|
||||||
|
Credential *Credential `json:"credential,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Locked bool `json:"locked"`
|
||||||
|
Dirty bool `json:"dirty,omitempty"`
|
||||||
|
EntryCount uint32 `json:"entryCount,omitempty"`
|
||||||
|
GRPCAddress string `json:"grpcAddress,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Match struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
Path []string `json:"path,omitempty"`
|
||||||
|
Quality string `json:"quality,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credential struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Connection struct {
|
||||||
|
GRPCAddress string
|
||||||
|
BearerToken string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client interface {
|
||||||
|
Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error)
|
||||||
|
FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error)
|
||||||
|
GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Browser string
|
||||||
|
|
||||||
|
const (
|
||||||
|
BrowserFirefox Browser = "firefox"
|
||||||
|
BrowserChrome Browser = "chrome"
|
||||||
|
BrowserChromium Browser = "chromium"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NativeHostManifest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
AllowedExtensions []string `json:"allowed_extensions,omitempty"`
|
||||||
|
AllowedOrigins []string `json:"allowed_origins,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultFirefoxExtensionID() string {
|
||||||
|
return defaultFirefoxID
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadRequest(r io.Reader) (Request, error) {
|
||||||
|
var sizeBuf [4]byte
|
||||||
|
if _, err := io.ReadFull(r, sizeBuf[:]); err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
size := binary.LittleEndian.Uint32(sizeBuf[:])
|
||||||
|
if size == 0 || size > maxNativeMessageSize {
|
||||||
|
return Request{}, fmt.Errorf("invalid native message size %d", size)
|
||||||
|
}
|
||||||
|
body := make([]byte, size)
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return Request{}, err
|
||||||
|
}
|
||||||
|
var req Request
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
return Request{}, fmt.Errorf("decode native request: %w", err)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteResponse(w io.Writer, resp Response) error {
|
||||||
|
data, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encode native response: %w", err)
|
||||||
|
}
|
||||||
|
if len(data) > maxNativeMessageSize {
|
||||||
|
return fmt.Errorf("native response too large: %d", len(data))
|
||||||
|
}
|
||||||
|
var sizeBuf [4]byte
|
||||||
|
binary.LittleEndian.PutUint32(sizeBuf[:], uint32(len(data)))
|
||||||
|
if _, err := w.Write(sizeBuf[:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r Request) Connection() (Connection, error) {
|
||||||
|
conn := Connection{
|
||||||
|
GRPCAddress: strings.TrimSpace(r.GRPCAddress),
|
||||||
|
BearerToken: strings.TrimSpace(r.BearerToken),
|
||||||
|
}
|
||||||
|
if conn.GRPCAddress == "" {
|
||||||
|
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||||
|
}
|
||||||
|
if conn.BearerToken == "" {
|
||||||
|
return Connection{}, fmt.Errorf("browser bridge bearer token is required")
|
||||||
|
}
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleRequest(ctx context.Context, req Request, client Client) Response {
|
||||||
|
conn, err := req.Connection()
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error()}
|
||||||
|
}
|
||||||
|
action := strings.TrimSpace(req.Action)
|
||||||
|
switch action {
|
||||||
|
case "status":
|
||||||
|
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Version: "1"}
|
||||||
|
case "find-logins":
|
||||||
|
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
if status.Locked {
|
||||||
|
return Response{Success: true, Status: status, Matches: nil, Version: "1"}
|
||||||
|
}
|
||||||
|
matches, err := findMatches(ctx, client, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Matches: matches, Version: "1"}
|
||||||
|
case "get-login":
|
||||||
|
status, err := statusResponse(ctx, client, conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: disconnectedStatus(conn.GRPCAddress)}
|
||||||
|
}
|
||||||
|
if status.Locked {
|
||||||
|
return Response{Success: false, Error: "vault is locked", Status: status}
|
||||||
|
}
|
||||||
|
credential, err := loadCredential(ctx, client, req.EntryID, req.URL)
|
||||||
|
if err != nil {
|
||||||
|
return Response{Success: false, Error: err.Error(), Status: status}
|
||||||
|
}
|
||||||
|
return Response{Success: true, Status: status, Credential: credential, Version: "1"}
|
||||||
|
default:
|
||||||
|
return Response{Success: false, Error: fmt.Sprintf("unsupported action %q", action)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func disconnectedStatus(addr string) *Status {
|
||||||
|
return &Status{Connected: false, GRPCAddress: strings.TrimSpace(addr)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func statusResponse(ctx context.Context, client Client, addr string) (*Status, error) {
|
||||||
|
resp, err := client.Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Status{
|
||||||
|
Connected: true,
|
||||||
|
Locked: resp.GetLocked(),
|
||||||
|
Dirty: resp.GetDirty(),
|
||||||
|
EntryCount: resp.GetEntryCount(),
|
||||||
|
GRPCAddress: strings.TrimSpace(addr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMatches(ctx context.Context, client Client, rawURL string) ([]Match, error) {
|
||||||
|
resp, err := client.FindBrowserLogins(ctx, strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]Match, 0, len(resp))
|
||||||
|
for _, match := range resp {
|
||||||
|
out = append(out, Match{
|
||||||
|
ID: match.GetId(),
|
||||||
|
Title: match.GetTitle(),
|
||||||
|
Username: match.GetUsername(),
|
||||||
|
URL: match.GetUrl(),
|
||||||
|
Path: append([]string(nil), match.GetPath()...),
|
||||||
|
Quality: match.GetQuality(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadCredential(ctx context.Context, client Client, entryID, rawURL string) (*Credential, error) {
|
||||||
|
id := strings.TrimSpace(entryID)
|
||||||
|
if id == "" {
|
||||||
|
return nil, fmt.Errorf("entry id is required")
|
||||||
|
}
|
||||||
|
resp, err := client.GetBrowserCredential(ctx, id, strings.TrimSpace(rawURL))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Credential{
|
||||||
|
ID: resp.GetId(),
|
||||||
|
Username: resp.GetUsername(),
|
||||||
|
Password: resp.GetPassword(),
|
||||||
|
URL: resp.GetUrl(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Manifest(browser Browser, binaryPath, extensionID string) (NativeHostManifest, error) {
|
||||||
|
path := strings.TrimSpace(binaryPath)
|
||||||
|
if path == "" {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("native host binary path is required")
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserFirefox:
|
||||||
|
id := strings.TrimSpace(extensionID)
|
||||||
|
if id == "" {
|
||||||
|
id = defaultFirefoxID
|
||||||
|
}
|
||||||
|
return NativeHostManifest{
|
||||||
|
Name: NativeHostName,
|
||||||
|
Description: "KeePassGO browser bridge",
|
||||||
|
Path: path,
|
||||||
|
Type: "stdio",
|
||||||
|
AllowedExtensions: []string{id},
|
||||||
|
}, nil
|
||||||
|
case BrowserChrome, BrowserChromium:
|
||||||
|
id := strings.TrimSpace(extensionID)
|
||||||
|
if id == "" {
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("%s extension id is required", browser)
|
||||||
|
}
|
||||||
|
return NativeHostManifest{
|
||||||
|
Name: NativeHostName,
|
||||||
|
Description: "KeePassGO browser bridge",
|
||||||
|
Path: path,
|
||||||
|
Type: "stdio",
|
||||||
|
AllowedOrigins: []string{"chrome-extension://" + id + "/"},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return NativeHostManifest{}, fmt.Errorf("unsupported browser %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChromiumExtensionIDFromManifestKey(raw string) (string, error) {
|
||||||
|
normalized := strings.TrimSpace(raw)
|
||||||
|
normalized = strings.ReplaceAll(normalized, "-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "-----END PUBLIC KEY-----", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\n", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\r", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, "\t", "")
|
||||||
|
normalized = strings.ReplaceAll(normalized, " ", "")
|
||||||
|
if normalized == "" {
|
||||||
|
return "", fmt.Errorf("chromium extension key is required")
|
||||||
|
}
|
||||||
|
publicKeyDER, err := base64.StdEncoding.DecodeString(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode chromium extension key: %w", err)
|
||||||
|
}
|
||||||
|
hash := sha256.Sum256(publicKeyDER)
|
||||||
|
var builder strings.Builder
|
||||||
|
builder.Grow(chromiumIDBytes * 2)
|
||||||
|
for _, b := range hash[:chromiumIDBytes] {
|
||||||
|
builder.WriteByte('a' + ((b >> 4) & 0x0f))
|
||||||
|
builder.WriteByte('a' + (b & 0x0f))
|
||||||
|
}
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultManifestPath(browser Browser) (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch browser {
|
||||||
|
case BrowserFirefox:
|
||||||
|
return filepath.Join(home, ".mozilla", "native-messaging-hosts", NativeHostName+".json"), nil
|
||||||
|
case BrowserChrome:
|
||||||
|
return filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||||
|
case BrowserChromium:
|
||||||
|
return filepath.Join(home, ".config", "chromium", "NativeMessagingHosts", NativeHostName+".json"), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported browser %q", browser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func InstallManifest(browser Browser, binaryPath, extensionID, outputPath string) (string, error) {
|
||||||
|
manifest, err := Manifest(browser, binaryPath, extensionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := strings.TrimSpace(outputPath)
|
||||||
|
if path == "" {
|
||||||
|
path, err = DefaultManifestPath(browser)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("create native host manifest dir: %w", err)
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("encode native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write native host manifest: %w", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadRequestAndWriteResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var input bytes.Buffer
|
||||||
|
body, err := json.Marshal(Request{
|
||||||
|
Action: "find-logins",
|
||||||
|
GRPCAddress: "127.0.0.1:47777",
|
||||||
|
BearerToken: "secret",
|
||||||
|
URL: "https://example.invalid/login",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Marshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := binary.Write(&input, binary.LittleEndian, uint32(len(body))); err != nil {
|
||||||
|
t.Fatalf("binary.Write() error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := input.Write(body); err != nil {
|
||||||
|
t.Fatalf("Write() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := ReadRequest(&input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadRequest() error = %v", err)
|
||||||
|
}
|
||||||
|
if req.Action != "find-logins" || req.BearerToken != "secret" {
|
||||||
|
t.Fatalf("ReadRequest() = %#v, want action and token preserved", req)
|
||||||
|
}
|
||||||
|
if conn, err := req.Connection(); err != nil || conn.GRPCAddress != "127.0.0.1:47777" {
|
||||||
|
t.Fatalf("req.Connection() = (%#v, %v), want explicit tcp address preserved", conn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := WriteResponse(&output, Response{Success: true, Version: "1"}); err != nil {
|
||||||
|
t.Fatalf("WriteResponse() error = %v", err)
|
||||||
|
}
|
||||||
|
var size uint32
|
||||||
|
if err := binary.Read(&output, binary.LittleEndian, &size); err != nil {
|
||||||
|
t.Fatalf("binary.Read() error = %v", err)
|
||||||
|
}
|
||||||
|
payload := make([]byte, size)
|
||||||
|
if _, err := output.Read(payload); err != nil {
|
||||||
|
t.Fatalf("Read() payload error = %v", err)
|
||||||
|
}
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if !resp.Success || resp.Version != "1" {
|
||||||
|
t.Fatalf("response = %#v, want success version 1", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestFindLogins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := fakeClient{
|
||||||
|
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 2},
|
||||||
|
matches: []*keepassgov1.BrowserLoginMatch{
|
||||||
|
{Id: "vault-console", Title: "Vault Console", Username: "dannyocean", Url: "https://vault.example.invalid", Quality: "exact-host"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "find-logins",
|
||||||
|
BearerToken: "secret",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if len(resp.Matches) != 1 || resp.Matches[0].ID != "vault-console" {
|
||||||
|
t.Fatalf("HandleRequest().Matches = %#v, want vault-console", resp.Matches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestGetLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := fakeClient{
|
||||||
|
status: &keepassgov1.GetSessionStatusResponse{Locked: false, EntryCount: 1},
|
||||||
|
credential: &keepassgov1.GetBrowserCredentialResponse{
|
||||||
|
Id: "vault-console",
|
||||||
|
Username: "dannyocean",
|
||||||
|
Password: "token-1",
|
||||||
|
Url: "https://vault.example.invalid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resp := HandleRequest(context.Background(), Request{
|
||||||
|
Action: "get-login",
|
||||||
|
BearerToken: "secret",
|
||||||
|
EntryID: "vault-console",
|
||||||
|
URL: "https://vault.example.invalid/login",
|
||||||
|
}, client)
|
||||||
|
if !resp.Success {
|
||||||
|
t.Fatalf("HandleRequest() success = false, error = %q", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Credential == nil || resp.Credential.ID != "vault-console" {
|
||||||
|
t.Fatalf("HandleRequest().Credential = %#v, want vault-console", resp.Credential)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleRequestRequiresBearerToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
resp := HandleRequest(context.Background(), Request{Action: "status"}, fakeClient{})
|
||||||
|
if resp.Success {
|
||||||
|
t.Fatal("HandleRequest().Success = true, want false without token")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestConnectionDefaultsAddress(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := Request{Action: "status", BearerToken: "secret"}
|
||||||
|
conn, err := req.Connection()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connection() error = %v", err)
|
||||||
|
}
|
||||||
|
if conn.GRPCAddress == "" {
|
||||||
|
t.Fatal("Connection().GRPCAddress = empty, want default address")
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" && !strings.HasPrefix(conn.GRPCAddress, "unix://") && conn.GRPCAddress != "off" {
|
||||||
|
t.Fatalf("Connection().GRPCAddress = %q, want unix socket default on this platform", conn.GRPCAddress)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallManifest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tmp := t.TempDir()
|
||||||
|
binaryPath := filepath.Join(tmp, "keepassgo-browser-bridge")
|
||||||
|
if err := os.WriteFile(binaryPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("WriteFile(binary) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := InstallManifest(BrowserFirefox, binaryPath, "", filepath.Join(tmp, "firefox-host.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InstallManifest() error = %v", err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile() error = %v", err)
|
||||||
|
}
|
||||||
|
var manifest NativeHostManifest
|
||||||
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
if manifest.Path != binaryPath {
|
||||||
|
t.Fatalf("manifest.Path = %q, want %q", manifest.Path, binaryPath)
|
||||||
|
}
|
||||||
|
if len(manifest.AllowedExtensions) != 1 || manifest.AllowedExtensions[0] != DefaultFirefoxExtensionID() {
|
||||||
|
t.Fatalf("manifest.AllowedExtensions = %#v, want default firefox extension id", manifest.AllowedExtensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChromiumExtensionIDFromManifestKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAMfW0u1k4K5A0uN2s0aH7uQKpM3x5Hf8mZfY1xVh0m7E2mJ7M8GiV4m0g0I2w9U9D1yqGQ6w8jzH5v8t7qB2RjMCAwEAAQ=="
|
||||||
|
got, err := ChromiumExtensionIDFromManifestKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ChromiumExtensionIDFromManifestKey() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "okcdfigpojphpoecpglkkmkjmiaefmpd" {
|
||||||
|
t.Fatalf("ChromiumExtensionIDFromManifestKey() = %q, want %q", got, "okcdfigpojphpoecpglkkmkjmiaefmpd")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeClient struct {
|
||||||
|
status *keepassgov1.GetSessionStatusResponse
|
||||||
|
matches []*keepassgov1.BrowserLoginMatch
|
||||||
|
credential *keepassgov1.GetBrowserCredentialResponse
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeClient) Status(context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
if f.status == nil {
|
||||||
|
return &keepassgov1.GetSessionStatusResponse{}, nil
|
||||||
|
}
|
||||||
|
return f.status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeClient) FindBrowserLogins(context.Context, string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
return f.matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f fakeClient) GetBrowserCredential(context.Context, string, string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
if f.credential == nil {
|
||||||
|
return &keepassgov1.GetBrowserCredentialResponse{}, nil
|
||||||
|
}
|
||||||
|
return f.credential, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package browserbridge
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/grpcaddr"
|
||||||
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GRPCClient struct {
|
||||||
|
client keepassgov1.VaultServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dial(ctx context.Context, conn Connection) (*grpc.ClientConn, *GRPCClient, context.Context, error) {
|
||||||
|
if strings.TrimSpace(conn.GRPCAddress) == "" {
|
||||||
|
conn.GRPCAddress = grpcaddr.Default(runtime.GOOS)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(conn.BearerToken) == "" {
|
||||||
|
return nil, nil, nil, fmt.Errorf("browser bridge bearer token is required")
|
||||||
|
}
|
||||||
|
network, endpoint, err := grpcaddr.Parse(conn.GRPCAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, err
|
||||||
|
}
|
||||||
|
target := endpoint
|
||||||
|
if network == "unix" {
|
||||||
|
target = "passthrough:///" + endpoint
|
||||||
|
}
|
||||||
|
grpcConn, err := grpc.NewClient(target,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, endpoint)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, fmt.Errorf("dial gRPC host %s: %w", strings.TrimSpace(conn.GRPCAddress), err)
|
||||||
|
}
|
||||||
|
ctx = metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer "+strings.TrimSpace(conn.BearerToken))
|
||||||
|
return grpcConn, &GRPCClient{client: keepassgov1.NewVaultServiceClient(grpcConn)}, ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) Status(ctx context.Context) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
return c.client.GetSessionStatus(ctx, &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) FindBrowserLogins(ctx context.Context, pageURL string) ([]*keepassgov1.BrowserLoginMatch, error) {
|
||||||
|
resp, err := c.client.FindBrowserLogins(ctx, &keepassgov1.FindBrowserLoginsRequest{
|
||||||
|
PageUrl: strings.TrimSpace(pageURL),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return resp.GetMatches(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *GRPCClient) GetBrowserCredential(ctx context.Context, entryID, pageURL string) (*keepassgov1.GetBrowserCredentialResponse, error) {
|
||||||
|
return c.client.GetBrowserCredential(ctx, &keepassgov1.GetBrowserCredentialRequest{
|
||||||
|
Id: strings.TrimSpace(entryID),
|
||||||
|
PageUrl: strings.TrimSpace(pageURL),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package grpcaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const socketName = "keepassgo-grpc.sock"
|
||||||
|
|
||||||
|
func Default(goos string) string {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(goos), "windows") {
|
||||||
|
return "127.0.0.1:47777"
|
||||||
|
}
|
||||||
|
return "unix://" + DefaultSocketPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DefaultSocketPath() string {
|
||||||
|
return filepath.Join(runtimeDir(), "keepassgo", socketName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeDir() string {
|
||||||
|
if dir := strings.TrimSpace(os.Getenv("XDG_RUNTIME_DIR")); dir != "" {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
uid := strconv.Itoa(os.Getuid())
|
||||||
|
runUserDir := filepath.Join("/run/user", uid)
|
||||||
|
if info, err := os.Stat(runUserDir); err == nil && info.IsDir() {
|
||||||
|
return runUserDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filepath.Join(os.TempDir(), fmt.Sprintf("keepassgo-runtime-%d", os.Getuid()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(raw string) (network, endpoint string, err error) {
|
||||||
|
value := strings.TrimSpace(raw)
|
||||||
|
switch {
|
||||||
|
case value == "":
|
||||||
|
return "", "", fmt.Errorf("gRPC address is required")
|
||||||
|
case strings.EqualFold(value, "off"):
|
||||||
|
return "", "", nil
|
||||||
|
case strings.HasPrefix(value, "unix://"):
|
||||||
|
path := strings.TrimSpace(strings.TrimPrefix(value, "unix://"))
|
||||||
|
if path == "" {
|
||||||
|
return "", "", fmt.Errorf("unix gRPC socket path is required")
|
||||||
|
}
|
||||||
|
return "unix", path, nil
|
||||||
|
case strings.HasPrefix(value, "tcp://"):
|
||||||
|
addr := strings.TrimSpace(strings.TrimPrefix(value, "tcp://"))
|
||||||
|
if addr == "" {
|
||||||
|
return "", "", fmt.Errorf("tcp gRPC address is required")
|
||||||
|
}
|
||||||
|
return "tcp", addr, nil
|
||||||
|
case strings.HasPrefix(value, "/"):
|
||||||
|
return "unix", value, nil
|
||||||
|
default:
|
||||||
|
return "tcp", value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package grpcaddr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultUsesUnixSocketOnUnixLikeSystems(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("unix default is not expected on windows")
|
||||||
|
}
|
||||||
|
t.Setenv("XDG_RUNTIME_DIR", "/tmp/keepassgo-runtime-test")
|
||||||
|
|
||||||
|
got := Default("linux")
|
||||||
|
want := "unix:///tmp/keepassgo-runtime-test/keepassgo/keepassgo-grpc.sock"
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("Default() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
wantNetwork string
|
||||||
|
wantEnd string
|
||||||
|
}{
|
||||||
|
{name: "unix scheme", input: "unix:///tmp/keepassgo.sock", wantNetwork: "unix", wantEnd: "/tmp/keepassgo.sock"},
|
||||||
|
{name: "tcp scheme", input: "tcp://127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||||
|
{name: "bare path", input: filepath.Clean("/tmp/keepassgo.sock"), wantNetwork: "unix", wantEnd: filepath.Clean("/tmp/keepassgo.sock")},
|
||||||
|
{name: "bare tcp", input: "127.0.0.1:47777", wantNetwork: "tcp", wantEnd: "127.0.0.1:47777"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotNetwork, gotEnd, err := Parse(tt.input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
if gotNetwork != tt.wantNetwork || gotEnd != tt.wantEnd {
|
||||||
|
t.Fatalf("Parse() = (%q, %q), want (%q, %q)", gotNetwork, gotEnd, tt.wantNetwork, tt.wantEnd)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,12 +42,14 @@ build() {
|
|||||||
local app_version
|
local app_version
|
||||||
app_version="$(git describe --tags --always --dirty)"
|
app_version="$(git describe --tags --always --dirty)"
|
||||||
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo ./cmd/keepassgo
|
||||||
|
go build -ldflags "-X git.julianfamily.org/keepassgo/internal/appui.appVersion=${app_version}" -o keepassgo-browser-bridge ./cmd/keepassgo-browser-bridge
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$(_repo_dir)"
|
cd "$(_repo_dir)"
|
||||||
|
|
||||||
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
install -Dm755 keepassgo "${pkgdir}/usr/bin/keepassgo"
|
||||||
|
install -Dm755 keepassgo-browser-bridge "${pkgdir}/usr/bin/keepassgo-browser-bridge"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.png \
|
install -Dm644 internal/assets/keepassgo-icon.png \
|
||||||
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
"${pkgdir}/usr/share/icons/hicolor/512x512/apps/keepassgo.png"
|
||||||
install -Dm644 internal/assets/keepassgo-icon.svg \
|
install -Dm644 internal/assets/keepassgo-icon.svg \
|
||||||
|
|||||||
+575
-252
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ service VaultService {
|
|||||||
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
rpc SaveVault(SaveVaultRequest) returns (SaveVaultResponse);
|
||||||
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
rpc LockVault(LockVaultRequest) returns (LockVaultResponse);
|
||||||
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
rpc UnlockVault(UnlockVaultRequest) returns (UnlockVaultResponse);
|
||||||
|
rpc FindBrowserLogins(FindBrowserLoginsRequest) returns (FindBrowserLoginsResponse);
|
||||||
|
rpc GetBrowserCredential(GetBrowserCredentialRequest) returns (GetBrowserCredentialResponse);
|
||||||
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse);
|
||||||
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
rpc ListGroups(ListGroupsRequest) returns (ListGroupsResponse);
|
||||||
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
rpc CreateGroup(CreateGroupRequest) returns (CreateGroupResponse);
|
||||||
@@ -75,6 +77,35 @@ message UnlockVaultRequest {
|
|||||||
|
|
||||||
message UnlockVaultResponse {}
|
message UnlockVaultResponse {}
|
||||||
|
|
||||||
|
message FindBrowserLoginsRequest {
|
||||||
|
string page_url = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BrowserLoginMatch {
|
||||||
|
string id = 1;
|
||||||
|
string title = 2;
|
||||||
|
string username = 3;
|
||||||
|
string url = 4;
|
||||||
|
repeated string path = 5;
|
||||||
|
string quality = 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindBrowserLoginsResponse {
|
||||||
|
repeated BrowserLoginMatch matches = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetBrowserCredentialRequest {
|
||||||
|
string id = 1;
|
||||||
|
string page_url = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetBrowserCredentialResponse {
|
||||||
|
string id = 1;
|
||||||
|
string username = 2;
|
||||||
|
string password = 3;
|
||||||
|
string url = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message ListEntriesRequest {
|
message ListEntriesRequest {
|
||||||
repeated string path = 1;
|
repeated string path = 1;
|
||||||
string query = 2;
|
string query = 2;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// - protoc-gen-go-grpc v1.5.1
|
// - protoc-gen-go-grpc v1.5.1
|
||||||
// - protoc v6.33.1
|
// - protoc v7.34.1
|
||||||
// source: proto/keepassgo/v1/keepassgo.proto
|
// source: proto/keepassgo/v1/keepassgo.proto
|
||||||
|
|
||||||
package keepassgov1
|
package keepassgov1
|
||||||
@@ -19,32 +19,34 @@ import (
|
|||||||
const _ = grpc.SupportPackageIsVersion9
|
const _ = grpc.SupportPackageIsVersion9
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
|
VaultService_GetSessionStatus_FullMethodName = "/keepassgo.v1.VaultService/GetSessionStatus"
|
||||||
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
|
VaultService_OpenVault_FullMethodName = "/keepassgo.v1.VaultService/OpenVault"
|
||||||
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
|
VaultService_OpenRemoteVault_FullMethodName = "/keepassgo.v1.VaultService/OpenRemoteVault"
|
||||||
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
VaultService_SaveVault_FullMethodName = "/keepassgo.v1.VaultService/SaveVault"
|
||||||
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
VaultService_LockVault_FullMethodName = "/keepassgo.v1.VaultService/LockVault"
|
||||||
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
VaultService_UnlockVault_FullMethodName = "/keepassgo.v1.VaultService/UnlockVault"
|
||||||
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
VaultService_FindBrowserLogins_FullMethodName = "/keepassgo.v1.VaultService/FindBrowserLogins"
|
||||||
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
VaultService_GetBrowserCredential_FullMethodName = "/keepassgo.v1.VaultService/GetBrowserCredential"
|
||||||
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
VaultService_ListEntries_FullMethodName = "/keepassgo.v1.VaultService/ListEntries"
|
||||||
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
VaultService_ListGroups_FullMethodName = "/keepassgo.v1.VaultService/ListGroups"
|
||||||
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
|
VaultService_CreateGroup_FullMethodName = "/keepassgo.v1.VaultService/CreateGroup"
|
||||||
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
VaultService_RenameGroup_FullMethodName = "/keepassgo.v1.VaultService/RenameGroup"
|
||||||
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
VaultService_DeleteGroup_FullMethodName = "/keepassgo.v1.VaultService/DeleteGroup"
|
||||||
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
VaultService_UpsertEntry_FullMethodName = "/keepassgo.v1.VaultService/UpsertEntry"
|
||||||
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
|
VaultService_DeleteEntry_FullMethodName = "/keepassgo.v1.VaultService/DeleteEntry"
|
||||||
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
|
VaultService_RestoreEntry_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntry"
|
||||||
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
|
VaultService_ListEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/ListEntryHistory"
|
||||||
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
|
VaultService_RestoreEntryHistory_FullMethodName = "/keepassgo.v1.VaultService/RestoreEntryHistory"
|
||||||
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
|
VaultService_ListTemplates_FullMethodName = "/keepassgo.v1.VaultService/ListTemplates"
|
||||||
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
|
VaultService_UpsertTemplate_FullMethodName = "/keepassgo.v1.VaultService/UpsertTemplate"
|
||||||
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
|
VaultService_DeleteTemplate_FullMethodName = "/keepassgo.v1.VaultService/DeleteTemplate"
|
||||||
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
|
VaultService_InstantiateTemplate_FullMethodName = "/keepassgo.v1.VaultService/InstantiateTemplate"
|
||||||
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
|
VaultService_ListAttachments_FullMethodName = "/keepassgo.v1.VaultService/ListAttachments"
|
||||||
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
|
VaultService_UploadAttachment_FullMethodName = "/keepassgo.v1.VaultService/UploadAttachment"
|
||||||
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
|
VaultService_DownloadAttachment_FullMethodName = "/keepassgo.v1.VaultService/DownloadAttachment"
|
||||||
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
|
VaultService_DeleteAttachment_FullMethodName = "/keepassgo.v1.VaultService/DeleteAttachment"
|
||||||
|
VaultService_CopyEntryField_FullMethodName = "/keepassgo.v1.VaultService/CopyEntryField"
|
||||||
|
VaultService_GeneratePassword_FullMethodName = "/keepassgo.v1.VaultService/GeneratePassword"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VaultServiceClient is the client API for VaultService service.
|
// VaultServiceClient is the client API for VaultService service.
|
||||||
@@ -57,6 +59,8 @@ type VaultServiceClient interface {
|
|||||||
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
SaveVault(ctx context.Context, in *SaveVaultRequest, opts ...grpc.CallOption) (*SaveVaultResponse, error)
|
||||||
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
LockVault(ctx context.Context, in *LockVaultRequest, opts ...grpc.CallOption) (*LockVaultResponse, error)
|
||||||
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
|
UnlockVault(ctx context.Context, in *UnlockVaultRequest, opts ...grpc.CallOption) (*UnlockVaultResponse, error)
|
||||||
|
FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error)
|
||||||
|
GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error)
|
||||||
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error)
|
||||||
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
ListGroups(ctx context.Context, in *ListGroupsRequest, opts ...grpc.CallOption) (*ListGroupsResponse, error)
|
||||||
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
CreateGroup(ctx context.Context, in *CreateGroupRequest, opts ...grpc.CallOption) (*CreateGroupResponse, error)
|
||||||
@@ -147,6 +151,26 @@ func (c *vaultServiceClient) UnlockVault(ctx context.Context, in *UnlockVaultReq
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *vaultServiceClient) FindBrowserLogins(ctx context.Context, in *FindBrowserLoginsRequest, opts ...grpc.CallOption) (*FindBrowserLoginsResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(FindBrowserLoginsResponse)
|
||||||
|
err := c.cc.Invoke(ctx, VaultService_FindBrowserLogins_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *vaultServiceClient) GetBrowserCredential(ctx context.Context, in *GetBrowserCredentialRequest, opts ...grpc.CallOption) (*GetBrowserCredentialResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(GetBrowserCredentialResponse)
|
||||||
|
err := c.cc.Invoke(ctx, VaultService_GetBrowserCredential_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
|
func (c *vaultServiceClient) ListEntries(ctx context.Context, in *ListEntriesRequest, opts ...grpc.CallOption) (*ListEntriesResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(ListEntriesResponse)
|
out := new(ListEntriesResponse)
|
||||||
@@ -357,6 +381,8 @@ type VaultServiceServer interface {
|
|||||||
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
SaveVault(context.Context, *SaveVaultRequest) (*SaveVaultResponse, error)
|
||||||
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
LockVault(context.Context, *LockVaultRequest) (*LockVaultResponse, error)
|
||||||
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
|
UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error)
|
||||||
|
FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error)
|
||||||
|
GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error)
|
||||||
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error)
|
||||||
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
ListGroups(context.Context, *ListGroupsRequest) (*ListGroupsResponse, error)
|
||||||
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
CreateGroup(context.Context, *CreateGroupRequest) (*CreateGroupResponse, error)
|
||||||
@@ -405,6 +431,12 @@ func (UnimplementedVaultServiceServer) LockVault(context.Context, *LockVaultRequ
|
|||||||
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
|
func (UnimplementedVaultServiceServer) UnlockVault(context.Context, *UnlockVaultRequest) (*UnlockVaultResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method UnlockVault not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedVaultServiceServer) FindBrowserLogins(context.Context, *FindBrowserLoginsRequest) (*FindBrowserLoginsResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method FindBrowserLogins not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedVaultServiceServer) GetBrowserCredential(context.Context, *GetBrowserCredentialRequest) (*GetBrowserCredentialResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method GetBrowserCredential not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
func (UnimplementedVaultServiceServer) ListEntries(context.Context, *ListEntriesRequest) (*ListEntriesResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method ListEntries not implemented")
|
||||||
}
|
}
|
||||||
@@ -594,6 +626,42 @@ func _VaultService_UnlockVault_Handler(srv interface{}, ctx context.Context, dec
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _VaultService_FindBrowserLogins_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(FindBrowserLoginsRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(VaultServiceServer).FindBrowserLogins(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: VaultService_FindBrowserLogins_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(VaultServiceServer).FindBrowserLogins(ctx, req.(*FindBrowserLoginsRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _VaultService_GetBrowserCredential_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(GetBrowserCredentialRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(VaultServiceServer).GetBrowserCredential(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: VaultService_GetBrowserCredential_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(VaultServiceServer).GetBrowserCredential(ctx, req.(*GetBrowserCredentialRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _VaultService_ListEntries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(ListEntriesRequest)
|
in := new(ListEntriesRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -985,6 +1053,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "UnlockVault",
|
MethodName: "UnlockVault",
|
||||||
Handler: _VaultService_UnlockVault_Handler,
|
Handler: _VaultService_UnlockVault_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "FindBrowserLogins",
|
||||||
|
Handler: _VaultService_FindBrowserLogins_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "GetBrowserCredential",
|
||||||
|
Handler: _VaultService_GetBrowserCredential_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "ListEntries",
|
MethodName: "ListEntries",
|
||||||
Handler: _VaultService_ListEntries_Handler,
|
Handler: _VaultService_ListEntries_Handler,
|
||||||
|
|||||||
Reference in New Issue
Block a user