Fix: WebCrypto P-256 SPKI DER Header Mismatch in Passkey Smart Accounts
In modern decentralized applications leveraging ERC-4337 Account Abstraction and Passkey Embedded Signers (such as Turnkey, Privy, Biconomy, or ZeroDev), cryptographic keys are generated using the NIST secp256r1 (P-256) elliptic curve via the browser’s native WebAuthn API.
However, frontend engineers and smart contract developers frequently hit fatal runtime exceptions when bridging public keys between the Browser WebCrypto API (window.crypto.subtle), backend indexing nodes, and on-chain P-256 verifiers:
DOMException: Data provided to an operation does not meet requirements
name: "DataError"
code: 0
at window.crypto.subtle.importKey (webcrypto-signer.ts:84)
at verifyPasskeyRegistration (turnkey-bridge.ts:219)
And in on-chain transaction simulations:
RPC Error: -32500: EntryPoint simulation failed: AA23 reverted
Reverted with custom error: InvalidPublicKeyEncoding(expected: 64 bytes, received: 91 bytes)
Contract: 0x0000000000000000000000000000000000000100 (RIP-7212 P256 Precompile)
Account: 0x8a15De32810C73d328F421C00742d46e395F7F0e
The underlying issue is a fundamental representation mismatch: WebCrypto demands a 91-byte ASN.1 DER SPKI structure, whereas EVM smart contracts demand raw 64-byte affine coordinates $(X, Y)$.
1. Cryptographic Structure Decomposition
A NIST P-256 public key exists in three distinct formats across the Web3 stack:
Format A: Raw Affine Coordinates (EVM Precompiles & RIP-7212)
- Length: Exactly 64 bytes.
- Composition: $X$ coordinate (32 bytes) $\parallel$ $Y$ coordinate (32 bytes).
- Used by:
RIP-7212 (0x100), DaimoP256.sol, FreshCryptoLib, and smart wallet storage.
Format B: ANSI X9.62 Uncompressed Point
- Length: Exactly 65 bytes.
- Composition:
0x04prefix $\parallel$ $X$ coordinate (32 bytes) $\parallel$ $Y$ coordinate (32 bytes). - Used by: Elliptic curve libraries and COSE key decoders.
Format C: SubjectPublicKeyInfo (SPKI) DER
- Length: Exactly 91 bytes.
- Composition: 26-byte ASN.1 metadata prefix $\parallel$ 65-byte ANSI X9.62 point.
- Used by:
crypto.subtle.importKey("spki", ...)and OpenSSL.
The Canonical 26-Byte DER Header
30 59 ; SEQUENCE (89 bytes)
30 13 ; SEQUENCE (19 bytes)
06 07 2a 86 48 ce 3d 02 01 ; OID: 1.2.840.10045.2.1 (id-ecPublicKey)
06 08 2a 86 48 ce 3d 03 01 07 ; OID: 1.2.840.10045.3.1.7 (secp256r1 / P-256)
03 42 00 ; BIT STRING (66 bytes, 0 unused bits)
04 [64 bytes of X and Y] ; Uncompressed Point
If an application attempts to pass Format A (64 bytes) to crypto.subtle.importKey("spki"), the parser expects the 0x30 sequence header and immediately crashes with DataError.
(For related mobile enclave integration failures, consult our guide on Android Credential Manager WebAuthn NotAllowedError Fix).
2. Universal Conversion Utilities (TypeScript)
To ensure seamless interoperability across WebCrypto, Viem, and on-chain contracts, deploy these bidirectional conversion functions:
// utils/p256Encoding.ts
const P256_SPKI_PREFIX = new Uint8Array([
0x30, 0x59, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01,
0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x03, 0x42, 0x00,
]);
/**
* Converts a 91-byte WebAuthn SPKI DER ArrayBuffer into raw 64-byte EVM coordinates.
*/
export function spkiDerToRawCoords(spkiBuffer: ArrayBuffer | Uint8Array): {
x: `0x${string}`;
y: `0x${string}`;
raw64: `0x${string}`;
} {
const bytes = new Uint8Array(spkiBuffer);
if (bytes.length !== 91) {
throw new Error(`Invalid SPKI DER length. Expected 91 bytes, received ${bytes.length}`);
}
// Verify the uncompressed point indicator at byte offset 26
if (bytes[26] !== 0x04) {
throw new Error(`Expected uncompressed point indicator (0x04) at offset 26, received 0x${bytes[26].toString(16)}`);
}
// Extract 32-byte X and Y
const xBytes = bytes.slice(27, 59);
const yBytes = bytes.slice(59, 91);
const toHex = (buf: Uint8Array) =>
'0x' + Array.from(buf).map((b) => b.toString(16).padStart(2, '0')).join('');
return {
x: toHex(xBytes) as `0x${string}`,
y: toHex(yBytes) as `0x${string}`,
raw64: toHex(bytes.slice(27, 91)) as `0x${string}`,
};
}
/**
* Wraps raw 64-byte (X, Y) coordinates into a 91-byte SPKI ArrayBuffer for WebCrypto.
*/
export function rawCoordsToSpki(xHex: string, yHex: string): ArrayBuffer {
const cleanX = xHex.replace(/^0x/, '').padStart(64, '0');
const cleanY = yHex.replace(/^0x/, '').padStart(64, '0');
const xBytes = new Uint8Array(cleanX.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)));
const yBytes = new Uint8Array(cleanY.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)));
const spki = new Uint8Array(91);
spki.set(P256_SPKI_PREFIX, 0);
spki[26] = 0x04; // Uncompressed point marker
spki.set(xBytes, 27);
spki.set(yBytes, 59);
return spki.buffer;
}
3. WebCrypto Integration Example
When verifying or re-importing a passkey public key stored on-chain:
import { rawCoordsToSpki } from './utils/p256Encoding';
export async function importOnChainPasskeyToWebCrypto(xCoord: string, yCoord: string): Promise<CryptoKey> {
// 1. Wrap the on-chain coordinates in the 26-byte SPKI header
const spkiBuffer = rawCoordsToSpki(xCoord, yCoord);
// 2. Safely import into WebCrypto without DataError
const cryptoKey = await window.crypto.subtle.importKey(
'spki',
spkiBuffer,
{
name: 'ECDSA',
namedCurve: 'P-256',
},
true,
['verify']
);
return cryptoKey;
}
4. Smart Contract Registration Example (Foundry & Viem)
When registering a passkey signer to a smart account via Viem:
import { spkiDerToRawCoords } from './utils/p256Encoding';
// 1. Retrieve passkey credential from browser
const credential = (await navigator.credentials.create(authOptions)) as PublicKeyCredential;
const spkiDer = (credential.response as AuthenticatorAttestationResponse).getPublicKey();
if (!spkiDer) {
throw new Error('Authenticator did not return SPKI public key buffer.');
}
// 2. Convert to raw 32-byte words for on-chain storage
const { x, y } = spkiDerToRawCoords(spkiDer);
// 3. Send transaction to configure PasskeyValidator module
await walletClient.writeContract({
address: SMART_ACCOUNT_VALIDATOR,
abi: validatorAbi,
functionName: 'addPasskeySigner',
args: [BigInt(x), BigInt(y)],
});
5. Summary Verification Checklist
- Never pass raw 64-byte coordinates directly to
crypto.subtle.importKey("spki", ...). - Always strip the 27-byte prefix (
26 bytes header + 0x04 marker) before storing public keys in Solidity contracts. - Ensure $X$ and $Y$ coordinates are zero-padded to exactly 32 bytes each to prevent byte alignment mismatches.
- Verify whether your target EVM chain supports the native RIP-7212 precompile (
0x100) or relies on an EVM fallback verifier.