🔑 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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file apwlib-0.1.1.tar.gz.
File metadata
- Download URL: apwlib-0.1.1.tar.gz
- Upload date:
- Size: 24.8 kB
- Tags: Source
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50c7ed23140631318ad74c5fbc69ec852205d15082016220d8c64035d207ad9e
|
|
| MD5 |
9fd55789f41bd73f9f75a46ce59d5ae2
|
|
| BLAKE2b-256 |
af3fba9d33d90c30ce7c732800ed005f616b94f326b4a91efb9ecfe2a15e44ce
|
File details
Details for the file apwlib-0.1.1-py3-none-any.whl.
File metadata
- Download URL: apwlib-0.1.1-py3-none-any.whl
- Upload date:
- Size: 32.3 kB
- Tags: Python 3
- Uploaded using 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}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e805ef2582b6518dadecc319c6ff5f14f285e83e157d18f0e18966aeff419a6c
|
|
| MD5 |
6c0bfc4cf651ed3c7649318b4801f1d0
|
|
| BLAKE2b-256 |
d52fe93383c635f747bfab5317073886c6a9be9a465260289aa02a81a8a337ca
|