← Home

Cryptograph Signer Protocol

Version 1 · Draft for audit · Add watch-approved signing to any iOS wallet — no relay server, no registry, no permission required.

The Cryptograph Signer Protocol (CSP) lets any iOS wallet app request EVM signatures from the Cryptograph watch-side signer. The wallet keeps its own UX — dapp routing, simulation, portfolio — and moves only the moment of signing to hardware the user wears: keys never leave the Apple Watch, and every signature is approved on the watch's own display.

Your wallet app
    │  https://cryptograph.watch/sign?req=…&sig=…      (Universal Link)
    ▼
Cryptograph iPhone app        — validates, decrypts, enforces grants
    │
    ▼
Cryptograph Watch app         — displays request + your verified domain,
    │                            user approves, Secure Enclave-guarded key signs
    ▼
Cryptograph iPhone app
    │  https://your-domain/callback?res=…&sig=…        (Universal Link)
    ▼
Your wallet app

Integrating? Use the TypeScript SDK @cryptograph/signer-protocol and the reference implementations in the open-source repository, which also carries the cross-implementation test vectors and a working demo wallet. Questions: contact@cryptograph.watch.

Design goals

  1. The integrator never touches Cryptograph key material. All signing happens on the watch after on-watch display and approval.
  2. No cloud intermediary. Both legs are local app-to-app Universal Links — no relay server, no WalletConnect-style infrastructure.
  3. Permissionless. Any app that owns a Universal-Link domain can integrate. There is no registry and no allowlist.
  4. Domain-bound identity. Each side is identified by a DNS domain proven by Apple's Universal-Link association (AASA), not by self-asserted metadata.
  5. Auditable. Small surface, standard primitives (P-256 ECDSA, RFC 9180 HPKE, RFC 8785 JCS), explicit test vectors.

Terminology and roles

Requirement words (MUST, MUST NOT, SHOULD, MAY) follow RFC 2119.

Transport

Universal Links only

Every protocol message is an https URL opened app-to-app. Requests open https://cryptograph.watch/<endpoint>; responses open the integrator's callback URL, which MUST be an https URL on a domain the integrator controls and has associated with its app via AASA.

Both sides MUST register the relevant paths in their apple-app-site-association file and hold the associated-domains entitlement. Domain control is the identity primitive of this protocol: iOS will only deliver https://cryptograph.watch/sign to the genuine Cryptograph app, and only deliver the callback to the app associated with the callback domain.

Custom URL schemes are detection-only

The cryptograph:// scheme exists solely so integrators can probe installation with canOpenURL (declare cryptograph in LSApplicationQueriesSchemes). The relay rejects any protocol message that arrives over a custom scheme, with no callback issued. Custom schemes are claimable by any app and carry no domain proof; they MUST NOT carry signer messages in either direction.

Endpoints

URLMessage type(s)
https://cryptograph.watch/pairpair_request
https://cryptograph.watch/accountsaccounts_request
https://cryptograph.watch/signsign_request
https://cryptograph.watch/revokerevoke
https://cryptograph.watch/rotaterotate_key
integrator callback URLpair_response, accounts_response, sign_response, revoke, rotate_key

All response types are delivered to the single callback URL fixed at pairing time. Integrators dispatch on the envelope type field, not the path.

URL encoding and size limits

Each message URL carries exactly two query parameters:

Senders MUST keep the total URL length at or below 65,536 bytes and the serialized envelope at or below 49,152 bytes. A relay receiving an oversized request responds payload_too_large. Realistic eth_signTypedData_v4 payloads (tested against mainnet Permit2, Seaport, and Blur orders) stay under 20% of budget.

Browser fallback

If the counterpart app is not installed (or AASA association fails), iOS opens the URL in Safari and the envelope reaches a web server. The protocol treats every URL as potentially logged by infrastructure: nothing confidential ever appears outside an HPKE-sealed body, and the Cryptograph endpoints serve an instructional web page and discard parameters. Integrator callback pages SHOULD do the equivalent. A leaked envelope is replayable only until its exp and only once per counter, and its body is unreadable without the recipient's private KEM key.

Cryptographic suite

Version 1 defines a single suite, CSP1:

FunctionPrimitive
Message signaturesECDSA over NIST P-256, SHA-256 digest, raw 64-byte r‖s
Body encryptionHPKE base mode (RFC 9180): DHKEM(P-256, HKDF-SHA256) = 0x0010, HKDF-SHA256 = 0x0001, AES-256-GCM = 0x0002
HashingSHA-256
CanonicalizationJCS (RFC 8785), restricted profile

Each side holds two long-term P-256 key pairs per pairing: a signature key (*_sig_pub) used to sign envelopes, and a KEM key (*_kem_pub) that the other side encrypts bodies to.

Keys MUST be unique per pairing so that pairings are unlinkable across integrators and revocation is surgical. Private keys MUST be stored with device-only protection (on iOS: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, never synchronized). Cryptograph's chain keys are unrelated to these keys and never leave the watch; CSP keys are transport identity keys only.

Encodings

Binary values

All binary values are base64url without padding (RFC 4648 §5).

Domain and address forms

Canonical JSON (JCS profile)

Signatures cover the JCS (RFC 8785) serialization of the envelope. CSP1 additionally restricts all protocol JSON (envelopes and sealed bodies) so that every conforming JSON serializer can produce the canonical form:

Under these restrictions JCS reduces to: UTF-8, object keys sorted by code point, no insignificant whitespace, integers serialized without exponent, sign, or leading zeros.

Envelope

Every message is one JSON object:

{
  "v": 1,
  "type": "sign_request",
  "pairing_id": "6BE2A38E-…",
  "ctr": 17,
  "iat": 1782300000,
  "exp": 1782300300,
  "body": "<base64url(enc ‖ ct)>"
}
FieldTypePresenceMeaning
vintalwaysProtocol version. This document defines 1.
typestringalwaysMessage type.
pairing_idstringall except pair_requestUUID assigned by the relay at pairing.
ctrintall except pair_request/pair_responseSender's strictly-increasing counter.
iatintalwaysIssued-at, Unix seconds.
expintrequestsExpiry, Unix seconds. exp − iat MUST be ≤ 600 for sign_request; receivers allow ±120 s clock skew on iat.
bodystringtype-dependentHPKE-sealed content.

Unknown fields MUST be rejected (invalid_request) — the envelope is part of the signed surface and silent extensions invite cross-version confusion.

Body sealing

body is produced by single-shot HPKE base-mode seal to the recipient's KEM public key with:

The AAD binds the ciphertext to its envelope position so a sealed body cannot be transplanted between messages, even by a sender whose signature key is compromised later.

Message authentication

Every message is signed by the sender's pairing signature key. The signature input is the UTF-8 encoding of:

"CSP1" ‖ "|" ‖ type ‖ "|" ‖ destination_host ‖ "|" ‖ JCS(envelope)

where destination_host is the lowercase host of the URL the sender opens (cryptograph.watch for requests; the callback host for responses). Binding the destination host means a message captured in transit to one domain cannot be replayed to a different domain. The ECDSA-P256/SHA-256 signature travels in the sig query parameter.

Receivers MUST validate in this order and stop at the first failure:

  1. Transport: message arrived on an allowed Universal-Link path (never a custom scheme).
  2. Envelope parses, v supported, type known for this endpoint, no unknown fields.
  3. Pairing exists and is not revoked; for pair_request, no live pairing exists for the identical peer domain and key — re-pairing requires fresh consent.
  4. Signature verifies against the pinned peer signature key (pair_request: against the key inside the envelope).
  5. ctr strictly greater than the stored high-water mark.
  6. iat/exp window valid.
  7. Body unseals with correct AAD.
  8. Content rules for the type: grants, methods, checksums.

Only after step 5 succeeds is the counter high-water mark advanced; steps 6–8 failing still consume the counter (a signed, replay-protected message was seen).

If validation fails at or before step 4, the relay MUST NOT open the callback at all — an unauthenticated caller must not be able to use Cryptograph as a URL-opening oracle. Failures at step 5 onward produce a signed error response.

Pairing

pair_request (integrator → relay)

The integrator generates fresh signature and KEM key pairs and a nonce, then opens:

https://cryptograph.watch/pair?req=<b64url(envelope)>&sig=<b64url(signature)>
{
  "v": 1,
  "type": "pair_request",
  "cb": "https://links.rabby.io/cryptograph-callback",
  "peer_sig_pub": "<b64url 33B>",
  "peer_kem_pub": "<b64url 33B>",
  "nonce_peer": "<b64url 32B>",
  "app_name": "Rabby",
  "iat": 1782300000,
  "exp": 1782300600
}

User consent

The relay displays the peer domain and collects explicit account/chain grants:

Allow links.rabby.io to request signatures from these accounts?

The user selects which EVM accounts and chains the pairing may reference. The pairing is then confirmed on the watch — domain plus granted accounts on the watch's own display — before any callback is issued. A pairing that the watch has not confirmed does not exist.

pair_response (relay → integrator)

{
  "v": 1,
  "type": "pair_response",
  "pairing_id": "6BE2A38E-…",
  "cg_sig_pub": "<b64url 33B>",
  "cg_kem_pub": "<b64url 33B>",
  "nonce_peer": "<echoed>",
  "nonce_cg": "<b64url 32B>",
  "req_hash": "<b64url 32B SHA-256 of JCS(pair_request envelope)>",
  "iat": 1782300042,
  "body": "<sealed grants>"
}

Sealed body:

{
  "grants": [
    { "account": "0xAb58…c9E7", "chains": ["eip155:1", "eip155:42161"] }
  ],
  "methods": ["eth_signTransaction", "personal_sign", "eth_signTypedData_v4"],
  "limits": { "max_envelope_bytes": 49152, "max_pending_sign": 1 }
}

req_hash closes the transcript: the integrator MUST recompute the hash of the exact pair_request envelope it sent and compare. Together with the echoed nonce_peer and the domain-bound delivery of both legs, this yields mutual authentication in two messages. On success the integrator pins cg_sig_pub/cg_kem_pub and stores the pairing.

If the user declines, the relay opens the callback with a pair_response envelope containing only nonce_peer, req_hash, cg_sig_pub, and error: "rejected_by_user" — no pairing_id, no KEM key, no body. Like pair_request, its signature is a self-signed proof of possession; the integrator's assurance that the decline is genuine comes from the domain-bound delivery.

Activation

A pairing is provisional on the relay until the first correctly signed integrator message referencing it, which proves the integrator received the pair_response and completed its side. Provisional pairings expire after 10 minutes and are not shown as connected wallets. This replaces a third pairing leg — one fewer app switch with the same transcript guarantee.

Accounts

accounts_request (integrator → relay) has no body. The relay responds with accounts_response whose sealed body is identical in shape to the pair_response body — the current grants, which may have narrowed since pairing. Integrators SHOULD call this on wallet unlock to reconcile revoked or added grants.

Signing

sign_request (integrator → relay)

Sealed body:

{
  "request_id": "0E1FA0C4-…",
  "account": "0xAb58…c9E7",
  "chain": "eip155:1",
  "method": "eth_signTransaction",
  "payload": { … }
}

request_id is a fresh UUID chosen by the integrator; the matching response echoes it.

Methods and payloads

v1 supports exactly three methods:

eth_signTransactionpayload is a transaction object with 0x-hex string members: required to (or absent for contract creation), value, data, nonce, chainId, gas; either gasPrice or maxFeePerGas + maxPriorityFeePerGas. The integrator is responsible for nonce and fee selection (it owns the RPC relationship). The result is the RLP-encoded signed raw transaction; Cryptograph never broadcasts. The chainId inside the payload MUST match the envelope-level chain. Requests arriving with method name eth_sendTransaction are treated as eth_signTransaction — same semantics, signed bytes returned to the integrator to broadcast.

personal_signpayload is { "message": "0x…" }, the raw bytes to sign under the EIP-191 personal-message prefix. The watch displays the UTF-8 decoding when the bytes are valid UTF-8, otherwise hex, and marks non-UTF-8 messages as opaque.

eth_signTypedData_v4payload is { "typedData": { … } }, the parsed EIP-712 object (not a doubly-encoded string). The relay recomputes the EIP-712 domain and message hashes; the watch renders the decoded structure through the same display guards as WalletConnect typed-data requests.

eth_sign is not supported (unsupported_method). It signs arbitrary 32-byte digests with no displayable structure, which cannot satisfy the watch's display-what-you-sign invariant.

Relay and signer obligations

The relay verifies the account/chain pair is granted and the method is in the pairing's capability list, then constructs the signing intent from the decrypted payload bytes — the same bytes are hashed into the intent's commitment, shipped to the watch, decoded on the watch, displayed, and signed. The watch approval surface shows the peer domain prominently:

links.rabby.io requests signature

together with all consequential transaction details. If any detail cannot be decoded and displayed, the watch fails closed and the relay responds decode_failed. At most one sign request may be in flight per pairing; further requests are refused with busy.

sign_response (relay → integrator)

Sealed body, success:

{ "request_id": "0E1FA0C4-…", "result": { "signedTransaction": "0x02f8…" } }

personal_sign and eth_signTypedData_v4 return { "signature": "0x…" } (65-byte r‖s‖v). Failure:

{ "request_id": "0E1FA0C4-…", "error": { "code": "rejected_by_user", "message": "Declined on watch" } }

Error codes

invalid_request, unsupported_version, unknown_pairing, revoked, invalid_signature, counter_replayed, expired, grant_violation, unsupported_method, payload_too_large, decode_failed, rejected_by_user, timeout, busy, rate_limited, internal_error.

Errors at or below signature validation produce no callback. All others are signed, sealed responses. timeout is issued when the watch approval window (the envelope exp) lapses. Requests beyond the relay's rate limit (10/minute per pairing) are dropped without a callback — every callback is an app switch, so answering a flood would amplify it.

Replay protection

Each direction of each pairing has an independent counter starting at 1 with the first post-pairing message. Senders increment monotonically; receivers store the highest verified value and reject ctr ≤ high-water. Counters need not be contiguous — senders MAY burn values (e.g. crashed before opening the URL). Both sides MUST persist counters atomically with the pairing record; losing counter state invalidates the pairing (fail closed, re-pair).

Integrator rule of thumb: persist the advanced counter before opening the request URL. Never reuse a counter value.

Revocation and rotation

Grants

Grants are fixed at pairing and only ever narrowed without a new pairing ceremony: the user may revoke an account or chain from Cryptograph settings at any time (reflected in the next accounts_response), but adding accounts, chains, or methods requires a fresh pair_request and watch confirmation. There are no session keys, no automatic approvals, and no background signing in v1; every sign_request requires an explicit watch approval.

Threat model

Malicious app on the same phone. Can open cryptograph.watch URLs freely. Cannot receive callbacks for a domain it doesn't own (AASA), so it cannot complete a pairing under a foreign identity; a pairing it does complete is labeled with its own domain everywhere, including on the watch at every signing approval. Cannot read sealed bodies of other pairings. Consent fatigue is bounded by rate limits and by pairing requiring watch confirmation.

Network / infrastructure observer. Sees URLs only if a leg falls back to Safari. Learns envelope metadata (type, pairing id, counter), never body content. Cannot forge (signatures), replay (counters + destination-host binding + expiry), or redirect (host binding) messages.

Compromised integrator (key theft). Can request signatures within existing grants — every one still requires watch display and approval, which is the security floor of the whole design. Cannot widen grants without a new watch ceremony. Recovery: revoke the pairing.

Compromised relay (Cryptograph iPhone app). Cannot forge chain signatures (keys on watch) and cannot bypass on-watch display of the signed bytes (commitment-hash binding, watch-side decode). It can misattribute the peer domain shown on the watch, censor requests, and leak request metadata. Domain attribution on the watch is therefore phone-attested; payload display is watch-verified. This matches the existing WalletConnect trust posture and is the deliberate residual risk of v1.

Phishing domains. Identity is the callback domain string; a user can still be socially engineered to pair with a look-alike domain. Mitigations: A-label rendering, a phishing-domain screen at pair time, the domain shown at every signing approval, and app_name never substituting for the domain.

Out of scope for v1: a jailbroken/compromised iOS (Universal Link routing integrity assumed), a malicious integrator socially engineering approval of transactions the user genuinely sees and approves (transaction-content warnings are the watch decoder's job and shared with all signing paths), and traffic analysis of app switches.

Known limits and forward plan

Versioning

v is an integer. Receivers reject unknown versions with unsupported_version (signed response where a pairing exists, silent drop otherwise). Suite agility is deliberately absent: a future suite is a new protocol version, not a negotiation.

Test vectors and reference code

The open-source repository carries:

Integration questions or audit reports: contact@cryptograph.watch · security disclosures: see Bug Bounty.