Metadata-Version: 2.5
Name: grantry-sdk
Version: 0.1.0
Summary: Identity, access and licensing for Python applications.
Project-URL: Homepage, https://grantry.dev
Project-URL: Documentation, https://docs.grantry.dev/reference/python/overview
Author: Grantry
License-Expression: MIT
License-File: LICENSE
Keywords: authentication,entitlements,grantry,identity,licensing,sign-in
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Security
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.9
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == 'dev'
Description-Content-Type: text/markdown

# 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.

```bash
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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

```python
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

```bash
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

```bash
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.
