Files
identity/implementations/IdentityManager.ts
Chris Daßler a6b10428fa 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
2025-08-29 14:17:39 +02:00

938 lines
32 KiB
TypeScript

/**
* 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<string, DeviceIdentity> = new Map();
private currentDeviceIndex: number = 0;
private storage: IIdentityStorage;
private config: Required<IdentityConfig>;
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<UserIdentity> {
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<UserIdentity> {
// 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<void>,
): Promise<void> {
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<UserIdentity> {
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<DeviceIdentity> {
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<string> {
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<DeviceIdentity> {
// 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<DeviceIdentity[]> {
return Array.from(this.devices.values());
}
async removeDevice(deviceId: string): Promise<void> {
// 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<number> {
// 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<string, DeviceIdentity[]>();
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<number> {
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<void> {
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<void> {
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<void> {
// 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<number> {
// 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<string> {
// 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<string> {
// 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<UserIdentity | null> {
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<void> {
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<void> {
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;
}
}
}