# Keys and signing

A wallet's Ed25519 key comes from its 32-byte seed through HKDF, and a transaction is signed by hashing its canonical JSON with SHA-256 and signing those 32 bytes.

## From the 24 words to the key

A wallet has one secret: a 32-byte **seed**, made at random when the wallet is created.

- The **recovery phrase** is the seed written as 24 English words: the seed is the phrase's BIP-39 *entropy* (`entropyToMnemonic` / `mnemonicToEntropy`). It is not the BIP-39 PBKDF2 seed that crypto wallets usually derive from a phrase.
- A **passkey** holds the same seed as its user handle. The web wallet creates the passkey for relying party `mlx.app` with the seed as `user.id`, and reads it back from `userHandle` after Face ID or Touch ID. A passkey whose user handle isn't 32 bytes isn't an MLX wallet.

The signing key is derived from the seed with HKDF-SHA256 (`seedToSecret` in `lib/chain/crypto.ts`):

| HKDF input | Value |
|---|---|
| hash | SHA-256 |
| input key material | the 32-byte seed |
| salt | the UTF-8 bytes of `mlx-wallet-v1` |
| info | the UTF-8 bytes of `ed25519-signing-key` |
| length | 32 bytes |

The 32 bytes are the Ed25519 private key (seed form). Its public key, as 64 lowercase hex characters, is the wallet's `pk`: `create_account` records it, and every later transaction from the wallet must carry the same `pk`. One key controls one wallet.

> [!WARNING]
> The phrase and the passkey are the wallet. Anyone who has either can sign for it, and nobody can restore a wallet without one of them. Never send a phrase to a server, and keep test phrases away from real funds.

## Canonical JSON

Hashes and signatures are computed over **canonical JSON** (`canonical` in `lib/chain/crypto.ts`):

- object keys sorted (JavaScript's default string sort), at every level;
- keys whose value is `undefined` left out; `null` is kept;
- no whitespace;
- strings and numbers written as `JSON.stringify` writes them, so non-ASCII characters stay as they are and are hashed as UTF-8;
- arrays keep their order.

```js title="canonical"
function canonical(v) {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
  const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
}
```

## The bytes that are signed

For a transaction `tx` with the fields `chain`, `from`, `nonce`, `ts`, `pk` and `body` (`txHash` and `signTx` in `lib/chain/types.ts`):

1. Take the transaction **without** `sig`.
2. `text = canonical(tx)`.
3. `hash = SHA-256(UTF-8 bytes of text)`, 32 bytes. Its lowercase hex is the transaction hash.
4. `sig = Ed25519.sign(hash, secret)`: the signature is over the **32 raw hash bytes**, not over the hex string and not over the JSON.
5. Send the transaction with `sig` added, as 128 hex characters.

The ledger checks the same way (`verifyTx`): it recomputes the hash from the received fields and verifies `sig` against `pk`. Any change to any field, including `ts`, gives another hash and a signature that no longer verifies.

For the transfer on the [overview](/transactions/overview), step 2 gives:

```text title="canonical(tx)"
{"body":{"amount":"2500000","to":"shop.mlx","token":"USDC","type":"transfer"},"chain":"mlx-1","from":"luca.mlx","nonce":4,"pk":"7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524","ts":1793116800000}
```

## Worked example

This script derives the key from a recovery phrase and signs a transfer of 2.5 USDC from `luca.mlx` to `shop.mlx`. It uses only [@noble/curves](https://github.com/paulmillr/noble-curves), [@noble/hashes](https://github.com/paulmillr/noble-hashes) and [@scure/bip39](https://github.com/paulmillr/scure-bip39), the libraries the wallet uses (tested with @noble/curves 1.9, @noble/hashes 1.8, @scure/bip39 1.6).

```bash title="Install"
npm install @noble/curves@1.9 @noble/hashes@1.8 @scure/bip39@1.6
```

```js title="sign.mjs"
import { ed25519 } from "@noble/curves/ed25519";
import { sha256 } from "@noble/hashes/sha2";
import { hkdf } from "@noble/hashes/hkdf";
import { bytesToHex, hexToBytes, utf8ToBytes } from "@noble/hashes/utils";
import { mnemonicToEntropy } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english";

// 1. The 24 words are the 32-byte seed (BIP-39 entropy). A public test phrase: never use it for real funds.
const phrase =
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon " +
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
const seed = mnemonicToEntropy(phrase, wordlist);

// 2. The signing key: HKDF-SHA256(seed, salt "mlx-wallet-v1", info "ed25519-signing-key", 32 bytes).
const secret = hkdf(sha256, seed, utf8ToBytes("mlx-wallet-v1"), utf8ToBytes("ed25519-signing-key"), 32);
const pk = bytesToHex(ed25519.getPublicKey(secret));

// 3. Canonical JSON: keys sorted, no whitespace, undefined left out.
function canonical(v) {
  if (v === null || typeof v !== "object") return JSON.stringify(v);
  if (Array.isArray(v)) return "[" + v.map(canonical).join(",") + "]";
  const keys = Object.keys(v).filter((k) => v[k] !== undefined).sort();
  return "{" + keys.map((k) => JSON.stringify(k) + ":" + canonical(v[k])).join(",") + "}";
}

// 4. The transaction without `sig`. Fixed nonce and time so the output below is reproducible.
const tx = {
  chain: "mlx-1",
  from: "luca.mlx",
  nonce: 4,
  ts: 1793116800000,
  pk,
  body: { type: "transfer", to: "shop.mlx", token: "USDC", amount: "2500000" }
};

// 5. hash = SHA-256 of the canonical JSON; sig = Ed25519 over the 32 hash bytes.
const hash = bytesToHex(sha256(utf8ToBytes(canonical(tx))));
const sig = bytesToHex(ed25519.sign(hexToBytes(hash), secret));

console.log("pk  ", pk);
console.log("hash", hash);
console.log("sig ", sig);
console.log(JSON.stringify({ tx: { ...tx, sig } }));
```

```text title="Output of node sign.mjs"
pk   7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524
hash 977ce97b9880accccb1263c207dba53bdfb0419646a3831c127ced2e07b1d024
sig  ce8c7621b38777895b06107168e08a542bf9d5fbaf2ac53bb1b9a1b1d3ade2635d5c00150a2f76990b49330365d964c2b55d3289bbc9cc3e380550a2715cb10f
{"tx":{"chain":"mlx-1","from":"luca.mlx","nonce":4,"ts":1793116800000,"pk":"7812b1435b317df04f45731ac508ae7774d4731ee0445fd46ccdcc5019faa524","body":{"type":"transfer","to":"shop.mlx","token":"USDC","amount":"2500000"},"sig":"ce8c7621b38777895b06107168e08a542bf9d5fbaf2ac53bb1b9a1b1d3ade2635d5c00150a2f76990b49330365d964c2b55d3289bbc9cc3e380550a2715cb10f"}}
```

Ed25519 signatures are deterministic, so the same phrase and fields give exactly this signature, and the ledger's own `verifyTx` accepts it. The output is the body for [`POST /api/tx`](/transactions/submitting).

### Signing a real transaction

For a transaction the ledger will take, read the chain id and the wallet's nonce first and sign with the current time:

```js title="send.mjs (after the definitions above)"
const { chain } = await (await fetch("https://mlx.app/api/stats")).json();
const { account } = await (await fetch("https://mlx.app/api/account/luca.mlx")).json();
const live = { chain, from: account.name, nonce: account.nonce + 1, ts: Date.now(), pk, body: tx.body };
const liveHash = bytesToHex(sha256(utf8ToBytes(canonical(live))));
const res = await fetch("https://mlx.app/api/tx", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ tx: { ...live, sig: bytesToHex(ed25519.sign(hexToBytes(liveHash), secret)) } })
});
console.log(res.status, await res.json());
```

The public key must be the one `account.pk` shows; otherwise the reply is "Signer does not control this account".

## Checking a published transaction

Every transaction on the explorer can be checked the same way: take `tx` from `GET /api/tx/<hash>`, drop `sig`, hash its canonical JSON and verify `sig` against `pk`. The hash you compute must equal the record's `hash`.

One exception: on public reads, a `bind_device` record shows the kind of Mac instead of its computer name and leaves out the Mac's `proof` (marked `"nameHidden": true`), so its hash can't be recomputed from what is published.

## Other things a wallet signs

The same key signs a few requests that are not transactions. Each is a SHA-256 hash of canonical JSON, signed like a transaction hash:

| What | Canonical object | Used by |
|---|---|---|
| Read the wallet's own device list | `{ "type": "mlx-devices", "chain", "account", "ts" }` | `POST /api/account/<name>/devices` (`devicesHash` in `lib/wallet/devices.ts`) |
| Read the names of the wallet's own Macs | `{ "type": "mlx-macs", "chain", "account", "ts" }` | `POST /api/account/<name>/macs` (`macsHash`) |

A Mac's own key is P-256 in its Secure Enclave, not the wallet key. It signs its link request and its requests to the network; see [Worker protocol](/node/worker-protocol).
