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
- The integrator never touches Cryptograph key material. All signing happens on the watch after on-watch display and approval.
- No cloud intermediary. Both legs are local app-to-app Universal Links — no relay server, no WalletConnect-style infrastructure.
- Permissionless. Any app that owns a Universal-Link domain can integrate. There is no registry and no allowlist.
- Domain-bound identity. Each side is identified by a DNS domain proven by Apple's Universal-Link association (AASA), not by self-asserted metadata.
- Auditable. Small surface, standard primitives (P-256 ECDSA, RFC 9180 HPKE, RFC 8785 JCS), explicit test vectors.
Terminology and roles
- Integrator (also peer): the third-party wallet app requesting signatures. Identified by its peer domain — the host of its callback URL.
- Relay: the Cryptograph iPhone app. Receives requests at
https://cryptograph.watch/…, enforces pairing state, forwards approved work to the signer, and delivers responses to the integrator callback. - Signer: the Cryptograph Watch app. Renders every request on its own display from the same bytes it signs, and holds the approval surface.
- Pairing: the durable relationship between one integrator installation and one Cryptograph installation: pinned keys, grants, counters.
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
| URL | Message type(s) |
|---|---|
https://cryptograph.watch/pair | pair_request |
https://cryptograph.watch/accounts | accounts_request |
https://cryptograph.watch/sign | sign_request |
https://cryptograph.watch/revoke | revoke |
https://cryptograph.watch/rotate | rotate_key |
| integrator callback URL | pair_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:
req(requests) orres(responses): base64url, no padding, of the UTF-8 JSON envelope.sig: base64url, no padding, of the sender's signature.
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:
| Function | Primitive |
|---|---|
| Message signatures | ECDSA over NIST P-256, SHA-256 digest, raw 64-byte r‖s |
| Body encryption | HPKE base mode (RFC 9180): DHKEM(P-256, HKDF-SHA256) = 0x0010, HKDF-SHA256 = 0x0001, AES-256-GCM = 0x0002 |
| Hashing | SHA-256 |
| Canonicalization | JCS (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).
- Public keys: SEC1 compressed points, 33 bytes.
- Signatures: raw 64-byte
r‖s. - Nonces: 32 random bytes.
- HPKE output:
enc ‖ ctconcatenated, whereencis the 65-byte uncompressed encapsulated key defined by DHKEM(P-256).
Domain and address forms
- Domains are lowercase ASCII (IDNA A-labels). Displaying software MUST render the A-label form (
xn--…) for any non-ASCII domain rather than the Unicode form, to defeat homograph spoofing. - EVM accounts are EIP-55 checksummed addresses. Mixed-case addresses MUST carry a valid EIP-55 checksum; all-lowercase forms are accepted (they carry no checksum to verify).
- Chains are CAIP-2 identifiers restricted to the
eip155namespace in v1, e.g.eip155:1.
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:
- All numbers MUST be integers with magnitude ≤ 253 − 1. Envelope-level fields (
v,ctr,iat,exp) MUST additionally be non-negative. (Sealed bodies may carry negative integers — EIP-712 messages legitimately contain them.) - Strings MUST be valid UTF-8; no lone surrogates.
- No
nullvalues: absent means absent.
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)>"
}
| Field | Type | Presence | Meaning |
|---|---|---|---|
v | int | always | Protocol version. This document defines 1. |
type | string | always | Message type. |
pairing_id | string | all except pair_request | UUID assigned by the relay at pairing. |
ctr | int | all except pair_request/pair_response | Sender's strictly-increasing counter. |
iat | int | always | Issued-at, Unix seconds. |
exp | int | requests | Expiry, Unix seconds. exp − iat MUST be ≤ 600 for sign_request; receivers allow ±120 s clock skew on iat. |
body | string | type-dependent | HPKE-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:
info= UTF-8"CSP1 body"aad= UTF-8"CSP1|" + type + "|" + pairing_id + "|" + ctr(forpair_response, which precedes counters,ctris the literal0).
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:
- Transport: message arrived on an allowed Universal-Link path (never a custom scheme).
- Envelope parses,
vsupported,typeknown for this endpoint, no unknown fields. - 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. - Signature verifies against the pinned peer signature key (
pair_request: against the key inside the envelope). ctrstrictly greater than the stored high-water mark.iat/expwindow valid.- Body unseals with correct AAD.
- 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
}
cb: the callback URL. MUST behttps, MUST NOT contain query or fragment, MUST NOT be an IP literal orlocalhost, and MUST NOT be the relay host itself (cryptograph.watch) — its endpoints belong to the relay. Subdomains of the relay host are permitted: only the relay operator can associate apps with them, so they serve first-party demo/test integrators. The callback host is the peer domain and the identity shown to the user everywhere.app_name: display-only hint, always rendered subordinate to the peer domain and never in place of it.- The envelope is signed by
peer_sig_pubitself: a proof of possession, not yet a proof of identity. Identity is established by delivering the response tocb, which only the app associated with that domain can receive.
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_signTransaction — payload 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_sign — payload 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_v4 — payload 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
- Local revocation. Either side can unilaterally mark a pairing revoked. Cryptograph exposes this in Settings → Connected Wallets; all subsequent messages get
revoked. revokemessage. A signed envelope (no body) telling the other side to mark the pairing revoked. Best-effort: local revocation is already final; the message keeps both UIs truthful.rotate_key. Sealed body{ "new_sig_pub": …, "new_kem_pub": …, "proof": … }where either key may be present andproofis a signature by the new signature key over"CSP1 rotate" ‖ pairing_id ‖ ctr ‖ new_sig_pub ‖ new_kem_pub(base64url values joined with|; absent keys contribute an empty field). The envelope itself is signed by the current key. Receivers pin the new key(s) only after both checks pass. Rotation does not change grants or counters.
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
- URL capacity. If a real integrator exceeds the size limits, v2 will keep the Universal Link as the authenticated control channel carrying a SHA-256 commitment plus an out-of-band sealed blob transfer. Not needed in v1.
- EVM only.
chainis restricted toeip155:*. The envelope, pairing, and grant machinery are chain-agnostic by construction; additional namespaces are a v2 concern gated on the watch having display-grade decoding for them. - iOS only. An Android equivalent (App Links) is tracked separately.
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:
@cryptograph/signer-protocol— the TypeScript SDK (pure JS, React Native compatible), with Jest suites including RFC 9180 HPKE vectors.- Cross-implementation vectors — deterministic fixtures generated by the TypeScript implementation and verified byte-for-byte by the Swift implementation: JCS canonicalization, signing inputs and signatures, HPKE seals, and a complete pair/sign transcript.
- SignerDemoWallet — a native SwiftUI demo wallet exercising the full flow, and a React Native shell showing the
Linking-based integration pattern.
Integration questions or audit reports: contact@cryptograph.watch · security disclosures: see Bug Bounty.