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
This commit is contained in:
937
implementations/IdentityManager.ts
Normal file
937
implementations/IdentityManager.ts
Normal file
@@ -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<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user