Skip to main content

Grantry for Python

Identity, access and licensing for Python applications. A native client SDK, alongside the C# and C++ ones — it runs on a machine the user controls, and its design says so throughout.

pip install grantry-sdk

The package is grantry-sdk, and it imports as grantry_sdk. Not pip install grantry: that name on PyPI is an unrelated project, and so is the grantry module it installs.

from grantry_sdk import GrantryClient, GrantryOptions

client = GrantryClient(GrantryOptions(
    publishable_key="pk_test_…",
))

result = client.sign_in(email, password)
if result.signed_in:
    identity = client.get_identity()   # signed, and verified here

No dependencies

HTTP, TLS, hashing and JSON come from the standard library. Two things are missing from it, and both are implemented here, so the wheel is pure Python and vendors cleanly into frozen executables and loaders.

RSA signature verification (grantry/_rsa.py), against Python's big integers. Verification only, never signing: there is no private key here, nothing secret in the inputs, and no branch that depends on a secret. The PKCS#1 padding is rebuilt in full and compared exactly, which is what stops the Bleichenbacher e=3 forgery that catches implementations merely searching the decrypted block for a digest. There is a test for precisely that.

AES-256-GCM (grantry/_aes.py), decryption only, for the variable payload layer below. This is the only hand-rolled cipher in the SDK and the narrowness is deliberate: nothing here produces a ciphertext, the key derives from this process's own session token, and the tag is checked before a plaintext byte is returned. It is tested against FIPS-197 and the GCM specification's own vectors, plus payloads minted by the API's Node implementation — a cipher that only ever talks to itself is untested.

What proved the wiring

⚠️ The unit tests certified a client that could not read a single variable. The API signs each read with a dig claim; this SDK read digest, got nothing, and failed every correct response as tampering. Every test passed, because the fixtures minted their own tokens with the same wrong name — the mistake was symmetrical, so nothing written here could see it.

The fixtures in tests/test_resource_signature.py and tests/test_variable_encryption.py are now produced by the API's own signer and encryptor, via scripts/emit-signature.ts and scripts/emit-encrypted.ts. Regenerate them if either scheme changes.

What it will not let you do

Nothing here reaches another user. Every call acts on whoever the session says you are, and no method takes somebody else's id — a property asserted by a test that walks the public surface looking for a parameter named after one.

There is no unsuspend and no unblock. Not permission-gated — absent. suspend and block exist so an application can punish itself when it detects tampering, and a self-punishment a patched binary can undo is not a punishment. Reversal is the merchant's deliberate act in the dashboard, where it is audited.

A secret key is refused at construction. A key beginning sk_ in something you distribute is the whole environment handed to whoever runs strings on the file, so it fails on your machine in the first second rather than in a breach report.

What proves anything

Exactly one thing: get_identity(). Everything else is a value the server sent; that is a value the server signed, checked against a key it published, for this environment, in answer to this request — a fresh nonce goes out with the request and must come back inside the signature.

For anything that matters, send identity.access_token to your own backend and verify it there. A check in this process runs on a machine the user controls; the same check on your server does not.

Use identity.server_time rather than the local clock for anything that expires. A trial ending is decided against the server's clock, and reading the local one lets somebody roll their machine back a month.

identity.plan_key and identity.has_feature(key) answer from the signed pln and fea claims.

When the plans change

A plan bought in a browser, a renewal that failed, a licence redeemed on another machine: watch_plans hears of it while the application is open.

client.watch_plans(lambda: post_to_ui_thread(refresh_plans))

The server holds a long-poll open until the person's plans change, the client mints a fresh identity, and only then calls back — so get_identity() already agrees with the change. Start it once after creating the client; it idles while nobody is signed in. stop_watching_plans() ends it.

⚠️ The callback runs on a daemon thread of the SDK's own. Hand back to your UI thread before touching a window. An exception it raises is swallowed, and the first answer is a baseline that calls nobody.

Device locking

client.device_hash is base64url(sha256(machine_anchor + 0x1f + environment_id)). The anchor is the machine SID on Windows — read from the Local Security Authority, the value the C# and C++ SDKs send, so one computer is one device whichever SDK an application uses — IOPlatformUUID on macOS and /etc/machine-id on Linux. MachineGuid stands in on Windows only if the LSA will not answer.

Build checking

With "Refuse unlisted builds" on, every request carries the SHA-256 of the build that is running, and the server refuses one it does not list. The build is:

  • a frozen executable (PyInstaller, Nuitka, cx_Freeze) — the executable;
  • an application run as python app.pyz — the zipapp;
  • anything run from source — the entry script, __main__'s file.

⚠️ A source run's digest covers that one file and not the modules it imports. Freeze the application, or ship it as a .pyz, to have the check cover all of it. Add the same file under Settings → Native applications → Hashing.

A refused build raises GrantryApiError with code == "application_unrecognised", and restore() keeps the stored session when it does: only a 401 means the session is gone.

Hashing with the environment is the point: a raw machine identifier would be the same value for every application on that machine, letting two merchants correlate their users with each other's. Two environments see two unrelated values for one machine.

It is an anchor, not a secret — anyone running the process can compute the same hash. Device locking is a licensing control, not an authentication one.


Construction is the connection

Building a client reaches the API once, in the background: a single GET of the public environment route. Nothing else in the SDK reads the answer.

It exists so a merchant's dashboard can see the application. A web application announces itself the moment it loads, because its provider fetches the environment on mount; a native one had no such moment — nothing left the process until somebody typed a password, which could be days after the SDK was wired up. Their setup guide sat on "watching for connection" the whole time.

⚠️ It never blocks start-up, never reports a failure and never retries. An application that starts on a train must still start, and a server that is down must not turn into a reconnect loop nobody asked for.

It runs on a daemon thread, so an interpreter shutting down never waits on it. Turn it off with connect_on_start=False.

Identifier-first sign-in

lookup_identifier answers whether an identifier has an account — so the card can send somebody who has none to sign-up with what they typed already filled in, instead of asking for the password of an account that is not there and refusing one screen later.

Whether to do that is your application's decision, not an environment setting: the same choice <SignIn withSignUp> makes on the web.

SEND_UNKNOWN_TO_SIGN_UP = True  # the app's decision, as <SignIn withSignUp> is

if SEND_UNKNOWN_TO_SIGN_UP and not client.lookup_identifier(identifier):
    show_sign_up(prefilled=identifier)
else:
    password = read_password()      # an account exists, or the app did not ask

⚠️ A refusal is raised, never answered as False. False sends somebody to sign-up, so a rate limit read as one would send a real user to a sign-up that then refuses them for having an account. Catch GrantryApiError and ask for the password. The same reasoning is why a refusal is raised here and folded into a None in get_variable and get_file: there the absent thing is the answer, here it says nothing about the person.

⚠️ It makes account existence public to anyone holding the publishable key, which ships inside your application. What bounds it is a rate limit on its own bucket — twenty per fifteen minutes per IP, fail-closed — so ask when the form is submitted, never on a keystroke.

⚠️ True means "go to the password screen", not "a password will work". An account that signs in only through Google answers true and has no password at all, and a suspended account answers true as well. sign_in still decides, and still refuses without saying which half was wrong.

Sign in through the browser

sign_in_with_redirect sends the person to your environment's hosted sign-in in their own browser, and brings them back signed in. The second factor, social providers and their password manager are all handled there, and your application never sees a password. It blocks until they come back, so call it from a worker thread in a desktop application.

import threading

from grantry_sdk import GrantryRedirectError

cancel = threading.Event()        # set it from a Cancel button

try:
    result = client.sign_in_with_redirect(cancel=cancel)
    if result.requires_plan:
        show_activate()
except GrantryRedirectError as error:
    if error.code == "timeout":
        say("Nobody came back from the browser.")   # a closed browser looks like this

It is RFC 8252's shape. A listener opens on 127.0.0.1, on a port the system picks. The browser carries a PKCE challenge (S256) and a random state, and the listener takes one answer, then closes. Only a request carrying this sign-in's state is taken, so a page that finds the port can neither end the wait nor hand you a code. The session is adopted exactly as sign_in adopts one, and the browser is sent on to the sign-in host's own /finished card, in the environment's theme, telling the person to return to your application.

⚠️ A closed browser cannot be seen from here. The call waits out timeout, ten minutes unless you pass one, so give the person a Cancel button. Ctrl+C in a command-line tool stops it the same way. Once the code reaches the API, a refusal is a GrantryApiError as at any sign-in: a device limit, an unrecognised build.

A right password that is no longer acceptable

sign_in and submit_mfa can both answer with no session, no error, and a reset token. It means the password was correct and is no longer acceptable: it has appeared in a breach corpus, or the merchant tightened the policy since it was set. The server re-checks the plaintext against the current policy at sign-in because that is the only moment the plaintext exists.

result = client.sign_in(identifier, password)

if result.mfa_challenge_pending:
    result = client.submit_mfa(result.mfa_token or "", code)

if result.password_reset_pending:
    if result.password_breached:
        say("That password has appeared in a public breach.")
    result = client.reset_password(result.password_reset_token or "", chosen)

    # An enrolled account is challenged again here — see below.
    if result.mfa_challenge_pending:
        result = client.submit_mfa(result.mfa_token or "", code)

if result.signed_in:
    identity = client.get_identity()

⚠️ signed_in False is not always a wrong password, and reading it as one is the bug this section exists for. The refusal is HTTP 202, so a transport that treats any 2xx as success reports "fine", the caller goes looking for an identity, there is nothing there, and somebody is told they are not signed in at the exact moment the right screen is "choose a new password" — with the one thing that would have finished the flow already dropped.

⚠️ Ask about the challenge first. The server withholds the password verdict until a factor is proved, so an enrolled account gets the challenge at sign-in and the verdict on the answer to submit_mfa. Ordering it the other way round would redeem a reset token for somebody whose second factor was never shown.

⚠️ password_reset_reasons is empty after a second factor, because only a boolean rides on the challenge. Draw the password policy your environment publishes — it works on both paths — and treat the list as extra detail. The exception is password_breached, the one reason a screen cannot work out for itself, and without which somebody is asked to rewrite a password that satisfies every rule in front of them.

⚠️ reset_password is the finish, not a detour. The token is a real single-use reset token, minted so that no new endpoint is needed, and redeeming it sets the new password and opens the session that was withheld — which is why it returns a SignInResult rather than nothing.

⚠️ And it can answer with a challenge of its own, which on the post-MFA path it always does. The session is gated on enrolment, not on what was proved a moment ago, because a stolen reset token that defeated a second factor would be worse than the stale password. So that path costs two challenges. Every other session on the account dies when the reset completes, including one this client was already holding.

Maintenance mode

The environment says whether it is closed, so a wait screen can be drawn before a form is. Every other route refuses with GrantryMaintenanceError while it is on; this one keeps answering, which is what makes the check possible.

environment = client.get_environment()

if environment.is_closed:
    show_wait_screen()          # your copy, not the server's sentence
else:
    show_sign_in()

⚠️ Absent means open, and is_closed is how you ask. A server too old to know the setting sends no field, so a client that read a missing value as closed would black out every application talking to the previous release — an outage caused by upgrading the client. The parse compares for equality with the one closed value and defaults to open; anything else, including a string this SDK has never heard of, reads as open and is still in environment.raw.

⚠️ Closed means everybody, not just new sign-ins. Access tokens live sixty seconds and maintenance refuses the refresh, so sessions already open end within about a minute.

⚠️ It is a snapshot, so keep handling the refusal. The switch can be thrown a second after you read this, and GrantryMaintenanceError is still what arrives when it is. Do not render the message it carries: switch on the exception and write your own.

⚠️ The code decides, not the status. GrantryMaintenanceError means the API said maintenance. A 503 without that code is a GrantryApiError with status == 503 — a load balancer with nothing behind it, or the API's own email_unavailable from a route that has to send a message — so "down" and "closed" stay two different screens. Until 25 September 2026 this SDK read any 503 as maintenance, unlike the C# and C++ SDKs, and drew a wait screen for both.

Certificate pinning

GrantryOptions(
    publishable_key="pk_…",
    certificate_pins=["base64-of-sha256-of-the-SPKI"],
)

The pin format is the same string the C# and C++ SDKs take, so one value works in all three.

Python exposes the peer certificate only after the handshake — the same limitation WinHTTP imposes on the C# SDK. So the first pinned call is a credential-free GET of the public JWKS: if something is intercepting the connection, it is found before any session token has been sent to it.

Variable payloads

On by default, and only when there is a session. get_variable sends x-grantry-encrypt, and the API returns the value under a key derived from your session token — HKDF-SHA256, salted per response, and bound to the variable's name so a ciphertext cannot be moved from one key's response into another's.

⚠️ Narrower than it looks, and still worth having. TLS already encrypts the response, and a proxy watching the whole exchange sees the session token go out in the request header — so it can derive the same key. What this defeats is a capture of the response alone, and bodies sitting in a log, a crash dump, a support bundle or a HAR file. Certificate pinning is what stops the proxy; the signature is what stops substitution.

The signature covers the plaintext, so the two layers check the same thing: decrypt, hash what came out, compare against what was signed.

Set encrypt_variable_payloads=False only to make a proxy trace readable while debugging.

Sessions

MemorySessionStore (the default) keeps the token for the life of the process. FileSessionStore writes an owner-only file in the user's config directory, via a temporary file and a rename so a crash leaves the previous token rather than half of the new one.

Both are honestly weak against another process running as the same user. A platform keychain is better and is what a shipped desktop application should pass in — the SessionStore protocol is three methods.

Running the tests

python -m pip install pytest
python -m pytest -q

The interesting ones are the forgeries: alg: none, HS256 signed with the RSA public key, a token from a rogue JWKS, a replayed nonce, another environment's audience, and the padding forgery above.

The example

GRANTRY_KEY=pk_test_… python example/main.py

GRANTRY_API=http://localhost:3102 points it at a local development server.

A walkthrough of the whole surface. Every irreversible call is shown as a commented line with its reasoning rather than executed, because an example that suspends the account of whoever runs it is not one.

Metadata

Release files for grantry-sdk 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for grantry-sdk 0.1.0
File Size Uploaded
grantry_sdk-0.1.0.tar.gz 137.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for grantry-sdk 0.1.0
File Interpreter ABI Platform
grantry_sdk-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 229.6 kB

Release files / grantry_sdk-0.1.0.tar.gz

Download URL grantry_sdk-0.1.0.tar.gz
Size 137.7 kB
Tags Source
SHA-256 checksum
How to use checksums
3df1a14c3a4afdf24d20c840731254144062a1bc9f8cd214fe7ed07ab62a928f
BLAKE2b-256 checksum
How to use checksums
adbcb859d7c9414ba6ec33bf9f26bf2f459dcebf83b3578beaa48ee2d16b1304
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release files / grantry_sdk-0.1.0-py3-none-any.whl

Download URL grantry_sdk-0.1.0-py3-none-any.whl
Size 91.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ae4d3da7e1af66be262a05612fabca2ba548e3ae651f4320919040ba546728a6
BLAKE2b-256 checksum
How to use checksums
7243af7ac20b6b9cfa9e59af628abcf5dce982269830031488dee64ce1eea03c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.9

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page