v0.3
This commit is contained in:
parent
217c3da7c7
commit
d01a6ebf85
175
cli/README.md
175
cli/README.md
@ -1,121 +1,108 @@
|
|||||||
# zkac-node CLI
|
# zkac-node CLI
|
||||||
|
|
||||||
Command-line interface for [ZKAC](../README.md) using the **Python bindings only** (`zkac` package). It runs a **registry-capable server** (management + client-managed registries + optional issuance relay) and **per-user** material under `~/.zkac/` (or `$ZKAC_HOME`).
|
Install the `zkac` wheel from the repo root first (`maturin develop` or `pip install .`), then:
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Python ≥ 3.9
|
|
||||||
- The **`zkac`** extension built and installed from the repository root, for example:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /path/to/ZKAC
|
|
||||||
maturin develop
|
|
||||||
# or: pip install -e .
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /path/to/ZKAC/cli
|
pip install -e ./cli
|
||||||
pip install -e .
|
zkac-node --help
|
||||||
```
|
```
|
||||||
|
|
||||||
This installs the **`zkac-node`** console script.
|
## Quick start
|
||||||
|
|
||||||
## Environment
|
```bash
|
||||||
|
# 1. Create identities (one per machine / actor)
|
||||||
|
zkac-node identity init # on admin machine
|
||||||
|
zkac-node identity init # on recipient machine (separate ~/.zkac)
|
||||||
|
|
||||||
| Variable | Meaning |
|
# Recipient shares their issuance public key out-of-band:
|
||||||
|------------|---------|
|
zkac-node identity show # prints issuance pk (hex)
|
||||||
| `ZKAC_HOME` | Base directory for users (default: `~/.zkac`). Each user lives at `$ZKAC_HOME/<userid>/`. |
|
|
||||||
|
|
||||||
## Server vs client
|
# 2. Start the server (separate machine or same, different data-dir)
|
||||||
|
zkac-node serve --data-dir /var/lib/zkac --port 9800 &
|
||||||
|
|
||||||
- **Server** (`zkac-node serve`): a node that can **accept registry create/update** from an operator with the **`zkac.mgmt`** credential. It also serves **managed** sessions (BBS+ auth against stored client-managed registries) and optionally a **relay** port for blind issuance queues.
|
# 3. Pin the server's public key (printed at startup)
|
||||||
- **Client**: a **userid** with files under `$ZKAC_HOME/<userid>/` (transport key, registries, credentials).
|
zkac-node server pin localhost:9800 --key <SERVER_PK_HEX>
|
||||||
|
|
||||||
## Ports (defaults)
|
# 4. Create a registry (admin side)
|
||||||
|
zkac-node registry create localhost:9800 --roles analyst,operator
|
||||||
|
|
||||||
| Port role | Default | Purpose |
|
# 5. Grant recipient the 'analyst' role (only needs their public key)
|
||||||
|------------|---------|---------|
|
zkac-node grant --server localhost:9800 \
|
||||||
| Management | 7400 | ZKAC + static role `zkac.mgmt`; JSON commands (create/update registry, issuance peek/grant). |
|
--registry <REGISTRY_ID> --role analyst --to <RECIPIENT_PK_HEX>
|
||||||
| Managed | 7401 | ZKAC + `RegistryManager`; member proves a role in a client-managed registry. |
|
|
||||||
| Relay | 7402 | Optional **plaintext** JSON line protocol for enqueue/poll of issuance requests. Use `--relay-port 0` on `serve` to disable. Binds with `--relay-bind` (default `127.0.0.1`). |
|
|
||||||
|
|
||||||
## Layout on disk
|
# 6. Recipient lists pending credentials
|
||||||
|
zkac-node credentials list --server localhost:9800
|
||||||
|
|
||||||
**Per user** (`$ZKAC_HOME/<userid>/`):
|
# 7. Recipient collects (host:port:registry_id:role)
|
||||||
|
zkac-node collect localhost:9800:<REGISTRY_ID>:analyst
|
||||||
|
|
||||||
- `transport.json` — Ristretto **client** transport keypair (`zkac.Keypair`).
|
# 8. Recipient authenticates anonymously
|
||||||
- `profile.json` — `userid` and metadata.
|
zkac-node auth --registry <REGISTRY_ID> --role analyst --server localhost:9800
|
||||||
- `registries/<slug>/` — one directory per logical registry:
|
```
|
||||||
- `admin.json` / `registry.json` — produced by `registry-init` (admin issuer material + public state + state cert).
|
|
||||||
- `roles/<name>.json` — member credential payloads for `connect`.
|
|
||||||
- `issued/` — files from `issue-member` (handoff).
|
|
||||||
- `pending/<request_id>.json` — saved by `issuance-request` until `issuance-poll` finalizes.
|
|
||||||
- `servers/<label>/server.json` — from `pin-server` (pinned server pubkey + host/ports).
|
|
||||||
|
|
||||||
**Server data** (`serve --data <dir>`):
|
|
||||||
|
|
||||||
- `transport.json` — server transport keypair.
|
|
||||||
- `mgmt_issuer.json` — BBS issuer secret for the static `zkac.mgmt` role only.
|
|
||||||
- `mgmt_member.json` — member credential for `zkac.mgmt` (give to operators who push registries).
|
|
||||||
- `registry_events.json` — append-only log to rebuild `RegistryManager` after restart (registry state; **not** the issuance queue).
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
Run `zkac-node --help` and `zkac-node <command> --help` for full flags.
|
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `serve` | Start server; `--init` creates keys and mgmt credential in `--data`. |
|
| `identity init` | Generate issuance keypair under `~/.zkac/` |
|
||||||
| `user-create` | Create a new userid directory with a client transport key. |
|
| `identity show` | Show issuance pk + owned registries + credentials |
|
||||||
| `pin-server` | Save server pubkey + host/ports for a user (from server’s `transport.json`). |
|
| `serve --data-dir D` | Run as a ZKAC server storing data in D |
|
||||||
| `registry-init` | Offline: build a client-managed registry (`RegistryState` + state cert) under the user’s `registries/<slug>/`. |
|
| `server pin <host:port> --key <hex>` | Pin a server's public key |
|
||||||
| `registry-push` | Push create (or `--update`) to the server over the **management** port using `mgmt_member.json` from the server data dir. |
|
| `registry create <server> --roles r1,r2` | Create a new registry (fresh BBS+ issuer) |
|
||||||
| `registry-import-public` | Copy `registry.json` from another user (public metadata for peers). |
|
| `registry update <server> --registry R --add-roles r3` | Add roles to a registry you own |
|
||||||
| `connect` | Open a **managed** ZKAC session and send a JSON command (`--command`, default `whoami`). |
|
| `registry get <server> --registry R` | Fetch registry state from a server |
|
||||||
| `issue-member` | Admin: blind-issue a role credential to a file under `issued/` (out-of-band handoff). |
|
| `registry list` | List locally owned registries |
|
||||||
| `import-credential` | Install a member JSON into `roles/<name>.json`. |
|
| `grant --server S --registry R --role X --to <pk>` | Issue credential encrypted to recipient's pk |
|
||||||
| `issuance-request` | Enqueue a blind commitment on the **relay** (see caveats below). |
|
| `credentials list [--server S ...]` | Show local credentials + pending grants |
|
||||||
| `issuance-grant` | Admin: list pending via mgmt peek, `issue_blind`, `grant_credential`. |
|
| `collect <host:port:registry:role>` | Fetch + finalize one pending credential |
|
||||||
| `issuance-poll` | Poll relay for blind signature; finalize credential into `roles/<role>.json` when ready. |
|
| `auth --registry R --role X [--server S]` | Authenticate via ZKAC handshake |
|
||||||
|
|
||||||
## Typical flows
|
## Protocol
|
||||||
|
|
||||||
**Operator: first-time server**
|
All connections use a single encrypted channel:
|
||||||
|
|
||||||
```bash
|
1. **Anonymous handshake** (X25519 ephemeral DH + Schnorr server identity proof
|
||||||
export ZKAC_HOME=~/.zkac # optional
|
verified against a pinned public key) establishes an encrypted session.
|
||||||
zkac-node serve --data ./myserver --init --mgmt-port 7400 --managed-port 7401 --relay-port 7402
|
2. The first encrypted frame selects the mode:
|
||||||
|
- `{"op": "mgmt"}` — management commands (JSON request/reply loop)
|
||||||
|
- `{"op": "auth", ...}` — BBS+ role authentication
|
||||||
|
|
||||||
|
Admin-only commands (`post_grant`) require a BBS+ presentation of the registry's
|
||||||
|
`__admin__` credential bound to the session transcript hash — unlinkable across
|
||||||
|
grants by construction.
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
The server is a **trustless, zero-information** relay:
|
||||||
|
|
||||||
|
- All traffic is encrypted and server-authenticated (same handshake as role auth).
|
||||||
|
- Registry state is opaque bytes with a BBS+ state certificate. The server cannot
|
||||||
|
mutate state or impersonate the admin without the BBS+ issuer secret.
|
||||||
|
- Grants are end-to-end encrypted from admin to recipient using ephemeral X25519
|
||||||
|
ECDH. The server stores only `(recipient_pk, eph_pk, ciphertext)` per grant —
|
||||||
|
no registry ID, role name, or credential material.
|
||||||
|
- Admin identity is unlinkable across `grant` calls (BBS+ presentations are
|
||||||
|
rerandomized per call).
|
||||||
|
- Recipient's authenticated session uses a fresh ephemeral transport key + an
|
||||||
|
unlinkable BBS+ presentation, so the server cannot correlate sessions with
|
||||||
|
the mailbox pk used at collect time.
|
||||||
|
- No user IDs exist on the server. Clients are identified only by ephemeral
|
||||||
|
keys (handshake) or issuance public keys (mailbox addressing).
|
||||||
|
|
||||||
|
## Storage layout
|
||||||
|
|
||||||
|
Client (`~/.zkac/`):
|
||||||
|
```
|
||||||
|
identity.json issuance keypair (long-term secret)
|
||||||
|
admin/<registry_id>.json BBS+ issuer + admin credential per owned registry
|
||||||
|
credentials/<rid>_<role>.json BBS+ credentials granted to this identity
|
||||||
|
servers/<host_port>.json pinned server public keys
|
||||||
```
|
```
|
||||||
|
|
||||||
Distribute **`myserver/transport.json`** (public key) and **`myserver/mgmt_member.json`** (sensitive) to admins who push registries.
|
Server (`--data-dir`):
|
||||||
|
|
||||||
**Admin: create registry offline and push**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
zkac-node user-create alice
|
|
||||||
zkac-node registry-init --user alice --slug demo --roles analyst reader
|
|
||||||
zkac-node registry-push --user alice --slug demo --server-data ./myserver --host 127.0.0.1 --mgmt-port 7400
|
|
||||||
```
|
```
|
||||||
|
server_key.json Schnorr keypair (long-term server secret)
|
||||||
**Member: pin server, import public registry + credential, connect**
|
registries/<rid>.state raw RegistryState bytes
|
||||||
|
registries/<rid>.cert raw state cert bytes
|
||||||
```bash
|
mailbox/<recipient_pk_hex>.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...]
|
||||||
zkac-node pin-server --user bob --name prod --transport-file ./myserver/transport.json \
|
|
||||||
--host 10.0.0.1 --mgmt-port 7400 --managed-port 7401 --relay-port 7402
|
|
||||||
zkac-node registry-import-public --from-user alice --to-user bob --slug demo
|
|
||||||
# copy or issue credential file into bob/registries/demo/roles/analyst.json
|
|
||||||
zkac-node connect --user bob --slug demo --credential roles/analyst.json \
|
|
||||||
--server-file ~/.zkac/bob/servers/prod/server.json --host 10.0.0.1 --managed-port 7401
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Caveats
|
|
||||||
|
|
||||||
1. **Issuance relay queue is in-memory.** Pending grants are lost if `serve` restarts before `issuance-grant` / `issuance-poll` complete. Registry snapshots are persisted via `registry_events.json`; the relay queue is not.
|
|
||||||
2. **Relay is a localhost-oriented, plaintext JSON line protocol** (not the full E2E design in the Rust layer). Use `--relay-bind 127.0.0.1` and do not expose the relay port untrusted networks without additional protection.
|
|
||||||
3. **`zkac.mgmt`** is a **separate** BBS role from the client-managed registry admin; it only authorizes **management RPCs** to the server (push registry, peek/grant issuance). Registry state integrity still comes from BBS+ state certificates inside `RegistryManager`.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
The CLI package name is **`zkac-node-cli`** (see `pyproject.toml`). It declares no PyPI dependency on `zkac`; install the `zkac` wheel from the parent project first.
|
|
||||||
|
|||||||
@ -1,17 +1,12 @@
|
|||||||
[build-system]
|
|
||||||
requires = ["hatchling"]
|
|
||||||
build-backend = "hatchling.build"
|
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "zkac-node-cli"
|
name = "zkac-node"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "ZKAC node CLI (server + client) using the zkac Python bindings"
|
requires-python = ">=3.10"
|
||||||
readme = "README.md"
|
dependencies = ["zkac"]
|
||||||
requires-python = ">=3.9"
|
|
||||||
dependencies = []
|
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
zkac-node = "zkac_cli.main:main"
|
zkac-node = "zkac_cli.main:main"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[build-system]
|
||||||
packages = ["zkac_cli"]
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
"""ZKAC node CLI — server (registry-capable) and client commands."""
|
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
368
cli/zkac_cli/client.py
Normal file
368
cli/zkac_cli/client.py
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
"""Client-side operations over a unified encrypted channel.
|
||||||
|
|
||||||
|
All management and grant traffic uses the same anonymous handshake
|
||||||
|
(X25519 + Schnorr server identity proof) as authenticated sessions.
|
||||||
|
|
||||||
|
Grant flow (admin-initiated, server is trustless):
|
||||||
|
1. Admin knows recipient's X25519 issuance public key (out-of-band).
|
||||||
|
2. Admin locally prepares a blind request and issues the blind signature.
|
||||||
|
3. Admin encrypts the full credential material to the recipient's pk.
|
||||||
|
4. Admin posts the opaque ciphertext to the server's mailbox, authenticated
|
||||||
|
by a BBS+ admin presentation bound to the session transcript.
|
||||||
|
5. Recipient trial-decrypts each pending entry to find matching grants.
|
||||||
|
|
||||||
|
The server sees only: recipient_pk, eph_pk, ciphertext, grant_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
|
||||||
|
import zkac
|
||||||
|
from zkac.tcp import FramedSession, client_handshake_anon, client_handshake_managed
|
||||||
|
|
||||||
|
from . import store
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(s: str) -> bytes:
|
||||||
|
return base64.b64decode(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_server(server: str) -> tuple[str, int]:
|
||||||
|
host, _, port = server.rpartition(":")
|
||||||
|
return host or "127.0.0.1", int(port)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_spec(spec: str) -> tuple[str, str, str]:
|
||||||
|
"""Parse 'host:port:registry_id:role' into (server, registry_id, role)."""
|
||||||
|
parts = spec.rsplit(":", 2)
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise ValueError(f"invalid spec {spec!r}, expected host:port:registry_id:role")
|
||||||
|
return parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Encrypted management session ─────────────────────────────────────
|
||||||
|
|
||||||
|
def _resolve_server_pk(server: str) -> zkac.PublicKey:
|
||||||
|
"""Load pinned server public key or fail."""
|
||||||
|
pin = store.load_server_pin(server)
|
||||||
|
if pin is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"no pinned key for {server}; run: zkac-node server pin {server} --key <hex>"
|
||||||
|
)
|
||||||
|
return zkac.PublicKey.from_bytes(_unb64(pin["server_public_key_b64"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _mgmt_connect(server: str) -> tuple[socket.socket, FramedSession]:
|
||||||
|
"""Open an encrypted management session to the server."""
|
||||||
|
host, port = _parse_server(server)
|
||||||
|
sock = socket.create_connection((host, port))
|
||||||
|
server_pk = _resolve_server_pk(server)
|
||||||
|
node = zkac.Node(zkac.Keypair())
|
||||||
|
session = client_handshake_anon(sock, node, server_pk)
|
||||||
|
framed = FramedSession(sock, session)
|
||||||
|
framed.send(json.dumps({"op": "mgmt"}).encode())
|
||||||
|
return sock, framed
|
||||||
|
|
||||||
|
|
||||||
|
def _mgmt_cmd(framed: FramedSession, cmd: dict) -> dict:
|
||||||
|
framed.send(json.dumps(cmd).encode())
|
||||||
|
return json.loads(framed.recv())
|
||||||
|
|
||||||
|
|
||||||
|
def _ok(resp: dict) -> dict:
|
||||||
|
if resp.get("error"):
|
||||||
|
raise RuntimeError(resp["error"])
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
# ── Registry operations ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_registry(server: str, role_names: list[str]) -> str:
|
||||||
|
identity = store.load_identity()
|
||||||
|
admin_mat = store.new_admin_material()
|
||||||
|
bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_mat)
|
||||||
|
|
||||||
|
role_entries = [(zkac.role_id(name), bbs_pk, 1) for name in role_names]
|
||||||
|
state = zkac.RegistryState.build(
|
||||||
|
bbs_pk, identity["issuance_pk"], 1, b"\x00" * 32, role_entries,
|
||||||
|
)
|
||||||
|
state_bytes = state.serialize()
|
||||||
|
state_cert = state.certify(admin_cred)
|
||||||
|
registry_id = state.registry_id()
|
||||||
|
|
||||||
|
sock, framed = _mgmt_connect(server)
|
||||||
|
try:
|
||||||
|
resp = _ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "create_registry",
|
||||||
|
"state_bytes_b64": _b64(state_bytes),
|
||||||
|
"state_cert_b64": _b64(bytes(state_cert)),
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
rid_hex = resp["registry_id"]
|
||||||
|
store.save_admin(rid_hex, {
|
||||||
|
"server": server,
|
||||||
|
"roles": role_names,
|
||||||
|
**admin_mat,
|
||||||
|
})
|
||||||
|
return rid_hex
|
||||||
|
|
||||||
|
|
||||||
|
def update_registry(server: str, registry_id_hex: str, add_roles: list[str]):
|
||||||
|
admin_data = store.load_admin(registry_id_hex)
|
||||||
|
bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data)
|
||||||
|
identity = store.load_identity()
|
||||||
|
|
||||||
|
sock, framed = _mgmt_connect(server)
|
||||||
|
try:
|
||||||
|
cur = _ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "get_registry", "registry_id": registry_id_hex,
|
||||||
|
}))
|
||||||
|
|
||||||
|
old_state = zkac.RegistryState.deserialize(_unb64(cur["state_bytes_b64"]))
|
||||||
|
prev_hash = old_state.state_hash()
|
||||||
|
new_version = old_state.version() + 1
|
||||||
|
|
||||||
|
old_roles = admin_data.get("roles", [])
|
||||||
|
all_roles = list(old_roles) + [r for r in add_roles if r not in old_roles]
|
||||||
|
role_entries = [(zkac.role_id(name), bbs_pk, 1) for name in all_roles]
|
||||||
|
|
||||||
|
new_state = zkac.RegistryState.build(
|
||||||
|
bbs_pk, identity["issuance_pk"], new_version, bytes(prev_hash), role_entries,
|
||||||
|
)
|
||||||
|
new_cert = new_state.certify(admin_cred)
|
||||||
|
|
||||||
|
_ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "update_registry",
|
||||||
|
"registry_id": registry_id_hex,
|
||||||
|
"state_bytes_b64": _b64(new_state.serialize()),
|
||||||
|
"state_cert_b64": _b64(bytes(new_cert)),
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
admin_data["roles"] = all_roles
|
||||||
|
store.save_admin(registry_id_hex, admin_data)
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry(server: str, registry_id_hex: str) -> dict:
|
||||||
|
sock, framed = _mgmt_connect(server)
|
||||||
|
try:
|
||||||
|
return _ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "get_registry", "registry_id": registry_id_hex,
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_own_registries() -> list[dict]:
|
||||||
|
result = []
|
||||||
|
for rid in store.list_admin_registries():
|
||||||
|
data = store.load_admin(rid)
|
||||||
|
result.append({
|
||||||
|
"registry_id": rid,
|
||||||
|
"server": data.get("server", "?"),
|
||||||
|
"roles": data.get("roles", []),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin-initiated grant ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def grant(server: str, registry_id_hex: str, role_name: str,
|
||||||
|
recipient_pk_hex: str) -> str:
|
||||||
|
"""Admin issues a credential and posts it (encrypted) to the recipient's mailbox."""
|
||||||
|
admin_data = store.load_admin(registry_id_hex)
|
||||||
|
roles = admin_data.get("roles", [])
|
||||||
|
if role_name not in roles:
|
||||||
|
raise RuntimeError(f"role {role_name!r} not in registry (have: {roles})")
|
||||||
|
|
||||||
|
bbs_issuer, bbs_pk, admin_cred = store.reconstruct_admin(admin_data)
|
||||||
|
role_rid = zkac.role_id(role_name)
|
||||||
|
epoch = 1
|
||||||
|
|
||||||
|
req = zkac.prepare_blind_request()
|
||||||
|
blind_sig = bbs_issuer.issue_blind(req.commitment_with_proof(), role_rid, epoch)
|
||||||
|
|
||||||
|
payload = json.dumps({
|
||||||
|
"registry_id": registry_id_hex,
|
||||||
|
"role_name": role_name,
|
||||||
|
"epoch": epoch,
|
||||||
|
"issuer_pk_b64": _b64(bbs_pk.to_bytes()),
|
||||||
|
"blind_sig_b64": _b64(blind_sig),
|
||||||
|
"member_secret_b64": _b64(req.member_secret()),
|
||||||
|
"prover_blind_b64": _b64(req.prover_blind()),
|
||||||
|
}).encode()
|
||||||
|
|
||||||
|
recipient_pk = bytes.fromhex(recipient_pk_hex)
|
||||||
|
eph_kp = zkac.IssuanceKeypair()
|
||||||
|
ciphertext = eph_kp.encrypt(recipient_pk, payload)
|
||||||
|
|
||||||
|
sock, framed = _mgmt_connect(server)
|
||||||
|
try:
|
||||||
|
transcript_hash = bytes(framed.session.transcript_hash())
|
||||||
|
admin_proof = admin_cred.present(transcript_hash)
|
||||||
|
resp = _ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "post_grant",
|
||||||
|
"registry_id": registry_id_hex,
|
||||||
|
"recipient_pk_hex": recipient_pk_hex,
|
||||||
|
"eph_pk_b64": _b64(eph_kp.public_key_bytes()),
|
||||||
|
"ciphertext_b64": _b64(ciphertext),
|
||||||
|
"admin_proof_b64": _b64(admin_proof),
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
return resp["grant_id"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Receiver: list + collect (trial-decrypt) ─────────────────────────
|
||||||
|
|
||||||
|
def list_pending(server: str) -> list[dict]:
|
||||||
|
identity = store.load_identity()
|
||||||
|
pk_hex = identity["issuance_pk"].hex()
|
||||||
|
sock, framed = _mgmt_connect(server)
|
||||||
|
try:
|
||||||
|
resp = _ok(_mgmt_cmd(framed, {"cmd": "list_grants", "recipient_pk_hex": pk_hex}))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
|
||||||
|
results = []
|
||||||
|
for entry in resp["grants"]:
|
||||||
|
try:
|
||||||
|
eph_pk = _unb64(entry["eph_pk_b64"])
|
||||||
|
ct = _unb64(entry["ciphertext_b64"])
|
||||||
|
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
|
||||||
|
results.append({
|
||||||
|
"grant_id": entry["grant_id"],
|
||||||
|
"registry_id": plaintext.get("registry_id", "?"),
|
||||||
|
"role_name": plaintext.get("role_name", "?"),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
results.append({
|
||||||
|
"grant_id": entry["grant_id"],
|
||||||
|
"registry_id": "?",
|
||||||
|
"role_name": "(undecryptable)",
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def collect(spec: str) -> dict:
|
||||||
|
"""Fetch and finalize a pending credential described by 'host:port:registry_id:role'."""
|
||||||
|
server, registry_id_hex, role_name = parse_spec(spec)
|
||||||
|
identity = store.load_identity()
|
||||||
|
pk_hex = identity["issuance_pk"].hex()
|
||||||
|
receiver_kp = zkac.IssuanceKeypair.from_secret(identity["issuance_sk"])
|
||||||
|
|
||||||
|
sock, framed = _mgmt_connect(server)
|
||||||
|
try:
|
||||||
|
pending = _ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "list_grants", "recipient_pk_hex": pk_hex,
|
||||||
|
}))["grants"]
|
||||||
|
|
||||||
|
# Trial-decrypt to find matching grant
|
||||||
|
target_grant_id = None
|
||||||
|
target_payload = None
|
||||||
|
for entry in pending:
|
||||||
|
try:
|
||||||
|
eph_pk = _unb64(entry["eph_pk_b64"])
|
||||||
|
ct = _unb64(entry["ciphertext_b64"])
|
||||||
|
plaintext = json.loads(receiver_kp.decrypt(eph_pk, ct))
|
||||||
|
if (plaintext.get("registry_id") == registry_id_hex and
|
||||||
|
plaintext.get("role_name") == role_name):
|
||||||
|
target_grant_id = entry["grant_id"]
|
||||||
|
target_payload = plaintext
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if target_grant_id is None:
|
||||||
|
raise RuntimeError(f"no pending grant for {spec}")
|
||||||
|
|
||||||
|
_ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "claim_grant",
|
||||||
|
"recipient_pk_hex": pk_hex,
|
||||||
|
"grant_id": target_grant_id,
|
||||||
|
}))
|
||||||
|
|
||||||
|
reg_info = _ok(_mgmt_cmd(framed, {
|
||||||
|
"cmd": "get_registry", "registry_id": registry_id_hex,
|
||||||
|
}))
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
|
|
||||||
|
cred_data = {
|
||||||
|
"blind_sig_b64": target_payload["blind_sig_b64"],
|
||||||
|
"member_secret_b64": target_payload["member_secret_b64"],
|
||||||
|
"prover_blind_b64": target_payload["prover_blind_b64"],
|
||||||
|
"role_name": role_name,
|
||||||
|
"epoch": target_payload["epoch"],
|
||||||
|
"issuer_pk_b64": target_payload["issuer_pk_b64"],
|
||||||
|
}
|
||||||
|
cred = store.reconstruct_credential(cred_data)
|
||||||
|
cred.present(b"self-test")
|
||||||
|
|
||||||
|
store.save_credential(registry_id_hex, role_name, cred_data)
|
||||||
|
|
||||||
|
# Pin server + stash registry metadata locally
|
||||||
|
pin = store.load_server_pin(server)
|
||||||
|
if pin:
|
||||||
|
server_pk_b64 = pin["server_public_key_b64"]
|
||||||
|
else:
|
||||||
|
server_pk_b64 = _b64(b"\x00" * 32)
|
||||||
|
store.pin_server(server, server_pk_b64)
|
||||||
|
|
||||||
|
return {"registry_id": registry_id_hex, "role": role_name, "server": server}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authenticated session ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def authenticate(registry_id_hex: str, role_name: str,
|
||||||
|
server: str | None = None) -> dict:
|
||||||
|
admin_data = None
|
||||||
|
try:
|
||||||
|
admin_data = store.load_admin(registry_id_hex)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if server is None:
|
||||||
|
if admin_data and admin_data.get("server"):
|
||||||
|
server = admin_data["server"]
|
||||||
|
else:
|
||||||
|
raise RuntimeError("server address required (--server host:port)")
|
||||||
|
|
||||||
|
cred_data = store.load_credential_data(registry_id_hex, role_name)
|
||||||
|
cred = store.reconstruct_credential(cred_data)
|
||||||
|
|
||||||
|
server_pk = _resolve_server_pk(server)
|
||||||
|
node = zkac.Node(zkac.Keypair())
|
||||||
|
host, port = _parse_server(server)
|
||||||
|
|
||||||
|
sock = socket.create_connection((host, port))
|
||||||
|
try:
|
||||||
|
session = client_handshake_anon(sock, node, server_pk)
|
||||||
|
framed = FramedSession(sock, session)
|
||||||
|
|
||||||
|
transcript_hash = bytes(session.transcript_hash())
|
||||||
|
auth_proof = cred.present(transcript_hash)
|
||||||
|
role_rid = zkac.role_id(role_name)
|
||||||
|
|
||||||
|
framed.send(json.dumps({
|
||||||
|
"op": "auth",
|
||||||
|
"registry_id": registry_id_hex,
|
||||||
|
"role_id": role_rid.hex(),
|
||||||
|
"bbs_auth_b64": _b64(auth_proof),
|
||||||
|
}).encode())
|
||||||
|
|
||||||
|
return json.loads(framed.recv())
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
@ -1,64 +0,0 @@
|
|||||||
"""TCP clients for management, managed, and relay channels."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import zkac
|
|
||||||
from zkac.tcp import FramedSession, client_handshake, client_handshake_managed
|
|
||||||
|
|
||||||
|
|
||||||
def mgmt_call(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
server_pk: zkac.PublicKey,
|
|
||||||
mgmt_cred: zkac.Credential,
|
|
||||||
cmd: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
node = zkac.Node(zkac.Keypair())
|
|
||||||
sock = socket.create_connection((host, port))
|
|
||||||
try:
|
|
||||||
session = client_handshake(sock, node, server_pk, mgmt_cred)
|
|
||||||
framed = FramedSession(sock, session)
|
|
||||||
framed.send(json.dumps(cmd).encode("utf-8"))
|
|
||||||
return json.loads(framed.recv().decode("utf-8"))
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
def managed_call(
|
|
||||||
host: str,
|
|
||||||
port: int,
|
|
||||||
server_pk: zkac.PublicKey,
|
|
||||||
cred: zkac.Credential,
|
|
||||||
registry_id: bytes,
|
|
||||||
cmd: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
node = zkac.Node(zkac.Keypair())
|
|
||||||
sock = socket.create_connection((host, port))
|
|
||||||
try:
|
|
||||||
session = client_handshake_managed(sock, node, server_pk, cred, registry_id)
|
|
||||||
framed = FramedSession(sock, session)
|
|
||||||
framed.send(json.dumps(cmd).encode("utf-8"))
|
|
||||||
return json.loads(framed.recv().decode("utf-8"))
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
|
|
||||||
|
|
||||||
def relay_line(host: str, port: int, obj: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
payload = (json.dumps(obj) + "\n").encode("utf-8")
|
|
||||||
sock = socket.create_connection((host, port))
|
|
||||||
try:
|
|
||||||
sock.sendall(payload)
|
|
||||||
buf = b""
|
|
||||||
while b"\n" not in buf:
|
|
||||||
chunk = sock.recv(4096)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
buf += chunk
|
|
||||||
line = buf.split(b"\n", 1)[0]
|
|
||||||
return json.loads(line.decode("utf-8"))
|
|
||||||
finally:
|
|
||||||
sock.close()
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
"""Serialize / restore BBS+ member credentials and transport keys."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import zkac
|
|
||||||
|
|
||||||
|
|
||||||
def save_json(path: Path, obj: Any) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps(obj, indent=2), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def load_json(path: Path) -> Any:
|
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
|
|
||||||
def member_payload(
|
|
||||||
blind_sig: bytes,
|
|
||||||
req: zkac.BlindRequest,
|
|
||||||
role_id: bytes,
|
|
||||||
epoch: int,
|
|
||||||
issuer_pk: zkac.BbsPublicKey,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"role_id_hex": role_id.hex(),
|
|
||||||
"epoch": epoch,
|
|
||||||
"blind_sig_b64": base64.b64encode(blind_sig).decode(),
|
|
||||||
"member_secret_b64": base64.b64encode(req.member_secret()).decode(),
|
|
||||||
"prover_blind_b64": base64.b64encode(req.prover_blind()).decode(),
|
|
||||||
"issuer_public_key_b64": base64.b64encode(issuer_pk.to_bytes()).decode(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def load_member_credential(data: dict[str, Any]) -> zkac.Credential:
|
|
||||||
pk = zkac.BbsPublicKey.from_bytes(base64.b64decode(data["issuer_public_key_b64"]))
|
|
||||||
rid = bytes.fromhex(data["role_id_hex"])
|
|
||||||
return zkac.Credential.finalize(
|
|
||||||
base64.b64decode(data["blind_sig_b64"]),
|
|
||||||
base64.b64decode(data["member_secret_b64"]),
|
|
||||||
base64.b64decode(data["prover_blind_b64"]),
|
|
||||||
rid,
|
|
||||||
int(data["epoch"]),
|
|
||||||
pk,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def save_transport_keypair(path: Path, kp: zkac.Keypair) -> None:
|
|
||||||
save_json(
|
|
||||||
path,
|
|
||||||
{
|
|
||||||
"secret_key_b64": base64.b64encode(kp.secret_key_bytes()).decode(),
|
|
||||||
"public_key_b64": base64.b64encode(kp.public_key().to_bytes()).decode(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_transport_keypair(path: Path) -> zkac.Keypair:
|
|
||||||
t = load_json(path)
|
|
||||||
return zkac.Keypair.from_secret_key(base64.b64decode(t["secret_key_b64"]))
|
|
||||||
|
|
||||||
|
|
||||||
def save_server_transport(path: Path, kp: zkac.Keypair) -> None:
|
|
||||||
save_json(
|
|
||||||
path,
|
|
||||||
{
|
|
||||||
"server_secret_key_b64": base64.b64encode(kp.secret_key_bytes()).decode(),
|
|
||||||
"server_public_key_b64": base64.b64encode(kp.public_key().to_bytes()).decode(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def load_server_public_key(path: Path) -> zkac.PublicKey:
|
|
||||||
t = load_json(path)
|
|
||||||
return zkac.PublicKey.from_bytes(base64.b64decode(t["server_public_key_b64"]))
|
|
||||||
|
|
||||||
|
|
||||||
def load_server_keypair(path: Path) -> zkac.Keypair:
|
|
||||||
t = load_json(path)
|
|
||||||
return zkac.Keypair.from_secret_key(base64.b64decode(t["server_secret_key_b64"]))
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
"""Helpers around RegistryManager issuance queues (Python API drains on take)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import zkac
|
|
||||||
|
|
||||||
|
|
||||||
def peek_pending_requests(
|
|
||||||
mgr: zkac.RegistryManager, registry_id: bytes
|
|
||||||
) -> list[tuple[bytes, bytes, bytes, bytes]]:
|
|
||||||
"""Return pending (request_id, role_id, eph_pk, ciphertext) and re-queue them."""
|
|
||||||
items = mgr.take_pending_requests(registry_id)
|
|
||||||
out: list[tuple[bytes, bytes, bytes, bytes]] = []
|
|
||||||
for tup in items:
|
|
||||||
req_id, role_id, eph_pk, enc = tup
|
|
||||||
out.append((req_id, role_id, eph_pk, enc))
|
|
||||||
mgr.queue_issuance_request(registry_id, req_id, role_id, eph_pk, enc)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def take_pending_requests(
|
|
||||||
mgr: zkac.RegistryManager, registry_id: bytes
|
|
||||||
) -> list[tuple[bytes, bytes, bytes, bytes]]:
|
|
||||||
"""Drain pending queue (admin processes and must grant or lose requests)."""
|
|
||||||
items = mgr.take_pending_requests(registry_id)
|
|
||||||
return [(a, b, c, d) for a, b, c, d in items]
|
|
||||||
@ -1,414 +1,246 @@
|
|||||||
"""zkac-node CLI entrypoint."""
|
"""CLI entry point for zkac-node."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import secrets
|
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import zkac
|
from . import client, store
|
||||||
|
|
||||||
from zkac_cli import creds
|
|
||||||
from zkac_cli import client_ops
|
|
||||||
from zkac_cli import paths
|
|
||||||
from zkac_cli import registry_local
|
|
||||||
from zkac_cli import server_app
|
|
||||||
|
|
||||||
|
|
||||||
MGMT_ROLE = server_app.MGMT_ROLE
|
# ── identity ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _cmd_identity_init(_args):
|
||||||
|
if store.identity_exists():
|
||||||
|
print("identity already exists")
|
||||||
|
ident = store.load_identity()
|
||||||
|
print(f" issuance public key: {ident['issuance_pk'].hex()}")
|
||||||
|
return
|
||||||
|
path = store.create_identity()
|
||||||
|
ident = store.load_identity()
|
||||||
|
print("created identity")
|
||||||
|
print(f" issuance public key: {ident['issuance_pk'].hex()}")
|
||||||
|
print(f" (share this key out-of-band to receive credentials)")
|
||||||
|
print(f" stored in {path}")
|
||||||
|
|
||||||
|
|
||||||
def cmd_serve(args: argparse.Namespace) -> None:
|
def _cmd_identity_show(_args):
|
||||||
data = Path(args.data).resolve()
|
ident = store.load_identity()
|
||||||
if args.init or not (data / "transport.json").is_file():
|
print(f" issuance pk: {ident['issuance_pk'].hex()}")
|
||||||
data.mkdir(parents=True, exist_ok=True)
|
|
||||||
transport = zkac.Keypair()
|
regs = client.list_own_registries()
|
||||||
issuer = zkac.BbsIssuer()
|
if regs:
|
||||||
pk = issuer.public_key()
|
print(f" registries owned: {len(regs)}")
|
||||||
mg_rid = zkac.role_id(MGMT_ROLE)
|
for r in regs:
|
||||||
req = zkac.prepare_blind_request()
|
print(f" {r['registry_id']} @ {r['server']} roles={r['roles']}")
|
||||||
sig = issuer.issue_blind(req.commitment_with_proof(), mg_rid, 1)
|
|
||||||
_ = zkac.Credential.finalize(
|
creds = store.list_credentials()
|
||||||
sig, req.member_secret(), req.prover_blind(), mg_rid, 1, pk
|
if creds:
|
||||||
)
|
print(f" credentials: {len(creds)}")
|
||||||
creds.save_server_transport(data / "transport.json", transport)
|
for reg_hex, role in creds:
|
||||||
creds.save_json(
|
print(f" {reg_hex[:16]}… / {role}")
|
||||||
data / "mgmt_issuer.json",
|
|
||||||
{"issuer_secret_b64": base64.b64encode(issuer.secret_key_bytes()).decode()},
|
|
||||||
)
|
|
||||||
m = creds.member_payload(sig, req, mg_rid, 1, pk)
|
|
||||||
creds.save_json(data / "mgmt_member.json", m)
|
|
||||||
print(f"Initialized server data in {data}", flush=True)
|
|
||||||
print(
|
|
||||||
"Copy mgmt_member.json for operators; distribute server_public_key from transport.json",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
relay = None if args.relay_port == 0 else args.relay_port
|
|
||||||
relay_bind = args.relay_bind
|
|
||||||
print(
|
|
||||||
f"Starting: mgmt={args.mgmt_port} managed={args.managed_port} relay={relay!r} bind={relay_bind}",
|
|
||||||
flush=True,
|
|
||||||
)
|
|
||||||
server_app.serve(data, args.mgmt_port, args.managed_port, relay, relay_bind)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_user_create(args: argparse.Namespace) -> None:
|
# ── server ────────────────────────────────────────────────────────────
|
||||||
uid = args.userid
|
|
||||||
d = paths.ensure_user(uid)
|
def _cmd_serve(args):
|
||||||
kp = zkac.Keypair()
|
from .server import serve
|
||||||
creds.save_transport_keypair(d / "transport.json", kp)
|
serve(args.data_dir, args.host, args.port)
|
||||||
creds.save_json(d / "profile.json", {"userid": uid})
|
|
||||||
print(f"User {uid!r}: wrote {d / 'transport.json'}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_registry_init(args: argparse.Namespace) -> None:
|
def _cmd_server_pin(args):
|
||||||
base = paths.ensure_user(args.user)
|
pk_hex = args.key
|
||||||
out = (base / "registries" / args.slug).resolve()
|
import base64
|
||||||
r = registry_local.create_registry_bundle(args.slug, list(args.roles), out)
|
pk_bytes = bytes.fromhex(pk_hex)
|
||||||
print(json.dumps(r, indent=2))
|
store.pin_server(args.server, base64.b64encode(pk_bytes).decode())
|
||||||
|
print(f"pinned {args.server} -> {pk_hex[:16]}…")
|
||||||
|
|
||||||
|
|
||||||
def cmd_registry_push(args: argparse.Namespace) -> None:
|
# ── registry ──────────────────────────────────────────────────────────
|
||||||
server_data = Path(args.server_data).resolve()
|
|
||||||
reg_dir = (paths.user_dir(args.user) / "registries" / args.slug).resolve()
|
def _cmd_registry_create(args):
|
||||||
reg = creds.load_json(reg_dir / "registry.json")
|
roles = [r.strip() for r in args.roles.split(",")]
|
||||||
mg = creds.load_json(server_data / "mgmt_member.json")
|
rid = client.create_registry(args.server, roles)
|
||||||
t = creds.load_json(server_data / "transport.json")
|
print(f"registry created: {rid}")
|
||||||
server_pk = zkac.PublicKey.from_bytes(base64.b64decode(t["server_public_key_b64"]))
|
print(f" roles: {', '.join(roles)}")
|
||||||
mgmt_cred = creds.load_member_credential(mg)
|
|
||||||
cmd = {"cmd": "create_registry", "state_b64": reg["state_bytes_b64"], "state_cert_b64": reg["state_cert_b64"]}
|
|
||||||
if args.update:
|
|
||||||
cmd = {"cmd": "update_registry", "state_b64": reg["state_bytes_b64"], "state_cert_b64": reg["state_cert_b64"]}
|
|
||||||
out = client_ops.mgmt_call(args.host, args.mgmt_port, server_pk, mgmt_cred, cmd)
|
|
||||||
print(json.dumps(out, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_connect(args: argparse.Namespace) -> None:
|
def _cmd_registry_update(args):
|
||||||
reg_dir = (paths.user_dir(args.user) / "registries" / args.slug).resolve()
|
add = [r.strip() for r in args.add_roles.split(",")]
|
||||||
reg_path = reg_dir / "registry.json"
|
client.update_registry(args.server, args.registry, add)
|
||||||
if reg_path.is_file():
|
print(f"registry updated: {args.registry[:16]}…")
|
||||||
reg = creds.load_json(reg_path)
|
print(f" added roles: {', '.join(add)}")
|
||||||
elif args.registry_id:
|
|
||||||
reg = {"registry_id_hex": args.registry_id}
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
"Missing registry.json. Copy from the admin (public) or pass --registry-id.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
raise SystemExit(1)
|
|
||||||
cred_path = (reg_dir / args.credential).resolve()
|
|
||||||
m = creds.load_json(cred_path)
|
|
||||||
credential = creds.load_member_credential(m)
|
|
||||||
server = creds.load_json(Path(args.server_file).expanduser().resolve())
|
|
||||||
server_pk = zkac.PublicKey.from_bytes(base64.b64decode(server["server_public_key_b64"]))
|
|
||||||
registry_id = bytes.fromhex(reg["registry_id_hex"])
|
|
||||||
body = client_ops.managed_call(
|
|
||||||
args.host,
|
|
||||||
args.managed_port,
|
|
||||||
server_pk,
|
|
||||||
credential,
|
|
||||||
registry_id,
|
|
||||||
{"cmd": args.command},
|
|
||||||
)
|
|
||||||
print(json.dumps(body, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_issuance_request(args: argparse.Namespace) -> None:
|
def _cmd_registry_get(args):
|
||||||
"""Queue a blind issuance request (payload is commitment bytes; localhost relay only)."""
|
info = client.get_registry(args.server, args.registry)
|
||||||
reg = creds.load_json(paths.user_dir(args.user) / "registries" / args.slug / "registry.json")
|
print(f"registry: {args.registry}")
|
||||||
issuance_pk = base64.b64decode(reg["issuance_public_key_b64"])
|
print(f" state bytes: {len(info.get('state_bytes_b64', ''))} chars (b64)")
|
||||||
req = zkac.prepare_blind_request()
|
|
||||||
payload = req.commitment_with_proof()
|
|
||||||
req_id = secrets.token_bytes(32)
|
|
||||||
eph_pk = secrets.token_bytes(32)
|
|
||||||
# Store locally for finalize
|
|
||||||
pending = {
|
|
||||||
"request_id_hex": req_id.hex(),
|
|
||||||
"role_name": args.role,
|
|
||||||
"member_secret_b64": base64.b64encode(req.member_secret()).decode(),
|
|
||||||
"prover_blind_b64": base64.b64encode(req.prover_blind()).decode(),
|
|
||||||
"issuer_public_key_b64": reg["admin_issuer_public_key_b64"],
|
|
||||||
"epoch": 1,
|
|
||||||
}
|
|
||||||
pdir = paths.user_dir(args.user) / "pending"
|
|
||||||
pdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
creds.save_json(pdir / f"{req_id.hex()}.json", pending)
|
|
||||||
|
|
||||||
obj = {
|
|
||||||
"cmd": "enqueue",
|
|
||||||
"registry_id_hex": reg["registry_id_hex"],
|
|
||||||
"role_name": args.role,
|
|
||||||
"request_id_hex": req_id.hex(),
|
|
||||||
"eph_pk_hex": eph_pk.hex(),
|
|
||||||
"payload_b64": base64.b64encode(payload).decode(),
|
|
||||||
}
|
|
||||||
out = client_ops.relay_line(args.host, args.relay_port, obj)
|
|
||||||
print(json.dumps(out, indent=2))
|
|
||||||
print(f"Saved pending material to {pdir / (req_id.hex() + '.json')}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_issuance_poll(args: argparse.Namespace) -> None:
|
def _cmd_registry_list(_args):
|
||||||
reg = creds.load_json(paths.user_dir(args.user) / "registries" / args.slug / "registry.json")
|
regs = client.list_own_registries()
|
||||||
obj = {
|
if not regs:
|
||||||
"cmd": "poll",
|
print("no registries")
|
||||||
"registry_id_hex": reg["registry_id_hex"],
|
return
|
||||||
"request_id_hex": args.request_id,
|
for r in regs:
|
||||||
}
|
print(f" {r['registry_id']} @ {r['server']} roles={r['roles']}")
|
||||||
out = client_ops.relay_line(args.host, args.relay_port, obj)
|
|
||||||
print(json.dumps(out, indent=2))
|
|
||||||
if out.get("status") == "ready":
|
|
||||||
pend = creds.load_json(paths.user_dir(args.user) / "pending" / f"{args.request_id}.json")
|
|
||||||
pk = zkac.BbsPublicKey.from_bytes(base64.b64decode(pend["issuer_public_key_b64"]))
|
|
||||||
rid = zkac.role_id(pend["role_name"])
|
|
||||||
blind_sig = base64.b64decode(out["blind_sig_b64"])
|
|
||||||
zkac.Credential.finalize(
|
|
||||||
blind_sig,
|
|
||||||
base64.b64decode(pend["member_secret_b64"]),
|
|
||||||
base64.b64decode(pend["prover_blind_b64"]),
|
|
||||||
rid,
|
|
||||||
int(pend["epoch"]),
|
|
||||||
pk,
|
|
||||||
)
|
|
||||||
out_dir = paths.user_dir(args.user) / "registries" / args.slug / "roles"
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
member = {
|
|
||||||
"role_id_hex": rid.hex(),
|
|
||||||
"epoch": int(pend["epoch"]),
|
|
||||||
"blind_sig_b64": base64.b64encode(blind_sig).decode(),
|
|
||||||
"member_secret_b64": pend["member_secret_b64"],
|
|
||||||
"prover_blind_b64": pend["prover_blind_b64"],
|
|
||||||
"issuer_public_key_b64": pend["issuer_public_key_b64"],
|
|
||||||
}
|
|
||||||
dest = out_dir / f"{pend['role_name']}.json"
|
|
||||||
creds.save_json(dest, member)
|
|
||||||
print(f"You have role {pend['role_name']!r}; credential saved to {dest}")
|
|
||||||
print("Use: zkac-node connect ... --credential roles/{0}.json".format(pend["role_name"]))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_issuance_grant(args: argparse.Namespace) -> None:
|
# ── grant (admin-initiated) ──────────────────────────────────────────
|
||||||
server_data = Path(args.server_data).resolve()
|
|
||||||
reg_dir = (paths.user_dir(args.admin_user) / "registries" / args.slug).resolve()
|
|
||||||
admin = creds.load_json(reg_dir / "admin.json")
|
|
||||||
issuer = zkac.BbsIssuer.from_secret_key(base64.b64decode(admin["admin_issuer_secret_b64"]))
|
|
||||||
t = creds.load_json(server_data / "transport.json")
|
|
||||||
mg = creds.load_json(server_data / "mgmt_member.json")
|
|
||||||
server_pk = zkac.PublicKey.from_bytes(base64.b64decode(t["server_public_key_b64"]))
|
|
||||||
mgmt_cred = creds.load_member_credential(mg)
|
|
||||||
|
|
||||||
peek = client_ops.mgmt_call(
|
def _cmd_grant(args):
|
||||||
args.host,
|
gid = client.grant(args.server, args.registry, args.role, args.to)
|
||||||
args.mgmt_port,
|
print(f"granted {args.role!r} to {args.to[:16]}…")
|
||||||
server_pk,
|
print(f" grant id: {gid}")
|
||||||
mgmt_cred,
|
print(f" recipient can collect with:")
|
||||||
{"cmd": "issuance_peek", "registry_id_hex": args.registry_id},
|
print(f" zkac-node collect {args.server}:{args.registry}:{args.role}")
|
||||||
)
|
|
||||||
pending = peek.get("pending") or []
|
|
||||||
target = None
|
|
||||||
for p in pending:
|
|
||||||
if p["request_id_hex"] == args.request_id:
|
|
||||||
target = p
|
|
||||||
break
|
|
||||||
if target is None:
|
|
||||||
print("request_id not in pending queue", file=sys.stderr)
|
|
||||||
raise SystemExit(1)
|
|
||||||
commit = base64.b64decode(target["payload_b64"])
|
|
||||||
role_id = bytes.fromhex(target["role_id_hex"])
|
|
||||||
blind = issuer.issue_blind(commit, role_id, 1)
|
|
||||||
out = client_ops.mgmt_call(
|
|
||||||
args.host,
|
|
||||||
args.mgmt_port,
|
|
||||||
server_pk,
|
|
||||||
mgmt_cred,
|
|
||||||
{
|
|
||||||
"cmd": "issuance_grant",
|
|
||||||
"registry_id_hex": args.registry_id,
|
|
||||||
"request_id_hex": args.request_id,
|
|
||||||
"blind_sig_b64": base64.b64encode(blind).decode(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
print(json.dumps(out, indent=2))
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_issue_member_file(args: argparse.Namespace) -> None:
|
# ── credentials list / collect ────────────────────────────────────────
|
||||||
"""Admin issues a role credential locally (out-of-band handoff)."""
|
|
||||||
reg_dir = (paths.user_dir(args.admin_user) / "registries" / args.slug).resolve()
|
def _cmd_credentials_list(args):
|
||||||
admin = creds.load_json(reg_dir / "admin.json")
|
local = store.list_credentials()
|
||||||
issuer = zkac.BbsIssuer.from_secret_key(base64.b64decode(admin["admin_issuer_secret_b64"]))
|
print("local credentials:")
|
||||||
pk = issuer.public_key()
|
if not local:
|
||||||
rid = zkac.role_id(args.role)
|
print(" (none)")
|
||||||
req = zkac.prepare_blind_request()
|
for reg_hex, role in local:
|
||||||
sig = issuer.issue_blind(req.commitment_with_proof(), rid, 1)
|
print(f" {reg_hex}:{role}")
|
||||||
payload = creds.member_payload(sig, req, rid, 1, pk)
|
|
||||||
out_path = reg_dir / "issued" / f"{args.role}_{args.target_user}.json"
|
servers = list(args.server or [])
|
||||||
creds.save_json(out_path, payload)
|
for s in store.list_pinned_servers():
|
||||||
print(f"Wrote {out_path}")
|
if s not in servers:
|
||||||
|
servers.append(s)
|
||||||
|
|
||||||
|
if not servers:
|
||||||
|
print("\n(no servers to query; pass --server host:port to check for pending)")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\npending grants:")
|
||||||
|
any_pending = False
|
||||||
|
for srv in servers:
|
||||||
|
try:
|
||||||
|
grants = client.list_pending(srv)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" [{srv}] error: {exc}")
|
||||||
|
continue
|
||||||
|
for g in grants:
|
||||||
|
any_pending = True
|
||||||
|
rid = g.get("registry_id", "?")
|
||||||
|
role = g.get("role_name", "?")
|
||||||
|
if rid != "?" and role != "?" and store.has_credential(rid, role):
|
||||||
|
note = " (already collected locally)"
|
||||||
|
else:
|
||||||
|
note = ""
|
||||||
|
print(f" {srv}:{rid}:{role}{note}")
|
||||||
|
if not any_pending:
|
||||||
|
print(" (none)")
|
||||||
|
|
||||||
|
|
||||||
def cmd_registry_import_public(args: argparse.Namespace) -> None:
|
def _cmd_collect(args):
|
||||||
"""Copy public registry.json (state + cert summary) from another local user."""
|
result = client.collect(args.spec)
|
||||||
src = (paths.user_dir(args.from_user) / "registries" / args.slug / "registry.json").resolve()
|
print("collected credential")
|
||||||
dst_dir = paths.ensure_user(args.to_user) / "registries" / args.slug
|
print(f" registry: {result['registry_id']}")
|
||||||
dst_dir.mkdir(parents=True, exist_ok=True)
|
print(f" role: {result['role']}")
|
||||||
shutil.copy(src, dst_dir / "registry.json")
|
print(f" server: {result['server']}")
|
||||||
print(f"Copied {src} -> {dst_dir / 'registry.json'}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_import_credential(args: argparse.Namespace) -> None:
|
# ── auth ──────────────────────────────────────────────────────────────
|
||||||
src = Path(args.file).resolve()
|
|
||||||
data = creds.load_json(src)
|
def _cmd_auth(args):
|
||||||
dest = paths.user_dir(args.user) / "registries" / args.slug / "roles" / f"{args.name}.json"
|
resp = client.authenticate(args.registry, args.role, server=args.server)
|
||||||
creds.save_json(dest, data)
|
print(json.dumps(resp, indent=2))
|
||||||
print(f"Imported credential to {dest}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_pin_server(args: argparse.Namespace) -> None:
|
# ── argparse wiring ───────────────────────────────────────────────────
|
||||||
"""Save server public key + ports for a user (from server's transport.json)."""
|
|
||||||
tpath = Path(args.transport_file).resolve()
|
|
||||||
t = creds.load_json(tpath)
|
|
||||||
d = paths.ensure_user(args.user) / "servers" / args.name
|
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
creds.save_json(
|
|
||||||
d / "server.json",
|
|
||||||
{
|
|
||||||
"server_public_key_b64": t["server_public_key_b64"],
|
|
||||||
"host": args.host,
|
|
||||||
"mgmt_port": args.mgmt_port,
|
|
||||||
"managed_port": args.managed_port,
|
|
||||||
"relay_port": args.relay_port,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
print(f"Pinned server {args.name!r} for user {args.user!r} -> {d / 'server.json'}")
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(prog="zkac-node")
|
||||||
|
sub = p.add_subparsers(dest="group", required=True)
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
# identity
|
||||||
p = argparse.ArgumentParser(
|
id_p = sub.add_parser("identity", help="manage local identity")
|
||||||
prog="zkac-node",
|
id_sub = id_p.add_subparsers(dest="action", required=True)
|
||||||
description="ZKAC node CLI (server + client). "
|
|
||||||
"Requires the 'zkac' package from this repo (maturin develop). "
|
|
||||||
"Set ZKAC_HOME to override ~/.zkac/. "
|
|
||||||
"Issuance relay queues are in-memory — use the same server process between request and grant.",
|
|
||||||
)
|
|
||||||
sub = p.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
ps = sub.add_parser("serve", help="Run server (registry-capable node)")
|
c = id_sub.add_parser("init", help="generate a new identity")
|
||||||
ps.add_argument("--data", type=Path, required=True, help="Server data directory")
|
c.set_defaults(func=_cmd_identity_init)
|
||||||
ps.add_argument("--init", action="store_true", help="Initialize data dir (transport + mgmt issuer)")
|
|
||||||
ps.add_argument("--mgmt-port", type=int, default=7400)
|
|
||||||
ps.add_argument("--managed-port", type=int, default=7401)
|
|
||||||
ps.add_argument(
|
|
||||||
"--relay-port",
|
|
||||||
type=int,
|
|
||||||
default=7402,
|
|
||||||
help="Plaintext relay for issuance queue (localhost only). Use 0 to disable.",
|
|
||||||
)
|
|
||||||
ps.add_argument("--relay-bind", default="127.0.0.1")
|
|
||||||
ps.set_defaults(func=cmd_serve)
|
|
||||||
|
|
||||||
su = sub.add_parser("user-create", help="Create a user directory under ~/.zkac/")
|
c = id_sub.add_parser("show", help="show identity + registries + credentials")
|
||||||
su.add_argument("userid")
|
c.set_defaults(func=_cmd_identity_show)
|
||||||
su.set_defaults(func=cmd_user_create)
|
|
||||||
|
|
||||||
pi = sub.add_parser("pin-server", help="Save server connection info for a user")
|
# serve
|
||||||
pi.add_argument("--user", required=True)
|
c = sub.add_parser("serve", help="run as a ZKAC server node")
|
||||||
pi.add_argument("--name", required=True, help="Label for this server")
|
c.add_argument("--data-dir", required=True, help="server data directory")
|
||||||
pi.add_argument("--transport-file", required=True, help="Path to server's transport.json")
|
c.add_argument("--host", default="127.0.0.1")
|
||||||
pi.add_argument("--host", required=True)
|
c.add_argument("--port", type=int, default=9800)
|
||||||
pi.add_argument("--mgmt-port", type=int, default=7400)
|
c.set_defaults(func=_cmd_serve)
|
||||||
pi.add_argument("--managed-port", type=int, default=7401)
|
|
||||||
pi.add_argument("--relay-port", type=int, default=7402)
|
|
||||||
pi.set_defaults(func=cmd_pin_server)
|
|
||||||
|
|
||||||
ri = sub.add_parser("registry-init", help="Create a client-managed registry offline")
|
# server pin
|
||||||
ri.add_argument("--user", required=True)
|
srv_p = sub.add_parser("server", help="manage server pins")
|
||||||
ri.add_argument("--slug", required=True)
|
srv_sub = srv_p.add_subparsers(dest="action", required=True)
|
||||||
ri.add_argument("--roles", nargs="+", required=True)
|
|
||||||
ri.set_defaults(func=cmd_registry_init)
|
|
||||||
|
|
||||||
rp = sub.add_parser("registry-push", help="Push registry snapshot to server (mgmt channel)")
|
c = srv_sub.add_parser("pin", help="pin a server's public key")
|
||||||
rp.add_argument("--user", required=True)
|
c.add_argument("server", help="host:port")
|
||||||
rp.add_argument("--slug", required=True)
|
c.add_argument("--key", required=True, help="server public key (hex)")
|
||||||
rp.add_argument("--server-data", type=Path, required=True, help="Server data dir (transport + mgmt_member)")
|
c.set_defaults(func=_cmd_server_pin)
|
||||||
rp.add_argument("--host", default="127.0.0.1")
|
|
||||||
rp.add_argument("--mgmt-port", type=int, default=7400)
|
|
||||||
rp.add_argument("--update", action="store_true", help="Send update_registry instead of create")
|
|
||||||
rp.set_defaults(func=cmd_registry_push)
|
|
||||||
|
|
||||||
cc = sub.add_parser("connect", help="Managed ZKAC session test (whoami/get_registry)")
|
# registry
|
||||||
cc.add_argument("--user", required=True)
|
reg_p = sub.add_parser("registry", help="manage registries")
|
||||||
cc.add_argument("--slug", required=True)
|
reg_sub = reg_p.add_subparsers(dest="action", required=True)
|
||||||
cc.add_argument("--credential", default="roles/member.json", help="Relative path under registries/<slug>/")
|
|
||||||
cc.add_argument(
|
|
||||||
"--registry-id",
|
|
||||||
default="",
|
|
||||||
help="If registry.json is absent, hex registry id (pin public registry first)",
|
|
||||||
)
|
|
||||||
cc.add_argument("--server-file", required=True, help="Pinned server.json from pin-server")
|
|
||||||
cc.add_argument("--host", required=True)
|
|
||||||
cc.add_argument("--managed-port", type=int, default=7401)
|
|
||||||
cc.add_argument("--command", default="whoami")
|
|
||||||
cc.set_defaults(func=cmd_connect)
|
|
||||||
|
|
||||||
ir = sub.add_parser(
|
c = reg_sub.add_parser("create", help="create a new registry on a server")
|
||||||
"issuance-request",
|
c.add_argument("server", help="host:port")
|
||||||
help="Enqueue blind commitment on relay (localhost; see help text)",
|
c.add_argument("--roles", required=True, help="comma-separated role names")
|
||||||
)
|
c.set_defaults(func=_cmd_registry_create)
|
||||||
ir.add_argument("--user", required=True)
|
|
||||||
ir.add_argument("--slug", required=True)
|
|
||||||
ir.add_argument("--role", required=True)
|
|
||||||
ir.add_argument("--host", default="127.0.0.1")
|
|
||||||
ir.add_argument("--relay-port", type=int, default=7402)
|
|
||||||
ir.set_defaults(func=cmd_issuance_request)
|
|
||||||
|
|
||||||
ip = sub.add_parser("issuance-poll", help="Poll relay for blind signature")
|
c = reg_sub.add_parser("update", help="add roles to a registry you own")
|
||||||
ip.add_argument("--user", required=True)
|
c.add_argument("server", help="host:port")
|
||||||
ip.add_argument("--slug", required=True)
|
c.add_argument("--registry", required=True)
|
||||||
ip.add_argument("--request-id", required=True, dest="request_id")
|
c.add_argument("--add-roles", required=True, help="comma-separated new roles")
|
||||||
ip.add_argument("--host", default="127.0.0.1")
|
c.set_defaults(func=_cmd_registry_update)
|
||||||
ip.add_argument("--relay-port", type=int, default=7402)
|
|
||||||
ip.set_defaults(func=cmd_issuance_poll)
|
|
||||||
|
|
||||||
ig = sub.add_parser("issuance-grant", help="Admin: issue blind sig and push grant to server")
|
c = reg_sub.add_parser("get", help="fetch registry info from server")
|
||||||
ig.add_argument("--admin-user", required=True)
|
c.add_argument("server", help="host:port")
|
||||||
ig.add_argument("--slug", required=True)
|
c.add_argument("--registry", required=True)
|
||||||
ig.add_argument("--server-data", type=Path, required=True)
|
c.set_defaults(func=_cmd_registry_get)
|
||||||
ig.add_argument("--registry-id", required=True, dest="registry_id")
|
|
||||||
ig.add_argument("--request-id", required=True, dest="request_id")
|
|
||||||
ig.add_argument("--host", default="127.0.0.1")
|
|
||||||
ig.add_argument("--mgmt-port", type=int, default=7400)
|
|
||||||
ig.set_defaults(func=cmd_issuance_grant)
|
|
||||||
|
|
||||||
im = sub.add_parser("issue-member", help="Admin: issue credential to file for handoff")
|
c = reg_sub.add_parser("list", help="list local registries (owned)")
|
||||||
im.add_argument("--admin-user", required=True)
|
c.set_defaults(func=_cmd_registry_list)
|
||||||
im.add_argument("--slug", required=True)
|
|
||||||
im.add_argument("--role", required=True)
|
|
||||||
im.add_argument("--target-user", required=True)
|
|
||||||
im.set_defaults(func=cmd_issue_member_file)
|
|
||||||
|
|
||||||
irp = sub.add_parser(
|
# grant
|
||||||
"registry-import-public",
|
c = sub.add_parser("grant", help="issue a credential to a recipient (admin)")
|
||||||
help="Copy registry.json from another user (public state + cert metadata)",
|
c.add_argument("--server", required=True, help="host:port")
|
||||||
)
|
c.add_argument("--registry", required=True)
|
||||||
irp.add_argument("--from-user", required=True, dest="from_user")
|
c.add_argument("--role", required=True)
|
||||||
irp.add_argument("--to-user", required=True, dest="to_user")
|
c.add_argument("--to", required=True,
|
||||||
irp.add_argument("--slug", required=True)
|
help="recipient's issuance public key (hex)")
|
||||||
irp.set_defaults(func=cmd_registry_import_public)
|
c.set_defaults(func=_cmd_grant)
|
||||||
|
|
||||||
ii = sub.add_parser("import-credential", help="Copy a member json into a user's registry folder")
|
# credentials list
|
||||||
ii.add_argument("--user", required=True)
|
cred_p = sub.add_parser("credentials", help="credentials (local + pending)")
|
||||||
ii.add_argument("--slug", required=True)
|
cred_sub = cred_p.add_subparsers(dest="action", required=True)
|
||||||
ii.add_argument("--name", required=True, help="Filename base (e.g. analyst)")
|
c = cred_sub.add_parser("list", help="show local + pending credentials")
|
||||||
ii.add_argument("file", type=Path)
|
c.add_argument("--server", action="append",
|
||||||
ii.set_defaults(func=cmd_import_credential)
|
help="server to query (host:port); may be repeated")
|
||||||
|
c.set_defaults(func=_cmd_credentials_list)
|
||||||
|
|
||||||
return p
|
# collect
|
||||||
|
c = sub.add_parser("collect", help="fetch and finalize a pending credential")
|
||||||
|
c.add_argument("spec", help="host:port:registry_id:role")
|
||||||
|
c.set_defaults(func=_cmd_collect)
|
||||||
|
|
||||||
|
# auth
|
||||||
|
c = sub.add_parser("auth", help="authenticate to a server with a credential")
|
||||||
|
c.add_argument("--registry", required=True)
|
||||||
|
c.add_argument("--role", required=True)
|
||||||
|
c.add_argument("--server", default=None, help="host:port (optional if known)")
|
||||||
|
c.set_defaults(func=_cmd_auth)
|
||||||
|
|
||||||
def main() -> None:
|
args = p.parse_args()
|
||||||
args = build_parser().parse_args()
|
if not hasattr(args, "func"):
|
||||||
|
p.print_help()
|
||||||
|
sys.exit(1)
|
||||||
args.func(args)
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,12 +8,6 @@ def zkac_home() -> Path:
|
|||||||
return Path(os.environ.get("ZKAC_HOME", Path.home() / ".zkac"))
|
return Path(os.environ.get("ZKAC_HOME", Path.home() / ".zkac"))
|
||||||
|
|
||||||
|
|
||||||
def user_dir(userid: str) -> Path:
|
def data_dir() -> Path:
|
||||||
return zkac_home() / userid
|
"""Server data directory (overridable via ZKAC_DATA_DIR)."""
|
||||||
|
return Path(os.environ.get("ZKAC_DATA_DIR", zkac_home()))
|
||||||
|
|
||||||
def ensure_user(userid: str) -> Path:
|
|
||||||
zkac_home().mkdir(parents=True, exist_ok=True)
|
|
||||||
d = user_dir(userid)
|
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
|
||||||
return d
|
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
"""Offline client-managed registry creation (same structure as demo/setup_managed_demo)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import zkac
|
|
||||||
|
|
||||||
from zkac_cli import creds
|
|
||||||
|
|
||||||
|
|
||||||
def create_registry_bundle(
|
|
||||||
slug: str,
|
|
||||||
role_names: list[str],
|
|
||||||
out_dir: Path,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
"""
|
|
||||||
Build a new registry with admin issuer = role issuer for all listed roles.
|
|
||||||
Writes admin.json, registry.json under out_dir.
|
|
||||||
"""
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
admin_issuer = zkac.BbsIssuer()
|
|
||||||
admin_pk = admin_issuer.public_key()
|
|
||||||
admin_rid = zkac.admin_role_id()
|
|
||||||
|
|
||||||
req = zkac.prepare_blind_request()
|
|
||||||
sig = admin_issuer.issue_blind(req.commitment_with_proof(), admin_rid, 0)
|
|
||||||
admin_cred = zkac.Credential.finalize(
|
|
||||||
sig, req.member_secret(), req.prover_blind(), admin_rid, 0, admin_pk
|
|
||||||
)
|
|
||||||
|
|
||||||
issuance_kp = zkac.IssuanceKeypair()
|
|
||||||
role_entries = [(zkac.role_id(name), admin_pk, 1) for name in role_names]
|
|
||||||
|
|
||||||
state = zkac.RegistryState.build(
|
|
||||||
admin_pk, issuance_kp.public_key_bytes(), 1, b"\x00" * 32, role_entries
|
|
||||||
)
|
|
||||||
state_bytes = state.serialize()
|
|
||||||
state_cert = state.certify(admin_cred)
|
|
||||||
registry_id = state.registry_id()
|
|
||||||
|
|
||||||
admin_payload = {
|
|
||||||
"slug": slug,
|
|
||||||
"admin_issuer_secret_b64": base64.b64encode(admin_issuer.secret_key_bytes()).decode(),
|
|
||||||
"admin_issuer_public_key_b64": base64.b64encode(admin_pk.to_bytes()).decode(),
|
|
||||||
"admin_member_secret_b64": base64.b64encode(req.member_secret()).decode(),
|
|
||||||
"admin_prover_blind_b64": base64.b64encode(req.prover_blind()).decode(),
|
|
||||||
"admin_blind_sig_b64": base64.b64encode(sig).decode(),
|
|
||||||
"issuance_secret_b64": base64.b64encode(issuance_kp.secret_bytes()).decode(),
|
|
||||||
"issuance_public_key_b64": base64.b64encode(issuance_kp.public_key_bytes()).decode(),
|
|
||||||
"registry_id_hex": registry_id.hex(),
|
|
||||||
}
|
|
||||||
creds.save_json(out_dir / "admin.json", admin_payload)
|
|
||||||
|
|
||||||
reg_payload = {
|
|
||||||
"slug": slug,
|
|
||||||
"registry_id_hex": registry_id.hex(),
|
|
||||||
"state_bytes_b64": base64.b64encode(state_bytes).decode(),
|
|
||||||
"state_cert_b64": base64.b64encode(bytes(state_cert)).decode(),
|
|
||||||
"admin_issuer_public_key_b64": base64.b64encode(admin_pk.to_bytes()).decode(),
|
|
||||||
"issuance_public_key_b64": base64.b64encode(issuance_kp.public_key_bytes()).decode(),
|
|
||||||
"roles": role_names,
|
|
||||||
}
|
|
||||||
creds.save_json(out_dir / "registry.json", reg_payload)
|
|
||||||
|
|
||||||
return {"registry_id_hex": registry_id.hex(), "path": str(out_dir)}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
"""Persist RegistryManager by replaying create/update events."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import zkac
|
|
||||||
|
|
||||||
|
|
||||||
def save_events(path: Path, events: list[dict[str, Any]]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(json.dumps({"events": events}, indent=2), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def load_events(path: Path) -> list[dict[str, Any]]:
|
|
||||||
if not path.is_file():
|
|
||||||
return []
|
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
|
||||||
return list(data.get("events", []))
|
|
||||||
|
|
||||||
|
|
||||||
def replay_manager(events: list[dict[str, Any]]) -> zkac.RegistryManager:
|
|
||||||
"""Rebuild manager from persisted events (multiple registries supported)."""
|
|
||||||
mgr = zkac.RegistryManager()
|
|
||||||
by_rid: dict[bytes, list[dict[str, Any]]] = {}
|
|
||||||
for e in events:
|
|
||||||
st = base64.b64decode(e["state_b64"])
|
|
||||||
st_obj = zkac.RegistryState.deserialize(st)
|
|
||||||
rid = st_obj.registry_id()
|
|
||||||
by_rid.setdefault(rid, []).append(e)
|
|
||||||
for rid, evs in by_rid.items():
|
|
||||||
evs.sort(
|
|
||||||
key=lambda e: zkac.RegistryState.deserialize(
|
|
||||||
base64.b64decode(e["state_b64"])
|
|
||||||
).version()
|
|
||||||
)
|
|
||||||
for i, e in enumerate(evs):
|
|
||||||
st = base64.b64decode(e["state_b64"])
|
|
||||||
cert = base64.b64decode(e["state_cert_b64"])
|
|
||||||
if i == 0:
|
|
||||||
mgr.create(st, cert)
|
|
||||||
else:
|
|
||||||
mgr.update(rid, st, cert)
|
|
||||||
return mgr
|
|
||||||
|
|
||||||
|
|
||||||
def append_event(path: Path, op: str, state_bytes: bytes, state_cert: bytes) -> None:
|
|
||||||
events = load_events(path)
|
|
||||||
events.append(
|
|
||||||
{
|
|
||||||
"op": op,
|
|
||||||
"state_b64": base64.b64encode(state_bytes).decode(),
|
|
||||||
"state_cert_b64": base64.b64encode(state_cert).decode(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
save_events(path, events)
|
|
||||||
282
cli/zkac_cli/server.py
Normal file
282
cli/zkac_cli/server.py
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
"""ZKAC server: all traffic over a single encrypted, server-authenticated channel.
|
||||||
|
|
||||||
|
Every connection performs an anonymous handshake (X25519 + Schnorr server
|
||||||
|
identity proof). The first encrypted frame selects the mode:
|
||||||
|
|
||||||
|
{"op": "mgmt"} management commands (create_registry, post_grant, ...)
|
||||||
|
{"op": "auth", "registry_id": hex, "bbs_auth_b64": ...} role authentication
|
||||||
|
|
||||||
|
The server stores only cryptographically verified opaque blobs:
|
||||||
|
<data_dir>/server_key.json Schnorr keypair
|
||||||
|
<data_dir>/registries/<rid>.state raw RegistryState bytes
|
||||||
|
<data_dir>/registries/<rid>.cert raw state cert bytes
|
||||||
|
<data_dir>/mailbox/<pk_hex>.json [{grant_id, eph_pk_b64, ciphertext_b64}, ...]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import zkac
|
||||||
|
from zkac.tcp import FramedSession, server_handshake_anon
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(s: str) -> bytes:
|
||||||
|
return base64.b64decode(s)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Opaque server storage ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ServerStore:
|
||||||
|
"""Thread-safe, opaque persistence for registries and mailbox."""
|
||||||
|
|
||||||
|
def __init__(self, data_dir: Path):
|
||||||
|
self._dir = data_dir
|
||||||
|
self._reg_dir = data_dir / "registries"
|
||||||
|
self._mbox_dir = data_dir / "mailbox"
|
||||||
|
self._reg_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._mbox_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# ── server key ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_or_create_keypair(self) -> zkac.Keypair:
|
||||||
|
kf = self._dir / "server_key.json"
|
||||||
|
if kf.exists():
|
||||||
|
data = json.loads(kf.read_text())
|
||||||
|
return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"]))
|
||||||
|
kp = zkac.Keypair()
|
||||||
|
kf.write_text(json.dumps({
|
||||||
|
"secret_b64": _b64(kp.secret_key_bytes()),
|
||||||
|
"public_b64": _b64(kp.public_key().to_bytes()),
|
||||||
|
}, indent=2))
|
||||||
|
return kp
|
||||||
|
|
||||||
|
# ── registries ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def save_registry(self, rid_hex: str, state_bytes: bytes, cert_bytes: bytes):
|
||||||
|
with self._lock:
|
||||||
|
(self._reg_dir / f"{rid_hex}.state").write_bytes(state_bytes)
|
||||||
|
(self._reg_dir / f"{rid_hex}.cert").write_bytes(cert_bytes)
|
||||||
|
|
||||||
|
def load_all_registries(self, mgr: zkac.RegistryManager) -> int:
|
||||||
|
count = 0
|
||||||
|
for p in sorted(self._reg_dir.glob("*.state")):
|
||||||
|
rid_hex = p.stem
|
||||||
|
cert_path = self._reg_dir / f"{rid_hex}.cert"
|
||||||
|
if not cert_path.exists():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
mgr.create(p.read_bytes(), cert_path.read_bytes())
|
||||||
|
count += 1
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[server] skip registry {rid_hex}: {exc}")
|
||||||
|
return count
|
||||||
|
|
||||||
|
# ── mailbox ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _mbox_path(self, pk_hex: str) -> Path:
|
||||||
|
return self._mbox_dir / f"{pk_hex}.json"
|
||||||
|
|
||||||
|
def post_grant(self, recipient_pk_hex: str, entry: dict) -> str:
|
||||||
|
grant_id = os.urandom(16).hex()
|
||||||
|
entry = {"grant_id": grant_id, **entry}
|
||||||
|
with self._lock:
|
||||||
|
p = self._mbox_path(recipient_pk_hex)
|
||||||
|
entries = json.loads(p.read_text()) if p.exists() else []
|
||||||
|
entries.append(entry)
|
||||||
|
p.write_text(json.dumps(entries, indent=2))
|
||||||
|
return grant_id
|
||||||
|
|
||||||
|
def list_grants(self, recipient_pk_hex: str) -> list[dict]:
|
||||||
|
with self._lock:
|
||||||
|
p = self._mbox_path(recipient_pk_hex)
|
||||||
|
if not p.exists():
|
||||||
|
return []
|
||||||
|
return json.loads(p.read_text())
|
||||||
|
|
||||||
|
def claim_grant(self, recipient_pk_hex: str, grant_id: str) -> dict | None:
|
||||||
|
with self._lock:
|
||||||
|
p = self._mbox_path(recipient_pk_hex)
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
entries = json.loads(p.read_text())
|
||||||
|
for i, e in enumerate(entries):
|
||||||
|
if e["grant_id"] == grant_id:
|
||||||
|
del entries[i]
|
||||||
|
if entries:
|
||||||
|
p.write_text(json.dumps(entries, indent=2))
|
||||||
|
else:
|
||||||
|
p.unlink(missing_ok=True)
|
||||||
|
return e
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Command dispatch (inside encrypted session) ──────────────────────
|
||||||
|
|
||||||
|
def _dispatch(cmd: dict, mgr: zkac.RegistryManager, store: _ServerStore,
|
||||||
|
server_pk_b64: str, transcript_hash: bytes) -> dict:
|
||||||
|
try:
|
||||||
|
action = cmd.get("cmd")
|
||||||
|
|
||||||
|
if action == "server_info":
|
||||||
|
return {"ok": True, "server_public_key_b64": server_pk_b64}
|
||||||
|
|
||||||
|
if action == "create_registry":
|
||||||
|
state_bytes = _unb64(cmd["state_bytes_b64"])
|
||||||
|
state_cert = _unb64(cmd["state_cert_b64"])
|
||||||
|
rid = mgr.create(state_bytes, state_cert)
|
||||||
|
store.save_registry(rid.hex(), state_bytes, state_cert)
|
||||||
|
return {"ok": True, "registry_id": rid.hex()}
|
||||||
|
|
||||||
|
if action == "get_registry":
|
||||||
|
rid = bytes.fromhex(cmd["registry_id"])
|
||||||
|
state_bytes, state_cert = mgr.get(rid)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"state_bytes_b64": _b64(state_bytes),
|
||||||
|
"state_cert_b64": _b64(state_cert),
|
||||||
|
}
|
||||||
|
|
||||||
|
if action == "update_registry":
|
||||||
|
rid = bytes.fromhex(cmd["registry_id"])
|
||||||
|
state_bytes = _unb64(cmd["state_bytes_b64"])
|
||||||
|
state_cert = _unb64(cmd["state_cert_b64"])
|
||||||
|
mgr.update(rid, state_bytes, state_cert)
|
||||||
|
store.save_registry(cmd["registry_id"], state_bytes, state_cert)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
if action == "post_grant":
|
||||||
|
rid = bytes.fromhex(cmd["registry_id"])
|
||||||
|
proof = _unb64(cmd["admin_proof_b64"])
|
||||||
|
if not mgr.verify_admin(rid, proof, transcript_hash):
|
||||||
|
return {"error": "admin proof failed"}
|
||||||
|
entry = {
|
||||||
|
"eph_pk_b64": cmd["eph_pk_b64"],
|
||||||
|
"ciphertext_b64": cmd["ciphertext_b64"],
|
||||||
|
}
|
||||||
|
gid = store.post_grant(cmd["recipient_pk_hex"], entry)
|
||||||
|
return {"ok": True, "grant_id": gid}
|
||||||
|
|
||||||
|
if action == "list_grants":
|
||||||
|
entries = store.list_grants(cmd["recipient_pk_hex"])
|
||||||
|
return {"ok": True, "grants": entries}
|
||||||
|
|
||||||
|
if action == "claim_grant":
|
||||||
|
entry = store.claim_grant(cmd["recipient_pk_hex"], cmd["grant_id"])
|
||||||
|
if entry is None:
|
||||||
|
return {"error": "grant not found"}
|
||||||
|
return {"ok": True, "grant": entry}
|
||||||
|
|
||||||
|
return {"error": f"unknown command: {action}"}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return {"error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Connection handler ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_conn(conn: socket.socket, addr: tuple, node: zkac.Node,
|
||||||
|
mgr: zkac.RegistryManager, store: _ServerStore,
|
||||||
|
server_pk_b64: str):
|
||||||
|
peer = f"{addr[0]}:{addr[1]}"
|
||||||
|
try:
|
||||||
|
session = server_handshake_anon(conn, node)
|
||||||
|
framed = FramedSession(conn, session)
|
||||||
|
transcript_hash = bytes(session.transcript_hash())
|
||||||
|
|
||||||
|
hello = json.loads(framed.recv())
|
||||||
|
op = hello.get("op")
|
||||||
|
|
||||||
|
if op == "mgmt":
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = framed.recv()
|
||||||
|
except (ConnectionError, OSError):
|
||||||
|
break
|
||||||
|
cmd = json.loads(data)
|
||||||
|
resp = _dispatch(cmd, mgr, store, server_pk_b64, transcript_hash)
|
||||||
|
framed.send(json.dumps(resp).encode())
|
||||||
|
|
||||||
|
elif op == "auth":
|
||||||
|
registry_id = bytes.fromhex(hello["registry_id"])
|
||||||
|
role_id = bytes.fromhex(hello["role_id"])
|
||||||
|
proof_bytes = _unb64(hello["bbs_auth_b64"])
|
||||||
|
|
||||||
|
ok = mgr.verify_presentation(
|
||||||
|
registry_id, role_id, proof_bytes, transcript_hash,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
framed.send(json.dumps({"error": "auth failed"}).encode())
|
||||||
|
return
|
||||||
|
|
||||||
|
resp = {
|
||||||
|
"status": "authenticated",
|
||||||
|
"registry_id": registry_id.hex(),
|
||||||
|
"role_id": role_id.hex(),
|
||||||
|
}
|
||||||
|
framed.send(json.dumps(resp).encode())
|
||||||
|
|
||||||
|
# keep session open for app traffic
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data = framed.recv()
|
||||||
|
except (ConnectionError, OSError):
|
||||||
|
break
|
||||||
|
framed.send(data)
|
||||||
|
else:
|
||||||
|
framed.send(json.dumps({"error": f"unknown op: {op}"}).encode())
|
||||||
|
|
||||||
|
except (ConnectionError, BrokenPipeError, OSError):
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"[server] {peer} error: {exc}")
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public entry point ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def serve(data_dir: str, host: str = "127.0.0.1", port: int = 9800):
|
||||||
|
dd = Path(data_dir)
|
||||||
|
dd.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
store = _ServerStore(dd)
|
||||||
|
kp = store.load_or_create_keypair()
|
||||||
|
server_pk_b64 = _b64(kp.public_key().to_bytes())
|
||||||
|
node = zkac.Node(kp)
|
||||||
|
|
||||||
|
mgr = zkac.RegistryManager()
|
||||||
|
n = store.load_all_registries(mgr)
|
||||||
|
|
||||||
|
print(f"server public key: {_unb64(server_pk_b64).hex()}")
|
||||||
|
print(f"loaded {n} registries")
|
||||||
|
print(f"listening on {host}:{port}")
|
||||||
|
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sock.bind((host, port))
|
||||||
|
sock.listen(8)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
conn, addr = sock.accept()
|
||||||
|
threading.Thread(
|
||||||
|
target=_handle_conn,
|
||||||
|
args=(conn, addr, node, mgr, store, server_pk_b64),
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nshutdown")
|
||||||
|
finally:
|
||||||
|
sock.close()
|
||||||
@ -1,272 +0,0 @@
|
|||||||
"""TCP servers: ZKAC management, ZKAC managed app, localhost relay (credential queue)."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
import zkac
|
|
||||||
from zkac.tcp import FramedSession, server_handshake, server_handshake_managed
|
|
||||||
|
|
||||||
from zkac_cli import issuance_util
|
|
||||||
from zkac_cli import registry_log
|
|
||||||
|
|
||||||
|
|
||||||
MGMT_ROLE = "zkac.mgmt"
|
|
||||||
|
|
||||||
|
|
||||||
def build_static_registry(issuer_pk: zkac.BbsPublicKey) -> zkac.RoleRegistry:
|
|
||||||
reg = zkac.RoleRegistry()
|
|
||||||
reg.register_role(zkac.role_id(MGMT_ROLE), issuer_pk, 1)
|
|
||||||
return reg
|
|
||||||
|
|
||||||
|
|
||||||
def handle_mgmt_session(
|
|
||||||
conn: socket.socket,
|
|
||||||
data_dir: Path,
|
|
||||||
mgr: zkac.RegistryManager,
|
|
||||||
registry_ids: set[bytes],
|
|
||||||
events_path: Path,
|
|
||||||
save_registry: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
from zkac_cli import creds
|
|
||||||
|
|
||||||
framed = None
|
|
||||||
try:
|
|
||||||
sk = creds.load_server_keypair(data_dir / "transport.json")
|
|
||||||
node = zkac.Node(sk)
|
|
||||||
issuer = zkac.BbsIssuer.from_secret_key(
|
|
||||||
base64.b64decode(creds.load_json(data_dir / "mgmt_issuer.json")["issuer_secret_b64"])
|
|
||||||
)
|
|
||||||
static_reg = build_static_registry(issuer.public_key())
|
|
||||||
session, role_id = server_handshake(conn, node, static_reg)
|
|
||||||
if role_id != zkac.role_id(MGMT_ROLE):
|
|
||||||
return
|
|
||||||
framed = FramedSession(conn, session)
|
|
||||||
raw = framed.recv()
|
|
||||||
msg = json.loads(raw.decode("utf-8"))
|
|
||||||
cmd = msg["cmd"]
|
|
||||||
if cmd == "create_registry":
|
|
||||||
st = base64.b64decode(msg["state_b64"])
|
|
||||||
cert = base64.b64decode(msg["state_cert_b64"])
|
|
||||||
rid = mgr.create(st, cert)
|
|
||||||
registry_ids.add(rid)
|
|
||||||
registry_log.append_event(events_path, "create", st, cert)
|
|
||||||
save_registry()
|
|
||||||
out = {"ok": True, "registry_id_hex": rid.hex()}
|
|
||||||
elif cmd == "update_registry":
|
|
||||||
st = base64.b64decode(msg["state_b64"])
|
|
||||||
cert = base64.b64decode(msg["state_cert_b64"])
|
|
||||||
st_o = zkac.RegistryState.deserialize(st)
|
|
||||||
rid = st_o.registry_id()
|
|
||||||
mgr.update(rid, st, cert)
|
|
||||||
registry_log.append_event(events_path, "update", st, cert)
|
|
||||||
save_registry()
|
|
||||||
out = {"ok": True, "registry_id_hex": rid.hex()}
|
|
||||||
elif cmd == "get_registry":
|
|
||||||
rid = bytes.fromhex(msg["registry_id_hex"])
|
|
||||||
st, c = mgr.get(rid)
|
|
||||||
out = {
|
|
||||||
"ok": True,
|
|
||||||
"state_b64": base64.b64encode(st).decode(),
|
|
||||||
"state_cert_b64": base64.b64encode(c).decode(),
|
|
||||||
}
|
|
||||||
elif cmd == "list_registries":
|
|
||||||
out = {"ok": True, "registry_ids_hex": [h.hex() for h in sorted(registry_ids)]}
|
|
||||||
elif cmd == "issuance_peek":
|
|
||||||
rid = bytes.fromhex(msg["registry_id_hex"])
|
|
||||||
pending = issuance_util.peek_pending_requests(mgr, rid)
|
|
||||||
out = {
|
|
||||||
"ok": True,
|
|
||||||
"pending": [
|
|
||||||
{
|
|
||||||
"request_id_hex": a.hex(),
|
|
||||||
"role_id_hex": b.hex(),
|
|
||||||
"eph_pk_hex": c.hex(),
|
|
||||||
"payload_b64": base64.b64encode(d).decode(),
|
|
||||||
}
|
|
||||||
for a, b, c, d in pending
|
|
||||||
],
|
|
||||||
}
|
|
||||||
elif cmd == "issuance_grant":
|
|
||||||
rid = bytes.fromhex(msg["registry_id_hex"])
|
|
||||||
req_id = bytes.fromhex(msg["request_id_hex"])
|
|
||||||
blind = base64.b64decode(msg["blind_sig_b64"])
|
|
||||||
mgr.grant_credential(rid, req_id, blind)
|
|
||||||
save_registry()
|
|
||||||
out = {"ok": True}
|
|
||||||
else:
|
|
||||||
out = {"ok": False, "error": f"unknown cmd {cmd}"}
|
|
||||||
framed.send(json.dumps(out).encode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def run_managed_handler(
|
|
||||||
conn: socket.socket,
|
|
||||||
data_dir: Path,
|
|
||||||
mgr: zkac.RegistryManager,
|
|
||||||
) -> None:
|
|
||||||
from zkac_cli import creds
|
|
||||||
|
|
||||||
try:
|
|
||||||
sk = creds.load_server_keypair(data_dir / "transport.json")
|
|
||||||
node = zkac.Node(sk)
|
|
||||||
session, registry_id, role_id = server_handshake_managed(conn, node, mgr)
|
|
||||||
framed = FramedSession(conn, session)
|
|
||||||
raw = framed.recv()
|
|
||||||
msg = json.loads(raw.decode("utf-8"))
|
|
||||||
cmd = msg.get("cmd", "ping")
|
|
||||||
if cmd == "get_registry":
|
|
||||||
rid = bytes.fromhex(msg["registry_id_hex"])
|
|
||||||
st, c = mgr.get(rid)
|
|
||||||
body = {
|
|
||||||
"ok": True,
|
|
||||||
"state_b64": base64.b64encode(st).decode(),
|
|
||||||
"state_cert_b64": base64.b64encode(c).decode(),
|
|
||||||
}
|
|
||||||
elif cmd == "whoami":
|
|
||||||
body = {
|
|
||||||
"ok": True,
|
|
||||||
"registry_id_hex": registry_id.hex(),
|
|
||||||
"role_id_hex": role_id.hex(),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
body = {
|
|
||||||
"ok": True,
|
|
||||||
"registry_id_hex": registry_id.hex(),
|
|
||||||
"role_id_hex": role_id.hex(),
|
|
||||||
"note": "authenticated managed session",
|
|
||||||
}
|
|
||||||
framed.send(json.dumps(body).encode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def handle_relay_session(
|
|
||||||
conn: socket.socket,
|
|
||||||
mgr: zkac.RegistryManager,
|
|
||||||
save_registry: Callable[[], None],
|
|
||||||
) -> None:
|
|
||||||
try:
|
|
||||||
buf = b""
|
|
||||||
while b"\n" not in buf:
|
|
||||||
chunk = conn.recv(4096)
|
|
||||||
if not chunk:
|
|
||||||
return
|
|
||||||
buf += chunk
|
|
||||||
line, _, _ = buf.partition(b"\n")
|
|
||||||
msg = json.loads(line.decode("utf-8"))
|
|
||||||
cmd = msg["cmd"]
|
|
||||||
if cmd == "enqueue":
|
|
||||||
rid = bytes.fromhex(msg["registry_id_hex"])
|
|
||||||
role = zkac.role_id(msg["role_name"])
|
|
||||||
req_id = bytes.fromhex(msg["request_id_hex"])
|
|
||||||
eph = bytes.fromhex(msg["eph_pk_hex"])
|
|
||||||
blob = base64.b64decode(msg["payload_b64"])
|
|
||||||
mgr.queue_issuance_request(rid, req_id, role, eph, blob)
|
|
||||||
save_registry()
|
|
||||||
out = {"ok": True, "status": "queued"}
|
|
||||||
elif cmd == "poll":
|
|
||||||
rid = bytes.fromhex(msg["registry_id_hex"])
|
|
||||||
req_id = bytes.fromhex(msg["request_id_hex"])
|
|
||||||
g = mgr.take_granted_credential(rid, req_id)
|
|
||||||
save_registry()
|
|
||||||
if g is None:
|
|
||||||
out = {"ok": True, "status": "pending"}
|
|
||||||
else:
|
|
||||||
out = {
|
|
||||||
"ok": True,
|
|
||||||
"status": "ready",
|
|
||||||
"blind_sig_b64": base64.b64encode(g).decode(),
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
out = {"ok": False, "error": "unknown relay cmd"}
|
|
||||||
conn.sendall((json.dumps(out) + "\n").encode("utf-8"))
|
|
||||||
except Exception:
|
|
||||||
traceback.print_exc()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
def serve(
|
|
||||||
data_dir: Path,
|
|
||||||
mgmt_port: int,
|
|
||||||
managed_port: int,
|
|
||||||
relay_port: int | None,
|
|
||||||
relay_bind: str,
|
|
||||||
) -> None:
|
|
||||||
events_path = data_dir / "registry_events.json"
|
|
||||||
registry_ids: set[bytes] = set()
|
|
||||||
if events_path.is_file():
|
|
||||||
mgr = registry_log.replay_manager(registry_log.load_events(events_path))
|
|
||||||
for e in registry_log.load_events(events_path):
|
|
||||||
st = base64.b64decode(e["state_b64"])
|
|
||||||
registry_ids.add(zkac.RegistryState.deserialize(st).registry_id())
|
|
||||||
else:
|
|
||||||
mgr = zkac.RegistryManager()
|
|
||||||
|
|
||||||
def save_registry() -> None:
|
|
||||||
return
|
|
||||||
|
|
||||||
def mgmt_loop() -> None:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
s.bind(("0.0.0.0", mgmt_port))
|
|
||||||
s.listen(8)
|
|
||||||
print(f"[mgmt] ZKAC listening on 0.0.0.0:{mgmt_port}")
|
|
||||||
while True:
|
|
||||||
c, a = s.accept()
|
|
||||||
print(f"[mgmt] connect {a}")
|
|
||||||
threading.Thread(
|
|
||||||
target=handle_mgmt_session,
|
|
||||||
args=(c, data_dir, mgr, registry_ids, events_path, save_registry),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def managed_loop() -> None:
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
s.bind(("0.0.0.0", managed_port))
|
|
||||||
s.listen(8)
|
|
||||||
print(f"[managed] ZKAC listening on 0.0.0.0:{managed_port}")
|
|
||||||
while True:
|
|
||||||
c, a = s.accept()
|
|
||||||
print(f"[managed] connect {a}")
|
|
||||||
threading.Thread(
|
|
||||||
target=run_managed_handler,
|
|
||||||
args=(c, data_dir, mgr),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
def relay_loop() -> None:
|
|
||||||
if relay_port is None:
|
|
||||||
return
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
||||||
s.bind((relay_bind, relay_port))
|
|
||||||
s.listen(8)
|
|
||||||
print(f"[relay] plaintext JSON lines on {relay_bind}:{relay_port}")
|
|
||||||
while True:
|
|
||||||
c, a = s.accept()
|
|
||||||
print(f"[relay] connect {a}")
|
|
||||||
threading.Thread(
|
|
||||||
target=handle_relay_session,
|
|
||||||
args=(c, mgr, save_registry),
|
|
||||||
daemon=True,
|
|
||||||
).start()
|
|
||||||
|
|
||||||
threading.Thread(target=mgmt_loop, daemon=True).start()
|
|
||||||
threading.Thread(target=managed_loop, daemon=True).start()
|
|
||||||
if relay_port is not None:
|
|
||||||
threading.Thread(target=relay_loop, daemon=True).start()
|
|
||||||
threading.Event().wait()
|
|
||||||
181
cli/zkac_cli/store.py
Normal file
181
cli/zkac_cli/store.py
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
"""Persistent storage for ZKAC identities, admin bundles, credentials, and server pins.
|
||||||
|
|
||||||
|
Layout (single identity, no userid):
|
||||||
|
~/.zkac/identity.json issuance keypair (long-term client secret)
|
||||||
|
~/.zkac/admin/<registry_id>.json BBS+ issuer + admin credential per owned registry
|
||||||
|
~/.zkac/credentials/<rid>_<role>.json BBS+ credentials granted to this identity
|
||||||
|
~/.zkac/servers/<host_port>.json pinned server public keys
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import zkac
|
||||||
|
|
||||||
|
from .paths import zkac_home
|
||||||
|
|
||||||
|
|
||||||
|
def _b64(data: bytes) -> str:
|
||||||
|
return base64.b64encode(data).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _unb64(s: str) -> bytes:
|
||||||
|
return base64.b64decode(s)
|
||||||
|
|
||||||
|
|
||||||
|
def _home() -> Path:
|
||||||
|
d = zkac_home()
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# ── Identity (single, no userid) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def create_identity() -> Path:
|
||||||
|
d = _home()
|
||||||
|
p = d / "identity.json"
|
||||||
|
if p.exists():
|
||||||
|
raise FileExistsError(f"identity already exists at {p}")
|
||||||
|
|
||||||
|
issuance_kp = zkac.IssuanceKeypair()
|
||||||
|
identity = {
|
||||||
|
"issuance_secret_b64": _b64(issuance_kp.secret_bytes()),
|
||||||
|
"issuance_public_b64": _b64(issuance_kp.public_key_bytes()),
|
||||||
|
}
|
||||||
|
p.write_text(json.dumps(identity, indent=2))
|
||||||
|
for sub in ("admin", "credentials", "servers"):
|
||||||
|
(d / sub).mkdir(exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def load_identity() -> dict:
|
||||||
|
data = json.loads((_home() / "identity.json").read_text())
|
||||||
|
return {
|
||||||
|
"issuance_sk": _unb64(data["issuance_secret_b64"]),
|
||||||
|
"issuance_pk": _unb64(data["issuance_public_b64"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def identity_exists() -> bool:
|
||||||
|
return (_home() / "identity.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Server pins ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _server_key(server: str) -> str:
|
||||||
|
return server.replace(":", "_")
|
||||||
|
|
||||||
|
|
||||||
|
def pin_server(server: str, server_pk_b64: str):
|
||||||
|
d = _home() / "servers"
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
(d / f"{_server_key(server)}.json").write_text(
|
||||||
|
json.dumps({"server_public_key_b64": server_pk_b64}, indent=2)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_server_pin(server: str) -> dict | None:
|
||||||
|
p = _home() / "servers" / f"{_server_key(server)}.json"
|
||||||
|
if not p.exists():
|
||||||
|
return None
|
||||||
|
return json.loads(p.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def list_pinned_servers() -> list[str]:
|
||||||
|
d = _home() / "servers"
|
||||||
|
if not d.exists():
|
||||||
|
return []
|
||||||
|
return sorted(p.stem.replace("_", ":") for p in d.glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Admin bundles (per registry) ──────────────────────────────────────
|
||||||
|
|
||||||
|
def new_admin_material() -> dict:
|
||||||
|
bbs_issuer = zkac.BbsIssuer()
|
||||||
|
bbs_pk = bbs_issuer.public_key()
|
||||||
|
admin_rid = zkac.admin_role_id()
|
||||||
|
|
||||||
|
req = zkac.prepare_blind_request()
|
||||||
|
sig = bbs_issuer.issue_blind(req.commitment_with_proof(), admin_rid, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"bbs_issuer_secret_b64": _b64(bbs_issuer.secret_key_bytes()),
|
||||||
|
"bbs_issuer_public_b64": _b64(bbs_pk.to_bytes()),
|
||||||
|
"admin_blind_sig_b64": _b64(sig),
|
||||||
|
"admin_member_secret_b64": _b64(req.member_secret()),
|
||||||
|
"admin_prover_blind_b64": _b64(req.prover_blind()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct_admin(data: dict) -> tuple:
|
||||||
|
bbs_issuer = zkac.BbsIssuer.from_secret_key(_unb64(data["bbs_issuer_secret_b64"]))
|
||||||
|
bbs_pk = bbs_issuer.public_key()
|
||||||
|
admin_cred = zkac.Credential.finalize(
|
||||||
|
_unb64(data["admin_blind_sig_b64"]),
|
||||||
|
_unb64(data["admin_member_secret_b64"]),
|
||||||
|
_unb64(data["admin_prover_blind_b64"]),
|
||||||
|
zkac.admin_role_id(), 0, bbs_pk,
|
||||||
|
)
|
||||||
|
return bbs_issuer, bbs_pk, admin_cred
|
||||||
|
|
||||||
|
|
||||||
|
def save_admin(registry_id_hex: str, info: dict):
|
||||||
|
d = _home() / "admin"
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
(d / f"{registry_id_hex}.json").write_text(json.dumps(info, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def load_admin(registry_id_hex: str) -> dict:
|
||||||
|
return json.loads((_home() / "admin" / f"{registry_id_hex}.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def list_admin_registries() -> list[str]:
|
||||||
|
d = _home() / "admin"
|
||||||
|
if not d.exists():
|
||||||
|
return []
|
||||||
|
return sorted(p.stem for p in d.glob("*.json"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Credentials ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def save_credential(registry_id_hex: str, role_name: str, cred_data: dict):
|
||||||
|
d = _home() / "credentials"
|
||||||
|
d.mkdir(exist_ok=True)
|
||||||
|
(d / f"{registry_id_hex}_{role_name}.json").write_text(json.dumps(cred_data, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def load_credential_data(registry_id_hex: str, role_name: str) -> dict:
|
||||||
|
return json.loads(
|
||||||
|
(_home() / "credentials" / f"{registry_id_hex}_{role_name}.json").read_text()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def has_credential(registry_id_hex: str, role_name: str) -> bool:
|
||||||
|
return (_home() / "credentials" / f"{registry_id_hex}_{role_name}.json").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct_credential(cred_data: dict) -> zkac.Credential:
|
||||||
|
pk = zkac.BbsPublicKey.from_bytes(_unb64(cred_data["issuer_pk_b64"]))
|
||||||
|
return zkac.Credential.finalize(
|
||||||
|
_unb64(cred_data["blind_sig_b64"]),
|
||||||
|
_unb64(cred_data["member_secret_b64"]),
|
||||||
|
_unb64(cred_data["prover_blind_b64"]),
|
||||||
|
zkac.role_id(cred_data["role_name"]),
|
||||||
|
cred_data["epoch"],
|
||||||
|
pk,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_credentials() -> list[tuple[str, str]]:
|
||||||
|
d = _home() / "credentials"
|
||||||
|
if not d.exists():
|
||||||
|
return []
|
||||||
|
result = []
|
||||||
|
for p in sorted(d.glob("*.json")):
|
||||||
|
parts = p.stem.rsplit("_", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
result.append((parts[0], parts[1]))
|
||||||
|
return result
|
||||||
5
cli/zkac_node.egg-info/PKG-INFO
Normal file
5
cli/zkac_node.egg-info/PKG-INFO
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
Metadata-Version: 2.4
|
||||||
|
Name: zkac-node
|
||||||
|
Version: 0.1.0
|
||||||
|
Requires-Python: >=3.10
|
||||||
|
Requires-Dist: zkac
|
||||||
14
cli/zkac_node.egg-info/SOURCES.txt
Normal file
14
cli/zkac_node.egg-info/SOURCES.txt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
README.md
|
||||||
|
pyproject.toml
|
||||||
|
zkac_cli/__init__.py
|
||||||
|
zkac_cli/client.py
|
||||||
|
zkac_cli/main.py
|
||||||
|
zkac_cli/paths.py
|
||||||
|
zkac_cli/server.py
|
||||||
|
zkac_cli/store.py
|
||||||
|
zkac_node.egg-info/PKG-INFO
|
||||||
|
zkac_node.egg-info/SOURCES.txt
|
||||||
|
zkac_node.egg-info/dependency_links.txt
|
||||||
|
zkac_node.egg-info/entry_points.txt
|
||||||
|
zkac_node.egg-info/requires.txt
|
||||||
|
zkac_node.egg-info/top_level.txt
|
||||||
1
cli/zkac_node.egg-info/dependency_links.txt
Normal file
1
cli/zkac_node.egg-info/dependency_links.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
2
cli/zkac_node.egg-info/entry_points.txt
Normal file
2
cli/zkac_node.egg-info/entry_points.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[console_scripts]
|
||||||
|
zkac-node = zkac_cli.main:main
|
||||||
1
cli/zkac_node.egg-info/requires.txt
Normal file
1
cli/zkac_node.egg-info/requires.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
zkac
|
||||||
1
cli/zkac_node.egg-info/top_level.txt
Normal file
1
cli/zkac_node.egg-info/top_level.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
zkac_cli
|
||||||
@ -1,4 +1,4 @@
|
|||||||
# Security model and audit notes (ZKAC 0.2)
|
# Security model and audit notes (ZKAC 0.3)
|
||||||
|
|
||||||
This document summarizes the design, residual risks, and recommendations for operators integrating **ZKAC**. It is not a substitute for independent review before high-assurance deployment.
|
This document summarizes the design, residual risks, and recommendations for operators integrating **ZKAC**. It is not a substitute for independent review before high-assurance deployment.
|
||||||
|
|
||||||
@ -6,10 +6,11 @@ This document summarizes the design, residual risks, and recommendations for ope
|
|||||||
|
|
||||||
- **Authentication:** Only holders of a valid BBS+ credential for a registered role can complete `verify_auth` for that role.
|
- **Authentication:** Only holders of a valid BBS+ credential for a registered role can complete `verify_auth` for that role.
|
||||||
- **Server identity:** The server proves its long-term identity to the client via a Schnorr signature over the session transcript; clients verify against a pinned public key. This prevents MITM attacks without requiring TLS.
|
- **Server identity:** The server proves its long-term identity to the client via a Schnorr signature over the session transcript; clients verify against a pinned public key. This prevents MITM attacks without requiring TLS.
|
||||||
- **Confidentiality & integrity:** Session payloads are authenticated-encrypted (ChaCha20-Poly1305) with a key derived from an ephemeral X25519 handshake.
|
- **Confidentiality & integrity:** All traffic (management and authenticated sessions) is authenticated-encrypted (ChaCha20-Poly1305) with keys derived from an ephemeral X25519 handshake.
|
||||||
- **Replay resistance:** Duplicate ciphertexts in a direction are rejected (sliding window + monotonic counter).
|
- **Replay resistance:** Duplicate ciphertexts in a direction are rejected (sliding window + monotonic counter).
|
||||||
- **Unlinkability (credential layer):** BBS+ presentations are unlinkable across sessions when the presentation header (the session transcript hash) differs; the verifier learns only the disclosed attributes (opaque `role_id`, epoch) and validity. Client anonymity is preserved: the client never reveals its long-term key during the handshake.
|
- **Unlinkability (credential layer):** BBS+ presentations are unlinkable across sessions when the presentation header (the session transcript hash) differs; the verifier learns only the disclosed attributes (opaque `role_id`, epoch) and validity. Client anonymity is preserved: the client never reveals its long-term key during the handshake.
|
||||||
- **Server cannot forge credentials:** The server stores only the issuer **public** key per role; forging requires the issuer secret key.
|
- **Server cannot forge credentials:** The server stores only the issuer **public** key per role; forging requires the issuer secret key.
|
||||||
|
- **Opaque server:** The server stores only cryptographically verified state blobs and opaque grant ciphertexts. No user identities, role names, or credential material are stored or visible to the server.
|
||||||
|
|
||||||
## Cryptographic components
|
## Cryptographic components
|
||||||
|
|
||||||
@ -19,9 +20,12 @@ This document summarizes the design, residual risks, and recommendations for ope
|
|||||||
| Identity | Schnorr on Ristretto255, BLAKE2b-512 challenge | Server identity binding |
|
| Identity | Schnorr on Ristretto255, BLAKE2b-512 challenge | Server identity binding |
|
||||||
| Credentials | BBS+ on BLS12-381 (zkryptium), SHAKE256 ciphersuite | Blind issuance, ZK presentations |
|
| Credentials | BBS+ on BLS12-381 (zkryptium), SHAKE256 ciphersuite | Blind issuance, ZK presentations |
|
||||||
| Role IDs | BLAKE2b-512 (truncated to 32 bytes) | Opaque role identifiers |
|
| Role IDs | BLAKE2b-512 (truncated to 32 bytes) | Opaque role identifiers |
|
||||||
|
| Grant delivery | X25519 static/ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305 | E2E-encrypted credential grants |
|
||||||
|
|
||||||
## Protocol flow
|
## Protocol flow
|
||||||
|
|
||||||
|
### Unified channel (all connections)
|
||||||
|
|
||||||
```
|
```
|
||||||
Client Server
|
Client Server
|
||||||
|--- init_msg (eph_pk) ------------>|
|
|--- init_msg (eph_pk) ------------>|
|
||||||
@ -30,10 +34,26 @@ Client Server
|
|||||||
|<-- response_msg + identity_pkt ---|
|
|<-- response_msg + identity_pkt ---|
|
||||||
| complete DH |
|
| complete DH |
|
||||||
| decrypt + verify server sig |
|
| decrypt + verify server sig |
|
||||||
| encrypt BBS+ auth |
|
|===== encrypted session ==========>|
|
||||||
|--- encrypted BBS+ auth ---------> |
|
|--- {op: "mgmt"} or {op: "auth"}->|
|
||||||
| | verify_auth()
|
```
|
||||||
|===== encrypted session ===========>|
|
|
||||||
|
Management commands (`create_registry`, `post_grant`, etc.) and BBS+ role authentication both run inside the same encrypted, server-authenticated channel. There is no unencrypted management path.
|
||||||
|
|
||||||
|
### Grant delivery (admin → recipient, through server)
|
||||||
|
|
||||||
|
```
|
||||||
|
Admin Server (opaque relay) Recipient
|
||||||
|
|-- post_grant ------->| |
|
||||||
|
| (admin_proof, | stores only: |
|
||||||
|
| recipient_pk, | {eph_pk, ciphertext} |
|
||||||
|
| eph_pk, | keyed by recipient_pk |
|
||||||
|
| ciphertext) | |
|
||||||
|
| |<-- list_grants ------------|
|
||||||
|
| |--- [{eph_pk, ct}, ...] --->|
|
||||||
|
| | | trial-decrypt
|
||||||
|
| |<-- claim_grant ------------|
|
||||||
|
| | (removes entry) |
|
||||||
```
|
```
|
||||||
|
|
||||||
## Threats considered
|
## Threats considered
|
||||||
@ -41,20 +61,26 @@ Client Server
|
|||||||
### Network attacker (passive)
|
### Network attacker (passive)
|
||||||
|
|
||||||
- Observes ciphertexts; cannot break ChaCha20-Poly1305 or derive session keys without breaking X25519 / HKDF under standard assumptions.
|
- Observes ciphertexts; cannot break ChaCha20-Poly1305 or derive session keys without breaking X25519 / HKDF under standard assumptions.
|
||||||
|
- Management traffic is indistinguishable from auth traffic at the wire level (same handshake, same framing).
|
||||||
|
|
||||||
### Network attacker (active / MITM)
|
### Network attacker (active / MITM)
|
||||||
|
|
||||||
- **Server impersonation:** The server signs the session transcript hash with its long-term Ristretto255 key (`prove_identity`). The client verifies this signature against the **pinned** server public key. A MITM running a separate DH exchange produces a different transcript; it cannot forge the server's signature. The client aborts on mismatch.
|
- **Server impersonation:** The server signs the session transcript hash with its long-term Ristretto255 key (`prove_identity`). The client verifies this signature against the **pinned** server public key. A MITM running a separate DH exchange produces a different transcript; it cannot forge the server's signature. The client aborts on mismatch.
|
||||||
- **Client impersonation:** The BBS+ presentation is bound to the session transcript hash. A MITM cannot relay a presentation from one session to another (different transcripts) or forge one (requires a valid credential from the issuer).
|
- **Client impersonation:** The BBS+ presentation is bound to the session transcript hash. A MITM cannot relay a presentation from one session to another (different transcripts) or forge one (requires a valid credential from the issuer).
|
||||||
- **Relay attack:** A MITM that relays the real server's identity proof to a client fails because the proof is encrypted under the MITM-to-server session keys (not the client-to-MITM keys), and the signature is over the wrong transcript.
|
- **Relay attack:** A MITM that relays the real server's identity proof to a client fails because the proof is encrypted under the MITM-to-server session keys (not the client-to-MITM keys), and the signature is over the wrong transcript.
|
||||||
|
- **Management channel:** All management commands (registry creation, grants) are protected by the same encrypted channel, eliminating the previous plaintext management path.
|
||||||
|
|
||||||
### Malicious server
|
### Malicious server
|
||||||
|
|
||||||
- Can **learn** opaque `role_id`, current epoch, and that *some* valid member authenticated.
|
- Can **learn** opaque `role_id`, current epoch, and that *some* valid member authenticated.
|
||||||
|
- Sees `registry_id` values (needed for routing) but not role names or registry contents beyond opaque state bytes.
|
||||||
|
- Sees `recipient_pk` for mailbox addressing, plus `eph_pk` and ciphertext per grant, but cannot decrypt grant payloads.
|
||||||
- **Cannot** forge BBS+ credentials without the issuer secret key.
|
- **Cannot** forge BBS+ credentials without the issuer secret key.
|
||||||
- **Cannot** learn `member_secret` from presentations under the BBS+ security assumptions.
|
- **Cannot** learn `member_secret` from presentations under the BBS+ security assumptions.
|
||||||
- **Cannot** distinguish which specific member authenticated among valid credential holders (unlinkability holds against the verifier for distinct presentation headers).
|
- **Cannot** distinguish which specific member authenticated among valid credential holders (unlinkability holds against the verifier for distinct presentation headers).
|
||||||
- **Cannot** learn the client's long-term public key — it is never transmitted.
|
- **Cannot** learn the client's long-term public key — it is never transmitted during handshake or auth.
|
||||||
|
- **Cannot** perform admin operations (registry updates, grant posting) without a valid admin BBS+ credential.
|
||||||
|
- **Cannot** correlate a recipient's mailbox identity with their authenticated sessions (different keys, unlinkable proofs).
|
||||||
|
|
||||||
### Malicious client
|
### Malicious client
|
||||||
|
|
||||||
@ -73,7 +99,7 @@ The server's long-term `PublicKey` (32-byte Ristretto255 point) functions as a *
|
|||||||
|
|
||||||
Recommended strategies:
|
Recommended strategies:
|
||||||
|
|
||||||
1. **Static configuration** (default): embed the server public key in client config, environment variable, or CLI flag. Equivalent to WireGuard's `[Peer] PublicKey = ...`.
|
1. **Static configuration** (default): embed the server public key in client config or CLI pin command (`zkac-node server pin`). Equivalent to WireGuard's `[Peer] PublicKey = ...`.
|
||||||
2. **Trust On First Use (TOFU):** accept the server's key on first connection, pin it for subsequent sessions. Risk: first connection is vulnerable.
|
2. **Trust On First Use (TOFU):** accept the server's key on first connection, pin it for subsequent sessions. Risk: first connection is vulnerable.
|
||||||
3. **Out-of-band verification:** compare public key fingerprints over a trusted side channel (phone, in-person, encrypted messaging).
|
3. **Out-of-band verification:** compare public key fingerprints over a trusted side channel (phone, in-person, encrypted messaging).
|
||||||
4. **Key registry / directory:** a trusted service maps names to public keys. Shifts trust to the registry and its authentication channel.
|
4. **Key registry / directory:** a trusted service maps names to public keys. Shifts trust to the registry and its authentication channel.
|
||||||
@ -81,28 +107,36 @@ Recommended strategies:
|
|||||||
## Operational requirements
|
## Operational requirements
|
||||||
|
|
||||||
1. **Issuer secret key:** Protect `BbsIssuer` secret material (HSM, KMS, or encrypted at rest). Compromise = ability to issue arbitrary credentials for that role.
|
1. **Issuer secret key:** Protect `BbsIssuer` secret material (HSM, KMS, or encrypted at rest). Compromise = ability to issue arbitrary credentials for that role.
|
||||||
2. **Server long-term key:** Protect the `Node` `Keypair` secret. Compromise = ability to impersonate the server. Rotate the key and distribute the new public key to clients if compromised.
|
2. **Server long-term key:** Protect the server's `server_key.json`. Compromise = ability to impersonate the server. Rotate the key and distribute the new public key to clients if compromised.
|
||||||
3. **Member storage:** `member_secret` and finalized `Credential` material must be protected; loss = re-enrollment required.
|
3. **Member storage:** `member_secret` and finalized `Credential` material must be protected; loss = re-enrollment required.
|
||||||
4. **Epoch revocation:** On compromise or policy change, call `set_epoch` and re-issue credentials only to legitimate members; old credentials become invalid at verification time.
|
4. **Epoch revocation:** On compromise or policy change, call `set_epoch` and re-issue credentials only to legitimate members; old credentials become invalid at verification time.
|
||||||
5. **Registry integrity:** The server's `(role_id → public key, epoch)` mapping must be integrity-protected (trusted storage or signed updates), or attackers could swap keys or epochs.
|
5. **Registry integrity:** Registry state is integrity-protected by BBS+ state certificates (admin must sign updates). The server verifies these certificates before accepting changes.
|
||||||
6. **Role ID privacy:** `role_id` is a hash of the role name only if you use `role_id("myrole")`; treat role names as secrets if enumeration is a concern, or derive role IDs with an additional secret salt known to members.
|
6. **Role ID privacy:** `role_id` is a hash of the role name only if you use `role_id("myrole")`; treat role names as secrets if enumeration is a concern, or derive role IDs with an additional secret salt known to members.
|
||||||
|
7. **Client identity:** The only persistent client identifier is the issuance public key used for grant mailbox addressing. This key is shared out-of-band with admins; it is not linked to any transport key or BBS+ presentation.
|
||||||
|
|
||||||
## Implementation notes (audit checklist)
|
## Implementation notes (audit checklist)
|
||||||
|
|
||||||
- [x] BBS+ proof verification uses the same header and presentation binding as proof generation (`verify_presentation` in Rust).
|
- [x] BBS+ proof verification uses the same header and presentation binding as proof generation (`verify_presentation` in Rust).
|
||||||
- [x] Session transcript is included in the presentation via `present(transcript_hash)`.
|
- [x] Session transcript is included in the presentation via `present(transcript_hash)`.
|
||||||
- [x] Server identity proof: Schnorr signature over `transcript_hash`, verified against pinned public key before BBS+ auth proceeds.
|
- [x] Server identity proof: Schnorr signature over `transcript_hash`, verified against pinned public key before any traffic.
|
||||||
- [x] Schnorr nonce is deterministic (`H(sk || msg)`) — no dependence on RNG quality at signing time.
|
- [x] Schnorr nonce is deterministic (`H(sk || msg)`) — no dependence on RNG quality at signing time.
|
||||||
- [x] Replay protection is symmetric per direction in `Session`.
|
- [x] Replay protection is symmetric per direction in `Session`.
|
||||||
- [x] Constant-time comparisons are used where critical in transport/replay paths (`subtle` crate).
|
- [x] Constant-time comparisons are used where critical in transport/replay paths (`subtle` crate).
|
||||||
- [x] Client long-term key is never transmitted, preserving BBS+ unlinkability.
|
- [x] Client long-term key is never transmitted, preserving BBS+ unlinkability.
|
||||||
|
- [x] Management and auth channels use the same encrypted handshake (no plaintext management path).
|
||||||
|
- [x] Admin proofs in management commands are bound to the session transcript hash (no separate nonce).
|
||||||
|
- [x] Server stores only opaque state bytes, state certs, and encrypted grant blobs (no role names, no user IDs).
|
||||||
- [ ] **External:** Python bindings surface raw bytes; callers must not log secrets (`secret_key_bytes`, `member_secret`, `prover_blind`).
|
- [ ] **External:** Python bindings surface raw bytes; callers must not log secrets (`secret_key_bytes`, `member_secret`, `prover_blind`).
|
||||||
- [ ] **External:** Use secure randomness from the OS (library uses OS RNG for key generation paths exposed in Rust).
|
- [ ] **External:** Use secure randomness from the OS (library uses OS RNG for key generation paths exposed in Rust).
|
||||||
|
|
||||||
## Design decisions
|
## Design decisions
|
||||||
|
|
||||||
|
- **Unified encrypted channel:** All traffic (management and auth) uses the same anonymous handshake. This eliminates the attack surface of an unencrypted management path and simplifies the protocol to a single mode.
|
||||||
|
- **Anonymous handshake (`complete_connect_anon`):** The client verifies the server's identity but does not authenticate itself during the handshake. BBS+ auth is sent as an application-layer message inside the encrypted session, not as part of the handshake. This allows the same channel for both anonymous management and authenticated role access.
|
||||||
- **Server-only identity proof:** Only the server signs the transcript. Adding client long-term signing would break BBS+ unlinkability (the server could correlate sessions by client public key). Client authentication is handled entirely by the anonymous BBS+ credential.
|
- **Server-only identity proof:** Only the server signs the transcript. Adding client long-term signing would break BBS+ unlinkability (the server could correlate sessions by client public key). Client authentication is handled entirely by the anonymous BBS+ credential.
|
||||||
- **Deterministic Schnorr nonces:** The signing nonce is derived as `H("zkac-schnorr-nonce" || sk || msg)`, eliminating a class of RNG-failure attacks (cf. PS3 ECDSA, Sony 2010). Same key + same message = same signature.
|
- **Deterministic Schnorr nonces:** The signing nonce is derived as `H("zkac-schnorr-nonce" || sk || msg)`, eliminating a class of RNG-failure attacks (cf. PS3 ECDSA, Sony 2010). Same key + same message = same signature.
|
||||||
|
- **Opaque mailbox:** Grant entries on the server contain only `(eph_pk, ciphertext)` — no registry ID or role name. Recipients find their grants by trial-decrypting. This prevents the server from learning which registry or role a grant is for.
|
||||||
|
- **No user IDs on server:** The server has no concept of user accounts. It is a stateless relay authenticated only by cryptographic proofs.
|
||||||
|
|
||||||
## Known limitations
|
## Known limitations
|
||||||
|
|
||||||
@ -110,6 +144,7 @@ Recommended strategies:
|
|||||||
- **Epoch granularity:** Revocation is coarse (epoch bump); plan issuance and rotation policy accordingly.
|
- **Epoch granularity:** Revocation is coarse (epoch bump); plan issuance and rotation policy accordingly.
|
||||||
- **zkryptium dependency:** Security follows the underlying crate and BLS12-381/BBS+ standards; keep dependencies updated.
|
- **zkryptium dependency:** Security follows the underlying crate and BLS12-381/BBS+ standards; keep dependencies updated.
|
||||||
- **Key distribution:** The library provides the cryptographic mechanism; initial key distribution is an application-layer responsibility.
|
- **Key distribution:** The library provides the cryptographic mechanism; initial key distribution is an application-layer responsibility.
|
||||||
|
- **Mailbox metadata:** The server sees `recipient_pk` as a mailbox address and the number/size/timing of grants. This is inherent to the delivery mechanism.
|
||||||
|
|
||||||
## Reporting issues
|
## Reporting issues
|
||||||
|
|
||||||
|
|||||||
@ -157,6 +157,50 @@ def server_handshake_managed(
|
|||||||
return session, registry_id, role_id
|
return session, registry_id, role_id
|
||||||
|
|
||||||
|
|
||||||
|
def client_handshake_anon(
|
||||||
|
sock: socket.socket,
|
||||||
|
node: Node,
|
||||||
|
expected_server_pk: PublicKey,
|
||||||
|
) -> Session:
|
||||||
|
"""
|
||||||
|
Anonymous client handshake: verify server identity only, no BBS+ auth.
|
||||||
|
|
||||||
|
Returns an encrypted :class:`Session` for management traffic.
|
||||||
|
"""
|
||||||
|
pending, init_msg = node.connect()
|
||||||
|
write_frame(sock, init_msg)
|
||||||
|
|
||||||
|
bundle = read_frame(sock)
|
||||||
|
if len(bundle) < _HANDSHAKE_MSG_LEN:
|
||||||
|
raise ValueError("server handshake bundle too short")
|
||||||
|
response_msg = bundle[:_HANDSHAKE_MSG_LEN]
|
||||||
|
identity_proof = bundle[_HANDSHAKE_MSG_LEN:]
|
||||||
|
|
||||||
|
session = node.complete_connect_anon(
|
||||||
|
pending, response_msg, identity_proof, expected_server_pk,
|
||||||
|
)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def server_handshake_anon(
|
||||||
|
sock: socket.socket,
|
||||||
|
node: Node,
|
||||||
|
) -> Session:
|
||||||
|
"""
|
||||||
|
Server-side anonymous handshake: prove identity, no BBS+ verification.
|
||||||
|
|
||||||
|
Returns an encrypted :class:`Session`.
|
||||||
|
"""
|
||||||
|
init_msg = read_frame(sock)
|
||||||
|
if len(init_msg) != _HANDSHAKE_MSG_LEN:
|
||||||
|
raise ValueError("init_msg must be 32 bytes")
|
||||||
|
|
||||||
|
session, response_msg = node.accept(init_msg)
|
||||||
|
identity_proof = node.prove_identity(session)
|
||||||
|
write_frame(sock, response_msg + identity_proof)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
class FramedSession:
|
class FramedSession:
|
||||||
"""
|
"""
|
||||||
One ZKAC ciphertext per TCP frame: encrypt before send, decrypt after recv.
|
One ZKAC ciphertext per TCP frame: encrypt before send, decrypt after recv.
|
||||||
|
|||||||
34
src/node.rs
34
src/node.rs
@ -103,6 +103,40 @@ impl Node {
|
|||||||
Ok((session, encrypted_auth))
|
Ok((session, encrypted_auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Complete the connection verifying only the server's identity proof.
|
||||||
|
/// No BBS+ presentation is produced — the session is anonymous on the
|
||||||
|
/// client side. Useful for management channels that still need an
|
||||||
|
/// encrypted, server-authenticated transport.
|
||||||
|
pub fn complete_connect_anon(
|
||||||
|
&self,
|
||||||
|
pending: PendingConnect,
|
||||||
|
response_msg: &[u8; HANDSHAKE_MSG_LEN],
|
||||||
|
identity_proof: &[u8],
|
||||||
|
expected_server_pk: &PublicKey,
|
||||||
|
) -> Result<Session> {
|
||||||
|
let mut session = pending.handshake.complete(response_msg)?;
|
||||||
|
|
||||||
|
let id_payload = session.decrypt(identity_proof)?;
|
||||||
|
if id_payload.len() != IDENTITY_PROOF_LEN {
|
||||||
|
return Err(Error::IdentityVerificationFailed("identity proof wrong length"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pk_bytes = [0u8; 32];
|
||||||
|
pk_bytes.copy_from_slice(&id_payload[..32]);
|
||||||
|
let server_pk = PublicKey::from_bytes(pk_bytes)?;
|
||||||
|
|
||||||
|
if server_pk != *expected_server_pk {
|
||||||
|
return Err(Error::IdentityVerificationFailed("server public key mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut sig_bytes = [0u8; SIGNATURE_LEN];
|
||||||
|
sig_bytes.copy_from_slice(&id_payload[32..]);
|
||||||
|
let sig = Signature::from_bytes(&sig_bytes)?;
|
||||||
|
server_pk.verify(session.transcript_hash(), &sig)?;
|
||||||
|
|
||||||
|
Ok(session)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Responder (server) side ──────────────────────────────────────
|
// ── Responder (server) side ──────────────────────────────────────
|
||||||
|
|
||||||
/// Accept an incoming handshake initiation.
|
/// Accept an incoming handshake initiation.
|
||||||
|
|||||||
@ -743,6 +743,37 @@ impl PyNode {
|
|||||||
Ok(PyBytes::new(py, &rid))
|
Ok(PyBytes::new(py, &rid))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Complete handshake verifying server identity only (no BBS+ auth).
|
||||||
|
fn complete_connect_anon(
|
||||||
|
&self,
|
||||||
|
pending: &mut PyPendingConnect,
|
||||||
|
response_msg: &[u8],
|
||||||
|
identity_proof: &[u8],
|
||||||
|
expected_server_pk: &PyPublicKey,
|
||||||
|
) -> PyResult<PySession> {
|
||||||
|
let p = pending
|
||||||
|
.inner
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| PyValueError::new_err("PendingConnect already consumed"))?;
|
||||||
|
|
||||||
|
if response_msg.len() != HANDSHAKE_MSG_LEN {
|
||||||
|
return Err(PyValueError::new_err("response_msg must be 32 bytes"));
|
||||||
|
}
|
||||||
|
let msg: [u8; HANDSHAKE_MSG_LEN] = response_msg.try_into().unwrap();
|
||||||
|
|
||||||
|
let session = self
|
||||||
|
.inner
|
||||||
|
.complete_connect_anon(
|
||||||
|
p,
|
||||||
|
&msg,
|
||||||
|
identity_proof,
|
||||||
|
&expected_server_pk.inner,
|
||||||
|
)
|
||||||
|
.map_err(to_py_err)?;
|
||||||
|
|
||||||
|
Ok(PySession { inner: session })
|
||||||
|
}
|
||||||
|
|
||||||
/// Complete handshake for a client-managed registry. Includes
|
/// Complete handshake for a client-managed registry. Includes
|
||||||
/// registry_id in the auth packet.
|
/// registry_id in the auth packet.
|
||||||
fn complete_connect_managed(
|
fn complete_connect_managed(
|
||||||
|
|||||||
@ -8,8 +8,10 @@ from zkac.tcp import (
|
|||||||
FramedSession,
|
FramedSession,
|
||||||
MAX_TCP_FRAME_BYTES,
|
MAX_TCP_FRAME_BYTES,
|
||||||
client_handshake,
|
client_handshake,
|
||||||
|
client_handshake_anon,
|
||||||
read_frame,
|
read_frame,
|
||||||
server_handshake,
|
server_handshake,
|
||||||
|
server_handshake_anon,
|
||||||
write_frame,
|
write_frame,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -81,6 +83,62 @@ class TestHandshakeOverTcp:
|
|||||||
assert not t.is_alive()
|
assert not t.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnonHandshake:
|
||||||
|
def test_anon_handshake_no_bbs(self):
|
||||||
|
"""Anonymous handshake: server identity verified, no BBS+ auth."""
|
||||||
|
client_sock, server_sock = socket.socketpair()
|
||||||
|
server_kp = zkac.Keypair()
|
||||||
|
server_pk = server_kp.public_key()
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
try:
|
||||||
|
srv = zkac.Node(server_kp)
|
||||||
|
session = server_handshake_anon(server_sock, srv)
|
||||||
|
framed = FramedSession(server_sock, session)
|
||||||
|
msg = framed.recv()
|
||||||
|
framed.send(b"echo:" + msg)
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=run_server)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
cli = zkac.Node(zkac.Keypair())
|
||||||
|
session = client_handshake_anon(client_sock, cli, server_pk)
|
||||||
|
framed = FramedSession(client_sock, session)
|
||||||
|
framed.send(b"hello")
|
||||||
|
assert framed.recv() == b"echo:hello"
|
||||||
|
finally:
|
||||||
|
client_sock.close()
|
||||||
|
t.join(timeout=5)
|
||||||
|
assert not t.is_alive()
|
||||||
|
|
||||||
|
def test_anon_wrong_server_key_rejected(self):
|
||||||
|
"""Anonymous handshake rejects wrong pinned server key."""
|
||||||
|
client_sock, server_sock = socket.socketpair()
|
||||||
|
server_kp = zkac.Keypair()
|
||||||
|
wrong_pk = zkac.Keypair().public_key()
|
||||||
|
|
||||||
|
def run_server():
|
||||||
|
try:
|
||||||
|
srv = zkac.Node(server_kp)
|
||||||
|
server_handshake_anon(server_sock, srv)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
server_sock.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=run_server)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
cli = zkac.Node(zkac.Keypair())
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
client_handshake_anon(client_sock, cli, wrong_pk)
|
||||||
|
finally:
|
||||||
|
client_sock.close()
|
||||||
|
t.join(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
class TestFramedSession:
|
class TestFramedSession:
|
||||||
def test_framed_encrypt_roundtrip(self):
|
def test_framed_encrypt_roundtrip(self):
|
||||||
_, pk, rid, cred = _make_credential()
|
_, pk, rid, cred = _make_credential()
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user