diff --git a/README.md b/README.md index 885ee1c..6ba6183 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,16 @@ Remember, it's self-paced so feel free to take a break! ☕️ © 2025 GitHub • [Code of Conduct](https://www.contributor-covenant.org/version/2/1/code_of_conduct/code_of_conduct.md) • [MIT License](https://gh.io/mit) +## Owner login scan tool + +This repository now includes a minimal owner verification tool in `owner_scan_auth.py`. + +It stores a hashed owner scan signature and denies login when the new scan does not match the enrolled owner signature. + +### Quick start + +```bash +python owner_scan_auth.py --database owner_scans.json enroll alice "FaceVector:12345" +python owner_scan_auth.py --database owner_scans.json login alice "FaceVector:12345" # Access granted +python owner_scan_auth.py --database owner_scans.json login alice "FaceVector:wrong" # Access denied +``` diff --git a/__pycache__/owner_scan_auth.cpython-312.pyc b/__pycache__/owner_scan_auth.cpython-312.pyc new file mode 100644 index 0000000..fbb1104 Binary files /dev/null and b/__pycache__/owner_scan_auth.cpython-312.pyc differ diff --git a/owner_scan_auth.py b/owner_scan_auth.py new file mode 100644 index 0000000..60d58c7 --- /dev/null +++ b/owner_scan_auth.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +from pathlib import Path + + +def _normalize_scan(scan_data: str) -> str: + return " ".join(scan_data.strip().lower().split()) + + +def create_scan_signature(scan_data: str) -> str: + normalized = _normalize_scan(scan_data) + if not normalized: + raise ValueError("Scan data cannot be empty") + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _load_database(database_path: str | Path) -> dict[str, str]: + db_path = Path(database_path) + if not db_path.exists(): + return {} + with db_path.open("r", encoding="utf-8") as file: + data = json.load(file) + if not isinstance(data, dict): + raise ValueError("Database must be a JSON object") + return {str(account): str(signature) for account, signature in data.items()} + + +def _save_database(database_path: str | Path, database: dict[str, str]) -> None: + db_path = Path(database_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + with db_path.open("w", encoding="utf-8") as file: + json.dump(database, file, indent=2) + + +def enroll_owner_scan(account_id: str, scan_data: str, database_path: str | Path) -> None: + normalized_account = account_id.strip() + if not normalized_account: + raise ValueError("Account ID cannot be empty") + + database = _load_database(database_path) + database[normalized_account] = create_scan_signature(scan_data) + _save_database(database_path, database) + + +def verify_owner_scan(account_id: str, attempted_scan_data: str, database_path: str | Path) -> bool: + normalized_account = account_id.strip() + if not normalized_account: + return False + + database = _load_database(database_path) + expected_signature = database.get(normalized_account) + if expected_signature is None: + return False + + attempted_signature = create_scan_signature(attempted_scan_data) + return hmac.compare_digest(expected_signature, attempted_signature) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Secretly scan and verify whether a login attempt matches the account owner." + ) + parser.add_argument( + "--database", + default="owner_scans.json", + help="Path to the owner scan signature database (default: owner_scans.json)", + ) + + subparsers = parser.add_subparsers(dest="command", required=True) + + enroll_parser = subparsers.add_parser("enroll", help="Enroll or update an account owner's scan") + enroll_parser.add_argument("account_id", help="Account identifier") + enroll_parser.add_argument("scan_data", help="Trusted owner scan input") + + login_parser = subparsers.add_parser("login", help="Verify login scan against the account owner") + login_parser.add_argument("account_id", help="Account identifier") + login_parser.add_argument("scan_data", help="Scan input captured at login") + + return parser + + +def main() -> int: + parser = _build_parser() + args = parser.parse_args() + + if args.command == "enroll": + enroll_owner_scan(args.account_id, args.scan_data, args.database) + print(f"Owner scan enrolled for account '{args.account_id}'.") + return 0 + + if verify_owner_scan(args.account_id, args.scan_data, args.database): + print("Access granted.") + return 0 + + print("Access denied: account owner scan mismatch.") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/__pycache__/test_owner_scan_auth.cpython-312.pyc b/tests/__pycache__/test_owner_scan_auth.cpython-312.pyc new file mode 100644 index 0000000..bb79628 Binary files /dev/null and b/tests/__pycache__/test_owner_scan_auth.cpython-312.pyc differ diff --git a/tests/test_owner_scan_auth.py b/tests/test_owner_scan_auth.py new file mode 100644 index 0000000..9a92530 --- /dev/null +++ b/tests/test_owner_scan_auth.py @@ -0,0 +1,31 @@ +import tempfile +import unittest +from pathlib import Path + +from owner_scan_auth import enroll_owner_scan, verify_owner_scan + + +class OwnerScanAuthTests(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.database_path = Path(self.temp_dir.name) / "owner_scans.json" + + def tearDown(self) -> None: + self.temp_dir.cleanup() + + def test_allows_login_when_scan_matches_owner(self) -> None: + enroll_owner_scan("alice", "FaceVector:12345", self.database_path) + + self.assertTrue(verify_owner_scan("alice", "facevector:12345", self.database_path)) + + def test_denies_login_when_scan_does_not_match_owner(self) -> None: + enroll_owner_scan("alice", "FaceVector:12345", self.database_path) + + self.assertFalse(verify_owner_scan("alice", "FaceVector:wrong", self.database_path)) + + def test_denies_login_for_unknown_account(self) -> None: + self.assertFalse(verify_owner_scan("unknown", "FaceVector:12345", self.database_path)) + + +if __name__ == "__main__": + unittest.main()