Add single-use registration cancellation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

This commit is contained in:
tegwick 2026-08-10 19:27:00 +02:00
parent 752d91cf5c
commit 98250e5838
3 changed files with 71 additions and 6 deletions

View file

@ -28,8 +28,11 @@ class SQLiteDeliveryStore:
request_id TEXT PRIMARY KEY, handle_hash TEXT UNIQUE NOT NULL,
registration_id TEXT NOT NULL, email TEXT NOT NULL, username TEXT NOT NULL,
client_id TEXT NOT NULL, tenant TEXT NOT NULL, display_name TEXT,
expires_at TEXT NOT NULL, consumed_at TEXT
expires_at TEXT NOT NULL, consumed_at TEXT, canceled_at TEXT
)""")
columns = {row[1] for row in self.db.execute("PRAGMA table_info(verifications)")}
if "canceled_at" not in columns:
self.db.execute("ALTER TABLE verifications ADD COLUMN canceled_at TEXT")
self.db.commit()
def reference(self, event_id: str) -> str | None:
@ -43,7 +46,10 @@ class SQLiteDeliveryStore:
def create_verification(self, payload: dict, handle_hash: str, expires_at: str) -> str:
request_id = f"vrq_{secrets.token_hex(12)}"
with self.db:
self.db.execute("INSERT INTO verifications VALUES (?,?,?,?,?,?,?,?,?,NULL)", (
self.db.execute("""INSERT INTO verifications
(request_id, handle_hash, registration_id, email, username,
client_id, tenant, display_name, expires_at, consumed_at, canceled_at)
VALUES (?,?,?,?,?,?,?,?,?,NULL,NULL)""", (
request_id, handle_hash, payload["registration_id"], payload["normalized_email"],
payload["preferred_username"], payload["client_id"], payload["tenant"],
payload.get("display_name"), expires_at,
@ -53,7 +59,7 @@ class SQLiteDeliveryStore:
def consume_verification(self, handle_hash: str) -> dict:
with self.lock, self.db:
row = self.db.execute("SELECT * FROM verifications WHERE handle_hash=?", (handle_hash,)).fetchone()
if row is None or row[9] is not None:
if row is None or row[9] is not None or row[10] is not None:
raise ValueError("verification_invalid")
if datetime.fromisoformat(row[8]) <= datetime.now(timezone.utc):
raise ValueError("verification_expired")
@ -62,6 +68,22 @@ class SQLiteDeliveryStore:
return {"request_id":row[0],"registration_id":row[2],"email":row[3],
"preferred_username":row[4],"client_id":row[5],"tenant":row[6],"display_name":row[7]}
def cancel_verification(self, handle_hash: str) -> dict:
with self.lock, self.db:
row = self.db.execute(
"SELECT * FROM verifications WHERE handle_hash=?", (handle_hash,)
).fetchone()
if row is None or row[9] is not None or row[10] is not None:
raise ValueError("verification_invalid")
if datetime.fromisoformat(row[8]) <= datetime.now(timezone.utc):
raise ValueError("verification_expired")
self.db.execute(
"UPDATE verifications SET canceled_at=? WHERE request_id=?",
(datetime.now(timezone.utc).isoformat(), row[0]),
)
return {"request_id":row[0],"registration_id":row[2],"email":row[3],
"preferred_username":row[4],"client_id":row[5],"tenant":row[6],"display_name":row[7]}
class SMTPProvider:
def __init__(self, host: str, port: int, username: str, password: str, sender: str) -> None:
@ -87,7 +109,7 @@ class TransactionalApplication:
if environ.get("PATH_INFO") in ("/healthz", "/readyz"):
return self._json(start_response, HTTPStatus.OK, {"status":"ok"})
path = environ.get("PATH_INFO")
if path not in ("/v1/send", "/v1/registration-verifications", "/v1/registration-verifications/consume") or environ.get("REQUEST_METHOD") != "POST":
if path not in ("/v1/send", "/v1/registration-verifications", "/v1/registration-verifications/consume", "/v1/registration-verifications/cancel") or environ.get("REQUEST_METHOD") != "POST":
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error":"not_found"})
if not hmac.compare_digest(str(environ.get("HTTP_AUTHORIZATION", "")), f"Bearer {self.token}"):
return self._json(start_response, HTTPStatus.UNAUTHORIZED, {"error":"unauthorized"})
@ -99,6 +121,8 @@ class TransactionalApplication:
return self._request_verification(start_response, payload)
if path == "/v1/registration-verifications/consume":
return self._consume_verification(start_response, payload)
if path == "/v1/registration-verifications/cancel":
return self._cancel_verification(start_response, payload)
event_id = str(payload["id"])
if environ.get("HTTP_IDEMPOTENCY_KEY") != event_id: raise ValueError("idempotency_key_mismatch")
if payload.get("source") != "user-engine" or payload.get("type") not in ALLOWED_EVENTS: raise ValueError("template_not_allowed")
@ -129,7 +153,9 @@ class TransactionalApplication:
)
self.provider.send(
str(payload["normalized_email"]), "Verify your NetKingdom registration",
f"Continue securely at {self.portal_url}/registration/verify?handle={handle}\nThis link expires in 30 minutes.\n",
f"Continue securely at {self.portal_url}/registration/verify?handle={handle}\n"
f"Cancel this request at {self.portal_url}/registration/cancel?handle={handle}\n"
"These links expire in 30 minutes.\n",
)
return self._json(start_response, HTTPStatus.ACCEPTED,
{"request_id":request_id,"accepted":True})
@ -145,6 +171,20 @@ class TransactionalApplication:
"assurance":{"mailbox_control":True},
})
def _cancel_verification(self, start_response, payload):
handle = str(payload.get("handle") or "")
if len(handle) < 32:
raise ValueError("verification_invalid")
evidence = self.store.cancel_verification(
hashlib.sha256(handle.encode()).hexdigest()
)
return self._json(start_response, HTTPStatus.OK, {
"purpose":"public-registration-cancel",
"verification_id":f"fvc_{secrets.token_hex(12)}",
**evidence, "source_system":"email-connect",
"assurance":{"mailbox_control":True},
})
@staticmethod
def _json(start_response, status, payload):
body=json.dumps(payload).encode(); start_response(f"{status.value} {status.phrase}", [("Content-Type","application/json"),("Content-Length",str(len(body)))])

View file

@ -36,3 +36,21 @@ def test_registration_verification_is_digest_only_and_single_use(tmp_path):
env["wsgi.input"]=io.BytesIO(consume)
replay=json.loads(b"".join(app(env,lambda s,h:result.update(status=s))))
assert replay["error"]=="verification_invalid"
def test_registration_cancellation_is_single_use_and_prevents_verification(tmp_path):
provider=Provider(); store=SQLiteDeliveryStore(str(tmp_path/"mail.db")); app=TransactionalApplication(store,provider,"opaque","https://users.example")
request={"registration_id":"reg-cancel","normalized_email":"person@example.test","preferred_username":"person","client_id":"coulomb-social","tenant":"tenant:coulomb","correlation_id":"corr-cancel"}
raw=json.dumps(request).encode(); result={}
app({"PATH_INFO":"/v1/registration-verifications","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(raw)),"wsgi.input":io.BytesIO(raw),"HTTP_AUTHORIZATION":"Bearer opaque"},lambda s,h:result.update(status=s))
text=provider.calls[0][2]; handle=text.split("handle=",1)[1].splitlines()[0]
assert f"/registration/cancel?handle={handle}" in text
payload=json.dumps({"handle":handle}).encode()
cancel_env={"PATH_INFO":"/v1/registration-verifications/cancel","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(payload)),"wsgi.input":io.BytesIO(payload),"HTTP_AUTHORIZATION":"Bearer opaque"}
canceled=json.loads(b"".join(app(cancel_env,lambda s,h:result.update(status=s))))
assert canceled["purpose"]=="public-registration-cancel"
consume_env={"PATH_INFO":"/v1/registration-verifications/consume","REQUEST_METHOD":"POST","CONTENT_LENGTH":str(len(payload)),"wsgi.input":io.BytesIO(payload),"HTTP_AUTHORIZATION":"Bearer opaque"}
rejected=json.loads(b"".join(app(consume_env,lambda s,h:result.update(status=s))))
assert rejected["error"]=="verification_invalid"
cancel_env["wsgi.input"]=io.BytesIO(payload)
replay=json.loads(b"".join(app(cancel_env,lambda s,h:result.update(status=s))))
assert replay["error"]=="verification_invalid"

View file

@ -8,7 +8,7 @@ status: active
owner: codex
topic_slug: netkingdom
created: "2026-08-08"
updated: "2026-08-08"
updated: "2026-08-10"
depends_on:
- NK-WP-0024
state_hub_workstream_id: "a37e5e4d-090a-4a75-88d2-aacd0c0fd235"
@ -68,6 +68,13 @@ SQLite retains a SHA-256 digest, evidence is purpose/binding scoped, expiry is
enforced, and consumption is atomic and single-use. All 23 repository tests
pass.
2026-08-10 cancellation increment: verification messages now include a fixed
portal cancellation link using the same opaque handle. The SQLite store keeps
only its digest and atomically records either consumption or cancellation, so
verification and cancellation invalidate one another and replay fails closed.
Purpose-bound cancellation evidence lets user-engine abandon the matching
registration without accepting browser identity claims. All 24 tests pass.
## T03 - Establish custody and deploy the service
```task