aweb-naapp-testing 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ .coverage
8
+ .venv/
9
+ dist/
10
+ build/
11
+ .DS_Store
12
+
13
+ # Editor lock/autosave files
14
+ .#*
15
+ \#*\#
16
+ *~
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: aweb-naapp-testing
3
+ Version: 0.2.0
4
+ Summary: Test support for naapps on aweb-naapp: real AWID routes on PostgreSQL, a team-auth v2 signer, a software WebAuthn authenticator, a team owner-act signer and a real local SMTP server. Dev-only; never a production dependency.
5
+ Author-email: "aweb.ai" <hello@aweb.ai>
6
+ License: MIT
7
+ Requires-Python: >=3.12
8
+ Requires-Dist: aiosmtpd>=1.4.6
9
+ Requires-Dist: aweb-naapp[hooks,runtime]==0.2.0
10
+ Requires-Dist: awid-service>=0.5.30
11
+ Requires-Dist: cbor2>=5.6.0
12
+ Requires-Dist: cryptography>=46.0.0
13
+ Requires-Dist: dnspython>=2.7.0
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "aweb-naapp-testing"
3
+ version = "0.2.0"
4
+ description = "Test support for naapps on aweb-naapp: real AWID routes on PostgreSQL, a team-auth v2 signer, a software WebAuthn authenticator, a team owner-act signer and a real local SMTP server. Dev-only; never a production dependency."
5
+ license = { text = "MIT" }
6
+ authors = [{ name = "aweb.ai", email = "hello@aweb.ai" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "aiosmtpd>=1.4.6",
10
+ # Released in lockstep with aweb-naapp, from the same tag and at the same version.
11
+ "aweb-naapp[hooks,runtime]==0.2.0",
12
+ # The first awid release with the principal-attestation records the runtime reads.
13
+ "awid-service>=0.5.30",
14
+ "cbor2>=5.6.0",
15
+ "cryptography>=46.0.0",
16
+ "dnspython>=2.7.0",
17
+ ]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/aweb_naapp_testing"]
25
+
26
+ [tool.uv.sources]
27
+ aweb-naapp = { path = "..", editable = true }
28
+ awid-service = { path = "../../awid", editable = true }
@@ -0,0 +1,267 @@
1
+ """Test support for naapps: real AWID routes on PostgreSQL, and a signer for
2
+ team-auth v2 requests.
3
+
4
+ The verifier reaches AWID through an in-process ASGI transport. Faults, when a
5
+ test asks for them, are applied to a response only after the real AWID handler
6
+ has produced it, so the cache code, the database and the pagination stay real.
7
+
8
+ This is a dev-only distribution: it mints throwaway team keys and
9
+ certificates, and no production install depends on it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import hashlib
16
+ import json
17
+ from collections.abc import AsyncIterator, Callable
18
+ from contextlib import asynccontextmanager
19
+ from dataclasses import dataclass, field
20
+ from datetime import UTC, datetime, timedelta
21
+ from typing import Any
22
+
23
+ import httpx
24
+ from awid.attestation_records import ATTESTATION_HEADER, encode_attestation_header, sign_record
25
+ from awid.did import did_from_public_key
26
+ from awid.ratelimit import NoOpRateLimiter
27
+ from awid.registry import RegistryClient
28
+ from awid.signing import canonical_json_bytes, sign_message
29
+ from awid_service.db import AwidDatabaseInfra
30
+ from awid_service.routes.attestation_records import router as awid_attestation_records_router
31
+ from awid_service.routes.did import router as awid_did_router
32
+ from awid_service.routes.teams import router as awid_teams_router
33
+ from fastapi import FastAPI
34
+ from nacl.signing import SigningKey
35
+ from pgdbm import AsyncDatabaseManager, DatabaseConfig
36
+
37
+ REGISTRY_URL = "https://registry.example"
38
+ SERVICE_TOKEN = "runtime-test-service-token-32-bytes"
39
+
40
+ ResponseFault = Callable[[httpx.Request, httpx.Response], None]
41
+
42
+
43
+ class RecordingTransport(httpx.AsyncBaseTransport):
44
+ """Delivers every request to real AWID, records it, then applies `fault`."""
45
+
46
+ def __init__(self, app: FastAPI) -> None:
47
+ self._inner = httpx.ASGITransport(app=app)
48
+ self.calls: list[httpx.Request] = []
49
+ self.fault: ResponseFault | None = None
50
+
51
+ async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
52
+ self.calls.append(request)
53
+ response = await self._inner.handle_async_request(request)
54
+ if self.fault is None:
55
+ return response
56
+ await response.aread()
57
+ self.fault(request, response)
58
+ return response
59
+
60
+
61
+ @dataclass
62
+ class Registry:
63
+ db: Any
64
+ transport: RecordingTransport
65
+
66
+ async def add_team(self, team_id: str, team_did_key: str, *, visibility: str = "public") -> None:
67
+ name, domain = team_id.split(":", 1)
68
+ await self.db.execute(
69
+ "INSERT INTO {{tables.teams}} (domain, name, team_did_key, visibility) VALUES ($1, $2, $3, $4)",
70
+ domain, name, team_did_key, visibility,
71
+ )
72
+
73
+ async def add_certificate(self, cert: dict[str, Any]) -> None:
74
+ name, domain = cert["team_id"].split(":", 1)
75
+ await self.db.execute(
76
+ "INSERT INTO {{tables.team_certificates}} "
77
+ "(team_uuid, certificate_id, member_did_key, alias, issued_at) "
78
+ "SELECT team_uuid, $3, $4, $5, now() FROM {{tables.teams}} WHERE domain = $1 AND name = $2",
79
+ domain, name, cert["certificate_id"], cert["member_did_key"], cert["alias"],
80
+ )
81
+
82
+ async def revoke(self, certificate_id: str) -> None:
83
+ await self.db.execute(
84
+ "UPDATE {{tables.team_certificates}} SET revoked_at = now() WHERE certificate_id = $1",
85
+ certificate_id,
86
+ )
87
+
88
+ def _client(self) -> RegistryClient:
89
+ return RegistryClient(registry_url=REGISTRY_URL, transport=self.transport)
90
+
91
+ async def register_did(self, key: SigningKey) -> str:
92
+ """Registers `key` as a new did:aw through AWID's own route; returns the did:aw."""
93
+ mapping = await self._client().register_did(did_key(key), bytes(key))
94
+ return mapping.did_aw
95
+
96
+ async def rotate_did(self, did_aw: str, old: SigningKey, new: SigningKey) -> None:
97
+ await self._client().rotate_key(did_aw, did_key(new), bytes(old), bytes(new))
98
+
99
+ async def add_custody(self, member: Member, *, custodian_id: str, issuer: SigningKey) -> None:
100
+ """Publish team-signed records: ``custodian_id`` attests with ``issuer``,
101
+ and holds ``member``'s certificate."""
102
+ name, domain = member.team_id.split(":", 1)
103
+ issued_at = datetime.now(UTC).replace(microsecond=0)
104
+ common = {"v": 1, "team_id": member.team_id, "team_did_key": member.team_did_key,
105
+ "custodian_id": custodian_id, "issued_at": utc_timestamp(issued_at)}
106
+ custodian = {**common, "type": "aweb.team-custodian", "issuer_did_keys": [did_key(issuer)]}
107
+ custody = {**common, "type": "aweb.member-custody", "certificate_id": member.certificate_id,
108
+ "member_did_key": member.did_key}
109
+ for table, key_column, record, extra in (
110
+ ("team_custodians", "custodian_id", custodian, {}),
111
+ ("member_custody", "certificate_id", custody, {"custodian_id": custodian_id}),
112
+ ):
113
+ columns = "".join(f", {column}" for column in extra)
114
+ values = "".join(f", ${8 + i}" for i in range(len(extra)))
115
+ await self.db.execute(
116
+ f"INSERT INTO {{{{tables.{table}}}}} "
117
+ f"(team_uuid, {key_column}, record_json, signature, team_did_key, issued_at{columns}) "
118
+ f"SELECT team_uuid, $1, $2, $3, $4, $5::timestamptz{values} FROM {{{{tables.teams}}}} "
119
+ "WHERE domain = $6 AND name = $7",
120
+ record[key_column], canonical_json_bytes(record).decode(),
121
+ sign_record(bytes(member.team_key), record), member.team_did_key,
122
+ issued_at, domain, name, *extra.values(),
123
+ )
124
+
125
+ async def rotate_team_key(self, team_id: str, team_did_key: str) -> None:
126
+ name, domain = team_id.split(":", 1)
127
+ await self.db.execute(
128
+ "UPDATE {{tables.teams}} SET team_did_key = $3 WHERE domain = $1 AND name = $2",
129
+ domain, name, team_did_key,
130
+ )
131
+
132
+
133
+ @asynccontextmanager
134
+ async def awid_registry(database_url: str) -> AsyncIterator[Registry]:
135
+ """Real AWID, with its migrations applied, on the empty database at
136
+ ``database_url``, reachable through ``Registry.transport``."""
137
+ pool = await AsyncDatabaseManager.create_shared_pool(
138
+ DatabaseConfig(connection_string=database_url, schema=None)
139
+ )
140
+ infra = AwidDatabaseInfra(schema="awid")
141
+ try:
142
+ await infra.initialize(shared_pool=pool, run_migrations=True)
143
+ app = FastAPI()
144
+ app.state.db = infra
145
+ app.state.rate_limiter = NoOpRateLimiter()
146
+ app.state.awid_service_token = SERVICE_TOKEN
147
+ app.include_router(awid_teams_router)
148
+ app.include_router(awid_attestation_records_router)
149
+ app.include_router(awid_did_router)
150
+ yield Registry(db=infra.get_manager("aweb"), transport=RecordingTransport(app))
151
+ finally:
152
+ await infra.close()
153
+ await pool.close()
154
+
155
+
156
+ def did_key(key: SigningKey) -> str:
157
+ return did_from_public_key(bytes(key.verify_key))
158
+
159
+
160
+ def utc_timestamp(moment: datetime) -> str:
161
+ return moment.astimezone(UTC).isoformat().replace("+00:00", "Z")
162
+
163
+
164
+ @dataclass
165
+ class Member:
166
+ """A team key and one member certificate signed by it."""
167
+
168
+ team_id: str
169
+ team_key: SigningKey = field(default_factory=SigningKey.generate)
170
+ member_key: SigningKey = field(default_factory=SigningKey.generate)
171
+ certificate_id: str = "cert-1"
172
+ alias: str = "alice"
173
+ extra_cert_fields: dict[str, Any] = field(default_factory=dict)
174
+
175
+ @property
176
+ def team_did_key(self) -> str:
177
+ return did_key(self.team_key)
178
+
179
+ @property
180
+ def did_key(self) -> str:
181
+ return did_key(self.member_key)
182
+
183
+ def certificate(self) -> dict[str, Any]:
184
+ cert = {
185
+ "version": 1,
186
+ "certificate_id": self.certificate_id,
187
+ "team_id": self.team_id,
188
+ "team_did_key": self.team_did_key,
189
+ "member_did_key": self.did_key,
190
+ "alias": self.alias,
191
+ "lifetime": "persistent",
192
+ "issued_at": "2026-01-01T00:00:00Z",
193
+ **self.extra_cert_fields,
194
+ }
195
+ cert["signature"] = sign_message(bytes(self.team_key), canonical_json_bytes(cert))
196
+ return cert
197
+
198
+ def headers(
199
+ self,
200
+ *,
201
+ aud: str,
202
+ method: str,
203
+ path: str,
204
+ body: bytes = b"",
205
+ timestamp: str | None = None,
206
+ payload_changes: dict[str, Any] | None = None,
207
+ cert: dict[str, Any] | None = None,
208
+ ) -> dict[str, str]:
209
+ timestamp = timestamp or utc_timestamp(datetime.now(UTC))
210
+ payload: dict[str, Any] = {
211
+ "v": 2,
212
+ "aud": aud,
213
+ "method": method,
214
+ "path": path,
215
+ "team_id": self.team_id,
216
+ "timestamp": timestamp,
217
+ "body_sha256": hashlib.sha256(body).hexdigest(),
218
+ }
219
+ for name, value in (payload_changes or {}).items():
220
+ if value is None:
221
+ payload.pop(name, None)
222
+ else:
223
+ payload[name] = value
224
+ canonical = canonical_json_bytes(payload)
225
+ return envelope_headers(
226
+ did_key=self.did_key,
227
+ signature=sign_message(bytes(self.member_key), canonical),
228
+ timestamp=timestamp,
229
+ signed_payload=canonical,
230
+ cert=cert if cert is not None else self.certificate(),
231
+ )
232
+
233
+
234
+ def attested(
235
+ member: Member, headers: dict[str, str], *, issuer: SigningKey, custodian_id: str, channel: str
236
+ ) -> dict[str, str]:
237
+ """``headers`` plus a principal attestation by ``issuer``, bound to their signed payload."""
238
+ raw = headers["X-AWEB-Signed-Payload"]
239
+ signed_payload = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
240
+ now = datetime.now(UTC).replace(microsecond=0)
241
+ payload = {
242
+ "v": 1,
243
+ "type": "aweb.principal-attestation",
244
+ "team_id": member.team_id,
245
+ "certificate_id": member.certificate_id,
246
+ "member_did_key": member.did_key,
247
+ "custody": "custodial",
248
+ "custodian_id": custodian_id,
249
+ "issuer_did_key": did_key(issuer),
250
+ "channel": channel,
251
+ "session_id": "session-1",
252
+ "iat": utc_timestamp(now),
253
+ "exp": utc_timestamp(now + timedelta(seconds=120)),
254
+ "request_binding": hashlib.sha256(signed_payload).hexdigest(),
255
+ }
256
+ return {**headers, ATTESTATION_HEADER: encode_attestation_header(bytes(issuer), payload)}
257
+
258
+
259
+ def envelope_headers(
260
+ *, did_key: str, signature: str, timestamp: str, signed_payload: bytes, cert: dict[str, Any]
261
+ ) -> dict[str, str]:
262
+ return {
263
+ "Authorization": f"DIDKey {did_key} {signature}",
264
+ "X-AWEB-Timestamp": timestamp,
265
+ "X-AWEB-Signed-Payload": base64.urlsafe_b64encode(signed_payload).rstrip(b"=").decode(),
266
+ "X-AWID-Team-Certificate": base64.b64encode(json.dumps(cert).encode()).decode(),
267
+ }
@@ -0,0 +1,27 @@
1
+ """Test databases that give each naapp test its own schema.
2
+
3
+ A naapp runs in its own schema: the candidate gate's role carries production's
4
+ search_path=pg_catalog, where an unqualified table resolves to no user schema
5
+ at all. With a schema configured, pgdbm pins the search_path to it on every
6
+ connection.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from pgdbm import AsyncDatabaseManager
14
+
15
+ TEST_SCHEMA = "naapp_test"
16
+
17
+
18
+ class SchemaDatabases:
19
+ """pgdbm's test database factory, giving every database a schema unless the test names one."""
20
+
21
+ def __init__(self, factory: Any) -> None:
22
+ self._factory = factory
23
+
24
+ async def create_db(
25
+ self, suffix: str | None = None, schema: str | None = None, **kwargs: Any
26
+ ) -> AsyncDatabaseManager:
27
+ return await self._factory.create_db(suffix, schema or TEST_SCHEMA, **kwargs)
@@ -0,0 +1,293 @@
1
+ """Real local DNS and TLS servers for network tests through the egress guard.
2
+
3
+ The DNS server answers A and AAAA queries from a table the test controls and
4
+ counts every query, so a test can change an answer between two lookups. The
5
+ TLS server presents a certificate from a throwaway CA, records the SNI name,
6
+ request line and headers of every request, and serves a few fixed paths, or
7
+ whatever its ``respond`` callback answers.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import datetime
14
+ import gzip
15
+ import ipaddress
16
+ import json
17
+ import socket
18
+ import ssl
19
+ from collections import Counter
20
+ from collections.abc import Awaitable, Callable
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+
24
+ import dns.flags
25
+ import dns.message
26
+ import dns.rcode
27
+ import dns.rdataclass
28
+ import dns.rdatatype
29
+ import dns.rrset
30
+ from cryptography import x509
31
+ from cryptography.hazmat.primitives import hashes, serialization
32
+ from cryptography.hazmat.primitives.asymmetric import ec
33
+ from cryptography.x509.oid import NameOID
34
+
35
+ Answer = list[str] | Callable[[int], list[str]]
36
+
37
+
38
+ def local_address() -> str:
39
+ """This host's own non-loopback IPv4 address.
40
+
41
+ The guard refuses loopback for every destination, internal ones included,
42
+ so the TLS server listens on one of the host's own addresses: the one its
43
+ outbound route uses, or, on a network with no route out (the gate's
44
+ isolated runner), one its own name resolves to locally. Connecting a UDP
45
+ socket sends nothing; it only picks the route.
46
+ """
47
+ for address in _route_address(), *_named_addresses():
48
+ if address is not None and not ipaddress.ip_address(address).is_loopback:
49
+ return address
50
+ raise RuntimeError("the egress network tests need a non-loopback IPv4 address on this host")
51
+
52
+
53
+ def _route_address() -> str | None:
54
+ probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
55
+ try:
56
+ probe.connect(("192.0.2.1", 9))
57
+ return str(probe.getsockname()[0])
58
+ except OSError:
59
+ return None
60
+ finally:
61
+ probe.close()
62
+
63
+
64
+ def _named_addresses() -> list[str]:
65
+ try:
66
+ infos = socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET)
67
+ except OSError:
68
+ return []
69
+ return [str(info[4][0]) for info in infos]
70
+
71
+
72
+ @dataclass
73
+ class DnsServer:
74
+ """Answers ``records[(name, "A"|"AAAA")]``; a callable gets the query count."""
75
+
76
+ records: dict[tuple[str, str], Answer] = field(default_factory=dict)
77
+ queries: Counter[tuple[str, str]] = field(default_factory=Counter)
78
+ # Seconds to wait before each answer, as a slow resolver would.
79
+ delay: float = 0.0
80
+ port: int = 0
81
+ _transport: asyncio.DatagramTransport | None = None
82
+
83
+ async def start(self) -> None:
84
+ loop = asyncio.get_running_loop()
85
+ server = self
86
+
87
+ class Protocol(asyncio.DatagramProtocol):
88
+ def connection_made(self, transport: asyncio.BaseTransport) -> None:
89
+ self.transport = transport
90
+
91
+ def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
92
+ answer = server._answer(data)
93
+ send = self.transport.sendto # type: ignore[attr-defined]
94
+ if server.delay:
95
+ loop.call_later(server.delay, send, answer, addr)
96
+ else:
97
+ send(answer, addr)
98
+
99
+ transport, _ = await loop.create_datagram_endpoint(Protocol, local_addr=("127.0.0.1", 0))
100
+ self._transport = transport # type: ignore[assignment]
101
+ self.port = transport.get_extra_info("sockname")[1]
102
+
103
+ def close(self) -> None:
104
+ if self._transport is not None:
105
+ self._transport.close()
106
+
107
+ def _answer(self, wire: bytes) -> bytes:
108
+ query = dns.message.from_wire(wire)
109
+ response = dns.message.make_response(query)
110
+ response.flags |= dns.flags.AA
111
+ question = query.question[0]
112
+ name = question.name.to_text(omit_final_dot=True).lower()
113
+ kind = dns.rdatatype.to_text(question.rdtype)
114
+ self.queries[(name, kind)] += 1
115
+ if not any(key[0] == name for key in self.records):
116
+ response.set_rcode(dns.rcode.NXDOMAIN)
117
+ return response.to_wire()
118
+ answer = self.records.get((name, kind), [])
119
+ values = answer(self.queries[(name, kind)]) if callable(answer) else answer
120
+ if values:
121
+ response.answer.append(
122
+ dns.rrset.from_text_list(question.name, 60, dns.rdataclass.IN, kind, values)
123
+ )
124
+ return response.to_wire()
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Certificates:
129
+ ca_file: Path
130
+ cert_file: Path
131
+ key_file: Path
132
+
133
+
134
+ def make_certificates(directory: Path, names: list[str], addresses: list[str]) -> Certificates:
135
+ """A throwaway CA and a server certificate for ``names`` and ``addresses``."""
136
+ now = datetime.datetime.now(datetime.UTC)
137
+ ca_key = ec.generate_private_key(ec.SECP256R1())
138
+ ca_name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "egress test CA")])
139
+ ca = (
140
+ x509.CertificateBuilder()
141
+ .subject_name(ca_name)
142
+ .issuer_name(ca_name)
143
+ .public_key(ca_key.public_key())
144
+ .serial_number(x509.random_serial_number())
145
+ .not_valid_before(now - datetime.timedelta(minutes=5))
146
+ .not_valid_after(now + datetime.timedelta(hours=1))
147
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
148
+ .add_extension(
149
+ x509.KeyUsage(
150
+ digital_signature=True,
151
+ content_commitment=False,
152
+ key_encipherment=False,
153
+ data_encipherment=False,
154
+ key_agreement=False,
155
+ key_cert_sign=True,
156
+ crl_sign=True,
157
+ encipher_only=False,
158
+ decipher_only=False,
159
+ ),
160
+ critical=True,
161
+ )
162
+ .add_extension(
163
+ x509.SubjectKeyIdentifier.from_public_key(ca_key.public_key()), critical=False
164
+ )
165
+ .sign(ca_key, hashes.SHA256())
166
+ )
167
+ key = ec.generate_private_key(ec.SECP256R1())
168
+ alt_names: list[x509.GeneralName] = [x509.DNSName(name) for name in names]
169
+ alt_names += [x509.IPAddress(ipaddress.ip_address(address)) for address in addresses]
170
+ cert = (
171
+ x509.CertificateBuilder()
172
+ .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, names[0])]))
173
+ .issuer_name(ca_name)
174
+ .public_key(key.public_key())
175
+ .serial_number(x509.random_serial_number())
176
+ .not_valid_before(now - datetime.timedelta(minutes=5))
177
+ .not_valid_after(now + datetime.timedelta(hours=1))
178
+ .add_extension(x509.SubjectAlternativeName(alt_names), critical=False)
179
+ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
180
+ .add_extension(
181
+ x509.ExtendedKeyUsage([x509.oid.ExtendedKeyUsageOID.SERVER_AUTH]), critical=False
182
+ )
183
+ .add_extension(
184
+ x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()),
185
+ critical=False,
186
+ )
187
+ .sign(ca_key, hashes.SHA256())
188
+ )
189
+ paths = Certificates(directory / "ca.pem", directory / "server.pem", directory / "server.key")
190
+ paths.ca_file.write_bytes(ca.public_bytes(serialization.Encoding.PEM))
191
+ paths.cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
192
+ paths.key_file.write_bytes(
193
+ key.private_bytes(
194
+ serialization.Encoding.PEM,
195
+ serialization.PrivateFormat.PKCS8,
196
+ serialization.NoEncryption(),
197
+ )
198
+ )
199
+ return paths
200
+
201
+
202
+ @dataclass
203
+ class Seen:
204
+ sni: str | None
205
+ request_line: str
206
+ headers: list[tuple[str, str]]
207
+ body: bytes
208
+
209
+
210
+ Reply = tuple[int, bytes, list[tuple[str, str]]]
211
+
212
+
213
+ @dataclass
214
+ class TlsServer:
215
+ """An HTTPS/1.1 server on ``host`` with a few fixed paths.
216
+
217
+ ``/ok`` answers JSON, ``/redirect`` answers 302 to another host,
218
+ ``/big?n=`` answers ``n`` bytes, ``/slow`` never answers, ``/encoded``
219
+ answers a gzip-encoded body, and ``/echo`` returns the request it saw as
220
+ JSON. When ``respond`` is set it answers every request instead, with a
221
+ status, a body and headers.
222
+ """
223
+
224
+ certificates: Certificates
225
+ host: str
226
+ seen: list[Seen] = field(default_factory=list)
227
+ respond: Callable[[Seen], Awaitable[Reply]] | None = None
228
+ port: int = 0
229
+ _server: asyncio.Server | None = None
230
+
231
+ async def start(self) -> None:
232
+ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
233
+ context.load_cert_chain(self.certificates.cert_file, self.certificates.key_file)
234
+ sni: dict[int, str | None] = {}
235
+
236
+ def remember(sock: ssl.SSLObject, name: str | None, _: ssl.SSLContext) -> None:
237
+ sni[id(sock)] = name
238
+
239
+ context.sni_callback = remember # type: ignore[assignment]
240
+
241
+ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
242
+ try:
243
+ await self._handle(reader, writer, sni.get(id(writer.get_extra_info("ssl_object"))))
244
+ except (ConnectionError, asyncio.IncompleteReadError, ssl.SSLError):
245
+ pass
246
+ finally:
247
+ writer.close()
248
+
249
+ self._server = await asyncio.start_server(handle, self.host, 0, ssl=context)
250
+ self.port = self._server.sockets[0].getsockname()[1]
251
+
252
+ async def close(self) -> None:
253
+ if self._server is not None:
254
+ self._server.close()
255
+ await self._server.wait_closed()
256
+
257
+ async def _handle(
258
+ self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, sni: str | None
259
+ ) -> None:
260
+ head = await reader.readuntil(b"\r\n\r\n")
261
+ lines = head.decode("latin-1").split("\r\n")
262
+ headers = [tuple(line.split(": ", 1)) for line in lines[1:] if line]
263
+ length = next((int(v) for k, v in headers if k.lower() == "content-length"), 0)
264
+ body = await reader.readexactly(length) if length else b""
265
+ seen = Seen(sni, lines[0], [(k, v) for k, v in headers], body)
266
+ self.seen.append(seen)
267
+ if self.respond is not None:
268
+ status, reply, reply_headers = await self.respond(seen)
269
+ await _respond(writer, status, reply, reply_headers)
270
+ return
271
+ path = lines[0].split(" ")[1]
272
+ if path == "/slow":
273
+ await asyncio.sleep(3600)
274
+ if path == "/redirect":
275
+ await _respond(writer, 302, b"", [("Location", "https://elsewhere.example/next")])
276
+ elif path.startswith("/big?n="):
277
+ await _respond(writer, 200, b"x" * int(path.split("=")[1]), [])
278
+ elif path == "/encoded":
279
+ await _respond(writer, 200, gzip.compress(b"hidden"), [("Content-Encoding", "gzip")])
280
+ elif path == "/echo":
281
+ echoed = {"request_line": seen.request_line, "headers": seen.headers, "sni": sni}
282
+ await _respond(writer, 200, json.dumps(echoed).encode(), [])
283
+ else:
284
+ await _respond(writer, 200, b'{"ok": true}', [("Content-Type", "application/json")])
285
+
286
+
287
+ async def _respond(
288
+ writer: asyncio.StreamWriter, status: int, body: bytes, headers: list[tuple[str, str]]
289
+ ) -> None:
290
+ head = [f"HTTP/1.1 {status} X", f"Content-Length: {len(body)}", "Connection: close"]
291
+ head += [f"{name}: {value}" for name, value in headers]
292
+ writer.write(("\r\n".join(head) + "\r\n\r\n").encode("latin-1") + body)
293
+ await writer.drain()
@@ -0,0 +1,104 @@
1
+ """Real team owner acts for tests.
2
+
3
+ The team key is registered in the tests' AWID registry, and every act is a
4
+ signed ``aweb.owner-act/1`` verified by the real K2 verifier, its nonce
5
+ consumed in the transaction that applies the act's effect.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import secrets
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime, timedelta
14
+ from typing import Any
15
+
16
+ from nacl.signing import SigningKey
17
+ from pgdbm.core import TransactionManager
18
+
19
+ from aweb_naapp.hooks.owner_act import APP_ACTS, PREFIX, VerifiedOwnerAct
20
+ from aweb_naapp.hooks.owner_act_nonces import OwnerActVerifier, PreparedOwnerAct
21
+ from aweb_naapp.hooks.strictjson import canonical
22
+ from aweb_naapp.runtime.passkeys import contact_sha256
23
+ from aweb_naapp.runtime.team_auth import AWIDTeamCache
24
+ from aweb_naapp_testing import REGISTRY_URL, SERVICE_TOKEN, did_key
25
+
26
+ TEAM_ID = "demo:example.com"
27
+ AUDIENCE = "https://app.example.com"
28
+ APP_ID = "example"
29
+ INSTALLATION_ID = "1b4e28ba-2fa1-4d3b-a3f5-ef19b5a7633b"
30
+
31
+
32
+ @dataclass
33
+ class TeamOwner:
34
+ """The team's owner: signs acts with the team key the registry knows."""
35
+
36
+ team_key: SigningKey
37
+ verifier: OwnerActVerifier
38
+ team_id: str = TEAM_ID
39
+
40
+ def sign(self, act: str, subject: dict[str, Any]) -> tuple[bytes, str]:
41
+ issued = datetime.now(UTC).replace(microsecond=0)
42
+ fields = {
43
+ "v": 1,
44
+ "type": "aweb.owner-act",
45
+ "act": act,
46
+ "team_id": self.team_id,
47
+ "aud": AUDIENCE,
48
+ "team_key_did": did_key(self.team_key),
49
+ "subject": subject,
50
+ "issued_at": issued.strftime("%Y-%m-%dT%H:%M:%SZ"),
51
+ "expires_at": (issued + timedelta(minutes=5)).strftime("%Y-%m-%dT%H:%M:%SZ"),
52
+ "nonce": secrets.token_hex(16),
53
+ }
54
+ signing = PREFIX + canonical(fields)
55
+ signature = base64.b64encode(self.team_key.sign(signing).signature).decode().rstrip("=")
56
+ return signing, signature
57
+
58
+ async def prepare(self, act: str, subject: dict[str, Any]) -> PreparedOwnerAct:
59
+ signing, signature = self.sign(act, subject)
60
+ return await self.verifier.prepare(signing, signature, team_id=self.team_id)
61
+
62
+ async def consume(
63
+ self, tx: TransactionManager, prepared: PreparedOwnerAct, subject: dict[str, Any]
64
+ ) -> VerifiedOwnerAct:
65
+ return await self.verifier.consume_in(tx, prepared, expected_subject=subject)
66
+
67
+
68
+ def designation_subject(scope: str, contact: str) -> dict[str, Any]:
69
+ """The subject of an act that designates, or recovers, ``scope``'s approver."""
70
+ return {
71
+ "app_id": APP_ID,
72
+ "installation_id": INSTALLATION_ID,
73
+ "role": scope,
74
+ "contact_sha256": contact_sha256(contact),
75
+ }
76
+
77
+
78
+ def revocation_subject(scope: str, subject_ref: str) -> dict[str, Any]:
79
+ return {
80
+ "app_id": APP_ID,
81
+ "installation_id": INSTALLATION_ID,
82
+ "role": scope,
83
+ "subject_ref": subject_ref,
84
+ }
85
+
86
+
87
+ async def team_owner(registry, *, team_id: str = TEAM_ID) -> TeamOwner:
88
+ """Register a real owner key for the team the app fixture actually uses."""
89
+ key = SigningKey.generate()
90
+ await registry.add_team(team_id, did_key(key))
91
+ cache = AWIDTeamCache(
92
+ registry_url=REGISTRY_URL,
93
+ ttl_seconds=0,
94
+ service_token=SERVICE_TOKEN,
95
+ transport=registry.transport,
96
+ )
97
+
98
+ async def current_team_key(team_id: str) -> str:
99
+ return (await cache.get(team_id)).team_did_key
100
+
101
+ verifier = OwnerActVerifier(
102
+ scope=APP_ID, audience=AUDIENCE, acts=APP_ACTS, team_key_for=current_team_key
103
+ )
104
+ return TeamOwner(team_key=key, verifier=verifier, team_id=team_id)
@@ -0,0 +1,150 @@
1
+ """A real SMTP server on this host for tests (aiosmtpd), so mail is delivered
2
+ and refused by an actual server rather than a stand-in.
3
+
4
+ The server listens on 127.0.0.1 with a throwaway certificate for ``localhost``
5
+ (:func:`aweb_naapp_testing.network.make_certificates`). It offers STARTTLS,
6
+ implicit TLS, or neither, and accepts one username and password, only over
7
+ TLS. Recipients in ``refuse`` are rejected; every accepted message is kept in
8
+ ``received``. :class:`SilentServer` accepts connections and never answers, for
9
+ timeouts.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import socket
16
+ import ssl
17
+ import threading
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any, Literal
21
+
22
+ from aiosmtpd.controller import Controller
23
+ from aiosmtpd.smtp import AuthResult, LoginPassword
24
+
25
+ from aweb_naapp_testing.network import Certificates, make_certificates
26
+
27
+ Mode = Literal["starttls", "implicit", "plain"]
28
+ # aiosmtpd 1.4.6 logs a deprecation of its own Session.login_data on every
29
+ # successful AUTH, even through the current authenticator API.
30
+ logging.getLogger("mail.log").setLevel(logging.ERROR)
31
+ HOST = "localhost"
32
+ ADDRESS = "127.0.0.1"
33
+
34
+
35
+ def _free_port() -> int:
36
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
37
+ probe.bind((ADDRESS, 0))
38
+ return int(probe.getsockname()[1])
39
+
40
+
41
+ @dataclass
42
+ class Received:
43
+ mail_from: str
44
+ rcpt_tos: list[str]
45
+ content: bytes
46
+
47
+
48
+ @dataclass
49
+ class SmtpServer:
50
+ """A running SMTP server; use it as a context manager."""
51
+
52
+ directory: Path
53
+ mode: Mode = "starttls"
54
+ username: str = "mailer"
55
+ password: str = "smtp-test-password"
56
+ refuse: set[str] = field(default_factory=set)
57
+ received: list[Received] = field(default_factory=list)
58
+ port: int = 0
59
+ certificates: Certificates | None = None
60
+ _controller: Controller | None = None
61
+
62
+ def __enter__(self) -> SmtpServer:
63
+ self.directory.mkdir(parents=True, exist_ok=True)
64
+ self.certificates = make_certificates(self.directory, [HOST], [ADDRESS])
65
+ context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
66
+ context.load_cert_chain(self.certificates.cert_file, self.certificates.key_file)
67
+ self.port = _free_port()
68
+ options: dict[str, Any] = dict(
69
+ hostname=ADDRESS,
70
+ port=self.port,
71
+ server_hostname=HOST,
72
+ authenticator=self._authenticate,
73
+ # aiosmtpd counts only STARTTLS as TLS, so an implicit-TLS session
74
+ # would be refused AUTH if it were required here.
75
+ auth_require_tls=self.mode == "starttls",
76
+ )
77
+ if self.mode == "starttls":
78
+ options.update(tls_context=context, require_starttls=True)
79
+ elif self.mode == "implicit":
80
+ options.update(ssl_context=context)
81
+ self._controller = Controller(self, **options)
82
+ self._controller.start()
83
+ return self
84
+
85
+ def __exit__(self, *exc: object) -> None:
86
+ if self._controller is not None:
87
+ self._controller.stop()
88
+ self._controller = None
89
+
90
+ @property
91
+ def ca_file(self) -> Path:
92
+ assert self.certificates is not None
93
+ return self.certificates.ca_file
94
+
95
+ def _authenticate(
96
+ self, server: Any, session: Any, envelope: Any, mechanism: str, auth_data: Any
97
+ ) -> AuthResult:
98
+ ok = (
99
+ isinstance(auth_data, LoginPassword)
100
+ and auth_data.login.decode() == self.username
101
+ and auth_data.password.decode() == self.password
102
+ )
103
+ return AuthResult(success=ok, handled=False)
104
+
105
+ async def handle_RCPT(
106
+ self, server: Any, session: Any, envelope: Any, address: str, rcpt_options: list[str]
107
+ ) -> str:
108
+ if address in self.refuse:
109
+ return "550 5.1.1 recipient refused"
110
+ envelope.rcpt_tos.append(address)
111
+ return "250 OK"
112
+
113
+ async def handle_DATA(self, server: Any, session: Any, envelope: Any) -> str:
114
+ self.received.append(
115
+ Received(envelope.mail_from, list(envelope.rcpt_tos), bytes(envelope.original_content))
116
+ )
117
+ return "250 Message accepted"
118
+
119
+
120
+ class SilentServer:
121
+ """Accepts connections and never sends a greeting; use it as a context manager."""
122
+
123
+ def __init__(self) -> None:
124
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
125
+ self._socket.bind((ADDRESS, 0))
126
+ self.port = int(self._socket.getsockname()[1])
127
+ self._accepted: list[socket.socket] = []
128
+ self._stop = threading.Event()
129
+
130
+ def __enter__(self) -> SilentServer:
131
+ self._socket.listen()
132
+ self._socket.settimeout(0.1)
133
+ threading.Thread(target=self._serve, daemon=True).start()
134
+ return self
135
+
136
+ def _serve(self) -> None:
137
+ while not self._stop.is_set():
138
+ try:
139
+ connection, _ = self._socket.accept()
140
+ except TimeoutError:
141
+ continue
142
+ except OSError:
143
+ return
144
+ self._accepted.append(connection)
145
+
146
+ def __exit__(self, *exc: object) -> None:
147
+ self._stop.set()
148
+ for connection in self._accepted:
149
+ connection.close()
150
+ self._socket.close()
@@ -0,0 +1,166 @@
1
+ """A software WebAuthn authenticator for tests.
2
+
3
+ It answers the server's real options with real ceremonies: a P-256 credential
4
+ key, CBOR authenticator data, a `packed` self-attestation and ES256 assertion
5
+ signatures over authenticator data and the client data hash, as a platform
6
+ authenticator and browser would produce them. The verifier under test checks
7
+ these with py_webauthn exactly as it checks a browser's.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import struct
16
+ from base64 import urlsafe_b64decode, urlsafe_b64encode
17
+ from dataclasses import dataclass
18
+
19
+ import cbor2
20
+ from cryptography.hazmat.primitives import hashes
21
+ from cryptography.hazmat.primitives.asymmetric import ec
22
+
23
+ UP, UV, AT = 0x01, 0x04, 0x40
24
+ ES256 = -7
25
+
26
+
27
+ def b64url(data: bytes) -> str:
28
+ return urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
29
+
30
+
31
+ def from_b64url(text: str) -> bytes:
32
+ return urlsafe_b64decode(text + "=" * (-len(text) % 4))
33
+
34
+
35
+ @dataclass
36
+ class SoftCredential:
37
+ credential_id: bytes
38
+ key: ec.EllipticCurvePrivateKey
39
+ rp_id: str
40
+ user_handle: bytes
41
+ sign_count: int = 0
42
+
43
+ def cose_public_key(self) -> bytes:
44
+ numbers = self.key.public_key().public_numbers()
45
+ return cbor2.dumps(
46
+ {
47
+ 1: 2,
48
+ 3: ES256,
49
+ -1: 1,
50
+ -2: numbers.x.to_bytes(32, "big"),
51
+ -3: numbers.y.to_bytes(32, "big"),
52
+ }
53
+ )
54
+
55
+
56
+ class SoftAuthenticator:
57
+ def __init__(self, origin: str) -> None:
58
+ self.origin = origin
59
+ self.credentials: dict[bytes, SoftCredential] = {}
60
+
61
+ def _client_data(self, kind: str, challenge: str, origin: str | None) -> bytes:
62
+ return json.dumps(
63
+ {
64
+ "type": kind,
65
+ "challenge": challenge,
66
+ "origin": origin or self.origin,
67
+ "crossOrigin": False,
68
+ }
69
+ ).encode()
70
+
71
+ def _sign(self, key: ec.EllipticCurvePrivateKey, auth_data: bytes, client_data: bytes) -> bytes:
72
+ return key.sign(auth_data + hashlib.sha256(client_data).digest(), ec.ECDSA(hashes.SHA256()))
73
+
74
+ def create(
75
+ self, options: dict, *, origin: str | None = None, user_verified: bool = True
76
+ ) -> dict:
77
+ """navigator.credentials.create() for the server's registration options."""
78
+ rp_id = options["rp"]["id"]
79
+ credential = SoftCredential(
80
+ credential_id=os.urandom(32),
81
+ key=ec.generate_private_key(ec.SECP256R1()),
82
+ rp_id=rp_id,
83
+ user_handle=from_b64url(options["user"]["id"]),
84
+ )
85
+ flags = UP | AT | (UV if user_verified else 0)
86
+ attested = (
87
+ bytes(16)
88
+ + struct.pack(">H", len(credential.credential_id))
89
+ + credential.credential_id
90
+ + credential.cose_public_key()
91
+ )
92
+ auth_data = (
93
+ hashlib.sha256(rp_id.encode()).digest()
94
+ + bytes([flags])
95
+ + struct.pack(">I", credential.sign_count)
96
+ + attested
97
+ )
98
+ client_data = self._client_data("webauthn.create", options["challenge"], origin)
99
+ attestation = cbor2.dumps(
100
+ {
101
+ "fmt": "packed",
102
+ "attStmt": {
103
+ "alg": ES256,
104
+ "sig": self._sign(credential.key, auth_data, client_data),
105
+ },
106
+ "authData": auth_data,
107
+ }
108
+ )
109
+ self.credentials[credential.credential_id] = credential
110
+ return {
111
+ "id": b64url(credential.credential_id),
112
+ "rawId": b64url(credential.credential_id),
113
+ "type": "public-key",
114
+ "response": {
115
+ "clientDataJSON": b64url(client_data),
116
+ "attestationObject": b64url(attestation),
117
+ "transports": ["internal"],
118
+ },
119
+ "clientExtensionResults": {},
120
+ "authenticatorAttachment": "platform",
121
+ }
122
+
123
+ def get(
124
+ self,
125
+ options: dict,
126
+ *,
127
+ origin: str | None = None,
128
+ user_verified: bool = True,
129
+ count_step: int = 1,
130
+ ) -> dict:
131
+ """navigator.credentials.get() for the server's authentication options."""
132
+ allowed = [c for c in self.credentials.values() if c.rp_id == options["rpId"]]
133
+ allow_ids = {item["id"] for item in options.get("allowCredentials", [])}
134
+ if allow_ids:
135
+ allowed = [c for c in allowed if b64url(c.credential_id) in allow_ids] or allowed
136
+ credential = allowed[0]
137
+ credential.sign_count += count_step
138
+ flags = UP | (UV if user_verified else 0)
139
+ auth_data = (
140
+ hashlib.sha256(credential.rp_id.encode()).digest()
141
+ + bytes([flags])
142
+ + struct.pack(">I", credential.sign_count)
143
+ )
144
+ client_data = self._client_data("webauthn.get", options["challenge"], origin)
145
+ return {
146
+ "id": b64url(credential.credential_id),
147
+ "rawId": b64url(credential.credential_id),
148
+ "type": "public-key",
149
+ "response": {
150
+ "clientDataJSON": b64url(client_data),
151
+ "authenticatorData": b64url(auth_data),
152
+ "signature": b64url(self._sign(credential.key, auth_data, client_data)),
153
+ "userHandle": b64url(credential.user_handle),
154
+ },
155
+ "clientExtensionResults": {},
156
+ "authenticatorAttachment": "platform",
157
+ }
158
+
159
+ def clone(self) -> SoftAuthenticator:
160
+ """A copy holding the same keys and counters, as a cloned device would."""
161
+ twin = SoftAuthenticator(self.origin)
162
+ twin.credentials = {
163
+ cid: SoftCredential(c.credential_id, c.key, c.rp_id, c.user_handle, c.sign_count)
164
+ for cid, c in self.credentials.items()
165
+ }
166
+ return twin