IBExWalletAPI
Advanced

Simple Signup (PIN/KDF)

IBEXFI API — Simple Signup (PIN-Derived Safe Signer)

Design note for a signup flow that issues a JWT without opening a wallet, then lets the user set a secret (PIN/password) to deterministically derive a Safe signer key that is never stored and can be recovered via Safe guardians/passkeys.

Goals

  • No passkey required at signup v1.2; only ExternalUserId + JWT are created.
  • User later defines a secret (PIN/password) that we never see; key is re-derived on demand.
  • Safe signer derived from KDF + HKDF; challenge only for proof-of-possession (not in KDF).
  • Support recovery via existing Safe recovery (guardian/multi-signer, passkey).
  • Limit brute force, replay, phishing; allow KDF/parameter versioning.

Cryptographic choices

  • KDF: Argon2id (example: memory 64 MiB, iterations 3, parallelism 1; versioned).
  • Key derivation: kdfKey = Argon2id(secret, salt, params) then privateKey = HKDF(kdfKey, info="ibex-safe-signer|appId|env|ExternalUserId"); publicKey/EOA derived client-side. Including appId (or env) and ExternalUserId in HKDF info domains the key to this app/user and prevents cross-use.
  • Challenge: random, expiring, single-use; used only in the signed proof message, not in KDF.
  • Optional PAKE: OPAQUE (recommended if secrets can be weak PINs); prevents offline guessing and authenticates the server.
  • Encoding: base64 for binary blobs, hex for addresses/public keys; canonical JSON (stable key order) for messages.

Enrollment (one-time, when the user sets the secret)

If PAKE is enabled:

  1. Client runs OPAQUE register with secret.
  2. Server stores: envelope, serverPub, salt, saltVersion, kdfParamsVersion. If PAKE is not used, only salt (per ExternalUserId) and KDF params are stored.

start-derive endpoint (wallet=kdf or wallet=email)

  • POST /auth/start-derive (conceptual; in v1.2 use GET /auth/sign-up with wallet=kdf|email)
  • Auth: Bearer JWT containing ExternalUserId.
  • Request: optional deviceInfo object.
  • Response:
    {
      "externalUserId": "abc123",
      "envelope": "b64-opaque-envelope",        // only if PAKE
      "serverPub": "b64-opaque-server-pub",     // only if PAKE
      "salt": "base64-salt",
      "saltVersion": 1,
      "kdf": { "algo": "argon2id", "memory": 65536, "iterations": 3, "parallelism": 1 },
      "kdfParamsVersion": 1,
      "challenge": "base64-nonce",
      "challengeExpiresAt": "2025-12-11T10:15:00Z",
      "serverSignature": "sig(challenge||exp||externalUserId)" // anti-tamper
    }

Client-side derivation (per session)

  1. kdfKey = Argon2id(secret, salt, params)
  2. privateKey = HKDF(kdfKey, info="ibex-safe-signer|appId|env|ExternalUserId")
  3. publicKey (or EOA) derived from privateKey
  4. Build canonical message to sign:
    {
      "challenge": "base64-nonce",
      "challengeExpiresAt": "2025-12-11T10:15:00Z",
      "challengeId": "uuid-or-db-id",           // if tracked server-side
      "externalUserId": "abc123",
      "saltVersion": 1,
      "kdfParamsVersion": 1,
      "appId": "ibex-app",
      "timestamp": 1733918400,
      "nonce": "base64-random"
    }
  5. signature = sig(privateKey, canonicalMessage)
  6. If PAKE is enabled, produce opaqueLoginRequest from OPAQUE using secret + envelope.

finish-derive endpoint

  • POST /auth/finish-derive
  • Request:
    {
      "externalUserId": "abc123",
      "opaqueLoginRequest": "b64-opaque-msg1",  // only if PAKE
      "publicKey": "0x...",                     // or derived address
      "challenge": "base64-nonce",
      "saltVersion": 1,
      "kdfParamsVersion": 1,
      "nonce": "base64-random",
      "timestamp": 1733918400,
      "signature": "base64-sig-over-message",
      "serverSignature": "sig(challenge||exp||externalUserId)"
    }
  • Server processing:
    1. Verify serverSignature, challenge validity (exp, single-use, bound to ExternalUserId and appId). Reject replay (challenge already used) and check timestamp is within an acceptable skew; optionally reject reused (challenge, nonce).
    2. If PAKE: process opaqueLoginRequest, return opaqueLoginResponse, derive session key (auth of secret; server proves itself).
    3. Verify signature with publicKey over canonical message.
    4. Enforce lifecycle: first-time bind ExternalUserIdpublicKey if none exists; otherwise ensure publicKey is authorized/expected (supports signerVersion/keyId for rotation).
    5. If OK, authorize Safe ownership by publicKey; optionally return a short-lived token to proceed with Safe deploy.
  • Response (example):
    {
      "status": "ok",
      "allowedOperations": ["safe_deploy"],
      "sessionToken": "short-lived-jwt"
    }

Recovery & rotation

  • Recovery is handled by existing Safe recovery (guardian/passkey, multi-signer). If the user forgets the secret, recovery adds/rotates a signer.
  • saltVersion and kdfParamsVersion are stored; allow multiple active versions during migration. Prompt re-derivation on next finish-derive when upgrading parameters.
  • Forced KDF upgrade scenario: if client sends kdfParamsVersion lower than currentMinVersion, server can require re-derivation with a fresh challenge and the new parameters, keeping both versions valid during a migration window.

Security notes

  • Brute force: Argon2id cost, minimum secret length policy, server rate limiting on finish-derive.
  • Replay/phishing: short-lived, single-use challenge; include appId/origin in signed message; with PAKE, the server must prove identity.
  • Data-at-rest: store only salt, versions, and (if PAKE) OPAQUE envelope + server public key; never store the derived key.
  • UX: tune Argon2id params to balance latency on target devices; adjust parallelism if clients can leverage multiple cores.
  • Rate limiting: per ExternalUserId, per IP, optionally per device; consider exponential backoff after N failures and step-up auth (OTP/guardian) after a threshold. Binding appId to origin/bundleId reduces phishing.
  • Canonical JSON: strict lexicographic key order, UTF-8, no extraneous spaces/newlines; same serializer across platforms to avoid signature mismatches.
  • UX/perf guidance: benchmark per platform; consider profiles (mobile vs desktop). Show user feedback during KDF (“Securing your signer…”). Explain clearly: the Safe key is derived client-side from the secret; backend never sees the secret nor the key.

Server signature (challenge integrity)

  • Example: serverSignature = sig_server(challenge || challengeExpiresAt || externalUserId || appId) using Ed25519 or secp256k1.
  • Include serverKeyId in the response to support key rotation. Clients verify with the advertised server public key matching serverKeyId.
  • Binding appId prevents a challenge from app A being reused on app B.

Signer lifecycle (first bind, re-derivation, rotation)

  • First bind: on the first successful finish-derive, bind ExternalUserId to publicKey (Safe signer). Reject a second “first bind” unless explicitly allowed.
  • Re-derivation/login: on subsequent finish-derive, ensure publicKey matches an authorized signer for that ExternalUserId, or belongs to an allowed rotation slot (signerVersion/keyId).
  • Rotation: allow adding a new signer with a controlled flow (challenge + policy) and marking the old signer as deprecated; keep both during migration if needed.

Endpoint strategy (reuse vs dedicated)

  • You can keep existing /auth/signup and /auth/signin and add a mode flag, e.g. passkeys=false, flow=pin-kdf, plus payload fields for the derive flow (envelope/serverPub, challenge, opaqueLoginRequest/Response, etc.).
  • Pro: backward compatibility and fewer new routes; fits “Simple ExternalUserId creation (passkeys=false, no email)” and “Email validation (passkeys=false, email provided)”.
  • Con: risk of overloading semantics; ensure the responses clearly include the derive materials when flow=pin-kdf. Dedicated endpoints (/auth/start-derive, /auth/finish-derive) stay clearer and reduce accidental misuse.
  • Whichever you pick, keep the contract stable: challenge single-use, serverKeyId for signatures, optional OPAQUE fields, and explicit versions (saltVersion, kdfParamsVersion).
  • If passkeys=true, ignore/reject the PIN-KDF-specific fields to avoid mixing flows. If passkeys=false, accept the PIN-KDF fields (salt/challenge/envelope/opaqueLoginRequest, etc.) in the same endpoints.

Minimal non-PAKE variant

If PAKE is not implemented, keep the same start-derive/finish-derive contracts, omit envelope/serverPub/opaqueLoginRequest, and rely on challenge + KDF + signature. This is simpler but weaker for short secrets (more exposed to offline brute force if salts leak).