Skip to main content

🔑 apwlib

Apple Passwords (iCloud Keychain) as a typed Python API. The library behind apwcli.

CI License: MIT Python 3.12+ macOS

Website · API reference · Examples · How it works


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 facadeget_password, save_password, get_otp, list_otp returning 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_provider and an unpaired call pairs itself; apwlib.pinwindow collects 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.pinwindow and apwlib.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

apwlib-0.1.0.tar.gz (24.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

apwlib-0.1.0-py3-none-any.whl (32.3 kB view details)

Uploaded Python 3

File details

Details for the file apwlib-0.1.0.tar.gz.

File metadata

  • Download URL: apwlib-0.1.0.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

Hashes for apwlib-0.1.0.tar.gz
Algorithm Hash digest
SHA256 5d8dd6e501ff8c48fb898aa3df316103a9e2475ada4ccff3e6e135573d2867ac
MD5 aa2e30407c5eac74dd839e62f89834bd
BLAKE2b-256 0824c3ace3ed0717c408b08421c880afb08aca1d9e3c6f11e45f8d6b5b855390

See more details on using hashes here.

File details

Details for the file apwlib-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: apwlib-0.1.0-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

Hashes for apwlib-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 8547f30585a2398ca999c1edbb5ab8d2960444660c88536ce84c1b4134938224
MD5 cacd45bc71cdc48180e241da4d2e5a60
BLAKE2b-256 035593820aa5ec0e684dde1e4577bcb3c4026adace80bd69704438b0e43d75f9

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page