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.
Handles and SubHandles (MAX_DEPTH = 2), derived offline via HKDF.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
Kbelongs to the nameNand is valid within grantG".
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.
Identityclass 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
}
Handleclass 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
}
SubHandle (leaf, MAX_DEPTH = 2)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)
}
dispose())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
}
Sessionclass 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>;
}
Attestationclass 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:
π 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.
Ed25519 key = HKDF-SHA256(seed, salt="", info="me2em/identity/v1/root", 32)HKDF-SHA256(identityKey, salt="", info="me2em/handle/v1/{name}", 32)HKDF-SHA256(handleKey, salt="", info="me2em/subhandle/v1/{handle}/{sub}", 32)base64url(payload).base64url(signature)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.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.@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
engines.node, A2 β Node 18/20 are EOL; CI pins
Node 22).@scure/base β btoa/atob are banned from
src/ by a source-scan test; jti generation uses
crypto.randomUUID with a getRandomValues-based v4 fallback.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:
RevocationChecker (e.g., Redis SET) β it is consulted for
attestation and session jti in verifyAttested.verifyAttested for any external audience; reserve
verifyStateless for infrastructure you fully control.subNamePatterns as narrowly as possible β a holder can do
everything inside its wildcard.β Don't:
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.