Skip to main content

Key Generation

A recipient identity is a hybrid pair: a secp256k1 spending keypair (the same curve Ethereum and Sui use) and a post-quantum ML-KEM-768 viewing keypair. This page explains how they are produced, why the protocol keeps them separate, and how their public halves combine into the one meta-address a recipient shares.

Two keypairs, two jobs

The split exists so that a recipient can let software watch for payments without giving that software the power to spend.

KeypairPublic half doesSecret half doesWho needs the secret
SpendingLets a sender derive a stealth addressRecovers the private key that spendsThe owner, at spend time
ViewingLets a sender encapsulate a shared secretDetects which payments are yoursA scanner, continuously

A scanning service or a hot device can hold the viewing secret key and still never move funds. The spending secret key can stay in colder storage and only come out to sign. See security boundaries for why this matters.

What each keypair produces

The two keypairs use different curves, so their sizes differ:

KeypairPublic keySecret keyNotes
Spending (secp256k1)33 B (compressed)32 B (scalar)Standard Ethereum/Sui curve; safe to publish the public key
Viewing (ML-KEM-768)1,184 B (encapsulation key)2,400 B (decapsulation key)Post-quantum; the viewing public key encapsulates shared secrets

generateSpecterKeys produces both and labels them spending and viewing. The viewing sizes are fixed by FIPS 203 and exported as KYBER_PUBLIC_KEY_SIZE / KYBER_SECRET_KEY_SIZE; the spending sizes are exported as SPEND_PUBLIC_KEY_SIZE / SPEND_SECRET_KEY_SIZE. generateKeysLocal remains available if you want a single ML-KEM-768 viewing keypair on its own.

Where the randomness and the keys live

Key generation needs a good random source, and the secret keys must not leak. SPECTER handles both in the Rust core that the SDK, CLI, and backend all share:

  • Keys are generated with the RustCrypto ml-kem crate, pure Rust with no C dependencies, which is what makes the WebAssembly build possible.
  • Every crate enforces #![forbid(unsafe_code)].
  • Secret key material is wiped from memory on drop with the zeroize crate, so it does not linger after use.

With the SDK, this runs on the user's device. The hosted API can also generate keys server side, but then the server sees the secrets. For wallets, generate locally.

import { generateSpecterKeys } from '@specterpq/sdk';

const recipient = generateSpecterKeys();
// recipient.spending.publicKey, recipient.spending.secretKey
// recipient.viewing.publicKey, recipient.viewing.secretKey

The secret keys exist on the returned object but are non-enumerable and redacted from logs. See the SDK security model.

From public keys to a meta-address

A recipient never publishes four separate keys. The two public keys are serialized into one meta-address, optionally with metadata such as a display name or avatar.

The serialized form is 1,218 bytes: a version byte, the 33-byte secp256k1 spending public key, and the 1,184-byte ML-KEM viewing public key (any display metadata is carried alongside). The meta-address is the only address a recipient hands out, and it is reusable. Every payment to it still lands at a different stealth address, which is the subject of the stealth derivation page.

import { metaAddressFromPublicKeys } from '@specterpq/sdk';

const meta = metaAddressFromPublicKeys(
recipient.spending.publicKey,
recipient.viewing.publicKey,
{ description: 'Receive profile' },
);
// meta.hex is publishable; meta.bytes.length === 1218

Recovery implications

These keypairs are long-lived. Treat both secret keys the way you treat a seed phrase:

  • Lose the viewing secret key and the wallet can no longer detect incoming payments.
  • Lose the spending secret key and the funds at every stealth address are unrecoverable.

There is no server-side reset. The protocol has no record of the secrets, which is the point.

Next