
# Derive keys

> One high-entropy secret, several independent keys, each labelled with what it is for.

## The rule

Never use one secret for two purposes. Derive a key per purpose with [`hkdf`](/crypto/key-derivation), give each one a distinct `info` label, and a compromise of one does not touch the others.

```ts
import { hkdf, importHkdfKey } from "unsecure/hkdf";

const ikm = await importHkdfKey(process.env.ROOT_SECRET);

const cookieKey = await hkdf(ikm, { info: "myapp/session-cookie/v1" });
const csrfKey = await hkdf(ikm, { info: "myapp/csrf/v1" });
const idKey = await hkdf(ikm, { info: "myapp/record-id/v1" });
```

`importHkdfKey` imports the material once into a non-extractable, `deriveBits`-only `CryptoKey`. Every derivation after that skips the import.

## Labelling

Give every `info` a namespace and a version.

```ts
// Ambiguous
await hkdf(ikm, { info: "key" });

// Specific, and rotatable
await hkdf(ikm, { info: "myapp/enc/v1" });
```

Rotating a key becomes a label change: bump `v1` to `v2`, keep the old label for reading old data, write new data with the new one. The root secret never has to move.

Two derivations with the same IKM, `salt` and `info` produce the same key. That is what makes derivation reproducible across processes, and it is also why the label has to be unique per use.

## Salt

`salt` is not secret, and RFC 5869 allows omitting it. Use it to bind a derivation to something specific: a session id, a tenant, a record id.

```ts
async function tenantKey(tenantId: string) {
  return hkdf(ikm, { salt: tenantId, info: "myapp/tenant-data/v1", returnAs: "bytes" });
}
```

## Splitting one derivation

One call can produce several keys at once. Ask for the total length and slice.

```ts
async function deriveSessionKeys(sharedSecret: Uint8Array, sessionId: string) {
  const okm = await hkdf(sharedSecret, {
    salt: sessionId,
    info: "myapp/session-keys/v1",
    length: 64,
  });
  return {
    clientToServer: okm.slice(0, 32),
    serverToClient: okm.slice(32, 64),
  };
}
```

Either shape is fine: one call sliced, or two calls with distinct labels. The second reads better; the first is one derivation instead of two.

## Feeding another primitive

Key material is bytes. When the IKM is a string, the default `returnAs` mirrors it and gives you hex text, which is almost never what you want next.

```ts
// hex text, then encoded again by accident
const key = await hkdf("root-secret", { info: "myapp/enc/v1" });

// bytes, ready for importKey
const key = await hkdf("root-secret", { info: "myapp/enc/v1", returnAs: "bytes" });

const aesKey = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt", "decrypt"]);
```

Choose `"base64url"` only when the material is about to be stored or transported as text.

## Deriving from a password

HKDF has no work factor, so running it on a password is no stronger than one HMAC over that password. Pay for the guessing defence first with [Argon2](/crypto/password-hashing), then split its output.

```ts
import { argon2 } from "unsecure/argon2";
import { hkdf } from "unsecure/hkdf";

const stretched = await argon2(passphrase, salt, {
  m: 65_536,
  t: 3,
  length: 32,
  returnAs: "uint8array",
});

const encKey = await hkdf(stretched, { info: "myvault/enc/v1", returnAs: "bytes" });
const macKey = await hkdf(stretched, { info: "myvault/mac/v1", returnAs: "bytes" });
```

Store the Argon2 salt and its parameters alongside the encrypted data. Without them the key cannot be re-derived.

## Limits

RFC 5869 caps one derivation at `255 * HashLen` bytes: 8160 for SHA-256, 16320 for SHA-512. The bound is checked before Web Crypto is reached.

```ts
await hkdf("x", { length: 9000 });
// UnsecureError OUT_OF_RANGE: hkdf: length must be an integer between 1 and 8160, got 9000.
```

If you need more material, derive several keys with distinct `info` values.
