identity.ts raw

   1  import { AggregateRoot } from '../events/domain-event';
   2  import { IdentityCreated, IdentityRenamed, IdentitySigned } from '../events/identity-events';
   3  import {
   4    IdentityId,
   5    Nickname,
   6    NostrKeyPair,
   7  } from '../value-objects';
   8  import type { IdentitySnapshot } from '../repositories/identity-repository';
   9  
  10  /**
  11   * Represents an unsigned Nostr event template.
  12   * This is what gets passed to the sign method.
  13   */
  14  export interface UnsignedEvent {
  15    kind: number;
  16    created_at: number;
  17    tags: string[][];
  18    content: string;
  19  }
  20  
  21  /**
  22   * Represents a signed Nostr event.
  23   */
  24  export interface SignedEvent extends UnsignedEvent {
  25    id: string;
  26    pubkey: string;
  27    sig: string;
  28  }
  29  
  30  /**
  31   * Signing function type - injected to avoid coupling to nostr-tools.
  32   */
  33  export type SigningFunction = (event: UnsignedEvent, privateKeyBytes: Uint8Array) => SignedEvent;
  34  
  35  /**
  36   * Encryption function types for NIP-04 and NIP-44.
  37   */
  38  export type EncryptFunction = (
  39    privateKeyBytes: Uint8Array,
  40    peerPubkey: string,
  41    plaintext: string
  42  ) => Promise<string>;
  43  
  44  export type DecryptFunction = (
  45    privateKeyBytes: Uint8Array,
  46    peerPubkey: string,
  47    ciphertext: string
  48  ) => Promise<string>;
  49  
  50  /**
  51   * Identity entity - represents a Nostr identity with its keypair.
  52   *
  53   * This is an aggregate root that encapsulates all operations
  54   * related to a single Nostr identity.
  55   */
  56  export class Identity extends AggregateRoot {
  57    private readonly _id: IdentityId;
  58    private _nickname: Nickname;
  59    private readonly _keyPair: NostrKeyPair;
  60    private readonly _createdAt: Date;
  61  
  62    private constructor(
  63      id: IdentityId,
  64      nickname: Nickname,
  65      keyPair: NostrKeyPair,
  66      createdAt: Date
  67    ) {
  68      super();
  69      this._id = id;
  70      this._nickname = nickname;
  71      this._keyPair = keyPair;
  72      this._createdAt = createdAt;
  73    }
  74  
  75    // ─────────────────────────────────────────────────────────────────────────
  76    // Factory Methods
  77    // ─────────────────────────────────────────────────────────────────────────
  78  
  79    /**
  80     * Create a new identity with an optional private key.
  81     * If no private key is provided, a new one will be generated.
  82     *
  83     * @param nickname - User-friendly name for this identity
  84     * @param privateKey - Optional private key (hex or nsec format)
  85     * @throws InvalidNicknameError if nickname is invalid
  86     * @throws InvalidNostrKeyError if private key is invalid
  87     */
  88    static create(nickname: string, privateKey?: string): Identity {
  89      const keyPair = privateKey
  90        ? NostrKeyPair.fromPrivateKey(privateKey)
  91        : NostrKeyPair.generate();
  92  
  93      const identity = new Identity(
  94        IdentityId.generate(),
  95        Nickname.create(nickname),
  96        keyPair,
  97        new Date()
  98      );
  99  
 100      identity.addDomainEvent(
 101        new IdentityCreated(
 102          identity._id.value,
 103          identity.publicKey,
 104          identity.nickname
 105        )
 106      );
 107  
 108      return identity;
 109    }
 110  
 111    /**
 112     * Reconstitute an identity from storage.
 113     * This bypasses validation since data comes from trusted storage.
 114     */
 115    static fromSnapshot(snapshot: IdentitySnapshot): Identity {
 116      return new Identity(
 117        IdentityId.from(snapshot.id),
 118        Nickname.fromStorage(snapshot.nick),
 119        NostrKeyPair.fromStorage(snapshot.privkey),
 120        new Date(snapshot.createdAt)
 121      );
 122    }
 123  
 124    // ─────────────────────────────────────────────────────────────────────────
 125    // Getters (Read-only access to state)
 126    // ─────────────────────────────────────────────────────────────────────────
 127  
 128    get id(): IdentityId {
 129      return this._id;
 130    }
 131  
 132    get nickname(): string {
 133      return this._nickname.value;
 134    }
 135  
 136    get publicKey(): string {
 137      return this._keyPair.publicKeyHex;
 138    }
 139  
 140    get npub(): string {
 141      return this._keyPair.npub;
 142    }
 143  
 144    get nsec(): string {
 145      return this._keyPair.nsec;
 146    }
 147  
 148    get createdAt(): Date {
 149      return this._createdAt;
 150    }
 151  
 152    // ─────────────────────────────────────────────────────────────────────────
 153    // Behavior Methods
 154    // ─────────────────────────────────────────────────────────────────────────
 155  
 156    /**
 157     * Rename this identity.
 158     *
 159     * @param newNickname - The new nickname
 160     * @throws InvalidNicknameError if nickname is invalid
 161     */
 162    rename(newNickname: string): void {
 163      const oldNickname = this._nickname.value;
 164      this._nickname = Nickname.create(newNickname);
 165  
 166      this.addDomainEvent(
 167        new IdentityRenamed(this._id.value, oldNickname, newNickname)
 168      );
 169    }
 170  
 171    /**
 172     * Sign a Nostr event with this identity's private key.
 173     *
 174     * @param event - The unsigned event template
 175     * @param signFn - The signing function (injected to avoid coupling)
 176     * @returns The signed event with id, pubkey, and sig
 177     */
 178    sign(event: UnsignedEvent, signFn: SigningFunction): SignedEvent {
 179      const signedEvent = signFn(event, this._keyPair.getPrivateKeyBytes());
 180  
 181      this.addDomainEvent(
 182        new IdentitySigned(this._id.value, event.kind, signedEvent.id)
 183      );
 184  
 185      return signedEvent;
 186    }
 187  
 188    /**
 189     * Encrypt a message using NIP-04 encryption.
 190     *
 191     * @param plaintext - The message to encrypt
 192     * @param recipientPubkey - The recipient's public key (hex)
 193     * @param encryptFn - The NIP-04 encryption function
 194     */
 195    async encryptNip04(
 196      plaintext: string,
 197      recipientPubkey: string,
 198      encryptFn: EncryptFunction
 199    ): Promise<string> {
 200      return encryptFn(
 201        this._keyPair.getPrivateKeyBytes(),
 202        recipientPubkey,
 203        plaintext
 204      );
 205    }
 206  
 207    /**
 208     * Decrypt a message using NIP-04 decryption.
 209     *
 210     * @param ciphertext - The encrypted message
 211     * @param senderPubkey - The sender's public key (hex)
 212     * @param decryptFn - The NIP-04 decryption function
 213     */
 214    async decryptNip04(
 215      ciphertext: string,
 216      senderPubkey: string,
 217      decryptFn: DecryptFunction
 218    ): Promise<string> {
 219      return decryptFn(
 220        this._keyPair.getPrivateKeyBytes(),
 221        senderPubkey,
 222        ciphertext
 223      );
 224    }
 225  
 226    /**
 227     * Encrypt a message using NIP-44 encryption.
 228     *
 229     * @param plaintext - The message to encrypt
 230     * @param recipientPubkey - The recipient's public key (hex)
 231     * @param encryptFn - The NIP-44 encryption function
 232     */
 233    async encryptNip44(
 234      plaintext: string,
 235      recipientPubkey: string,
 236      encryptFn: EncryptFunction
 237    ): Promise<string> {
 238      return encryptFn(
 239        this._keyPair.getPrivateKeyBytes(),
 240        recipientPubkey,
 241        plaintext
 242      );
 243    }
 244  
 245    /**
 246     * Decrypt a message using NIP-44 decryption.
 247     *
 248     * @param ciphertext - The encrypted message
 249     * @param senderPubkey - The sender's public key (hex)
 250     * @param decryptFn - The NIP-44 decryption function
 251     */
 252    async decryptNip44(
 253      ciphertext: string,
 254      senderPubkey: string,
 255      decryptFn: DecryptFunction
 256    ): Promise<string> {
 257      return decryptFn(
 258        this._keyPair.getPrivateKeyBytes(),
 259        senderPubkey,
 260        ciphertext
 261      );
 262    }
 263  
 264    /**
 265     * Check if this identity has the same private key as another.
 266     * Used for duplicate detection.
 267     */
 268    hasSameKeyAs(other: Identity): boolean {
 269      return this._keyPair.hasSamePublicKey(other._keyPair);
 270    }
 271  
 272    /**
 273     * Check if this identity matches a given public key.
 274     */
 275    matchesPublicKey(publicKey: string): boolean {
 276      return this._keyPair.matchesPublicKey(publicKey);
 277    }
 278  
 279    // ─────────────────────────────────────────────────────────────────────────
 280    // Persistence
 281    // ─────────────────────────────────────────────────────────────────────────
 282  
 283    /**
 284     * Convert to a snapshot for persistence.
 285     */
 286    toSnapshot(): IdentitySnapshot {
 287      return {
 288        id: this._id.value,
 289        nick: this._nickname.value,
 290        privkey: this._keyPair.toStorageHex(),
 291        createdAt: this._createdAt.toISOString(),
 292      };
 293    }
 294  
 295    // ─────────────────────────────────────────────────────────────────────────
 296    // Equality
 297    // ─────────────────────────────────────────────────────────────────────────
 298  
 299    /**
 300     * Check equality based on identity ID.
 301     */
 302    equals(other: Identity): boolean {
 303      return this._id.equals(other._id);
 304    }
 305  }
 306