Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,12 @@ Loopback binding prevents devices on Wi-Fi, Ethernet, or the wider LAN from
connecting. It does not isolate the listener from other users or processes on
the same Mac; guest SSH authentication is still required.

### Touch ID for 1Password

An optional process-scoped integration can use the Mac's Touch ID to unlock
1Password inside the guest. Existing synced passwords and passkeys stay managed
by 1Password. See [setup and authorization boundaries](docs/onepassword-touch-id.md).

### Touch ID for sudo

The native authentication bridge can enroll this Mac and use
Expand Down
77 changes: 77 additions & 0 deletions docs/onepassword-touch-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Touch ID for 1Password in the guest

This opt-in integration unlocks 1Password for Linux using Touch ID on the Mac.
1Password continues to manage the vault and passkeys in the guest. It does not
forward credentials to the Mac or create a separate passkey store.

## Requirements and setup

Use a host helper with the `onepassword-unlock` operation, an already paired
Touch ID guest, and 1Password installed at `/opt/1Password/1password`. The guest
needs Python GObject bindings, GTK 3, and PolkitAgent introspection. The installer
checks these dependencies and does not install packages automatically.

Sign in to 1Password in the guest, enable **Unlock using system authentication**,
then lock and unlock once with the account password. Keep the app running.
1Password's own policy still requires the account password after app restart and
when its password-confirmation interval expires.

From this checkout in the guest, run:

```sh
sudo guest/scripts/install-onepassword-touch-id.sh "$USER"
```

Then lock 1Password and use its fingerprint button. Focus the VM while approving
the Mac's **Unlock 1Password in the focused Try Omarchy guest** prompt.
If Touch ID is denied or unavailable, a guest password dialog uses the standard
polkit PAM session. The 1Password account-password option remains available.

The fallback asks for the guest Linux user's password, not the 1Password account
password. It follows the guest's normal PAM lockout policy. Repeated failed or
abandoned authentication attempts can temporarily block this path even while the
1Password account password still works. Check the guest's authentication journal
and `faillock --user "$USER"` before retrying repeatedly; this integration does
not disable or bypass password lockouts.

Disable the integration without changing PAM or deleting enrollment:

```sh
sudo systemctl disable --now "try-omarchy-onepassword-touch-id@$USER.service"
```

The installer retains replaced files in a root-private directory under
`/var/lib/try-omarchy/onepassword-backup.*`. A host update and guest installation
are both required. Updating only the host does not update an existing guest.

## Authorization boundary

A root-owned agent registers with polkit for the main, installed 1Password
process belonging to the configured guest account. It does not replace the
session-wide agent. Executable ownership, permissions, process start time,
UID, and the active local display session are checked. The installed executable
and its parent directories must be root-owned and not group/other-writable.

Only `BeginAuthentication` from the current system-bus owner of polkit is
accepted. Only `com.1password.1Password.unlock` with the exact guest-user
identity can request Touch ID. CLI, SSH-agent, and other authentication requests
for that process use the normal PAM password path in an unprivileged GTK dialog.
Other processes remain with their existing desktop authentication agents.

The Mac signs a versioned, nonce-bearing request with the dedicated unlock
operation, service, and matching user identities. The guest verifies the pinned
key, signature, request binding, and expiry, then rechecks process, session, and
cancellation before responding to polkit's original cookie. The cookie is not
sent to the Mac. Passwords are handled only by the unprivileged dialog and the
standard polkit authentication helper; a successful dialog exit cannot itself
create an authorization response.

A nonblocking exclusive lock serializes requests on the shared authentication
port. Contention uses password fallback. Closing or disabling the agent removes
its process registrations; the desktop agent remains intact. No blanket `YES`
polkit rules or shared PAM changes are installed.

This is a root-trusting VM design: a compromised root user can change the guest's
authentication policy. Touch ID approval does not attest the guest application or
make a compromised guest trustworthy. Passkeys remain subject to 1Password's
normal guest-side security boundary.
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
[Unit]
Description=Touch ID unlock for 1Password in Try Omarchy (%i)
After=polkit.service systemd-logind.service
Requires=polkit.service
ConditionPathExists=/dev/virtio-ports/dev.tryomarchy.authentication
ConditionPathExists=/var/lib/try-omarchy/native-authentication.json

[Service]
Type=simple
ExecStart=/usr/local/lib/try-omarchy/onepassword-touch-id-agent --user %i
Restart=on-failure
RestartSec=5
TimeoutStopSec=5
UMask=0077

[Install]
WantedBy=multi-user.target
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
#!/usr/bin/python3 -I
"""Root-only broker for signed Try Omarchy Touch ID sudo approvals."""
"""Root-only broker for signed Try Omarchy authentication approvals."""

from __future__ import annotations

import base64
import binascii
import hashlib
import fcntl
import json
import os
from pathlib import Path
Expand Down Expand Up @@ -97,14 +98,14 @@ def validate_request(request: dict[str, object]) -> None:
set(request) != REQUEST_FIELDS
or request.get("type") != "authorize"
or request.get("version") != PROTOCOL_VERSION
or request.get("operation") not in {"disable", "enroll", "sudo"}
or request.get("service") != "sudo"
or request.get("operation") not in {"disable", "enroll", "sudo", "onepassword-unlock"}
or not isinstance(request.get("requestId"), str)
or not isinstance(request.get("challenge"), str)
or not isinstance(request.get("guestId"), str)
or not isinstance(request.get("user"), str)
or not isinstance(request.get("requestingUser"), str)
or not isinstance(request.get("tty"), str)
or not isinstance(request.get("service"), str)
):
raise AuthorizationError("invalid authentication request")
try:
Expand All @@ -117,6 +118,15 @@ def validate_request(request: dict[str, object]) -> None:
or not HEX_32_PATTERN.fullmatch(request["guestId"])
):
raise AuthorizationError("invalid request identity")
if request["operation"] == "onepassword-unlock":
if (request["service"] != "com.1password.1Password.unlock"
or not ACCOUNT_PATTERN.fullmatch(request["user"])
or request["requestingUser"] != request["user"]
or request["tty"]):
raise AuthorizationError("invalid 1Password unlock context")
return
if request["service"] != "sudo":
raise AuthorizationError("invalid sudo authentication service")
if request["operation"] in {"disable", "enroll"}:
if request["user"] or request["requestingUser"] or request["tty"]:
raise AuthorizationError("control request carries sudo context")
Expand Down Expand Up @@ -392,6 +402,10 @@ def exchange(request: dict[str, object], timeout: float = 65) -> dict[str, objec
or stat.S_IMODE(info.st_mode) != 0o600
):
raise AuthorizationError("authentication endpoint is not a root-only character device")
try:
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as error:
raise AuthorizationError("another Touch ID request is in progress") from error
return exchange_on_descriptor(descriptor, request, timeout)
finally:
os.close(descriptor)
Expand Down Expand Up @@ -620,7 +634,26 @@ def authenticate_pam() -> bool:
return False


def authenticate_onepassword(user: str) -> None:
if os.getuid() != 0 or os.geteuid() != 0:
raise AuthorizationError("1Password authorization requires the root agent")
pinned_public_key, guest_id = load_state()
request = make_request(
"onepassword-unlock", guest_id=guest_id, user=user, requesting_user=user,
service="com.1password.1Password.unlock",
)
response = exchange(request)
verify_approval(request, response, pinned_public_key=pinned_public_key)


def main() -> int:
if len(sys.argv) == 3 and sys.argv[1] == "onepassword-unlock":
try:
authenticate_onepassword(sys.argv[2])
return 0
except (AuthorizationError, OSError, subprocess.SubprocessError, TimeoutError):
print("Touch ID approval unavailable; use the normal password prompt.", file=sys.stderr)
return 1
if len(sys.argv) != 2 or sys.argv[1] not in {"disable", "enroll", "migrate", "pam"}:
print(
"usage: native-authentication-broker disable|enroll|migrate|pam",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
#!/usr/bin/python3 -I
"""Unprivileged GTK password fallback using the standard polkit PAM session."""

import json
import os
import sys
import gi

gi.require_version("Gtk", "3.0")
gi.require_version("PolkitAgent", "1.0")
from gi.repository import Gtk, Polkit, PolkitAgent


def main():
if os.getuid() == 0:
return 1
request = json.loads(sys.stdin.buffer.readline(16385))
identities = request["identities"]
available = [item[1]["uid"] for item in identities if item[0] == "unix-user"]
if not available:
return 1
uid = os.getuid() if os.getuid() in available else available[0]
session = PolkitAgent.Session.new(Polkit.UnixUser.new(uid), request["cookie"])
dialog = Gtk.Dialog(title="1Password — System Authentication")
dialog.set_default_size(440, 180)
dialog.set_resizable(False)
dialog.set_modal(True)
dialog.set_border_width(16)
dialog.add_button("Cancel", Gtk.ResponseType.CANCEL)
dialog.add_button("Authenticate", Gtk.ResponseType.OK)
box = dialog.get_content_area()
explanation = Gtk.Label(label=request["message"])
explanation.set_line_wrap(True)
box.pack_start(explanation, False, False, 8)
prompt = Gtk.Label(label="")
box.pack_start(prompt, False, False, 8)
entry = Gtk.Entry()
entry.set_visibility(False)
entry.set_activates_default(True)
box.pack_start(entry, False, False, 8)
dialog.set_default_response(Gtk.ResponseType.OK)
result = {"success": False, "waiting": False}

def on_request(session, text, echo):
prompt.set_text(text)
entry.set_text("")
entry.set_visibility(echo)
entry.set_sensitive(True)
entry.grab_focus()
result["waiting"] = True
dialog.present()

def response(dialog, response_id):
if response_id == Gtk.ResponseType.OK and result["waiting"]:
result["waiting"] = False
secret = entry.get_text()
entry.set_text("")
entry.set_sensitive(False)
session.response(secret)
secret = None
elif response_id != Gtk.ResponseType.OK:
session.cancel()
Gtk.main_quit()

def completed(session, authorized):
result["success"] = authorized
Gtk.main_quit()

session.connect("request", on_request)
session.connect("show-info", lambda session, text: prompt.set_text(text))
session.connect("show-error", lambda session, text: prompt.set_text(text))
session.connect("completed", completed)
dialog.connect("response", response)
dialog.show_all()
session.initiate()
Gtk.main()
dialog.destroy()
return 0 if result["success"] else 1


if __name__ == "__main__":
raise SystemExit(main())
Loading
Loading