me2em Protocol API Reference - v0.5.0-alpha.1

๐Ÿ“ฆ @me2em/core โ€” Core Cryptographic Primitives

Core primitives for the Me2em authorization protocol: Identity, Handle, and secure channel derivation with Ed25519 cryptography.

npm version License Docs


@me2em/core provides the cryptographic foundation for the Me2em protocol โ€” a decentralized multi-context identity system. It enables:

  • Hierarchical Deterministic Identities: One seed โ†’ multiple isolated Handles
  • Zero-Knowledge Password Management: Deterministic password derivation without storage
  • Secure Channel Communication: Encrypted channels between Identity and Handles without key exchange
  • Stateless Authentication: Cryptographic proof without server-side session storage

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

Dependencies:

  • @noble/ed25519 โ€” Ed25519 signatures
  • @noble/hashes โ€” HKDF, SHA-256

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

// 1. Create Identity from seed (32 bytes)
const seed = new Uint8Array(32).fill(42); // Replace with your secure seed
const identity = await Identity.fromSeed(seed);

// 2. Derive a contextual Handle
const workHandle = await identity.deriveHandle('work', {
displayName: 'Alice @ Work',
avatar: 'https://example.com/avatar.png'
});

// 3. Use Handle for cryptographic operations
const message = new TextEncoder().encode('Hello, world!');
const signature = await workHandle.sign(message);
const isValid = await Handle.verify(signature, message, workHandle.getPublicKey());

console.log('Handle ID:', workHandle.getId()); // base64url(publicKey)
console.log('Signature valid:', isValid); // true

For production applications, use BIP39 mnemonic phrases. @me2em/core provides built-in utilities to handle generation, validation, and conversion to the required 32-byte Ed25519 seed.

import { 
Identity,
generateSeedPhrase,
get32ByteSeedFromMnemonic,
validateSeedPhrase
} from '@me2em/core';

// 1. Generate a 12-word phrase (use 256 for 24 words)
const phrase = generateSeedPhrase(128);
console.log('Your seed:', phrase.join(' '));

// 2. (Optional) Validate a user-provided phrase
const validation = validateSeedPhrase(phrase);
if (!validation.isValid) throw new Error(validation.error);

// 3. Convert to 32-byte seed and create Identity
const seedBytes = await get32ByteSeedFromMnemonic(phrase);
const identity = await Identity.fromSeed(seedBytes);

Why is this better? BIP39 produces a 64-byte seed, but Ed25519 requires exactly 32 bytes. The get32ByteSeedFromMnemonic utility handles the SHA-256 hashing deterministically and safely, so you don't have to write boilerplate crypto code.


The root cryptographic identity, derived from a seed phrase.

class Identity {
// Create Identity from seed (32-byte Uint8Array)
static fromSeed(seed: Uint8Array): Promise<Identity>;

// Derive a new Handle with optional metadata
deriveHandle(name: string, metadata?: HandleMetadata): Promise<Handle>;

// Get root public key (for verification, never share private key)
getPublicKey(): Uint8Array;
}

Creates an Identity from a 32-byte seed.

const seed = new Uint8Array(32);
crypto.getRandomValues(seed); // Generate secure random seed
const identity = await Identity.fromSeed(seed);

Security: The seed must be kept secret. Never log, transmit, or store it in plaintext.

Derives a new Handle deterministically from the Identity.

const handle = await identity.deriveHandle('google', {
displayName: 'Alice Personal',
avatar: 'https://example.com/alice.png'
});

Parameters:

  • name: Unique identifier for this Handle (case-insensitive, trimmed)
  • metadata: Optional public data attached to the Handle

Returns: A Handle instance with its own keypair, derived deterministically from the Identity.

Key Properties:

  • ๐Ÿ” Deterministic: Same seed + same name โ†’ same Handle (always)
  • ๐Ÿ”— Isolated: Each Handle has its own keypair; compromise of one does not affect others
  • ๐Ÿ”’ One-way: Cannot derive Identity key from Handle key

Built-in helpers for human-friendly key generation and validation.

// Generate a new phrase (128 bits = 12 words, 256 bits = 24 words)
function generateSeedPhrase(strength: 128 | 256 = 128): string[];

// Normalize input (handles extra spaces, lowercase)
function normalizeSeedPhrase(input: string | string[]): string[];

// Validate word count, wordlist, and BIP39 checksum
function validateSeedPhrase(words: string[]): { isValid: boolean; error?: string };

// Convert validated mnemonic to a secure 32-byte Uint8Array for Ed25519
async function get32ByteSeedFromMnemonic(phrase: string | string[]): Promise<Uint8Array>;

โœ… Benefit: Developers don't need to manually manage @scure/bip39 imports or remember to hash the 64-byte output. Everything is handled securely within the protocol.


A derived Ed25519 keypair representing a specific context (work, personal, IoT device).

class Handle {
// Get public identifier (safe to share)
getId(): string; // base64url-encoded public key

// Sign arbitrary data with Handle's private key
sign(data: Uint8Array): Promise<Uint8Array>;

// Verify a signature using a public key (static method)
static verify(signature: Uint8Array, data: Uint8Array, publicKey: Uint8Array): Promise<boolean>;

// Deterministically derive a password/secret for a specific context
derivePassword(context: string, length?: number): string;

// Derive a symmetric channel key for encrypted communication
deriveChannelKey(context: string): Uint8Array;

// Accessors
getName(): string;
getMetadata(): HandleMetadata | undefined;
getPublicKey(): Uint8Array;
}

Returns a URL-safe base64 string representing the Handle's public key:

const handleId = handle.getId();
// Example: "pK7xJ2mN8vQ3rL5wY9zB1cD4eF6gH8iJ0kL2mN4oP6qR8sT0uV2wX4yZ6aB8cD0"

This is the identifier you send to servers for authentication and routing.

Signs arbitrary binary data using Ed25519:

const payload = new TextEncoder().encode('{"action":"post","content":"Hello"}');
const signature = await handle.sign(payload);

// Send to server:
fetch('https://api.example.com/endpoint', {
method: 'POST',
headers: {
'X-Handle-ID': handle.getId(),
'Content-Type': 'application/json'
},
body: JSON.stringify({
payload: btoa(String.fromCharCode(...payload)),
signature: btoa(String.fromCharCode(...signature))
})
});

Static method for server-side signature verification:

// Server receives: handleId, signature, payload
const publicKey = Uint8Array.from(atob(handleId), c => c.charCodeAt(0));
const data = Uint8Array.from(atob(payload), c => c.charCodeAt(0));
const sig = Uint8Array.from(atob(signature), c => c.charCodeAt(0));

const isValid = await Handle.verify(sig, data, publicKey);
if (!isValid) throw new Error('Invalid signature');

Deterministically derives a secret (e.g., a password or API key) for a specific service context. The private key never leaves this class, ensuring maximum security.

// Derive a password for a specific service
const googlePassword = workHandle.derivePassword('google');
// Example output: "xK9mP2qL5wY9zB1cD4eF6g"

// Derive a longer secret (e.g., 32 bytes for an API key)
const apiKey = workHandle.derivePassword('aws-api', 32);

Parameters:

  • context: A unique identifier for the service (e.g., 'google', 'github', 'wifi-router')
  • length: Length of the derived raw bytes (default: 16 bytes = ~22 chars base64url)

Returns: A URL-safe base64 string suitable for use as a strong password.

Security Benefit: The privateKey remains strictly encapsulated within the Handle instance. It is used internally by HKDF-SHA256 and is never returned, logged, or serialized.

Use Case: Zero-knowledge password management. No database of passwords is required on the server. If a service forces a password change, the user simply derives a new handle (e.g., 'google-v2') or adds a version suffix to the context.

Derives a symmetric 256-bit key for establishing a secure, encrypted communication channel between the Identity (controller) and this Handle (device/context). Both parties can independently compute this key without any key exchange protocol, because they both have access to the Handle's private key.

// On the device (Handle side):
const channelKey = droneHandle.deriveChannelKey('telemetry-v1');
// channelKey is a 32-byte Uint8Array, ready for AES-256-GCM

// On the controller (Identity side):
const droneHandle = await centerIdentity.deriveHandle('drone-001');
const channelKey = droneHandle.deriveChannelKey('telemetry-v1');
// Identical key, derived independently

Parameters:

  • context: Channel identifier for domain separation (e.g., 'drone-001', 'session-abc'). Both parties must use the same context to derive the same key.

Returns: 32-byte Uint8Array suitable for AES-256-GCM encryption.

Security Benefit: The privateKey remains strictly encapsulated within the Handle instance. No key exchange protocol (ECDH, etc.) is needed โ€” both parties derive the same key independently from the shared Handle private key.

Note: This method returns raw bytes (Uint8Array), unlike derivePassword which returns a base64url string. Use the returned bytes directly with AES-256-GCM via Web Crypto API or similar.


Instead of storing passwords in a database, derive them deterministically from the Handle. The user only needs to remember their root Seed and the service name.

// 1. User restores Identity from Seed (e.g., after entering a PIN)
const identity = await Identity.fromSeed(userSeed);

// 2. Derive the specific service Handle
const googleHandle = await identity.deriveHandle('google', {
displayName: 'Alice Personal'
});

// 3. Deterministically generate the password on the fly
const password = googleHandle.derivePassword('google');

// 4. Auto-fill the login form
console.log('Login:', 'alice@example.com');
console.log('Password:', password); // Always the same for this seed + handle + context

โœ… Benefit: Zero-knowledge password management. No database of passwords is required on the server. If a service forces a password change, the user simply derives a new handle (e.g., 'google-v2') or adds a version suffix to the context (e.g., derivePassword('google-v2')).


Control a fleet of devices (drones, sensors, robots) with zero-knowledge encrypted communication. The control center derives a channel key for each device, and the device independently derives the same key โ€” no key exchange protocol required.

// === CONTROL CENTER (Identity) ===
const centerIdentity = await Identity.fromSeed(centerSeed);

// Minimal registry: just device names (no public keys stored!)
const allowedDevices = ['drone-001', 'drone-002', 'sensor-warehouse-a'];

// Receiving telemetry from a drone
async function receiveTelemetry(message: { name: string, signature: Uint8Array, encrypted: Uint8Array }) {
// 1. Check if device is in registry
if (!allowedDevices.includes(message.name)) {
throw new Error('Unknown device');
}

// 2. Derive the Handle (deterministic, no DB lookup)
const deviceHandle = await centerIdentity.deriveHandle(message.name);

// 3. Verify signature (proves device owns the private key)
const dataToVerify = concatBytes(
new TextEncoder().encode(message.name),
message.encrypted
);
const isValid = await Handle.verify(message.signature, dataToVerify, deviceHandle.getPublicKey());
if (!isValid) throw new Error('Invalid signature');

// 4. Derive the SAME channel key the device used for encryption
const channelKey = deviceHandle.deriveChannelKey('telemetry-v1');

// 5. Decrypt the message
const telemetry = await decryptAESGCM(message.encrypted, channelKey);
return telemetry;
}

// === DRONE (Handle, provisioned at factory) ===
// Drone is provisioned with its Handle's private key and name
async function sendTelemetry(telemetry: object) {
const name = 'drone-001';
const privateKey = /* loaded from secure enclave */;
const droneHandle = new Handle(privateKey, name);

// 1. Derive the SAME channel key the center will use for decryption
const channelKey = droneHandle.deriveChannelKey('telemetry-v1');

// 2. Encrypt telemetry
const telemetryBytes = new TextEncoder().encode(JSON.stringify(telemetry));
const encrypted = await encryptAESGCM(telemetryBytes, channelKey);

// 3. Sign (name + encrypted data)
const dataToSign = concatBytes(new TextEncoder().encode(name), encrypted);
const signature = await droneHandle.sign(dataToSign);

return { name, signature, encrypted };
}

โœ… Benefits:

  • Minimal registry: Control center stores only device names (strings), not public keys
  • No key exchange: Both parties derive the same key independently
  • Stateless verification: Signature check proves device authenticity
  • Isolation: Compromise of one device doesn't affect others (different Handles โ†’ different channel keys)

A user with the same Identity on multiple devices (phone, laptop, tablet) can derive identical Handles and secrets on each device without any synchronization protocol.

// On the phone:
const identity = await Identity.fromSeed(userSeed);
const messengerHandle = await identity.deriveHandle('messenger-main');
const handleId = messengerHandle.getId();
// Send handleId to server for registration

// On the laptop (later, no sync needed):
const identity = await Identity.fromSeed(userSeed); // Same seed
const messengerHandle = await identity.deriveHandle('messenger-main'); // Same name
const handleId = messengerHandle.getId(); // Identical handleId!
// Server recognizes the same user automatically

โœ… Benefit: Zero-knowledge multi-device support. No QR codes, no server-side key sync, no backup servers. The mathematics guarantees identity across devices.


A user's entire digital identity can be recovered from a single seed phrase, even years later, on any device, without contacting any service provider.

// User stores seed phrase in a physical safe (or via Shamir's Secret Sharing with trusted heirs)

// Years later, on a new device:
const identity = await Identity.fromSeed(recoveredSeed);

// All handles are instantly recoverable:
const googleHandle = await identity.deriveHandle('google');
const bankHandle = await identity.deriveHandle('bank');
const messengerHandle = await identity.deriveHandle('messenger-main');

// All passwords are instantly recoverable:
const googlePassword = googleHandle.derivePassword('google');
const bankPassword = bankHandle.derivePassword('bank');

// All channel keys are instantly recoverable:
const messengerChannelKey = messengerHandle.deriveChannelKey('session-2026');

โœ… Benefit: True self-sovereignty. No company can lock you out of your identity. Recovery is a mathematical certainty, not a customer support ticket.


Grant time-limited access to a contractor or temporary service by deriving a Handle with a time-bound name.

// Grant access to contractor until 2026-12-31
const contractorHandle = await identity.deriveHandle('contractor-acme-2026-12-31', {
displayName: 'ACME Corp Contractor',
role: 'auditor',
expiresAt: '2026-12-31T23:59:59Z'
});

// Share the Handle ID with the contractor's system
// Contractor uses this Handle for authenticated access

// After expiration:
// - Server rejects requests (checks expiresAt metadata)
// - User simply stops using this Handle
// - No cleanup needed โ€” the Handle is just a name in the derivation tree

โœ… Benefit: Clean delegation without polluting the permanent identity. Expired Handles become inert cryptographic artifacts.


Handles are derived using HKDF-SHA256 with domain separation:

HandlePrivateKey = HKDF-SHA256(
inputKeyMaterial = IdentityPrivateKey,
salt = empty,
info = "me2em/handle/v1/" + lowercase(name),
length = 32
)
HandlePublicKey = Ed25519.PublicKey(HandlePrivateKey)
HandleId = Base64Url(HandlePublicKey)

Properties:

  • ๐Ÿ” Deterministic: Same inputs โ†’ same output across all implementations
  • ๐Ÿ”’ One-way: Cannot derive Identity key from Handle key
  • ๐Ÿงฉ Isolated: Each Handle uses independent Ed25519 keypair

Passwords are derived using HKDF-SHA256 with the Handle's private key as input key material:

PasswordBytes = HKDF-SHA256(
inputKeyMaterial = HandlePrivateKey,
salt = "me2em/secret/" + lowercase(context),
info = "me2em/secret/v1",
length = 16 (default)
)
Password = Base64Url(PasswordBytes)

Properties:

  • ๐Ÿ” Deterministic: Same Handle + same context โ†’ same password
  • ๐Ÿ” Encapsulated: Private key never leaves the Handle instance
  • ๐Ÿงฉ Domain-separated: Different contexts produce different passwords

Channel keys are derived using HKDF-SHA256 with the Handle's private key as input key material:

ChannelKey = HKDF-SHA256(
inputKeyMaterial = HandlePrivateKey,
salt = "me2em/channel/" + lowercase(context),
info = "me2em/channel/v1",
length = 32
)

Properties:

  • ๐Ÿ” Deterministic: Same Handle + same context โ†’ same 32-byte key
  • ๐Ÿ” Encapsulated: Private key never leaves the Handle instance
  • ๐Ÿงฉ Domain-separated: Different contexts produce different keys (e.g., 'telemetry-v1' vs 'command-v1')
  • โšก No key exchange: Both parties derive the key independently, no ECDH needed
  • Algorithm: Ed25519 (RFC 8032)
  • Hash: SHA-512 (via @noble/ed25519)
  • Encoding: Raw bytes โ†’ base64url for transport

The Root Identity is the cryptographic foundation of the entire Me2em system. Compromise of the Root Identity private key allows an attacker to derive all Handles retroactively, impersonate the user across every context, and decrypt all session tokens.

The IdentityPrivateKey must never leave a Hardware Security Module (HSM), Trusted Platform Module (TPM), or secure enclave. Storing the root seed in plaintext in IndexedDB, localStorage, or a file on disk is a critical security vulnerability.

  • Recommended: Use Web Crypto API with platform-bound keys, or integrate with a dedicated HSM provider.
  • Acceptable: Encrypt the seed with a user-entered PIN using AES-256-GCM before persisting.
  • Never: Log, transmit, or cache the root seed in any form.

For high-value identities (e.g., organizational root identities), use one of these approaches:

  • Multi-Sig: Require multiple independent root keys to sign critical operations. No single key can compromise the system alone.
  • Shamir's Secret Sharing (SSS): Split the root seed into N shares, requiring K shares to reconstruct. Distribute shares among trusted parties or secure locations.
Component If Compromised
Root Identity Total compromise: All derived Handles, all passwords, all channel keys, all session tokens can be forged retroactively
Individual Handle Contained: Only affects that specific context; other Handles remain secure
Session Token Time-limited: Only affects the window before expiration; does not expose long-term keys

The deriveChannelKey method provides a static room key โ€” anyone who derives the same key can decrypt all past and future messages in the room. For applications requiring Perfect Forward Secrecy (PFS), implement an application-level ratchet.

Derive the root key for the room using deriveChannelKey:

const rootKey = roomHandle.deriveChannelKey('room-abc-123');
let chainKey = rootKey;

For each message $N$, derive the message key and update the chain key:

function deriveMessageKey(chainKey: Uint8Array): { msgKey: Uint8Array, nextChainKey: Uint8Array } {
const msgKey = hkdf(sha256, chainKey, new Uint8Array(0), new TextEncoder().encode('msg-key'), 32);
const nextChainKey = hkdf(sha256, chainKey, new Uint8Array(0), new TextEncoder().encode('next-chain-key'), 32);
return { msgKey, nextChainKey };
}

// For message N:
const { msgKey, nextChainKey } = deriveMessageKey(chainKey);
// Encrypt message with msgKey using AES-256-GCM
chainKey = nextChainKey;

The recipient performs the same derivation sequentially:

// Recipient starts with the same rootKey and iterates through N messages
let chainKey = rootKey;
for (let i = 0; i < messageIndex; i++) {
const result = deriveMessageKey(chainKey);
chainKey = result.nextChainKey;
}
const { msgKey } = deriveMessageKey(chainKey);
// Decrypt the message with msgKey

When a user is removed from a group:

  1. Rotate the RootKey: The remaining members call deriveChannelKey with an updated context (e.g., 'room-abc-123-v2') to get a new root key.
  2. Distribute the New Key: The new root key is distributed to all remaining members via the existing ratchet chain.
  3. Old Messages: Messages encrypted with old chain keys remain decryptable by the removed user โ€” this is the trade-off of application-level PFS. For stronger guarantees, use a full Double Ratchet (as in Signal) that incorporates ECDH between every pair of participants.
Pattern Forward Secrecy Implementation Complexity
Static deriveChannelKey โŒ No Low
HKDF Chain Ratchet โœ… Per-message Medium
Double Ratchet (Signal-style) โœ… Per-message + ECDH High

  • Store seeds encrypted: Use PIN/biometric + AES-GCM before persisting to IndexedDB
  • Clear memory: Zero out seed/private key buffers after use (buffer.fill(0))
  • Use short TTLs: Rotate session tokens frequently (โ‰ค1 hour recommended)
  • Validate metadata server-side: Never trust client-provided Handle metadata without verification
  • Pin dependencies: Lock @me2em/core to specific version in package.json
  • Never transmit seeds: The seed is the root of trust โ€” keep it client-side only
  • Don't reuse Handles across contexts: Derive separate Handles for different apps/services
  • Don't log private keys: Ensure debugging output never includes key material
  • Don't disable signature verification: Always verify Ed25519 signatures server-side
[User enters seed]
โ”‚
โ–ผ
[Derive Identity in RAM]
โ”‚
โ–ผ
[Derive Handle(s) as needed]
โ”‚
โ–ผ
[Sign challenge / data / derive password / derive channel key]
โ”‚
โ–ผ
[Zero out private key buffers] โ† Critical!
โ”‚
โ–ผ
[Keep only public HandleId for future use]

cd packages/core
pnpm test

Tests cover:

  • โœ… Deterministic derivation (same seed + name โ†’ same HandleId)
  • โœ… Signature generation and verification
  • โœ… Password derivation (same Handle + context โ†’ same password)
  • โœ… Channel key derivation (same Handle + context โ†’ same key)
  • โœ… Edge cases (empty metadata, long names, unicode)
import { Identity } from '@me2em/core';

test('full auth flow', async () => {
// Client side
const identity = await Identity.fromSeed(testSeed);
const handle = await identity.deriveHandle('test');
const challenge = new TextEncoder().encode('nonce-123');
const signature = await handle.sign(challenge);

// Server side
const isValid = await Handle.verify(
signature,
challenge,
handle.getPublicKey()
);
expect(isValid).toBe(true);
expect(handle.getId()).toMatch(/^[A-Za-z0-9_-]{43}$/);
});

{
"name": "@me2em/core",
"version": "0.4.2-alpha.1",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"dependencies": {
"@noble/ed25519": "^3.1.0",
"@noble/hashes": "^2.2.0",
"@scure/bip39": "^1.3.0"
},
"engines": {
"node": ">=18.0.0"
}
}

We welcome contributions! See:

  • ๐Ÿ“„ CONTRIBUTING.md โ€” How to contribute code/docs
  • ๐Ÿ—ณ๏ธ GOVERNANCE.md โ€” Project decision-making process
  • ๐Ÿ” SECURITY.md โ€” Responsible disclosure policy

Apache License 2.0 โ€” see LICENSE for details.

ยฉ 2026 Me2em Organization. Built for privacy, openness, and user sovereignty.