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.1.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.1-py3-none-any.whl (32.3 kB view details)

Uploaded Python 3

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

Hashes for apwlib-0.1.1.tar.gz
Algorithm Hash digest
SHA256 50c7ed23140631318ad74c5fbc69ec852205d15082016220d8c64035d207ad9e
MD5 9fd55789f41bd73f9f75a46ce59d5ae2
BLAKE2b-256 af3fba9d33d90c30ce7c732800ed005f616b94f326b4a91efb9ecfe2a15e44ce

See more details on using hashes here.

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

Hashes for apwlib-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 e805ef2582b6518dadecc319c6ff5f14f285e83e157d18f0e18966aeff419a6c
MD5 6c0bfc4cf651ed3c7649318b4801f1d0
BLAKE2b-256 d52fe93383c635f747bfab5317073886c6a9be9a465260289aa02a81a8a337ca

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