78 lines
2.6 KiB
TypeScript
78 lines
2.6 KiB
TypeScript
|
|
/**
|
||
|
|
* timmy-identity.ts — Timmy's persistent Nostr identity.
|
||
|
|
*
|
||
|
|
* Loads a secp256k1 keypair from TIMMY_NOSTR_NSEC env var at boot.
|
||
|
|
* If the env var is absent, generates an ephemeral keypair and logs
|
||
|
|
* a warning instructing the operator to persist it.
|
||
|
|
*
|
||
|
|
* Exports `timmyIdentityService` singleton with:
|
||
|
|
* .npub — bech32 public key (npub1...)
|
||
|
|
* .nsec — bech32 private key (nsec1...) — never log this
|
||
|
|
* .pubkeyHex — raw 64-char hex pubkey
|
||
|
|
* .sign(template) — finalises and signs a Nostr event template
|
||
|
|
* .encryptDm(recipientPubkeyHex, plaintext) — NIP-04 encrypt
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { generateSecretKey, getPublicKey, finalizeEvent, nip19, nip04 } from "nostr-tools";
|
||
|
|
import type { EventTemplate, NostrEvent } from "nostr-tools";
|
||
|
|
import { makeLogger } from "./logger.js";
|
||
|
|
|
||
|
|
const logger = makeLogger("timmy-identity");
|
||
|
|
|
||
|
|
function loadOrGenerate(): { sk: Uint8Array; nsec: string; npub: string; pubkeyHex: string } {
|
||
|
|
const raw = process.env["TIMMY_NOSTR_NSEC"];
|
||
|
|
if (raw && raw.startsWith("nsec1")) {
|
||
|
|
try {
|
||
|
|
const decoded = nip19.decode(raw);
|
||
|
|
if (decoded.type !== "nsec") throw new Error("not nsec type");
|
||
|
|
const sk = decoded.data as Uint8Array;
|
||
|
|
const pubkeyHex = getPublicKey(sk);
|
||
|
|
const npub = nip19.npubEncode(pubkeyHex);
|
||
|
|
logger.info("timmy identity loaded from env", { npub });
|
||
|
|
return { sk, nsec: raw, npub, pubkeyHex };
|
||
|
|
} catch (err) {
|
||
|
|
logger.warn("TIMMY_NOSTR_NSEC is set but could not be decoded — generating ephemeral key", {
|
||
|
|
error: err instanceof Error ? err.message : String(err),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const sk = generateSecretKey();
|
||
|
|
const pubkeyHex = getPublicKey(sk);
|
||
|
|
const nsec = nip19.nsecEncode(sk);
|
||
|
|
const npub = nip19.npubEncode(pubkeyHex);
|
||
|
|
|
||
|
|
logger.warn(
|
||
|
|
"TIMMY_NOSTR_NSEC not set — generated ephemeral Nostr identity. " +
|
||
|
|
"Set this env var permanently to preserve Timmy's identity across restarts.",
|
||
|
|
{ npub },
|
||
|
|
);
|
||
|
|
|
||
|
|
return { sk, nsec, npub, pubkeyHex };
|
||
|
|
}
|
||
|
|
|
||
|
|
class TimmyIdentityService {
|
||
|
|
readonly npub: string;
|
||
|
|
readonly nsec: string;
|
||
|
|
readonly pubkeyHex: string;
|
||
|
|
private readonly _sk: Uint8Array;
|
||
|
|
|
||
|
|
constructor() {
|
||
|
|
const identity = loadOrGenerate();
|
||
|
|
this.npub = identity.npub;
|
||
|
|
this.nsec = identity.nsec;
|
||
|
|
this.pubkeyHex = identity.pubkeyHex;
|
||
|
|
this._sk = identity.sk;
|
||
|
|
}
|
||
|
|
|
||
|
|
sign(template: EventTemplate): NostrEvent {
|
||
|
|
return finalizeEvent(template, this._sk);
|
||
|
|
}
|
||
|
|
|
||
|
|
async encryptDm(recipientPubkeyHex: string, plaintext: string): Promise<string> {
|
||
|
|
return nip04.encrypt(this._sk, recipientPubkeyHex, plaintext);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export const timmyIdentityService = new TimmyIdentityService();
|