> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dakota.xyz/llms.txt
> Use this file to discover all available pages before exploring further.

# WebAuthn & Passkey Signing

> How to register a WEBAUTHN signer and endorse wallet transactions with a WebAuthn assertion instead of a raw ECDSA signature.

Dakota signers can be **WebAuthn credentials** (passkeys, platform authenticators, or roaming keys like a YubiKey) in addition to raw `ES256` keys. A WebAuthn signer endorses an intent by producing a standard [WebAuthn assertion](https://www.w3.org/TR/webauthn-2/) — the same object a browser returns from `navigator.credentials.get()` — rather than a bare ECDSA signature over the intent hash.

This page is the reference for the WebAuthn path. If you sign with raw `ES256` keys, read [Wallet Transaction Signing](/documentation/wallet-signing) instead. For the conceptual model, intent catalogue, and signer-group / policy lifecycles (which are identical for both signer types), read [Signing & Endorsed Requests](/documentation/signing-guide).

<Info>
  **Why WebAuthn is different.** An authenticator never signs an arbitrary hash. It signs `authenticatorData ‖ SHA-256(clientDataJSON)`, and the intent reaches the signature only as the **challenge** embedded inside `clientDataJSON`. The verifier therefore needs the whole assertion bundle (`authenticatorData`, `clientDataJSON`, `signature`) — not a DER signature. Everything below exists to thread the intent through that bundle correctly.
</Info>

## The model at a glance

A WebAuthn integration has two phases:

1. **Register** — create a WebAuthn credential, extract its COSE public key, and register it as a `key_type: WEBAUTHN` signer.
2. **Sign** — build a transaction intent, **JCS-canonicalize it, use those exact bytes as the WebAuthn challenge**, produce an assertion, and submit it in an endorsed request.

Once a WebAuthn signer is in a signer group attached to a wallet, it participates in approval thresholds exactly like an `ES256` signer. The signer-group and policy mechanics in [Signing & Endorsed Requests](/documentation/signing-guide) apply unchanged.

<Note>
  **Supported algorithms.** A `WEBAUTHN` signer's COSE key must be **ES256** (EC2 / P-256, COSE `alg` `-7`) or **RS256** (RSA, 2048-bit minimum, COSE `alg` `-257`). Other algorithms and curves — including **ES384/ES512, Ed25519, and secp256k1** — are rejected at verification time. Virtually all passkeys are ES256, so request `[-7, -257]` and you will get an ES256 credential.
</Note>

***

## 1. Register a WEBAUTHN signer

### 1a. Create the credential (browser)

Run a normal WebAuthn registration ceremony. The relying-party options come from your own server; the only Dakota-specific constraint is `pubKeyCredParams` (i.e. `supportedAlgorithmIDs`) limited to ES256 / RS256.

```typescript theme={null}
import { create } from '@github/webauthn-json/browser-ponyfill';

// creationOptions come from your relying-party server and set
// rpId, challenge, user, and pubKeyCredParams ([-7, -257]).
const credential = await create({ publicKey: creationOptions });

// credential.id      — base64url credential ID. SAVE THIS — you need it to sign.
// credential.response — contains the attestationObject (CBOR) holding the COSE key.
```

### 1b. Extract the COSE public key (server)

The public key Dakota stores is the **raw COSE key** (CBOR) embedded in the attestation object — not a PKIX/SPKI blob. The simplest way to get it is to verify the registration with [`@simplewebauthn/server`](https://simplewebauthn.dev/) and read the credential public key it returns:

```typescript theme={null}
import { verifyRegistrationResponse } from '@simplewebauthn/server';

const verification = await verifyRegistrationResponse({
  response: credential,                       // the PublicKeyCredential from 1a
  expectedChallenge,                          // the challenge you issued
  expectedOrigin: 'https://your-app.example',
  expectedRPID: 'your-app.example',
});

// COSE public key (Uint8Array). Field name varies by library version:
//   @simplewebauthn/server v7+ : registrationInfo.credential.publicKey
//   v6 and below               : registrationInfo.credentialPublicKey
const cosePublicKey = verification.registrationInfo.credential.publicKey;

// base64url-encode for the API (Node 14.18+).
const publicKeyB64 = Buffer.from(cosePublicKey).toString('base64url');
```

<Note>
  If you are not using `@simplewebauthn/server`, the COSE key is the `credentialPublicKey` field of `attestedCredentialData` inside the `authData` of the `attestationObject`. Decode the attestation CBOR with any CBOR library and walk the standard authenticator-data layout to extract it. The bytes you register are exactly that COSE key, base64-encoded.
</Note>

### 1c. Register the signer

```bash theme={null}
curl -X POST https://api.platform.dakota.xyz/signers \
  -H "X-API-Key: $DAKOTA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Alice'\''s passkey",
    "key_type": "WEBAUTHN",
    "public_key": "<publicKeyB64>"
  }'
```

Save both the returned signer `id` (for building signer groups) and the `credential.id` from step 1a (for signing). Add the signer to a signer group and attach that group to a wallet exactly as you would for an `ES256` signer — see the [Signer Group Lifecycle](/documentation/signing-guide#signer-group-lifecycle).

<Warning>
  `public_key` for a `WEBAUTHN` signer is the **raw CBOR COSE key**, base64-encoded. This is a different encoding from `key_type: ES256`, which expects an X.509 SubjectPublicKeyInfo (PKIX) DER/PEM blob. Sending a PKIX key for a WebAuthn signer (or vice-versa) fails verification later, not at registration.
</Warning>

***

## 2. Sign and submit a transaction

The intent and `operation` schema are identical to the [ECDSA flow](/documentation/wallet-signing) — only the signing step changes.

### 2a. Build and canonicalize the intent

```typescript theme={null}
import canonicalize from 'canonicalize'; // RFC 8785 JCS; npm install canonicalize

const intent = {
  wallet_id: 'wal_2LfZm5KMnRvLFtRP7nJJug4zJEP',
  caip2: 'eip155:1',
  operation: {
    kind: 'transfer',
    from: '0xYourWalletAddress...',
    to: '0xDestinationAddress...',
    amount: '10.5',          // strings, never numbers
    asset_id: 'USDC',
  },
  idempotency_key: crypto.randomUUID(),
  // context_digest is optional — include it only if an upstream service
  // hands you one. It is part of the intent, so it is covered automatically.
};

// The RFC 8785 canonical bytes. These bytes ARE the WebAuthn challenge.
const canonicalBytes = new TextEncoder().encode(canonicalize(intent));
```

### 2b. The challenge — the one thing that trips integrators up

<Warning>
  The challenge is the **raw bytes of the RFC 8785 (JCS) canonical intent JSON** — **not** `SHA-256(intent)`, and **not** any other digest. Do not hash the intent before using it as the challenge. The verifier re-derives the hash itself.
</Warning>

What the verifier does, step by step:

```text theme={null}
decoded        = base64url_decode(clientDataJSON.challenge)   // → canonical intent bytes
intent_obj     = JSON.parse(decoded)
intent_obj     = removeEmptyStrings(intent_obj)               // matches server omitempty
intent_obj     = normalizeDecimals(intent_obj)                // "0.10" → "0.1"
challenge_hash = SHA-256( JCS(intent_obj) )
stored_hash    = SHA-256( JCS(submitted endorsed_request.intent) )
assert  challenge_hash == stored_hash                          // constant-time
```

Because the verifier re-canonicalizes whatever it decodes from the challenge, minor formatting differences (key order, trailing decimal zeros, empty strings) are tolerated. Signing the output of your JCS library directly is the safest approach.

<Note>
  **`context_digest` participates automatically.** It is an optional field on `SendTransactionIntent`; because it lives inside the intent object, it is part of the canonical JSON the challenge commits to — no separate handling. **The hash is a single SHA-256**, not a double hash.
</Note>

### 2c. Produce the assertion (browser)

```typescript theme={null}
import {
  get as getAssertion,
  parseRequestOptionsFromJSON,
} from '@github/webauthn-json/browser-ponyfill';

/**
 * Sign a canonicalized intent with a registered WebAuthn credential.
 * Returns one entry suitable for EndorsedRequest.signatures[].
 */
async function signIntent(
  canonicalBytes: Uint8Array,
  credentialId: string,   // the base64url credential.id from registration
): Promise<string> {
  // parseRequestOptionsFromJSON expects the challenge as a base64url string
  // and decodes it back to the raw bytes the authenticator signs over.
  const requestOptions = parseRequestOptionsFromJSON({
    publicKey: {
      challenge: base64url(canonicalBytes),  // the canonical intent bytes
      allowCredentials: [{ type: 'public-key', id: credentialId }],
      userVerification: 'preferred',
      timeout: 300_000,
    },
  });

  // The browser prompts the user, then signs authenticatorData ‖ SHA-256(clientDataJSON).
  const assertion = await getAssertion(requestOptions);

  // assertion.toJSON() is the CredentialAssertionResponse shape, with every
  // binary field inside `response` already base64url-encoded:
  // { id, rawId, type: "public-key",
  //   response: { authenticatorData, clientDataJSON, signature } }
  const assertionJson = JSON.stringify(assertion.toJSON());

  // A signatures[] entry is base64url( JSON(assertion) ).
  return base64url(new TextEncoder().encode(assertionJson));
}

// RFC 4648 §5 base64url, no padding.
function base64url(bytes: Uint8Array): string {
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
```

### 2d. Submit the endorsed request

The request body **is the endorsed request** — `intent` and `signatures` at the top level. There is no wrapper object.

```bash theme={null}
curl -X POST https://api.platform.dakota.xyz/wallets/wal_2LfZm5KMnRvLFtRP7nJJug4zJEP/transactions \
  -H "X-API-Key: $DAKOTA_API_KEY" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "signatures": [
      "<output of signIntent()>"
    ],
    "intent": {
      "wallet_id": "wal_2LfZm5KMnRvLFtRP7nJJug4zJEP",
      "caip2": "eip155:1",
      "operation": {
        "kind": "transfer",
        "from": "0xYourWalletAddress...",
        "to": "0xDestinationAddress...",
        "amount": "10.5",
        "asset_id": "USDC"
      },
      "idempotency_key": "a6f8c8c0-6f0a-4a24-a3a3-9e8a0cf2f7c0"
    }
  }'
```

The `intent` you submit must be the same object you canonicalized and signed — the verifier independently canonicalizes it and compares its hash against the challenge. The `signatures` array holds one entry per endorsing signer, up to the signer group's approval threshold.

### End-to-end

```typescript theme={null}
import canonicalize from 'canonicalize';

async function sendSignedTransaction(params: {
  walletId: string;
  caip2: string;
  fromAddress: string;
  toAddress: string;
  amount: string;
  assetId: string;
  credentialId: string;   // base64url WebAuthn credential ID from registration
}) {
  const intent = {
    wallet_id: params.walletId,
    caip2: params.caip2,
    operation: {
      kind: 'transfer',
      from: params.fromAddress,
      to: params.toAddress,
      amount: params.amount,
      asset_id: params.assetId,
    },
    idempotency_key: crypto.randomUUID(),
  };

  const canonicalBytes = new TextEncoder().encode(canonicalize(intent));
  const signature = await signIntent(canonicalBytes, params.credentialId);

  return api.post(`/wallets/${params.walletId}/transactions`, {
    signatures: [signature],
    intent,
  });
}
```

***

## What goes in `signatures[]`

Each entry is the WebAuthn assertion **serialized as JSON, then base64-encoded** — there is no CBOR, no custom concatenation, and no proprietary envelope:

```text theme={null}
signatures[i] = base64url( JSON(CredentialAssertionResponse) )
```

The decoded JSON:

```json theme={null}
{
  "id":    "<base64url credential ID>",
  "rawId": "<base64url credential ID>",
  "type":  "public-key",
  "response": {
    "authenticatorData": "<base64url>",
    "clientDataJSON":    "<base64url>",
    "signature":         "<base64url — DER ECDSA for ES256, PKCS#1 v1.5 for RS256>"
  }
}
```

| Field                        | Requirement                                                                                 |
| ---------------------------- | ------------------------------------------------------------------------------------------- |
| `id`                         | Required. Base64url (no padding) — this is validated.                                       |
| `type`                       | Required. Must be `"public-key"`.                                                           |
| `rawId`                      | Recommended (standard `toJSON()` output).                                                   |
| `response.authenticatorData` | Required. Base64url of the raw authenticator data.                                          |
| `response.clientDataJSON`    | Required. Base64url; decodes to JSON containing `type: "webauthn.get"` and the `challenge`. |
| `response.signature`         | Required. Base64url; DER for ES256, PKCS#1 v1.5 for RS256.                                  |

JSON field order is irrelevant. The fields inside `response` must be **base64url**; the outer `signatures[]` string itself may be standard base64 or base64url, padded or not — the decoder accepts all four.

***

## rpId and origin

Dakota does **not** re-validate `rpId` or `origin` server-side. The verifier extracts `authenticatorData`, `clientDataJSON`, and `signature`, checks the challenge binding, and verifies the signature against the registered COSE key. The `rpId` and `origin` are cryptographically bound into the signature but are not compared against an expected value.

You still need a valid, stable `rpId` configured in your relying party to complete the browser ceremony in the first place — use your real domain (and keep it consistent between registration and signing). `clientDataJSON.type` will be `"webauthn.get"` naturally.

***

## Troubleshooting

| Symptom                                             | Likely cause                                                                                                                                   | Fix                                                                                                                      |
| --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `WebAuthn challenge hash does not match intentHash` | Challenge was set to `SHA-256(intent)` instead of the **raw** canonical JSON bytes; or the `intent` was mutated between signing and submission | Use the canonical bytes directly as the challenge, and submit the same intent object you signed.                         |
| `failed to parse WebAuthn response`                 | A `response` field (or `id`) is not base64url, or `authenticatorData` / `clientDataJSON` is malformed                                          | Use the standard `assertion.toJSON()` output; do not re-encode its inner fields. `id` must be base64url with no padding. |
| `failed to unmarshal WebAuthn signature`            | The decoded `signatures[]` entry is not the assertion JSON                                                                                     | Ensure each entry is `base64( JSON(assertion) )`, not the raw assertion object and not just the `signature` field.       |
| `failed to parse WebAuthn public key`               | The registered `public_key` is a PKIX/PEM key, or the COSE algorithm is unsupported                                                            | Register the **raw COSE** key (1b), and use an **ES256** or **RS256** credential.                                        |
| `signature verification failed`                     | `allowCredentials` referenced a credential whose key does not match the registered signer                                                      | Confirm the `credential.id` you sign with corresponds to the COSE key you registered.                                    |

<Note>
  **Multi-signer approvals.** When a wallet's policy requires more than one approval, collect one `signatures[]` entry per signer (each a base64url assertion JSON) and submit them together. WebAuthn and `ES256` signatures can be mixed in the same `signatures` array.
</Note>
