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[]:
- Read the inline signature's
key field. By some convention it indicates "this is an EVM-key attestation" — see "Design questions" below for options.
- 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).
- Reconstruct the EIP-712 hash from the record's CID + the agreed-upon domain/struct.
ecrecover(eip712Hash, r, s, v) → candidate address. If v not stored, try both v ∈ {0, 1} and accept either match.
- 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
- 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).
- Byte layout. Store 64 bytes (drop
v, try-both at verify) or 65 bytes (keep v, single recovery). See "Signature shape" above.
- 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.
- 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:
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
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.evmexplicitly opened the door for this: the EIP-712prooffield onlink.evmproves wallet consent (the EVM key holder agreed to the binding); thesignatures[]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:
link.evmis 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 averificationMethodwithpublicKeyMultibase. The multicodec prefix on the multibase blob picks the curve (0xE701= K-256,0x1200= P-256; badge.blue additionally lists P-384). Verifier runsECDSA_Verify(pubkey, hash(message), r, s)and returns a boolean. The signature isr || 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 bytev(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
vbyte 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). Thevbyte solves a different problem — disambiguating between the two candidate pubkeys thatecrecovercould 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:v); EVM-aware verifier tries both candidate addresses and accepts a match against either. Costs one extraecrecoverper verification. Not weaker — the security depends on whether one of the two candidate addresses matches the known address, not on whichvhappens to be on the wire.r || s || v) — wire-valid against oursignature: byteslexicon 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.app.certified.signature.defs#evmInline— that is explicitly Model-B-shaped and carriesvin 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
$sigrepository 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 signskeccak256("\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 likeHypercertsAttestation 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.evmrecord 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[]:keyfield. By some convention it indicates "this is an EVM-key attestation" — see "Design questions" below for options.link.evmrecord at the indicated URI and read itsaddressfield; or parse adid:pkh:eip155:DID directly).ecrecover(eip712Hash, r, s, v)→ candidate address. Ifvnot stored, try bothv ∈ {0, 1}and accept either match.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:pkhprofile or an EVM-attestation variant), but we shouldn't block on that.Design questions to settle before implementation
did:pkh:eip155:1:0x…in thekeyfield (most spec-aligned; needsdid:pkhresolver).link.evmstrongRef embedded in thekeyfield or a sibling field (most Hypercerts-native; couples the two records).signature.defs#evmInlinevariant in the array union (cleanest schema-level signal; adds a lexicon def).v, try-both at verify) or 65 bytes (keepv, single recovery). See "Signature shape" above.recordCIDandrepositoryDIDto mirror the$sigbinding from Model A.link.evm'sprooffield is already open to?Acceptance criteria
A future implementer should:
did:pkhconvention, or otherwise) toapp.certified.signature.*.tests/validate-signing-example.test.tspattern) that signs a record with aviemaccount, packages the signature into asignatures[]entry, and verifies it viaecrecoveragainst the signer'slink.evmaddress.building-with-hypercerts-lexiconsskill with an EVM-key signing example sitting alongside the existingdid:plcexample.Out of scope for this issue
chainIdstory for attestation signatures is its own design question.References
did:plcsigning path; merge commit 478f5be.app.certified.link.evmlexicon — the EIP-712 wallet-ownership integrity primitive this issue proposes to complement.