Metadata-Version: 2.4
Name: burnledger
Version: 0.9.0
Summary: Python SDK for the BurnLedger API
License-Expression: MIT
Project-URL: Homepage, https://burnledger.io/docs/
Project-URL: Documentation, https://burnledger.io/docs/
Project-URL: Verifier, https://burnledger.io/verify/
Project-URL: Support, https://burnledger.io/contact/
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: httpx>=0.25.0
Requires-Dist: cryptography>=41.0.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
Requires-Dist: respx>=0.21; extra == "dev"
Requires-Dist: mypy==2.3.1; extra == "dev"
Dynamic: license-file

# BurnLedger Python SDK

Python client for the [BurnLedger](https://burnledger.io) API — cryptographic deletion certificates for regulatory compliance.

## Scope

The SDK covers the data plane: systems, attestations, certificates, webhooks,
API keys, `/v1/me`, and the transparency log — everything involved in
measuring a datastore and verifying what came back.

Account management (teams, TOTP enrolment, subscriptions, invoices, the audit
log, billing) is deliberately not part of the SDK. Those are administrative
actions people take once, in the dashboard at
<https://dashboard.burnledger.io>, and they are available on the raw `/v1` API
for anyone who needs to automate them.

## Install

```bash
pip install burnledger
```

**Requirements:** Python 3.10+

## Quick Start

```python
from burnledger import BurnLedger

with BurnLedger(api_key="dp_...") as dp:
    # 1. Attest — snapshot systems before deletion
    att = dp.attest("user@example.com", system_ids=["sys_abc", "sys_def"])

    # 2. Delete data (your code, your tools)

    # 3. Verify — confirm deletion and get certificate
    result = dp.verify(att.id, "user@example.com", timeout=60)

    # 4. Download certificate PDF
    dp.save_pdf(result.certificate.id, "./deletion-cert.pdf")
```

### Async

```python
from burnledger import AsyncBurnLedger

async with AsyncBurnLedger(api_key="dp_...") as dp:
    att = await dp.attest("user@example.com", system_ids=["sys_abc"])
    result = await dp.verify(att.id, "user@example.com", timeout=60)
```

## Offline Verification

Verify certificates without network access using Ed25519 signatures:

```python
from burnledger import verify_certificate, verify_transparency, PublicKeyInfo

key = PublicKeyInfo.from_hex("abcdef...", revoked=False)
keys = {key.key_id: key}

cert_result = verify_certificate(certificate, keys)
# VerificationResult.VALID or raises VerificationError

log_result = verify_transparency(certificate, keys)
# TransparencyResult.INCLUDED or raises VerificationError
```

Malformed input — a missing field, a value of the wrong JSON type, bytes that
are neither hex nor base64, a hash or signature of the wrong length — raises
`VerificationError` too, with a message naming the field
(`systems[0].query_hash is 31 bytes, expected 32`). A record in a format this
version cannot read raises its subclass `UnsupportedFormatVersionError`.

### Requiring a registration

From format 9.0 every system in a record says whether the enclave checked it
against a registration (`authorization: certified`, with the `customer_key_id`
it was registered under) or not (`authorization: none`). A `none` record is
still genuine, and verifies by default: whoever relays requests to the enclave
can always leave a registration out, and the enclave cannot refuse that. What
nothing but you can do is refuse the record, so ask for it, naming your key id:

```python
from burnledger import AUTHORIZATION_POLICY_FAILED, AuthorizationPolicy

policy = AuthorizationPolicy(customer_key_ids={"cust_k_..."})  # every system, your key
verify_certificate(certificate, keys, require_authorization=policy)
```

What a pass means: every required system is `certified` under a key id you
named, in a record from an enclave image of wire protocol 12 or later — the
first that registers a system only with the signatures of the key group that id
names. So that key group signed the registration. Earlier images, which include
every image that has issued format 9.0 so far, could register a system with no
customer signature, so a `certified` record from one of them fails the policy,
and so does a record that names no image. The key id is required for the same
reason: `certified` under a key you did not name could be under a key group the
relay enrolled itself.

`systems` narrows the requirement to the system ids you name. A record that does
not meet the policy raises `VerificationError` with
`code == AUTHORIZATION_POLICY_FAILED`, naming the reason. A record older than
9.0 always fails: nothing signs its `authorization` field. The CLI takes
`--require-certified` or `--require-certified-system <system_id>`, each with
`--customer-key-id <id>`.

## Registering a system with your own key

ADR-025 lets a system's Verification Records say `authorization: certified`
with your `customer_key_id`: before measuring it, the enclave checked that the
system's config and query are the ones a key group *you* hold registered. That
holds for records from an enclave image of protocol 12 or later; the verifiers
say when a record comes from an earlier one, whose `certified` is not evidence.
All of it happens on your machine, and the SDK never holds or sends a private key.

```python
from burnledger import BurnLedger, generate_key_group

# 1. A key group: n Ed25519 members, any t of which can act.
generated = generate_key_group(members=2, threshold=1)
seeds = [s.export_seed() for s in generated.signers]  # store these offline, now

with BurnLedger(api_key="dp_...") as dp:
    # 2. Verify the enclave against the PCR0 you pinned out of band. Configs
    #    are sealed to it, and everything below must come back signed by its key.
    enclave = dp.attest_enclave_identity(expected_pcr0=PINNED_PCR0)

    # 3. Enrol the group for your team. Every member signs, over the latest the
    #    authorization may end (`not_after`, by default the enclave's 90-day maximum).
    dp.enroll_key_group(
        team_id=TEAM_ID, group=generated.group, signers=generated.signers, enclave=enclave
    )

    # 4. Seal the config to the enclave, create the system with only the
    #    sealed bytes, and register it, signed at the group's threshold.
    result = dp.create_registered_system(
        team_id=TEAM_ID,
        group=generated.group,
        signers=generated.signers[:1],
        enclave=enclave,
        name="users-db",
        connector_type="postgresql",
        dsn="postgres://...",
        subject_query="SELECT id FROM users WHERE email = $1",
    )

    # 5. Rotate when you must. The OUTGOING group signs at its threshold and
    #    every incoming member signs; nothing is re-registered.
    successor = generate_key_group(members=2, threshold=1)
    dp.rotate_key_group(
        team_id=TEAM_ID,
        previous_group=generated.group,
        previous_signers=generated.signers[:1],
        next_group=successor.group,
        next_signers=successor.signers,
        enclave=enclave,
    )
```

**What each call checks.** The API relays every document, so each one that
comes back — the enrolment statement, the authorization, the registration — must
verify under the signing key of the enclave you attested, or the call raises
`VerificationError`. A relay that forged an enrolment would otherwise have you
believe you were registered. The key comes from your attestation, not from
`/.well-known/burnledger-keys`, which the same API serves. Then the enrolment
must name the group you hold and the team you asked for, its authorization must
end exactly at the `not_after` you signed and not start in the future, so an
older genuine answer or another team's is refused, and the registration must
name the system, config and connector you signed.
`verify_key_enrollment_statement`, `verify_team_authorization` and
`verify_system_registration` re-check a stored document against the enclave key.
One thing the client cannot check is the registration's `query_template_hash`:
the server computes it over the template after normalizing it, and the SDK has
no normalizer. So a relay could hand back an older genuine registration of this
system, under this key and config, over a template you signed for it earlier.

A signer is anything with a `public_key` and a `sign(payload) -> bytes` method
(`KeyGroupSigner`), so a member can live in an HSM, a cloud KMS or another
process; `InMemorySigner` is the convenience for keys on this machine. A system
that already exists is registered with `register_system_with_key`, given the
exact config bytes it was created with. `AsyncBurnLedger` has the same methods.

**What the records then say.** Every Verification Record of a registered system
carries `authorization: certified` and your `customer_key_id` for it, and the CLI
prints that key id on the system's Registration line, with what the record's
enclave image makes of it. A system never registered
says `authorization: none`. A record naming a key id that is not yours means
someone else authorized that verification. The authorization behind your key
expires; enrol the same group again before `authorization.not_after` to renew it.

**Custody.** The private keys are yours alone: BurnLedger never sees them and
cannot reissue them. Lose enough members that the group can no longer reach its
threshold and it can neither register a system nor authorize its own rotation,
and there is no recovery, because an operator who could re-enrol on your behalf
is an operator who could mint (ADR-025 §3). The way back is a new group and
re-registering every system, and records before and after will name different key
ids, visibly. So enrol more members than the threshold and keep one offline — two
members, threshold one, is the simplest. A recovery-phrase format is not defined
yet; until it is, back up each member's 32-byte seed (`export_seed()`) as you
would any credential that cannot be reissued.

## Webhook Verification

Verify incoming webhook signatures (HMAC-SHA256):

```python
from burnledger import verify_webhook_signature

valid = verify_webhook_signature(
    secret=webhook_secret,         # from dp.register_webhook()
    body=request.body,             # raw request body
    signature=request.headers["X-BurnLedger-Signature"],
)
```

## API Reference

### Client

```python
BurnLedger(
    api_key: str,
    *,
    base_url: str = "https://api.burnledger.io",
    timeout: float = 30.0,
    max_retries: int = 2,
)
```

### Systems

| Method | Returns |
|--------|---------|
| `register_system(**opts)` | `System` |
| `get_system(id)` | `System` |
| `list_systems(limit=25)` | `SyncPaginator[System]` |
| `deregister_system(id)` | `None` |
| `health_check(id)` | `System` |

### Attestations

| Method | Returns |
|--------|---------|
| `attest(subject, **opts)` | `Attestation` |
| `batch_attest(subjects, **opts)` | `BatchAttestationResponse` |
| `get_attestation(id)` | `Attestation` |
| `wait_for(id, **opts)` | `Attestation` |
| `verify(id, subject, **opts)` | `VerifyResult` |

### Certificates

| Method | Returns |
|--------|---------|
| `get_certificate(id)` | `CertificateResponse` |
| `list_certificates(limit=25)` | `SyncPaginator[CertificateResponse]` |
| `get_certificate_stats()` | `CertificateStats` |
| `export_certificates(**opts)` | `bytes` |
| `download_pdf(id)` | `bytes` |
| `save_pdf(id, path)` | `None` |
| `get_revocation_status(id)` | `RevocationStatus` |
| `revoke_certificate(id, reason=...)` | `CertificateResponse` |
| `batch_revoke_certificates(ids, reason=...)` | `BatchRevokeResponse` |

`batch_revoke_certificates` takes up to `MAX_BATCH_REVOKE` (100) ids under one
reason and returns whether all, some or none were revoked: the failures are in
`errors`, each naming the request `index` and `certificate_id`, so a partially
failed batch is inspected, not caught. More than 100 ids raises `ValueError`
before any request is made; chunk larger sets by `MAX_BATCH_REVOKE`.

### Webhooks

| Method | Returns |
|--------|---------|
| `register_webhook(url=...)` | `Webhook` |
| `list_webhooks(limit=25)` | `SyncPaginator[Webhook]` |
| `delete_webhook(id)` | `None` |
| `rotate_webhook_secret(id)` | `WebhookRotateResponse` |
| `commit_webhook_rotation(id)` | `None` |
| `list_failed_deliveries(limit=25)` | `SyncPaginator[FailedDelivery]` |
| `retry_delivery(delivery_id)` | `None` |
| `resolve_delivery(delivery_id)` | `None` |

### API Keys

| Method | Returns |
|--------|---------|
| `list_api_keys()` | `list[ApiKeyListItem]` |
| `create_api_key(role=..., team_id=None)` | `ApiKeyResponse` |
| `revoke_api_key(id)` | `None` |

### Transparency Log

These methods do not require authentication.

| Method | Returns |
|--------|---------|
| `get_log_head()` | `SignedTreeHead` |
| `get_log_entry(index)` | `LogEntry` |
| `get_log_entries(start, end)` | `list[LogEntry]` |
| `get_inclusion_proof(index, tree_size)` | `InclusionProof` |
| `get_consistency_proof(old_size, new_size)` | `ConsistencyProof` |

### Pagination

All `list_*` methods return a paginator that auto-fetches pages:

```python
for cert in dp.list_certificates():
    print(cert.id)

# async
async for cert in dp.list_certificates():
    print(cert.id)
```

## License

MIT
