@me2em/core โ Core Cryptographic PrimitivesCore primitives for the Me2em authorization protocol:
Identity,Handle, and secure channel derivation with Ed25519 cryptography.
@me2em/core provides the cryptographic foundation for the Me2em protocol โ a decentralized multi-context identity system. It enables:
npm install @me2em/core
# or
pnpm add @me2em/core
# or
yarn add @me2em/core
Dependencies:
@noble/ed25519 โ Ed25519 signatures@noble/hashes โ HKDF, SHA-256import { 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.
Identity ClassThe 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;
}
Identity.fromSeed(seed)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.
Identity.deriveHandle(name, metadata?)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 HandleReturns: A Handle instance with its own keypair, derived deterministically from the Identity.
Key Properties:
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.
Handle ClassA 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;
}
Handle.getId()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.
Handle.sign(data)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))
})
});
Handle.verify(signature, data, publicKey)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');
Handle.derivePassword(context, length?)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.
Handle.deriveChannelKey(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:
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:
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:
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:
'telemetry-v1' vs 'command-v1')@noble/ed25519)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.
For high-value identities (e.g., organizational root identities), use one of these approaches:
| 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:
deriveChannelKey with an updated context (e.g., 'room-abc-123-v2') to get a new root key.| Pattern | Forward Secrecy | Implementation Complexity |
|---|---|---|
Static deriveChannelKey |
โ No | Low |
| HKDF Chain Ratchet | โ Per-message | Medium |
| Double Ratchet (Signal-style) | โ Per-message + ECDH | High |
buffer.fill(0))@me2em/core to specific version in package.json[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:
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:
Apache License 2.0 โ see LICENSE for details.
ยฉ 2026 Me2em Organization. Built for privacy, openness, and user sovereignty.