🔑 apwlib
Apple Passwords (iCloud Keychain) as a typed Python API.
The library behind apwcli.
Apple brokers keychain access through a helper that only runs inside an
approved browser — there is no public API. apwlib runs that plumbing for
you: a background daemon hosts the real iCloud Passwords extension in a
headless browser, pairs with the macOS PIN once, and your code gets a small,
typed facade.
- Typed facade —
get_password,save_password,get_otp,list_otpreturning dataclasses, keyed by site URL - Zero babysitting — the daemon auto-starts as a detached singleton and is reused across programs and runs
- Transparent pairing — pass a
pin_providerand an unpaired call pairs itself;apwlib.pinwindowcollects the PIN on screen when there's no terminal - Nothing leaves your Mac — Apple's own extension does the crypto (SRP pairing, AES-GCM per command); apwlib is local transport around it
- Small public surface — the package root plus
apwlib.pinwindowandapwlib.diagnostics; everything else is private and underscore-named
Install
uv add apwlib # or: pip install apwlib
Requires macOS with a supported browser installed (Chrome, Brave, Edge, or Chromium); the iCloud Passwords extension itself is downloaded automatically from the Chrome Web Store.
Quickstart
from apwlib import ApplePasswords
pw = ApplePasswords(pin_provider=lambda: input("PIN: "))
for entry in pw.get_password("github.com"):
print(entry.username) # entry.password holds the real value
The first call starts the daemon and, if the session isn't paired yet, macOS
shows a 6-digit PIN and your pin_provider supplies it. Keep one
ApplePasswords instance around and call it whenever you need a credential.
Usage
from apwlib import ApplePasswords, ApwError, SessionError
pw = ApplePasswords(pin_provider=lambda: input("PIN: "))
# Entries for a site. Matching is by registrable domain: a bare host, a full
# URL, or a subdomain all resolve to the same entries. Narrow with a username,
# or omit it for every match. An empty list means no match; reads don't raise
# for "not found".
for entry in pw.get_password("github.com", "me@example.com"):
login(entry.username, entry.password)
# Create or update a credential.
pw.save_password("example.com", "me@example.com", "s3cret-passw0rd")
# One-time codes: what's available, and the current code.
for code in pw.list_otp("github.com"):
print(code.username, code.domain)
for code in pw.get_otp("github.com"):
print(code.code)
# The paired session lives in the browser and can drop; catch SessionError
# to re-pair, or ApwError for anything protocol-level.
try:
pw.get_password("github.com")
except SessionError:
print("session dropped — pair again")
except ApwError as exc:
print(f"request failed (status {int(exc.status)}): {exc}")
Constructor options: pin_provider (callable returning the PIN; without it an
unpaired call raises NotPairedError), auto_start=False (require an
already-running daemon), socket_path (override the daemon socket).
Pairing
Pairing needs a 6-digit PIN that macOS displays, once per daemon lifetime.
A pin_provider callback handles it transparently; to drive it explicitly:
from apwlib import Daemon
daemon = Daemon()
daemon.request_challenge() # macOS shows a 6-digit PIN
paired = daemon.verify_challenge(input("PIN: ")) # blocks until settled
A pairing cannot outlive the daemon (the helper issues a fresh PIN per handshake by design), so keeping the daemon alive is what keeps the PIN rare.
No terminal to prompt in? apwlib.pinwindow.request_pin is a ready-made
pin_provider that collects the PIN in a small on-screen window (six code
boxes, opened chromeless in an installed browser) — it's what apwcli uses
when stdin is not a TTY:
from apwlib import ApplePasswords
from apwlib.pinwindow import request_pin
pw = ApplePasswords(pin_provider=request_pin)
The window is a plain HTML page with a bundled default stylesheet. Restyle it
by passing CSS text (pin_provider=lambda: request_pin(css=...)) or by
dropping a replacement at ~/.apwlib/pinwindow.css.
Daemon control & diagnostics
Most programs never touch the daemon — the facade auto-starts and reuses it. For explicit lifecycle control (a setup step, a health endpoint):
from apwlib import Daemon
from apwlib.diagnostics import run_checks
daemon = Daemon()
daemon.start() # no-op if already running; raises DaemonStartError if not ready
daemon.status() # {"running": ..., "bridge": ..., "paired": ..., ...}
daemon.restart() # replace a wedged daemon
daemon.stop()
for check in run_checks(daemon): # what `apwcli doctor` renders
print(check.key, check.ok, check.detail)
Data model
Reads return dataclasses (PasswordEntry, OTPEntry):
from apwlib import PasswordEntry
entry = PasswordEntry(username="me@example.com", domain="github.com", password="hunter2")
print(entry.username, entry.domain)
#> me@example.com github.com
PasswordEntry.password is None when the vault withholds it.
Errors
Every failure raises ApwError (or a subclass — SessionError,
DaemonNotRunningError, NotPairedError, DaemonStartError, ServerError)
carrying a Status:
from apwlib import SessionError
try:
raise SessionError()
except SessionError as exc:
print(int(exc.status))
#> 9
Examples
Runnable scripts, ordered by complexity, live in
examples/: reading entries, one-time codes, saving a
credential, and explicit daemon control. Run any with
uv run python examples/<dir>/main.py.
How it works
The design notes cover the whole story: Apple's helper and its kernel-enforced launch constraint, the SRP pairing and SMSG encryption, and why a (headless) browser is involved at all. The full API reference is generated from the source at michel-tricot.github.io/apwcli.
License
MIT © Michel Tricot
Release files for apwlib 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| apwlib-0.1.2.tar.gz | 24.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| apwlib-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 57.1 kB
Release files / apwlib-0.1.2.tar.gz
| Download URL | apwlib-0.1.2.tar.gz |
|---|---|
| Size | 24.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f7545b27f7de62a7fb35970f640a705c41877661d63da78a9f12462b54306a95
|
|
BLAKE2b-256 checksum How to use checksums |
d15f4a11b1a149af383abce7aecc0c466dfbac3540ef93586213d3bd75cf2819
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / apwlib-0.1.2-py3-none-any.whl
| Download URL | apwlib-0.1.2-py3-none-any.whl |
|---|---|
| Size | 32.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1c48b808f75af46213b47420c354bd1fb764f53edfe540248b39d3feaca17844
|
|
BLAKE2b-256 checksum How to use checksums |
244332533a9749aedc3f48ae6428c1ec671af3d718541903abddc0d1b32bc4b8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.12.1 {"installer":{"name":"uv","version":"0.12.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|