1. The stack at a glance
Five components, all under our control:
- Chip: NXP MIFARE DESFire EV3 (xDF3 implant or external fob), 8 KB EEPROM, AES-128 secure element. Tamper-resistant, NXP-signed at fabrication.
- Native client: iPhone (Core NFC), Mac (PCSC USB reader), Windows (PCSC), Android (planned). Acts as a transport relay between chip and server. Holds no keys, is replaceable.
- Browser extension: Chrome and Firefox (Safari pending review). Talks to the local native bridge or directly to the server. Holds no keys.
- Server: Single VPS in Switzerland. Flask + gunicorn + gevent on Python 3.12, behind nginx with TLS 1.3 termination. SQLite WAL with nightly age-encrypted backups.
- Operator panel: Distinct admin surface at /operator with role-based access (support / senior / super), multi-operator approval gates, tamper-evident audit log.
No third-party services on the critical path. No CDN-hosted JavaScript. No cloud key management. No analytics. The server is in Switzerland, the data is in Switzerland, the company is in Switzerland.
2. Chip and provisioning protocol
A factory-fresh DESFire chip ships with a single DES master key on the PICC (the chip's master application), all zeros. The first time a user taps a fresh chip, the server walks the chip through a 13-round-trip provisioning protocol that converts it to AES, installs a per-chip key, and writes a vault wrap key into an encrypted file.
Provisioning sequence
Per-chip secrets generated at provisioning
- K_chip (16 bytes, AES-128): the DESFire master key for the SkinID application. Server-generated via os.urandom(16), written to the chip via DESFire ChangeKey, stored server-side as ciphertext under MASTER_KEY-derived KEK.
- K_vault (32 bytes): the vault wrap key. Server-generated, written to chip file 0x01 in encrypted form (the file is configured with comm_set=0x03, full encryption + MAC, requires K_chip authentication for read or write). Server keeps an encrypted backup copy.
Both keys are versioned. Rotation = re-provision; the old chip's keys become unreachable when the customer activates a fresh chip via the recovery workflow.
3. Authentication on every use
When a user wants to log into a website, the chain looks like this:
- Trigger. Browser extension detects a login form, or the user explicitly requests autofill. Native client opens an NFC session.
- Read UID. Chip is tapped. Native client reads the 7-byte UID and the 28-byte GET_VERSION response. Forwards both to the server.
- Lookup. Server finds the chip in provisioned_chips, decrypts K_chip from server-side ciphertext using MASTER_KEY-derived KEK.
- Mutual authentication (3 round trips). Server initiates AuthenticateEV2First. APDUs are relayed through the native client to the chip and back. After completion both sides hold session keys (KSesAuthENC, KSesAuthMAC) and a 4-byte transaction identifier (TI).
- Read vault key. Server issues ReadData(file=1, offset=0, len=32) in CommMode.FULL. The chip's response is encrypted with KSesAuthENC + MAC'd with KSesAuthMAC. Server decrypts to recover K_vault.
- Originality check (optional, cached). First time we see a chip, the server reads the NXP originality signature (cmd 0x3C), verifies it against the published NXP DESFire EV3 public key on secp224r1. Result is cached in provisioned_chips.originality_verified_at.
- Decrypt the credential. The credential ciphertext (stored in credentials_v2.password_blob) is decrypted using the per-credential key derived from K_vault (see §4).
- Serve plaintext to client. Decrypted credential goes back over TLS to the browser extension, which fills the form. The chip can now leave the field.
Total wall-clock time: ~3 seconds end-to-end on iPhone Core NFC, ~1.5 seconds on a USB reader on Mac.
4. Credential encryption and key derivation
Every credential is encrypted with a unique key. The chain of derivations:
# For credential row id N belonging to user U on rp R: cred_salt = struct.pack('<Q', N) # 8 bytes, unique per cred cred_key = HKDF(K_vault, cred_salt, info=b'skinid-vault-password-v1', length=32) aad = f'u={U};rp={R};c={N}'.encode() # associated data nonce = os.urandom(12) # fresh per-write ct = ChaCha20Poly1305(cred_key).encrypt(nonce, plaintext, aad) blob = bytes([0x01]) + nonce + ct # 1 + 12 + N+16 = N+29 bytes
Why each design choice
- HKDF-SHA256 derives the per-credential key from K_vault. Salting with the credential row id makes per-credential keys independent (compromising one yields nothing about siblings) without storing extra randomness.
- Domain separation via HKDF info string. Password / FIDO / future note credential types use different info strings (skinid-vault-password-v1, skinid-vault-fido-v1, etc.) so a FIDO blob's derived key is cryptographically unrelated to a password blob's, even if cred_id collides across types.
- ChaCha20-Poly1305 AEAD: avoids the IV-misuse failure modes of AES-GCM. Modern, fast on phones, constant-time.
- AAD binding: the user id, rp_id (site), and cred_id are baked into the authentication tag. An attacker with DB write access cannot copy ciphertext from one user to another or one site to another; the AAD won't match and decryption fails. This blocks ciphertext-swap attacks at scale.
- Per-write fresh nonce. 12 bytes from os.urandom on every encrypt. ChaCha20 has a 64-bit period at fixed key, so even with 2^32 messages the nonce-collision probability is negligible.
- Versioned blob. First byte is the format version. Lets us migrate to a different cipher later without flag day.
Reference implementation lives in vault_crypto.py, ~262 lines, with a self-test that exercises round-trip, tamper detection, AAD swap rejection, wrong-key rejection, and FIDO/password domain separation.
5. Account recovery (three paths)
If the user loses their chip, three independent paths exist. Customers choose any one at signup.
Path A: backup chip
At signup we offer to provision a second chip. K_vault for both chips is the same (the customer's vault, not a per-chip vault). The backup chip lives in a drawer, a safe deposit box, or a trusted family member. Tapping the backup at /recover swaps the primary, retires the lost chip's K_chip, and provisions a fresh backup slot.
Path B: printable Shamir key
K_vault is split via Shamir Secret Sharing over GF(2^8) using the Rijndael polynomial 0x11B. Default 3-of-5 split. The 5 shares are printed on paper at signup; the customer stores them separately. Three shares reconstruct K_vault and let us provision a fresh chip.
Each share is hashed with SHA-256 and a per-user, per-index salt; we store only the hash. The user provides the share bytes, we hash and verify.
Path C: KYC + cooling-off + multi-operator approval
Customer has neither backup chip nor Shamir key. They submit a recovery request with proof of identity (passport / national ID). After a documented cooling-off period (default 7 days), two operators of role senior or super must independently approve. A single reject vote rejects the request. On execution, a one-time activation code is generated; the customer enters it on a fresh chip, which gets provisioned with their existing vault key (derived from the recovered Shamir or, in last resort, from the encrypted-under-MASTER_KEY backup copy).
This last branch is the only situation where the server can technically decrypt customer credentials. It requires:
- Loss of the customer's chip and Shamir key
- Successful KYC review
- Cooling-off period elapsed
- Two distinct senior+ operator approvals
- Access to the off-server private MASTER_KEY material
Each step is audit-logged and notifiable to the customer.
6. Operator access and multi-party approval
Roles
| Role | Can do |
|---|---|
| support | Read dashboard, chip inventory, users; change own password; ship chips (factory to shipped); transition shipped to activating |
| senior | All of support + read audit log + vote on approval requests + lock/unlock user accounts (with approval gate when user has stored credentials) |
| super | All of senior + create/disable other operators + retire activated chips (peer-approved) |
Multi-operator approval
High-risk actions are gated behind a 2-of-N voting workflow:
- Retire an activated chip (real customer is using it)
- Lock a user account that has stored credentials
- Complete an account recovery request
- Roll a chip's K_chip (key rotation)
The requester's vote is auto-counted. A second senior+ operator must vote approve before the action runs. A single reject vote rejects the entire request immediately. Requests expire after 7 days. All votes are recorded in the tamper-evident audit log with operator id, timestamp, and free-text comment.
Operator session security
- Passwords: PBKDF2-SHA256, 600 000 iterations (OWASP 2023), 16-byte salt, format pbkdf2_sha256$iter$b64salt$b64hash
- Session tokens: 256-bit, generated via secrets.token_urlsafe(32), stored as SHA-256 hash. Cookies are HttpOnly + Secure + SameSite=Strict
- Lifetime: 30-minute idle timeout, 8-hour absolute cap. Password change revokes all other sessions immediately
- Lockout: 5 failed login attempts on a single account = 15-minute lockout; 10 failed attempts from a single IP across all accounts = 15-minute IP block (defeats password spraying)
- CSRF: state-changing endpoints require both SameSite=Strict cookie AND X-Requested-With: fetch header (defense-in-depth)
7. Tamper-evident audit log
Every operator action is logged to activity_log. Each row carries a SHA-256 hash chained to the previous row's hash:
row_hash = SHA-256( prev_hash || \x1f || str(user_id) || \x1f || action || \x1f || details || \x1f || str(timestamp) || \x1f || str(operator_id) || \x1f || before_state_json || \x1f || after_state_json || \x1f || reason || \x1f || ip_address )
Modifying any historical row breaks the chain at that row and at every row after it. /operator/api/audit/verify_chain walks the entire log and reports any mismatches. Pair with periodic external pinning of the latest last_hash (Slack channel, off-server log, public transparency log) for evidentiary value: if the on-server chain matches the external pin, you have cryptographic proof the log was not silently rewritten.
Each row carries: actor (operator + user id), action type, before/after JSON-serialised state, free-text reason, IP address (real client IP via ProxyFix, not the nginx loopback), and the timestamp.
This is the single mechanism that makes insider abuse detectable. An operator who silently fixes their own audit trail to hide an unauthorised action would have to recompute every row's hash from that point forward, which would mismatch the externally-pinned hash.
8. Server architecture and hardening
Process
- nginx (TLS terminator) → gunicorn (single gevent worker) → Flask app, on a single VPS in Switzerland
- SQLite in WAL mode at /opt/skinid/skinid.db; nightly age-encrypted backups to off-server storage; private age key held off-VPS
- Self-hosted JavaScript dependencies; no CDN on the critical path
- No third-party analytics, no behavioural profiling, no advertising
HTTP defense layers
- TLS 1.3 with HSTS preload (max-age=63072000; includeSubDomains; preload)
- Content-Security-Policy: default-src 'self', with explicit allowances only for Google Fonts (style + font-src) and our own WebSocket. frame-ancestors 'none' + X-Frame-Options DENY
- Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Resource-Policy: same-origin
- Permissions-Policy denies camera, microphone, geolocation, payment, USB, bluetooth, sensors
- Referrer-Policy strict-origin-when-cross-origin
- Host header allowlist: requests with a Host other than skinid.ch / www.skinid.ch get 421 Misdirected before any handler runs
- MAX_CONTENT_LENGTH = 2 MB: huge bodies get 413 Payload Too Large at the framework layer, before parsing
- ProxyFix with x_for=1: real client IPs from X-Forwarded-For; per-IP rate limiting and audit logs are accurate
- Rate limits: 300 requests / IP / minute on all /api/* and /nfc/*; tighter 10 / 15 minutes on /operator/api/login specifically
Code hygiene
- All SQL queries use ? placeholders. Two f-string queries in db.py are migration code with hardcoded column names (no user input). Audited.
- All HTML interpolation in the operator panel goes through esc(). Numeric IDs are interpolated unescaped; everything else is escaped. 31 esc() call sites verified.
- Constant-time comparisons: hmac.compare_digest for token verification, cryptography.exceptions.InvalidSignature caught uniformly
- Pre-deploy gate (tools/security_scan.sh) runs: parse check, crypto self-tests, pip-audit dependency CVE scan, bandit static analysis, secret-leak heuristic, encryption-key permission check, and verifies all required HTTP headers are set
9. Cryptographic primitive choices
| Use | Algorithm | Library |
|---|---|---|
| Chip mutual auth | AES-128 EV2First (NXP DESFire EV3) | desfire.py |
| Chip secure messaging | AES-128 CBC + AES-128 CMAC | desfire.py |
| Vault wrap key | 32 bytes random, written to chip | os.urandom |
| Per-credential key | HKDF-SHA256(K_vault, salt=cred_id, info=type) | pyca/cryptography |
| Credential AEAD | ChaCha20-Poly1305 | pyca/cryptography |
| FIDO2 assertion | ECDSA P-256 + SHA-256 (RFC 8152) | pyca/cryptography |
| Originality verify | ECDSA secp224r1, NXP-signed | pyca/cryptography |
| Recovery key sharing | Shamir over GF(2^8), Rijndael poly 0x11B | crypto.py |
| Server master KEK | HKDF-SHA256 from /opt/skinid/.encryption_key | crypto.py |
| Operator passwords | PBKDF2-SHA256, 600 000 iterations, 16-byte salt | operator_auth.py |
| Audit log chaining | SHA-256, Merkle-style | operator_models.py |
| Backups at rest | age (X25519 + ChaCha20-Poly1305) | filippo.io/age |
| TLS | TLS 1.3, HSTS preload | nginx |
| CSPRNG | Linux getrandom(2) on server, SecRandom on iOS, SecureRandom on Android | stdlib |
| Constant-time compare | hmac.compare_digest | Python stdlib |
Every primitive is replaceable. Each encrypted blob carries a one-byte version so we can rotate algorithms without flag-day breakage. pyca/cryptography is the underlying library for everything except DESFire (which we wrote against the NXP datasheet) and Shamir (small-enough scope that we wrote it explicitly).
10. Threat model in detail
| Threat | Mitigation |
|---|---|
| Stolen DB | Ciphertext only. K_vault lives on the chip. Server-side encrypted backup of K_vault requires MASTER_KEY (off-server) to unwrap. |
| Stolen DB + MASTER_KEY | Disaster-recovery branch only. Used only when the customer's chip is destroyed AND self-recovery via Shamir or backup chip has failed AND multi-operator approval. Audit-logged. |
| Stolen chip alone | No DB access, no value to attacker. Chip won't talk meaningfully without a SkinID server in the loop. |
| Stolen chip + DB | Full access for the held-chip duration. Bounded to physical proximity time. Customer revokes by contacting support or tapping a backup chip. |
| Phishing | Passkeys are origin-bound (FIDO2). Phishing fails by construction. Passwords stored under origin-keyed entries; the extension matches origin before serving. |
| Server compromise (RCE) | No customer credentials decryptable without chip taps. Attacker can intercept chip taps for users authenticating during the compromise window. They cannot exfiltrate the entire vault as plaintext. |
| Network eavesdropping | TLS 1.3, HSTS preload, certificate pinning on iOS native client. |
| Insider attack | Operators cannot decrypt vaults. Recovery completion requires multi-operator approval. Tamper-evident audit log makes silent rewriting detectable. |
| Supply chain (ours) | All JS self-hosted. Single dependency that's a real risk surface: pyca/cryptography. We track its CVEs and ship within 7 days of a critical advisory. |
| Supply chain (NXP) | DESFire EV3 has a documented originality signature; we verify it. We don't have a counter for a state-actor-level NXP backdoor; this is a residual risk for any DESFire-based authenticator. |
| Coercion of a single user | Out of scope. If someone forces you to scan, the system can't tell. |
| Physical removal of the chip | Out of scope. Body autonomy is yours. |
| Compromise of user's primary device while the chip is tapped | Bounded. The window of decryptability ends when the user lifts their hand. |
| State-level adversary with arbitrary code execution on user's device | Out of scope. No consumer authenticator defends against this. |
11. Compliance map
- Swiss FADP (revised, in force since 2023): all customer data hosted in Switzerland; data subject rights for access, deletion, portability are exposed via the customer panel; data breach notification within 72 h to the FDPIC and affected users when applicable
- EU GDPR: Article 33 (breach notification) automated procedurally; Article 17 (right to erasure) supported via the operator panel multi-op approval flow; Article 20 (portability) via the existing CSV export
- Cryptographic agility: every blob carries a one-byte version so algorithm rotation is a backwards-compatible migration, not a flag day
- NIST SP 800-63B: passkey (WebAuthn) flows align with AAL3 hardware-backed authenticator characteristics
- OWASP ASVS L2: most controls in place. Outstanding: a formal penetration test by an accredited third party (planned, not yet scheduled)
- SOC 2 Type II: not yet audited. Internal controls are documented (operator runbook, incident response plan, backup procedures) and would form the input to a future audit
12. How to verify our claims
- Pen test under safe harbour: see /bug-bounty. Scope, rules, and disclosure policy are public. No NDA required to start.
- Source review: our SECURITY.md (full architecture document) and the implementation files (desfire.py, vault_crypto.py, operator_auth.py, operator_models.py) can be reviewed under NDA. Email support@skinid.ch.
- Operator panel demo under NDA: we can walk you through the audit-log verification, multi-op approval workflow, chip lifecycle.
- RFC 9116 contact: /.well-known/security.txt
This document describes the architecture as of 2026-05. Material changes will be noted at the bottom of /security.