Metadata-Version: 2.4
Name: witnessos-passport
Version: 0.1.1
Summary: Verifiable identity and scoped authority for autonomous agents. Offline verification, fail-closed by default.
License-Expression: Apache-2.0
Project-URL: Homepage, https://witnessos.com.au
Project-URL: Documentation, https://witnessos.com.au/passport
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: cryptography>=41
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# WitnessOS-Passport

Verifiable identity and scoped authority for autonomous agents. Give an agent a passport,
and anyone can check **which key speaks for it** and **what it is allowed to do** — offline,
with no account, no API call, and no permission from us.

Verification is free and offline by design. Issuance is the product.

```bash
pip install witnessos-passport
```

The issuer signs; the agent's key is carried:

```python
from witnessos_passport import issue_passport, verify_passport, authorise

# The issuer signs for an agent whose public key it has been given.
passport, _ = issue_passport(
    subject="agent-01",
    issuer="empire-labs",
    subject_public_key=agent_public_key_hex,   # the agent keeps its private key
    issuer_seed=issuer_seed,
    authority={"actions": ["gmail.send"], "resources": ["mailbox:ops"]},
)

verify_passport(passport).grade                       # E1 — signed, signer unidentified
verify_passport(                                      # E2 — issuer bound out of band
    passport, trusted_issuer_keys={"empire-labs": issuer_public_key_hex}
).identity_route                                      # "issuer_attested"

authorise(                                            # no trust policy -> refused
    passport, action="gmail.send", resource="mailbox:ops"
).code                                                # passport_invalid
```

## Command line

```bash
witnessos-passport keygen --key-out issuer.key

witnessos-passport issue  --subject agent-01 --issuer empire-labs \
                          --subject-key <agent-public-key-hex> \
                          --issuer-key issuer.key \
                          --actions gmail.send,http.get --resources 'mailbox:ops' \
                          --spend 25.00 --out passport.json

witnessos-passport verify  passport.json --trusted empire-labs=<issuer-pubkey-hex>
witnessos-passport authorise passport.json --action gmail.send --resource mailbox:ops \
                          --trusted empire-labs=<issuer-pubkey-hex>

Presentation — the verifier asks, the agent answers, the verifier decides:

```bash
witnessos-passport challenge --action fs.write --resource workdir:/srv/notes \
                          --ttl 120 --out challenge.json
witnessos-passport prove   --passport passport.json --key agent.key \
                          --challenge challenge.json --out presentation.json
witnessos-passport check   --presentation presentation.json --challenge challenge.json \
                          --trusted-subject agent-01=<agent-pubkey-hex> \
                          --replay-store ~/.local/state/passport-replay.sqlite
```

```bash
witnessos-passport vectors --out vectors/passport-v2-vectors.json
```

An agent may instead issue for itself (`--issuer` equal to `--subject`, `--key-out`); a
verifier accepts that with `--trusted-subject agent-01=<agent-pubkey-hex>`, which is the
`pinned_subject` route.

Exit codes: `0` verified/allowed, `1` failed/denied, `2` usage error. `verify` performs no
network access and needs no configuration file. Add `--json` for machine-readable output —
accepted either before or after the command.

Issuing in an issuer's name without that issuer's key is refused rather than silently
self-signed.

## Two keys, two roles

| Field | Whose key | What it does |
|---|---|---|
| `issuer_key` | the issuer | signed this document |
| `subject.public_key` | the agent | is bound to the agent by that signature; signs the agent's own actions |

Both keys are inside the signed bytes, so editing which key speaks for the agent breaks the
signature exactly as editing its authority does. The agent's private key never enters the
issuance path when a third party issues.

## Presenting a passport

Verification reads a *document*, and it is offline — so a copy of a passport answers it as well
as the original. That is the point (the signed bytes are meant to be shared), but it also means
verification alone cannot tell you who is acting. Presentation does: the agent proves it holds
the private key its passport carries, over a challenge the verifier issued for that one action.

```python
from witnessos_passport import issue_challenge, prove, check_presentation, SQLiteReplayGuard

challenge = issue_challenge(action="fs.write", resource="workdir:/srv/notes", ttl_seconds=120)

presentation = prove(passport, agent_seed, challenge)        # the agent's side

check_presentation(                                          # the verifier's side
    presentation,
    challenge=challenge,                                     # the one it issued
    replay_guard=FileReplayGuard("~/.local/state/passport-replay.json"),
    trusted_issuer_keys={"empire-labs": issuer_public_key_hex},
    require="E2",
).code                                                       # "allowed", or why not
```

A copy of someone else's passport is not a credential: the proof must be made by the key the
document carries, and the challenge dies with its window and with its nonce. **No replay guard
is a refusal** (`challenge_replay_unverifiable`), not an assumption — single use is a property
of the verifier's state, not of the signature. Not anchored, and not a timestamp authority: see
[§8.5 of the format spec](docs/FORMAT.md).

The same flow is on the command line, and `check` will not run without a replay store: it
refuses rather than assume a challenge is fresh. Two verifiers sharing one store cannot both
accept the same presentation — the claim is a single `INSERT` against a primary key, so the
store decides who wins, not the reader. That matters because a read-then-write store has a
window in which one claim erases another; `FileReplayGuard` is kept for exactly the case it
is documented for, one process at a time.

When the action produces *data*, naming the action and the resource is not enough — an
authorisation to write "the shortlist" is satisfied by any bytes at all. A challenge can bind
the exact bytes instead: `payload={"target": "shortlist.json", "sha256": payload_digest(blob)}`,
where the target is relative to the resource the challenge names. It costs no new signature,
because the proof already covers the whole challenge; what it does require is a runtime that
**recomputes the digest over the content it is about to deliver and refuses on mismatch** — the
verifier never sees the bytes, and a gate that skips that step has a decorative binding. See
[§8.6](docs/FORMAT.md).

## What a passport proves — and what it does not

A grade is a verifier conclusion, never a document's self-description. A passport carrying a
`grade` field is recorded and ignored.

| Grade | Name | Proven |
|---|---|---|
| E0 | declared | nothing independent — a claim, with integrity at most |
| E1 | observed | an independent party witnessed the key-to-subject binding, through a route outside the document |
| E2 | enforced | the gateway authorised and routed the action; the credential broker enforced policy — **engine evidence: this SDK never emits it** |
| E3 | corroborated | the destination provider confirmed the outcome — **engine evidence: this SDK never emits it** |
| E4 | anchored | inclusion in an anchored batch, verified offline against a trusted timestamp |

**This is the WitnessOS ladder, and it is canonical on every surface — this repo included.** WitnessOS
defined it first; this repo's earlier wording was the
newcomer and has been retired. Anchored is **E4**. **Identity, self-signature and custody are
properties, not rungs**: a key carried inside the document signing that document proves possession,
not who holds it; and an independent custodian holding a snapshot proves the record still *exists*,
which was never proof that it is *more true*. Custody is reported as an attribute (`retained_by`)
and never moves the grade.

Identity enters only at E1 (observed), and only through the verifier's out-of-band policy — never
because the document claimed it. There are two routes to E1, and both are reported:

- **`issuer_attested`** — the signing key is bound to an issuer the verifier trusts. This is
  the route that lets a third party vouch for an agent.
- **`pinned_subject`** — the document is self-issued and the verifier's policy binds that
  exact key to the document's own subject id. The **label is checked, not just the key**: a
  key pinned for one agent can never carry another agent's identity, and a labelless list of
  keys promotes nothing. Restricted to self-issued documents on purpose: otherwise an
  untrusted issuer could mint a wildcard grant naming a pinned agent and inherit its trust.

`authorise` requires E2 by default. A signature only proves *someone* signed; an
unidentified author's grant can be produced by anyone who can generate a keypair, so
honouring it by default would make every scope check decorative. Pass `require="E1"` to
accept unidentified documents deliberately.

The ladder is nested: E1 presupposes integrity, E4 the observed rung. A rung is never claimed on
a foundation that did not hold, and unprovable components cap the grade; they never inflate it.

## Status — read this before depending on it

Phase 1 (core primitive). Implemented and tested:

- Passport issuance: issuer-signed claims with the agent's key bound inside the signature.
- Offline verification with fail-closed grading and explicit identity routes.
- Scoped authorisation with deny-by-default semantics and machine-readable refusal codes.
- Holder-bound presentation: an agent proves possession of the key its passport carries, over a
  verifier-issued, windowed, single-use challenge — with a refused-by-default replay guard.
- Presentation on the command line end to end (`challenge` → `prove` → `check`), so the control
  is reachable from the interface people actually install, not only from the SDK.
- An atomic replay store (`SQLiteReplayGuard`): a spent challenge is claimed by `INSERT` against
  a primary key, so concurrent verifiers serialise on the store instead of losing a write.
- Byte-level payload binding: a challenge can name the exact content authorised for a target
  inside its resource, and the proof covers it without any additional signature.
- **Anchored receipts (E4).** Records are batched into an RFC 6962 Merkle tree and an external
  RFC 3161 timestamp authority signs a token over the whole receipt, not just the root. A
  verifier proves inclusion and validates the token against a trust anchor it supplies — no
  access to our storage, no trust in our clock, no trust in us.
- Python SDK and CLI.
- Binding suite (forgery, borrowed issuer names, trust laundering, key substitution) alongside
  the tamper suite, each with a positive control, plus published cross-language test vectors.

**Retention is supplied, not proven.** Retention by an independent custodian stays an attribute
supplied from outside — this code cannot witness somebody else's archive, and it does not pretend to
(§6, §11.5). It is carried and tested: the attribute is recorded, `require_retention=True` demands
it, and custody never moves the grade. What is missing is a custodian to make the claim, not the
code to carry it.

We are not that custodian — hold our own archive and independence is gone by construction. So
custody is the customer's to hold, or a partner's, and ours to prove once they do. Who qualifies,
what a receipt must bind and how a custodian is named: **[docs/CUSTODY.md](docs/CUSTODY.md)**.
Recorded 2026-09-15 as a position, not a gap.

**Also absent:** a replay store that spans *machines*. The atomic claim shipped here covers
processes that share a filesystem — one host, or hosts whose filesystem implements POSIX
locking correctly (a local disk does; some network mounts quietly do not). A verifier fleet
spread across machines wants a server-side store; the interface is one method, so that is a
constructor argument, not a rewrite. Anchoring is only as good as the target you point it at:
the SDK verifies whatever the *verifier* trusts, and ships an adapter for RFC 3161 only.
**Declared out of v1.0 — decided, not postponed.** Hosted issuance is a service: tenancy, key
custody, an uptime promise. No customer is asking for it yet, so it returns when one does (recorded
2026-09-15). Self-serve billing follows hosted issuance and returns with it — there is nothing priced
to bill for until then.

**Scheduled, Passport v1.1 (Interop):** TypeScript parity — the format exists for agent runtimes and
most agent code is JS/TS, and the cross-language vectors are already published, so it is bounded work
rather than research. Same release, same phase: submission of anchored receipts to a transparency log.

**Equivocation is not detected by this code.** The chain catches gaps and splices. It cannot catch a
sealer anchoring two contradictory batches — that needs third parties able to compare what was
published, which is what a transparency log adds. "A third party stood next to this" is what ships;
"third parties can catch a liar" is not, yet (§11.7).

**Anchored is proven; retention is an attribute.** E4 (anchored) is awarded when a verifier walks
the inclusion proof and accepts the token itself. Custody is supplied from outside and is reported
as an attribute, never as a grade: `AnchorClaim(anchored=True)` — a boolean handed in from outside —
earns a named cap and no grade at all.

## Design rules

1. **Fail closed.** Malformed input yields a refusal with a reason, never a lenient default.
2. **One clock.** The verifier's clock decides validity. `issued_at` in the verifier's future
   is refused, so forward-dating is detectable.
3. **Deny by default.** Empty scope grants nothing; wildcards must be explicit and may only
   be trailing.
4. **Exact money.** Amounts are decimal strings parsed as `Decimal`; nothing compares in
   binary floating point.
5. **No hidden state.** Every function is pure. The same question always gets the same answer.
6. **No keys in the library.** Seeds are generated and returned; this project never persists,
   logs or transmits them.

## Testing

```bash
python3 -m pytest -m "not network"   # 336 tests
python3 -m pytest -m "network"       # talks to FreeTSA; deselected in CI on purpose
python3 scripts/smoke_cli.py         # end-to-end CLI walkthrough
```

The suite includes a positive control alongside every rejection case — a suite where
everything fails would otherwise prove nothing.

`tests/fixtures/tsa/` holds a **real** FreeTSA token (see its `PROVENANCE.md`) over a receipt
this repository can rebuild. The offline suite verifies that token against the vendored
certificate chain, so the anchoring cryptography is exercised on every run without depending on
a third party being up. The `network` tests submit a live query to FreeTSA; they are excluded
from CI because a public service must never be able to turn this repository red.

`scripts/smoke_cli.py` drives the installed CLI the way a user would — keygen, issue (both
routes), verify (untrusted, issuer-trusted, subject-pinned, over-required), authorise, tamper,
expiry — and asserts on the JSON payload as well as the exit code. A step that exits 0 while
reporting the wrong grade is a failure.

## Interoperability

`vectors/passport-v2-vectors.json` is the contract for independent implementations: published
test seeds (never real key material) for both roles, two signed documents — one
issuer-attested, one self-issued — their expected signatures, canonical JSON examples with
SHA-256 digests, and expected verification and authorisation outcomes.

## Specifications

- [`docs/FORMAT.md`](docs/FORMAT.md) — the normative format specification.

## Licence

Apache-2.0. Verification is free and stays free.
