feat: NIP-24
This commit is contained in:
@ -4,7 +4,7 @@ import { unwrap, ExternalStore, unixNowMs } from "@snort/shared";
|
||||
|
||||
import { DefaultConnectTimeout } from "./const";
|
||||
import { ConnectionStats } from "./connection-stats";
|
||||
import { NostrEvent, ReqCommand, TaggedRawEvent, u256 } from "./nostr";
|
||||
import { NostrEvent, ReqCommand, TaggedNostrEvent, u256 } from "./nostr";
|
||||
import { RelayInfo } from "./relay-info";
|
||||
|
||||
export type AuthHandler = (challenge: string, relay: string) => Promise<NostrEvent | undefined>;
|
||||
@ -62,7 +62,7 @@ export class Connection extends ExternalStore<ConnectionStateSnapshot> {
|
||||
ReconnectTimer?: ReturnType<typeof setTimeout>;
|
||||
EventsCallback: Map<u256, (msg: boolean[]) => void>;
|
||||
OnConnected?: (wasReconnect: boolean) => void;
|
||||
OnEvent?: (sub: string, e: TaggedRawEvent) => void;
|
||||
OnEvent?: (sub: string, e: TaggedNostrEvent) => void;
|
||||
OnEose?: (sub: string) => void;
|
||||
OnDisconnect?: (code: number) => void;
|
||||
Auth?: AuthHandler;
|
||||
|
@ -2,7 +2,7 @@ import * as secp from "@noble/curves/secp256k1";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { getPublicKey, sha256, unixNow } from "@snort/shared";
|
||||
|
||||
import { EventKind, HexKey, NostrEvent } from ".";
|
||||
import { EventKind, HexKey, NostrEvent, NotSignedNostrEvent } from ".";
|
||||
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
|
||||
|
||||
export interface Tag {
|
||||
@ -56,7 +56,7 @@ export abstract class EventExt {
|
||||
return result;
|
||||
}
|
||||
|
||||
static createId(e: NostrEvent) {
|
||||
static createId(e: NostrEvent | NotSignedNostrEvent) {
|
||||
const payload = [0, e.pubkey, e.created_at, e.kind, e.tags, e.content];
|
||||
|
||||
const hash = sha256(JSON.stringify(payload));
|
||||
@ -136,16 +136,4 @@ export abstract class EventExt {
|
||||
ret.pubKeys = Array.from(new Set(ev.tags.filter(a => a[0] === "p").map(a => a[1])));
|
||||
return ret;
|
||||
}
|
||||
|
||||
static async decryptDm(content: string, privkey: HexKey, pubkey: HexKey) {
|
||||
const enc = new Nip4WebCryptoEncryptor();
|
||||
const key = enc.getSharedSecret(privkey, pubkey);
|
||||
return await enc.decryptData(content, key);
|
||||
}
|
||||
|
||||
static async encryptDm(content: string, privKey: HexKey, pubKey: HexKey) {
|
||||
const enc = new Nip4WebCryptoEncryptor();
|
||||
const secret = enc.getSharedSecret(privKey, pubKey);
|
||||
return await enc.encryptData(content, secret);
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,8 @@ enum EventKind {
|
||||
Reaction = 7, // NIP-25
|
||||
BadgeAward = 8, // NIP-58
|
||||
SimpleChatMessage = 9, // NIP-29
|
||||
SealedRumor = 13, // NIP-59
|
||||
ChatRumor = 14, // NIP-24
|
||||
SnortSubscriptions = 1000, // NIP-XX
|
||||
Polls = 6969, // NIP-69
|
||||
GiftWrap = 1059, // NIP-59
|
||||
|
@ -1,16 +1,18 @@
|
||||
import * as secp from "@noble/curves/secp256k1";
|
||||
import * as utils from "@noble/curves/abstract/utils";
|
||||
import { unwrap, getPublicKey } from "@snort/shared";
|
||||
import { unwrap, getPublicKey, unixNow } from "@snort/shared";
|
||||
|
||||
import {
|
||||
EventKind,
|
||||
EventSigner,
|
||||
FullRelaySettings,
|
||||
HexKey,
|
||||
Lists,
|
||||
Nip44Encryptor,
|
||||
NostrEvent,
|
||||
NotSignedNostrEvent,
|
||||
PrivateKeySigner,
|
||||
RelaySettings,
|
||||
TaggedRawEvent,
|
||||
TaggedNostrEvent,
|
||||
u256,
|
||||
UserMetadata,
|
||||
} from ".";
|
||||
@ -22,53 +24,6 @@ import { Nip7Signer } from "./impl/nip7";
|
||||
|
||||
type EventBuilderHook = (ev: EventBuilder) => EventBuilder;
|
||||
|
||||
export interface EventSigner {
|
||||
init(): Promise<void>;
|
||||
getPubKey(): Promise<string> | string;
|
||||
nip4Encrypt(content: string, key: string): Promise<string>;
|
||||
nip4Decrypt(content: string, otherKey: string): Promise<string>;
|
||||
sign(ev: NostrEvent): Promise<NostrEvent>;
|
||||
}
|
||||
|
||||
export class PrivateKeySigner implements EventSigner {
|
||||
#publicKey: string;
|
||||
#privateKey: string;
|
||||
|
||||
constructor(privateKey: string | Uint8Array) {
|
||||
if (typeof privateKey === "string") {
|
||||
this.#privateKey = privateKey;
|
||||
} else {
|
||||
this.#privateKey = utils.bytesToHex(privateKey);
|
||||
}
|
||||
this.#publicKey = getPublicKey(this.#privateKey);
|
||||
}
|
||||
|
||||
get privateKey() {
|
||||
return this.#privateKey;
|
||||
}
|
||||
|
||||
init(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getPubKey(): string {
|
||||
return this.#publicKey;
|
||||
}
|
||||
|
||||
async nip4Encrypt(content: string, key: string): Promise<string> {
|
||||
return await EventExt.encryptDm(content, this.#privateKey, key);
|
||||
}
|
||||
|
||||
async nip4Decrypt(content: string, otherKey: string): Promise<string> {
|
||||
return await EventExt.decryptDm(content, this.#privateKey, otherKey);
|
||||
}
|
||||
|
||||
sign(ev: NostrEvent): Promise<NostrEvent> {
|
||||
EventExt.sign(ev, this.#privateKey);
|
||||
return Promise.resolve(ev);
|
||||
}
|
||||
}
|
||||
|
||||
export class EventPublisher {
|
||||
#pubKey: string;
|
||||
#signer: EventSigner;
|
||||
@ -209,7 +164,7 @@ export class EventPublisher {
|
||||
/**
|
||||
* Reply to a note
|
||||
*/
|
||||
async reply(replyTo: TaggedRawEvent, msg: string, fnExtra?: EventBuilderHook) {
|
||||
async reply(replyTo: TaggedNostrEvent, msg: string, fnExtra?: EventBuilderHook) {
|
||||
const eb = this.#eb(EventKind.TextNote);
|
||||
eb.content(msg);
|
||||
|
||||
@ -298,7 +253,15 @@ export class EventPublisher {
|
||||
}
|
||||
|
||||
async decryptDm(note: NostrEvent) {
|
||||
if (note.pubkey !== this.#pubKey && !note.tags.some(a => a[1] === this.#pubKey)) {
|
||||
if (note.kind === EventKind.SealedRumor) {
|
||||
const unseal = await this.unsealRumor(note);
|
||||
return unseal.content;
|
||||
}
|
||||
if (
|
||||
note.kind === EventKind.DirectMessage &&
|
||||
note.pubkey !== this.#pubKey &&
|
||||
!note.tags.some(a => a[1] === this.#pubKey)
|
||||
) {
|
||||
throw new Error("Can't decrypt, DM does not belong to this user");
|
||||
}
|
||||
const otherPubKey = note.pubkey === this.#pubKey ? unwrap(note.tags.find(a => a[0] === "p")?.[1]) : note.pubkey;
|
||||
@ -322,21 +285,54 @@ export class EventPublisher {
|
||||
/**
|
||||
* NIP-59 Gift Wrap event with ephemeral key
|
||||
*/
|
||||
async giftWrap(inner: NostrEvent) {
|
||||
async giftWrap(inner: NostrEvent, explicitP?: string) {
|
||||
const secret = utils.bytesToHex(secp.secp256k1.utils.randomPrivateKey());
|
||||
const signer = new PrivateKeySigner(secret);
|
||||
|
||||
const pTag = findTag(inner, "p");
|
||||
const pTag = explicitP ?? findTag(inner, "p");
|
||||
if (!pTag) throw new Error("Inner event must have a p tag");
|
||||
|
||||
const eb = new EventBuilder();
|
||||
eb.pubKey(getPublicKey(secret));
|
||||
eb.pubKey(signer.getPubKey());
|
||||
eb.kind(EventKind.GiftWrap);
|
||||
eb.tag(["p", pTag]);
|
||||
|
||||
const enc = new Nip44Encryptor();
|
||||
const shared = enc.getSharedSecret(secret, pTag);
|
||||
eb.content(enc.encryptData(JSON.stringify(inner), shared));
|
||||
eb.content(await signer.nip44Encrypt(JSON.stringify(inner), pTag));
|
||||
|
||||
return await eb.buildAndSign(secret);
|
||||
}
|
||||
|
||||
async unwrapGift(gift: NostrEvent) {
|
||||
const body = await this.#signer.nip44Decrypt(gift.content, gift.pubkey);
|
||||
return JSON.parse(body) as NostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unsigned gossip message
|
||||
*/
|
||||
createUnsigned(kind: EventKind, content: string, fnHook: EventBuilderHook) {
|
||||
const eb = new EventBuilder();
|
||||
eb.pubKey(this.pubKey);
|
||||
eb.kind(kind);
|
||||
eb.content(content);
|
||||
fnHook(eb);
|
||||
return eb.build() as NotSignedNostrEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create sealed rumor
|
||||
*/
|
||||
async sealRumor(inner: NotSignedNostrEvent, toKey: string) {
|
||||
const eb = this.#eb(EventKind.SealedRumor);
|
||||
eb.content(await this.#signer.nip44Encrypt(JSON.stringify(inner), toKey));
|
||||
return await this.#sign(eb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unseal rumor
|
||||
*/
|
||||
async unsealRumor(inner: NostrEvent) {
|
||||
if (inner.kind !== EventKind.SealedRumor) throw new Error("Not a sealed rumor event");
|
||||
const body = await this.#signer.nip44Decrypt(inner.content, inner.pubkey);
|
||||
return JSON.parse(body) as NostrEvent;
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MessageEncryptor } from "index";
|
||||
import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index";
|
||||
|
||||
import { base64 } from "@scure/base";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
@ -22,26 +22,22 @@ export class Nip4WebCryptoEncryptor implements MessageEncryptor {
|
||||
key,
|
||||
data
|
||||
);
|
||||
const uData = new Uint8Array(result);
|
||||
return `${base64.encode(uData)}?iv=${base64.encode(iv)}`;
|
||||
return {
|
||||
ciphertext: new Uint8Array(result),
|
||||
nonce: iv,
|
||||
v: MessageEncryptorVersion.Nip4
|
||||
} as MessageEncryptorPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the content of the message
|
||||
*/
|
||||
async decryptData(cyphertext: string, sharedSecet: Uint8Array) {
|
||||
|
||||
async decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array) {
|
||||
const key = await this.#importKey(sharedSecet);
|
||||
const cSplit = cyphertext.split("?iv=");
|
||||
const data = base64.decode(cSplit[0]);
|
||||
const iv = base64.decode(cSplit[1]);
|
||||
|
||||
const result = await window.crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-CBC",
|
||||
iv: iv,
|
||||
iv: payload.nonce,
|
||||
},
|
||||
key,
|
||||
data
|
||||
payload.ciphertext
|
||||
);
|
||||
return new TextDecoder().decode(result);
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { MessageEncryptor } from "index";
|
||||
import { MessageEncryptor, MessageEncryptorPayload, MessageEncryptorVersion } from "index";
|
||||
|
||||
import { base64 } from "@scure/base";
|
||||
import { randomBytes } from "@noble/hashes/utils";
|
||||
@ -6,12 +6,7 @@ import { streamXOR as xchacha20 } from "@stablelib/xchacha20";
|
||||
import { secp256k1 } from "@noble/curves/secp256k1";
|
||||
import { sha256 } from "@noble/hashes/sha256";
|
||||
|
||||
export enum Nip44Version {
|
||||
Reserved = 0x00,
|
||||
XChaCha20 = 0x01,
|
||||
}
|
||||
|
||||
export class Nip44Encryptor implements MessageEncryptor {
|
||||
export class XChaCha20Encryptor implements MessageEncryptor {
|
||||
getSharedSecret(privateKey: string, publicKey: string) {
|
||||
const key = secp256k1.getSharedSecret(privateKey, "02" + publicKey);
|
||||
return sha256(key.slice(1, 33));
|
||||
@ -21,19 +16,18 @@ export class Nip44Encryptor implements MessageEncryptor {
|
||||
const nonce = randomBytes(24);
|
||||
const plaintext = new TextEncoder().encode(content);
|
||||
const ciphertext = xchacha20(sharedSecret, nonce, plaintext, plaintext);
|
||||
const ctb64 = base64.encode(Uint8Array.from(ciphertext));
|
||||
const nonceb64 = base64.encode(nonce);
|
||||
return JSON.stringify({ ciphertext: ctb64, nonce: nonceb64, v: Nip44Version.XChaCha20 });
|
||||
return {
|
||||
ciphertext: Uint8Array.from(ciphertext),
|
||||
nonce: nonce,
|
||||
v: MessageEncryptorVersion.XChaCha20,
|
||||
} as MessageEncryptorPayload;
|
||||
}
|
||||
|
||||
decryptData(cyphertext: string, sharedSecret: Uint8Array) {
|
||||
const dt = JSON.parse(cyphertext);
|
||||
if (dt.v !== 1) throw new Error("NIP44: unknown encryption version");
|
||||
decryptData(payload: MessageEncryptorPayload, sharedSecret: Uint8Array) {
|
||||
if (payload.v !== MessageEncryptorVersion.XChaCha20) throw new Error("NIP44: wrong encryption version");
|
||||
|
||||
const ciphertext = base64.decode(dt.ciphertext);
|
||||
const nonce = base64.decode(dt.nonce);
|
||||
const plaintext = xchacha20(sharedSecret, nonce, ciphertext, ciphertext);
|
||||
const text = new TextDecoder().decode(plaintext);
|
||||
return text;
|
||||
const dst = xchacha20(sharedSecret, payload.nonce, payload.ciphertext, payload.ciphertext);
|
||||
const decoded = new TextDecoder().decode(dst);
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ import { v4 as uuid } from "uuid";
|
||||
import debug from "debug";
|
||||
|
||||
import { Connection } from "../connection";
|
||||
import { EventSigner, PrivateKeySigner } from "../event-publisher";
|
||||
import { EventSigner, PrivateKeySigner } from "../signer";
|
||||
import { NostrEvent } from "../nostr";
|
||||
import { EventBuilder } from "../event-builder";
|
||||
import EventKind from "../event-kind";
|
||||
@ -138,6 +138,14 @@ export class Nip46Signer implements EventSigner {
|
||||
return await this.#rpc<string>("nip04_decrypt", [otherKey, content]);
|
||||
}
|
||||
|
||||
nip44Encrypt(content: string, key: string): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
nip44Decrypt(content: string, otherKey: string): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async sign(ev: NostrEvent) {
|
||||
const evStr = await this.#rpc<string>("sign_event", [JSON.stringify(ev)]);
|
||||
return JSON.parse(evStr);
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { WorkQueueItem, processWorkQueue, barrierQueue, unwrap } from "@snort/shared";
|
||||
import { EventSigner } from "../event-publisher";
|
||||
import { HexKey, NostrEvent } from "../nostr";
|
||||
import { EventSigner, HexKey, NostrEvent } from "..";
|
||||
|
||||
const Nip7Queue: Array<WorkQueueItem> = [];
|
||||
processWorkQueue(Nip7Queue);
|
||||
@ -51,6 +50,14 @@ export class Nip7Signer implements EventSigner {
|
||||
);
|
||||
}
|
||||
|
||||
async nip44Encrypt(content: string, key: string): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async nip44Decrypt(content: string, otherKey: string): Promise<string> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
async sign(ev: NostrEvent): Promise<NostrEvent> {
|
||||
if (!window.nostr) {
|
||||
throw new Error("Cannot use NIP-07 signer, not found!");
|
||||
|
@ -19,6 +19,7 @@ export * from "./event-builder";
|
||||
export * from "./nostr-link";
|
||||
export * from "./profile-cache";
|
||||
export * from "./zaps";
|
||||
export * from "./signer";
|
||||
|
||||
export * from "./impl/nip4";
|
||||
export * from "./impl/nip44";
|
||||
@ -52,8 +53,19 @@ export interface SystemSnapshot {
|
||||
}>;
|
||||
}
|
||||
|
||||
export const enum MessageEncryptorVersion {
|
||||
Nip4 = 0,
|
||||
XChaCha20 = 1,
|
||||
}
|
||||
|
||||
export interface MessageEncryptorPayload {
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
v: MessageEncryptorVersion
|
||||
}
|
||||
|
||||
export interface MessageEncryptor {
|
||||
getSharedSecret(privateKey: string, publicKey: string): Promise<Uint8Array> | Uint8Array;
|
||||
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<string> | string;
|
||||
decryptData(cyphertext: string, sharedSecet: Uint8Array): Promise<string> | string;
|
||||
encryptData(plaintext: string, sharedSecet: Uint8Array): Promise<MessageEncryptorPayload> | MessageEncryptorPayload;
|
||||
decryptData(payload: MessageEncryptorPayload, sharedSecet: Uint8Array): Promise<string> | string;
|
||||
}
|
||||
|
@ -46,6 +46,38 @@ export function encodeTLV(prefix: NostrPrefix, id: string, relays?: string[], ki
|
||||
return bech32.encode(prefix, bech32.toWords(new Uint8Array([...tl0, ...tl1, ...tl2, ...tl3])), 1_000);
|
||||
}
|
||||
|
||||
export function encodeTLVEntries(prefix: NostrPrefix, ...entries: Array<TLVEntry>) {
|
||||
const enc = new TextEncoder();
|
||||
const buffers: Array<number> = [];
|
||||
|
||||
for (const v of entries) {
|
||||
switch (v.type) {
|
||||
case TLVEntryType.Special: {
|
||||
const buf =
|
||||
prefix === NostrPrefix.Address ? enc.encode(v.value as string) : utils.hexToBytes(v.value as string);
|
||||
buffers.push(0, buf.length, ...buf);
|
||||
break;
|
||||
}
|
||||
case TLVEntryType.Relay: {
|
||||
const data = enc.encode(v.value as string);
|
||||
buffers.push(1, data.length, ...data);
|
||||
break;
|
||||
}
|
||||
case TLVEntryType.Author: {
|
||||
if ((v.value as string).length !== 64) throw new Error("Author must be 32 bytes");
|
||||
buffers.push(2, 32, ...utils.hexToBytes(v.value as string));
|
||||
break;
|
||||
}
|
||||
case TLVEntryType.Kind: {
|
||||
if (typeof v.value !== "number") throw new Error("Kind must be a number");
|
||||
buffers.push(3, 4, ...new Uint8Array(new Uint32Array([v.value as number]).buffer).reverse());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return bech32.encode(prefix, bech32.toWords(new Uint8Array(buffers)), 1_000);
|
||||
}
|
||||
|
||||
export function decodeTLV(str: string) {
|
||||
const decoded = bech32.decode(str, 1_000);
|
||||
const data = bech32.fromWords(decoded.words);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import debug from "debug";
|
||||
|
||||
import { unwrap, sanitizeRelayUrl, ExternalStore, FeedCache } from "@snort/shared";
|
||||
import { NostrEvent, TaggedRawEvent } from "./nostr";
|
||||
import { NostrEvent, TaggedNostrEvent } from "./nostr";
|
||||
import { AuthHandler, Connection, RelaySettings, ConnectionStateSnapshot } from "./connection";
|
||||
import { Query } from "./query";
|
||||
import { NoteStore } from "./note-collection";
|
||||
@ -147,7 +147,7 @@ export class NostrSystem extends ExternalStore<SystemSnapshot> implements System
|
||||
}
|
||||
}
|
||||
|
||||
OnEvent(sub: string, ev: TaggedRawEvent) {
|
||||
OnEvent(sub: string, ev: TaggedNostrEvent) {
|
||||
for (const [, v] of this.Queries) {
|
||||
v.onEvent(sub, ev);
|
||||
}
|
||||
|
@ -10,7 +10,7 @@ export interface NostrEvent {
|
||||
sig: string;
|
||||
}
|
||||
|
||||
export interface TaggedRawEvent extends NostrEvent {
|
||||
export interface TaggedNostrEvent extends NostrEvent {
|
||||
/**
|
||||
* A list of relays this event was seen on
|
||||
*/
|
||||
@ -85,3 +85,5 @@ export interface FullRelaySettings {
|
||||
url: string;
|
||||
settings: RelaySettings;
|
||||
}
|
||||
|
||||
export type NotSignedNostrEvent = Omit<NostrEvent, "sig">;
|
@ -1,12 +1,12 @@
|
||||
import { appendDedupe } from "@snort/shared";
|
||||
import { TaggedRawEvent, u256 } from ".";
|
||||
import { TaggedNostrEvent, u256 } from ".";
|
||||
import { findTag } from "./utils";
|
||||
|
||||
export interface StoreSnapshot<TSnapshot> {
|
||||
data: TSnapshot | undefined;
|
||||
clear: () => void;
|
||||
loading: () => boolean;
|
||||
add: (ev: Readonly<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>) => void;
|
||||
add: (ev: Readonly<TaggedNostrEvent> | Readonly<Array<TaggedNostrEvent>>) => void;
|
||||
}
|
||||
|
||||
export const EmptySnapshot = {
|
||||
@ -20,10 +20,10 @@ export const EmptySnapshot = {
|
||||
},
|
||||
} as StoreSnapshot<FlatNoteStore>;
|
||||
|
||||
export type NoteStoreSnapshotData = Readonly<Array<TaggedRawEvent>> | Readonly<TaggedRawEvent>;
|
||||
export type NoteStoreSnapshotData = Readonly<Array<TaggedNostrEvent>> | Readonly<TaggedNostrEvent>;
|
||||
export type NoteStoreHook = () => void;
|
||||
export type NoteStoreHookRelease = () => void;
|
||||
export type OnEventCallback = (e: Readonly<Array<TaggedRawEvent>>) => void;
|
||||
export type OnEventCallback = (e: Readonly<Array<TaggedNostrEvent>>) => void;
|
||||
export type OnEventCallbackRelease = () => void;
|
||||
export type OnEoseCallback = (c: string) => void;
|
||||
export type OnEoseCallbackRelease = () => void;
|
||||
@ -32,7 +32,7 @@ export type OnEoseCallbackRelease = () => void;
|
||||
* Generic note store interface
|
||||
*/
|
||||
export abstract class NoteStore {
|
||||
abstract add(ev: Readonly<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>): void;
|
||||
abstract add(ev: Readonly<TaggedNostrEvent> | Readonly<Array<TaggedNostrEvent>>): void;
|
||||
abstract clear(): void;
|
||||
|
||||
// react hooks
|
||||
@ -74,7 +74,7 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
|
||||
this.onChange([]);
|
||||
}
|
||||
|
||||
abstract add(ev: Readonly<TaggedRawEvent> | Readonly<Array<TaggedRawEvent>>): void;
|
||||
abstract add(ev: Readonly<TaggedNostrEvent> | Readonly<Array<TaggedNostrEvent>>): void;
|
||||
abstract clear(): void;
|
||||
|
||||
hook(cb: NoteStoreHook): NoteStoreHookRelease {
|
||||
@ -106,7 +106,7 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
|
||||
|
||||
protected abstract takeSnapshot(): TSnapshot | undefined;
|
||||
|
||||
protected onChange(changes: Readonly<Array<TaggedRawEvent>>): void {
|
||||
protected onChange(changes: Readonly<Array<TaggedNostrEvent>>): void {
|
||||
this.#needsSnapshot = true;
|
||||
if (!this.#nextNotifyTimer) {
|
||||
this.#nextNotifyTimer = setTimeout(() => {
|
||||
@ -137,13 +137,13 @@ export abstract class HookedNoteStore<TSnapshot extends NoteStoreSnapshotData> i
|
||||
/**
|
||||
* A simple flat container of events with no duplicates
|
||||
*/
|
||||
export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent>>> {
|
||||
#events: Array<TaggedRawEvent> = [];
|
||||
export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEvent>>> {
|
||||
#events: Array<TaggedNostrEvent> = [];
|
||||
#ids: Set<u256> = new Set();
|
||||
|
||||
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
|
||||
add(ev: TaggedNostrEvent | Array<TaggedNostrEvent>) {
|
||||
ev = Array.isArray(ev) ? ev : [ev];
|
||||
const changes: Array<TaggedRawEvent> = [];
|
||||
const changes: Array<TaggedNostrEvent> = [];
|
||||
ev.forEach(a => {
|
||||
if (!this.#ids.has(a.id)) {
|
||||
this.#events.push(a);
|
||||
@ -176,18 +176,18 @@ export class FlatNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent
|
||||
/**
|
||||
* A note store that holds a single replaceable event for a given user defined key generator function
|
||||
*/
|
||||
export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<TaggedRawEvent>>> {
|
||||
#keyFn: (ev: TaggedRawEvent) => string;
|
||||
#events: Map<string, TaggedRawEvent> = new Map();
|
||||
export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<TaggedNostrEvent>>> {
|
||||
#keyFn: (ev: TaggedNostrEvent) => string;
|
||||
#events: Map<string, TaggedNostrEvent> = new Map();
|
||||
|
||||
constructor(fn: (ev: TaggedRawEvent) => string) {
|
||||
constructor(fn: (ev: TaggedNostrEvent) => string) {
|
||||
super();
|
||||
this.#keyFn = fn;
|
||||
}
|
||||
|
||||
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
|
||||
add(ev: TaggedNostrEvent | Array<TaggedNostrEvent>) {
|
||||
ev = Array.isArray(ev) ? ev : [ev];
|
||||
const changes: Array<TaggedRawEvent> = [];
|
||||
const changes: Array<TaggedNostrEvent> = [];
|
||||
ev.forEach(a => {
|
||||
const keyOnEvent = this.#keyFn(a);
|
||||
const existingCreated = this.#events.get(keyOnEvent)?.created_at ?? 0;
|
||||
@ -214,12 +214,12 @@ export class KeyedReplaceableNoteStore extends HookedNoteStore<Readonly<Array<Ta
|
||||
/**
|
||||
* A note store that holds a single replaceable event
|
||||
*/
|
||||
export class ReplaceableNoteStore extends HookedNoteStore<Readonly<TaggedRawEvent>> {
|
||||
#event?: TaggedRawEvent;
|
||||
export class ReplaceableNoteStore extends HookedNoteStore<Readonly<TaggedNostrEvent>> {
|
||||
#event?: TaggedNostrEvent;
|
||||
|
||||
add(ev: TaggedRawEvent | Array<TaggedRawEvent>) {
|
||||
add(ev: TaggedNostrEvent | Array<TaggedNostrEvent>) {
|
||||
ev = Array.isArray(ev) ? ev : [ev];
|
||||
const changes: Array<TaggedRawEvent> = [];
|
||||
const changes: Array<TaggedNostrEvent> = [];
|
||||
ev.forEach(a => {
|
||||
const existingCreated = this.#event?.created_at ?? 0;
|
||||
if (a.created_at > existingCreated) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import debug from "debug";
|
||||
import { unixNowMs, FeedCache } from "@snort/shared";
|
||||
import { EventKind, HexKey, SystemInterface, TaggedRawEvent, NoteCollection, RequestBuilder } from ".";
|
||||
import { EventKind, HexKey, SystemInterface, TaggedNostrEvent, NoteCollection, RequestBuilder } from ".";
|
||||
import { ProfileCacheExpire } from "./const";
|
||||
import { mapEventToProfile, MetadataCache } from "./cache";
|
||||
|
||||
@ -57,7 +57,7 @@ export class ProfileLoaderService {
|
||||
}
|
||||
}
|
||||
|
||||
async onProfileEvent(e: Readonly<TaggedRawEvent>) {
|
||||
async onProfileEvent(e: Readonly<TaggedNostrEvent>) {
|
||||
const profile = mapEventToProfile(e);
|
||||
if (profile) {
|
||||
await this.#cache.update(profile);
|
||||
@ -101,7 +101,7 @@ export class ProfileLoaderService {
|
||||
await this.onProfileEvent(pe);
|
||||
}
|
||||
});
|
||||
const results = await new Promise<Readonly<Array<TaggedRawEvent>>>(resolve => {
|
||||
const results = await new Promise<Readonly<Array<TaggedNostrEvent>>>(resolve => {
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
|
||||
const release = feed.hook(() => {
|
||||
if (!feed.loading) {
|
||||
|
@ -2,7 +2,7 @@ import { v4 as uuid } from "uuid";
|
||||
import debug from "debug";
|
||||
import { unixNowMs, unwrap } from "@snort/shared";
|
||||
|
||||
import { Connection, ReqFilter, Nips, TaggedRawEvent } from ".";
|
||||
import { Connection, ReqFilter, Nips, TaggedNostrEvent } from ".";
|
||||
import { NoteStore } from "./note-collection";
|
||||
import { flatMerge } from "./request-merger";
|
||||
import { BuiltRawReqFilter } from "./request-builder";
|
||||
@ -176,7 +176,7 @@ export class Query implements QueryBase {
|
||||
return this.#feed;
|
||||
}
|
||||
|
||||
onEvent(sub: string, e: TaggedRawEvent) {
|
||||
onEvent(sub: string, e: TaggedNostrEvent) {
|
||||
for (const t of this.#tracing) {
|
||||
if (t.id === sub) {
|
||||
this.feed.add(e);
|
||||
|
108
packages/system/src/signer.ts
Normal file
108
packages/system/src/signer.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { bytesToHex } from "@noble/curves/abstract/utils";
|
||||
import { getPublicKey } from "@snort/shared";
|
||||
import { EventExt } from "./event-ext";
|
||||
import { Nip4WebCryptoEncryptor } from "./impl/nip4";
|
||||
import { XChaCha20Encryptor } from "./impl/nip44";
|
||||
import { MessageEncryptorPayload, MessageEncryptorVersion } from "./index";
|
||||
import { NostrEvent } from "./nostr";
|
||||
import { base64 } from "@scure/base";
|
||||
|
||||
export interface EventSigner {
|
||||
init(): Promise<void>;
|
||||
getPubKey(): Promise<string> | string;
|
||||
nip4Encrypt(content: string, key: string): Promise<string>;
|
||||
nip4Decrypt(content: string, otherKey: string): Promise<string>;
|
||||
nip44Encrypt(content: string, key: string): Promise<string>;
|
||||
nip44Decrypt(content: string, otherKey: string): Promise<string>;
|
||||
sign(ev: NostrEvent): Promise<NostrEvent>;
|
||||
}
|
||||
|
||||
export class PrivateKeySigner implements EventSigner {
|
||||
#publicKey: string;
|
||||
#privateKey: string;
|
||||
|
||||
constructor(privateKey: string | Uint8Array) {
|
||||
if (typeof privateKey === "string") {
|
||||
this.#privateKey = privateKey;
|
||||
} else {
|
||||
this.#privateKey = bytesToHex(privateKey);
|
||||
}
|
||||
this.#publicKey = getPublicKey(this.#privateKey);
|
||||
}
|
||||
|
||||
get privateKey() {
|
||||
return this.#privateKey;
|
||||
}
|
||||
|
||||
init(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
getPubKey(): string {
|
||||
return this.#publicKey;
|
||||
}
|
||||
|
||||
async nip4Encrypt(content: string, key: string) {
|
||||
const enc = new Nip4WebCryptoEncryptor();
|
||||
const secret = enc.getSharedSecret(this.privateKey, key);
|
||||
const data = await enc.encryptData(content, secret);
|
||||
return `${base64.encode(data.ciphertext)}?iv=${base64.encode(data.nonce)}`;
|
||||
}
|
||||
|
||||
async nip4Decrypt(content: string, otherKey: string) {
|
||||
const enc = new Nip4WebCryptoEncryptor();
|
||||
const secret = enc.getSharedSecret(this.privateKey, otherKey);
|
||||
const [ciphertext, iv] = content.split("?iv=");
|
||||
return await enc.decryptData(
|
||||
{
|
||||
ciphertext: base64.decode(ciphertext),
|
||||
nonce: base64.decode(iv),
|
||||
v: MessageEncryptorVersion.Nip4,
|
||||
},
|
||||
secret
|
||||
);
|
||||
}
|
||||
|
||||
async nip44Encrypt(content: string, key: string) {
|
||||
const enc = new XChaCha20Encryptor();
|
||||
const shared = enc.getSharedSecret(this.#privateKey, key);
|
||||
const data = enc.encryptData(content, shared);
|
||||
return this.#encodePayload(data);
|
||||
}
|
||||
|
||||
async nip44Decrypt(content: string, otherKey: string) {
|
||||
const payload = this.#decodePayload(content);
|
||||
if (payload.v !== MessageEncryptorVersion.XChaCha20) throw new Error("Invalid payload version");
|
||||
|
||||
const enc = new XChaCha20Encryptor();
|
||||
const shared = enc.getSharedSecret(this.#privateKey, otherKey);
|
||||
return enc.decryptData(payload, shared);
|
||||
}
|
||||
|
||||
#decodePayload(p: string) {
|
||||
if (p.startsWith("{") && p.endsWith("}")) {
|
||||
const pj = JSON.parse(p) as { v: number; nonce: string; ciphertext: string };
|
||||
return {
|
||||
v: pj.v,
|
||||
nonce: base64.decode(pj.nonce),
|
||||
ciphertext: base64.decode(pj.ciphertext),
|
||||
} as MessageEncryptorPayload;
|
||||
} else {
|
||||
const buf = base64.decode(p);
|
||||
return {
|
||||
v: buf[0],
|
||||
nonce: buf.subarray(1, 25),
|
||||
ciphertext: buf.subarray(25),
|
||||
} as MessageEncryptorPayload;
|
||||
}
|
||||
}
|
||||
|
||||
#encodePayload(p: MessageEncryptorPayload) {
|
||||
return base64.encode(new Uint8Array([p.v, ...p.nonce, ...p.ciphertext]));
|
||||
}
|
||||
|
||||
sign(ev: NostrEvent): Promise<NostrEvent> {
|
||||
EventExt.sign(ev, this.#privateKey);
|
||||
return Promise.resolve(ev);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user