diff --git a/.gitignore b/.gitignore index 9fae5b7..441ab86 100644 --- a/.gitignore +++ b/.gitignore @@ -134,4 +134,6 @@ dmypy.json .idea # vscode specific -.vscode \ No newline at end of file +.vscode + +.tmp/ \ No newline at end of file diff --git a/README.md b/README.md index 44b044a..ef5d235 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,25 @@ Run the fast test suite with: make test ``` +### Agent signaling + +KERIA can publish transient server-sent events for the connected agent at +`GET /signals/stream`. SignifyPy exposes this generic channel through +`client.signals()`. + +The signaling API is deliberately separate from topic APIs: + +- `client.signals().stream()` opens the authenticated SSE stream. +- `client.signals().verifyReplyEnvelope(envelope, route=...)` verifies that the + SSE payload is a KERI `rpy` envelope signed by the connected KERIA agent AID. +- Topic resources own durable fallback and approval behavior. For did:webs, + use `client.didwebs().requests()`, `client.didwebs().request(id)`, and + `client.didwebs().approve(request)`. + +SSE delivery is not durable. A client that is offline or disconnected must poll +the relevant topic endpoint. For did:webs publication, the durable fallback is +`/didwebs/signing/requests`. + ### Packaging ```bash diff --git a/docs/signify_app.rst b/docs/signify_app.rst index 1d8d0f6..a018341 100644 --- a/docs/signify_app.rst +++ b/docs/signify_app.rst @@ -54,6 +54,39 @@ signify.app.delegating .. automodule:: signify.app.delegating :members: +signify.app.signaling +--------------------- + +``signify.app.signaling`` is the generic KERIA agent signaling API for +SignifyPy. It is deliberately separate from topic helpers such as did:webs. + +Ownership boundary: + +- ``AgentSignals`` opens the authenticated ``GET /signals/stream`` SSE stream. +- ``AgentSignals`` verifies KERIA agent-signed KERI ``rpy`` envelopes. +- Topic modules interpret event names, expected reply routes, and payloads. +- Topic modules also own durable polling fallback because SSE delivery is + transient. + +For did:webs publication, a live event should be verified with +``client.signals().verifyReplyEnvelope(envelope, route=DIDWEBS_SIGNING_ROUTE)`` +before auto-approval. A disconnected client should recover through +``client.didwebs().requests()``. + +.. automodule:: signify.app.signaling + :members: + +signify.app.didwebing +--------------------- + +``signify.app.didwebing`` owns did:webs publication request polling and +approval. It does not own SSE transport. KERIA coordinates publication for +managed AIDs, but the Signify edge client remains the signer for registry +creation and designated-alias ACDC issuance. + +.. automodule:: signify.app.didwebing + :members: + signify.app.exchanging ---------------------- diff --git a/scripts/README.md b/scripts/README.md index f751f98..7376828 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -28,6 +28,12 @@ To inspect the current live-scenario coverage: material for integration scenarios. - `keri/` keeps legacy config assets that may still be useful for reference during future harness cleanup. +- `setup_vrd_projection_chain.py` is an operator setup tool, not test + coverage. It builds a real GEDA -> QVI -> LE -> VRD chain against a running + KERIA instance, writes the manifest consumed by W3C projection acceptance + work, and prints the LE wallet passcode for manual `signify-react-ts` login. Verdict: do not add new integration workflows back under `scripts/`. New live -coverage belongs under `tests/integration`. +coverage belongs under `tests/integration`; durable operator setup tooling can +live here when it is meant to prepare a local environment for manual or +cross-repo acceptance. diff --git a/scripts/setup_vrd_projection_chain.py b/scripts/setup_vrd_projection_chain.py new file mode 100644 index 0000000..687f4be --- /dev/null +++ b/scripts/setup_vrd_projection_chain.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python +"""Build a real Signify/KERIA GEDA -> LE VRD chain for W3C projection tests. + +This script is intentionally a workflow harness, not a shortcut. It talks to a +running KERIA instance through SignifyPy, creates managed AIDs, resolves OOBIs, +creates registries, issues the vLEI chain, admits each grant, waits for the QVI +did:webs DID to become ready, and writes a manifest that KERIA W3C projection +acceptance tests can consume. +""" + +from __future__ import annotations + +import argparse +import json +import secrets +import string +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import requests +from keri.app import signing as app_signing +from keri.core import coring, eventing +from keri.core import signing as csigning +from keri.core.coring import Tiers +from keri.help import helping +from requests import HTTPError + +from signify.app.clienting import SignifyClient +from signify.app.didwebing import DidWebs + +QVI_SCHEMA = "EBfdlu8R27Fbx-ehrqwImnK-8Cm79sqbAQ4MmvEAYqao" +LE_SCHEMA = "ENPXp1vQzRF6JwIuS-mp2U8Uf1MoADoP_GqQ62VsDZWY" +VRD_AUTH_SCHEMA = "EFiYsVADHXcn1BZirDRH301Rm12301povihg5UMIYkfc" +VRD_SCHEMA = "EAyv2DLocYxJlPrWAfYBuHWDpjCStdQBzNLg0-3qQ-KP" + +DEFAULT_WITNESS_AIDS = [ + "BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha", +] +DEFAULT_WITNESS_OOBIS = [ + "http://127.0.0.1:5642/oobi/BBilc4-L3tFUnfM_wJr4S4OJanAv_VmF_dJNN6vkf2Ha/controller?name=Wan&tag=witness", +] + +LEI = "254900OPPU84GM83MG36" +LEGAL_NAME = "Example Legal Entity LLC" +LEGAL_ADDRESS = "1 Market St, San Francisco, CA, US" +LE_USAGE_DISCLAIMER = ( + "Usage of a valid, unexpired, and non-revoked vLEI Credential, as defined " + "in the associated Ecosystem Governance Framework, does not assert that " + "the Legal Entity is trustworthy, honest, reputable in its business " + "dealings, safe to do business with, or compliant with any laws or that " + "an implied or expressly intended purpose will be fulfilled." +) +LE_ISSUANCE_DISCLAIMER = ( + "All information in a valid, unexpired, and non-revoked vLEI Credential, " + "as defined in the associated Ecosystem Governance Framework, is accurate " + "as of the date the validation process was complete. The vLEI Credential " + "has been issued to the legal entity or person named in the vLEI " + "Credential as the subject; and the qualified vLEI Issuer exercised " + "reasonable care to perform the validation process set forth in the vLEI " + "Ecosystem Governance Framework." +) + + +@dataclass +class Actor: + """One Signify client and the managed identifier it owns.""" + + label: str + client: SignifyClient + name: str + passcode: str + aid: str | None = None + + +def main() -> None: + args = parser().parse_args() + suffix = args.suffix or secrets.token_hex(4) + validate_schema_oobi_base(args.schema_base_url) + + log("booting Signify clients") + geda = connect_actor("geda", args, f"{args.alias_prefix}-geda-{suffix}") + qvi = connect_actor("qvi", args, f"{args.alias_prefix}-qvi-{suffix}") + le = connect_actor("le", args, f"{args.alias_prefix}-le-{suffix}") + actors = [geda, qvi, le] + + log("resolving vLEI and VRD schemas") + for actor in actors: + resolve_schema_oobis(actor.client, args.schema_base_url) + + log("creating managed AIDs") + create_actor_aid(geda, args) + create_actor_aid(qvi, args) + create_actor_aid(le, args) + log("exchanging agent OOBIs") + exchange_agent_oobis(actors) + + log("publishing QVI did:webs DID") + qvi_did = wait_for_didwebs_ready(qvi.client, qvi.name, qvi.aid, timeout=args.didwebs_timeout) + + log("creating credential registries") + registries = { + "geda": create_registry(geda.client, geda.name, "geda-vlei"), + "qvi": create_registry(qvi.client, qvi.name, "qvi-vlei"), + "le": create_registry(le.client, le.name, "le-vlei"), + } + + log("issuing QVI credential from GEDA to QVI") + qvi_cred = issue_and_admit( + issuer=geda, + holder=qvi, + registry_name="geda-vlei", + schema=QVI_SCHEMA, + data={"LEI": LEI}, + ) + log("issuing LE credential from QVI to LE") + le_cred = issue_and_admit( + issuer=qvi, + holder=le, + registry_name="qvi-vlei", + schema=LE_SCHEMA, + data={"LEI": LEI}, + edges=source_edges("qvi", qvi_cred["received"]), + rules=le_rules(), + ) + le_did = wait_for_didwebs_ready(le.client, le.name, le.aid, timeout=args.didwebs_timeout) + + log("issuing VRD Auth credential from LE to QVI") + vrd_auth = issue_and_admit( + issuer=le, + holder=qvi, + registry_name="le-vlei", + schema=VRD_AUTH_SCHEMA, + data={ + "i": qvi.aid, + "AID": le.aid, + "DID": le_did, + "HeadquartersAddress": LEGAL_ADDRESS, + "LegalName": LEGAL_NAME, + }, + edges=source_edges("le", le_cred["received"]), + rules=blank_privacy_rules(), + ) + log("issuing final VRD credential from QVI to LE") + vrd_cred = issue_and_admit( + issuer=qvi, + holder=le, + registry_name="qvi-vlei", + schema=VRD_SCHEMA, + data={ + "i": le.aid, + "AID": le.aid, + "DID": le_did, + "HeadquartersAddress": LEGAL_ADDRESS, + "LegalName": LEGAL_NAME, + }, + edges=source_edges("le", le_cred["received"], operator="NI2I"), + rules=blank_privacy_rules(), + ) + + manifest = { + "actors": { + actor.label: { + "name": actor.name, + "aid": actor.aid, + "passcode": actor.passcode, + } + for actor in actors + }, + "registries": registries, + "credentials": { + "qvi": qvi_cred["said"], + "legalEntity": le_cred["said"], + "vrdAuth": vrd_auth["said"], + "vrd": vrd_cred["said"], + }, + "didwebs": { + "qvi": qvi_did, + }, + "projection": { + "identifierName": qvi.name, + "credentialSaid": vrd_cred["said"], + }, + } + + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"LE wallet alias: {le.name}", file=sys.stderr) + print(f"LE wallet passcode: {le.passcode}", file=sys.stderr) + print(f"Projection issuer alias: {qvi.name}", file=sys.stderr) + print(f"Projection VRD credential SAID: {vrd_cred['said']}", file=sys.stderr) + print(json.dumps(manifest, indent=2, sort_keys=True)) + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--admin-url", default="http://127.0.0.1:3901", help="KERIA admin URL") + p.add_argument("--boot-url", default="http://127.0.0.1:3903", help="KERIA boot URL") + p.add_argument("--schema-base-url", default="http://127.0.0.1:7723", help="vLEI schema/OOBI base URL") + p.add_argument("--alias-prefix", default="w3c-vrd", help="alias prefix for created AIDs") + p.add_argument("--suffix", help="stable suffix for repeatable local debugging") + p.add_argument("--output", default=".tmp/w3c-vrd-chain-manifest.json", help="manifest output path") + p.add_argument("--unwitnessed", action="store_true", help="create non-witnessed AIDs") + p.add_argument("--witness", action="append", default=None, help="witness AID to use; repeatable") + p.add_argument("--witness-oobi", action="append", default=None, help="witness OOBI to resolve; repeatable") + p.add_argument("--operation-timeout", type=float, default=180.0) + p.add_argument("--didwebs-timeout", type=float, default=180.0) + return p + + +def validate_schema_oobi_base(schema_base_url: str) -> None: + """Fail before asking KERIA to resolve a schema server with empty OOBIs.""" + for said in (QVI_SCHEMA, LE_SCHEMA, VRD_AUTH_SCHEMA, VRD_SCHEMA): + url = f"{schema_base_url.rstrip('/')}/oobi/{said}" + response = requests.get(url, timeout=10) + response.raise_for_status() + if not response.content: + raise RuntimeError( + f"schema OOBI {url} returned an empty body; use the " + "w3c-crosswalk integration vLEI assets for VRD schemas" + ) + + +def log(message: str) -> None: + print(f"[setup-vrd-chain] {message}", file=sys.stderr, flush=True) + + +def connect_actor(label: str, args, name: str) -> Actor: + passcode = random_passcode() + client = SignifyClient( + passcode=passcode, + tier=Tiers.low, + url=args.admin_url, + boot_url=args.boot_url, + ) + client.boot() + client.connect() + return Actor(label=label, client=client, name=name, passcode=passcode) + + +def create_actor_aid(actor: Actor, args) -> None: + wits = [] if args.unwitnessed else (args.witness or DEFAULT_WITNESS_AIDS) + if wits: + for oobi in args.witness_oobi or DEFAULT_WITNESS_OOBIS: + wait_for_operation(actor.client, actor.client.oobis().resolve(oobi), timeout=args.operation_timeout) + + _, _, operation = actor.client.identifiers().create( + actor.name, + wits=wits, + toad=str(len(wits)) if wits else "0", + ) + wait_for_operation(actor.client, operation, timeout=args.operation_timeout) + hab = actor.client.identifiers().get(actor.name) + actor.aid = hab["prefix"] + + _, _, endrole_op = actor.client.identifiers().addEndRole(actor.name) + wait_for_operation(actor.client, endrole_op, timeout=args.operation_timeout) + wait_for_oobi(actor.client, actor.name, role="agent", timeout=args.operation_timeout) + + +def resolve_schema_oobis(client: SignifyClient, schema_base_url: str) -> None: + for said in (QVI_SCHEMA, LE_SCHEMA, VRD_AUTH_SCHEMA, VRD_SCHEMA): + wait_for_operation( + client, + client.oobis().resolve(f"{schema_base_url.rstrip('/')}/oobi/{said}", alias=f"schema-{said[:8]}"), + ) + + +def exchange_agent_oobis(actors: list[Actor]) -> None: + for source in actors: + for target in actors: + if source is target: + continue + oobi = wait_for_oobi(source.client, source.name, role="agent")[0] + wait_for_operation(target.client, target.client.oobis().resolve(oobi, alias=source.name)) + + +def create_registry(client: SignifyClient, name: str, registry_name: str) -> dict: + result = client.registries().create(name, registry_name) + wait_for_operation(client, result.op()) + registry = client.registries().get(name, registry_name) + return {"name": registry_name, "regk": registry["regk"]} + + +def issue_and_admit( + *, + issuer: Actor, + holder: Actor, + registry_name: str, + schema: str, + data: dict[str, Any], + edges: dict[str, Any] | None = None, + rules: dict[str, Any] | None = None, +) -> dict[str, Any]: + result = issuer.client.credentials().issue( + issuer.name, + registry_name, + data=data, + schema=schema, + recipient=holder.aid, + edges=edges, + rules=rules, + timestamp=helping.nowIso8601(), + ) + wait_for_operation(issuer.client, result.op()) + wait_for_issued_credential(issuer.client, issuer.aid, result.acdc.said) + log( + f"issued credential schema={schema} said={result.acdc.said} " + f"issuer={issuer.name} holder={holder.name}" + ) + + send_grant( + issuer.client, + issuer_name=issuer.name, + recipient=holder.aid, + creder=result.acdc, + iserder=result.iss, + anc=result.anc, + sigs=result.sigs, + ) + log(f"sent IPEX grant said={result.acdc.said} to holder={holder.name}") + + notification = wait_for_grant_notification(holder.client, result.acdc.said) + log(f"holder={holder.name} received grant notification {notification['a']['d']}") + admit_op = submit_admit( + holder.client, + holder_name=holder.name, + issuer_prefix=issuer.aid, + grant_said=notification["a"]["d"], + ) + if isinstance(admit_op, dict) and "done" in admit_op: + log(f"waiting for admit operation {admit_op['name']}") + wait_for_operation(holder.client, admit_op, timeout=120.0) + received = wait_for_credential(holder.client, result.acdc.said) + log(f"holder={holder.name} stored credential said={result.acdc.said}") + return {"said": result.acdc.said, "received": received} + + +def send_grant( + client: SignifyClient, + *, + issuer_name: str, + recipient: str, + creder, + iserder, + anc, + sigs: list[str], +) -> dict | None: + issuer_hab = client.identifiers().get(issuer_name) + prefixer = coring.Prefixer(qb64=iserder.pre) + seqner = coring.Seqner(sn=iserder.sn) + acdc = app_signing.serialize(creder, prefixer, seqner, coring.Saider(qb64=iserder.said)) + iss = client.registries().serialize(iserder, anc) + grant, grant_sigs, atc = client.ipex().grant( + issuer_hab, + recp=recipient, + message="", + acdc=acdc, + iss=iss, + anc=eventing.messagize( + serder=anc, + sigers=[csigning.Siger(qb64=sig) for sig in sigs], + ), + dt=helping.nowIso8601(), + ) + return client.ipex().submitGrant(issuer_name, exn=grant, sigs=grant_sigs, atc=atc, recp=[recipient]) + + +def submit_admit(client: SignifyClient, *, holder_name: str, issuer_prefix: str, grant_said: str) -> dict | None: + holder_hab = client.identifiers().get(holder_name) + admit, sigs, atc = client.ipex().admit( + holder_hab, + "", + grant_said, + issuer_prefix, + helping.nowIso8601(), + ) + return client.ipex().submitAdmit(holder_name, exn=admit, sigs=sigs, atc=atc, recp=[issuer_prefix]) + + +def wait_for_didwebs_ready(client: SignifyClient, name: str, aid: str, *, timeout: float) -> str: + didwebs = DidWebs(client) + seen: set[str] = set() + + def _ready(): + try: + did = client.get(f"/identifiers/{name}/dws").json().get("dws") + if did: + return did + except HTTPError: + pass + + for request in didwebs.requests(aid=aid): + if request["d"] in seen: + continue + result = didwebs.approve(request) + seen.add(request["d"]) + if hasattr(result, "op"): + wait_for_operation(client, result.op()) + return None + + return poll_until( + _ready, + ready=lambda did: isinstance(did, str) and did.startswith("did:webs:"), + timeout=timeout, + interval=1.0, + describe=f"did:webs readiness for {name}", + ) + + +def wait_for_operation(client: SignifyClient, operation: dict, *, timeout: float = 180.0) -> dict: + if operation.get("done"): + return operation + return client.operations().wait( + operation, + timeout=timeout, + interval=0.5, + max_interval=0.5, + backoff=1.0, + ) + + +def wait_for_oobi(client: SignifyClient, name: str, *, role: str, timeout: float = 120.0) -> list[str]: + return poll_until( + lambda: client.oobis().get(name, role=role)["oobis"], + ready=bool, + timeout=timeout, + interval=0.5, + describe=f"{role} OOBI for {name}", + retry_exceptions=(HTTPError,), + ) + + +def wait_for_grant_notification(client: SignifyClient, credential_said: str, timeout: float = 180.0) -> dict: + """Wait for the IPEX grant that actually carries the expected credential.""" + inspected: set[str] = set() + + def _fetch(): + for note in reversed(client.notifications().list()["notes"]): + if note["a"].get("r") != "/exn/ipex/grant" or note.get("r") is not False: + continue + note_id = note["i"] + if note_id in inspected: + continue + exchange_said = note["a"]["d"] + try: + exchange = client.exchanges().get(exchange_said) + except HTTPError: + inspected.add(note_id) + continue + + if credential_said in json.dumps(exchange): + return note + + inspected.add(note_id) + client.notifications().mark(note_id) + return None + + note = poll_until( + _fetch, + ready=lambda value: value is not None, + timeout=timeout, + interval=0.5, + describe=f"IPEX grant notification for credential {credential_said}", + ) + client.notifications().mark(note["i"]) + return note + + +def wait_for_notification(client: SignifyClient, route: str, timeout: float = 180.0) -> dict: + def _fetch(): + for note in reversed(client.notifications().list()["notes"]): + if note["a"].get("r") == route and note.get("r") is False: + return note + return None + + note = poll_until( + _fetch, + ready=lambda value: value is not None, + timeout=timeout, + interval=0.5, + describe=f"notification {route}", + ) + client.notifications().mark(note["i"]) + return note + + +def wait_for_issued_credential(client: SignifyClient, issuer_aid: str, said: str) -> dict: + return poll_until( + lambda: next( + ( + credential + for credential in client.credentials().list(filter={"-i": issuer_aid}) + if credential["sad"]["d"] == said + ), + None, + ), + ready=lambda credential: credential is not None, + timeout=180.0, + interval=0.75, + describe=f"issued credential {said}", + ) + + +def wait_for_credential(client: SignifyClient, said: str) -> dict: + return poll_until( + lambda: next( + ( + credential + for credential in client.credentials().list() + if credential["sad"]["d"] == said + ), + None, + ), + ready=lambda credential: credential is not None, + timeout=180.0, + interval=0.75, + describe=f"received credential {said}", + ) + + +def poll_until(fetch, *, ready, timeout: float, interval: float, describe: str, retry_exceptions=()): + deadline = time.monotonic() + timeout + last_value = None + last_error = None + while time.monotonic() < deadline: + try: + last_value = fetch() + except retry_exceptions as ex: + last_error = str(ex) + else: + if ready(last_value): + return last_value + time.sleep(interval) + raise TimeoutError(f"timed out waiting for {describe}; last_value={last_value!r}; last_error={last_error!r}") + + +def source_edges(label: str, credential: dict, *, operator: str | None = None) -> dict: + sad = credential["sad"] + edge = {"n": sad["d"], "s": sad["s"]} + if operator is not None: + edge["o"] = operator + return coring.Saider.saidify(sad={"d": "", label: edge})[1] + + +def le_rules() -> dict: + return coring.Saider.saidify( + sad={ + "d": "", + "usageDisclaimer": {"l": LE_USAGE_DISCLAIMER}, + "issuanceDisclaimer": {"l": LE_ISSUANCE_DISCLAIMER}, + } + )[1] + + +def blank_privacy_rules() -> dict: + return coring.Saider.saidify( + sad={ + "d": "", + "usageDisclaimer": {"l": ""}, + "issuanceDisclaimer": {"l": ""}, + "privacyDisclaimer": {"l": ""}, + } + )[1] + + +def random_passcode() -> str: + alphabet = string.ascii_lowercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(21)) + + +if __name__ == "__main__": + main() diff --git a/src/signify/app/aiding.py b/src/signify/app/aiding.py index 6bc337a..afb2b59 100644 --- a/src/signify/app/aiding.py +++ b/src/signify/app/aiding.py @@ -8,7 +8,7 @@ """ from dataclasses import asdict from math import ceil -from urllib.parse import urlsplit +from urllib.parse import quote, urlsplit from keri import kering from keri.app.keeping import Algos @@ -244,19 +244,35 @@ def addEndRole(self, name, *, role=Roles.agent, eid=None, stamp=None): In OOBI-heavy flows this record must exist before any role-specific OOBI becomes available. """ + resolved_eid = self._resolveEndRoleEid(role=role, eid=eid) hab = self.get(name) pre = hab["prefix"] - rpy = self.makeEndRole(pre, role, eid, stamp) + rpy = self.makeEndRole(pre, role, resolved_eid, stamp) keeper = self.client.manager.get(aid=hab) sigs = keeper.sign(ser=rpy.raw) rpy_msg = api.ReplyMessage( rpy=rpy.ked, sigs=sigs) - res = self.client.post(f"/identifiers/{name}/endroles", json=asdict(rpy_msg)) + res = self.client.post(f"/identifiers/{quote(name, safe='')}/endroles", json=asdict(rpy_msg)) return rpy, sigs, res.json() + def _resolveEndRoleEid(self, *, role, eid): + """Resolve the endpoint provider AID for endpoint-role authorization.""" + if eid: + return eid + + if role == Roles.agent: + agent = getattr(self.client, "agent", None) + agent_pre = getattr(agent, "pre", None) + if agent_pre: + return agent_pre + + raise kering.ConfigurationError("agent endpoint role authorization requires a connected agent AID") + + raise kering.ConfigurationError(f"endpoint role {role} authorization requires eid") + def addLocScheme(self, name, url, *, eid=None, scheme=None, stamp=None): """Publish a location-scheme reply for an identifier-scoped endpoint. diff --git a/src/signify/app/clienting.py b/src/signify/app/clienting.py index c992bde..21ab307 100644 --- a/src/signify/app/clienting.py +++ b/src/signify/app/clienting.py @@ -7,7 +7,7 @@ request families documented in the feature guide. """ from dataclasses import asdict -from urllib.parse import urlparse, urljoin, urlsplit +from urllib.parse import quote, urlparse, urljoin, urlsplit import requests import sseclient @@ -419,6 +419,21 @@ def schemas(self): from signify.app.schemas import Schemas return Schemas(client=self) + def didwebs(self): + """Return the did:webs publication request resource wrapper.""" + from signify.app.didwebing import DidWebs + return DidWebs(client=self) + + def w3c(self): + """Return the W3C projection session resource wrapper.""" + from signify.app.w3cing import W3C + return W3C(client=self) + + def signals(self): + """Return the generic KERIA agent signaling resource wrapper.""" + from signify.app.signaling import AgentSignals + return AgentSignals(client=self) + def config(self): """Return the agent-configuration read resource wrapper.""" from signify.app.coring import Config @@ -491,5 +506,5 @@ def __call__(self, req): p = urlsplit(req.url) path = p.path if p.path else "/" - req.headers = self.authn.sign(headers, req.method, path) + req.headers = self.authn.sign(headers, req.method, quote(path)) return req diff --git a/src/signify/app/credentialing.py b/src/signify/app/credentialing.py index 3580c4c..f06c0ed 100644 --- a/src/signify/app/credentialing.py +++ b/src/signify/app/credentialing.py @@ -20,6 +20,7 @@ - use :class:`Registries` when the operation is about the VDR registry itself. """ from collections import namedtuple +from urllib.parse import quote from keri.core import coring, counting, serdering from keri.core.eventing import TraitDex, interact @@ -162,7 +163,7 @@ def get(self, name, registryName): Returns: dict: Decoded registry record returned by KERIA. """ - res = self.client.get(f"/identifiers/{name}/registries/{registryName}") + res = self.client.get(f"/identifiers/{name}/registries/{quote(registryName, safe='')}") return res.json() def list(self, name): @@ -396,7 +397,7 @@ def rename(self, target, registryName, newName): """ name = target if isinstance(target, str) else target["name"] body = dict(name=newName) - resp = self.client.put(path=f"/identifiers/{name}/registries/{registryName}", json=body) + resp = self.client.put(path=f"/identifiers/{name}/registries/{quote(registryName, safe='')}", json=body) return resp.json() diff --git a/src/signify/app/didwebing.py b/src/signify/app/didwebing.py new file mode 100644 index 0000000..7176404 --- /dev/null +++ b/src/signify/app/didwebing.py @@ -0,0 +1,365 @@ +# -*- encoding: utf-8 -*- +"""SIGNIFY did:webs publication helpers. + +This module owns the did:webs-specific durable API: request polling and +edge-side approval. Live SSE transport and KERI ``rpy`` envelope verification +belong to ``signify.app.signaling.AgentSignals`` so future KERIA topics can use +the same agent signaling channel without importing did:webs code. +""" + +from dataclasses import dataclass +from datetime import datetime, timezone + +from keri.db import dbing, koming + +DWS_SIGN_ROUTE = "/didwebs/signing/request" +DWS_ACT_CRT_REG = "create_registry" +DWS_ACT_ISS_DA = "issue_designated_alias" +DWS_DEDUPE_IN_FLIGHT = "in_flight" +DWS_DEDUPE_SUBMITTED = "submitted" +DWS_DEDUPE_COMPLETE = "complete" +DWS_DEDUPE_FAILED = "failed" +DWS_DEDUPE_REJECTED = "rejected" +DWS_DEDUPE_TERMINAL_RETENTION_SECONDS = 10 * 60 +DWS_DEDUPE_IN_FLIGHT_RETENTION_SECONDS = 10 * 60 +DWS_TERMINAL_DEDUPE_STATUSES = { + DWS_DEDUPE_COMPLETE, + DWS_DEDUPE_FAILED, + DWS_DEDUPE_REJECTED, +} + + +class DidWebs: + """Client helper for KERIA did:webs managed-AID signing requests. + + KERIA may ask an edge client to create a registry or issue the designated + aliases ACDC for a Signify-managed AID. Those requests are durable and + recoverable through this helper. If the request arrived over + ``client.signals().stream()``, verify its envelope first with + ``client.signals().verifyReplyEnvelope(..., route=DIDWEBS_SIGNING_ROUTE)``. + """ + + def __init__(self, client): + self.client = client + + def requests(self, aid=None, includeComplete=False): + """List pending did:webs signing requests from the connected KERIA agent.""" + params = {} + if aid is not None: + params["aid"] = aid + if includeComplete: + params["includeComplete"] = "true" + res = self.client.get("/didwebs/signing/requests", params=params or None) + return res.json()["requests"] + + def request(self, requestId): + """Fetch one did:webs signing request by SAID.""" + res = self.client.get(f"/didwebs/signing/requests/{requestId}") + return res.json() + + def approve(self, request): + """Perform the edge-signed work requested by KERIA. + + This method signs only through normal SignifyPy managed-AID APIs. It + does not make KERIA sign as the managed AID. + """ + action = request["action"] + name = request["name"] + if action == DWS_ACT_CRT_REG: + return self.client.registries().create(name, request["registryName"]) + if action == DWS_ACT_ISS_DA: + return self.client.credentials().issue( + name, + request["registryName"], + request["credentialData"], + request["schema"], + rules=request["rules"], + ) + + raise ValueError(f"unsupported did:webs signing request action {action}") + + +@dataclass +class DidWebsDedupeRecord: + """Local did:webs auto-approval dedupe state for one request SAID.""" + + id: str + aid: str + action: str + status: str + updated: str + error: str | None = None + + +class MemoryDidWebsDedupeStore: + """In-memory did:webs request dedupe store.""" + + def __init__(self): + self.records = {} + + def get(self, request_id): + return self.records.get(request_id) + + def put(self, record): + self.records[record.id] = record + + def delete(self, request_id): + self.records.pop(request_id, None) + + def purgeTerminal( + self, max_age_seconds=DWS_DEDUPE_TERMINAL_RETENTION_SECONDS, now=None + ): + now = now if now is not None else datetime.now(timezone.utc).isoformat() + deleted = 0 + for request_id, record in list(self.records.items()): + if shouldPurgeTerminalRecord(record, max_age_seconds, now): + self.delete(request_id) + deleted += 1 + return deleted + + +class DidWebsDedupeBaser(dbing.LMDBer): + """LMDBer rooted at the SignifyPy did:webs dedupe home.""" + + HeadDirPath = "~" + TailDirPath = ".keri/sigpy" + AltTailDirPath = ".keri/sigpy" + TempPrefix = "sigpy_didwebs_" + + def reopen(self, **kwa): + opened = super().reopen(**kwa) + self.records = koming.Komer( + db=self, subkey="requestDedupe.", schema=DidWebsDedupeRecord + ) + return opened + + +class LmdbDidWebsDedupeStore: + """Persistent did:webs request dedupe store under ``~/.keri/sigpy/didwebs``.""" + + def __init__( + self, + name="didwebs", + headDirPath=None, + reopen=True, + clear=False, + temp=False, + ): + self.db = DidWebsDedupeBaser( + name=name, + headDirPath=headDirPath, + reopen=reopen, + clear=clear, + temp=temp, + reuse=True, + ) + + def get(self, request_id): + return self.db.records.get(keys=(request_id,)) + + def put(self, record): + self.db.records.pin(keys=(record.id,), val=record) + + def delete(self, request_id): + self.db.records.rem(keys=(request_id,)) + + def purgeTerminal( + self, max_age_seconds=DWS_DEDUPE_TERMINAL_RETENTION_SECONDS, now=None + ): + now = now if now is not None else datetime.now(timezone.utc).isoformat() + deleted = 0 + for keys, record in list(self.db.records.getItemIter()): + if shouldPurgeTerminalRecord(record, max_age_seconds, now): + self.db.records.rem(keys=keys) + deleted += 1 + return deleted + + def close(self, clear=False): + self.db.close(clear=clear) + + +def shouldPurgeTerminalRecord(record, max_age_seconds, now): + """Return True when a terminal dedupe record is old enough to discard.""" + if record.status not in DWS_TERMINAL_DEDUPE_STATUSES: + return False + + return recordAgeSeconds(record, now) >= max_age_seconds + + +def recordAgeSeconds(record, now): + try: + updated = datetime.fromisoformat(record.updated) + current = datetime.fromisoformat(now) + except ValueError: + return 0 + + if updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + if current.tzinfo is None: + current = current.replace(tzinfo=timezone.utc) + + return (current - updated).total_seconds() + + +class DidWebsAutoApprover: + """Auto-approve did:webs requests once, deduped by request SAID.""" + + def __init__( + self, + client, + store=None, + now=None, + terminalRetentionSeconds=None, + inFlightRetentionSeconds=None, + ): + self.client = client + self.didwebs = DidWebs(client) + self.store = store if store is not None else LmdbDidWebsDedupeStore() + self.now = now if now is not None else self._now + self.terminalRetentionSeconds = ( + terminalRetentionSeconds + if terminalRetentionSeconds is not None + else DWS_DEDUPE_TERMINAL_RETENTION_SECONDS + ) + self.inFlightRetentionSeconds = ( + inFlightRetentionSeconds + if inFlightRetentionSeconds is not None + else DWS_DEDUPE_IN_FLIGHT_RETENTION_SECONDS + ) + + def handleEnvelope(self, envelope): + """Verify and handle one did:webs request from the signed SSE stream.""" + verified = self.client.signals().verifyReplyEnvelope( + envelope, route=DWS_SIGN_ROUTE + ) + if not verified: + return { + "outcome": "rejected", + "error": "did:webs signing request envelope failed verification", + } + + return self.handleRequest(envelope.get("rpy", {}).get("a"), source="sse") + + def handleRequest(self, request, source="polling"): + """Handle one durable did:webs request from SSE or polling.""" + request_id = (request or {}).get("d") + if not request_id: + return { + "outcome": "rejected", + "source": source, + "error": "did:webs signing request is missing request SAID", + } + + now = self.now() + self.store.purgeTerminal(self.terminalRetentionSeconds, now) + existing = self.store.get(request_id) + if self._shouldSkip(existing, now): + return { + "outcome": "skipped", + "requestId": request_id, + "source": source, + "record": existing, + } + + self._putRecord(request, DWS_DEDUPE_IN_FLIGHT) + ownership_error = self._localOwnershipError(request) + if ownership_error is not None: + record = self._putRecord(request, DWS_DEDUPE_REJECTED, ownership_error) + return { + "outcome": "rejected", + "requestId": request_id, + "source": source, + "record": record, + "error": ownership_error, + } + + try: + self.didwebs.approve(request) + except Exception as ex: + record = self._putRecord(request, DWS_DEDUPE_FAILED, str(ex)) + return { + "outcome": "failed", + "requestId": request_id, + "source": source, + "record": record, + "error": str(ex), + } + + record = self._putRecord(request, DWS_DEDUPE_SUBMITTED) + return { + "outcome": "submitted", + "requestId": request_id, + "source": source, + "record": record, + } + + def pollOnce(self, aid=None): + """Fetch pending durable requests and handle each once.""" + return [ + self.handleRequest(request, source="polling") + for request in self.didwebs.requests(aid=aid) + ] + + def reconcile(self, aid=None): + """Update local dedupe records from KERIA's durable request state.""" + self.store.purgeTerminal(self.terminalRetentionSeconds, self.now()) + records = [] + for request in self.didwebs.requests(aid=aid, includeComplete=True): + if request.get("state") == DWS_DEDUPE_COMPLETE: + records.append(self._putRecord(request, DWS_DEDUPE_COMPLETE)) + elif request.get("state") == DWS_DEDUPE_FAILED: + records.append( + self._putRecord( + request, + DWS_DEDUPE_FAILED, + request.get("error") or "KERIA reported request failure", + ) + ) + return records + + @staticmethod + def _now(): + return datetime.now(timezone.utc).isoformat() + + def _shouldSkip(self, record, now): + if ( + record is not None + and record.status == DWS_DEDUPE_IN_FLIGHT + and recordAgeSeconds(record, now) >= self.inFlightRetentionSeconds + ): + return False + + return record is not None and record.status in { + DWS_DEDUPE_IN_FLIGHT, + DWS_DEDUPE_SUBMITTED, + DWS_DEDUPE_COMPLETE, + DWS_DEDUPE_FAILED, + DWS_DEDUPE_REJECTED, + } + + def _localOwnershipError(self, request): + try: + hab = self.client.identifiers().get(request["name"]) + except Exception as ex: + return ( + f"did:webs request {request['d']} targets {request['aid']}, " + f"but local identifier {request['name']} is unavailable: {ex}" + ) + if hab.get("prefix") != request["aid"]: + return ( + f"did:webs request {request['d']} targets {request['aid']}, " + f"but local identifier {request['name']} is {hab.get('prefix')}" + ) + return None + + def _putRecord(self, request, status, error=None): + record = DidWebsDedupeRecord( + id=request["d"], + aid=request["aid"], + action=request["action"], + status=status, + updated=self.now(), + error=error, + ) + self.store.put(record) + return record diff --git a/src/signify/app/signaling.py b/src/signify/app/signaling.py new file mode 100644 index 0000000..a170606 --- /dev/null +++ b/src/signify/app/signaling.py @@ -0,0 +1,77 @@ +# -*- encoding: utf-8 -*- +"""Generic KERIA agent signaling helpers. + +KERIA's ``/signals/stream`` endpoint is a transport for transient, agent-scoped +events. Topic modules such as did:webs use that transport, but they do not own +it. Keep this module route-agnostic so later workflows can reuse the same SSE +channel and KERI ``rpy`` envelope verification without importing did:webs code. + +The stream is intentionally not durable. A consumer that misses an SSE event +must recover through the topic's polling endpoint. For did:webs publication, +that fallback is ``/didwebs/signing/requests``. +""" + +from keri import kering +from keri.core import indexing, serdering + + +class AgentSignals: + """Generic signed event stream for one connected KERIA agent. + + ``AgentSignals`` owns only the transport and envelope-authentication + contract: + + * ``stream`` opens KERIA's authenticated ``/signals/stream`` SSE endpoint. + * ``verifyReplyEnvelope`` verifies that a received KERI ``rpy`` envelope was + signed by the connected KERIA agent AID. + + Topic-specific code is responsible for interpreting event names, routes, + and payloads after this generic verification step. + """ + + def __init__(self, client): + self.client = client + + def stream(self): + """Open the authenticated generic agent SSE stream. + + Native browser ``EventSource`` cannot attach the Signify authentication + headers required by KERIA admin routes. Signify clients therefore use an + authenticated HTTP stream and parse SSE frames themselves. + """ + return self.client.stream( + "/signals/stream", + headers={"Accept": "text/event-stream"}, + ) + + def verifyReplyEnvelope(self, envelope, route=None): + """Verify one KERIA agent-signed KERI ``rpy`` envelope. + + Parameters: + envelope (dict): A mapping with ``rpy`` and ``sigs`` keys as emitted + by KERIA's signaling layer. + route (str | None): Optional expected KERI reply route. Topic + modules pass their own route, for example + ``/didwebs/signing/request``. + + Returns: + bool: ``True`` only when the envelope route matches, the payload + names the connected KERIA agent, and the first signature verifies + against the connected agent's current verifier. + """ + if self.client.agent is None: + raise kering.ConfigurationError("client must be connected before verification") + + rserder = serdering.SerderKERI(sad=envelope["rpy"]) + if route is not None and rserder.ked.get("r") != route: + return False + data = rserder.ked.get("a", {}) + if data.get("agent") != self.client.agent.pre: + return False + + sigs = envelope.get("sigs") or [] + if not sigs: + return False + + siger = indexing.Siger(qb64=sigs[0]) + return self.client.agent.verfer.verify(sig=siger.raw, ser=rserder.raw) diff --git a/src/signify/app/w3cing.py b/src/signify/app/w3cing.py new file mode 100644 index 0000000..05bf9e0 --- /dev/null +++ b/src/signify/app/w3cing.py @@ -0,0 +1,372 @@ +# -*- encoding: utf-8 -*- +"""SignifyPy W3C projection helpers. + +KERIA creates short-lived projection sessions and requests edge signatures over +the generic signed SSE channel. This module owns only the W3C-specific polling, +signature submission, and local dedupe/auto-approval behavior. +""" + +import base64 +from dataclasses import dataclass +from datetime import datetime, timezone + +from keri.db import dbing, koming + +W3C_SIGN_ROUTE = "/w3c/signing/request" +W3C_KIND_PROOF = "data_integrity_proof" +W3C_KIND_JWT = "vc_jwt" +W3C_DEDUPE_IN_FLIGHT = "in_flight" +W3C_DEDUPE_SUBMITTED = "submitted" +W3C_DEDUPE_COMPLETE = "complete" +W3C_DEDUPE_FAILED = "failed" +W3C_DEDUPE_REJECTED = "rejected" +W3C_DEDUPE_TERMINAL_RETENTION_SECONDS = 10 * 60 +W3C_DEDUPE_IN_FLIGHT_RETENTION_SECONDS = 10 * 60 +W3C_TERMINAL_DEDUPE_STATUSES = { + W3C_DEDUPE_COMPLETE, + W3C_DEDUPE_FAILED, + W3C_DEDUPE_REJECTED, +} + + +class W3C: + """Client helper for KERIA W3C projection sessions and signing requests.""" + + def __init__(self, client): + self.client = client + + def verifiers(self): + """Return the configured verifier allowlist from KERIA.""" + return self.client.get("/w3c/verifiers").json()["verifiers"] + + def project(self, name, credentialSaid, verifierId): + """Create one short-lived W3C projection session.""" + return self.client.post( + f"/identifiers/{name}/w3c/projections", + json=dict(credentialSaid=credentialSaid, verifierId=verifierId), + ).json() + + def projection(self, name, sessionId): + """Fetch one W3C projection session status.""" + return self.client.get( + f"/identifiers/{name}/w3c/projections/{sessionId}" + ).json() + + def requests(self, name=None, includeComplete=False): + """List pending W3C signing requests, optionally across all identifiers.""" + names = [name] if name is not None else self._managedNames() + requests = [] + params = {"includeComplete": "true"} if includeComplete else None + for aid_name in names: + res = self.client.get( + f"/identifiers/{aid_name}/w3c/signing-requests", params=params + ) + requests.extend(res.json()["requests"]) + return requests + + def submitSignature(self, request, signature): + """Submit one edge signature for a W3C signing request.""" + return self.client.post( + f"/identifiers/{request['name']}/w3c/signing-requests/{request['d']}/signatures", + json=dict(signature=signature), + ).json() + + def _managedNames(self): + return [aid["name"] for aid in self.client.identifiers().list()["aids"]] + + +@dataclass +class W3CDedupeRecord: + """Local W3C projection dedupe state for one request SAID.""" + + id: str + aid: str + kind: str + status: str + updated: str + error: str | None = None + + +class MemoryW3CDedupeStore: + """In-memory W3C projection request dedupe store.""" + + def __init__(self): + self.records = {} + + def get(self, request_id): + return self.records.get(request_id) + + def put(self, record): + self.records[record.id] = record + + def delete(self, request_id): + self.records.pop(request_id, None) + + def purgeTerminal( + self, max_age_seconds=W3C_DEDUPE_TERMINAL_RETENTION_SECONDS, now=None + ): + now = now if now is not None else datetime.now(timezone.utc).isoformat() + deleted = 0 + for request_id, record in list(self.records.items()): + if shouldPurgeTerminalRecord(record, max_age_seconds, now): + self.delete(request_id) + deleted += 1 + return deleted + + +class W3CDedupeBaser(dbing.LMDBer): + """LMDBer rooted at the SignifyPy W3C dedupe home.""" + + HeadDirPath = "~" + TailDirPath = ".keri/sigpy" + AltTailDirPath = ".keri/sigpy" + TempPrefix = "sigpy_w3c_" + + def reopen(self, **kwa): + opened = super().reopen(**kwa) + self.records = koming.Komer( + db=self, subkey="requestDedupe.", schema=W3CDedupeRecord + ) + return opened + + +class LmdbW3CDedupeStore: + """Persistent W3C projection dedupe store under ``~/.keri/sigpy/w3c``.""" + + def __init__( + self, + name="w3c", + headDirPath=None, + reopen=True, + clear=False, + temp=False, + ): + self.db = W3CDedupeBaser( + name=name, + headDirPath=headDirPath, + reopen=reopen, + clear=clear, + temp=temp, + reuse=True, + ) + + def get(self, request_id): + return self.db.records.get(keys=(request_id,)) + + def put(self, record): + self.db.records.pin(keys=(record.id,), val=record) + + def delete(self, request_id): + self.db.records.rem(keys=(request_id,)) + + def purgeTerminal( + self, max_age_seconds=W3C_DEDUPE_TERMINAL_RETENTION_SECONDS, now=None + ): + now = now if now is not None else datetime.now(timezone.utc).isoformat() + deleted = 0 + for keys, record in list(self.db.records.getItemIter()): + if shouldPurgeTerminalRecord(record, max_age_seconds, now): + self.db.records.rem(keys=keys) + deleted += 1 + return deleted + + def close(self, clear=False): + self.db.close(clear=clear) + + +def shouldPurgeTerminalRecord(record, max_age_seconds, now): + """Return True when a terminal dedupe record is old enough to discard.""" + if record.status not in W3C_TERMINAL_DEDUPE_STATUSES: + return False + return recordAgeSeconds(record, now) >= max_age_seconds + + +def recordAgeSeconds(record, now): + try: + updated = datetime.fromisoformat(record.updated) + current = datetime.fromisoformat(now) + except ValueError: + return 0 + if updated.tzinfo is None: + updated = updated.replace(tzinfo=timezone.utc) + if current.tzinfo is None: + current = current.replace(tzinfo=timezone.utc) + return (current - updated).total_seconds() + + +class W3CProjectionAutoApprover: + """Auto-sign W3C projection requests once, deduped by request SAID.""" + + def __init__( + self, + client, + store=None, + now=None, + terminalRetentionSeconds=None, + inFlightRetentionSeconds=None, + ): + self.client = client + self.w3c = W3C(client) + self.store = store if store is not None else LmdbW3CDedupeStore() + self.now = now if now is not None else self._now + self.terminalRetentionSeconds = ( + terminalRetentionSeconds + if terminalRetentionSeconds is not None + else W3C_DEDUPE_TERMINAL_RETENTION_SECONDS + ) + self.inFlightRetentionSeconds = ( + inFlightRetentionSeconds + if inFlightRetentionSeconds is not None + else W3C_DEDUPE_IN_FLIGHT_RETENTION_SECONDS + ) + + def handleEnvelope(self, envelope): + """Verify and handle one W3C request from the signed SSE stream.""" + verified = self.client.signals().verifyReplyEnvelope( + envelope, route=W3C_SIGN_ROUTE + ) + if not verified: + return { + "outcome": "rejected", + "error": "W3C signing request envelope failed verification", + } + return self.handleRequest(envelope.get("rpy", {}).get("a"), source="sse") + + def handleRequest(self, request, source="polling"): + """Handle one W3C signing request from SSE or polling.""" + request_id = (request or {}).get("d") + if not request_id: + return { + "outcome": "rejected", + "source": source, + "error": "W3C signing request is missing request SAID", + } + + now = self.now() + self.store.purgeTerminal(self.terminalRetentionSeconds, now) + existing = self.store.get(request_id) + if self._shouldSkip(existing, now): + return { + "outcome": "skipped", + "requestId": request_id, + "source": source, + "record": existing, + } + + self._putRecord(request, W3C_DEDUPE_IN_FLIGHT) + ownership_error = self._localOwnershipError(request) + if ownership_error is not None: + record = self._putRecord(request, W3C_DEDUPE_REJECTED, ownership_error) + return { + "outcome": "rejected", + "requestId": request_id, + "source": source, + "record": record, + "error": ownership_error, + } + + try: + signature = self._signRequest(request) + self.w3c.submitSignature(request, signature) + except Exception as ex: + record = self._putRecord(request, W3C_DEDUPE_FAILED, str(ex)) + return { + "outcome": "failed", + "requestId": request_id, + "source": source, + "record": record, + "error": str(ex), + } + + record = self._putRecord(request, W3C_DEDUPE_SUBMITTED) + return { + "outcome": "submitted", + "requestId": request_id, + "source": source, + "record": record, + } + + def pollOnce(self, name=None): + """Fetch pending durable requests and handle each once.""" + return [ + self.handleRequest(request, source="polling") + for request in self.w3c.requests(name=name) + ] + + def reconcile(self, name=None): + """Update local dedupe records from KERIA request state.""" + self.store.purgeTerminal(self.terminalRetentionSeconds, self.now()) + records = [] + for request in self.w3c.requests(name=name, includeComplete=True): + if request.get("state") == W3C_DEDUPE_COMPLETE: + records.append(self._putRecord(request, W3C_DEDUPE_COMPLETE)) + elif request.get("state") == W3C_DEDUPE_FAILED: + records.append( + self._putRecord( + request, + W3C_DEDUPE_FAILED, + request.get("error") or "KERIA reported W3C request failure", + ) + ) + return records + + @staticmethod + def _now(): + return datetime.now(timezone.utc).isoformat() + + def _shouldSkip(self, record, now): + if ( + record is not None + and record.status == W3C_DEDUPE_IN_FLIGHT + and recordAgeSeconds(record, now) >= self.inFlightRetentionSeconds + ): + return False + return record is not None and record.status in { + W3C_DEDUPE_IN_FLIGHT, + W3C_DEDUPE_SUBMITTED, + W3C_DEDUPE_COMPLETE, + W3C_DEDUPE_FAILED, + W3C_DEDUPE_REJECTED, + } + + def _localOwnershipError(self, request): + try: + hab = self.client.identifiers().get(request["name"]) + except Exception as ex: + return ( + f"W3C request {request['d']} targets {request['aid']}, " + f"but local identifier {request['name']} is unavailable: {ex}" + ) + if hab.get("prefix") != request["aid"]: + return ( + f"W3C request {request['d']} targets {request['aid']}, " + f"but local identifier {request['name']} is {hab.get('prefix')}" + ) + return None + + def _signRequest(self, request): + if request["kind"] not in {W3C_KIND_PROOF, W3C_KIND_JWT}: + raise ValueError(f"unsupported W3C signing request kind {request['kind']}") + hab = self.client.identifiers().get(request["name"]) + keeper = self.client.manager.get(aid=hab) + signing_input = b64url_decode(request["signingInputB64"]) + sigs = keeper.sign(ser=signing_input, indexed=False) + return sigs[0] + + def _putRecord(self, request, status, error=None): + record = W3CDedupeRecord( + id=request["d"], + aid=request["aid"], + kind=request["kind"], + status=status, + updated=self.now(), + error=error, + ) + self.store.put(record) + return record + + +def b64url_decode(value): + """Decode an unpadded base64url string.""" + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(value + padding) diff --git a/src/signify/core/authing.py b/src/signify/core/authing.py index 5aa459a..2f860c2 100644 --- a/src/signify/core/authing.py +++ b/src/signify/core/authing.py @@ -4,7 +4,7 @@ signify.core.authing module """ -from urllib.parse import urlparse +from urllib.parse import quote, urlparse from keri import kering from keri.app import keeping @@ -276,7 +276,7 @@ def verify(self, rep, **kwargs): raise kering.AuthNError("No valid signature from agent on response.") resource = rep.headers["SIGNIFY-RESOURCE"] - if resource != self.agent.pre or not self.verifysig(rep.headers, rep.request.method, url.path): + if resource != self.agent.pre or not self.verifysig(rep.headers, rep.request.method, quote(url.path)): raise kering.AuthNError("No valid signature from agent on response.") def verifysig(self, headers, method, path): diff --git a/tests/app/test_aiding.py b/tests/app/test_aiding.py index f25e068..284ed38 100644 --- a/tests/app/test_aiding.py +++ b/tests/app/test_aiding.py @@ -579,6 +579,7 @@ def test_aiding_add_end_role(): from signify.app.aiding import Identifiers ids = Identifiers(client=mock_client) # type: ignore + mock_client.agent = mock({"pre": "agent-pre"}, strict=False) # type: ignore mock_hab = {'prefix': 'hab prefix', 'name': 'aid1'} expect(ids, times=1).get('aid1').thenReturn(mock_hab) @@ -586,7 +587,7 @@ def test_aiding_add_end_role(): from keri.core import serdering mock_serder = mock({'ked': {'a': 'key event dictionary'}, 'raw': b'serder raw bytes'}, spec=serdering.SerderKERI, strict=True) - expect(ids, times=1).makeEndRole('hab prefix', 'agent', None, None).thenReturn(mock_serder) + expect(ids, times=1).makeEndRole('hab prefix', 'agent', 'agent-pre', None).thenReturn(mock_serder) from signify.core import keeping mock_keeper = mock({'params': lambda: {'keeper': 'params'}}, spec=keeping.SaltyKeeper, strict=True) @@ -608,6 +609,58 @@ def test_aiding_add_end_role(): unstub() +def test_aiding_add_end_role_preserves_explicit_eid(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.core import keeping + mock_manager = mock(spec=keeping.Manager, strict=True) + mock_client.manager = mock_manager # type: ignore + mock_client.agent = mock({"pre": "agent-pre"}, strict=False) # type: ignore + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + mock_hab = {'prefix': 'hab prefix', 'name': 'aid1'} + expect(ids, times=1).get('aid1').thenReturn(mock_hab) + + from keri.core import serdering + mock_serder = mock({'ked': {'a': 'key event dictionary'}, 'raw': b'serder raw bytes'}, spec=serdering.SerderKERI, + strict=True) + expect(ids, times=1).makeEndRole('hab prefix', 'agent', 'explicit-eid', None).thenReturn(mock_serder) + + mock_keeper = mock({'params': lambda: {'keeper': 'params'}}, spec=keeping.SaltyKeeper, strict=True) + expect(mock_manager, times=1).get(aid=mock_hab).thenReturn(mock_keeper) + expect(mock_keeper, times=1).sign(ser=mock_serder.raw).thenReturn(['a signature']) + + from requests import Response + mock_response = mock(spec=Response, strict=True) + expected_data = {'rpy': {'a': 'key event dictionary'}, 'sigs': ['a signature']} + expect(mock_client, times=1).post('/identifiers/aid1/endroles', json=expected_data).thenReturn(mock_response) + expect(mock_response, times=1).json().thenReturn({'success': 'yay'}) + + _serder, _sig, out = ids.addEndRole('aid1', eid='explicit-eid') + assert out['success'] == 'yay' + + verifyNoUnwantedInteractions() + unstub() + + +def test_aiding_add_end_role_requires_eid_for_non_agent_role(): + from signify.app.clienting import SignifyClient + mock_client = mock(spec=SignifyClient, strict=True) + + from signify.app.aiding import Identifiers + ids = Identifiers(client=mock_client) # type: ignore + + from keri import kering + with pytest.raises(kering.ConfigurationError): + ids.addEndRole('aid1', role='mailbox') + + verifyNoUnwantedInteractions() + unstub() + + def test_aiding_sign(): from signify.app.clienting import SignifyClient mock_client = mock(spec=SignifyClient, strict=True) diff --git a/tests/app/test_clienting.py b/tests/app/test_clienting.py index 33f3ba3..0177064 100644 --- a/tests/app/test_clienting.py +++ b/tests/app/test_clienting.py @@ -817,6 +817,26 @@ def test_signify_client_delegations(make_signify_client): assert out.client == client +def test_signify_client_didwebs(make_signify_client): + client = make_signify_client() + + out = client.didwebs() + + from signify.app.didwebing import DidWebs + assert type(out) is DidWebs + assert out.client == client + + +def test_signify_client_signals(make_signify_client): + client = make_signify_client() + + out = client.signals() + + from signify.app.signaling import AgentSignals + assert type(out) is AgentSignals + assert out.client == client + + def test_signify_client_registries(make_signify_client): client = make_signify_client() @@ -972,7 +992,42 @@ def test_signify_auth(): 'Signify-Timestamp': 'now ISO8601!', 'Content-Length': 11 } - expect(mock_authenticator, times=1).sign({'Signify-Resource': 'a prefix', 'Signify-Timestamp': 'now ISO8601!', 'Content-Length': 14}, 'GET', '/my_path').thenReturn({'headers': 'modified'}) + expect(mock_authenticator, times=1).sign( + {'Signify-Resource': 'a prefix', 'Signify-Timestamp': 'now ISO8601!', 'Content-Length': 14}, + 'GET', + '/my_path', + ).thenReturn({'headers': 'modified'}) + + out = signify_auth.__call__(mock_request) + assert out.headers == {'headers': 'modified'} + + unstub() + verifyNoUnwantedInteractions() + + +def test_signify_auth_quotes_signed_path(): + from signify.core import authing + mock_controller = mock({'pre': 'a prefix'}, spec=authing.Controller, strict=True) + mock_authenticator = mock({'ctrl': mock_controller}, spec=authing.Authenticater, strict=True) + + from signify.app.clienting import SignifyAuth + signify_auth = SignifyAuth(mock_authenticator) + + import requests + mock_request = mock({ + 'method': 'GET', + 'url': 'http://example.com/identifiers/name/registries/did:webs_designated_aliases:Eaid', + 'headers': {}, + 'body': None, + }, spec=requests.Request, strict=True) + + from keri.help import helping + expect(helping).nowIso8601().thenReturn('now ISO8601!') + expect(mock_authenticator, times=1).sign( + {'Signify-Resource': 'a prefix', 'Signify-Timestamp': 'now ISO8601!'}, + 'GET', + '/identifiers/name/registries/did%3Awebs_designated_aliases%3AEaid', + ).thenReturn({'headers': 'modified'}) out = signify_auth.__call__(mock_request) assert out.headers == {'headers': 'modified'} diff --git a/tests/app/test_didwebing.py b/tests/app/test_didwebing.py new file mode 100644 index 0000000..fc7160b --- /dev/null +++ b/tests/app/test_didwebing.py @@ -0,0 +1,439 @@ +# -*- encoding: utf-8 -*- +""" +SIGNIFY did:webs helper tests. +""" + +import pytest +from mockito import expect, mock, verifyNoUnwantedInteractions, unstub + +from signify.app.didwebing import ( + DWS_ACT_CRT_REG, + DWS_ACT_ISS_DA, + DWS_DEDUPE_COMPLETE, + DWS_DEDUPE_FAILED, + DWS_DEDUPE_IN_FLIGHT, + DWS_DEDUPE_IN_FLIGHT_RETENTION_SECONDS, + DWS_DEDUPE_REJECTED, + DWS_DEDUPE_SUBMITTED, + DWS_DEDUPE_TERMINAL_RETENTION_SECONDS, + DWS_SIGN_ROUTE, + DidWebsDedupeRecord, + LmdbDidWebsDedupeStore, + DidWebsAutoApprover, + DidWebs, + MemoryDidWebsDedupeStore, +) + + +def request_fixture(**overrides): + request = { + "d": "request-id", + "type": "didwebs.registry.create", + "action": DWS_ACT_CRT_REG, + "agent": "agent-aid", + "aid": "managed-aid", + "name": "aid1", + "did": "did:webs:example:aid", + "registryName": "registry", + "schema": "schema", + "credentialData": {}, + "rules": {}, + "didJsonUrl": "http://example/did.json", + "keriCesrUrl": "http://example/keri.cesr", + "dt": "2021-06-27T21:26:21.233257+00:00", + } + request.update(overrides) + return request + + +def envelope_fixture(request=None): + return { + "rpy": { + "r": DWS_SIGN_ROUTE, + "a": request if request is not None else request_fixture(), + }, + "sigs": ["signature"], + } + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +class FakeClient: + def __init__( + self, + requests=None, + verify=True, + prefix="managed-aid", + approve_error=None, + identifier_error=None, + ): + self.requests_payload = requests if requests is not None else [] + self.verify = verify + self.prefix = prefix + self.approve_error = approve_error + self.identifier_error = identifier_error + self.calls = [] + + def signals(self): + return self + + def verifyReplyEnvelope(self, _envelope, route=None): + self.calls.append(("verify", route)) + return self.verify + + def identifiers(self): + return self + + def registries(self): + return self + + def credentials(self): + return self + + def get(self, *args, **kwargs): + if args and args[0] == "/didwebs/signing/requests": + self.calls.append(("requests", args, kwargs)) + return FakeResponse({"requests": self.requests_payload}) + if args and args[0].startswith("/didwebs/signing/requests/"): + self.calls.append(("request", args, kwargs)) + return FakeResponse(request_fixture()) + + name = args[0] + if self.identifier_error is not None: + raise self.identifier_error + self.calls.append(("identifier", name)) + return {"prefix": self.prefix} + + def create(self, name, registry_name): + if self.approve_error is not None: + raise self.approve_error + self.calls.append(("registry", name, registry_name)) + return "registry-op" + + def issue(self, name, registry_name, credential_data, schema, rules=None): + if self.approve_error is not None: + raise self.approve_error + self.calls.append( + ("credential", name, registry_name, credential_data, schema, rules) + ) + return "credential-op" + + +def test_didwebs_requests(make_mock_response): + client = mock(strict=True) + response = make_mock_response({"json": lambda: {"requests": [{"d": "request-id"}]}}) + expect(client, times=1).get( + "/didwebs/signing/requests", params={"aid": "aid", "includeComplete": "true"} + ).thenReturn(response) + + result = DidWebs(client).requests(aid="aid", includeComplete=True) + + assert result == [{"d": "request-id"}] + verifyNoUnwantedInteractions() + unstub() + + +def test_didwebs_request(make_mock_response): + client = mock(strict=True) + response = make_mock_response({"json": lambda: {"d": "request-id"}}) + expect(client, times=1).get("/didwebs/signing/requests/request-id").thenReturn( + response + ) + + result = DidWebs(client).request("request-id") + + assert result == {"d": "request-id"} + verifyNoUnwantedInteractions() + unstub() + + +def test_didwebs_approve_registry_request(): + client = mock(strict=True) + registries = mock(strict=True) + response = mock() + expect(client, times=1).registries().thenReturn(registries) + expect(registries, times=1).create("aid1", "registry").thenReturn(response) + + result = DidWebs(client).approve( + { + "action": DWS_ACT_CRT_REG, + "name": "aid1", + "registryName": "registry", + } + ) + + assert result is response + verifyNoUnwantedInteractions() + unstub() + + +def test_didwebs_approve_designated_alias_request(): + client = mock(strict=True) + credentials = mock(strict=True) + response = mock() + expect(client, times=1).credentials().thenReturn(credentials) + expect(credentials, times=1).issue( + "aid1", + "registry", + {"ids": ["did:webs:example:aid"]}, + "schema", + rules={"usageDisclaimer": {}}, + ).thenReturn(response) + + result = DidWebs(client).approve( + { + "action": DWS_ACT_ISS_DA, + "name": "aid1", + "registryName": "registry", + "credentialData": {"ids": ["did:webs:example:aid"]}, + "schema": "schema", + "rules": {"usageDisclaimer": {}}, + } + ) + + assert result is response + verifyNoUnwantedInteractions() + unstub() + + +def test_didwebs_approve_rejects_unknown_action(): + client = mock(strict=True) + + with pytest.raises(ValueError, match="unsupported did:webs signing request"): + DidWebs(client).approve({"action": "unknown", "name": "aid1"}) + + +def test_auto_approver_dedupes_duplicate_sse_envelopes(): + client = FakeClient() + approver = DidWebsAutoApprover( + client, store=MemoryDidWebsDedupeStore(), now=lambda: "now" + ) + + first = approver.handleEnvelope(envelope_fixture()) + second = approver.handleEnvelope(envelope_fixture()) + + assert first["outcome"] == "submitted" + assert second["outcome"] == "skipped" + assert second["record"].status == DWS_DEDUPE_SUBMITTED + assert [call[0] for call in client.calls].count("registry") == 1 + + +def test_auto_approver_dedupes_same_request_from_sse_and_polling(): + request = request_fixture() + client = FakeClient(requests=[request]) + approver = DidWebsAutoApprover(client, store=MemoryDidWebsDedupeStore()) + + approver.handleEnvelope(envelope_fixture(request)) + results = approver.pollOnce() + + assert results[0]["outcome"] == "skipped" + assert [call[0] for call in client.calls].count("registry") == 1 + + +def test_auto_approver_uses_provided_store_across_instances(): + store = MemoryDidWebsDedupeStore() + client = FakeClient() + + DidWebsAutoApprover(client, store=store).handleRequest(request_fixture()) + second = DidWebsAutoApprover(client, store=store).handleRequest(request_fixture()) + + assert second["outcome"] == "skipped" + assert [call[0] for call in client.calls].count("registry") == 1 + + +def test_auto_approver_retries_stale_in_flight_records_after_active_window(): + store = MemoryDidWebsDedupeStore() + store.put( + DidWebsDedupeRecord( + id="request-id", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_IN_FLIGHT, + updated="2021-06-27T21:00:00+00:00", + ) + ) + client = FakeClient() + + result = DidWebsAutoApprover( + client, + store=store, + now=lambda: "2021-06-27T21:10:00+00:00", + inFlightRetentionSeconds=DWS_DEDUPE_IN_FLIGHT_RETENTION_SECONDS, + ).handleRequest(request_fixture()) + + assert result["outcome"] == "submitted" + assert [call[0] for call in client.calls].count("registry") == 1 + + +def test_auto_approver_rejects_unverified_sse_envelope(): + client = FakeClient(verify=False) + + result = DidWebsAutoApprover( + client, store=MemoryDidWebsDedupeStore() + ).handleEnvelope(envelope_fixture()) + + assert result["outcome"] == "rejected" + assert [call[0] for call in client.calls].count("registry") == 0 + + +def test_auto_approver_rejects_mismatched_local_aid(): + client = FakeClient(prefix="other-aid") + + result = DidWebsAutoApprover( + client, store=MemoryDidWebsDedupeStore() + ).handleRequest(request_fixture()) + + assert result["outcome"] == "rejected" + assert result["record"].status == DWS_DEDUPE_REJECTED + assert [call[0] for call in client.calls].count("registry") == 0 + + +def test_auto_approver_rejects_unavailable_local_identifier(): + client = FakeClient(identifier_error=ValueError("not found")) + + result = DidWebsAutoApprover( + client, store=MemoryDidWebsDedupeStore() + ).handleRequest(request_fixture()) + + assert result["outcome"] == "rejected" + assert result["record"].status == DWS_DEDUPE_REJECTED + assert "unavailable: not found" in result["error"] + assert [call[0] for call in client.calls].count("registry") == 0 + + +def test_auto_approver_marks_approval_error_failed_without_completing(): + client = FakeClient(approve_error=ValueError("boom")) + + result = DidWebsAutoApprover( + client, store=MemoryDidWebsDedupeStore() + ).handleRequest(request_fixture()) + + assert result["outcome"] == "failed" + assert result["record"].status == DWS_DEDUPE_FAILED + assert result["record"].error == "boom" + + +def test_auto_approver_reconciles_completion_only_from_keria_state(): + request = request_fixture(state=DWS_DEDUPE_COMPLETE) + pending = request_fixture(d="pending-id", state="pending") + client = FakeClient(requests=[request, pending]) + store = MemoryDidWebsDedupeStore() + approver = DidWebsAutoApprover(client, store=store) + + approver.handleRequest(request_fixture()) + records = approver.reconcile() + + assert len(records) == 1 + assert store.get("request-id").status == DWS_DEDUPE_COMPLETE + assert store.get("pending-id") is None + + +def test_memory_dedupe_store_purges_old_terminal_records_only(): + store = MemoryDidWebsDedupeStore() + store.put( + DidWebsDedupeRecord( + id="old-complete", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_COMPLETE, + updated="2021-06-27T21:00:00+00:00", + ) + ) + store.put( + DidWebsDedupeRecord( + id="recent-complete", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_COMPLETE, + updated="2021-06-27T21:09:30+00:00", + ) + ) + store.put( + DidWebsDedupeRecord( + id="old-submitted", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_SUBMITTED, + updated="2021-06-27T21:00:00+00:00", + ) + ) + store.put( + DidWebsDedupeRecord( + id="old-rejected", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_REJECTED, + updated="2021-06-27T21:00:00+00:00", + ) + ) + + deleted = store.purgeTerminal( + DWS_DEDUPE_TERMINAL_RETENTION_SECONDS, + now="2021-06-27T21:10:00+00:00", + ) + + assert deleted == 2 + assert store.get("old-complete") is None + assert store.get("old-rejected") is None + assert store.get("recent-complete").status == DWS_DEDUPE_COMPLETE + assert store.get("old-submitted").status == DWS_DEDUPE_SUBMITTED + + +def test_lmdb_dedupe_store_persists_records_across_reopen(tmp_path): + store = LmdbDidWebsDedupeStore(headDirPath=str(tmp_path), name="didwebs-test") + store.put( + DidWebsDedupeRecord( + id="request-id", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_SUBMITTED, + updated="2021-06-27T21:00:00+00:00", + ) + ) + store.close() + + reopened = LmdbDidWebsDedupeStore(headDirPath=str(tmp_path), name="didwebs-test") + try: + assert reopened.get("request-id").status == DWS_DEDUPE_SUBMITTED + finally: + reopened.close(clear=True) + + +def test_lmdb_dedupe_store_purges_terminal_records(tmp_path): + store = LmdbDidWebsDedupeStore(headDirPath=str(tmp_path), name="didwebs-test") + try: + store.put( + DidWebsDedupeRecord( + id="old-failed", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_FAILED, + updated="2021-06-27T21:00:00+00:00", + ) + ) + store.put( + DidWebsDedupeRecord( + id="old-submitted", + aid="managed-aid", + action=DWS_ACT_CRT_REG, + status=DWS_DEDUPE_SUBMITTED, + updated="2021-06-27T21:00:00+00:00", + ) + ) + + deleted = store.purgeTerminal( + DWS_DEDUPE_TERMINAL_RETENTION_SECONDS, + now="2021-06-27T21:10:00+00:00", + ) + + assert deleted == 1 + assert store.get("old-failed") is None + assert store.get("old-submitted").status == DWS_DEDUPE_SUBMITTED + finally: + store.close(clear=True) diff --git a/tests/app/test_signaling.py b/tests/app/test_signaling.py new file mode 100644 index 0000000..d9cd5aa --- /dev/null +++ b/tests/app/test_signaling.py @@ -0,0 +1,82 @@ +# -*- encoding: utf-8 -*- +""" +SIGNIFY generic agent signaling helper tests. +""" + +import pytest +from keri import kering +from keri.core import eventing, signing +from mockito import expect, mock, verifyNoUnwantedInteractions, unstub + +from signify.app.signaling import AgentSignals + + +def signed_envelope(route="/test/route", agent="agent-aid", signer=None): + signer = signer if signer is not None else signing.Salter(raw=b"0123456789abcdef").signer() + rpy = eventing.reply(route=route, data={"agent": agent, "payload": "value"}) + sig = signer.sign(ser=rpy.raw, index=0) + return signer, {"rpy": rpy.ked, "sigs": [sig.qb64]} + + +def test_agent_signals_stream_uses_generic_endpoint(): + client = mock(strict=True) + stream = iter([]) + expect(client, times=1).stream( + "/signals/stream", + headers={"Accept": "text/event-stream"}, + ).thenReturn(stream) + + assert AgentSignals(client).stream() is stream + verifyNoUnwantedInteractions() + unstub() + + +def test_agent_signals_verify_agent_signed_reply_envelope(): + signer, envelope = signed_envelope(route="/didwebs/signing/request") + client = mock({"agent": mock({"pre": "agent-aid", "verfer": signer.verfer})}) + + assert AgentSignals(client).verifyReplyEnvelope( + envelope, + route="/didwebs/signing/request", + ) is True + + +def test_agent_signals_verify_rejects_wrong_route(): + signer, envelope = signed_envelope(route="/wrong") + client = mock({"agent": mock({"pre": "agent-aid", "verfer": signer.verfer})}) + + assert AgentSignals(client).verifyReplyEnvelope( + envelope, + route="/didwebs/signing/request", + ) is False + + +def test_agent_signals_verify_rejects_wrong_agent_payload(): + signer, envelope = signed_envelope(agent="wrong-agent") + client = mock({"agent": mock({"pre": "agent-aid", "verfer": signer.verfer})}) + + assert AgentSignals(client).verifyReplyEnvelope(envelope) is False + + +def test_agent_signals_verify_rejects_missing_signatures(): + signer, envelope = signed_envelope() + envelope["sigs"] = [] + client = mock({"agent": mock({"pre": "agent-aid", "verfer": signer.verfer})}) + + assert AgentSignals(client).verifyReplyEnvelope(envelope) is False + + +def test_agent_signals_verify_rejects_bad_signature(): + signer, envelope = signed_envelope() + other = signing.Salter(raw=b"fedcba9876543210").signer() + client = mock({"agent": mock({"pre": "agent-aid", "verfer": other.verfer})}) + + assert AgentSignals(client).verifyReplyEnvelope(envelope) is False + + +def test_agent_signals_verify_requires_connected_agent(): + _, envelope = signed_envelope() + client = mock({"agent": None}) + + with pytest.raises(kering.ConfigurationError, match="client must be connected"): + AgentSignals(client).verifyReplyEnvelope(envelope) diff --git a/tests/core/test_authing.py b/tests/core/test_authing.py index 8197a3e..bdb41cf 100644 --- a/tests/core/test_authing.py +++ b/tests/core/test_authing.py @@ -45,6 +45,25 @@ def test_verify(): strict=True) authn.verify(rep=mock_rep) + + seen = {} + + def capture_path(_headers, _method, path): + seen["path"] = path + return True + + authn.verifysig = capture_path + mock_request = mock({'method': 'GET', + 'url': 'http://example.com/identifiers/name/registries/did:webs_designated_aliases:Eaid', + 'headers': {}, + 'body': "a body for len"}, + spec=requests.Request, strict=True) + mock_rep = mock({'request': mock_request, 'headers': {"SIGNIFY-RESOURCE": 'EEz01234'}}, spec=requests.Response, + strict=True) + + authn.verify(rep=mock_rep) + assert seen["path"] == "/identifiers/name/registries/did%3Awebs_designated_aliases%3AEaid" + verifyNoUnwantedInteractions() unstub() diff --git a/tests/integration/_services/witness_server.py b/tests/integration/_services/witness_server.py index 902e6b1..d6a1fac 100644 --- a/tests/integration/_services/witness_server.py +++ b/tests/integration/_services/witness_server.py @@ -46,7 +46,7 @@ def create_loopback_http_server( indirecting.createHttpServer = create_loopback_http_server class NoopQueryEnd: - def __init__(self, hab): + def __init__(self, hab, **_kwa): self.hab = hab def on_get(self, req, rep):