diff --git a/.gitignore b/.gitignore index a7bd687..df1e76c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ CONVERGENCE*.md *.privat.md .geminirules .claude/ + +# interop example deps (npm) and project-local venvs +node_modules/ +.venv314/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 00a6f80..8a2adb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2026-08-17 + +### Added +- **BRC-138 — Single-Use Signed Proofs** (`bsv_brc.brc138`), the first Python + implementation of the standard, byte-compatible with the reference + TypeScript `@bsv/auth`: + - `create_auth_proof` / `verify_auth_proof` over the spec's canonical + encoding (newline-delimited auth fields; request payloads bound in via a + VarInt length prefix), BRC-42/43 child-key derivation per proof + (`protocolID [2, name]`, `keyID = nonce`, `counterparty = verifier`), + SHA-256 + DER ECDSA signing (matches `@bsv/sdk` `createSignature`). + - `check_auth_proof_data` — pure shape/action/freshness checks (mirrors + `checkAuthSigData`), clock-skew tolerance, expiry-bound. + - `AuthProof` / `AuthProofData` with the spec's wire form + (`signature` as an array of byte values; hex accepted on input for + wallet interop). + - Single-use stores: `SingleUseStore` ABC + `MemorySingleUseStore` + (locked, lazy eviction) + `SqliteSingleUseStore` (atomic + `INSERT OR IGNORE` against a unique index; bounded retention). + - `AuthProofMiddleware` — optional Starlette/ASGI auth gate + (proof via `x-bsv-auth-proof` header or JSON body `proof` member; + 401 otherwise; sets `scope["bsv_auth_proof"]`). + - `crypto.keys.derive_signing_public_key` — the verifier-side counterpart + of BRC-43 signing-key derivation. + - **Cross-implementation proof**: `examples/brc138_interop/` runs a + live 4-way interop check against `@bsv/auth`+`@bsv/sdk` (Python↔Node, + bodyless and body-bound), and `tests/test_brc138_interop.py` pins the + certified vectors as a Node-free regression test. +- `docs/MODERNIZATION.md` — full gap analysis of `bsv-brc` vs the current + BRC ecosystem (spec set current to 2026-08-12): the payments 402 family + (BRC-118 multipart, BRC-120 x402, BRC-121 simple 402), BEEF V2 + (BRC-96/158), overlay sync (BRC-76 GASP, BRC-136 BASM), identity-adjacent + standards, and the corrected BRC-101/108/116 notes. + +### Fixed / corrected +- README/CHANGELOG claims about "out of scope" BRCs were stale and wrong: + BRC-101 is *SHIP/SLAP facilitator URL protocols* (not "aspirational"), + BRC-108 is the *Identity-Linked Token Protocol* (not Mandala-bound), + BRC-116 is *Wallet Permissions and Counterparty Trust* (no sCrypt + toolchain involved). Replaced with a current out-of-scope note. +- The venv note in `CLAUDE.md` is updated: the suite is verified on + Python 3.14 + `bsv-sdk` 2.3.3 (197 pre-existing tests stay green). + ## [0.4.0] - 2026-06-03 ### Added diff --git a/README.md b/README.md index 10c4785..7b3e1be 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ open (it ships only the overlay *client* side). | `bsv_brc.brc094` | [BRC-94](https://bsv.brc.dev/key-derivation/0094) | Verifiable ECDH shared secrets via Schnorr proof | | `bsv_brc.brc104` | [BRC-103/104](https://bsv.brc.dev/peer-to-peer/0104) | ASGI mutual-auth adapter over `bsv.auth` | | `bsv_brc.brc105` | [BRC-105](https://bsv.brc.dev/payments/0105) | HTTP 402 micropayment middleware + client | +| `bsv_brc.brc138` | [BRC-138](https://bsv.brc.dev/peer-to-peer/0138) | Single-use signed proofs — login in one request (`@bsv/auth`-compatible) | | `bsv_brc.brc22` | [BRC-22](https://bsv.brc.dev/overlays/0022) | **Server-side** overlay topic submission (`/submit`) | | `bsv_brc.brc24` | [BRC-24](https://bsv.brc.dev/overlays/0024) | **Server-side** lookup services — the feed (`/lookup`) | | `bsv_brc.brc87` | [BRC-87](https://bsv.brc.dev/overlays/0087) | `tm_`/`ls_` overlay name validation | @@ -115,6 +116,30 @@ ok = overlay.verify_state("tm_posts", outpoints) # local state_root == node' Async apps can use the pure `build_submit_headers` / `parse_steak` / `parse_lookup_answer` / `parse_state` helpers with their own HTTP client. +### BRC-138: Authenticate a request in one shot (login, no handshake) + +Single-use signed proofs: prove you hold an identity key in one request, +without a BRC-103 session. Interoperates with the reference TypeScript +`@bsv/auth` out of the box (same default protocol `[2, "bsv auth proof"]`): + +```python +from bsv_brc.brc138 import MemorySingleUseStore, create_auth_proof, verify_auth_proof + +# Client (e.g. a browser wallet): sign a "login" proof toward the server key +proof = create_auth_proof(client_identity_privkey, server_identity_pubkey, "login") +# ...transmit proof.to_dict() (signature = byte values, per spec)... + +# Server: shape -> action -> freshness -> signature -> single-use, atomically +identity = verify_auth_proof( + server_identity_privkey, proof, "login", + single_use_store=MemorySingleUseStore(), # SqliteSingleUseStore for persistence +) +# identity == client public key -> caller provably holds the key +``` + +See [`examples/brc138_auth.py`](examples/brc138_auth.py), and +`examples/brc138_interop/` for the Python ⇄ TypeScript byte-compat proof. + ### BRC-105: Accept micropayments on any endpoint ```python @@ -166,6 +191,7 @@ cert = issue( ## Roadmap - [x] BRC-103/104 — Mutual authentication (ASGI adapter over `bsv.auth`) +- [x] BRC-138 — Single-use signed proofs (login in one request, `@bsv/auth`-compatible) - [x] BRC-22 — Server-side overlay topic submission (`/submit`) - [x] BRC-24 + `OverlayEngine` — Lookup services + a runnable overlay node - [ ] BRC-35 — Global KVStore over overlay (pending byte-exact interop check) @@ -174,12 +200,16 @@ cert = issue( - [x] Per-topic state root + `GET /state` endpoint — **matches `overlay.peck.to` `/state`** - [x] Overlay `OverlayClient` (submit / lookup / state / verify) — defaults to `overlay.peck.to` - [x] Optional SPV verify on submit (`OverlayEngine(verify_tx=...)`, injectable ChainTracker) +- [ ] BRC-118 multipart transport + BRC-121 simple-402 profile for `brc105` - [ ] paymail (→ bsv-compat), peck-anchor client + headers.peck.to ChainTracker (peck-infra lib) -- [ ] GASP-style peer sync + on-chain root anchoring +- [ ] BRC-76 GASP peer sync / BRC-136 BASM + on-chain root anchoring -Deliberately **out of scope** (see `CHANGELOG.md`): BRC-101 (aspirational, -no normative behavior), BRC-108 (no Mandala/BRC-92/107 token base), -BRC-116 (needs an external sCrypt toolchain). +See [`docs/MODERNIZATION.md`](docs/MODERNIZATION.md) for the full BRC +ecosystem gap analysis (payments 402 family, BEEF v2, overlay sync). + +Out of scope today: SHIP/SLAP (BRC-88) — the overlay.social model is +GASP/Merkle-roots, not SHIP/SLAP; BRC-120 x402 conformance (frozen external +spec) and the 1Sat token series (BRC-147/150/159/160) — separate concerns. ## Development @@ -188,7 +218,8 @@ git clone https://github.com/datamynt/bsv-brc-python.git cd bsv-brc-python python3 -m venv .venv && source .venv/bin/activate pip install -e ".[starlette,dev]" -pytest -v # 197 tests +pytest -v # 235 tests +``` ``` ## License diff --git a/docs/MODERNIZATION.md b/docs/MODERNIZATION.md new file mode 100644 index 0000000..cf03e10 --- /dev/null +++ b/docs/MODERNIZATION.md @@ -0,0 +1,147 @@ +# bsv-brc-python — BRC ecosystem modernization review + +_Sist oppdatert: 2026-08-17 · Agent: dsh-peck/deepseek-v4-flash_ + +This document maps `bsv-brc` against the **current** BRC standards +repository (`bsv-blockchain/BRCs`, spec set current to 2026-08-12, 150+ +standards up to BRC-228) and lays out what modernization means for this +library. It is the basis for the 0.5.0 release; items below are prioritized. + +## 1. Where the library stands (verified) + +Baseline on this round: the full suite (**197 tests**) passes on +Python 3.14 with the latest `bsv-sdk` **2.3.3** (the pin floor was 2.1.3). +It remains the only OSS Python package providing the *server side* of the +BSV stack: BRC-22/24 overlay node, BRC-103/104 auth adapter, BRC-105 402 +payments, BRC-52 certificates, BRC-94 Schnorr proofs, BRC-87 names. +Consumers in the wild (this monorepo): `peck-certifier` (brc052), +`peck-web` (brc104 AuthMiddleware + brc052/crypto), `bsv-compat` (bitcom). + +## 2. What matured/changed in the BRC ecosystem since 0.4.0 (2026-06) + +### 2.1 Removed / corrected misconceptions in this repo's docs + +The "out of scope" claims in `README.md`/`CHANGELOG.md` are **stale and +factually wrong** against the current BRC index: + +| Claim in repo | Reality (2026-08 BRC index) | +|---|---| +| "BRC-101: aspirational, no normative behavior" | BRC-101 = *Diverse Facilitators and URL Protocols for SHIP and SLAP Overlay Advertisements* (overlays/0101.md) — has content. | +| "BRC-108: no Mandala/BRC-92/107 token base" | BRC-108 = *Identity-Linked Token Protocol* (tokens/0108.md) — links token state to the identity ledger of BRC-42/52/53. | +| "BRC-116: needs an external sCrypt toolchain" | BRC-116 = *Wallet Permissions and Counterparty Trust* (wallet/0116.md) — pure wallet/wire concept, nothing sCrypt. | +| "BRC-88: not pursued" | Still correct — SHIP/SLAP remains out of the overlay.social model (GASP + state roots instead). | + +These have been corrected in this release (see `README.md`, `CLAUDE.md`). + +### 2.2 New standards directly relevant to this library + +**Payments — the 402 family matured (payments/README.md):** +- **BRC-105** remains the *primary* BSV-native HTTP monetization framework. +- **BRC-118** — Multipart body transport for BRC-105 (large chained BEEFs + blow past header limits; 8 KB auto-switch; backward-compatible, header + transport not deprecated). Direct extension of our `brc105` module. +- **BRC-120** — x402 (frozen external spec, stateless settlement-gated). +- **BRC-121** — Simple 402 (small BSV-specific profile; `x-bsv-sats`, + `x-bsv-server`, `x-bsv-beef`, `x-bsv-nonce`, `x-bsv-time`, `x-bsv-vout`; + replay via wallet `isMerge`). Reference impls: `@bsv/402-pay`, + `go-402-pay`, browser extension. +- **BRC-125** — PeerPay URI scheme for BRC-29 payments. +- **BRC-228** — Unlinkable payments under the identity paradigm (sender + side profile of BRC-29, ephemeral per-payment keys). + +**Authentication — request auth beyond BRC-103:** +- **BRC-138** — Single-Use Signed Proofs for Request Authentication: a + one-shot, expiry-bound, action-bound signed proof; login and one-shot + actions without a full BRC-103 session. Reference impl `@bsv/auth` + (TS). **→ implemented in this release as `bsv_brc.brc138`, proven + byte-compatible with `@bsv/auth` in both directions.** + +**Transactions — BEEF family:** +- **BRC-96** — BEEF V2 Txid-only extension (bandwidth win for ancestors). +- **BRC-158** — Outpoint BEEF. +- **BRC-95** — Atomic BEEF (already in use by overlay/105 paths). + +**Overlay:** +- **BRC-76** — Graph Aware Sync Protocol (GASP) — this is the sync the + repo's roadmap already names; still unimplemented. +- **BRC-136** — Block-Anchored Overlay Synchronization via BASM + (block-aligned sparse Merkle trees) — a newer, block-anchored sync + alternative worth comparing with GASP. +- **BRC-35** — Layered KVStore — still gated in this repo; unchanged. + +**Identity-adjacent (worth a look for peck's identity stack):** +- BRC-77/78 (message signing / portable encrypted messages — py-sdk has + BRC-77 already), BRC-140/154/157 (backups), BRC-169 (universal handle + addressing), BRC-145 (registry-free typed content anchor), BRC-146 + (access gates), BRC-147/150/159/160 (1Sat ordinals). + +### 2.3 SDK floor + +`bsv-sdk` is at **2.3.3**; the repo floor is `>=2.1.3`. The full suite +passes on 2.3.3, so the floor can be raised to `>=2.2.0` (or 2.3.x) at the +next release without code changes — recommended so downstream gets the +latest primitives (the two py-sdk bugs in `docs/upstream/`, filed as +py-sdk#158/#159, persist in 2.3.3 and are worked around here already). + +## 3. What "modernization" means for this library — prioritized roadmap + +Legend: 🟢 done this release · 🟡 next · 🔵 later + +### Auth & payments (the web ergonomics core) + +- 🟢 **BRC-138 single-use proofs** (`bsv_brc.brc138`) — new module; login + primitive for peck-style apps; interoperable with `@bsv/auth` out of the + box (same default protocol `[2, "bsv auth proof"]`). +- 🟡 **BRC-118 multipart transport in `brc105`** — server-side extraction + (`x-bsv-payment-transports` advertising + multipart body parse) and + client-side switch at 8 KB. Needed the moment agents chain payments + (llm-gateway/peck-host use cases). +- 🟡 **BRC-121 simple 402 profile** — a `x-bsv-*` header-only alternative + that works *without* BRC-103 (the repo's current 105 middleware demands + auth first). Good for public, stateless monetized endpoints. +- 🔵 BRC-120 x402 conformance layer (thin — mostly a no-op alias onto 105 + semantics) and BRC-125 PeerPay URI parsing. + +### Overlay node + +- 🟡 **BRC-96 txid-only BEEF / BRC-158 outpoint BEEF** — bandwidth + optimization for `/submit` and `/lookup` answers when peers opt in. +- 🔵 **BRC-76 GASP peer sync** (roadmap item, still open) and/or **BRC-136 + BASM** — the repo's per-topic Merkle state root is already the right + primitive; anchoring the root on-chain + syncing via GASP is the + remaining piece. +- 🔵 BRC-35 KVStore client once a live `tm_kvstore` exists to interop + against. + +### Hygiene + +- 🟡 Raise `bsv-sdk` floor, add py3.14 to CI matrix (suite already green), + fix the stale BRC-101/108/116 claims (done in docs this release). +- 🔵 File the remaining readiness of `docs/upstream/` reports (both already + filed — #158/#159 — mark README accordingly). + +## 4. Decision points for Thomas + +1. **BRC-118 vs BRC-121 first?** Both extend `brc105`. BRC-118 = breathing + room for chained payments (agents); BRC-121 = stateless public paywalls + without auth. The repo's README frames 105 as building on 103 — BRC-121 + deliberately drops that dependency. +2. **GASP vs BASM** for the sync roadmap — worth one design note before + implementing either. +3. **peck-identity alignment**: `identity.peck.to` is described as "BRC-101 + identity resolution" in the architecture docs, but official BRC-101 is + SHIP/SLAP advertisement URLs. Identity resolution in the BRC dir lives + elsewhere (BRC-169 handles; BRC-68 trust anchors; certificates BRC-52). + Worth a correction sweep in peck-docs and identity-services naming. + +## 5. What landed in 0.5.0 (this release) + +- `bsv_brc.brc138` — BRC-138 single-use signed proofs: create / verify / + check, memory + SQLite single-use stores, Starlette ASGI middleware. +- `crypto.keys.derive_signing_public_key` — the verifier-side counterpart + of BRC-43 signing key derivation. +- Cross-implementation interop harness (`examples/brc138_interop/`) + + pinned regression vectors (`tests/test_brc138_interop.py`) certified + against `@bsv/auth` 0.1.3 / `@bsv/sdk` 2.4.1. +- Stale BRC-101/108/116 claims corrected in README/CLAUDE; documented + gap analysis (this file). \ No newline at end of file diff --git a/examples/brc138_auth.py b/examples/brc138_auth.py new file mode 100644 index 0000000..2c1d3cf --- /dev/null +++ b/examples/brc138_auth.py @@ -0,0 +1,59 @@ +""" +BRC-138 single-use signed proof — a login endpoint in one request. + +Demo of the module: proves you hold an identity key (no BRC-103 handshake, +no session) in a single request. Shows both sides with one wallet pair — +in production the client signs in a browser wallet and the server verifies +with its own identity key. +""" + +from bsv import PrivateKey + +from bsv_brc.brc138 import ( + AuthProofError, + MemorySingleUseStore, + create_auth_proof, + verify_auth_proof, +) +from bsv_brc.crypto.keys import public_key_from_private + +# --- Setup: a client wallet and a server wallet (random keys for the demo) --- +client_key = PrivateKey() +server_key = PrivateKey() +server_pub = public_key_from_private(server_key.serialize()).hex() + +# The server's single-use store: consumed nonces are rejected atomically. +# Swap in SqliteSingleUseStore (or any DB with a unique index) for a +# multi-instance deployment. +store = MemorySingleUseStore() + +# --- Client: sign a "login" proof toward the server's identity key --- +proof = create_auth_proof( + client_key.serialize(), + server_pub, + "login", # the only action this proof authorizes +) +print("proof:", proof.data.action, "for identity", proof.data.identity_key[:16], "…") + +# --- Wire form: a plain JSON object (signature as byte values, per BRC-138) --- +wire = proof.to_dict() +print("wire size:", len(str(wire)), "bytes (expires in 2 min, single use)") + +# --- Server: verify (shape → action → freshness → signature → single-use) --- +try: + identity = verify_auth_proof( + server_key.serialize(), + wire, + "login", + single_use_store=store, + ) + print("authenticated:", identity == proof.data.identity_key) +except AuthProofError as exc: + print("rejected:", exc) + +# --- A replay of the same proof is rejected --- +try: + verify_auth_proof(server_key.serialize(), wire, "login", single_use_store=store) + print("replay ACCEPTED (bug!)") +except AuthProofError as exc: + print("replay rejected:", exc) \ No newline at end of file diff --git a/examples/brc138_interop/README.md b/examples/brc138_interop/README.md new file mode 100644 index 0000000..5ef360a --- /dev/null +++ b/examples/brc138_interop/README.md @@ -0,0 +1,50 @@ +# BRC-138 interop check (Python ⇄ TypeScript reference) + +Proves `bsv_brc.brc138` is byte-compatible with the canonical TypeScript +implementation for BRC-138, [`@bsv/auth`](https://www.npmjs.com/package/@bsv/auth) +(which wraps the `@bsv/sdk` BRC-100 wallet interface): + +1. Python creates a proof → Node (`verifyAuthProof`) accepts it +2. Python creates a proof with a bound payload → Node accepts it +3. Node (`createAuthProof`) creates a proof → Python (`verify_auth_proof`) accepts it +4. Node creates a proof with a bound binary payload → Python accepts it + +The pinned vectors from a certified run live in +`tests/test_brc138_interop.py` (a regression test that needs no Node), so the +byte-compatibility contract stays guarded in CI. + +## Setup + +```bash +cd examples/brc138_interop +npm install # installs @bsv/auth + @bsv/sdk (see package.json) +cd ../.. +python -m venv .venv && source .venv/bin/activate # or reuse an existing venv +pip install -e ".[dev]" +``` + +## Run the live cross-implementation check + +```bash +python examples/brc138_interop/run_interop.py +``` + +Expect: + +``` +[1/4] Python proof verified by @bsv/auth (Node): OK +[2/4] Python bound-payload proof verified by @bsv/auth (Node): OK +[3/4] Node proof verified by bsv_brc.brc138 (Python): OK +[4/4] Node bound-payload proof verified by bsv_brc.brc138 (Python): OK +ALL INTEROP CHECKS PASSED +``` + +## Re-capture pinned vectors (after upstream changes) + +```bash +python examples/brc138_interop/capture_vectors.py +``` + +Paste the printed JSON into `tests/test_brc138_interop.py` (each vector is +re-certified against Node during capture — the script aborts if Node rejects +one). \ No newline at end of file diff --git a/examples/brc138_interop/capture_vectors.py b/examples/brc138_interop/capture_vectors.py new file mode 100644 index 0000000..cea4c61 --- /dev/null +++ b/examples/brc138_interop/capture_vectors.py @@ -0,0 +1,92 @@ +"""Capture pinned cross-implementation vectors for the regression test. + +Uses FIXED keys and times so the resulting JSON can be embedded in +tests/test_brc138_interop.py. Each vector is certified against the reference +@bsv/auth implementation (Node) at capture time. +""" +from __future__ import annotations + +import base64 +import json +import subprocess + +from bsv_brc.brc138 import create_auth_proof +from bsv_brc.crypto.keys import public_key_from_private + +# Fixed identities (generated once, used only as test fixtures). +CLIENT_PRIV = "524c969962c3128365f5c147cea31c8cad0bad2b745020c0ad42f4d7a1785b2e" +SERVER_PRIV = "9777da3f30df19f2c1cd61420e9688e673205dd02bb4738291eaceac5eecdcf9" +FIXED_NOW = int(__import__("time").time() * 1000) # fresh epoch ms at capture + + +def b64(b: bytes) -> str: + return base64.b64encode(b).decode() + + +def certify_proof(proof_dict: dict, action: str, payload_b64=None) -> dict: + """Ask the reference @bsv/auth (Node) to verify; assert it accepts.""" + vector = { + "clientPrivHex": CLIENT_PRIV, + "serverPrivHex": SERVER_PRIV, + "action": action, + "proof": proof_dict, + } + if payload_b64 is not None: + vector["payloadB64"] = payload_b64 + with open("/tmp/cap_vector.json", "w") as fh: + json.dump(vector, fh) + res = subprocess.run( + ["node", "interop.mjs", "verifyPy", "/tmp/cap_vector.json"], + capture_output=True, + text=True, + check=True, + ) + out = json.loads(res.stdout.strip().splitlines()[-1]) + assert out.get("valid") is True, f"Node rejected vector: {out}" + return out + + +def main() -> None: + server_pub = public_key_from_private(bytes.fromhex(SERVER_PRIV)).hex() + + # Vector 1: Python-created, bodyless login — certified by Node. + proof1 = create_auth_proof( + bytes.fromhex(CLIENT_PRIV), server_pub, "login", now_ms=FIXED_NOW + ) + certify_proof(proof1.to_dict(), "login") + print("### PYTHON LOGIN (certified by Node)") + print(json.dumps(proof1.to_dict())) + + # Vector 2: Python-created, bound payload — certified by Node. + payload2 = b'{"username":"alice","role":"admin"}' + proof2 = create_auth_proof( + bytes.fromhex(CLIENT_PRIV), + server_pub, + "updateProfile", + payload=payload2, + now_ms=FIXED_NOW, + ) + certify_proof(proof2.to_dict(), "updateProfile", b64(payload2)) + print("### PYTHON UPDATE PROFILE (certified by Node)") + print(json.dumps(proof2.to_dict())) + print(f"### PAYLOAD2: {b64(payload2)}") + + # Vectors 3/4: Node-created — Python must verify them. + for tag, action, payload in ( + ("login", "login", None), + ("transcribe", "transcribe", b"\x00\x01\xff raw binary body"), + ): + args = ["node", "interop.mjs", "create", CLIENT_PRIV, SERVER_PRIV, action] + if payload is not None: + args.append(b64(payload)) + res = subprocess.run(args, capture_output=True, text=True, check=True) + out = json.loads(res.stdout.strip().splitlines()[-1]) + print(f"### NODE {tag.upper()}") + print(json.dumps(out["data"])) + print(f"### NODE {tag.upper()} SIG: " + json.dumps(out["signature"])) + if payload is not None: + print(f"### NODE {tag.upper()} PAYLOAD: {b64(payload)}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/brc138_interop/interop.mjs b/examples/brc138_interop/interop.mjs new file mode 100644 index 0000000..f807caa --- /dev/null +++ b/examples/brc138_interop/interop.mjs @@ -0,0 +1,59 @@ +/** + * BRC-138 cross-implementation interop driver (TypeScript side). + * + * Verifies proofs created by the Python implementation and creates proofs + * the Python implementation verifies, using the reference @bsv/auth library + * and @bsv/sdk ProtoWallet. This proves the Python module is byte-compatible + * with the canonical TypeScript implementation. + * + * Usage: + * node interop.mjs verifyPy # verify a Python-made proof + * node interop.mjs create + * + * verifyPy input JSON: { clientPrivHex, serverPrivHex, action, proof, payloadB64? } + * Output: JSON { valid, identityKey?, error? } + */ +import { PrivateKey, ProtoWallet } from '@bsv/sdk' +import { verifyAuthProof, createAuthProof } from '@bsv/auth' + +const mode = process.argv[2] + +function hexSig(sig) { + // Accept byte array (Python wire form) or hex string; return hex. + if (typeof sig === 'string') return sig + if (Array.isArray(sig)) return Buffer.from(sig).toString('hex') + throw new Error('unknown signature form') +} + + +if (mode === 'verifyPy') { + const fs = await import('node:fs') + const data = JSON.parse(fs.readFileSync(process.argv[3], "utf8")) + const serverWallet = new ProtoWallet(PrivateKey.fromHex(data.serverPrivHex)) + const proof = { + data: data.proof.data, + signature: data.proof.signature // spec wire form: array of byte values + } + const consumeNonce = async () => true // single-use is exercised on the Python side + const result = await verifyAuthProof({ + wallet: serverWallet, + proof, + action: data.action, + consumeNonce, + body: data.payloadB64 !== undefined ? Buffer.from(data.payloadB64, 'base64') : undefined + }) + console.log(JSON.stringify(result)) +} else if (mode === 'create') { + const clientPrivHex = process.argv[3] + const serverPrivHex = process.argv[4] + const action = process.argv[5] + const payloadB64 = process.argv[6] !== undefined ? process.argv[6] : undefined + const clientWallet = new ProtoWallet(PrivateKey.fromHex(clientPrivHex)) + const serverPubHex = PrivateKey.fromHex(serverPrivHex).toPublicKey().toString() + const body = payloadB64 !== undefined ? Buffer.from(payloadB64, 'base64') : undefined + const proof = await createAuthProof({ wallet: clientWallet, counterparty: serverPubHex, action, body }) + console.log(JSON.stringify({ ...proof, identityKey: (await clientWallet.getPublicKey({ identityKey: true })).publicKey })) +} else { + console.error('unknown mode') + process.exit(1) +} diff --git a/examples/brc138_interop/package-lock.json b/examples/brc138_interop/package-lock.json new file mode 100644 index 0000000..080d72b --- /dev/null +++ b/examples/brc138_interop/package-lock.json @@ -0,0 +1,39 @@ +{ + "name": "brc138_interop", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@bsv/auth": "^0.1.3", + "@bsv/sdk": "^2.4.1" + } + }, + "node_modules/@bsv/auth": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@bsv/auth/-/auth-0.1.3.tgz", + "integrity": "sha512-l4EeDAKnWTOA6P4zDrP01vEssS7Ur+44hBnaFiSiLAKm2NR22WjrLiN7Nm0s9lxqLv9+MJjiVWp/cHH+CLsxig==", + "license": "SEE LICENSE IN LICENSE.txt", + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@bsv/sdk": "^2.1.6" + }, + "peerDependenciesMeta": { + "@bsv/sdk": { + "optional": false + } + } + }, + "node_modules/@bsv/sdk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@bsv/sdk/-/sdk-2.4.1.tgz", + "integrity": "sha512-iCsPJN3ESeV5ncg78lzkFKsJ6YFTIMN/6zLt7WPsL5iwzM5Rrg+/y+zjhFYcI7PFXhlXdqUehHefPLrxhljw8Q==", + "license": "SEE LICENSE IN LICENSE.txt", + "engines": { + "node": ">=22" + } + } + } +} diff --git a/examples/brc138_interop/package.json b/examples/brc138_interop/package.json new file mode 100644 index 0000000..41b9c02 --- /dev/null +++ b/examples/brc138_interop/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "@bsv/auth": "^0.1.3", + "@bsv/sdk": "^2.4.1" + } +} diff --git a/examples/brc138_interop/run_interop.py b/examples/brc138_interop/run_interop.py new file mode 100644 index 0000000..d02b39b --- /dev/null +++ b/examples/brc138_interop/run_interop.py @@ -0,0 +1,125 @@ +"""BRC-138 cross-implementation interop driver (Python side). + +Proves byte-compatibility with the reference TypeScript implementation +@bsv/auth: creates proofs Python -> Node, and verifies proofs Node -> Python. +Run from the repo root after installing the interop deps (see +examples/brc138_interop/README.md). +""" +from __future__ import annotations + +import base64 +import json +import subprocess +import sys +import time +from pathlib import Path + +from bsv import PrivateKey + +from bsv_brc.brc138 import ( + AuthProof, + create_auth_proof, + verify_auth_proof, +) +from bsv_brc.crypto.keys import public_key_from_private + +INTEROP_DIR = str(Path(__file__).resolve().parent) +NOW = int(time.time() * 1000) + + +def rand_hex() -> str: + return PrivateKey().serialize().hex() + + +def call_node(mode: str, *args) -> dict: + result = subprocess.run( + ["node", f"{INTEROP_DIR}/interop.mjs", mode, *args], + capture_output=True, + text=True, + check=True, + cwd=INTEROP_DIR, # resolve @bsv/auth + @bsv/sdk from node_modules here + ) + return json.loads(result.stdout.strip().splitlines()[-1]) + + +def b64(b: bytes) -> str: + return base64.b64encode(b).decode() + + +def main() -> None: + client_priv = rand_hex() + server_priv = rand_hex() + server_pub = public_key_from_private(bytes.fromhex(server_priv)).hex() + client_pub = public_key_from_private(bytes.fromhex(client_priv)).hex() + + # ---- 1. Python creates, Node verifies (bodyless login) ---- + proof = create_auth_proof( + bytes.fromhex(client_priv), server_pub, "login", now_ms=NOW + ) + vector = { + "clientPrivHex": client_priv, + "serverPrivHex": server_priv, + "action": "login", + "proof": proof.to_dict(), + } + with open("/tmp/interop_vector.json", "w") as fh: + json.dump(vector, fh) + res = call_node("verifyPy", "/tmp/interop_vector.json") + assert res.get("valid") is True, f"Node rejected Python proof: {res}" + assert res.get("identityKey") == client_pub, res + print("[1/4] Python proof verified by @bsv/auth (Node): OK") + + # ---- 2. Python creates with bound payload, Node verifies ---- + payload = b'{"username":"alice","role":"admin"}' + proof2 = create_auth_proof( + bytes.fromhex(client_priv), + server_pub, + "updateProfile", + payload=payload, + now_ms=NOW, + ) + vector2 = { + "clientPrivHex": client_priv, + "serverPrivHex": server_priv, + "action": "updateProfile", + "proof": proof2.to_dict(), + "payloadB64": b64(payload), + } + with open("/tmp/interop_vector2.json", "w") as fh: + json.dump(vector2, fh) + res2 = call_node("verifyPy", "/tmp/interop_vector2.json") + assert res2.get("valid") is True, f"Node rejected Python bound proof: {res2}" + print("[2/4] Python bound-payload proof verified by @bsv/auth (Node): OK") + + # ---- 3. Node creates, Python verifies (bodyless) ---- + res3 = call_node("create", client_priv, server_priv, "login") + py_proof = AuthProof.from_dict({"data": res3["data"], "signature": res3["signature"]}) + assert py_proof.data.identity_key == client_pub, res3 + identity = verify_auth_proof( + bytes.fromhex(server_priv), + py_proof, + "login", + now_ms=NOW + 1_000, + ) + assert identity == client_pub + print("[3/4] Node proof verified by bsv_brc.brc138 (Python): OK") + + # ---- 4. Node creates with bound payload, Python verifies ---- + payload4 = b"\x00\x01\xff raw binary body" + res4 = call_node("create", client_priv, server_priv, "transcribe", b64(payload4)) + py_proof4 = AuthProof.from_dict({"data": res4["data"], "signature": res4["signature"]}) + identity4 = verify_auth_proof( + bytes.fromhex(server_priv), + py_proof4, + "transcribe", + payload=payload4, + now_ms=NOW + 1_000, + ) + assert identity4 == client_pub + print("[4/4] Node bound-payload proof verified by bsv_brc.brc138 (Python): OK") + + print("ALL INTEROP CHECKS PASSED") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index fa4bb26..1d14ffb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "bsv-brc" -version = "0.4.0" -description = "Framework adapters for the official bsv.auth (BRC-103/104) plus BRC-52, BRC-94, the BRC-105 HTTP micropayment layer, and a server-side BRC-22 overlay topic-submission framework" +version = "0.5.0" +description = "Framework adapters for the official bsv.auth (BRC-103/104) plus BRC-52, BRC-94, BRC-138 single-use auth proofs, the BRC-105 HTTP micropayment layer, and a server-side BRC-22 overlay topic-submission framework" readme = "README.md" license = {text = "Open BSV License"} requires-python = ">=3.10" -keywords = ["bsv", "brc-52", "brc-94", "brc-105", "identity", "certificates", "micropayments", "402", "schnorr", "secp256k1"] +keywords = ["bsv", "brc-52", "brc-94", "brc-105", "brc-138", "identity", "certificates", "micropayments", "402", "auth", "schnorr", "secp256k1"] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", @@ -34,7 +34,7 @@ Repository = "https://github.com/datamynt/bsv-brc-python" Issues = "https://github.com/datamynt/bsv-brc-python/issues" [tool.hatch.build.targets.sdist] -include = ["src/bsv_brc", "tests", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"] +include = ["src/bsv_brc", "tests", "docs", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"] exclude = [".venv*", "venv", "build", "dist", "**/__pycache__", "*.pyc"] [tool.hatch.build.targets.wheel] diff --git a/src/bsv_brc/__init__.py b/src/bsv_brc/__init__.py index 6db4e4a..e1d1f2e 100644 --- a/src/bsv_brc/__init__.py +++ b/src/bsv_brc/__init__.py @@ -12,6 +12,7 @@ brc094 — BRC-94 verifiable ECDH shared secrets (Schnorr proof) brc104 — BRC-103/104 mutual auth, ASGI adapter over bsv.auth brc105 — BRC-105 HTTP 402 micropayments (middleware + client) + brc138 — BRC-138 single-use signed proofs (request authentication) brc22 — BRC-22 server-side overlay topic submission (/submit) brc24 — BRC-24 server-side lookup services / feed (/lookup) brc87 — BRC-87 tm_/ls_ overlay name validation @@ -25,7 +26,7 @@ Compatible with @bsv/sdk (TypeScript). License: Open BSV. """ -__version__ = "0.4.0" +__version__ = "0.5.0" # Always-available core (no optional dependencies). from bsv_brc.brc105.types import ( @@ -59,6 +60,23 @@ SqliteOverlayStorage, UnknownServiceError, ) +from bsv_brc.brc138 import ( + DEFAULT_CLOCK_SKEW_MS, + DEFAULT_PROTOCOL, + DEFAULT_VALIDITY_WINDOW_MS, + NONCE_BYTES, + AuthProof, + AuthProofData, + AuthProofError, + MemorySingleUseStore, + SingleUseStore, + SqliteSingleUseStore, + check_auth_proof_data, + create_auth_proof, + generate_nonce, + normalize_body, + verify_auth_proof, +) __all__ = [ "__version__", @@ -68,6 +86,22 @@ "PricingStrategy", "StaticPricing", "NonceManager", + # BRC-138 single-use signed proofs + "AuthProof", + "AuthProofData", + "AuthProofError", + "SingleUseStore", + "MemorySingleUseStore", + "SqliteSingleUseStore", + "DEFAULT_PROTOCOL", + "DEFAULT_VALIDITY_WINDOW_MS", + "DEFAULT_CLOCK_SKEW_MS", + "NONCE_BYTES", + "check_auth_proof_data", + "create_auth_proof", + "generate_nonce", + "normalize_body", + "verify_auth_proof", # BRC-22 overlay (server side) "TopicManager", "TopicEngine", diff --git a/src/bsv_brc/brc138/__init__.py b/src/bsv_brc/brc138/__init__.py new file mode 100644 index 0000000..4f06ebf --- /dev/null +++ b/src/bsv_brc/brc138/__init__.py @@ -0,0 +1,65 @@ +""" +BRC-138: Single-Use Signed Proofs for Request Authentication. + +A lightweight, signature-based, expiry-bound, single-use request +authentication primitive — login and one-shot actions without a full +BRC-103 mutual-authentication session. Interoperates with the reference +``@bsv/auth`` implementation by default (same protocol, window and skew). + +Core (no framework dependency): + + from bsv_brc.brc138 import ( + create_auth_proof, verify_auth_proof, + AuthProof, MemorySingleUseStore, + ) + + proof = create_auth_proof(client_private_key, server_public_key, "login") + # ... transmit proof.to_dict() ... + + identity = verify_auth_proof( + server_private_key, proof, "login", + single_use_store=MemorySingleUseStore(), + ) + +Optional Starlette middleware (requires the ``starlette`` extra): + + from bsv_brc.brc138.adapters.asgi import AuthProofMiddleware +""" + +from bsv_brc.brc138.proof import ( + DEFAULT_CLOCK_SKEW_MS, + DEFAULT_PROTOCOL, + DEFAULT_VALIDITY_WINDOW_MS, + NONCE_BYTES, + AuthProof, + AuthProofData, + AuthProofError, + check_auth_proof_data, + create_auth_proof, + generate_nonce, + normalize_body, + verify_auth_proof, +) +from bsv_brc.brc138.store import ( + MemorySingleUseStore, + SingleUseStore, + SqliteSingleUseStore, +) + +__all__ = [ + "AuthProof", + "AuthProofData", + "AuthProofError", + "DEFAULT_CLOCK_SKEW_MS", + "DEFAULT_PROTOCOL", + "DEFAULT_VALIDITY_WINDOW_MS", + "NONCE_BYTES", + "MemorySingleUseStore", + "SingleUseStore", + "SqliteSingleUseStore", + "check_auth_proof_data", + "create_auth_proof", + "generate_nonce", + "normalize_body", + "verify_auth_proof", +] diff --git a/src/bsv_brc/brc138/adapters/__init__.py b/src/bsv_brc/brc138/adapters/__init__.py new file mode 100644 index 0000000..de09d93 --- /dev/null +++ b/src/bsv_brc/brc138/adapters/__init__.py @@ -0,0 +1 @@ +"""Framework adapters for BRC-138 (currently: ASGI/Starlette).""" diff --git a/src/bsv_brc/brc138/adapters/asgi.py b/src/bsv_brc/brc138/adapters/asgi.py new file mode 100644 index 0000000..25b7bd8 --- /dev/null +++ b/src/bsv_brc/brc138/adapters/asgi.py @@ -0,0 +1,238 @@ +""" +ASGI adapter for BRC-138 single-use signed proofs (Starlette/FastAPI/FastHTML). + +The BRC-138 spec deliberately leaves transport open; this adapter provides a +convenient default: a proof is read from the request — either the +``x-bsv-auth-proof`` header (the wire JSON) or a ``proof`` member of a JSON +body — verified, and on success the authenticated identity key is exposed to +the wrapped application as ``scope["bsv_auth_proof"]`` (and +``scope["state"]["bsv_auth_proof"]`` so ``request.state.auth_proof`` works in +Starlette). On failure a 401 JSON response is returned and the wrapped +application is never invoked. + +Because the middleware may consume the request body to find the proof, the +body is buffered and replayed to the wrapped application unchanged (the same +pattern as :class:`bsv_brc.brc104.adapters.asgi.AuthMiddleware`). +""" + +from __future__ import annotations + +import json +from typing import Any, Awaitable, Callable + +from bsv_brc._asgi import MAX_BODY_BYTES, BodyTooLarge, read_body_capped +from bsv_brc.brc138.proof import ( + DEFAULT_PROTOCOL, + AuthProof, + AuthProofError, + verify_auth_proof, +) + +PROOF_HEADER = "x-bsv-auth-proof" + +ASGIApp = Callable[ + [dict, Callable[[], Awaitable[dict]], Callable[[dict], Awaitable[None]]], + Awaitable[None], +] + +# Type for the proof-extraction callback: given the raw request bytes and the +# request headers, return the proof wire dict or None. +GetProofFn = Callable[[bytes, list[tuple[bytes, bytes]]], dict[str, Any] | None] + + +async def _send_json( + send: Callable[[dict], Awaitable[None]], status: int, payload: dict +) -> None: + body = json.dumps(payload, default=str).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + } + ) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + +def _header_value(headers: list[tuple[bytes, bytes]], name: str) -> str | None: + target = name.lower().encode("latin-1") + for k, v in headers: + if k.lower() == target: + return v.decode("latin-1") + return None + + +def get_proof_from_header_or_body( + body: bytes, + headers: list[tuple[bytes, bytes]], +) -> dict[str, Any] | None: + """Default proof extraction: header first, else a ``proof`` JSON member.""" + header = _header_value(headers, PROOF_HEADER) + if header: + parsed = json.loads(header) + return parsed if isinstance(parsed, dict) else None + if not body: + return None + data = json.loads(body.decode("utf-8")) + proof = data.get("proof") if isinstance(data, dict) else None + return proof if isinstance(proof, dict) else None + + +def _wallet_identity_key(wallet: Any) -> bytes | str: + """Best-effort extraction of a wallet's identity private key.""" + for method in ("get_private_key", "get_identity_key"): + fn = getattr(wallet, method, None) + if fn is None: + continue + try: + value = fn("identity") if method == "get_private_key" else fn() + except TypeError: + value = fn() + if value is not None: + return value + raise AuthProofError( + "cannot read identity private key from wallet; pass get_identity_private_key" + ) + + +class AuthProofMiddleware: + """ + Raw-ASGI middleware that authenticates requests with a BRC-138 proof. + + Args: + app: The wrapped ASGI application. + wallet: The server wallet whose identity key clients sign toward. Used + only to read the server's identity private key (see + ``get_identity_private_key``). + expected_action: The only action this deployment authorizes, e.g. + ``"login"``. + get_identity_private_key: Callable returning the server's identity + private key (32 bytes or hex). Defaults to a best-effort read of + ``wallet.get_private_key("identity")``. + get_proof: Optional ``(body, headers) -> dict | None`` callback. + Defaults to :func:`get_proof_from_header_or_body`. + single_use_store: Optional + :class:`~bsv_brc.brc138.store.SingleUseStore`. Pass one in + production; without it replay protection is disabled. + protocol / validity_window_ms / clock_skew_ms: Verification parameters; + must match the client's. + excluded_paths: Paths that skip authentication (e.g. the BRC-103 + handshake endpoint). + max_body_bytes: Cap on the buffered request body. + """ + + def __init__( + self, + app: ASGIApp, + *, + wallet: Any, + expected_action: str, + get_identity_private_key: Callable[[], bytes | str] | None = None, + get_proof: GetProofFn | None = None, + single_use_store: Any = None, + protocol: Any = DEFAULT_PROTOCOL, + validity_window_ms: int = 120_000, + clock_skew_ms: int = 30_000, + excluded_paths: set[str] | None = None, + max_body_bytes: int = MAX_BODY_BYTES, + ) -> None: + if wallet is None: + raise ValueError("AuthProofMiddleware requires a wallet instance") + self.app = app + self.wallet = wallet + self.expected_action = expected_action + self.get_identity_private_key = get_identity_private_key or ( + lambda: _wallet_identity_key(wallet) + ) + self.get_proof = get_proof or get_proof_from_header_or_body + self.single_use_store = single_use_store + self.protocol = protocol + self.validity_window_ms = validity_window_ms + self.clock_skew_ms = clock_skew_ms + self.excluded_paths = excluded_paths or {"/health", "/.well-known/auth"} + self.max_body_bytes = max_body_bytes + + async def __call__(self, scope: dict, receive, send) -> None: + if scope.get("type") != "http": + await self.app(scope, receive, send) + return + + path = scope.get("path", "/") or "/" + if path in self.excluded_paths: + await self.app(scope, receive, send) + return + + headers = scope.get("headers", []) + if _header_value(headers, PROOF_HEADER) is None and not _has_json_body( + headers + ): + # No proof header and no JSON body that could carry a proof — + # the request is unauthenticated. This middleware is an + # authentication gate: reject rather than pass through. + await _send_json(send, 401, {"error": "missing authentication proof"}) + return + + try: + body = await read_body_capped(receive, self.max_body_bytes) + except BodyTooLarge: + await _send_json(send, 413, {"error": "request body too large"}) + return + + try: + proof_dict = self.get_proof(body, headers) + except (json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: + await _send_json(send, 401, {"error": f"malformed proof payload: {exc}"}) + return + + if proof_dict is None: + await _send_json(send, 401, {"error": "missing authentication proof"}) + return + + try: + identity_key = verify_auth_proof( + self.get_identity_private_key(), + AuthProof.from_dict(proof_dict), + self.expected_action, + protocol=self.protocol, + validity_window_ms=self.validity_window_ms, + clock_skew_ms=self.clock_skew_ms, + single_use_store=self.single_use_store, + ) + except AuthProofError as exc: + await _send_json(send, 401, {"error": str(exc)}) + return + + scope = dict(scope) + scope["bsv_auth_proof"] = {"identity_key": identity_key} + state = scope.setdefault("state", {}) + if isinstance(state, dict): + state["bsv_auth_proof"] = {"identity_key": identity_key} + + body_consumed = False + + async def replay_receive() -> dict: + nonlocal body_consumed + if body_consumed: + return {"type": "http.disconnect"} + body_consumed = True + return {"type": "http.request", "body": body, "more_body": False} + + async def passthrough_send(message: dict) -> None: + await send(message) + + await self.app(scope, replay_receive, passthrough_send) + + +def _has_json_body(headers: list[tuple[bytes, bytes]]) -> bool: + ctype = _header_value(headers, "content-type") or "" + return ctype.startswith("application/json") + + +__all__ = [ + "AuthProofMiddleware", + "PROOF_HEADER", + "get_proof_from_header_or_body", +] diff --git a/src/bsv_brc/brc138/proof.py b/src/bsv_brc/brc138/proof.py new file mode 100644 index 0000000..2cf6bec --- /dev/null +++ b/src/bsv_brc/brc138/proof.py @@ -0,0 +1,451 @@ +""" +BRC-138: Single-Use Signed Proofs for Request Authentication. + +A lightweight mechanism for a server to authenticate that a request was made +by the holder of a wallet identity key — login being the most common example — +in a single request, without a prior challenge round-trip and without a full +BRC-103 mutual-authentication session. + +A proof is a small signed payload ``{ action, identityKey, expiresAt, nonce }`` +together with a signature over its canonical encoding, created with the +client's signing key derived toward the verifier's identity key (BRC-42/43, +``protocolID = [2, name]``, ``keyID = nonce``, ``counterparty = verifierKey``). +Because the keyID is the per-request nonce, a distinct child key is derived for +every proof — no signing key is ever reused. The verifier checks shape, action, +freshness (expiry-bound, with clock-skew tolerance), the signature, and finally +consumes the nonce in an atomic single-use store. + +A request payload MAY be bound into the signature: the canonical bytes are the +auth fields joined by newlines, then a VarInt length prefix, then the exact +payload bytes. Bind the raw bytes you transmit / receive so a tampered body +fails verification. + +Wire form (``to_dict`` / ``from_dict``): ``{"data": {...}, "signature": [0-255 +byte values]}`` — the signature is transported as an array of bytes per the +spec. Hex strings are also accepted on input for interop with wallets that +hand back DER-hex signatures (e.g. ``@bsv/auth``). + +The default ``protocol``, validity window and clock skew match the reference +implementation ``@bsv/auth`` (``DEFAULT_PROTOCOL = [2, "bsv auth proof"]``), +so proofs interoperate across languages out of the box. + +References: + BRC-138: https://bsv.brc.dev/peer-to-peer/0138 + BRC-42: https://bsv.brc.dev/key-derivation/0042 + BRC-43: https://bsv.brc.dev/key-derivation/0043 + @bsv/auth: https://www.npmjs.com/package/@bsv/auth +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +import time +from dataclasses import dataclass +from typing import Any, Sequence + +from bsv import PrivateKey, PublicKey + +from bsv_brc.crypto.keys import ( + derive_signing_key, + derive_signing_public_key, + public_key_from_private, +) + +# Matches the reference implementation @bsv/auth (createAuthProof/verifyAuthProof). +DEFAULT_PROTOCOL: tuple[int, str] = (2, "bsv auth proof") +DEFAULT_VALIDITY_WINDOW_MS = 120_000 # 2 minutes +DEFAULT_CLOCK_SKEW_MS = 30_000 # 30 seconds +NONCE_BYTES = 32 + +_ACTION_RE = re.compile(r"^[A-Za-z0-9 ]+$") + + +class AuthProofError(ValueError): + """Raised when a proof cannot be created, parsed or verified.""" + + +def _varint(n: int) -> bytes: + """Bitcoin variable-length integer encoding (compactint).""" + if n < 0xFD: + return bytes([n]) + if n <= 0xFFFF: + return b"\xfd" + n.to_bytes(2, "little") + if n <= 0xFFFFFFFF: + return b"\xfe" + n.to_bytes(4, "little") + return b"\xff" + n.to_bytes(8, "little") + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _identity_key_bytes(identity_key: str) -> bytes: + try: + raw = bytes.fromhex(identity_key) + except ValueError as exc: + raise AuthProofError("identityKey must be hex-encoded") from exc + if len(raw) != 33 or raw[0] not in (0x02, 0x03): + raise AuthProofError("identityKey must be a 33-byte compressed public key") + return raw + + +def _protocol_parts(protocol: Sequence[int | str]) -> tuple[int, str]: + if len(protocol) != 2 or protocol[0] != 2: + raise AuthProofError( + "protocol must be a BRC-43 protocol identifier of the form [2, name]" + ) + name = str(protocol[1]) + if not name or any(ord(c) < 0x20 for c in name): + raise AuthProofError( + "protocol name must not contain control characters" + ) + return 2, name + + +def _check_action(action: str) -> None: + """Actions MUST NOT contain the newline delimiter (or other control chars). + + The spec's examples are "letters, numbers and spaces", but the normative + requirement is to reject anything that could contain the delimiter; the + reference implementation only requires a non-empty string. We reject + control characters (which would break the line-delimited canonical + encoding) but stay permissive with punctuation so Python and TypeScript + clients agree. + """ + if not isinstance(action, str) or not action: + raise AuthProofError("action must be a non-empty string") + if any(ord(c) < 0x20 for c in action): + raise AuthProofError("action must not contain control characters") + + +def generate_nonce() -> str: + """Base64 of 32 cryptographically random bytes — a fresh nonce.""" + return base64.b64encode(os.urandom(NONCE_BYTES)).decode("ascii") + + +@dataclass(frozen=True) +class AuthProofData: + """The signed statement inside a BRC-138 proof.""" + + action: str + identity_key: str # hex-encoded, compressed (33-byte) public key + expires_at: int # epoch milliseconds + nonce: str + + def canonical_bytes(self, payload: bytes | None = None) -> bytes: + """ + The exact bytes signed and verified. + + ``S = action + "\\n" + identityKey + "\\n" + decimal(expiresAt) + "\\n" + + nonce``, UTF-8 encoded. A bound request payload is appended + length-prefixed (VarInt) rather than delimited, so arbitrary binary + content is unambiguous, and an empty bound payload (length 0) is + distinct from no bound payload (nothing appended). + """ + head = ( + f"{self.action}\n{self.identity_key}\n{self.expires_at}\n{self.nonce}" + ).encode("utf-8") + if payload is None: + return head + return head + _varint(len(payload)) + payload + + def to_dict(self) -> dict[str, Any]: + return { + "action": self.action, + "identityKey": self.identity_key, + "expiresAt": self.expires_at, + "nonce": self.nonce, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "AuthProofData": + if not isinstance(data, dict): + raise AuthProofError("proof data must be an object") + try: + action = data["action"] + identity_key = data["identityKey"] + expires_at = data["expiresAt"] + nonce = data["nonce"] + except KeyError as exc: + raise AuthProofError( + f"malformed proof: missing field {exc.args[0]}" + ) from exc + _check_action(action) + if not isinstance(identity_key, str) or not identity_key: + raise AuthProofError("identityKey must be a non-empty string") + _identity_key_bytes(identity_key) + if not isinstance(expires_at, (int, float)) or not _is_finite(expires_at): + raise AuthProofError("expiresAt must be a finite number") + if not isinstance(nonce, str) or not nonce: + raise AuthProofError("nonce must be a non-empty string") + return cls( + action=action, + identity_key=identity_key, + expires_at=int(expires_at), + nonce=nonce, + ) + + +def _is_finite(value: Any) -> bool: + try: + return float(value) == float(value) # NaN != NaN + except (TypeError, ValueError): + return False + + +def _decode_signature(signature: Any) -> bytes: + """Accept the spec's byte-array form plus bytes/hex for interop.""" + if isinstance(signature, (bytes, bytearray)): + return bytes(signature) + if isinstance(signature, str): + try: + return bytes.fromhex(signature) + except ValueError: + raise AuthProofError( + "signature string must be hex-encoded DER" + ) from None + if isinstance(signature, (list, tuple)): + if all(isinstance(b, int) and 0 <= b <= 255 for b in signature): + return bytes(signature) + raise AuthProofError("signature array must contain byte values 0-255") + raise AuthProofError( + "signature must be an array of byte values, a hex string, or bytes" + ) + + +@dataclass(frozen=True) +class AuthProof: + """A complete BRC-138 authentication proof.""" + + data: AuthProofData + signature: bytes # DER-encoded ECDSA signature + + def to_dict(self) -> dict[str, Any]: + """Wire form: ``{"data": {...}, "signature": [byte values]}``.""" + return {"data": self.data.to_dict(), "signature": list(self.signature)} + + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> "AuthProof": + if not isinstance(obj, dict) or not isinstance(obj.get("data"), dict): + raise AuthProofError("malformed proof: expected {'data': ..., 'signature': ...}") + if "signature" not in obj: + raise AuthProofError("malformed proof: missing signature") + return cls( + data=AuthProofData.from_dict(obj["data"]), + signature=_decode_signature(obj["signature"]), + ) + + +def normalize_body(body: Any) -> bytes: + """ + Reduce a request payload to the exact bytes bound into a signature. + + Mirrors the reference implementation's ``normalizeBody``: a string is + UTF-8, bytes are taken as raw bytes, anything else is JSON-encoded then + UTF-8. ``None`` raises :class:`TypeError` — pass ``payload=None`` to + create a bodyless proof instead. The reduction MUST be identical on + client and verifier. + """ + if body is None: + raise TypeError("body is None; pass payload=None for a bodyless proof") + if isinstance(body, str): + return body.encode("utf-8") + if isinstance(body, (bytes, bytearray, memoryview)): + return bytes(body) + return json.dumps(body, separators=(",", ":")).encode("utf-8") + + +def _payload_bytes(payload: Any) -> bytes | None: + """Normalize an optional payload to bytes (None = no bound payload).""" + if payload is None: + return None + return normalize_body(payload) + + +def create_auth_proof( + identity_private_key: bytes | str, + verifier_identity_key: str, + action: str, + *, + protocol: Sequence[int | str] = DEFAULT_PROTOCOL, + validity_window_ms: int = DEFAULT_VALIDITY_WINDOW_MS, + nonce: str | None = None, + expires_at: int | None = None, + payload: Any = None, + now_ms: int | None = None, +) -> AuthProof: + """ + Create a BRC-138 authentication proof (client side). + + Args: + identity_private_key: The client's identity private key (32 bytes or + hex). Its compressed public key becomes ``data.identityKey``. + verifier_identity_key: The verifier's identity public key (hex, + compressed) — the counterparty the signing key is derived toward. + action: The operation the proof authorizes, e.g. ``"login"``. + protocol: BRC-43 protocol identifier ``[2, name]``. Defaults to the + same value as the reference ``@bsv/auth`` implementation. + validity_window_ms: Proof lifetime in ms (default 120000). + nonce: Optional explicit nonce; a fresh base64 of 32 random bytes is + generated when omitted. + expires_at: Optional explicit expiry (epoch ms); defaults to + ``now + validity_window_ms``. + payload: Optional request payload to bind into the signature (string, + bytes, or JSON-serializable). Pass the exact bytes you transmit. + now_ms: Injectable clock (epoch ms) for deterministic tests. + """ + if isinstance(identity_private_key, str): + identity_private_key = bytes.fromhex(identity_private_key) + if len(identity_private_key) != 32: + raise AuthProofError("identity_private_key must be 32 bytes") + _check_action(action) + security_level, protocol_name = _protocol_parts(protocol) + verifier_pub = _identity_key_bytes(verifier_identity_key) + identity_key = public_key_from_private(identity_private_key).hex() + + now = _now_ms() if now_ms is None else int(now_ms) + expires = now + validity_window_ms if expires_at is None else int(expires_at) + nonce = nonce or generate_nonce() + + data = AuthProofData( + action=action, + identity_key=identity_key, + expires_at=expires, + nonce=nonce, + ) + signable = data.canonical_bytes(_payload_bytes(payload)) + + derived_priv, _ = derive_signing_key( + identity_private_key, + security_level, + protocol_name, + key_id=nonce, + counterparty_public_key=verifier_pub, + ) + # BRC-100 signing: SHA-256 the canonical bytes, then ECDSA-DER sign the + # digest (matches @bsv/sdk createSignature: hash = sha256(args.data)). + digest = hashlib.sha256(signable).digest() + signature = PrivateKey(derived_priv).sign(digest, hasher=lambda x: x) + return AuthProof(data=data, signature=signature) + + +def check_auth_proof_data( + data: AuthProofData, + expected_action: str, + *, + validity_window_ms: int = DEFAULT_VALIDITY_WINDOW_MS, + clock_skew_ms: int = DEFAULT_CLOCK_SKEW_MS, + now_ms: int | None = None, +) -> None: + """ + Pure shape/action/freshness checks (no signature, no single-use lookup). + + Raises :class:`AuthProofError` on the first failed check; returns None on + success. Mirrors the reference implementation's ``checkAuthSigData``. + """ + if data.action != expected_action: + raise AuthProofError("action mismatch") + now = _now_ms() if now_ms is None else int(now_ms) + if now >= data.expires_at: + raise AuthProofError("proof expired") + if data.expires_at - now > validity_window_ms + clock_skew_ms: + raise AuthProofError("proof expiry too far in the future") + + +def verify_auth_proof( + verifier_private_key: bytes | str, + proof: AuthProof | dict[str, Any], + expected_action: str, + *, + protocol: Sequence[int | str] = DEFAULT_PROTOCOL, + validity_window_ms: int = DEFAULT_VALIDITY_WINDOW_MS, + clock_skew_ms: int = DEFAULT_CLOCK_SKEW_MS, + single_use_store: Any = None, + payload: Any = None, + now_ms: int | None = None, +) -> str: + """ + Verify a BRC-138 proof (server side). + + Performs, in order: shape, action, freshness, signature, then the atomic + single-use nonce consumption. Any failure raises :class:`AuthProofError`. + On success returns the authenticated identity key (hex) — treat it as the + authenticated subject. + + Args: + verifier_private_key: The verifier's identity private key, used to + re-derive the client's child signing public key. + proof: An :class:`AuthProof` or its wire dict. + expected_action: The only action this proof may authorize. + single_use_store: Optional :class:`~bsv_brc.brc138.store.SingleUseStore` + (or any object with ``insert_if_not_exists(nonce, expires_at) -> + bool``). If omitted, single-use is NOT enforced — pass one in + production. The store is only consulted after the signature check + passes, so invalid proofs never populate it. + payload: The raw request payload bytes to bind for verification, when + the action is expected to carry one. Must be byte-for-byte what + the client signed. + """ + if isinstance(verifier_private_key, str): + verifier_private_key = bytes.fromhex(verifier_private_key) + if not isinstance(proof, AuthProof): + proof = AuthProof.from_dict(proof) + + check_auth_proof_data( + proof.data, + expected_action, + validity_window_ms=validity_window_ms, + clock_skew_ms=clock_skew_ms, + now_ms=now_ms, + ) + + security_level, protocol_name = _protocol_parts(protocol) + identity_pub = _identity_key_bytes(proof.data.identity_key) + + try: + derived_pub = derive_signing_public_key( + identity_pub, + security_level, + protocol_name, + key_id=proof.data.nonce, + counterparty_private_key=verifier_private_key, + ) + signable = proof.data.canonical_bytes(_payload_bytes(payload)) + digest = hashlib.sha256(signable).digest() + signature_valid = PublicKey(derived_pub).verify( + proof.signature, digest, hasher=lambda x: x + ) + except Exception: + # Any error during verification is a verification failure. + signature_valid = False + if not signature_valid: + raise AuthProofError("invalid signature") + + if single_use_store is not None: + consumed = single_use_store.insert_if_not_exists( + proof.data.nonce, proof.data.expires_at + ) + if not consumed: + raise AuthProofError("proof already used") + + return proof.data.identity_key + + +__all__ = [ + "AuthProof", + "AuthProofData", + "AuthProofError", + "DEFAULT_CLOCK_SKEW_MS", + "DEFAULT_PROTOCOL", + "DEFAULT_VALIDITY_WINDOW_MS", + "NONCE_BYTES", + "check_auth_proof_data", + "create_auth_proof", + "generate_nonce", + "normalize_body", + "verify_auth_proof", +] diff --git a/src/bsv_brc/brc138/store.py b/src/bsv_brc/brc138/store.py new file mode 100644 index 0000000..13086b5 --- /dev/null +++ b/src/bsv_brc/brc138/store.py @@ -0,0 +1,149 @@ +""" +BRC-138 single-use nonce stores. + +A single-use store answers "was this nonce already used?" and MUST do so +atomically — a single conditional write (e.g. a uniqueness constraint), never +a read-then-write, otherwise two concurrent requests bearing the same nonce +can both be accepted. Entries only need to be retained until the proof's +``expires_at``; because expiry is enforced independently by the freshness +check, an entry whose ``expires_at`` has passed can be evicted safely, so +storage stays bounded to proofs seen within one validity window. + +- :class:`MemorySingleUseStore` — a locked in-memory map, acceptable for a + single long-lived process. +- :class:`SqliteSingleUseStore` — backed by a SQLite table with a PRIMARY KEY + unique index; usable in multi-process deployments on a shared database file. + In serverless / multi-instance deployments use a shared database with a + unique index (any DB; the SQL here is deliberately portable). +""" + +from __future__ import annotations + +import sqlite3 +import threading +import time +from abc import ABC, abstractmethod +from typing import Optional + + +class SingleUseStore(ABC): + """Atomic insert-if-not-exists store for consumed proof nonces.""" + + @abstractmethod + def insert_if_not_exists(self, nonce: str, expires_at: Optional[int] = None) -> bool: + """ + Atomically record ``nonce`` as used. + + Returns True if this call consumed it (it was not already present), + False if it was already recorded (a replay). ``expires_at`` is the + proof's expiry in epoch ms, used for bounded retention. + """ + + @abstractmethod + def evict_expired(self, now_ms: Optional[int] = None) -> int: + """Drop entries whose expiry has passed; return the count removed.""" + + +class MemorySingleUseStore(SingleUseStore): + """In-memory single-use store with lazy expiry eviction. + + Safe for a single long-lived process. ``insert_if_not_exists`` prunes + expired entries opportunistically and is atomic under a lock. + """ + + def __init__(self) -> None: + self._lock = threading.Lock() + self._seen: dict[str, int] = {} # nonce -> expires_at (epoch ms) + + def insert_if_not_exists(self, nonce: str, expires_at: Optional[int] = None) -> bool: + now_ms = int(time.time() * 1000) + with self._lock: + self._prune_locked(now_ms) + if nonce in self._seen: + return False + self._seen[nonce] = expires_at if expires_at is not None else now_ms + return True + + def evict_expired(self, now_ms: Optional[int] = None) -> int: + with self._lock: + return self._prune_locked(int(time.time() * 1000) if now_ms is None else now_ms) + + def _prune_locked(self, now_ms: int) -> int: + expired = [n for n, exp in self._seen.items() if exp < now_ms] + for n in expired: + del self._seen[n] + return len(expired) + + def __len__(self) -> int: + with self._lock: + return len(self._seen) + + +class SqliteSingleUseStore(SingleUseStore): + """SQLite-backed single-use store. + + Uses ``INSERT OR IGNORE`` against a PRIMARY KEY unique index — atomic for + concurrent processes sharing the database file. Set ``check_same_thread`` + appropriately (SQLite defaults to per-connection threading; pass + ``check_same_thread=False`` if the connection is shared across threads and + guard with the connection's own locking or a lock here). + + Args: + path: SQLite database path (``":memory:"`` supported). + table: Table name for consumed nonces. + """ + + def __init__( + self, + path: str = ":memory:", + table: str = "auth_proof_nonces", + check_same_thread: bool = True, + ) -> None: + self._path = path + self._table = table + self._lock = threading.Lock() + self._conn = sqlite3.connect(path, check_same_thread=check_same_thread) + self._conn.execute( + f"CREATE TABLE IF NOT EXISTS {table} (" + "nonce TEXT PRIMARY KEY, " + "expires_at INTEGER NOT NULL)" + ) + self._conn.execute( + f"CREATE INDEX IF NOT EXISTS idx_{table}_expires ON {table}(expires_at)" + ) + self._conn.commit() + + def insert_if_not_exists(self, nonce: str, expires_at: Optional[int] = None) -> bool: + now_ms = int(time.time() * 1000) + exp = now_ms if expires_at is None else expires_at + with self._lock: + # Opportunistic eviction keeps the table bounded. + self._conn.execute( + f"DELETE FROM {self._table} WHERE expires_at < ?", (now_ms,) + ) + cur = self._conn.execute( + f"INSERT OR IGNORE INTO {self._table} (nonce, expires_at) VALUES (?, ?)", + (nonce, exp), + ) + self._conn.commit() + return cur.rowcount == 1 + + def evict_expired(self, now_ms: Optional[int] = None) -> int: + with self._lock: + cur = self._conn.execute( + f"DELETE FROM {self._table} WHERE expires_at < ?", + (int(time.time() * 1000) if now_ms is None else now_ms,), + ) + self._conn.commit() + return cur.rowcount + + def close(self) -> None: + with self._lock: + self._conn.close() + + +__all__ = [ + "MemorySingleUseStore", + "SingleUseStore", + "SqliteSingleUseStore", +] diff --git a/src/bsv_brc/crypto/keys.py b/src/bsv_brc/crypto/keys.py index 9297ad8..2fec102 100644 --- a/src/bsv_brc/crypto/keys.py +++ b/src/bsv_brc/crypto/keys.py @@ -103,3 +103,38 @@ def derive_signing_key( def public_key_from_private(private_key: bytes) -> bytes: """Return 33-byte compressed public key from 32-byte private key.""" return PrivateKey(private_key).public_key().serialize() + + +def derive_signing_public_key( + identity_public_key: bytes, + security_level: int, + protocol: str, + key_id: str, + counterparty_private_key: bytes | None = None, +) -> bytes: + """ + BRC-43 signing *public* key derivation — the verifier's side. + + Computes the child public key a counterparty would derive from + ``identity_public_key`` without the identity's private key. The HMAC + key is the ECDH shared secret between ``counterparty_private_key`` and + ``identity_public_key`` (equal to the shared secret the signer used), + or ``identity_public_key`` itself in "anyone" mode. This is exactly + the inverse of :func:`derive_signing_key`: a signature made with the + child private key returned there verifies against the child public + key returned here. + """ + hmac_key = ( + identity_public_key + if counterparty_private_key is None + else shared_secret(counterparty_private_key, identity_public_key) + ) + + inv = invoice_number(security_level, protocol, key_id) + h = _hmac_sha256(hmac_key, inv) + + h_int = int.from_bytes(h, "big") + pub = PublicKey(identity_public_key) + h_point = curve_multiply(h_int, curve.g) + derived_pub_point = curve_add(pub.point(), h_point) + return PublicKey(derived_pub_point).serialize() diff --git a/tests/test_brc138.py b/tests/test_brc138.py new file mode 100644 index 0000000..80130da --- /dev/null +++ b/tests/test_brc138.py @@ -0,0 +1,550 @@ +"""Tests for BRC-138 single-use signed proofs.""" + +from __future__ import annotations + +import json +import time + +import pytest +from bsv import PrivateKey + +from bsv_brc import brc138 +from bsv_brc.brc138 import ( + AuthProof, + AuthProofData, + AuthProofError, + DEFAULT_CLOCK_SKEW_MS, + DEFAULT_PROTOCOL, + DEFAULT_VALIDITY_WINDOW_MS, + MemorySingleUseStore, + SqliteSingleUseStore, + check_auth_proof_data, + create_auth_proof, + generate_nonce, + normalize_body, + verify_auth_proof, +) +from bsv_brc.crypto.keys import public_key_from_private + + +def _random_key() -> bytes: + return PrivateKey().serialize() + + +@pytest.fixture +def keys(): + client_priv = _random_key() + server_priv = _random_key() + return { + "client_priv": client_priv, + "server_priv": server_priv, + "client_pub": public_key_from_private(client_priv).hex(), + "server_pub": public_key_from_private(server_priv).hex(), + } + + +NOW = int(time.time() * 1000) + + +def _verify(keys, proof, expected_action="login", **kwargs): + store = kwargs.pop("store", MemorySingleUseStore()) + now = kwargs.pop("now_ms", NOW + 1_000) + return verify_auth_proof( + keys["server_priv"], + proof, + expected_action, + single_use_store=store, + now_ms=now, + **kwargs, + ) + + +class TestCreateAndVerify: + def test_round_trip(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + assert proof.data.action == "login" + assert proof.data.identity_key == keys["client_pub"] + assert proof.data.expires_at == NOW + DEFAULT_VALIDITY_WINDOW_MS + assert len(proof.data.nonce) > 0 + assert isinstance(proof.signature, bytes) and len(proof.signature) > 0 + + identity = _verify(keys, proof) + assert identity == keys["client_pub"] + + def test_wire_dict_round_trip(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + wire = proof.to_dict() + assert set(wire) == {"data", "signature"} + assert isinstance(wire["signature"], list) + assert all(isinstance(b, int) and 0 <= b <= 255 for b in wire["signature"]) + + # dict input works; hex-string signature input works too + identity = _verify(keys, wire) + assert identity == keys["client_pub"] + wire_hex = {"data": wire["data"], "signature": proof.signature.hex()} + assert _verify(keys, wire_hex) == keys["client_pub"] + + def test_explicit_nonce_expiry(self, keys): + proof = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "login", + nonce="AAAA", + expires_at=NOW + 60_000, + now_ms=NOW, + ) + assert proof.data.nonce == "AAAA" + assert proof.data.expires_at == NOW + 60_000 + assert _verify(keys, proof, now_ms=NOW + 59_000) == keys["client_pub"] + + def test_signature_is_per_nonce_key(self, keys): + # keyID = nonce, so two proofs with different nonces use different keys. + p1 = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + p2 = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + assert p1.signature != p2.signature + + def test_hex_private_key_input(self, keys): + proof = create_auth_proof( + keys["client_priv"].hex(), + keys["server_pub"], + "login", + now_ms=NOW, + ) + assert _verify(keys, proof) == keys["client_pub"] + + +class TestPayloadBinding: + def test_body_binding_string(self, keys): + proof = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "update_profile", + payload='{"username":"alice"}', + now_ms=NOW, + ) + # correct payload verifies + assert ( + _verify(keys, proof, expected_action="update_profile", payload='{"username":"alice"}') + == keys["client_pub"] + ) + # tampered payload fails + with pytest.raises(AuthProofError, match="invalid signature"): + _verify( + keys, + proof, + expected_action="update_profile", + payload='{"username":"bob"}', + ) + # missing payload fails (proof bound one) + with pytest.raises(AuthProofError, match="invalid signature"): + _verify(keys, proof, expected_action="update_profile") + + def test_body_binding_bytes_and_json(self, keys): + binary = b"\x00\x01\xff\nbody-with-newlines" + proof = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "transcribe", + payload=binary, + now_ms=NOW, + ) + assert ( + _verify(keys, proof, expected_action="transcribe", payload=binary) + == keys["client_pub"] + ) + with pytest.raises(AuthProofError, match="invalid signature"): + _verify( + keys, + proof, + expected_action="transcribe", + payload=binary + b"x", + ) + + obj = {"prompt": "banana", "n": 2} + proof2 = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "generate", + payload=obj, + now_ms=NOW, + ) + # JSON objects are normalized to compact JSON on both sides. + assert normalize_body(obj) == b'{"prompt":"banana","n":2}' + assert ( + _verify(keys, proof2, expected_action="generate", payload=obj) + == keys["client_pub"] + ) + # The verifier must bind the raw bytes received — a re-serialized + # object with different spacing fails. + with pytest.raises(AuthProofError, match="invalid signature"): + _verify( + keys, + proof2, + expected_action="generate", + payload='{"prompt": "banana", "n": 2}', + ) + + def test_empty_body_distinct_from_no_body(self, keys): + bound = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "login", + payload=b"", + now_ms=NOW, + ) + unbound = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "login", + now_ms=NOW, + ) + assert bound.signature != unbound.signature + # verifying the empty-bound proof without a payload fails + with pytest.raises(AuthProofError, match="invalid signature"): + _verify(keys, bound) + + +class TestChecks: + def test_action_mismatch(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + with pytest.raises(AuthProofError, match="action mismatch"): + _verify(keys, proof, expected_action="delete") + + def test_expired(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + with pytest.raises(AuthProofError, match="expired"): + _verify(keys, proof, now_ms=proof.data.expires_at) + + def test_minted_too_far_in_future(self, keys): + proof = create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "login", + expires_at=NOW + 60 * 60_000, # 1h validity + now_ms=NOW, + ) + with pytest.raises(AuthProofError, match="too far in the future"): + _verify(keys, proof, now_ms=NOW + 1_000) + + def test_clock_skew_tolerance(self, keys): + # Proof minted 25s in the "past" relative to verifier: within skew. + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + assert ( + _verify(keys, proof, now_ms=proof.data.expires_at - 5_000) + == keys["client_pub"] + ) + + def test_tampered_identity_key_fails(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + other = _random_key() + tampered = AuthProof( + data=AuthProofData( + action=proof.data.action, + identity_key=public_key_from_private(other).hex(), + expires_at=proof.data.expires_at, + nonce=proof.data.nonce, + ), + signature=proof.signature, + ) + with pytest.raises(AuthProofError, match="invalid signature"): + _verify(keys, tampered) + + def test_tampered_nonce_fails(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + tampered = AuthProof( + data=AuthProofData( + action=proof.data.action, + identity_key=proof.data.identity_key, + expires_at=proof.data.expires_at, + nonce="EVILNONCE", + ), + signature=proof.signature, + ) + with pytest.raises(AuthProofError, match="invalid signature"): + _verify(keys, tampered) + + def test_wrong_verifier_key_fails(self, keys): + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + eavesdropper = _random_key() + with pytest.raises(AuthProofError, match="invalid signature"): + verify_auth_proof( + eavesdropper, + proof, + "login", + single_use_store=MemorySingleUseStore(), + now_ms=NOW + 1_000, + ) + + +class TestSingleUse: + def test_replay_rejected(self, keys): + store = MemorySingleUseStore() + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + assert _verify(keys, proof, store=store) == keys["client_pub"] + with pytest.raises(AuthProofError, match="already used"): + _verify(keys, proof, store=store) + + def test_invalid_proof_does_not_populate_store(self, keys): + store = MemorySingleUseStore() + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + bad = AuthProof( + data=AuthProofData( + action="delete", # wrong action — fails before single-use + identity_key=proof.data.identity_key, + expires_at=proof.data.expires_at, + nonce=proof.data.nonce, + ), + signature=proof.signature, + ) + with pytest.raises(AuthProofError): + _verify(keys, bad, store=store) + assert len(store) == 0 + + def test_evict_expired(self): + # Fresh timestamps: the store prunes against wall-clock time. + now = int(time.time() * 1000) + store = MemorySingleUseStore() + assert store.insert_if_not_exists("n1", expires_at=now + 100) + assert store.insert_if_not_exists("n2", expires_at=now - 100) + # n2 was already expired on insert; the first evict drops it, n1 stays. + assert store.evict_expired(now_ms=now) == 1 + assert "n1" in store._seen + assert "n2" not in store._seen + # past n1's expiry it is evicted too + assert store.evict_expired(now_ms=now + 200) == 1 + assert "n1" not in store._seen + + def test_sqlite_store(self): + now = int(time.time() * 1000) + store = SqliteSingleUseStore(":memory:") + try: + assert store.insert_if_not_exists("abc", expires_at=now + 1000) + assert not store.insert_if_not_exists("abc", expires_at=now + 1000) + assert store.insert_if_not_exists("def", expires_at=now - 1000) + assert store.evict_expired(now_ms=now) == 1 + finally: + store.close() + + def test_sqlite_store_in_verification(self, keys): + store = SqliteSingleUseStore(":memory:") + try: + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + assert _verify(keys, proof, store=store) == keys["client_pub"] + with pytest.raises(AuthProofError, match="already used"): + _verify(keys, proof, store=store) + finally: + store.close() + + +class TestMalformedInput: + def test_missing_fields(self): + with pytest.raises(AuthProofError, match="missing field"): + AuthProofData.from_dict({"action": "login"}) + + def test_bad_action_chars(self, keys): + with pytest.raises(AuthProofError, match="control characters"): + create_auth_proof(keys["client_priv"], keys["server_pub"], "login\nadmin") + with pytest.raises(AuthProofError, match="non-empty"): + create_auth_proof(keys["client_priv"], keys["server_pub"], "") + + def test_bad_protocol(self, keys): + with pytest.raises(AuthProofError, match="form \\[2, name\\]"): + create_auth_proof( + keys["client_priv"], + keys["server_pub"], + "login", + protocol=(1, "x"), + ) + + def test_bad_identity_key(self, keys): + with pytest.raises(AuthProofError, match="33-byte"): + create_auth_proof(keys["client_priv"], "deadbeef", "login") + + def test_from_dict_malformed(self): + with pytest.raises(AuthProofError, match="malformed proof"): + AuthProof.from_dict({"data": {"action": "login"}}) + with pytest.raises(AuthProofError, match="signature"): + AuthProof.from_dict( + { + "data": { + "action": "login", + "identityKey": "02" + "ab" * 32, + "expiresAt": 1_000, + "nonce": "n", + }, + "signature": "zz", + } + ) + + def test_normalize_body_none(self): + with pytest.raises(TypeError): + normalize_body(None) + + +class TestCanonicalEncoding: + def test_vector(self): + data = AuthProofData( + action="login", + identity_key="02" + "ab" * 32, + expires_at=1_750_000_000_123, + nonce="bm9uY2U=", + ) + assert data.canonical_bytes() == ( + b"login\n02" + b"ab" * 32 + b"\n1750000000123\nbm9uY2U=" + ) + bound = data.canonical_bytes(b"hi") + assert bound == data.canonical_bytes() + b"\x02hi" + # empty bound payload is distinct from none + assert data.canonical_bytes(b"") == data.canonical_bytes() + b"\x00" + + def test_generate_nonce(self): + n1 = generate_nonce() + n2 = generate_nonce() + assert n1 != n2 + import base64 + + assert len(base64.b64decode(n1)) == 32 + + +class TestCheckAuthProofData: + def test_pure_checks(self, keys): + data = AuthProofData( + action="login", + identity_key=keys["client_pub"], + expires_at=NOW + 60_000, + nonce="x", + ) + check_auth_proof_data(data, "login", now_ms=NOW) # ok + with pytest.raises(AuthProofError, match="action mismatch"): + check_auth_proof_data(data, "other", now_ms=NOW) + with pytest.raises(AuthProofError, match="expired"): + check_auth_proof_data(data, "login", now_ms=NOW + 60_000) + + +class TestAdapter: + def test_starlette_adapter_optional(self): + # The ASGI adapter lives behind the starlette extra; importing the + # package core must not require it. + import bsv_brc.brc138 as mod + + assert mod.create_auth_proof is not None + + def test_adapter_import(self): + pytest.importorskip("starlette") + from bsv_brc.brc138.adapters.asgi import ( + AuthProofMiddleware, + PROOF_HEADER, + get_proof_from_header_or_body, + ) + + assert PROOF_HEADER == "x-bsv-auth-proof" + body = b'{"prompt":"x"}' + headers = [(b"x-bsv-auth-proof", json.dumps({"data": {}}).encode())] + assert get_proof_from_header_or_body(body, headers) == {"data": {}} + headers2 = [(b"content-type", b"application/json")] + assert get_proof_from_header_or_body( + b'{"proof": {"a": 1}}', headers2 + ) == {"a": 1} + assert get_proof_from_header_or_body(b"", headers2) is None + + def test_middleware_end_to_end(self, keys): + starlette = pytest.importorskip("starlette") + from starlette.applications import Starlette + from starlette.responses import JSONResponse + from starlette.testclient import TestClient + + from bsv_brc.brc138.adapters.asgi import AuthProofMiddleware + + app = Starlette() + + async def login(request): + identity = request.scope["bsv_auth_proof"]["identity_key"] + return JSONResponse({"ok": True, "identity": identity}) + + app.add_route("/login", login, methods=["GET", "POST"]) + + wrapped = AuthProofMiddleware( + app, + wallet=type( + "W", + (), + {"get_private_key": lambda self, k: keys["server_priv"]}, + )(), + expected_action="login", + single_use_store=MemorySingleUseStore(), + ) + client = TestClient(wrapped) + proof = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + + # no proof -> 401 + r = client.get("/login") + assert r.status_code == 401 + + # valid proof in header -> 200 with identity + r = client.get( + "/login", + headers={"x-bsv-auth-proof": json.dumps(proof.to_dict())}, + ) + assert r.status_code == 200 + assert r.json()["identity"] == keys["client_pub"] + + # replay -> 401 + r = client.get( + "/login", + headers={"x-bsv-auth-proof": json.dumps(proof.to_dict())}, + ) + assert r.status_code == 401 + + # proof in JSON body -> 200 + proof2 = create_auth_proof( + keys["client_priv"], keys["server_pub"], "login", now_ms=NOW + ) + r = client.post("/login", json={"proof": proof2.to_dict()}) + assert r.status_code == 200 + + # tampered action -> 401 + proof3 = create_auth_proof( + keys["client_priv"], keys["server_pub"], "delete", now_ms=NOW + ) + r = client.get( + "/login", + headers={"x-bsv-auth-proof": json.dumps(proof3.to_dict())}, + ) + assert r.status_code == 401 + + +class TestTopLevelExports: + def test_brc138_importable_from_root(self): + import bsv_brc + + assert bsv_brc.create_auth_proof is create_auth_proof + assert bsv_brc.verify_auth_proof is verify_auth_proof + assert bsv_brc.DEFAULT_PROTOCOL == (2, "bsv auth proof") diff --git a/tests/test_brc138_interop.py b/tests/test_brc138_interop.py new file mode 100644 index 0000000..6f0010a --- /dev/null +++ b/tests/test_brc138_interop.py @@ -0,0 +1,146 @@ +"""Pinned cross-implementation vectors for BRC-138 (vs the reference @bsv/auth). + +These vectors were captured and CERTIFIED against the canonical TypeScript +implementation (@bsv/auth 0.1.3 + @bsv/sdk, see examples/brc138_interop/): + +- The Python-created proofs below were submitted to Node's verifyAuthProof + and accepted (valid: true), proving our canonical encoding, BRC-42/43 key + derivation and DER signature format are byte-compatible with @bsv/auth. +- The Node-created proofs below were produced by Node's createAuthProof and + are verified by our verify_auth_proof in this file. + +The clock is injected so the pinned proofs (whose expiry is in the past by +wall-clock time) still satisfy the freshness checks deterministically. +""" + +from __future__ import annotations + +import pytest + +from bsv_brc.brc138 import AuthProof, verify_auth_proof + +CLIENT_PRIV = "524c969962c3128365f5c147cea31c8cad0bad2b745020c0ad42f4d7a1785b2e" +SERVER_PRIV = "9777da3f30df19f2c1cd61420e9688e673205dd02bb4738291eaceac5eecdcf9" +CLIENT_PUB = "02eea1cc2de56a6f05ec3cb0eab671fe1c7b08aa8c58380342e75cd98c8e231b3c" + +# Python-created, bodyless login proof — Node's verifyAuthProof returned +# {"valid": true, "identityKey": CLIENT_PUB} at capture time. +PY_LOGIN_PROOF = { + "data": { + "action": "login", + "identityKey": CLIENT_PUB, + "expiresAt": 1787015583918, + "nonce": "eHbOz00W8pvtWMWSgazt+gPjNuefyQrRo5wUmau2A54=", + }, + "signature": [ + 48, 68, 2, 32, 116, 191, 100, 100, 111, 213, 251, 149, 185, 72, 140, 129, + 177, 51, 12, 130, 59, 36, 31, 186, 188, 27, 176, 38, 20, 37, 106, 168, 244, + 247, 144, 157, 2, 32, 37, 71, 24, 233, 202, 233, 142, 45, 254, 68, 81, 8, + 234, 156, 49, 13, 3, 95, 205, 11, 165, 79, 78, 198, 253, 226, 46, 39, 39, + 28, 28, 21, + ], +} + +# Python-created proof with a bound payload — Node accepted it too. +PY_UPDATE_PROOF = { + "data": { + "action": "updateProfile", + "identityKey": CLIENT_PUB, + "expiresAt": 1787015583918, + "nonce": "azg/aTd+E9FKWidUbbZwLJNg4HkZLkZD+cvEo6J59D0=", + }, + "signature": [ + 48, 69, 2, 33, 0, 133, 205, 54, 23, 194, 80, 3, 92, 97, 245, 56, 125, 207, + 198, 148, 22, 24, 218, 11, 12, 64, 77, 54, 140, 150, 192, 243, 155, 173, + 113, 25, 147, 2, 32, 7, 149, 20, 4, 249, 189, 130, 60, 63, 182, 56, 210, 6, + 66, 214, 6, 135, 58, 236, 188, 43, 202, 58, 13, 199, 218, 107, 104, 42, 82, + 91, 94, + ], +} +PY_UPDATE_PAYLOAD = b'{"username":"alice","role":"admin"}' + +# Node-created, bodyless login proof — verifyAuthProof produced this. +NODE_LOGIN_DATA = { + "action": "login", + "identityKey": CLIENT_PUB, + "expiresAt": 1787015584409, + "nonce": "0vHr0SotrdS/bDshHoP9Nz7ufNk1ywafM3GSpfOO9dM=", +} +NODE_LOGIN_SIG = [ + 48, 69, 2, 33, 0, 199, 103, 75, 212, 27, 82, 28, 147, 56, 32, 37, 75, 37, 173, + 151, 35, 71, 239, 254, 215, 246, 119, 128, 199, 48, 157, 61, 48, 137, 244, 175, + 169, 2, 32, 28, 31, 245, 12, 230, 95, 152, 60, 148, 163, 131, 150, 130, 32, 28, + 41, 219, 179, 91, 146, 126, 67, 10, 69, 56, 96, 72, 53, 107, 38, 217, 113, +] + +# Node-created proof with a bound binary payload. +NODE_TRANSCRIBE_DATA = { + "action": "transcribe", + "identityKey": CLIENT_PUB, + "expiresAt": 1787015584589, + "nonce": "NUrXFgXBHGL7LZM5i9VzNZxFhCTmdbjE2F+aMzExtQ0=", +} +NODE_TRANSCRIBE_SIG = [ + 48, 68, 2, 32, 87, 245, 119, 164, 197, 20, 214, 162, 115, 224, 192, 20, 96, + 140, 236, 66, 91, 202, 53, 210, 50, 73, 149, 225, 224, 220, 7, 161, 10, 216, + 35, 116, 2, 32, 127, 167, 235, 232, 87, 164, 7, 59, 200, 181, 165, 32, 36, 180, + 124, 31, 63, 97, 169, 90, 230, 50, 54, 44, 29, 155, 53, 156, 63, 231, 245, 218, +] +NODE_TRANSCRIBE_PAYLOAD = b"\x00\x01\xff raw binary body" + + +def _verify(data, sig, action, payload=None, expires_at=None): + proof = AuthProof.from_dict( + {"data": data, "signature": list(sig)} + ) + # Inject a clock just inside the proof's own validity window. + now_ms = (expires_at or data["expiresAt"]) - 1_000 + return verify_auth_proof( + SERVER_PRIV, + proof, + action, + single_use_store=None, + payload=payload, + now_ms=now_ms, + ) + + +class TestPinnedPythonProofs: + """Python-created proofs the reference @bsv/auth verified at capture time.""" + + def test_python_login_accepted_by_node_reference(self): + assert _verify(PY_LOGIN_PROOF["data"], PY_LOGIN_PROOF["signature"], "login") == CLIENT_PUB + + def test_python_bound_payload_accepted_by_node_reference(self): + identity = _verify( + PY_UPDATE_PROOF["data"], + PY_UPDATE_PROOF["signature"], + "updateProfile", + payload=PY_UPDATE_PAYLOAD, + ) + assert identity == CLIENT_PUB + + +class TestPinnedNodeProofs: + """Node-created proofs our Python implementation must verify.""" + + def test_node_login_verified_by_python(self): + assert _verify(NODE_LOGIN_DATA, NODE_LOGIN_SIG, "login") == CLIENT_PUB + + def test_node_bound_payload_verified_by_python(self): + identity = _verify( + NODE_TRANSCRIBE_DATA, + NODE_TRANSCRIBE_SIG, + "transcribe", + payload=NODE_TRANSCRIBE_PAYLOAD, + ) + assert identity == CLIENT_PUB + + def test_node_proof_with_wrong_payload_fails(self): + with pytest.raises(Exception): + _verify( + NODE_TRANSCRIBE_DATA, + NODE_TRANSCRIBE_SIG, + "transcribe", + payload=b"tampered", + ) diff --git a/tests/test_overlay.py b/tests/test_overlay.py index ed2e3e0..ab37ff2 100644 --- a/tests/test_overlay.py +++ b/tests/test_overlay.py @@ -303,7 +303,7 @@ def test_empty_state_root_matches_live_overlay(): def test_state_root_matches_live_overlay_nonempty_vector(): # A real cross-implementation vector captured from overlay.peck.to: - # these were the 4 live tm_peck-bio-profile outpoints, and the overlay + # these were the 4 live tm_social-profile outpoints, and the overlay # published this exact stateRoot for that set (GET /state, 2026-06-02). # Our state_root reproduces it byte-for-byte — confirming the algorithm, # the "txid:vout" form, and the txid (display) orientation all match.