Skip to content

Support EVM-key attestations in signatures[] via link.evm #217

Description

@aspiers

Summary

PR #170 (prematurely merged in 478f5be, will be resubmitted) added optional cryptographic signatures[] arrays to every record lexicon, conforming to Nick Gerakines' ATProtocol Attestation Specification at badge.blue. The signing path that landed is the spec-canonical one: signatures verify against a public key resolved from the signer's DID document (did:plc / W3C-DID).

That path doesn't cover a use case the project clearly cares about: a user signing a Hypercerts record with the same EVM key they used to attest a DID↔wallet binding via app.certified.link.evm. This issue tracks closing that gap.

Why this matters

The PR's framing of link.evm explicitly opened the door for this: the EIP-712 proof field on link.evm proves wallet consent (the EVM key holder agreed to the binding); the signatures[] array proves record provenance (this record was minted by a known party). Both are useful, and they don't conflict. But provenance-by-EVM-key only works if a verifier can actually verify an EVM-keyed attestation, and the current verification path can't.

If we don't close this gap:

  • Users who have already linked an EVM wallet (the natural Hypercerts demographic) can't reuse that key to sign records.
  • Platform operators wanting to attest records via familiar EVM tooling (MetaMask, WalletConnect, hardware wallets) have no path.
  • The "two orthogonal integrity primitives" story we documented on link.evm is half-implemented.

What's blocking direct reuse

Three independent issues:

1. Verification architecture mismatch

The spec uses what we'll call Model A: verify against a published pubkey. Input is a did: reference resolvable to a DID document containing a verificationMethod with publicKeyMultibase. The multicodec prefix on the multibase blob picks the curve (0xE701 = K-256, 0x1200 = P-256; badge.blue additionally lists P-384). Verifier runs ECDSA_Verify(pubkey, hash(message), r, s) and returns a boolean. The signature is r || s; no recovery byte is needed because the pubkey is already known.

EVM uses Model B: recover the signer's identity from the signature itself. Input is an address (a 20-byte keccak hash of the pubkey), not a pubkey. ecrecover(hash(message), r, s, v) reconstructs one of two candidate pubkeys from (r, s) and derives its address. The recovery byte v (0 or 1, sometimes encoded as 27/28) disambiguates which of the two valid candidate points on the curve to pick. Verifier compares the recovered address to the known address. Standard EVM signatures are 65 bytes: r || s || v.

Both are full-strength ECDSA. The difference is where the pubkey comes from — resolved (Model A) versus recovered (Model B). A verifier supporting EVM keys needs a second code path implementing Model B, distinct from the spec's Model A.

2. Signature shape (the v byte is orthogonal to low-S normalization)

The spec mandates low-S normalization (BIP-0062) and is silent on recovery bytes. Low-S normalization solves signature malleability (without it, both (r, s) and (r, n − s) are valid for the same message+key pair). The v byte solves a different problem — disambiguating between the two candidate pubkeys that ecrecover could produce from a given (r, s). They address orthogonal concerns and are not substitutes.

Because the spec doesn't mention v, two coherent design choices exist for storing EVM-key attestations:

  • Store 64 bytes (drop v); EVM-aware verifier tries both candidate addresses and accepts a match against either. Costs one extra ecrecover per verification. Not weaker — the security depends on whether one of the two candidate addresses matches the known address, not on which v happens to be on the wire.
  • Store 65 bytes (r || s || v) — wire-valid against our signature: bytes lexicon field (no length constraint), and what EVM tooling produces natively. Spec verifiers running pure Model A wouldn't recognise the extra byte, but they wouldn't be the audience for EVM-keyed attestations anyway.
  • Store 65 bytes via a sibling def — e.g. app.certified.signature.defs#evmInline — that is explicitly Model-B-shaped and carries v in a distinct field. Cleaner schema-level discriminator; adds a new lexicon def.

3. The signed payload differs

The spec says: ECDSA-sign the bare 36-byte CIDv1 of the record (with $sig repository binding inserted). Standard ECDSA libraries SHA-256-hash this input internally before signing.

EVM wallets (MetaMask, Coinbase Wallet, hardware wallets, WalletConnect) cannot sign arbitrary 36-byte payloads. The only modern, safe wallet-signing primitive is eth_signTypedData_v4, which signs keccak256("\x19\x01" || domainSeparator || hashStruct(typedMessage)) — an EIP-712 envelope around typed structured data.

So an EVM-key attestation can't sign the bare CID. It has to sign an EIP-712 wrapper containing the CID — e.g. a typed struct { recordCID: bytes32, repositoryDID: string } under a domain like HypercertsAttestation v1. That's a different signature target from the spec's bare-CID rule, and a verifier would need to know how to reconstruct the EIP-712 hash to verify.

The existing EIP-712 signature inside a link.evm record can not be re-used: it signs {did, evmAddress, chainId, timestamp, nonce} — a wallet-ownership assertion that says nothing about any specific Hypercerts record. A Hypercerts attestation by the same EVM key needs its own separately-produced EIP-712 signature; only the address is shared between the two.

Verifier flow (proposed)

For an EVM-key attestation entry in signatures[]:

  1. Read the inline signature's key field. By some convention it indicates "this is an EVM-key attestation" — see "Design questions" below for options.
  2. Resolve to the signer's EVM address (e.g. fetch the link.evm record at the indicated URI and read its address field; or parse a did:pkh:eip155: DID directly).
  3. Reconstruct the EIP-712 hash from the record's CID + the agreed-upon domain/struct.
  4. ecrecover(eip712Hash, r, s, v) → candidate address. If v not stored, try both v ∈ {0, 1} and accept either match.
  5. Compare against the address from step 2. If equal, signature is valid.

This is a controlled divergence from the spec — Model A is what the spec describes, Model B becomes a second branch the verifier needs to know about. Worth proposing back upstream to Nick (e.g. as a did:pkh profile or an EVM-attestation variant), but we shouldn't block on that.

Design questions to settle before implementation

  1. Discriminator strategy. How does a verifier know to route to the EVM-key path?
    • did:pkh:eip155:1:0x… in the key field (most spec-aligned; needs did:pkh resolver).
    • A link.evm strongRef embedded in the key field or a sibling field (most Hypercerts-native; couples the two records).
    • A separate signature.defs#evmInline variant in the array union (cleanest schema-level signal; adds a lexicon def).
  2. Byte layout. Store 64 bytes (drop v, try-both at verify) or 65 bytes (keep v, single recovery). See "Signature shape" above.
  3. EIP-712 domain. Project-wide constant or per-record? What are the typed fields? Likely needs at minimum recordCID and repositoryDID to mirror the $sig binding from Model A.
  4. Forward compatibility. Does the new path also accommodate ERC-1271 (smart contract wallet signatures) and ERC-6492 (pre-deploy smart contract wallets), which link.evm's proof field is already open to?

Acceptance criteria

A future implementer should:

  • Decide on the discriminator strategy (one of the three above; document the trade-offs).
  • Add the EVM-attestation variant (whether as a sibling def, a did:pkh convention, or otherwise) to app.certified.signature.*.
  • Define the EIP-712 typed-data structure for record attestation (domain, struct shape, type hash).
  • Add an end-to-end test (mirroring the existing tests/validate-signing-example.test.ts pattern) that signs a record with a viem account, packages the signature into a signatures[] entry, and verifies it via ecrecover against the signer's link.evm address.
  • Update README and the building-with-hypercerts-lexicons skill with an EVM-key signing example sitting alongside the existing did:plc example.
  • Decide whether to propose the extension back to Nick Gerakines' spec.

Out of scope for this issue

  • ERC-1271 / ERC-6492 (smart-contract-wallet) attestations. Worth supporting eventually but distinct mechanics; raise separately if pursued.
  • Verifying EVM signatures from chains other than mainnet. The chainId story for attestation signatures is its own design question.

References

Metadata

Metadata

Assignees

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions