Skip to content

Instantly share code, notes, and snippets.

@jollyjoker992
Last active July 7, 2025 09:45
Show Gist options
  • Save jollyjoker992/367a9daa82d72a6408ea2aac23c05278 to your computer and use it in GitHub Desktop.
Save jollyjoker992/367a9daa82d72a6408ea2aac23c05278 to your computer and use it in GitHub Desktop.
Unified Passkey & Ed25519

Feral File · Unified Trust & Playlist‑Signing Flow

(Push‑to‑Mobile Ed25519 model, iOS 16 baseline)


1 · Purpose

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.

2 · Core entities & identifiers

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.

3 · Data model (PostgreSQL)

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
);

4 · Key generation & migration (mobile)

  1. User logs in with the existing passkey (Face ID / Touch ID).

  2. App checks human_keys. If missing:

    1. SecKeyCreateRandomKey (Ed25519, secureEnclave=true).

    2. POST /auth/bind-ed25519

      {
        "passkey_assertion": "",          // WebAuthn GetAssertion payload
        "ed25519_pub_b64":  "8JQi…"
      }
    3. Server verifies the passkey and that SHA‑256(ed25519_pub) == last32(publicKey.challenge) (challenge‑cargo trick), then inserts the row into human_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.


5 · Signing flows

5.1 Mobile‑native Create playlist

sequenceDiagram
  MobileApp->>MobileApp: Face/Touch ID prompt
  MobileApp->>FeedServer: POST /playlist {json, ed25519_sig}
  FeedServer-->>MobileApp: 201 {playlistId}
Loading

Payload to sign

payload    = nonce32 ‖ SHA‑256(playlistJSON)
signature  = ED25519_SIGN(private_key, payload)

nonce32 is fetched from /auth/nonce to prevent replay.


5.2 Web Create playlist (Push‑to‑Mobile)

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}
Loading

Step‑by‑step

  1. Web UI posts the playlist draft → backend.

  2. Backend creates a sign_requests row with:

    • nonce = random32()
    • playlist_hash = SHA‑256(playlistJSON)
  3. Backend pushes

    {"type":"SIGN_REQUEST","req":"<req_id>","nonce":"","hash":""}

    to the phone (APNS/FCM).

  4. Phone prompts the user; signs nonce ‖ playlist_hash.

  5. Phone PUTs the signature; backend verifies with human_keys.ed25519_pub.

  6. 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.


6 · API sketches

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 Gone

Push payload (APNS / FCM):

{
  "type": "SIGN_REQUEST",
  "req": "c6442d71-3be4-4d26-8fd6-3d74343762b1",
  "nonce": "9h68…",
  "hash":  "e3b0c442…"
}

7 · Component responsibilities

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.

8 · Security considerations

  • Key isolation — Ed25519 private key stays in Secure Enclave.
  • User verification — Keychain ACL biometryCurrentSet forces 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.

9 · Failure & fallback UX

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)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment