You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
287 lines
9.0 KiB
287 lines
9.0 KiB
/** |
|
* Identity Resolution for Directory Consensus Protocol |
|
* |
|
* This module provides functionality to resolve actual identities behind |
|
* delegate keys and manage key delegations. |
|
*/ |
|
|
|
import type { EventStore } from 'applesauce-core'; |
|
import type { NostrEvent } from 'applesauce-core/helpers'; |
|
import type { IdentityTag, PublicKeyAdvertisement } from './types.js'; |
|
import { EventKinds } from './types.js'; |
|
import { parseIdentityTag, parsePublicKeyAdvertisement } from './parsers.js'; |
|
import { ValidationError } from './validation.js'; |
|
import { Observable, combineLatest, map, startWith } from 'rxjs'; |
|
|
|
/** |
|
* Manages identity resolution and key delegation tracking |
|
*/ |
|
export class IdentityResolver { |
|
private eventStore: EventStore; |
|
private delegateToIdentity: Map<string, string> = new Map(); |
|
private identityToDelegates: Map<string, Set<string>> = new Map(); |
|
private identityTagCache: Map<string, IdentityTag> = new Map(); |
|
private publicKeyAds: Map<string, PublicKeyAdvertisement> = new Map(); |
|
|
|
constructor(eventStore: EventStore) { |
|
this.eventStore = eventStore; |
|
this.initializeTracking(); |
|
} |
|
|
|
/** |
|
* Initialize tracking of identity tags and key delegations |
|
*/ |
|
private initializeTracking(): void { |
|
// Track all events with I tags |
|
this.eventStore.stream({ kinds: Object.values(EventKinds) }).subscribe(event => { |
|
this.processEvent(event); |
|
}); |
|
|
|
// Track Public Key Advertisements (kind 39103) |
|
this.eventStore.stream({ kinds: [EventKinds.PublicKeyAdvertisement] }).subscribe(event => { |
|
try { |
|
const keyAd = parsePublicKeyAdvertisement(event); |
|
this.publicKeyAds.set(keyAd.keyID, keyAd); |
|
} catch (err) { |
|
// Ignore invalid events |
|
console.warn('Invalid public key advertisement:', err); |
|
} |
|
}); |
|
} |
|
|
|
/** |
|
* Process an event to extract and cache identity information |
|
*/ |
|
private processEvent(event: NostrEvent): void { |
|
try { |
|
const identityTag = parseIdentityTag(event); |
|
if (identityTag) { |
|
this.cacheIdentityTag(identityTag); |
|
} |
|
} catch (err) { |
|
// Event doesn't have a valid I tag or parsing failed |
|
} |
|
} |
|
|
|
/** |
|
* Cache an identity tag mapping |
|
*/ |
|
private cacheIdentityTag(tag: IdentityTag): void { |
|
const { identity, delegate } = tag; |
|
|
|
// Store delegate -> identity mapping |
|
this.delegateToIdentity.set(delegate, identity); |
|
|
|
// Store identity -> delegates mapping |
|
if (!this.identityToDelegates.has(identity)) { |
|
this.identityToDelegates.set(identity, new Set()); |
|
} |
|
this.identityToDelegates.get(identity)!.add(delegate); |
|
|
|
// Cache the full tag |
|
this.identityTagCache.set(delegate, tag); |
|
} |
|
|
|
/** |
|
* Resolve the actual identity behind a public key (which may be a delegate) |
|
* |
|
* @param pubkey - The public key to resolve (may be delegate or identity) |
|
* @returns The actual identity public key, or the input if it's already an identity |
|
*/ |
|
public resolveIdentity(pubkey: string): string { |
|
return this.delegateToIdentity.get(pubkey) || pubkey; |
|
} |
|
|
|
/** |
|
* Resolve the actual identity behind an event's pubkey |
|
* |
|
* @param event - The event to resolve |
|
* @returns The actual identity public key |
|
*/ |
|
public resolveEventIdentity(event: NostrEvent): string { |
|
return this.resolveIdentity(event.pubkey); |
|
} |
|
|
|
/** |
|
* Check if a public key is a known delegate |
|
* |
|
* @param pubkey - The public key to check |
|
* @returns true if the key is a delegate, false otherwise |
|
*/ |
|
public isDelegateKey(pubkey: string): boolean { |
|
return this.delegateToIdentity.has(pubkey); |
|
} |
|
|
|
/** |
|
* Check if a public key is a known identity (has delegates) |
|
* |
|
* @param pubkey - The public key to check |
|
* @returns true if the key is an identity with delegates, false otherwise |
|
*/ |
|
public isIdentityKey(pubkey: string): boolean { |
|
return this.identityToDelegates.has(pubkey); |
|
} |
|
|
|
/** |
|
* Get all delegate keys for a given identity |
|
* |
|
* @param identity - The identity public key |
|
* @returns Set of delegate public keys |
|
*/ |
|
public getDelegatesForIdentity(identity: string): Set<string> { |
|
return this.identityToDelegates.get(identity) || new Set(); |
|
} |
|
|
|
/** |
|
* Get the identity tag for a delegate key |
|
* |
|
* @param delegate - The delegate public key |
|
* @returns The identity tag, or undefined if not found |
|
*/ |
|
public getIdentityTag(delegate: string): IdentityTag | undefined { |
|
return this.identityTagCache.get(delegate); |
|
} |
|
|
|
/** |
|
* Get all public key advertisements for an identity |
|
* |
|
* @param identity - The identity public key |
|
* @returns Array of public key advertisements |
|
*/ |
|
public getPublicKeyAdvertisements(identity: string): PublicKeyAdvertisement[] { |
|
const delegates = this.getDelegatesForIdentity(identity); |
|
const ads: PublicKeyAdvertisement[] = []; |
|
|
|
for (const keyAd of this.publicKeyAds.values()) { |
|
const adIdentity = this.resolveIdentity(keyAd.event.pubkey); |
|
if (adIdentity === identity || delegates.has(keyAd.publicKey)) { |
|
ads.push(keyAd); |
|
} |
|
} |
|
|
|
return ads; |
|
} |
|
|
|
/** |
|
* Get a public key advertisement by key ID |
|
* |
|
* @param keyID - The unique key identifier |
|
* @returns The public key advertisement, or undefined if not found |
|
*/ |
|
public getPublicKeyAdvertisementByID(keyID: string): PublicKeyAdvertisement | undefined { |
|
return this.publicKeyAds.get(keyID); |
|
} |
|
|
|
/** |
|
* Stream all events by their actual identity |
|
* |
|
* @param identity - The identity public key |
|
* @param includeNewEvents - If true, include future events (default: false) |
|
* @returns Observable of events signed by this identity or its delegates |
|
*/ |
|
public streamEventsByIdentity(identity: string, includeNewEvents = false): Observable<NostrEvent> { |
|
const delegates = this.getDelegatesForIdentity(identity); |
|
const allKeys = [identity, ...Array.from(delegates)]; |
|
|
|
return this.eventStore.stream( |
|
{ authors: allKeys }, |
|
includeNewEvents |
|
); |
|
} |
|
|
|
/** |
|
* Stream events by identity with real-time delegate updates |
|
* |
|
* This will automatically include events from newly discovered delegates. |
|
* |
|
* @param identity - The identity public key |
|
* @returns Observable of events signed by this identity or its delegates |
|
*/ |
|
public streamEventsByIdentityLive(identity: string): Observable<NostrEvent> { |
|
// Create an observable that emits whenever delegates change |
|
const delegateUpdates$ = new Observable<Set<string>>(observer => { |
|
// Emit initial delegates |
|
observer.next(this.getDelegatesForIdentity(identity)); |
|
|
|
// Watch for new delegates |
|
const subscription = this.eventStore.stream({ kinds: Object.values(EventKinds) }, true) |
|
.subscribe(event => { |
|
try { |
|
const identityTag = parseIdentityTag(event); |
|
if (identityTag && identityTag.identity === identity) { |
|
this.cacheIdentityTag(identityTag); |
|
observer.next(this.getDelegatesForIdentity(identity)); |
|
} |
|
} catch (err) { |
|
// Ignore invalid events |
|
} |
|
}); |
|
|
|
return () => subscription.unsubscribe(); |
|
}); |
|
|
|
// Map delegate updates to event streams |
|
return delegateUpdates$.pipe( |
|
map(delegates => { |
|
const allKeys = [identity, ...Array.from(delegates)]; |
|
return this.eventStore.stream({ authors: allKeys }, true); |
|
}), |
|
// Flatten the nested observable |
|
map(stream$ => stream$), |
|
) as any; // Type assertion needed due to complex Observable nesting |
|
} |
|
|
|
/** |
|
* Verify that an identity tag signature is valid |
|
* |
|
* Note: This requires schnorr signature verification which should be |
|
* implemented using appropriate cryptographic libraries. |
|
* |
|
* @param tag - The identity tag to verify |
|
* @returns Promise that resolves to true if valid, false otherwise |
|
*/ |
|
public async verifyIdentityTag(tag: IdentityTag): Promise<boolean> { |
|
// TODO: Implement schnorr signature verification |
|
// The signature is over: sha256(identity + delegate + relayHint) |
|
// |
|
// Example implementation would require: |
|
// 1. Concatenate: identity + delegate + (relayHint || '') |
|
// 2. Compute SHA256 hash |
|
// 3. Verify signature using identity key |
|
|
|
throw new Error('Identity tag verification not yet implemented'); |
|
} |
|
|
|
/** |
|
* Clear all cached identity mappings |
|
*/ |
|
public clearCache(): void { |
|
this.delegateToIdentity.clear(); |
|
this.identityToDelegates.clear(); |
|
this.identityTagCache.clear(); |
|
this.publicKeyAds.clear(); |
|
} |
|
|
|
/** |
|
* Get statistics about tracked identities and delegates |
|
*/ |
|
public getStats(): { |
|
identities: number; |
|
delegates: number; |
|
publicKeyAds: number; |
|
} { |
|
return { |
|
identities: this.identityToDelegates.size, |
|
delegates: this.delegateToIdentity.size, |
|
publicKeyAds: this.publicKeyAds.size, |
|
}; |
|
} |
|
} |
|
|
|
/** |
|
* Helper function to create an identity resolver instance |
|
*/ |
|
export function createIdentityResolver(eventStore: EventStore): IdentityResolver { |
|
return new IdentityResolver(eventStore); |
|
} |
|
|
|
|