How PropGuard AI protects accounts, data, and ledger integrity, and what is still missing. Role details are in Documents/08-roles-and-permissions.md; ledger details in Documents/04-blockchain-verification.md.
Do not open a public GitHub issue. Contact the repository owner privately (GitHub: @royceregi) with the steps to reproduce. This is an academic prototype; no production data should ever be loaded into it.
| Aspect | Implementation | Code |
|---|---|---|
| Login | Email + password: POST /api/auth/login |
server/app/api/v1/endpoints/auth.py |
| Password storage | Argon2 hashes (passlib); plaintext is never stored or logged |
server/app/core/security.py |
| Login errors | One generic message ("Invalid email or password") for unknown email and wrong password; a dummy Argon2 check runs for unknown emails so response time doesn't reveal which accounts exist | verify_password() |
| Account state | Login is refused for STANDARD (no access yet), ELEVATED (pending approval), rejected, or inactive accounts |
auth.py |
| Session token | JWT (HS256) signed with SECRET_KEY; claims sub, access_level, iat, exp, type=access; expires after 8 hours (ACCESS_TOKEN_EXPIRE_MINUTES=480) |
create_access_token() |
| Token check on every request | Signature, expiry, and type are validated, then the user is re-loaded from the DB: a deactivated or deleted user is rejected immediately, even with a valid token |
server/app/api/deps.py get_current_user() |
| Cookie | The Next.js route /api/auth/login stores the JWT in the propguard_access_token cookie: HttpOnly, SameSite=Lax, Secure in production, 8-hour max age. Logout clears it |
client/src/app/api/auth/*/route.ts |
| Token transport | The API accepts Authorization: Bearer <token> or the cookie |
_extract_token() |
| Password change | POST /api/auth/change-password requires the current password; the new one must differ and be 8–128 characters |
auth.py |
Accounts are never created from the app (no sign-up, no create-user API). All users already exist in core.users; only their access changes.
Authorization uses core.users.access_level, enforced on the server for every endpoint. The frontend checks are only for navigation.
access_level |
Can sign in | Scope |
|---|---|---|
SYSTEM (Super Admin) |
Yes | Everything, including user access, hash rebuild, and settings |
REGISTRY (Land Registry) |
Yes | All properties and transactions; official review; nominate end users |
COMPANY (Real Estate Company) |
Yes | All properties and transactions (read / verify / analyse); nominate end users |
PUBLIC (Property Owner / buyer) |
Yes | Read-only, own and shared properties only |
STANDARD, ELEVATED |
No | None until nominated and approved |
Layers:
- Role guard:
require_roles([...])on each route returns 403 if the caller's level isn't allowed (server/app/api/deps.py). - Record scope for public users:
ensure_property_access/ensure_transaction_access/scoped_property_ids(server/app/api/property_access.py) limitPUBLICusers to properties they own, owned before, or are a party on. Out-of-scope records return 404, as if they don't exist, and lists are filtered, so IDs can't be probed. Blockchain verification returns the same 403 for "not shared" and "doesn't exist" for the same reason. - User directory scope: Registry and Company only see end-user accounts (Registry also sees Registry). They can move a user between
STANDARDandELEVATEDand toggleis_active/kyc_status, but can't grantPUBLIC,REGISTRY,COMPANY, orSYSTEMthrough the access-level endpoint, or edit any other field (server/app/api/v1/endpoints/users.py). - Admin-only actions: ledger hash rebuild (
/api/v1/admin/blockchain/*), rejecting users, and deleting users areSYSTEMonly. - Frontend guard:
client/src/proxy.tsredirects signed-out users to/loginand blocks pages not allowed for the role (client/src/lib/auth/roles.ts); the UI hides actions the role can't use. This is convenience only: the server checks are what protect the data.
- NIC numbers are stored only as a SHA-256 hash (
nic_hash), never raw. - Passwords are stored only as Argon2 hashes.
- API responses for other users never include password hashes; public users cannot see other people's NIC, phone number, or private documents.
- SQL injection: all queries use SQLAlchemy ORM or bound parameters (
:name); dynamic SQL only joins fixed column names and placeholders, never user input. - Input validation: every request body is a Pydantic schema (types, lengths, required fields,
EmailStr). - CORS: only origins in
CORS_ORIGINS(defaulthttp://localhost:3000) may call the API with credentials. - The demo dataset is synthetic; don't load real personal data.
| Secret | Where | Notes |
|---|---|---|
DATABASE_URL |
server/.env |
DB credentials |
SECRET_KEY |
server/.env |
Signs JWTs; use a long random value (python -c "import secrets; print(secrets.token_urlsafe(64))"). Changing it signs everyone out |
REGISTRY_SIGNING_KEY |
server/.env |
Ed25519 private key that signs ledger blocks; only needed for a hash rebuild. Generate with server/scripts/generate_registry_key.py |
REGISTRY_PUBLIC_KEY |
server/.env |
Enough to verify signatures |
AI_API_KEY |
server/.env |
LLM provider key |
.env, .env.*, *.pem, and *.key are gitignored; only .env.example is committed. Never paste secrets into code, docs, issues, or chat. If a secret leaks, rotate it: a new SECRET_KEY invalidates all sessions, and a new registry key needs a hash rebuild.
The simulated blockchain detects changes made directly in the database:
property_hash,transaction_data_hash, anddocument_hash(SHA-256) detect edited property, transaction, or document data.tx_hash = SHA256(previous_hash + transaction_data_hash + block_timestamp)chains the blocks of each property, so editing one block breaks the link to the next.- Each block's
tx_hashis signed with the registry's Ed25519 key. Someone with DB access could recompute every SHA-256, but can't produce a valid signature without the private key. smart_contract_addressmust matchREGISTRY_CONTRACT_ADDRESS; duplicatetx_hashvalues are flagged.- Any failure makes integrity INVALID and raises a risk indicator with evidence.
server/scripts/tamper_demo.sqldemonstrates each case.
The ledger proves a record wasn't altered after anchoring; it doesn't prove the record was legally correct. That's what the registry checks and risk analysis are for.
- AI agent runs are logged in
core.agent_runs(agent, input, output, status, timing). - Risk assessments and indicators are stored with the evidence they used (
risk_assessments,risk_indicators). - Users'
last_loginis recorded.
These are open items, not yet protected:
- No login rate limiting or lockout. Brute-force attempts are only slowed by Argon2. Add rate limiting before any public deployment.
- Token also kept in
sessionStorageby the frontend auth store and sent as a Bearer header. Any XSS could read it; relying on the HttpOnly cookie alone would be safer. - No token revocation. Logout clears the cookie, but an issued JWT stays valid until it expires (8 hours), unless the user is deactivated.
- No CSRF token. SameSite=Lax cookies and the CORS allow-list mitigate it; state-changing requests should still move to an explicit CSRF check.
- No MFA. The
mfa_enabledcolumn exists but isn't enforced. - HTTP in development. Run behind HTTPS in any shared deployment (the cookie becomes
Securein production). - Approve endpoint:
POST /api/v1/users/{id}/approvecurrently accepts Registry and Company callers as well as Super Admin, so they can promote an end user straight toPUBLIC. The documented rule is Super Admin only. - Buyer read-only audit of every write endpoint is pending (Phase 8c in Documents/11-implementation-phases.md).