# Me2eM Core Protocol — Specification v1.0

**Status:** normative specification for `@me2em/core` **1.0.0-rc.1**
(LTS program). The document is implementation-independent: it describes
the wire format and derivation formulas that any conforming
implementation must reproduce. The conformance instrument is
[`specs/test-vectors.json`](./test-vectors.json); the golden test
`packages/core/test/vectors.spec.ts` enforces it continuously. The
reference implementation's public API is **frozen** as of this release
candidate — see §12.

**Freeze rule:** all derivation-affecting content of this document is frozen
until v1.0.0. Any change to an info string, a payload schema or a limit below
is a BREAKING protocol change requiring a new version and a vector-file
revision.

---

## 1. Conventions

- **Encoding.** All binary values on the wire are **Base64URL, unpadded**
  (RFC 4648 §5). Tokens, ids and payload bytes use the same encoding.
- **JSON.** Payloads are compact UTF-8 JSON (`JSON.stringify` semantics: no
  insignificant whitespace, no trailing newline).
- **Field order is normative.** Object keys are emitted in the order given by
  the schemas in §5 and §6. Verifiers MUST accept any key order when parsing,
  but signers MUST emit exactly this order; vectors pin it.
- **Timestamps** are Unix seconds (integers).
- **Canonical names.** A name is canonicalized before any cryptographic use:
  NFKC → lowercase → trim. Handle names and SubHandle names inside info
  strings and tokens are always canonical.
- **Canonical contexts.** Password/channel contexts are canonicalized as
  NFKC → lowercase → trim, and MUST NOT be empty after canonicalization.
  (Before the revisions PR the rule was lowercase → trim without NFKC;
  strengthening it is a breaking derivation change for non-NFKC contexts —
  re-derive. Recorded in the core CHANGELOG; vectors extended.)

## 2. Seed (BIP39)

- Mnemonic: 12 or 24 words (128/256-bit entropy), BIP39 English wordlist.
  Intermediate strengths are not part of v1.0.
- **Seed = 64 bytes**: `PBKDF2-HMAC-SHA512(password = NFKD(mnemonic),
  salt = "mnemonic" + NFKD(passphrase), iterations = 2048, dkLen = 64)`.
  This is the unmodified BIP39 output; **no post-hashing is applied**.
- Passphrase is optional (default `""`), case-sensitive, NFKD-normalized
  (by the BIP39 layer), and never recoverable.
  Normalization of the mnemonic and passphrase is performed by the BIP39
  layer (NFKD, per the standard); implementations MUST NOT apply
  additional normalization on top.

## 3. Key derivation (HKDF-SHA256, RFC 5869)

All keys are 32 bytes and are used directly as Ed25519 seeds (RFC 8032).
`salt = ""` denotes a zero-length salt. `L` is the HKDF output length.

| Purpose | IKM | Salt | Info | L |
|---|---|---|---|---|
| Root identity key | seed (64 B) | `""` | `me2em/identity/v1/root` | 32 |
| Handle key | root private key | `""` | `me2em/handle/v1/{name}` | 32 |
| SubHandle key | parent Handle private key | `""` | `me2em/subhandle/v1/{handleName}/{subName}` | 32 |
| Password (secret) | Handle private key | `me2em/secret/{context}` | `me2em/secret/v1` (revision `N ≥ 2`: `me2em/secret/v1/rN`) | caller-defined integer 1–8160, default 16 |
| Channel key | Handle private key | `me2em/channel/{context}` | `me2em/channel/v1` (revision `N ≥ 2`: `me2em/channel/v1/rN`) | 32 |
| P2P shared secret | X25519 ECDH result (see below) | both public keys, byte-sorted, concatenated (64 B) | `me2em/p2p-channel/v2` | 32 |

Rules:

- `deriveSubHandle(handleName, subName)` (atomic, from the Identity) and
  `handle.deriveSubHandle(subName)` (two-step) MUST produce the same key.
- **Revisions (rotation path).** Password and channel-key derivation
  accepts a `revision`: a positive integer, default `1`. `revision` is
  an integer (monotonic counter) and is canonical by construction: the
  NFKC normalization of §1 applies to `context` strings only — never to
  `revision`. Revision `1` —
  the initial secret — uses the info strings above unchanged and is
  byte-identical to the pre-revision derivation. Revision `N ≥ 2`
  appends a `/rN` path segment to the info string and derives a secret
  that is cryptographically independent of revision 1 for the same
  context: the sanctioned way to rotate a leaked credential or a
  compromised channel without changing the context string. The v1 info
  strings themselves stay frozen (see the Freeze rule); revisions are
  the only evolution path for these two derivations (PLAN-LTS §4).
  Introduced pre-v1.0.0 by the revisions PR (D3): API is
  `derivePassword(context, { revision?, byteLength? })`,
  `deriveChannelKey(context, { revision? })` — options object, no
  positional/shim forms. Vectors pin revision 1, revision 2 and the
  NFKC context rows.
- P2P: the Ed25519 keypairs are converted to the Montgomery form (X25519,
  RFC 7748); the raw ECDH output is the HKDF IKM. Both peers' 32-byte
  Ed25519 public keys are sorted lexicographically and concatenated to form
  the salt, which makes the derivation order-independent and binds it
  against unknown-key-share attacks.

## 4. Tokens

```
token   = base64url(payloadBytes) "." base64url(signatureBytes)
signature = Ed25519.sign(payloadBytes, signerPrivateKey)
```

Verifiers MUST verify the signature over the exact payload bytes — never
over a re-serialized copy.

## 5. Attestation payload

`typ` constant: `me2em/attestation/v1`.

Key order and schema:

```json
{
  "typ": "me2em/attestation/v1",
  "subjectName": "<canonical name>",
  "subjectId": "<base64url of the 32-byte subject public key>",
  "grant": {
    "audiences": ["…"],           // optional; omitted ≠ []; [] = deny all
    "scopes": ["…"],              // required, exact strings, no wildcards
    "maxSessionTtl": 7200,        // required, finite positive number
    "subNamePatterns": ["…"]      // optional; Identity→Handle level
  },
  "iat": 1767225600,
  "exp": 1798761600,
  "jti": "<issuer-chosen unique id>"
}
```

Normative constraints:

- **Payload limit: 2048 bytes** (`ATTESTATION_MAX_PAYLOAD`). Larger tokens
  are `MALFORMED`.
- Default lifetime: one year (`ttlSeconds` default `31536000`);
  `expiresAt` overrides `ttlSeconds`.
- `subNamePatterns`: each pattern is an exact name, ends with a single
  trailing `*` (prefix match), or is a lone `*`. Interior wildcards
  (`a*b`) are rejected at issue time.
- Chain: `Identity → Handle → SubHandle`, maximum depth 2 (one Handle
  attestation plus one SubHandle attestation).
- Attestations are **byte-deterministic**: the same (subject key, name,
  grant, `iat`, `exp`, `jti`) produce byte-identical tokens.

## 6. Session payload

Key order and schema:

```json
{
  "hId": "<base64url of the signing Handle/SubHandle public key>",
  "hNm": "<canonical name>",
  "aud": "<intended audience>",
  "scp": ["…"],
  "exp": 1767227400,
  "iat": 1767225600,
  "jti": "<session id, used for revocation>",
  "hPath": ["<handleName>", "<subName>"]
}
```

- `hPath` is present **only** for SubHandle sessions and is the last key.
- **Payload limit: 4096 bytes.** Larger tokens are rejected.
- `ttl` must be a positive integer; `exp = iat + ttl`.
- Signed by the Handle/SubHandle key.

## 7. Verification

**Clock skew: `CLOCK_SKEW_SECONDS = 30`.** All time comparisons use it:
expired when `now > exp + 30`; not yet valid when `now < iat - 30`.

### 7.1 Stateless mode (verifier holds the identity key)

Order is normative: token structure → required fields → audience match →
time (with skew) → key reconstruction (`hPath` present: atomic
`deriveSubHandle(hPath[0], hPath[1])`; otherwise `deriveHandle(hNm)`) →
signature against the reconstructed public key → `hId` agreement →
revocation (`jti`) last.

### 7.2 Attested mode (verifier holds only the root public key)

Chain shape: exactly 1 attestation for a Handle session, exactly 2 for a
SubHandle session, root first. Enforcement order and error levels:

| Step | Check | Error level |
|---|---|---|
| 1 | token structure / required fields | FORMAT |
| 2 | audience | SESSION |
| 3 | session time ± skew | SESSION (`EXPIRED` / `NOT_YET_VALID`) |
| 4 | chain shape (1 or 2 attestations) | FORMAT (`CHAIN_INCOMPLETE`) |
| 5–8 | root attestation: decode → signature vs root → time → revocation | ROOT |
| 9a | SubHandle chain: decode B → signature vs `A.subjectId` → time → revocation | HANDLE_ATTESTATION |
| 9b | path agreement (`A.subjectName == hPath[0]`, `B.subjectName == hPath[1]`), wildcard permitting, `hId == B.subjectId`, grant nesting (`B.scopes ⊆ A.scopes`, `B.maxSessionTtl ≤ A.maxSessionTtl`) | ROOT / SUB_ATTESTATION |
| 10 | session vs effective grant `G` (scopes, audiences, duration ≤ `maxSessionTtl`) | SESSION |
| 11 | session does not outlive the earliest attestation (`exp ≤ min(A.exp, B.exp) + skew`) | SESSION (`SESSION_OUTLIVES_ATTESTATION`) |
| 12 | session signature against `hId` | SESSION |
| 13 | session revocation | SESSION |

Error model: every failure — attested-mode verification, token parsing,
stateless verification and session-creation constraint checks — is
`AttestationError` with a stable `code` (`MALFORMED`, `BAD_SIGNATURE`,
`EXPIRED`, `NOT_YET_VALID`, `REVOKED`, `PATH_MISMATCH`,
`NAME_NOT_PERMITTED`, `SCOPE_EXCEEDED`, `AUDIENCE_NOT_PERMITTED`,
`TTL_EXCEEDED`, `CHAIN_INCOMPLETE`, `SUBJECT_MISMATCH`,
`SESSION_OUTLIVES_ATTESTATION`) and a `level` (`ROOT`,
`HANDLE_ATTESTATION`, `SUB_ATTESTATION`, `SESSION`, `FORMAT`).
Integrators match on `code`/`level`, never on message text. Two
library-level codes exist outside the verification table:
`MAX_DEPTH_EXCEEDED` (derivation-tree limit — a leaf SubHandle cannot
derive children) and `DISPOSED` (capability call on a disposed
Identity/Handle, carried by the `DisposedError` subclass).

API-misuse errors — non-string name/context and a non-`Signer` issuer —
throw `TypeError`; they indicate a programming error, not a protocol
failure, and are outside the `AttestationError` model.

## 8. Issuance and the Signer requirement

Issuance is always performed by the issuer's own private key (root key for
Handle attestations, Handle key for SubHandle attestations). Normative
requirement (v1.0 target): the public issuance API accepts a **Signer** —
an object capable of producing an Ed25519 signature — so that raw private
keys never cross a trust boundary as function arguments. Conforming
implementations SHOULD expose issuance through such an abstraction.

Passing raw private-key bytes (the pre-0.8 form) throws `TypeError`;
`Identity`, `Handle` and `SubHandle` implement `Signer` and are the
expected issuers.

## 9. Revocation

Revocation is application-defined via a single checker interface consulted
by `jti` — for every chain level (attestation `jti`) and for the session
`jti`. It is always the **last** check, after signature verification.
Attestation `jti` values and session `jti` values share one namespace at
v1.0; applications prefixing them is recommended (`att:` / `sess:`).

## 10. Test vectors

[`specs/test-vectors.json`](./test-vectors.json) is the machine-checkable
form of this document: seed, identity/handle/subhandle derivation,
passwords, channel keys, shared secrets, byte-exact attestation tokens and
pinned session tokens. Regenerate with:

```bash
corepack pnpm --filter @me2em/core vectors
```

The golden test re-computes every row; a failure is a protocol break, not a
test bug. The vector file version (`vector_file_version`) increments with
every normative change in this document.

## 11. Module structure (reference implementation)

The TypeScript reference implementation keeps its module graph **acyclic
and 100% static**: every module can be the graph root, in any order, with
no initialization dance and no dynamic `import()` anywhere in `src/`.

Two structural decisions are pinned here so they are not "simplified"
back into a cycle later:

- **`Handle` and `SubHandle` are co-located in one module**
  (`packages/core/src/handle.ts`); `packages/core/src/subhandle.ts` is a
  pure re-export facade that preserves the public import path.
  Rationale: `SubHandle extends Handle`, and a cross-file static subclass
  import (`import { SubHandle } from './subhandle.js'` in `handle.ts`)
  makes the ESM module graph cyclic with a top-level use — the class
  heritage clause is evaluated at module-evaluation time, so when the
  graph is rooted at `handle.ts` the `class SubHandle extends Handle`
  statement runs before the `Handle` class binding is initialized and
  the module dies with `ReferenceError: Cannot access 'Handle' before
  initialization`. Such a cycle is illegal under ESM, not merely fragile
  (reproduced on Node 24). Co-location is therefore a deliberate
  deviation from a naive "static subclass import in `handle.ts`" reading
  of the architecture plan. The co-located module is side-effect-free at
  module level (imports, constants, helper functions and the class
  declarations themselves), so bundlers can still eliminate unused
  exports.
- **All cryptographic primitives live behind the `crypto/` wrappers**
  (`crypto/hkdf.ts`, `crypto/init.ts`, `crypto/ecdh.ts`,
  `crypto/derivation.ts`, `crypto/derivation-paths.ts`); no `@noble/*`
  import exists outside `packages/core/src/crypto/`, checked across every
  import form (static, side-effect, dynamic `import()`, `require()`,
  `export … from`). The Ed25519 backend is imported by `crypto/init.ts`
  alone, which configures it on module load (auto-init); every module
  that performs an Ed25519 operation imports the backend from there, so
  any code path that can reach a crypto operation sees a configured
  backend regardless of import order. The package exposes a single entry
  point (its `exports` map allows only the root), so deep imports cannot
  bypass this guarantee.

These properties are enforced by tests (`test/arch-cycle.spec.ts`,
`test/import-order-*.spec.ts`), not by convention.

---

## 12. Public API freeze (1.0.0-rc.1)

As of `@me2em/core` **1.0.0-rc.1** the package's public API is
**frozen**. The frozen surface is exactly:

- the classes `Identity`, `Handle`, `SubHandle`, `Session`, `Attestation`
  and the `DisposedError` / `AttestationError` model (`code`/`level`,
  error-code table of §7);
- the `Signer` contract and the issuance signature
  `Attestation.issue(signer, subjectPublicKey, subjectName, grant, opts)`;
- the `crypto` namespace — the derivation functions (`deriveRootKey`,
  `deriveHandleKey`, `deriveSubHandleKey`, `rawX25519SharedSecret`),
  the documented raw Ed25519 wrappers (`getPublicKey`, `sign`,
  `verify`, `getPublicKeyAsync`, `signAsync`, `verifyAsync`) and the
  hash helpers (`hkdf`, `sha256`) — plus `DERIVATION_PATHS`.
  (`derivePassword` and `deriveChannelKey` are `Handle` methods, not
  namespace members);
- the seed utilities (`generateSeedPhrase`, `normalizeSeedPhrase`,
  `validateSeedPhrase`, `get64ByteSeedFromMnemonic`, `wordlist`) and
  the BIP39 passphrase model (§2);
- the wire formats and limits of §4–§7 (token types
  `me2em/attestation/v1`, `me2em/session/v1`; payload caps 2048/4096
  bytes; `CLOCK_SKEW_SECONDS = 30`; `MAX_DEPTH = 2`), pinned byte-exact
  by `specs/test-vectors.json`.

Evolution rules from this point:

1. **Additive** changes (new optional parameters, new error codes, new
   exported helpers) may land in `1.x` minor releases and are recorded
   in the package CHANGELOG.
2. **Breaking** changes — anything that removes or narrows the surface
   above, or any derivation-affecting change (the Freeze rule at the top
   of this document) — require
   `2.0.0` **and** a new spec revision with a new vector-file revision.
   The derivation revision mechanism (§3) remains the only sanctioned
   rotation path inside 1.x.
3. The CHANGELOG is the record of every change at the moment it lands;
   there are no compatibility shims and no migration guides (the
   no-compatibility mandate, program amendment A9).

This section is documentation of a release-management decision; the
machine-checkable part of the freeze is the golden vector file — any
deviation from §4–§7 fails the conformance test.
