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.
- aweb_naapp_testing-0.2.0/.gitignore +16 -0
- aweb_naapp_testing-0.2.0/PKG-INFO +13 -0
- aweb_naapp_testing-0.2.0/pyproject.toml +28 -0
- aweb_naapp_testing-0.2.0/src/aweb_naapp_testing/__init__.py +267 -0
- aweb_naapp_testing-0.2.0/src/aweb_naapp_testing/databases.py +27 -0
- aweb_naapp_testing-0.2.0/src/aweb_naapp_testing/network.py +293 -0
- aweb_naapp_testing-0.2.0/src/aweb_naapp_testing/owner_acts.py +104 -0
- aweb_naapp_testing-0.2.0/src/aweb_naapp_testing/smtp.py +150 -0
- aweb_naapp_testing-0.2.0/src/aweb_naapp_testing/webauthn.py +166 -0
|
@@ -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
|