From a6b10428fa1c6a2270f55a733d7276854ccdc104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Da=C3=9Fler?= Date: Fri, 29 Aug 2025 14:17:39 +0200 Subject: [PATCH] Initial commit: Identity management component - Hierarchical user/device identity system with HD key derivation - Dependency injection for AsyncStorage and Platform - Self-contained TypeScript declarations - Ed25519 keypairs managed by IdentityManager - Deterministic peer ID generation from BIP39 mnemonic --- .gitignore | 237 +++++++ IdentityFactory.ts | 118 ++++ README.md | 234 ++++++ implementations/AsyncStorageAdapter.ts | 43 ++ implementations/IdentityManager.ts | 937 +++++++++++++++++++++++++ index.d.ts | 234 ++++++ index.ts | 31 + interfaces/IIdentity.ts | 163 +++++ package.json | 38 + 9 files changed, 2035 insertions(+) create mode 100644 .gitignore create mode 100644 IdentityFactory.ts create mode 100644 README.md create mode 100644 implementations/AsyncStorageAdapter.ts create mode 100644 implementations/IdentityManager.ts create mode 100644 index.d.ts create mode 100644 index.ts create mode 100644 interfaces/IIdentity.ts create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53e7358 --- /dev/null +++ b/.gitignore @@ -0,0 +1,237 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,node +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,linux,visualstudiocode,node + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/macos,windows,linux,visualstudiocode,node \ No newline at end of file diff --git a/IdentityFactory.ts b/IdentityFactory.ts new file mode 100644 index 0000000..53cef2f --- /dev/null +++ b/IdentityFactory.ts @@ -0,0 +1,118 @@ +/** + * Factory for creating identity manager instances + */ + +import { LoggerComponent } from 'ior:gitea:gitea.metatrom.net:universal-components/logger@1.0.0'; +import { AsyncStorageAdapter } from './implementations/AsyncStorageAdapter'; +import { IdentityManager } from './implementations/IdentityManager'; +import type { + IdentityConfig, + IIdentityFactory, + IIdentityManager, + IIdentityStorage, +} from './interfaces/IIdentity'; + +// Platform interface for dependency injection +interface IPlatform { + OS: string; +} + +export class IdentityFactory implements IIdentityFactory { + private static instance?: IdentityFactory; + private identityManager?: IIdentityManager; + private storage?: IIdentityStorage; + private logger = new LoggerComponent('IdentityFactory'); + private platform?: IPlatform; + private asyncStorage?: any; + + private constructor(platform?: IPlatform, asyncStorage?: any) { + this.platform = platform; + this.asyncStorage = asyncStorage; + } + + static getInstance(platform?: IPlatform, asyncStorage?: any): IdentityFactory { + if (!IdentityFactory.instance) { + IdentityFactory.instance = new IdentityFactory(platform, asyncStorage); + } + return IdentityFactory.instance; + } + + create(config?: IdentityConfig): IIdentityManager { + // Use singleton pattern for identity manager + if (!this.identityManager) { + const storage = this.getStorage(); + + // Enhanced config with platform-specific device name provider + const enhancedConfig: IdentityConfig = { + ...config, + deviceNameProvider: config?.deviceNameProvider || this.getDeviceNameProvider(), + }; + + this.identityManager = new IdentityManager(storage, enhancedConfig); + } + + return this.identityManager; + } + + private getStorage(): IIdentityStorage { + if (!this.storage) { + if (!this.asyncStorage) { + throw new Error('AsyncStorage not provided. Please provide AsyncStorage when creating the factory.'); + } + // Use AsyncStorage for React Native + this.storage = new AsyncStorageAdapter(this.asyncStorage); + } + return this.storage; + } + + private getDeviceNameProvider(): () => Promise { + return async () => { + try { + // Try to use react-native-device-info if available + const DeviceInfo = require('react-native-device-info').default; + if (DeviceInfo?.getDeviceName) { + const name = await DeviceInfo.getDeviceName(); + return name || this.getFallbackDeviceName(); + } + } catch (_error) { + // Module not available, use fallback + this.logger.debug('react-native-device-info not available, using fallback device name'); + } + + return this.getFallbackDeviceName(); + }; + } + + private getFallbackDeviceName(): string { + const platform = this.platform?.OS || 'unknown'; + const timestamp = Date.now().toString(36).substring(-4); + + switch (platform) { + case 'ios': + return `iPhone-${timestamp}`; + case 'android': + return `Android-${timestamp}`; + default: + return `Device-${timestamp}`; + } + } + + /** + * Reset the singleton instance (useful for testing) + */ + static reset(): void { + if (IdentityFactory.instance?.identityManager) { + IdentityFactory.instance.identityManager = undefined; + } + IdentityFactory.instance = undefined; + } +} + +// Export singleton instance creator with dependency injection +export const createIdentityManager = ( + config?: IdentityConfig, + platform?: IPlatform, + asyncStorage?: any +): IIdentityManager => { + return IdentityFactory.getInstance(platform, asyncStorage).create(config); +}; diff --git a/README.md b/README.md new file mode 100644 index 0000000..38fca50 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# Identity Management Component v1.0.0 + +Hierarchical user/device identity management system with HD key derivation for multi-device support. + +## Features + +- **HD Key Derivation**: Uses BIP39 mnemonics and BIP32 HD wallets +- **User/Device Hierarchy**: Master user identity with derived device identities +- **Deterministic Peer IDs**: Generates standard libp2p peer IDs (12D3KooW format) from HD keys +- **Secure Storage**: Persistent storage with AsyncStorage adapter +- **Device Management**: Add, remove, and list devices with unique indices +- **Recovery**: 24-word mnemonic phrase for backup/restore +- **libp2p Compatible**: Generates Ed25519 keypairs and standard peer IDs +- **Verifiable Identity**: Peer IDs can be cryptographically verified as belonging to a user + +## Usage + +### Basic Usage + +```typescript +import { identityManager } from '@/components/identity/1.0.0'; + +// Initialize identity (creates new or loads existing) +const userIdentity = await identityManager.initialize(); +console.log('User ID:', userIdentity.userId); + +// Get current device +const device = await identityManager.getCurrentDevice(); +console.log('Device ID:', device.deviceId); + +// Get libp2p keypair +const keypair = await identityManager.getLibp2pKeypair(); +``` + +### Advanced Usage with Dependency Injection + +```typescript +import { + createIdentityManager, + IdentityConfig +} from '@/components/identity/1.0.0'; + +const config: IdentityConfig = { + storagePrefix: '@MyApp', + mnemonicStrength: 256, + deviceNameProvider: async () => 'Custom Device Name' +}; + +const identityManager = createIdentityManager(config); +``` + +### Device Management + +```typescript +// Create new device identity (auto-increments index) +const newDevice = await identityManager.createDeviceIdentity('Tablet'); +console.log('New device peer ID:', newDevice.deviceId); // 12D3KooW... + +// List all devices +const devices = await identityManager.getRegisteredDevices(); + +// Remove a device +await identityManager.removeDevice(deviceId); + +// Verify a peer belongs to this user +const peerBelongsToUser = await identityManager.verifyPeerOwnership(peerId); +``` + +### Backup and Recovery + +```typescript +// Get recovery phrase +const mnemonic = identityManager.getMnemonic(); + +// Restore from mnemonic (maintains device index coordination) +await identityManager.restoreFromMnemonic(mnemonic); + +// After restoration, device gets next available index +const device = await identityManager.getCurrentDevice(); +console.log('Device index:', device.derivationPath); // e.g., m/44'/0'/0'/0/1 +``` + +## Interfaces + +### IIdentityManager + +Main interface for identity management operations. + +### UserIdentity + +```typescript +interface UserIdentity { + userId: string; // Hash of master public key + publicKey: Uint8Array; // Master public key + mnemonic?: string; // Recovery phrase +} +``` + +### DeviceIdentity + +```typescript +interface DeviceIdentity { + deviceId: string; // libp2p peer ID (12D3KooW... format) + deviceName: string; // Human-readable name + publicKey: Uint8Array; // Ed25519 public key + privateKey: Uint8Array; // Ed25519 private key (seed) + derivationPath: string; // HD derivation path (m/44'/0'/0'/0/index) + createdAt: number; + lastSeen?: number; +} +``` + +## Architecture + +``` +┌─────────────────────────────────┐ +│ IdentityFactory │ +│ (Singleton Factory) │ +└─────────────┬───────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ IdentityManager │ +│ (Core Implementation) │ +└─────────────┬───────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ AsyncStorageAdapter │ +│ (Persistence Layer) │ +└─────────────────────────────────┘ +``` + +## HD Key Derivation Path + +The component uses BIP44-like derivation paths: + +``` +m/44'/0'/0'/0/[device_index] +``` + +- `44'`: Purpose (identity management) +- `0'`: Coin type (custom for this app) +- `0'`: Account (always 0 for now) +- `0`: External chain +- `[device_index]`: Sequential device index (0, 1, 2, ...) + +Each device gets a unique index, ensuring unique peer IDs while maintaining the cryptographic relationship to the user identity. + +## Peer ID Generation + +The system generates standard libp2p peer IDs from Ed25519 keys: + +1. **Derive Ed25519 keypair** from HD path +2. **Add multicodec prefix** (0xed01) to public key +3. **Hash with SHA256** for standard format +4. **Encode as base58** with multihash prefix (0x12) +5. **Result**: Peer IDs starting with `12D3KooW...` + +This ensures compatibility with all libp2p implementations while maintaining deterministic generation from the mnemonic. + +## Device Index Coordination + +During device pairing: + +1. **Master device** tracks the highest device index +2. **New device** receives its assigned index during pairing +3. **Storage key**: `identity__highestDeviceIndex` +4. **Fallback**: System can detect existing indices by testing derivations + +This prevents peer ID collisions when multiple devices join the same identity. + +## Security Considerations + +1. **Mnemonic Storage**: The mnemonic is stored in AsyncStorage. In production, consider encrypting it. +2. **Private Keys**: Device private keys are stored locally. Use secure storage in production. +3. **Recovery Phrase**: Users should backup their recovery phrase securely. +4. **Device Removal**: Removing a device only removes it from the registry, not from the device itself. +5. **Peer ID Verification**: Any peer ID can be verified as belonging to a user by testing derivation paths. +6. **Deterministic Generation**: Lost devices can be recovered with the same peer ID using mnemonic + index. + +## DHT Integration + +The identity system integrates with the DHT for user/device discovery: + +1. User identity is registered with the DHT +2. Each device is registered under the user +3. DHT tracks online/offline status of devices +4. Enables device handoff and multi-device sync + +## Peer ID Update Broadcasting + +When a device's peer ID changes (typically after pairing and identity restoration), the system automatically notifies other devices: + +### How It Works + +1. **Detection**: System detects peer ID change after `restoreFromMnemonic()` +2. **Broadcast**: Sends update notification via `/metatrom/identity/peer-update/1.0.0` protocol +3. **Update**: Other devices update their records automatically +4. **Migration**: Chat messages and connections migrate to new peer ID + +### Usage + +```typescript +// Automatic during pairing +const oldPeerId = await identityManager.getCurrentDevice()?.deviceId; +await identityManager.restoreFromMnemonic(mnemonic); +const newPeerId = await identityManager.getCurrentDevice()?.deviceId; + +// Broadcast if changed (automatic in pairing flow) +if (oldPeerId !== newPeerId) { + await identityManager.broadcastPeerIdUpdate( + oldPeerId, + newPeerId, + sendProtocolData + ); +} +``` + +### Benefits + +- **Seamless Updates**: No manual intervention needed +- **Preserves History**: Chat and connection history maintained +- **Prevents Duplicates**: Eliminates duplicate peer ID issues +- **Network Coherence**: All devices stay synchronized + +## Version + +- Component Version: 1.0.0 +- Protocol Version: /metatrom/identity/1.0.0 + +## License + +MIT \ No newline at end of file diff --git a/implementations/AsyncStorageAdapter.ts b/implementations/AsyncStorageAdapter.ts new file mode 100644 index 0000000..7a63762 --- /dev/null +++ b/implementations/AsyncStorageAdapter.ts @@ -0,0 +1,43 @@ +/** + * AsyncStorage adapter for React Native + */ + +import type { IIdentityStorage } from '../interfaces/IIdentity'; + +// AsyncStorage type definition for dependency injection +interface IAsyncStorage { + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; + getAllKeys(): Promise; + multiRemove(keys: string[]): Promise; +} + +export class AsyncStorageAdapter implements IIdentityStorage { + private asyncStorage: IAsyncStorage; + + constructor(asyncStorage: IAsyncStorage) { + this.asyncStorage = asyncStorage; + } + + async setItem(key: string, value: string): Promise { + await this.asyncStorage.setItem(key, value); + } + + async getItem(key: string): Promise { + return await this.asyncStorage.getItem(key); + } + + async removeItem(key: string): Promise { + await this.asyncStorage.removeItem(key); + } + + async clear(): Promise { + // Only clear identity-related keys + const keys = await this.asyncStorage.getAllKeys(); + const identityKeys = keys.filter((key) => key.startsWith('@IdentityManager')); + if (identityKeys.length > 0) { + await this.asyncStorage.multiRemove(identityKeys); + } + } +} diff --git a/implementations/IdentityManager.ts b/implementations/IdentityManager.ts new file mode 100644 index 0000000..f9d6856 --- /dev/null +++ b/implementations/IdentityManager.ts @@ -0,0 +1,937 @@ +/** + * Identity Manager Implementation + * + * Manages hierarchical user/device identity system with HD key derivation + */ + +import { LoggerComponent } from 'ior:gitea:gitea.metatrom.net:universal-components/logger@1.0.0'; +import { ed25519 } from '@noble/curves/ed25519'; +import { sha256 } from '@noble/hashes/sha256'; +import { HDKey } from '@scure/bip32'; +import * as bip39 from 'bip39'; +import type { + DeviceIdentity, + IdentityConfig, + IIdentityManager, + IIdentityStorage, + UserIdentity, +} from '../interfaces/IIdentity'; + +export class IdentityManager implements IIdentityManager { + private masterKey?: HDKey; + private userIdentity?: UserIdentity; + private devices: Map = new Map(); + private currentDeviceIndex: number = 0; + private storage: IIdentityStorage; + private config: Required; + private initialized: boolean = false; + private logger = new LoggerComponent('IdentityManager'); + + constructor(storage: IIdentityStorage, config?: IdentityConfig) { + this.storage = storage; + this.config = { + storagePrefix: config?.storagePrefix || '@IdentityManager', + mnemonicStrength: config?.mnemonicStrength || 256, + deviceNameProvider: config?.deviceNameProvider || this.defaultDeviceNameProvider, + }; + } + + async initialize(): Promise { + if (this.initialized && this.userIdentity) { + return this.userIdentity; + } + + // Try to load existing identity + const existingIdentity = await this.loadIdentity(); + if (existingIdentity) { + this.initialized = true; + return existingIdentity; + } + + // Create new identity + const newIdentity = await this.createNewIdentity(); + this.initialized = true; + return newIdentity; + } + + async createNewIdentity(): Promise { + // Generate new mnemonic + const mnemonic = bip39.generateMnemonic(this.config.mnemonicStrength); + const seed = await bip39.mnemonicToSeed(mnemonic); + + // Create master HD key + this.masterKey = HDKey.fromMasterSeed(seed); + + // Get master public key + const masterPublicKey = this.masterKey.publicKey!; + + // Create user ID from hash of public key + const userId = this.hashPublicKey(masterPublicKey); + + this.userIdentity = { + userId, + publicKey: masterPublicKey, + mnemonic, + }; + + // Save to storage + await this.saveIdentity(); + + return this.userIdentity; + } + + async broadcastPeerIdUpdate( + oldPeerId: string, + newPeerId: string, + sendProtocolData?: (peerId: string, protocolId: string, data: Uint8Array) => Promise, + ): Promise { + if (!sendProtocolData) { + this.logger.warn( + '[IdentityManager] No sendProtocolData function provided, cannot broadcast peer ID update', + ); + return; + } + + const userIdentity = await this.getUserIdentity(); + const currentDevice = await this.getCurrentDevice(); + + // Create update notification + const updateData = { + userId: userIdentity.userId, + oldPeerId, + newPeerId, + deviceName: currentDevice.deviceName, + timestamp: Date.now(), + }; + + this.logger.info('[IdentityManager] Broadcasting peer ID update:', updateData); + + // Encode the update data + const encoded = encodeURIComponent(JSON.stringify(updateData)); + const messageBytes = []; + for (let i = 0; i < encoded.length; i++) { + if (encoded[i] === '%') { + const hex = encoded.substring(i + 1, i + 3); + messageBytes.push(Number.parseInt(hex, 16)); + i += 2; + } else { + messageBytes.push(encoded.charCodeAt(i)); + } + } + const data = new Uint8Array(messageBytes); + + // Get all other devices for this user + const allDevices = await this.getRegisteredDevices(); + const otherDevices = allDevices.filter((d) => d.deviceId !== newPeerId); + + // Send update to each other device + for (const device of otherDevices) { + try { + await sendProtocolData(device.deviceId, '/metatrom/identity/peer-update/1.0.0', data); + this.logger.debug( + `[IdentityManager] Sent peer ID update to ${device.deviceName} (${device.deviceId})`, + ); + } catch (err) { + this.logger.warn(`[IdentityManager] Failed to send update to ${device.deviceId}:`, err); + } + } + } + + async restoreFromMnemonic(mnemonic: string): Promise { + this.logger.debug('[IdentityManager] Validating mnemonic...'); + this.logger.debug('[IdentityManager] Mnemonic length:', mnemonic.length); + this.logger.debug('[IdentityManager] Mnemonic words:', mnemonic.trim().split(/\s+/).length); + + const words = mnemonic.trim().split(/\s+/); + this.logger.debug('[IdentityManager] First word:', words[0]); + this.logger.debug('[IdentityManager] Last word:', words[words.length - 1]); + + // Check each word individually + const wordlist = bip39.wordlists.english; + const invalidWords = words.filter((word) => !wordlist.includes(word)); + if (invalidWords.length > 0) { + this.logger.error('[IdentityManager] Invalid words found:', JSON.stringify(invalidWords)); + this.logger.error('[IdentityManager] These words are not in the BIP39 English wordlist'); + + // Show what each invalid word looks like in detail + for (let index = 0; index < invalidWords.length; index++) { + const word = invalidWords[index]; + this.logger.error( + `[IdentityManager] Invalid word ${index + 1}: "${word}" (length: ${word.length})`, + ); + // Check if it's a truncated version of a valid word + const possibleMatches = wordlist.filter((w) => w.startsWith(word)); + if (possibleMatches.length > 0) { + this.logger.error( + `[IdentityManager] Possible matches: ${possibleMatches.slice(0, 5).join(', ')}`, + ); + } + } + } + + if (!bip39.validateMnemonic(mnemonic)) { + this.logger.error('[IdentityManager] BIP39 validation failed for mnemonic'); + this.logger.error('[IdentityManager] This could be due to invalid words or checksum failure'); + throw new Error('Invalid mnemonic phrase'); + } + + // Keep track of current device before restoration + const currentDeviceIdKey = this.getStorageKey('currentDeviceId'); + const currentDeviceId = await this.storage.getItem(currentDeviceIdKey); + let currentDeviceBackup: DeviceIdentity | undefined; + + if (currentDeviceId && this.devices.has(currentDeviceId)) { + // Backup current device info + currentDeviceBackup = { ...this.devices.get(currentDeviceId)! }; + this.logger.debug( + '[IdentityManager] Backing up current device:', + currentDeviceBackup.deviceName, + ); + } + + const seed = await bip39.mnemonicToSeed(mnemonic); + this.masterKey = HDKey.fromMasterSeed(seed); + + const masterPublicKey = this.masterKey.publicKey!; + const userId = this.hashPublicKey(masterPublicKey); + + this.userIdentity = { + userId, + publicKey: masterPublicKey, + mnemonic, + }; + + // Clear existing devices as they belong to the old identity + this.devices.clear(); + + // Find the next available device index by checking existing peer IDs + // This ensures each device gets a unique derivation path + this.currentDeviceIndex = await this.findNextAvailableDeviceIndex(); + this.logger.debug('[IdentityManager] Using device index:', this.currentDeviceIndex); + + // If we had a current device, recreate it with the new identity + if (currentDeviceBackup) { + this.logger.debug('[IdentityManager] Recreating current device with new identity'); + // Create a new device with the same name but derived from the new master key + const newDevice = await this.createDeviceIdentity(currentDeviceBackup.deviceName); + + // Update the current device ID reference + await this.storage.setItem(currentDeviceIdKey, newDevice.deviceId); + this.logger.debug('[IdentityManager] New device created with ID:', newDevice.deviceId); + this.logger.debug( + '[IdentityManager] Device uses derivation path index:', + this.currentDeviceIndex - 1, + ); + } + + await this.saveIdentity(); + this.initialized = true; + return this.userIdentity; + } + + async createDeviceIdentity(deviceName: string): Promise { + if (!this.masterKey) { + throw new Error('Master identity not initialized'); + } + + // Use BIP44-like path: m/44'/0'/0'/0/index + const derivationPath = `m/44'/0'/0'/0/${this.currentDeviceIndex}`; + + // Derive child key + const childKey = this.masterKey.derive(derivationPath); + + // Convert to Ed25519 keypair for libp2p + const seed = childKey.privateKey!.slice(0, 32); + this.logger.debug('[IdentityManager] === DEVICE IDENTITY CREATION ==='); + this.logger.debug('[IdentityManager] Device index:', this.currentDeviceIndex); + this.logger.debug('[IdentityManager] Derivation path:', derivationPath); + this.logger.debug('[IdentityManager] Seed length:', seed.length); + this.logger.debug( + '[IdentityManager] Seed (hex):', + Array.from(seed) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + + // Using @noble/curves/ed25519 getPublicKey + const keyPair = ed25519.getPublicKey(seed); + this.logger.debug('[IdentityManager] Public key from ed25519.getPublicKey:', keyPair); + this.logger.debug( + '[IdentityManager] Public key type:', + typeof keyPair, + keyPair.constructor.name, + ); + this.logger.debug('[IdentityManager] Public key length:', keyPair.length); + this.logger.debug( + '[IdentityManager] Public key (hex):', + Array.from(keyPair) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + + // Create libp2p compatible peer ID + const deviceId = await this.createPeerIdFromEd25519(seed, keyPair); + + const device: DeviceIdentity = { + deviceId, + deviceName, + publicKey: keyPair, + privateKey: seed, + derivationPath, + createdAt: Date.now(), + }; + + // Store device + this.devices.set(deviceId, device); + this.currentDeviceIndex++; + + // Update the highest device index in storage + const deviceIndexKey = this.getStorageKey('highestDeviceIndex'); + await this.storage.setItem(deviceIndexKey, this.currentDeviceIndex.toString()); + + await this.saveDevices(); + + return device; + } + + /** + * Calculate what the peer ID will be for a given device index + * This is useful during pairing to know what peer ID the new device will have + */ + async calculatePeerIdForIndex(deviceIndex: number): Promise { + if (!this.masterKey) { + throw new Error('Master identity not initialized'); + } + + // Use BIP44-like path: m/44'/0'/0'/0/index + const derivationPath = `m/44'/0'/0'/0/${deviceIndex}`; + + // Derive child key + const childKey = this.masterKey.derive(derivationPath); + + // Convert to Ed25519 keypair for libp2p + const seed = childKey.privateKey!.slice(0, 32); + const keyPair = ed25519.getPublicKey(seed); + + // Create libp2p compatible peer ID + return await this.createPeerIdFromEd25519(seed, keyPair); + } + + async getCurrentDevice(): Promise { + // Get a stable device ID from storage first + const deviceIdKey = this.getStorageKey('currentDeviceId'); + const storedDeviceId = await this.storage.getItem(deviceIdKey); + + // If we have a stored device ID, try to find that device + if (storedDeviceId) { + const existingDevice = this.devices.get(storedDeviceId); + if (existingDevice) { + // Update last seen + existingDevice.lastSeen = Date.now(); + await this.saveDevices(); + return existingDevice; + } else { + // Try to find a device that matches the current platform + const deviceName = await this.config.deviceNameProvider(); + const matchingDevice = Array.from(this.devices.values()).find( + (d) => d.deviceName === deviceName || d.deviceName.startsWith(deviceName.split('-')[0]), + ); + + if (matchingDevice) { + // Update the stored device ID to this device + await this.storage.setItem(deviceIdKey, matchingDevice.deviceId); + matchingDevice.lastSeen = Date.now(); + await this.saveDevices(); + return matchingDevice; + } + } + } + + // Get device name from platform + const deviceName = await this.config.deviceNameProvider(); + + // Check if we already have a device with this exact name (fallback) + const existingDevice = Array.from(this.devices.values()).find( + (d) => d.deviceName === deviceName, + ); + + if (existingDevice) { + // Store this as the current device + await this.storage.setItem(deviceIdKey, existingDevice.deviceId); + existingDevice.lastSeen = Date.now(); + await this.saveDevices(); + return existingDevice; + } + + // Create new device identity + const newDevice = await this.createDeviceIdentity(deviceName); + + // Store as current device + await this.storage.setItem(deviceIdKey, newDevice.deviceId); + + return newDevice; + } + + async getRegisteredDevices(): Promise { + return Array.from(this.devices.values()); + } + + async removeDevice(deviceId: string): Promise { + // Check if this is the current device + const currentDeviceIdKey = this.getStorageKey('currentDeviceId'); + const currentDeviceId = await this.storage.getItem(currentDeviceIdKey); + + if (currentDeviceId === deviceId) { + throw new Error('Cannot remove the current device'); + } + + this.devices.delete(deviceId); + await this.saveDevices(); + } + + async cleanupDuplicateDevices(): Promise { + // Get current device ID to avoid removing it + const currentDeviceIdKey = this.getStorageKey('currentDeviceId'); + const currentDeviceId = await this.storage.getItem(currentDeviceIdKey); + + // Group devices by name + const devicesByName = new Map(); + for (const device of this.devices.values()) { + const baseName = device.deviceName.split('-')[0]; // Get base name without random suffix + if (!devicesByName.has(baseName)) { + devicesByName.set(baseName, []); + } + devicesByName.get(baseName)!.push(device); + } + + const devicesToRemove: string[] = []; + + // For each group of devices with the same base name + for (const [_baseName, devices] of devicesByName) { + if (devices.length > 1) { + // Sort by lastSeen (most recent first) and createdAt as fallback + devices.sort((a, b) => { + const aTime = a.lastSeen || a.createdAt; + const bTime = b.lastSeen || b.createdAt; + return bTime - aTime; + }); + + // Keep the most recent one (or the current device if it's in this group) + let deviceToKeep = devices[0]; + for (const device of devices) { + if (device.deviceId === currentDeviceId) { + deviceToKeep = device; + break; + } + } + + // Mark others for removal + for (const device of devices) { + if (device.deviceId !== deviceToKeep.deviceId) { + devicesToRemove.push(device.deviceId); + } + } + } + } + + // Remove duplicate devices + for (const deviceId of devicesToRemove) { + this.devices.delete(deviceId); + } + + if (devicesToRemove.length > 0) { + await this.saveDevices(); + } + + return devicesToRemove.length; + } + + async cleanupInactiveDevices(daysInactive: number = 30): Promise { + const now = Date.now(); + const cutoffTime = now - daysInactive * 24 * 60 * 60 * 1000; + + this.logger.info(`[IdentityManager] Cleaning up devices inactive for ${daysInactive} days`); + this.logger.debug(`[IdentityManager] Current time: ${new Date(now).toISOString()}`); + this.logger.debug(`[IdentityManager] Cutoff time: ${new Date(cutoffTime).toISOString()}`); + + // Get current device ID to avoid removing it + const currentDeviceIdKey = this.getStorageKey('currentDeviceId'); + const currentDeviceId = await this.storage.getItem(currentDeviceIdKey); + this.logger.debug(`[IdentityManager] Current device ID: ${currentDeviceId}`); + + const devicesToRemove: string[] = []; + + for (const [deviceId, device] of this.devices) { + // Never remove current device + if (deviceId === currentDeviceId) { + this.logger.debug(`[IdentityManager] Skipping current device: ${deviceId}`); + continue; + } + + // Check last seen time + const lastSeen = device.lastSeen || device.createdAt; + this.logger.debug( + `[IdentityManager] Device ${device.deviceName} (${deviceId}) last seen: ${new Date(lastSeen).toISOString()}`, + ); + + if (lastSeen < cutoffTime) { + this.logger.debug(`[IdentityManager] Marking for removal: ${device.deviceName}`); + devicesToRemove.push(deviceId); + } + } + + // Remove inactive devices + for (const deviceId of devicesToRemove) { + this.devices.delete(deviceId); + } + + if (devicesToRemove.length > 0) { + await this.saveDevices(); + this.logger.info(`[IdentityManager] Removed ${devicesToRemove.length} inactive devices`); + } else { + this.logger.info(`[IdentityManager] No inactive devices to remove`); + } + + return devicesToRemove.length; + } + + async getLibp2pKeypair(): Promise<{ privateKey: Uint8Array; publicKey: Uint8Array }> { + const device = await this.getCurrentDevice(); + + // Marshal the Ed25519 private key for libp2p compatibility + // The protobuf format for Ed25519 keys is: + // message PrivateKey { + // Type key_type = 1; // enum where Ed25519 = 1 + // bytes data = 2; // the actual key data + // } + // + // In protobuf wire format: + // - Field 1 (key_type), varint: 0x08 (field number 1, wire type 0) followed by 0x01 (Ed25519 enum value) + // - Field 2 (data), length-delimited: 0x12 (field number 2, wire type 2) followed by length and data + + // For Ed25519, the data field contains 64 bytes: 32-byte private key + 32-byte public key + const keyData = new Uint8Array(64); + keyData.set(device.privateKey, 0); + keyData.set(device.publicKey, 32); + + // Build the protobuf message + const protobufMessage = new Uint8Array([ + 0x08, + 0x01, // Field 1: key_type = Ed25519 (1) + 0x12, + 0x40, // Field 2: data field with length 0x40 (64 bytes) + ...keyData, // The 64 bytes of key data + ]); + + this.logger.debug('[IdentityManager] Marshaled private key for libp2p:'); + this.logger.debug('[IdentityManager] - Total length:', protobufMessage.length); + this.logger.debug( + '[IdentityManager] - Header:', + Array.from(protobufMessage.slice(0, 4)) + .map((b) => `0x${b.toString(16).padStart(2, '0')}`) + .join(' '), + ); + this.logger.debug( + '[IdentityManager] - Private key part (first 8 bytes):', + Array.from(protobufMessage.slice(4, 12)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + this.logger.debug( + '[IdentityManager] - Public key part (first 8 bytes):', + Array.from(protobufMessage.slice(36, 44)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + + return { + privateKey: protobufMessage, + publicKey: device.publicKey, + }; + } + + async updateDevicePeerId(peerId: string): Promise { + const deviceIdKey = this.getStorageKey('currentDeviceId'); + const storedDeviceId = await this.storage.getItem(deviceIdKey); + + if (!storedDeviceId) { + this.logger.error('[IdentityManager] No current device ID found in storage'); + return; + } + + // First check if the device exists with the stored ID + let device = this.devices.get(storedDeviceId); + + // If not found with stored ID, check if it's already been updated to the new peer ID + if (!device) { + device = this.devices.get(peerId); + if (device) { + // Update storage to reflect the current state + await this.storage.setItem(deviceIdKey, peerId); + return; + } + } + + if (!device) { + // As a fallback, find the device by name pattern + const deviceName = await this.config.deviceNameProvider(); + const matchingDevice = Array.from(this.devices.values()).find( + (d) => d.deviceName === deviceName || d.deviceName.startsWith(deviceName.split('-')[0]), + ); + + if (matchingDevice) { + device = matchingDevice; + } else { + return; + } + } + + // Update the device's peer ID to match what libp2p generated + if (device.deviceId !== peerId) { + // Remove old entry + this.devices.delete(device.deviceId); + + // Update device ID + device.deviceId = peerId; + + // Add with new ID + this.devices.set(peerId, device); + + // Update storage + await this.saveDevices(); + await this.storage.setItem(deviceIdKey, peerId); + } + } + + getMnemonic(): string | undefined { + return this.userIdentity?.mnemonic; + } + + getUserIdentity(): UserIdentity | undefined { + return this.userIdentity; + } + + isInitialized(): boolean { + return this.initialized; + } + + async testPeerIdGeneration(): Promise { + this.logger.debug('[IdentityManager] === TESTING PEER ID GENERATION ==='); + + // Test 1: Known test vector + const testSeed = new Uint8Array(32).fill(1); // Simple test seed + const testPublicKey = ed25519.getPublicKey(testSeed); + this.logger.debug( + '[IdentityManager] Test seed (all 1s):', + Array.from(testSeed) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + this.logger.debug( + '[IdentityManager] Test public key:', + Array.from(testPublicKey) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + + const testPeerId = await this.createPeerIdFromEd25519(testSeed, testPublicKey); + this.logger.debug('[IdentityManager] Test peer ID:', testPeerId); + this.logger.debug('[IdentityManager] Test peer ID starts with:', testPeerId.substring(0, 8)); + + // Test 2: Current device + if (this.devices.size > 0) { + const currentDevice = await this.getCurrentDevice(); + this.logger.debug('[IdentityManager] Current device ID:', currentDevice.deviceId); + this.logger.debug( + '[IdentityManager] Current device ID starts with:', + currentDevice.deviceId.substring(0, 8), + ); + + // Regenerate peer ID from stored keys + const regeneratedId = await this.createPeerIdFromEd25519( + currentDevice.privateKey, + currentDevice.publicKey, + ); + this.logger.debug('[IdentityManager] Regenerated peer ID:', regeneratedId); + this.logger.debug('[IdentityManager] IDs match?:', regeneratedId === currentDevice.deviceId); + } + + this.logger.debug('[IdentityManager] === END TEST ==='); + } + + async reset(): Promise { + // Clear all stored data + await this.storage.removeItem(this.getStorageKey('mnemonic')); + await this.storage.removeItem(this.getStorageKey('userId')); + await this.storage.removeItem(this.getStorageKey('devices')); + await this.storage.removeItem(this.getStorageKey('currentDeviceId')); + + // Clear memory + this.masterKey = undefined; + this.userIdentity = undefined; + this.devices.clear(); + this.currentDeviceIndex = 0; + this.initialized = false; + } + + // Private helper methods + + private hashPublicKey(publicKey: Uint8Array): string { + const hash = sha256(publicKey); + // Convert Uint8Array to hex string without using Buffer + return Array.from(hash) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') + .substring(0, 16); + } + + private async findNextAvailableDeviceIndex(): Promise { + // First check if there's a pending index from pairing + const pendingIndexKey = 'identity_pending_highestDeviceIndex'; + const pendingIndex = await this.storage.getItem(pendingIndexKey); + + if (pendingIndex !== null) { + this.logger.debug( + `[IdentityManager] Using pending device index from pairing: ${pendingIndex}`, + ); + const index = Number.parseInt(pendingIndex, 10); + // Move it to the proper storage key now that we have the user ID + const deviceIndexKey = this.getStorageKey('highestDeviceIndex'); + await this.storage.setItem(deviceIndexKey, pendingIndex); + await this.storage.removeItem(pendingIndexKey); + return index; + } + + // Try to get the highest device index from storage + const deviceIndexKey = this.getStorageKey('highestDeviceIndex'); + const storedIndex = await this.storage.getItem(deviceIndexKey); + + if (storedIndex !== null) { + // Use the next index after the highest one stored + const nextIndex = Number.parseInt(storedIndex, 10) + 1; + await this.storage.setItem(deviceIndexKey, nextIndex.toString()); + return nextIndex; + } + + // If no stored index, check if we can detect existing devices + // by trying different indices and seeing if they match our current peer ID + if (this.masterKey) { + try { + // Get the current libp2p peer ID if available + const { NativeModules } = require('react-native'); + const currentPeerId = await NativeModules.Libp2pModule?.getPeerId?.(); + + if (currentPeerId) { + // Try indices 0-9 to see if any match our current peer ID + for (let i = 0; i < 10; i++) { + const derivationPath = `m/44'/0'/0'/0/${i}`; + const childKey = this.masterKey.derive(derivationPath); + const seed = childKey.privateKey!.slice(0, 32); + const keyPair = ed25519.getPublicKey(seed); + const testPeerId = await this.createPeerIdFromEd25519(seed, keyPair); + + if (testPeerId === currentPeerId) { + this.logger.debug(`[IdentityManager] Found current device at index ${i}`); + // This is our current device's index, so use the next one + await this.storage.setItem(deviceIndexKey, (i + 1).toString()); + return i + 1; + } + } + } + } catch (e) { + this.logger.debug('[IdentityManager] Could not check current peer ID:', e); + } + } + + // Default to 0 if we can't determine + await this.storage.setItem(deviceIndexKey, '0'); + return 0; + } + + private async createPeerIdFromEd25519( + _privateKey: Uint8Array, + publicKey: Uint8Array, + ): Promise { + // Generate libp2p peer ID from Ed25519 public key + // Swift-libp2p uses identity multihash for Ed25519 keys + + this.logger.debug('[IdentityManager] === PEER ID GENERATION (Ed25519) ==='); + this.logger.debug('[IdentityManager] Input public key length:', publicKey.length); + this.logger.debug( + '[IdentityManager] Input public key (hex):', + Array.from(publicKey.slice(0, 8)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + + // Based on research of swift-libp2p and the "12D3KooW" format: + // 1. Ed25519 public keys use identity multihash (no hashing) + // 2. The public key is prefixed with Ed25519-pub multicodec + // 3. The identity multihash embeds the key directly + + // Ed25519-pub multicodec is 0xed (237 in decimal) + // In unsigned LEB128/varint: 237 = 0xed requires 2 bytes: [0xed, 0x01] + const ED25519_PUB_CODEC = new Uint8Array([0xed, 0x01]); + + // Build key with codec prefix + const keyWithCodec = new Uint8Array(ED25519_PUB_CODEC.length + publicKey.length); + keyWithCodec.set(ED25519_PUB_CODEC, 0); + keyWithCodec.set(publicKey, ED25519_PUB_CODEC.length); + + // Create identity multihash + // Format: [hash-code, length, data] + // For identity: code = 0x00, followed by length, then the data + const multihash = new Uint8Array(2 + keyWithCodec.length); + multihash[0] = 0x00; // Identity hash function code + multihash[1] = keyWithCodec.length; // Should be 34 (0x22) for Ed25519 + multihash.set(keyWithCodec, 2); + + // Base58btc encode the multihash + const peerId = this.base58Encode(multihash); + + this.logger.debug('[IdentityManager] Using identity multihash with Ed25519 codec'); + this.logger.debug('[IdentityManager] Codec + key length:', keyWithCodec.length); + this.logger.debug( + '[IdentityManager] Multihash bytes:', + Array.from(multihash.slice(0, 8)) + .map((b) => `0x${b.toString(16).padStart(2, '0')}`) + .join(' '), + ); + this.logger.debug('[IdentityManager] Generated peer ID:', peerId); + this.logger.debug('[IdentityManager] First 8 chars:', peerId.substring(0, 8)); + + // The generated format should be: + // - "14..." if using identity with Ed25519 codec (what we expect) + // - "12D3KooW..." is what swift-libp2p actually generates + + // Since swift-libp2p generates a different format, we'll need to update + // with the actual peer ID after libp2p starts + if (!peerId.startsWith('12D3KooW')) { + this.logger.debug('[IdentityManager] NOTE: Generated format differs from swift-libp2p'); + this.logger.debug('[IdentityManager] Will be updated when libp2p starts with actual peer ID'); + + // IMPORTANT: For now, we return the identity multihash format + // The App.tsx updateDevicePeerId will update this with the actual + // peer ID that swift-libp2p generates when it starts + } + + return peerId; + } + + private base58Encode(bytes: Uint8Array): string { + // Base58 alphabet (Bitcoin/IPFS format) + const ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; + + // Handle special case of all zeros + if (bytes.every((b) => b === 0)) { + return '1'.repeat(bytes.length); + } + + // Convert bytes to big integer + let num = 0n; + for (const byte of bytes) { + num = num * 256n + BigInt(byte); + } + + // Convert to base58 + let encoded = ''; + while (num > 0n) { + const remainder = num % 58n; + num = num / 58n; + encoded = ALPHABET[Number(remainder)] + encoded; + } + + // Handle leading zeros - each leading zero byte becomes '1' in base58 + let leadingZeros = 0; + for (const byte of bytes) { + if (byte === 0) { + leadingZeros++; + } else { + break; + } + } + + encoded = '1'.repeat(leadingZeros) + encoded; + + return encoded; + } + + private async defaultDeviceNameProvider(): Promise { + // Default implementation + // In React Native, this would use DeviceInfo.getDeviceName() + return 'Mobile Device'; + } + + private getStorageKey(key: string): string { + return `${this.config.storagePrefix}:${key}`; + } + + private async loadIdentity(): Promise { + try { + const mnemonic = await this.storage.getItem(this.getStorageKey('mnemonic')); + if (!mnemonic) return null; + + // Restore from mnemonic + await this.restoreFromMnemonic(mnemonic); + + // Load devices + const devicesJson = await this.storage.getItem(this.getStorageKey('devices')); + if (devicesJson) { + const devicesList = JSON.parse(devicesJson); + for (const device of devicesList) { + // Convert arrays back to Uint8Array + device.publicKey = new Uint8Array(device.publicKey); + device.privateKey = new Uint8Array(device.privateKey); + this.devices.set(device.deviceId, device); + + // Update device index + const match = device.derivationPath.match(/\/(\d+)$/); + if (match) { + const index = Number.parseInt(match[1], 10); + if (index >= this.currentDeviceIndex) { + this.currentDeviceIndex = index + 1; + } + } + } + } + + return this.userIdentity!; + } catch (error) { + this.logger.error('Failed to load identity:', error); + return null; + } + } + + private async saveIdentity(): Promise { + if (!this.userIdentity) return; + + try { + // Save mnemonic (should be encrypted in production!) + if (this.userIdentity.mnemonic) { + await this.storage.setItem(this.getStorageKey('mnemonic'), this.userIdentity.mnemonic); + } + + // Save user ID + await this.storage.setItem(this.getStorageKey('userId'), this.userIdentity.userId); + } catch (error) { + this.logger.error('Failed to save identity:', error); + throw error; + } + } + + private async saveDevices(): Promise { + try { + const devicesList = Array.from(this.devices.values()).map((device) => ({ + ...device, + // Convert Uint8Array to array for JSON serialization + publicKey: Array.from(device.publicKey), + privateKey: Array.from(device.privateKey), + })); + + await this.storage.setItem(this.getStorageKey('devices'), JSON.stringify(devicesList)); + } catch (error) { + this.logger.error('Failed to save devices:', error); + throw error; + } + } +} diff --git a/index.d.ts b/index.d.ts new file mode 100644 index 0000000..84a7d1e --- /dev/null +++ b/index.d.ts @@ -0,0 +1,234 @@ +declare module '@metatrom/identity' { + /** + * User identity (master identity) + */ + export interface UserIdentity { + userId: string; // Hash of master public key + publicKey: Uint8Array; // Master public key + mnemonic?: string; // Stored securely, only on this device + } + + /** + * Device identity (derived from user) + */ + export interface DeviceIdentity { + deviceId: string; // libp2p peer ID + deviceName: string; // Human-readable name + publicKey: Uint8Array; // Device public key (Ed25519) + privateKey: Uint8Array; // Device private key (Ed25519) + derivationPath: string; // HD derivation path + createdAt: number; + lastSeen?: number; + } + + /** + * Device information for remote devices + */ + export interface DeviceInfo { + deviceId: string; + deviceName: string; + multiaddrs: string[]; + isOnline: boolean; + lastSeen: number; + } + + /** + * Configuration for identity manager + */ + export interface IdentityConfig { + storagePrefix?: string; + mnemonicStrength?: 128 | 160 | 192 | 224 | 256; + deviceNameProvider?: () => Promise; + } + + /** + * Main identity management interface + */ + export interface IIdentityManager { + /** + * Initialize the identity manager + * Either loads existing identity or creates new one + */ + initialize(): Promise; + + /** + * Create a completely new user identity with mnemonic + */ + createNewIdentity(): Promise; + + /** + * Restore identity from mnemonic phrase + */ + restoreFromMnemonic(mnemonic: string): Promise; + + /** + * Create a new device identity derived from master + */ + createDeviceIdentity(deviceName: string): Promise; + + /** + * Get current device identity or create one if none exists + */ + getCurrentDevice(): Promise; + + /** + * List all registered devices for this user + */ + getRegisteredDevices(): Promise; + + /** + * Remove a device from the user's identity + */ + removeDevice(deviceId: string): Promise; + + /** + * Clean up duplicate devices with the same base name + * @returns Number of devices removed + */ + cleanupDuplicateDevices(): Promise; + + /** + * Clean up inactive devices (except current device) + * @param daysInactive - Number of days of inactivity before removal (default: 30) + * @returns Number of devices removed + */ + cleanupInactiveDevices(daysInactive?: number): Promise; + + /** + * Get the libp2p keypair for current device + */ + getLibp2pKeypair(): Promise<{ privateKey: Uint8Array; publicKey: Uint8Array }>; + + /** + * Update the current device's peer ID after libp2p generates it + */ + updateDevicePeerId(peerId: string): Promise; + + /** + * Calculate what the peer ID will be for a given device index + * Useful during pairing to predict the new device's peer ID + */ + calculatePeerIdForIndex(deviceIndex: number): Promise; + + /** + * Export mnemonic for backup (should be done securely!) + */ + getMnemonic(): string | undefined; + + /** + * Get user identity + */ + getUserIdentity(): UserIdentity | undefined; + + /** + * Check if identity is initialized + */ + isInitialized(): boolean; + + /** + * Clear all identity data (dangerous!) + */ + reset(): Promise; + + /** + * Broadcast peer ID update to other devices + */ + broadcastPeerIdUpdate( + oldPeerId: string, + newPeerId: string, + sendProtocolData?: (peerId: string, protocolId: string, data: Uint8Array) => Promise, + ): Promise; + } + + /** + * Storage interface for identity persistence + */ + export interface IIdentityStorage { + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; + clear(): Promise; + } + + /** + * Factory interface for creating identity managers + */ + export interface IIdentityFactory { + create(config?: IdentityConfig): IIdentityManager; + } + + // AsyncStorage type definition for dependency injection + interface IAsyncStorage { + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; + getAllKeys(): Promise; + multiRemove(keys: string[]): Promise; + } + + // Platform interface for dependency injection + interface IPlatform { + OS: string; + } + + /** + * AsyncStorage adapter for React Native + */ + export class AsyncStorageAdapter implements IIdentityStorage { + constructor(asyncStorage: IAsyncStorage); + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; + clear(): Promise; + } + + /** + * Identity Manager Implementation + */ + export class IdentityManager implements IIdentityManager { + constructor(storage: IIdentityStorage, config?: IdentityConfig); + initialize(): Promise; + createNewIdentity(): Promise; + restoreFromMnemonic(mnemonic: string): Promise; + createDeviceIdentity(deviceName: string): Promise; + getCurrentDevice(): Promise; + getRegisteredDevices(): Promise; + removeDevice(deviceId: string): Promise; + cleanupDuplicateDevices(): Promise; + cleanupInactiveDevices(daysInactive?: number): Promise; + getLibp2pKeypair(): Promise<{ privateKey: Uint8Array; publicKey: Uint8Array }>; + updateDevicePeerId(peerId: string): Promise; + calculatePeerIdForIndex(deviceIndex: number): Promise; + getMnemonic(): string | undefined; + getUserIdentity(): UserIdentity | undefined; + isInitialized(): boolean; + reset(): Promise; + broadcastPeerIdUpdate( + oldPeerId: string, + newPeerId: string, + sendProtocolData?: (peerId: string, protocolId: string, data: Uint8Array) => Promise, + ): Promise; + } + + /** + * Factory for creating identity manager instances + */ + export class IdentityFactory implements IIdentityFactory { + static getInstance(platform?: IPlatform, asyncStorage?: IAsyncStorage): IdentityFactory; + create(config?: IdentityConfig): IIdentityManager; + static reset(): void; + } + + /** + * Create identity manager with dependency injection + */ + export function createIdentityManager( + config?: IdentityConfig, + platform?: IPlatform, + asyncStorage?: IAsyncStorage + ): IIdentityManager; + + // Version information + export const VERSION: string; + export const PROTOCOL_VERSION: string; +} \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..dc818c2 --- /dev/null +++ b/index.ts @@ -0,0 +1,31 @@ +/** + * Identity Management Component + * + * Provides hierarchical user/device identity management with HD key derivation + * + * @module identity/1.0.0 + */ + +// Export factory and helper +export { createIdentityManager, IdentityFactory } from './IdentityFactory'; +export { AsyncStorageAdapter } from './implementations/AsyncStorageAdapter'; + +// Export implementations (for advanced usage) +export { IdentityManager } from './implementations/IdentityManager'; +// Export interfaces +export type { + DeviceIdentity, + DeviceInfo, + IdentityConfig, + IIdentityFactory, + IIdentityManager, + IIdentityStorage, + UserIdentity, +} from './interfaces/IIdentity'; + +// Note: Default singleton instance removed - must be created with dependencies +// Use createIdentityManager() with platform and storage dependencies + +// Version information +export const VERSION = '1.0.0'; +export const PROTOCOL_VERSION = '/metatrom/identity/1.0.0'; diff --git a/interfaces/IIdentity.ts b/interfaces/IIdentity.ts new file mode 100644 index 0000000..8df6149 --- /dev/null +++ b/interfaces/IIdentity.ts @@ -0,0 +1,163 @@ +/** + * Identity Management Interfaces + * + * Provides hierarchical user/device identity management with HD key derivation + */ + +/** + * User identity (master identity) + */ +export interface UserIdentity { + userId: string; // Hash of master public key + publicKey: Uint8Array; // Master public key + mnemonic?: string; // Stored securely, only on this device +} + +/** + * Device identity (derived from user) + */ +export interface DeviceIdentity { + deviceId: string; // libp2p peer ID + deviceName: string; // Human-readable name + publicKey: Uint8Array; // Device public key (Ed25519) + privateKey: Uint8Array; // Device private key (Ed25519) + derivationPath: string; // HD derivation path + createdAt: number; + lastSeen?: number; +} + +/** + * Device information for remote devices + */ +export interface DeviceInfo { + deviceId: string; + deviceName: string; + multiaddrs: string[]; + isOnline: boolean; + lastSeen: number; +} + +/** + * Configuration for identity manager + */ +export interface IdentityConfig { + storagePrefix?: string; + mnemonicStrength?: 128 | 160 | 192 | 224 | 256; + deviceNameProvider?: () => Promise; +} + +/** + * Main identity management interface + */ +export interface IIdentityManager { + /** + * Initialize the identity manager + * Either loads existing identity or creates new one + */ + initialize(): Promise; + + /** + * Create a completely new user identity with mnemonic + */ + createNewIdentity(): Promise; + + /** + * Restore identity from mnemonic phrase + */ + restoreFromMnemonic(mnemonic: string): Promise; + + /** + * Create a new device identity derived from master + */ + createDeviceIdentity(deviceName: string): Promise; + + /** + * Get current device identity or create one if none exists + */ + getCurrentDevice(): Promise; + + /** + * List all registered devices for this user + */ + getRegisteredDevices(): Promise; + + /** + * Remove a device from the user's identity + */ + removeDevice(deviceId: string): Promise; + + /** + * Clean up duplicate devices with the same base name + * @returns Number of devices removed + */ + cleanupDuplicateDevices(): Promise; + + /** + * Clean up inactive devices (except current device) + * @param daysInactive - Number of days of inactivity before removal (default: 30) + * @returns Number of devices removed + */ + cleanupInactiveDevices(daysInactive?: number): Promise; + + /** + * Get the libp2p keypair for current device + */ + getLibp2pKeypair(): Promise<{ privateKey: Uint8Array; publicKey: Uint8Array }>; + + /** + * Update the current device's peer ID after libp2p generates it + */ + updateDevicePeerId(peerId: string): Promise; + + /** + * Calculate what the peer ID will be for a given device index + * Useful during pairing to predict the new device's peer ID + */ + calculatePeerIdForIndex(deviceIndex: number): Promise; + + /** + * Export mnemonic for backup (should be done securely!) + */ + getMnemonic(): string | undefined; + + /** + * Get user identity + */ + getUserIdentity(): UserIdentity | undefined; + + /** + * Check if identity is initialized + */ + isInitialized(): boolean; + + /** + * Clear all identity data (dangerous!) + */ + reset(): Promise; + + /** + * Broadcast peer ID update to other devices + */ + broadcastPeerIdUpdate( + oldPeerId: string, + newPeerId: string, + sendProtocolData?: (peerId: string, protocolId: string, data: Uint8Array) => Promise, + ): Promise; +} + +/** + * Storage interface for identity persistence + */ +export interface IIdentityStorage { + setItem(key: string, value: string): Promise; + getItem(key: string): Promise; + removeItem(key: string): Promise; + clear(): Promise; +} + +/** + * Factory for creating identity manager instances + */ +export interface IIdentityFactory { + create(config?: IdentityConfig): IIdentityManager; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..73f4ea5 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "@metatrom/identity", + "version": "1.0.0", + "main": "index.ts", + "type": "module", + "description": "Identity management system with hierarchical user-device structure", + "metatrom": { + "ior": "com.metatrom.identity@1.0.0", + "capabilities": { + "p2p": true, + "contracts": false, + "viewer": false, + "sync": true + } + }, + "exports": { + ".": "./index.ts", + "./interfaces": "./interfaces/IIdentity.ts", + "./implementations": "./implementations/index.ts" + }, + "dependencies": { + "@noble/curves": "^1.9.6", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "bip39": "^3.1.0" + }, + "peerDependencies": { + "@react-native-async-storage/async-storage": "^2.2.0" + }, + "keywords": [ + "metatrom", + "identity", + "p2p", + "ed25519", + "hd-keys" + ], + "license": "MIT" +} \ No newline at end of file