Skip to content
Open
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
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ pishield/
│ ├── app.py
│ ├── config.py
│ ├── security_engine.py
│ ├── security_shared.py
│ └── wallet_manager.py
├── frontend/
│ ├── index.html
│ ├── app.js
│ └── styles.css
├── tests/
├── requirements.txt
└── README.md
```
Expand Down Expand Up @@ -270,3 +272,42 @@ Testing includes:
## Disclaimer

This repository is a research and development prototype intended to demonstrate secure wallet recovery concepts and passphrase rotation workflows. It is not affiliated with or endorsed by the Pi Core Team and should not be used in production without comprehensive security review, testing, and integration with the official Pi platform.

---

## Sandbox security architecture

PiShield is a **sandbox demonstration**. It does not connect to Pi Wallets, change
real Pi Wallet credentials, transmit credentials to third parties, or represent a
production credential provider. The demo-only credential hashes stay in process
memory and are never returned by the API.

The Flask application keeps general MFA, rotation, audit, and dashboard behavior
in `SecurityEngine`. Its sandbox authentication path is intentionally separate:
`PiWalletManager` decides whether a demo credential is active, revoked, or
invalid. It receives `PiSecurityEngine` through the narrow
`RecoveryResponseHandler` interface. A revoked demo credential is denied and
causes a temporary recovery lock; it records a security signal, not a conclusion
that a person is a scammer or that phishing is confirmed.

Wallet timestamps are normalized to timezone-aware UTC at the `Wallet` boundary.
While a recovery lock is active, all authentication attempts—including active,
revoked, and invalid demo credentials—are denied. Expired locks do not prevent a
valid active demo credential from being accepted.

`PiTrustAnalyzer` is advisory only. It accepts explicit connection metadata
(`uses_vpn`, `uses_tor`, and `ip_address`) rather than inferring network behavior
from a device ID. VPN/Tor use, IP addresses, and fingerprints do not independently
identify a user as malicious. Confirmed phishing activity is assigned only by the
separate analyst review workflow.

### Validation

Run the sandbox regression suite from the repository root:

```bash
python -m compileall -q pishield
pytest -q
python -m pytest -q
git diff --check
```
156 changes: 101 additions & 55 deletions pishield/backend/app.py
Original file line number Diff line number Diff line change
@@ -1,98 +1,144 @@
"""Flask application for the PiShield sandbox demo."""
from pathlib import Path

from flask import Flask, jsonify, request, send_from_directory
from flask_cors import CORS

if __package__:
from .config import PI_APP_NAME, PI_APP_URL, PI_NETWORK, PI_SANDBOX, PI_SANDBOX_URL
from .security_engine import SecurityEngine
from .wallet_manager import build_device_fingerprint
from .security_engine import PiSecurityEngine, SecurityEngine
from .wallet_manager import PiWalletManager, build_device_fingerprint
else:
from config import PI_APP_NAME, PI_APP_URL, PI_NETWORK, PI_SANDBOX, PI_SANDBOX_URL
from security_engine import SecurityEngine
from wallet_manager import build_device_fingerprint
from security_engine import PiSecurityEngine, SecurityEngine
from wallet_manager import PiWalletManager, build_device_fingerprint

app = Flask(__name__)
CORS(app)
CORS(app)

security_engine = SecurityEngine()
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
pi_security_engine = PiSecurityEngine(general_security=security_engine)
wallet_manager = PiWalletManager(response_handler=pi_security_engine)
FRONTEND_DIR = Path(__file__).resolve().parents[1] / "frontend"


@app.route("/")
def home():
@app.route("/")
def home():
return jsonify(
{
"app": PI_APP_NAME,
"status": "sandbox_running",
"pi_network": "sandbox" if PI_SANDBOX else PI_NETWORK,
"security": "enabled",
"app_url": PI_APP_URL,
"sandbox_url": PI_SANDBOX_URL,
}
)


return jsonify({
"app": PI_APP_NAME,
"status": "sandbox_running",
"pi_network": "sandbox" if PI_SANDBOX else PI_NETWORK,
"security": "enabled",
"app_url": PI_APP_URL,
"sandbox_url": PI_SANDBOX_URL,
})

@app.route("/app")
def frontend_app():
return send_from_directory(FRONTEND_DIR, "index.html")


@app.route("/frontend/<path:filename>")
def frontend_asset(filename):
return send_from_directory(FRONTEND_DIR, filename)


@app.route("/health")
def health():
return jsonify({
"server": "online",
"wallet_security": "active",
"sandbox": PI_SANDBOX,
})




@app.route("/health")
def health():
return jsonify(
{"server": "online", "wallet_security": "active", "sandbox": PI_SANDBOX}
)


@app.route("/pi/validate")
def validate():
return jsonify({
"app": PI_APP_NAME,
"verified": True,
"network": "sandbox" if PI_SANDBOX else PI_NETWORK,
})

return jsonify(
{
"app": PI_APP_NAME,
"verified": True,
"network": "sandbox" if PI_SANDBOX else PI_NETWORK,
}
)


@app.route("/api/config")
def frontend_config():
return jsonify({
"app": PI_APP_NAME,
"network": PI_NETWORK,
"sandbox": PI_SANDBOX,
"app_url": PI_APP_URL,
"sandbox_url": PI_SANDBOX_URL,
})

return jsonify(
{
"app": PI_APP_NAME,
"network": PI_NETWORK,
"sandbox": PI_SANDBOX,
"app_url": PI_APP_URL,
"sandbox_url": PI_SANDBOX_URL,
}
)


@app.route("/api/mfa/challenge", methods=["POST"])
def create_mfa_challenge():
payload = request.get_json(silent=True) or {}
username = payload.get("username", "pi_sandbox_user")
pi_auth_uid = payload.get("pi_auth_uid")
return jsonify(security_engine.create_mfa_challenge(username, pi_auth_uid=pi_auth_uid))
if "pi_auth_uid" in payload and (
not isinstance(pi_auth_uid, str) or not pi_auth_uid.strip()
):
return jsonify({"error": "invalid_pi_auth_uid"}), 400
return jsonify(
security_engine.create_mfa_challenge(
username,
pi_auth_uid=pi_auth_uid.strip() if isinstance(pi_auth_uid, str) else None,
)
)


@app.route("/api/wallet/authenticate", methods=["POST"])
def authenticate_wallet():
"""Authenticate a sandbox-only demo credential without exposing it."""
payload = request.get_json(silent=True) or {}
username = payload.get("username", "pi_sandbox_user")
demo_credential = payload.get("demo_credential")
if not isinstance(username, str) or not isinstance(demo_credential, str):
return jsonify({"error": "username and demo_credential are required"}), 400

try:
authenticated = wallet_manager.authenticate(username, demo_credential)
except ValueError:
return jsonify({"error": "wallet not found"}), 404

return jsonify({"authenticated": authenticated, "sandbox": PI_SANDBOX}), (
200 if authenticated else 403
)


@app.route("/api/wallet/fingerprint", methods=["POST"])
def fingerprint():
payload = request.get_json(silent=True) or {}
username = payload.get("username", "pi_sandbox_user")
user_agent = request.headers.get("User-Agent", "unknown-agent")
return jsonify({
"username": username,
"device_fingerprint": build_device_fingerprint(user_agent, username),
})
return jsonify(
{
"username": username,
"device_fingerprint": build_device_fingerprint(user_agent, username),
}
)


@app.route("/api/wallet/rotate-passphrase", methods=["POST"])
def rotate_passphrase():
payload = request.get_json(silent=True) or {}
pi_auth_uid = payload.get("pi_auth_uid")
if "pi_auth_uid" in payload and (not isinstance(pi_auth_uid, str) or not pi_auth_uid.strip()):
return jsonify({
"error": "invalid_pi_auth_uid",
"message": "If provided, pi_auth_uid must be a non-empty string.",
}), 400
if "pi_auth_uid" in payload and (
not isinstance(pi_auth_uid, str) or not pi_auth_uid.strip()
):
return jsonify(
{
"error": "invalid_pi_auth_uid",
"message": "If provided, pi_auth_uid must be a non-empty string.",
}
), 400

result, status = security_engine.rotate_passphrase(
username=payload.get("username", "pi_sandbox_user"),
Expand All @@ -102,7 +148,7 @@ def rotate_passphrase():
device_fingerprint=payload.get("device_fingerprint", ""),
mfa_challenge_id=payload.get("mfa_challenge_id"),
pi_auth_uid=pi_auth_uid.strip() if isinstance(pi_auth_uid, str) else None,
)
)
return jsonify(result), status


Expand All @@ -112,4 +158,4 @@ def security_dashboard():


if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=31415, debug=True)
app.run(host="0.0.0.0", port=31415, debug=True)
65 changes: 45 additions & 20 deletions pishield/backend/config.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,58 @@

""PiShield sandbox configuration.

Values default to the Pi Developer Portal sandbox settings and can be
overridden with environment variables for local/ngrok development.
""

"""PiShield sandbox configuration."""
from __future__ import annotations

import os

from dotenv import load_dotenv

load_dotenv()


def _env_bool(name: str, default: bool) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}



class PiOSConfig:
"""Configuration values for Pi Browser and wallet-security workflows."""

APP_NAME = os.getenv("PI_APP_NAME", "PiShield")
APP_VERSION = "1.0.0"
PIOS_COMPATIBLE = True

PROD_APP_URL = os.getenv("PI_PROD_APP_URL", "https://pishield.pinet.com")
DEV_APP_URL = os.getenv("PI_APP_URL", "http://localhost:31415")
API_URL = os.getenv("PI_API_URL", "https://api.minepi.com")
PRIVACY_POLICY_URL = os.getenv(
"PRIVACY_POLICY_URL", "https://pishield.pinet.com/privacy-policy"
)
TERMS_OF_SERVICE_URL = os.getenv(
"TERMS_OF_SERVICE_URL", "https://pishield.pinet.com/terms"
)
SANDBOX_URL = os.getenv("PI_SANDBOX_URL", "https://sandbox.minepi.com")

PI_BROWSER_REQUIRED = True
PI_SDK_ENABLED = True
PI_MAINNET_ENABLED = True

ROTATION_DELAY_HOURS = 48
RECOVERY_LOCK_HOURS = 24
THREAT_SCORE_THRESHOLD = 70
HIGH_RISK_THRESHOLD = 90
MAX_RECOVERY_ATTEMPTS = 3


# Backwards-compatible aliases used by the existing Flask app/config imports.
# TODO: migrate callers to PiOSConfig and remove these aliases.
PI_SANDBOX = _env_bool("PI_SANDBOX", True)
PI_APP_NAME = os.getenv("PI_APP_NAME", "PiShield")
PI_APP_NAME = PiOSConfig.APP_NAME
PI_API_KEY = os.getenv("PI_API_KEY", "YOUR_PI_API_KEY")
PI_NETWORK = os.getenv("PI_NETWORK", "Pi Testnet")
PI_APP_URL = os.getenv("PI_APP_URL", "http://localhost:31415")
PI_API_URL = os.getenv("PI_API_URL", "https://api.minepi.com")
PI_SANDBOX_URL = os.getenv("PI_SANDBOX_URL", "https://sandbox.minepi.com")
PRIVACY_POLICY_URL = os.getenv(
"PRIVACY_POLICY_URL",
"https://pishield.pinet.com/privacy-policy",
)
TERMS_OF_SERVICE_URL = os.getenv(
"TERMS_OF_SERVICE_URL",
"https://pishield.pinet.com/terms",
)
# Legacy Flask uses the development URL for the app.
PI_APP_URL = PiOSConfig.DEV_APP_URL
PI_API_URL = PiOSConfig.API_URL
PI_SANDBOX_URL = PiOSConfig.SANDBOX_URL
PRIVACY_POLICY_URL = PiOSConfig.PRIVACY_POLICY_URL
TERMS_OF_SERVICE_URL = PiOSConfig.TERMS_OF_SERVICE_URL
Loading