(Push‑to‑Mobile Ed25519 model, iOS 16 baseline)
Enable all mobile users who already authenticate with a passkey to sign any “playlist JSON” (or other DP‑1 content) with a 64‑byte Ed25519 signature — even when the action is triggered from the web UI — while keeping DP‑1’s “Ed25519‑only for human signatures” rule intact.
We achieve this by:
- Generating & storing an Ed25519 key‑pair on the phone (Secure Enclave → Keychain).
- Pushing every web‑origin signing request to the phone (Push‑to‑Mobile).
- Returning the Ed25519 signature to the web page through the backend.
| Entity | Stable ID | Key / Data | Notes |
|---|---|---|---|
| Passkey credential | credential_id (b64url) |
ES‑256 public key (COSE) | iOS 16 generates ES‑256 only; used for login. |
| Human signing key | ed25519_pub (32 B) |
Ed25519 key‑pair | Generated once per user on first post‑migration login. Private key lives in Secure Enclave, Keychain ACL biometryCurrentSet. |
| Device (mobile app) | device_uuid (UUID‑v4) |
— | Install‑scoped ID stored in Keychain. |
| Sign‑request | req_id (UUID‑v4) |
nonce32 ‖ SHA‑256(playlist JSON) |
Transient row while the web waits for the phone. |
create table passkey_credentials (
credential_id text primary key,
user_id text not null,
cose_key bytea not null
);
create table human_keys (
user_id text primary key,
ed25519_pub bytea not null, -- 32 B
created_at timestamptz default now()
);
create table sign_requests (
req_id uuid primary key,
user_id text not null,
playlist_hash bytea not null, -- 32 B
nonce bytea not null, -- 32 B
expires_at timestamptz not null,
signature bytea -- 64 B, set by mobile
);-
User logs in with the existing passkey (Face ID / Touch ID).
-
App checks
human_keys. If missing:-
SecKeyCreateRandomKey(Ed25519,secureEnclave=true). -
POST
/auth/bind-ed25519{ "passkey_assertion": "…", // WebAuthn GetAssertion payload "ed25519_pub_b64": "8JQi…" } -
Server verifies the passkey and that
SHA‑256(ed25519_pub) == last32(publicKey.challenge)(challenge‑cargo trick), then inserts the row intohuman_keys.
-
Byte layout of the binding challenge
[ 16 B random entropy ] ‖ [ 32 B SHA‑256(ed25519_pub) ]
Entire 48‑byte string becomes publicKey.challenge in navigator.credentials.create().
The browser sends only SHA‑256 of this to the authenticator, so length is not limited by CTAP2.
sequenceDiagram
MobileApp->>MobileApp: Face/Touch ID prompt
MobileApp->>FeedServer: POST /playlist {json, ed25519_sig}
FeedServer-->>MobileApp: 201 {playlistId}
Payload to sign
payload = nonce32 ‖ SHA‑256(playlistJSON)
signature = ED25519_SIGN(private_key, payload)
nonce32 is fetched from /auth/nonce to prevent replay.
sequenceDiagram
WebApp->>Backend: POST /playlist {draft}
Backend->>Backend: INSERT sign_requests
Backend-->>Mobile: 🔔 push {req_id}
loop user action
Mobile->>Mobile: Face/Touch ID prompt
Mobile->>Backend: PUT /sign/{req_id} {signature}
end
Backend-->>WebApp: 200 {signature}
Step‑by‑step
-
Web UI posts the playlist draft → backend.
-
Backend creates a
sign_requestsrow with:nonce = random32()playlist_hash = SHA‑256(playlistJSON)
-
Backend pushes
{"type":"SIGN_REQUEST","req":"<req_id>","nonce":"…","hash":"…"}to the phone (APNS/FCM).
-
Phone prompts the user; signs
nonce ‖ playlist_hash. -
Phone PUTs the signature; backend verifies with
human_keys.ed25519_pub. -
Backend updates
sign_requests.signature. If the browser is polling/playlist/drafts/{id}/sig, the promise resolves and the UI continues.
Timeout — after expires_at (default 60 s) backend returns 408.
POST /playlist/drafts
{ title, items[…] }
→ 202 Accepted { req_id }
GET /playlist/drafts/{id}/sig
→ 200 { signature } | 202 Accepted | 408 Request Timeout
PUT /sign/{req_id}
{ signature }
→ 200 OK | 400 Bad signature | 410 GonePush payload (APNS / FCM):
{
"type": "SIGN_REQUEST",
"req": "c6442d71-3be4-4d26-8fd6-3d74343762b1",
"nonce": "9h68…",
"hash": "e3b0c442…"
}| Component | Owner (team) | Responsibilities |
|---|---|---|
| Mobile app | Mobile | Generate & store Ed25519; biometric‑gate each sign; push handler. |
| Feed server | Backend | Verify Ed25519 signatures; DP‑1 ingestion. |
| Auth service | Auth | Passkey login; challenge‑cargo binding; issue nonces; manage sign_requests. |
| Push bridge | DevOps | APNS tokens, FCM routing, retry / error metrics. |
| Web frontend | Web | Draft playlists; poll /sig endpoint;show “Confirm on phone” banner or QR deep‑link. |
- Key isolation — Ed25519 private key stays in Secure Enclave.
- User verification — Keychain ACL
biometryCurrentSetforces a fresh Face/Touch ID on every signature. - Replay defence — 32‑byte nonce, single use, 60 s expiry.
- Transport — All traffic over TLS; APNS/FCM payload encryption.
- Audit — Log
req_id,user_id,device_uuid, timestamps, verification result.
| Scenario | Web UI reaction |
|---|---|
| Phone offline | Banner: “Open the Feral File app to confirm” + QR deep‑link (ff://sign/<req_id>). |
| User cancels biometric | Show “Signature cancelled”, let user retry. |
| Timeout (60 s) | Auto‑retry draft POST (new req_id). |
| Push blocked | Same QR deep‑link fallback. |
Draft updated 07‑Jul‑2025 (Asia/Ho Chi Minh)