130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
"""signatures.sign: step-up, the append-only row, its content hash and the audit row."""
|
|||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import hashlib
|
||
|
|
import uuid
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import psycopg
|
||
|
|
import pytest
|
||
|
|
from fakedir import FAKE_PASSWORD, FakeDirectory
|
||
|
|
from fastapi.testclient import TestClient
|
||
|
|
from pshelpers import PIN, app_conn, needs_pg, set_pin, sign_in
|
||
|
|
|
||
|
|
from packetstation import audit
|
||
|
|
from packetstation.api.errors import ApiError
|
||
|
|
from packetstation.auth import sessions, signatures
|
||
|
|
from packetstation.settings import Settings
|
||
|
|
|
||
|
|
|
||
|
|
def test_content_hash_is_canonical() -> None:
|
||
|
|
a = signatures.content_hash({"b": [1, 2], "a": "7A"})
|
||
|
|
b = signatures.content_hash({"a": "7A", "b": [1, 2]})
|
||
|
|
assert a == b == hashlib.sha256(b'{"a":"7A","b":[1,2]}').digest()
|
||
|
|
assert signatures.content_hash(None) is None
|
||
|
|
|
||
|
|
|
||
|
|
def _actor(conn: Any, sam: str) -> sessions.Actor:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT s.id, u.id, u.sam_account, u.display_name, s.kind::text"
|
||
|
|
" FROM sessions s JOIN users u ON u.id = s.user_id"
|
||
|
|
" WHERE u.sam_account = %s AND s.revoked_at IS NULL ORDER BY s.created_at DESC LIMIT 1",
|
||
|
|
(sam,),
|
||
|
|
).fetchone()
|
||
|
|
assert row is not None
|
||
|
|
return sessions.Actor(row[0], row[1], row[2], row[3], row[4], ("shop:03:worker",))
|
||
|
|
|
||
|
|
|
||
|
|
@needs_pg
|
||
|
|
def test_sign_with_pin_and_password(pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory) -> None:
|
||
|
|
set_pin(pg_client, "weld.andy")
|
||
|
|
sign_in(pg_client, "weld.andy")
|
||
|
|
content = {"packet": "L662001/1", "lot": "7A", "step": "submit"}
|
||
|
|
with app_conn(pg_settings) as conn:
|
||
|
|
actor = _actor(conn, "weld.andy")
|
||
|
|
sig = signatures.sign(
|
||
|
|
conn,
|
||
|
|
fake_dir,
|
||
|
|
actor,
|
||
|
|
factor="pin",
|
||
|
|
secret=PIN,
|
||
|
|
action="packet.submit",
|
||
|
|
meaning="Work complete",
|
||
|
|
packet_id=12,
|
||
|
|
subject_type="packet",
|
||
|
|
subject_id=12,
|
||
|
|
content=content,
|
||
|
|
settings=pg_settings,
|
||
|
|
)
|
||
|
|
assert sig.content_hash == signatures.content_hash(content) and sig.factor == "pin"
|
||
|
|
sig2 = signatures.sign(
|
||
|
|
conn,
|
||
|
|
fake_dir,
|
||
|
|
actor,
|
||
|
|
factor="password",
|
||
|
|
secret=FAKE_PASSWORD,
|
||
|
|
action="x",
|
||
|
|
meaning=None,
|
||
|
|
settings=pg_settings,
|
||
|
|
)
|
||
|
|
assert sig2.id > sig.id and sig2.content_hash is None
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT account, display_name, session_kind::text, factor::text, action, packet_id, subject_id,"
|
||
|
|
" content_hash FROM signatures WHERE id = %s",
|
||
|
|
(sig.id,),
|
||
|
|
).fetchone()
|
||
|
|
assert row[:7] == ("weld.andy", "Andy Welder", "password", "pin", "packet.submit", 12, "12")
|
||
|
|
assert bytes(row[7]) == signatures.content_hash(content)
|
||
|
|
detail = conn.execute("SELECT detail FROM audit_events WHERE action = 'sig.signed' ORDER BY id").fetchone()[0]
|
||
|
|
assert detail == {"signature_id": sig.id, "action": "packet.submit", "factor": "pin"}
|
||
|
|
for stmt in ("UPDATE signatures SET action = 'x'", "DELETE FROM signatures", "TRUNCATE signatures"):
|
||
|
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||
|
|
conn.execute(stmt)
|
||
|
|
for stmt in ("UPDATE signin_attempts SET ok = true", "DELETE FROM signin_attempts"):
|
||
|
|
with pytest.raises(psycopg.errors.InsufficientPrivilege):
|
||
|
|
conn.execute(stmt)
|
||
|
|
assert audit.verify_chain(conn)[0]
|
||
|
|
|
||
|
|
|
||
|
|
@needs_pg
|
||
|
|
def test_wrong_signatures_end_the_session(
|
||
|
|
pg_client: TestClient, pg_settings: Settings, fake_dir: FakeDirectory
|
||
|
|
) -> None:
|
||
|
|
sign_in(pg_client, "weld.andy")
|
||
|
|
with app_conn(pg_settings) as conn:
|
||
|
|
actor = _actor(conn, "weld.andy")
|
||
|
|
for code in ("wrong_secret", "wrong_secret", "session_ended"):
|
||
|
|
with pytest.raises(ApiError) as e:
|
||
|
|
signatures.sign(
|
||
|
|
conn,
|
||
|
|
fake_dir,
|
||
|
|
actor,
|
||
|
|
factor="password",
|
||
|
|
secret="nope",
|
||
|
|
action="x",
|
||
|
|
meaning=None,
|
||
|
|
settings=pg_settings,
|
||
|
|
)
|
||
|
|
assert e.value.code == code
|
||
|
|
assert conn.execute("SELECT count(*) FROM signatures").fetchone()[0] == 0
|
||
|
|
kinds = conn.execute("SELECT DISTINCT kind FROM signin_attempts WHERE NOT ok").fetchall()
|
||
|
|
assert kinds == [("signature",)]
|
||
|
|
assert pg_client.get("/api/auth/me").json()["code"] == "no_session"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unknown_factor_refused() -> None:
|
||
|
|
actor = sessions.Actor(uuid.uuid4(), uuid.uuid4(), "a", "A", "password", ())
|
||
|
|
with pytest.raises(ApiError) as e:
|
||
|
|
signatures.sign(
|
||
|
|
None,
|
||
|
|
FakeDirectory(),
|
||
|
|
actor,
|
||
|
|
factor="sms",
|
||
|
|
secret="1",
|
||
|
|
action="x",
|
||
|
|
meaning=None,
|
||
|
|
settings=Settings(_env_file=None, stack="ci"),
|
||
|
|
)
|
||
|
|
assert e.value.code == "bad_factor"
|