Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Binary file added __pycache__/owner_scan_auth.cpython-312.pyc
Binary file not shown.
104 changes: 104 additions & 0 deletions owner_scan_auth.py
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +14 to +18


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)
Comment on lines +25 to +26
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)
Comment thread
Syko12345 marked this conversation as resolved.
Comment on lines +35 to +36


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)
Comment thread
Syko12345 marked this conversation as resolved.
Comment on lines +59 to +60


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
Comment on lines +86 to +100


if __name__ == "__main__":
raise SystemExit(main())
Binary file not shown.
31 changes: 31 additions & 0 deletions tests/test_owner_scan_auth.py
Original file line number Diff line number Diff line change
@@ -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()