Voting Platform - Authentication & Wallet Architecture
1. Core Principles
- Security Server CANNOT decrypt user passwords and wallets
- Security Server stores a HASH of the password to verify login from a new device
- Security Private key exists only ENCRYPTED with user's password
- Risk Lost password = wallet lost forever (if the wallet was created without a seed phrase)
- UX Password and private key stored in SecureStore (Keychain/Keystore)
- UX Session JWT tokens for operations
2. Registration Flow
- Operator creates invitation with
phone, email and saves codes for verification
- User receives invitation link
- User opens app → SignUp screen (phone & email pre-filled, locked)
- User creates account / password,
confirms phone and email
{
"invitation_token": "...",
"account_name": "...",
"password_hash": "argon2id(password + salt)",
"phone_verification_code": "...",
"email_verification_code": "...",
}
- Server stores
account, password_hash and marks invitation as used and sends session tokens
- App generates wallet key pair locally on device
- App encrypts private key with user's password
- App sends to server:
{
"session_token": "...",
"public_key": "0x...",
"encrypted_wallet": "base64(aes_gcm(privkey, password))"
}
- Server stores
public_key and encrypted_wallet
- App saves password to SecureStore (Keychain/Keystore)
3. Login Flow
3.1 Normal Login (same device)
- App retrieves password from SecureStore
- App computes
password_hash = argon2id(password)
- App sends to server:
POST /auth/login { "phone": "...", "password_hash": "..." }
- Server verifies password_hash matches stored hash
- Server returns
session_token (JWT, 24h validity)
- App stores session_token in memory (or SecureStore)
- All subsequent API calls use
session_token
3.2 Login on New Device (wallet import)
- User installs app on new device
- SecureStore is empty → user must enter password
- User enters phone + password
- App computes
password_hash → sends to server
- Server verifies password_hash → returns
encrypted_wallet
- App decrypts
encrypted_wallet using the same password
- App gets private key → ready to vote!
- App saves password to SecureStore on new device
4. Session Tokens
- Purpose: Avoid sending password_hash for every operation
- Lifetime: 24 hours (short-lived)
- Storage: In-memory during app session, optionally in SecureStore for persistence
- Refresh: Refresh token mechanism for seamless extension
// Typical authenticated request
GET /api/voting/active
Authorization: Bearer <session_token>
5. Password Change (user knows old password)
- User is authenticated (session_token valid)
- User enters old password and new password
- App decrypts wallet with old password
- App re-encrypts same private key with new password
- App sends to server:
PUT /user/wallet
{
"new_password_hash": "argon2id(new_password)",
"new_encrypted_wallet": "base64(...)"
}
- Server updates
password_hash and encrypted_wallet
- App updates password in SecureStore
- Session tokens are invalidated (user must re-login)
6. Password Recovery (lost password)
⚠ CRITICAL: Wallet is lost FOREVER. No recovery possible by design (security).
- User contacts community operator
- Operator sends new invitation (same phone/email)
- User registers again (creates new wallet)
- Old account marked as
wallet_locked (archived for audit)
- Old wallet remains encrypted forever (cannot be recovered)
Note: No seed phrase is used. The password is the ONLY key to the wallet.
This is a conscious trade-off: simplicity vs. recovery options.
7. Repeated Invitation (incomplete registration)
- Operator sees invitation status =
pending in admin panel
- Operator can send new invitation (generates new token)
- Old token becomes invalid
- User starts registration from scratch (wallet not yet created → no loss)
8. Database Schema
Table: invitations
| Field | Type | Description |
invitation_uuid | UUID | Primary key |
community_uuid | UUID | Target community |
phone | VARCHAR(20) | Plain phone (temporary, for delivery) |
email | VARCHAR(255) | Plain email (temporary, for delivery) |
invitation_token | VARCHAR(255) | Signed JWT token |
expires_at | TIMESTAMP | Invitation expiration |
status | ENUM | pending, used, expired |
created_by | UUID | Operator who created invitation |
Table: users
| Field | Type | Description |
user_uuid | UUID | Primary key |
community_uuid | UUID | Foreign key to community |
phone_hash | VARCHAR(64) | sha256(phone + salt) |
email_hash | VARCHAR(64) | sha256(email + salt) |
password_hash | VARCHAR(128) | Argon2id hash of password |
encrypted_wallet | TEXT | Private key encrypted with password |
public_key | VARCHAR(128) | Wallet public key |
status | ENUM | active, wallet_locked, pending |
created_at | TIMESTAMP | Registration timestamp |
Table: sessions
| Field | Type | Description |
session_id | UUID | Primary key |
user_uuid | UUID | Foreign key to users |
session_token | VARCHAR(255) | JWT or random token |
expires_at | TIMESTAMP | Token expiration |
device_id | VARCHAR(255) | Optional device fingerprint |
created_at | TIMESTAMP | Session creation time |
9. Security Summary
| Scenario | Outcome |
| Password lost | Wallet lost forever. New registration required via operator. |
| Invitation intercepted | Useless without access to email/phone. |
| Server breached | Attacker gets encrypted_wallet but cannot decrypt without user's password. |
| Device compromised | If password in SecureStore + device unlocked → wallet accessible. SecureStore protects against other apps. |
| Man-in-the-middle | HTTPS + certificate pinning. Only password_hash transmitted, not password. |
10. Key Design Decisions (Summary)
- No seed phrase — users would lose it; password is the only key
- No server-side decryption — server never sees private key or password
- Password stored in SecureStore — convenience without sacrificing security
- Session tokens — efficient frequent operations (voting, checking results)
- Lost password = lost wallet — honest, simple, secure trade-off
Ready for: API specification (OpenAPI), detailed encryption scheme.