@me2em/core

Core primitives for the Me2eM authorization protocol: hierarchical deterministic identities (Identity β†’ Handle β†’ SubHandle), stateless signed sessions, and parent-signed attestation chains for delegatable, externally verifiable authorization β€” all on Ed25519.

npm version License Docs

  • Hierarchical Deterministic Identities β€” one seed β†’ isolated Handles and SubHandles (MAX_DEPTH = 2), derived offline via HKDF.
  • Stateless Authentication β€” self-contained signed session tokens, verified without server-side storage, with optional revocation.
  • Attestations β€” a parent cryptographically binds a child key to a name and a grant (audiences, scopes, TTL caps, name patterns). Chains are verified offline by third parties using only the root public key β€” the root private key never leaves its owner.
  • Zero-Knowledge Password Management β€” deterministic password derivation without storage.
  • Secure Channels β€” symmetric channel keys and X25519 shared secrets derived without key exchange.
  • BIP39 Passphrase β€” optional "25th word" support: the passphrase silently derives a different identity if changed.
  • UKS-Safe ECDH β€” deriveSharedSecret binds both peers' public keys into the KDF (p2p-channel/v2), preventing unknown key-share attacks.

Protocol terms are domain-neutral β€” each use case assigns them its own meaning. The five terms most relevant to this package:

Term Definition
Identity Root cryptographic identity derived from a seed phrase. Constant forever.
Handle A derived, attested, named key representing a distinct identity context.
SubHandle A leaf child of a Handle (MAX_DEPTH = 2).
Attestation A parent-signed statement binding a child key to a name and a grant.
Session A stateless signed authorization token.

Domain interpretations β€” the same primitives, read through different lenses:

Use case Handle is… SubHandle is…
AI Agent Delegation (UC-1) An AI agent acting on behalf of a user β€” (the agent signs sessions directly)
IoT Device Hierarchy (UC-2) A physical device A component of that device
Multi-Context Identity (UC-3) A user persona (work, personal) Delegated access within a persona
Multi-App SSO (UC-4) A per-app persona of the user β€”
Deterministic Password Manager (UC-5) A service context β€”
E2EE Messaging (UC-6) The user's messaging identity An optional device identity

Full glossary (including Channel and material-class terms): see the root GLOSSARY.md.


npm install @me2em/core
# or
pnpm add @me2em/core

Dependencies: @noble/curves (Ed25519/X25519), @noble/ed25519, @noble/hashes (HKDF, SHA-256), @scure/bip39 (mnemonic handling).

πŸ’‘ This package is self-contained. For E2EE channels, X3DH key agreement, and Argon2id use @me2em/crypto. For React hooks and seed phrase UX components use @me2em/react. The three packages are designed to work together β€” see the root README for the full package map.

import { Identity, Session } from '@me2em/core';

// 1. Create an Identity from a 64-byte seed
const seed = new Uint8Array(64).fill(42); // use a CSPRNG in production
const identity = await Identity.fromSeed(seed);

// 2. Derive a contextual Handle
const station = await identity.deriveHandle('station-001', {
displayName: 'Station Berlin #001',
});

// 3. Derive a SubHandle with constraints
const connector = await station.deriveSubHandle('connector-ccs', {
allowedAudiences: ['ev-app.com'],
allowedScopes: ['charge:start', 'charge:stop'],
maxSessionTtl: 7200,
});

// 4. Create a stateless session (signed by the SubHandle)
const session = await Session.create(connector, {
audience: 'ev-app.com',
scopes: ['charge:start'],
ttl: 1800,
});

console.log(session.token); // base64url(payload).base64url(signature)
import {
Identity, generateSeedPhrase,
get64ByteSeedFromMnemonic, validateSeedPhrase,
} from '@me2em/core';

const phrase = generateSeedPhrase(128); // 12 words (256 β†’ 24 words)
const check = validateSeedPhrase(phrase);
if (!check.isValid) throw new Error(check.error);

const seedBytes = await get64ByteSeedFromMnemonic(phrase); // full 64-byte BIP39 seed
const identity = await Identity.fromSeed(seedBytes);

⚠️ Passphrases (BIP39 "25th word") ARE supported: get64ByteSeedFromMnemonic(words, passphrase). The passphrase is case-sensitive, not recoverable, and silently derives a different identity if mistyped. Store it with the same care as the words.

An attestation is a parent-signed statement:

"the public key K belongs to the name N and is valid within grant G".

Chains (root β†’ handle β†’ subhandle β†’ session) let third parties verify sessions offline using only the root public key β€” with grant constraints enforced at verification time, not just at creation.

Identity ──attestHandle──▢ Handle (e.g. a charging station, issued once)
                             β”‚  offline, no Identity, no network:
                             β”œβ”€ deriveSubHandle('connector-ccs')
                             β”œβ”€ attestSubHandle('connector-ccs', grant)
                             └─ Session.create(subHandle, ...)
                             β–Ό
        third-party verifier: verifyAttested(token, rootPublicKey, [A, B], audience)
// Root owner (once per handle lifetime):
const A = await identity.attestHandle('station-001', {
audiences: ['ev-app.com'],
scopes: ['charge:start', 'charge:stop', 'charge:status'],
maxSessionTtl: 7200,
subNamePatterns: ['connector-*', 'meter-*'], // wildcard: trailing '*' only
});

// Handle (autonomous, offline, per subhandle):
const B = await station.attestSubHandle('connector-ccs', {
audiences: ['ev-app.com'],
scopes: ['charge:start', 'charge:stop'],
maxSessionTtl: 7200,
});

The attested public key is always derived internally β€” a parent cannot attest a foreign key, and B.subjectId always equals the key reconstructed via identity.deriveSubHandle('station-001', 'connector-ccs').

import { Session, AttestationError } from '@me2em/core';

try {
const s = await Session.verifyAttested(
session.token,
rootPublicKey, // Uint8Array β€” public key only
[A.token, B.token], // chain, root first; length 1 for handle sessions
'ev-app.com',
revocationChecker?, // optional; checks attestation and session jti
);
await startCharging(s.path![1]);
} catch (e) {
if (e instanceof AttestationError) {
console.error(e.code, e.level); // e.g. SCOPE_EXCEEDED / SESSION
}
throw new Error('Unauthorized');
}

All failures throw AttestationError with code and level (ROOT | HANDLE_ATTESTATION | SUB_ATTESTATION | SESSION | FORMAT) β€” including token parsing, Session.verifyStateless and session-creation constraint checks (PR-7). Match on code/level, never on messages:

code levels
MALFORMED, CHAIN_INCOMPLETE, MAX_DEPTH_EXCEEDED, DISPOSED FORMAT
BAD_SIGNATURE ROOT, HANDLE_ATTESTATION, SESSION
EXPIRED, NOT_YET_VALID, REVOKED ROOT, HANDLE_ATTESTATION, SESSION
PATH_MISMATCH ROOT, HANDLE_ATTESTATION, SESSION
NAME_NOT_PERMITTED, SCOPE_EXCEEDED, TTL_EXCEEDED ROOT, SESSION
SCOPE_EXCEEDED, TTL_EXCEEDED, AUDIENCE_NOT_PERMITTED, SESSION_OUTLIVES_ATTESTATION SESSION
SUBJECT_MISMATCH ROOT, SUB_ATTESTATION, SESSION

MAX_DEPTH_EXCEEDED signals the derivation-tree limit (a leaf SubHandle cannot derive children); DISPOSED signals a capability call on a disposed Identity/Handle β€” DisposedError is an AttestationError subclass carrying that code, so e.name === 'DisposedError' still works for targeted handling.

The library provides two ways to verify a session token. Both answer the same question β€” "is this signature valid, unexpired, unrevoked, and what does it allow?" β€” but they differ in who is able to verify and in what is actually enforced.

Mode 1 β€” Session.verifyStateless (direct verification). The verifier re-derives the signer's public key from the token's hNm/hPath using an Identity instance and checks the signature. Re-derivation requires the private root key, so Mode 1 is only available to the key holder itself or to backend infrastructure trusted with the root. Scopes, audience, TTL and revocation are checked from the token itself β€” but SubHandle constraints (allowedScopes, maxSessionTtl, …) are enforced only at session creation. A party that has obtained a SubHandle private key can sign a session with any scopes and TTL, and Mode 1 verification will accept it.

Mode 2 β€” Session.verifyAttested (attestation-chain verification). The verifier checks the session signature against the key bound by a parent-signed attestation, and each attestation against its parent's signature, up to the root public key. Because every chain link carries a signed grant, the verifier enforces grants at verification time: session scopes must fit the grant, TTL caps and audiences hold, and child grants must nest inside parent grants. This makes delegation externally verifiable β€” a partner service verifies sessions with the root public key only β€” and revoking an attestation jti disables the entire branch: current and future sessions under it.

Which mode should I use?

Situation Mode
Verifier is your own trusted backend that already holds the root key Mode 1
Verifier is a third party / partner / customer who must not receive the root private key Mode 2
You need branch revocation (disable a device, an employee, a sold deal entirely) Mode 2
SubHandle constraints must be enforced, not just advisory Mode 2
Session.verifyStateless (Mode 1) Session.verifyAttested (Mode 2)
Verifier needs Identity (private root) root public key + chain
Trust model verifier is the root owner any third party
SubHandle constraints enforced at creation only enforced at verification
Revoke a handle/branch per-session jti only revoke attestation jti
Use when closed perimeter, own services external audiences, delegation, selling access

The modes coexist naturally: internal telemetry verified with Mode 1, external access granted via Mode 2 β€” see USE_CASES.md for all nine production scenarios.

class Identity {
static fromSeed(seed: Uint8Array | string): Promise<Identity>; // 64 bytes or 128-char hex
deriveHandle(name: string, metadata?: HandleMetadata): Promise<Handle>;
deriveSubHandle(handleName: string, subName: string,
metadata?: SubHandleMetadata): Promise<SubHandle>; // atomic, for verification
sign(data: Uint8Array): Promise<Uint8Array>; // implements Signer (root key)
attestHandle(name: string, grant: AttestationGrant,
opts?: { ttlSeconds?, expiresAt?, jti?, now? }): Promise<Attestation>;
getPublicKey(): Uint8Array;
dispose(): void; // zeroize key; capability methods throw DisposedError
}
class Handle {
getId(): string; // base64url of the public key
getName(): string; // canonical (normalized) name
getMetadata(): HandleMetadata | undefined; // defensive copy
getPublicKey(): Uint8Array; // defensive copy
getPath(): string[] | undefined; // undefined for Handle, path for SubHandle
sign(data: Uint8Array): Promise<Uint8Array>;
static verify(sig: Uint8Array, data: Uint8Array, pub: Uint8Array): Promise<boolean>;
derivePassword(context: string, options?: { revision?: number; byteLength?: number }): string; // base64url secret
deriveChannelKey(context: string, options?: { revision?: number }): Uint8Array; // 32-byte AES key
deriveSharedSecret(otherPublicKey: Uint8Array): Promise<Uint8Array>; // X25519 + HKDF
deriveSubHandle(name: string, metadata?: SubHandleMetadata): Promise<SubHandle>;
attestSubHandle(subName: string, grant: AttestationGrant,
opts?: { ttlSeconds?, expiresAt?, jti?, now? }): Promise<Attestation>;
validateSessionOptions(options: { audience: string; scopes: string[]; ttl: number }): void;
dispose(): void; // zeroize key; capability methods throw DisposedError
}
class SubHandle extends Handle {
getPath(): string[]; // ['station-001', 'connector-ccs'] β€” a copy
getPathString(): string; // 'station-001/connector-ccs'
getDepth(): number; // always 2
isLeaf(): boolean; // always true
getSubMetadata(): SubHandleMetadata; // defensive copy of constraints
validateSessionOptions(options: { audience: string; scopes: string[]; ttl: number }): void;
// deriveSubHandle is overridden and ALWAYS throws (leaf node)
}

Identity and Handle own true #private key material. dispose() zeroizes the private key and permanently revokes every capability method (sign, derivations, attestation issuance) β€” they then throw DisposedError. Informational getters (getName, getId, getPublicKey, metadata) keep working: public keys are not secrets. Serialization is redacted by design β€” JSON.stringify(handle) and console.log(handle) emit only { kind, id, name, path? }, never key material. Defensive copies on inputs and getters mean caller-side mutation never reaches the stored keys.

const handle = await identity.deriveHandle('station-001');
try {
// ... use the handle ...
} finally {
handle.dispose(); // key material zeroized; idempotent
}
class Session {
static create(handle: Handle | SubHandle, options: SessionOptions): Promise<Session>;
static verifyStateless(token: string, companyIdentity: Identity,
expectedAudience: string,
revocationChecker?: RevocationChecker): Promise<Session>;
static verifyAttested(token: string, rootPublicKey: Uint8Array,
attestationChain: string[], expectedAudience: string,
revocationChecker?: RevocationChecker): Promise<Session>;
isExpired(): boolean;
readonly handleId, handleName, audience, scopes, expiresAt, token, path, sessionId;
}

interface SessionOptions {
audience: string; scopes: string[]; ttl: number; sessionId?: string;
}

interface SessionPayload { // signed token contents
hId: string; hNm: string; hPath?: string[];
aud: string; scp: string[]; exp: number; iat: number; jti: string;
}

interface RevocationChecker {
isRevoked(jti: string): Promise<boolean>;
}
class Attestation {
static issue(signer: Signer, subjectPublicKey: Uint8Array,
subjectName: string, grant: AttestationGrant,
opts?: { ttlSeconds?, expiresAt?, jti?, now? }): Promise<Attestation>;
static decode(token: string): AttestationPayload; // structure only β€” no sig, no time
static verifySignature(token: string, signerPublicKey: Uint8Array): Promise<boolean>;
static matchNamePattern(name: string, patterns: string[]): boolean;
get payload(): AttestationPayload;
get token(): string;
get jti(): string;
}

interface Signer { // the issuance contract (D2)
sign(data: Uint8Array): Promise<Uint8Array>;
}
// Identity, Handle and SubHandle implement Signer; custom
// (remote/HSM-backed) signers are accepted structurally. Raw private
// keys are NOT accepted as of 1.0.0-rc.1 β€” the signer's key stays encapsulated.

interface AttestationGrant {
audiences?: string[]; // undefined = unrestricted; [] = deny all
scopes: string[]; // exact strings, no wildcards
maxSessionTtl: number; // seconds, > 0
subNamePatterns?: string[]; // root→handle only; trailing '*' only
}

class AttestationError extends Error {
readonly code: AttestationErrorCode;
readonly level: AttestationLevel;
}

All string derivation inputs are canonicalized: NFKC β†’ lowercase β†’ trim. NFKC applies to string inputs only β€” revision (a positive integer) is canonical by construction and is never normalized. Names are then validated against /^[a-z0-9][a-z0-9._@-]{0,62}$/; names with /, non-ASCII letters, or > 64 chars throw. Password/channel contexts use the same canonical form without the character-set pattern (a context is a caller-defined label, not a wire identifier) but MUST NOT be empty after canonicalization. Identical inputs always produce identical keys across platforms and entry points.

derivePassword and deriveChannelKey take a revision (positive integer, default 1). A revision is a monotonic integer β€” canonical by construction; the NFKC canonicalization above applies to the string context only. Revision 1 is the initial secret β€” byte-identical to the frozen v1.0 derivation. Revision N β‰₯ 2 derives a secret that is cryptographically independent of revision 1 for the same context: the sanctioned rotation path when a credential leaks, without changing the context string. Both parties of a channel MUST bump the revision in lockstep.

const github = handle.derivePassword('github');                  // revision 1
const rotated = handle.derivePassword('github', { revision: 2 }); // after a breach
const long = handle.derivePassword('github', { byteLength: 32 });
const channel = handle.deriveChannelKey('telemetry-v1', { revision: 2 }); // both peers!

Info strings: me2em/secret/v1 β†’ me2em/secret/v1/rN, me2em/channel/v1 β†’ me2em/channel/v1/rN (centralized in DERIVATION_PATHS.password(revision?) / .channel(revision?)). byteLength accepts an integer in [1, 8160] (RFC 5869 bound); the output is prefix-stable β€” the first n bytes of a longer derivation equal the n-byte derivation.

Choosing revision values (application-level convention). A revision is an opaque positive integer β€” the protocol deliberately does not assign meaning to it (mechanism, not policy). Common app-level conventions: date-stamped rotations (20261009 = YYYYMMDD, keeps the rotation history readable), small enum-like codes for named credential generations, or a hash of a human-readable label mapped into the integer range β€” the last one reproduces the same secret from the label across implementations without storing the mapping. The label↔revision mapping (where one exists) is public metadata: keep it next to the credentials it describes (e.g. in the password manager's notes) β€” the secret itself is protected by the seed alone.

Mapped to the official registry β€” see USE_CASES.md:

  • UC-5 β€” Deterministic password manager β€” derive secrets per service, store nothing.
  • UC-2 β€” IoT fleets with encrypted channels β€” devices operate fully offline.
  • Stateless multi-device sync β€” same seed β†’ same handles everywhere (spans UC-3/UC-4).
  • UC-1 / UC-2 β€” Delegatable access control β€” attestation chains grant scope- and time-limited access, verifiable by external parties.

πŸ‘‰ USE_CASES.md β€” nine scenarios: AI agents, satellite capacity marketplace, drone fleet management, multi-app SSO, multi-context identity, EV charging, corporate messenger, password manager, E2EE messenger β€” plus a production Redis RevocationChecker.

  • Identity: Ed25519 key = HKDF-SHA256(seed, salt="", info="me2em/identity/v1/root", 32)
  • Handle: HKDF-SHA256(identityKey, salt="", info="me2em/handle/v1/{name}", 32)
  • SubHandle: HKDF-SHA256(handleKey, salt="", info="me2em/subhandle/v1/{handle}/{sub}", 32)
  • Signatures: Ed25519 (RFC 8032); tokens are base64url(payload).base64url(signature)
  • Attestation payload limit: 2048 bytes; session payload limit: 4096 bytes
  • Clock skew tolerance: Β±30 seconds on all time checks
  • Derivation module: the formulas above live in the crypto namespace β€” crypto.deriveRootKey, crypto.deriveHandleKey, crypto.deriveSubHandleKey, plus crypto.rawX25519SharedSecret for the raw X25519 (Montgomery) ECDH step of the P2P derivation. Both SubHandle entry points (handle.deriveSubHandle and identity.deriveSubHandle) share crypto.deriveSubHandleKey, so their outputs are byte-identical by construction.
  • Raw primitives in crypto: besides the derivation module, the namespace exposes documented Ed25519 wrappers (crypto.getPublicKey, crypto.sign, crypto.verify and their *Async variants) and the crypto.hkdf / crypto.sha256 helpers β€” building blocks for tooling outside the token flow (the protocol itself signs through Identity/Handle/SubHandle, which implement Signer). The wrappers are real functions rather than alias re-exports so TSDoc ownership and TypeDoc typing stay in-package (treatWarningsAsErrors); they are frozen public API (spec Β§12) β€” removal is a breaking 2.0.0 decision, do not propose.
  • Auto-init: importing @me2em/core synchronously configures the Ed25519 backend (sha512 wiring for @noble/ed25519). There is no init call to remember and no async setup β€” the first sign/verify/derive can run immediately after import. The guarantee is structural, not conventional: the package exposes a single entry point (the exports map allows only . β€” deep subpath imports such as @me2em/core/crypto/* are not part of the public API), and every module that touches Ed25519 imports the backend from crypto/init.ts, which configures it on module load. Any code path that can reach a crypto operation therefore loads the configured backend first, in any import order (pinned by test/arch-cycle.spec.ts; see specs/v1.0.md Β§11 for the module-structure contract).

Runtimes

  • Node.js β‰₯ 22 (engines.node, A2 β€” Node 18/20 are EOL; CI pins Node 22).
  • Modern browsers and edge runtimes. Portability is pinned by tests: base64url comes from @scure/base β€” btoa/atob are banned from src/ by a source-scan test; jti generation uses crypto.randomUUID with a getRandomValues-based v4 fallback.
  • The Ed25519 backend auto-initializes at import (structural guarantee β€” see Cryptographic Details above); modules are side-effect-free at the top level otherwise.

Limits

Limit Value Enforced
Attestation payload ≀ 2048 bytes (ATTESTATION_MAX_PAYLOAD, exported) rejected before decode
Session payload ≀ 4096 bytes rejected before decode
Clock-skew tolerance Β±30 seconds on all time checks EXPIRED / NOT_YET_VALID
Derivation tree depth MAX_DEPTH = 2 β€” a SubHandle is always a leaf MAX_DEPTH_EXCEEDED
Password byteLength integer 1–8160 (RFC 5869: 255 Γ— SHA-256), default 16 MALFORMED/FORMAT
Session ttl positive integer (seconds), capped by the grant's maxSessionTtl; the session cannot outlive the earliest attestation expiry MALFORMED / TTL_EXCEEDED
jti length non-empty, ≀ 256 characters MALFORMED

Key lifecycle: dispose() zeroizes the private key (fill(0)) and permanently revokes all capability methods (DisposedError); it is idempotent. Hard zeroization cannot be guaranteed in a JS runtime β€” GC/JIT may retain copies (BACKLOG BL-07, THREAT_MODEL.md Β§5.2).

βœ… Do:

  • Store seeds encrypted (PIN/biometric + AES-GCM); keep the root offline except for issuance ceremonies.
  • Use short session TTLs (≀ 1 hour recommended).
  • Implement a RevocationChecker (e.g., Redis SET) β€” it is consulted for attestation and session jti in verifyAttested.
  • Prefer verifyAttested for any external audience; reserve verifyStateless for infrastructure you fully control.
  • Scope subNamePatterns as narrowly as possible β€” a holder can do everything inside its wildcard.

❌ Don't:

  • Never transmit seeds; keep derivation client-side.
  • Don't reuse Handles across unrelated contexts.
  • Don't log private keys or seed phrases.
  • Don't rely on zeroing buffers as a hard guarantee β€” GC/JIT may retain copies (tracked in BACKLOG BL-07).
cd packages/core && pnpm test

350 tests cover: deterministic derivation and cross-entry-point key consistency, name canonicalization, derivation revisions (r1 byte-parity, r2 independence, NFKC contexts), session lifecycle (create, verify, tamper, expiry, iat), revocation, SubHandle constraint enforcement, MAX_DEPTH leaf enforcement, attestation issue/decode/signature determinism and validation, the full verifyAttested chain (28 cases including nesting, wildcard permitting, revocation at every level, signature robustness, BIP39 passphrase derivation, and wordlist validation), module-structure and crypto-boundary pins, and parser fuzz (~1 050 rounds).

Passphrase support and dispose() zeroization have shipped. Remaining recommendations β€” session lineage (sid), zeroization hardening (BL-07), attestation reference compression, and the full package strategy (@me2em/crypto, @me2em/messenger, @me2em/iot) β€” live in BACKLOG.md. The backlog is advisory and not part of the shipped API contract.

The public API is frozen as of 1.0.0-rc.1 β€” see specs/v1.0.md Β§12 for the evolution rules.

Apache License 2.0 β€” see LICENSE.

Β© 2026 Me2eM Organization. Built for privacy, openness, and user sovereignty.