Hardware-bound API tokens

launchpadlib can use a USB FIDO2 security key to protect your Launchpad API credentials.

Ordinary launchpadlib logins (see OAuth authentication in the introduction) store a long-lived OAuth access token. Anyone who reads that file, or the keyring entry holding it, can act as you until you revoke the token.

A hardware-bound login stores a long-lived OAuth refresh grant instead. Launchpad binds the grant to one of your registered security keys, and launchpadlib must prove that it holds the key to exchange the grant for a short-lived ordinary OAuth access token. API requests then use launchpadlib’s usual OAuth 1 signing.

Hardware binding protects the stored grant, not each access token. A minted access token is an ordinary OAuth token for its short lifetime, but the stored grant cannot mint a new one without the security key.

Installation

Security-key support is optional. launchpadlib imports without python-fido2 and only loads it when a security-key operation starts. Install the extra:

pip install 'launchpadlib[fido2]'

On Debian and Ubuntu you can instead install:

apt install python3-fido2

The supported versions are fido2>=1.1.2,<3.

To check whether python-fido2 is available:

from launchpadlib.fido2compat import Fido2SupportError, require_fido2

try:
    require_fido2()
except Fido2SupportError:
    ...

require_fido2() imports python-fido2 but does not look for a device or prompt the user.

Security keys

Only USB (HID) authenticators are supported. NFC authenticators and platform passkeys are not.

HardwareKeyClient performs WebAuthn ceremonies with a local key. It checks the HTTPS origin and the WebAuthn relying party ID before it talks to the key, and closes every device it opened afterwards, even on failure.

ConsoleInteraction, the default, prompts on the console for a touch and a PIN, and asks you to choose when several keys or several matching credentials are available. NonInteractiveInteraction fails instead of prompting; it cannot bypass what the key requires.

Talking to a USB key needs access to its hidraw device. If a connected key is not found or cannot be opened, check the device permissions and the FIDO2 udev rules from your distribution or key vendor. Avoid rules that grant access to every hidraw device. Running as root is not required.

Registering a security key

register_hardware_key registers a security key with your Launchpad account using an authenticated devel client:

from launchpadlib.launchpad import Launchpad

launchpad = Launchpad.login_with(
    "hardware-key-registration", "production", version="devel"
)
authenticator = launchpad.register_hardware_key("My security key")

The WebAuthn origin defaults to the web root of the service root, such as https://launchpad.net; pass origin to override it. Pass interaction to change how you are prompted, or hardware_client to use a configured HardwareKeyClient.

The returned authenticator is an API entry. Pass it directly when issuing grants. To revoke the key:

authenticator.revoke()

Grants bound to a revoked key can no longer mint access tokens. Access tokens already minted with it expire as well.

Issuing a hardware-bound grant

Issuing a grant is separate from logging in with it. Use issue_hardware_bound_oauth_token on an authenticated devel client:

grant = launchpad.issue_hardware_bound_oauth_token(
    consumer_key="hardware-client",
    authenticator=authenticator,
    permission="Change Anything",
)
consumer_key

The OAuth consumer that will log in with the grant.

authenticator

The registered authenticator entry that the grant is bound to.

permission

The title of an OAuth permission, such as "Read Non-Private Data" or "Change Anything".

context

Optionally, a pillar name, or distribution/sourcepackagename, to restrict the grant to.

date_expires

Optionally, a timezone-aware expiry. Launchpad defaults to, and limits grants to, 365 days.

The result is a dictionary with oauth_token and oauth_token_secret. Launchpad only returns the secret once, so pass the dictionary straight to login_with_hardware_token or keep it secret.

A grant cannot be broader than the permission of the credentials used to issue it, and must keep their context restriction, if any.

To revoke a grant:

launchpad.revoke_hardware_bound_oauth_token(grant["oauth_token"])

This also expires access tokens already minted from the grant. Other grants bound to the same key are not affected. Revoking on the server does not remove a stored copy of the grant; logins using it will be rejected.

Logging in with a grant

Pass the issued grant to Launchpad.login_with_hardware_token. The consumer must match the consumer_key used to issue the grant:

from launchpadlib.launchpad import Launchpad

launchpad = Launchpad.login_with_hardware_token(
    "my-application",
    "production",
    account="example-user",
    consumer_name="hardware-client",
    grant=grant,
)
print(launchpad.me.name)

Like login_with, the default service root is staging. The login mints an access token straight away, because fetching the service root must be signed, so expect to touch the key once during login.

grant also accepts a HardwareTokenGrant. credential_id optionally restricts the assertion to one key by its base64url WebAuthn credential ID; this is not the numeric id of the authenticator entry.

The grant is stored once it has authenticated a request as account, so a failed import does not replace a working stored grant. Later logins omit grant and load it from the credential store:

launchpad = Launchpad.login_with_hardware_token(
    "my-application",
    "production",
    account="example-user",
    consumer_name="hardware-client",
)

If no grant is stored, HardwareTokenError is raised; there is no fallback to browser authorization. Each service root and consumer pair stores one grant, and account must match the account that owns it.

origin, interaction and hardware_client work as for register_hardware_key. origin does not change where the token exchange is sent. application_name, consumer_name, credentials_file, credential_store, credential_save_failed, launchpadlib_dir, timeout, proxy_info and version work as for login_with, except that version defaults to devel.

Storing grants

Grants are stored in the system keyring by default, and only in memory under sudo. Pass credentials_file, or set LP_HARDWARE_CREDENTIALS_FILE, to store the grant in a file instead. The file is written atomically and holds the grant’s secret, so protect it like a password. credentials_file and credential_store cannot both be used.

A stored record holds the refresh token and its secret, the account, the optional credential ID, the service root, the WebAuthn origin and the consumer key. Access tokens, challenges, assertions and PINs are never stored, and private keys never leave the security key.

Records are stored under v2: followed by the SHA-256 digest of a JSON list of the service root and consumer key. The keyring service is launchpadlib.hardware-token.v2.

Token renewal

An access token is reused until it is close to expiry, so ordinary requests do not need a touch each. Renewal happens while signing a request, the smaller of 60 seconds and a tenth of the token’s lifetime before it expires.

Minting is serialized, so concurrent requests share one new token instead of starting parallel ceremonies. The HTTP client itself is not thread-safe; use one client per thread.

If Launchpad rejects an access token as expired, invalid or unknown, it is discarded. GET, HEAD and OPTIONS requests are retried once with a new token; other requests are not, because repeating them could duplicate their effects.

Requests to other hosts, such as file downloads, are sent without the Authorization header.

The exchange protocol

HttpMintingTransport exchanges grants using two endpoints on the web root of the service root:

POST <web root>/+hw-challenge

Sends oauth_token and oauth_token_secret, form encoded. Returns JSON with a positive integer challenge_id and WebAuthn request options under publicKey.

POST <web root>/+hw-access-token

Sends oauth_token, oauth_token_secret, challenge_id and the JSON-encoded WebAuthn assertion, form encoded. Returns a form-encoded oauth_token, oauth_token_secret, ISO 8601 expires_at and optional lp.context.

The exchange is authenticated by the grant, not signed with OAuth. The challenge must be 32 bytes and its relying party ID must match the origin. Redirects are not followed, responses are not cached and assertions are never retried. HTTP 401, 403 and 410 raise HardwareTokenGrantError; other failures raise HardwareTokenError. The transport refuses to send secrets while httplib2.debuglevel is set, and honours LP_DISABLE_SSL_CERTIFICATE_VALIDATION like ordinary logins.

To exchange grants another way, pass a minting_transport with two methods:

request_challenge(grant)

Return a MintingChallenge(handle, options).

verify_assertion(grant, handle, assertion)

Return a MintedAccessToken. If it has no expires_at, the token is used until Launchpad rejects it.

Errors

HardwareKeyError

A security-key operation failed.

HardwareKeyCancelledError

The user or the key cancelled the operation.

HardwareKeyTimeoutError

The security-key operation timed out.

Fido2SupportError

python-fido2 is not installed.

HardwareTokenError

A hardware-bound grant or access token could not be issued, stored or minted. It is a subclass of HardwareKeyError.

HardwareTokenGrantError

Launchpad rejected a grant during the exchange.

Base64url encoding

launchpadlib.fido2compat provides encode_base64url and decode_base64url for WebAuthn byte strings. Encoding never adds padding. Decoding accepts optional padding but rejects the standard Base64 alphabet, excess padding and non-canonical encodings.