diff --git a/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.css b/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.css new file mode 100644 index 00000000..1be77b53 --- /dev/null +++ b/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.css @@ -0,0 +1,3 @@ +.signing-content { + height: 120px; +} \ No newline at end of file diff --git a/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.html b/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.html new file mode 100644 index 00000000..946bcb86 --- /dev/null +++ b/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.html @@ -0,0 +1,9 @@ +
+ {{ "Action.AttestContentDescription" | translate }}: +

+ + {{ "Action.Wallet" | translate }}: {{walletManager.activeWallet?.name}}
+ {{ "Action.Account" | translate }}: {{walletManager.activeAccount?.name}}
+ {{ "Action.DerivationPath" | translate }}: {{networkService.getDerivationPathForAccount(walletManager.activeAccount)}}

+ +
diff --git a/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.ts b/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.ts new file mode 100644 index 00000000..807bd85a --- /dev/null +++ b/angular/src/app/action/nostr-nip76-root/nostr-nip76-root.component.ts @@ -0,0 +1,75 @@ +import { Component, ChangeDetectorRef, ApplicationRef, NgZone, OnInit } from '@angular/core'; +import { CryptoService, UIState, NetworksService, CommunicationService, AppManager, WalletManager } from '../../services'; +import { Router } from '@angular/router'; +import { ActionService } from '../../services/action.service'; +import { TranslateService } from '@ngx-translate/core'; +import { nostrPrivateChannelAccountName } from '../../../shared/handlers/nostr-nip76-wallet-handler'; +const { v4: uuidv4 } = require('uuid'); + +@Component({ + selector: 'app-nostr-nip76-root', + templateUrl: './nostr-nip76-root.component.html', + styleUrls: ['./nostr-nip76-root.component.css'] +}) +export class ActionNostrNip76RootComponent implements OnInit { + content?: string; + parameters?: any; + expiryDate: Date; + callback: string; + result: string; + success?: boolean; + mnemonic: string; + + constructor( + public uiState: UIState, + private crypto: CryptoService, + private router: Router, + private app: ApplicationRef, + private actionService: ActionService, + private ngZone: NgZone, + private communication: CommunicationService, + public networkService: NetworksService, + public walletManager: WalletManager, + private manager: AppManager, + private cd: ChangeDetectorRef, + public translate: TranslateService) { + + this.actionService.status.title = 'Use an HDK Index'; + this.actionService.status.description = + `Uses a separate Hierarchical Deterministic Key on the Account used for signing, indexing and encrypting Nostr Events.`; + this.actionService.consentType = 'regular'; + this.actionService.permissionLevel = 'account'; + } + + ngOnDestroy(): void { + + } + + async ngOnInit() { + const accountTranslate = await this.translate.get('Account.Account').toPromise(); + this.uiState.title = accountTranslate + ': ' + this.walletManager.activeAccount?.name; + this.mnemonic = this.crypto.generateMnemonic(); + // this.createPrivateChannelAccount(); + } + + async createPrivateChannelAccount() { + let account = this.walletManager.activeWallet.accounts.find(x => x.name === nostrPrivateChannelAccountName); + if (!account) { + account = { + identifier: uuidv4(), + type: 'identity', + mode: 'normal', + singleAddress: true, + networkType: 'NOSTR', + name: nostrPrivateChannelAccountName, + index: 1776, + network: 1237, + purpose: 44, + purposeAddress: 340, + icon: 'account_circle', + }; + await this.walletManager.addAccount(account, this.walletManager.activeWallet); + } + this.actionService.accountId = account.identifier; + } +} diff --git a/angular/src/app/app-routing.module.ts b/angular/src/app/app-routing.module.ts index 8548b894..0cc851f7 100644 --- a/angular/src/app/app-routing.module.ts +++ b/angular/src/app/app-routing.module.ts @@ -65,6 +65,7 @@ import { ActionNostrDecryptComponent } from './action/nostr.decrypt/nostr.decryp import { ActionTransactionSendComponent } from './action/transaction.send/transaction.send.component'; import { ActionWalletUnlockComponent } from './action/wallet.unlock/wallet.unlock.component'; import { ActionSwapsSendComponent } from './action/swaps.send/swaps.send.component'; +import { ActionNostrNip76RootComponent } from './action/nostr-nip76-root/nostr-nip76-root.component'; const routes: Routes = [ { @@ -477,6 +478,41 @@ const routes: Routes = [ data: LoadingResolverService, }, }, + { + path: 'nostr.nip76.index', + component: ActionNostrNip76RootComponent, + resolve: { + data: LoadingResolverService, + }, + }, + { + path: 'nostr.nip76.event.create', + component: ActionNostrNip76RootComponent, + resolve: { + data: LoadingResolverService, + }, + }, + { + path: 'nostr.nip76.event.delete', + component: ActionNostrNip76RootComponent, + resolve: { + data: LoadingResolverService, + }, + }, + { + path: 'nostr.nip76.invite.read', + component: ActionNostrNip76RootComponent, + resolve: { + data: LoadingResolverService, + }, + }, + { + path: 'nostr.nip76.invite.create', + component: ActionNostrNip76RootComponent, + resolve: { + data: LoadingResolverService, + }, + }, { path: 'transaction.send', component: ActionTransactionSendComponent, diff --git a/angular/src/app/app.module.ts b/angular/src/app/app.module.ts index ef818bb3..89d9d931 100644 --- a/angular/src/app/app.module.ts +++ b/angular/src/app/app.module.ts @@ -131,7 +131,7 @@ import { ActionTransactionSendComponent } from './action/transaction.send/transa import { SendConfirmationDialog } from './action/transaction.send/send-confirmation-dialog/send-confirmation-dialog'; import { ActionWalletUnlockComponent } from './action/wallet.unlock/wallet.unlock.component'; import { ActionSwapsSendComponent } from './action/swaps.send/swaps.send.component'; - +import { ActionNostrNip76RootComponent } from './action/nostr-nip76-root/nostr-nip76-root.component'; // required for AOT compilation export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http); @@ -216,6 +216,7 @@ export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { SendConfirmationDialog, ActionWalletUnlockComponent, ActionSwapsSendComponent, + ActionNostrNip76RootComponent ], imports: [ BrowserModule, diff --git a/angular/src/shared/handlers/index.ts b/angular/src/shared/handlers/index.ts index ef73c2fa..b83aac79 100644 --- a/angular/src/shared/handlers/index.ts +++ b/angular/src/shared/handlers/index.ts @@ -20,6 +20,7 @@ import { SendTransactionHandler } from './send-transaction-handler'; import { AtomicSwapsKeyHandler } from './atomic-swap-key-handler'; import { AtomicSwapsSecretHandler } from './atomic-swap-secret-handler'; import { AtomicSwapsSendHandler } from './atomic-swap-send-handler'; +import { NostrNip76WalletHandler } from './nostr-nip76-wallet-handler'; // TODO: Make this more generic where the handlers are registered as form of factory. export class Handlers { @@ -53,6 +54,14 @@ export class Handlers { return new NostrEncryptHandler(backgroundManager); case 'nostr.decrypt': return new NostrDecryptHandler(backgroundManager); + case 'nostr.nip76.index': + case 'nostr.nip76.event.create': + case 'nostr.nip76.event.delete': + case 'nostr.nip76.invite.read': + case 'nostr.nip76.invite.create': + const handler = new NostrNip76WalletHandler(backgroundManager); + handler.action = [action]; + return handler; case 'transaction.send': return new SendTransactionHandler(backgroundManager); case 'atomicswaps.key': diff --git a/angular/src/shared/handlers/nostr-nip76-wallet-handler.ts b/angular/src/shared/handlers/nostr-nip76-wallet-handler.ts new file mode 100644 index 00000000..87159090 --- /dev/null +++ b/angular/src/shared/handlers/nostr-nip76-wallet-handler.ts @@ -0,0 +1,143 @@ +import { sha512 } from '@noble/hashes/sha512'; +import { bytesToHex } from '@noble/hashes/utils'; +import { HDKey } from '@scure/bip32'; +import { + ContentDocument, getReducedKey, HDKey as Nip76HDKey, HDKIndex, HDKIndexDTO, HDKIndexType, nip19Extension, + NostrEventDocument, SequentialKeysetDTO, Versions, Nip76ProviderIndexArgs, walletRsvpDocumentsOffset +} from 'animiq-nip76-tools'; +import { getPublicKey } from 'nostr-tools'; +import { BackgroundManager } from '../background-manager'; +import { SigningUtilities } from '../identity/signing-utilities'; +import { ActionPrepareResult, ActionResponse, Permission } from '../interfaces'; +import { ActionHandler, ActionState } from './action-handler'; + +export const nostrPrivateChannelAccountName = 'Nostr Private Channels'; + +export class NostrNip76WalletHandler implements ActionHandler { + action = ['nostr.nip76.index']; + utility = new SigningUtilities(); + + constructor(private backgroundManager: BackgroundManager) { } + + async prepare(state: ActionState): Promise { + return { + content: [], + consent: true, + }; + } + + async getKey(permission: Permission, keyId: string): Promise { + const { network, node } = await this.backgroundManager.getKey(permission.walletId, permission.accountId, keyId); + return node as HDKey; + } + + async getRootKeyInfo(permission: Permission, keyPage = 0) + : Promise<{ rootKey: Nip76HDKey, wordset: Uint32Array, documentsIndex: HDKIndex }> { + + const key0 = await this.getKey(permission, `1776'/0'`); + const rootKey = new Nip76HDKey({ privateKey: key0.privateKey, chainCode: key0.chainCode, version: Versions.nip76API1 }); + + const wordsetKey = await this.getKey(permission, `1776'/1'`); + const wordsetHash = sha512(wordsetKey.privateKey); + const wordset = new Uint32Array((wordsetHash).buffer); + + const key1 = getReducedKey({ root: rootKey, wordset: wordset.slice(0, 4) }); + const key2 = getReducedKey({ root: rootKey, wordset: wordset.slice(4, 8) }); + const documentsIndex = new HDKIndex(HDKIndexType.Sequential | HDKIndexType.Private, key1, key2, wordset.slice(8)); + + documentsIndex.getSequentialKeyset(0, keyPage); + documentsIndex.getSequentialKeyset(walletRsvpDocumentsOffset, keyPage); + + return { rootKey, wordset, documentsIndex }; + } + + async execute(state: ActionState, permission: Permission): Promise { + const profileKey = await this.getKey(permission, permission.keyId); + const key = getPublicKey(profileKey.privateKey as any); + + switch (state.message.request.method) { + case 'nostr.nip76.index': { + const indexArgs: Nip76ProviderIndexArgs = state.message.request.params[1]; + const { rootKey, wordset, documentsIndex } = await this.getRootKeyInfo(permission, indexArgs.keyPage); + if (indexArgs.privateIndexId === null) { + documentsIndex.signingParent.wipePrivateData(); + documentsIndex.encryptParent.wipePrivateData(); + const response = { hdkIndex: documentsIndex.toJSON() }; + return { key, response }; + } else { + const keyset = documentsIndex.getDocumentKeyset(indexArgs.privateIndexId, bytesToHex(profileKey.privateKey)); + const hdkIndex = new HDKIndex(HDKIndexType.Sequential | HDKIndexType.Private, keyset.signingKey, keyset.encryptKey); + hdkIndex.getSequentialKeyset(0, indexArgs.keyPage); + hdkIndex.signingParent.wipePrivateData(); + hdkIndex.encryptParent.wipePrivateData(); + const response = { hdkIndex: hdkIndex.toJSON() }; + return { key, response }; + } + } + case 'nostr.nip76.event.create': { + const serializedContent = state.message.request.params[2]; + const kind = parseInt(serializedContent.match(/\d+/)![0]); + const doc = HDKIndex.getContentDocument(kind); + doc.deserialize(serializedContent); + doc.content.pubkey = key; + doc.docIndex = state.message.request.params[3]; + + let hdkIndex: HDKIndex; + const indexArgs: Nip76ProviderIndexArgs = state.message.request.params[1]; + if (indexArgs.publicIndex) { + hdkIndex = HDKIndex.fromJSON(indexArgs.publicIndex as HDKIndexDTO); + } else { + const { rootKey, wordset, documentsIndex } = await this.getRootKeyInfo(permission); + if (indexArgs.privateIndexId === null) { + hdkIndex = documentsIndex; + } else { + const keyset = documentsIndex.getDocumentKeyset(indexArgs.privateIndexId, bytesToHex(profileKey.privateKey)); + hdkIndex = new HDKIndex(HDKIndexType.Sequential | HDKIndexType.Private, keyset.signingKey, keyset.encryptKey); + } + } + const event = await hdkIndex.createEvent(doc, bytesToHex(profileKey.privateKey)); + const response = { event }; + return { key, response }; + } + case 'nostr.nip76.event.delete': { + const doc = new ContentDocument(); + doc.nostrEvent = { id: state.message.request.params[2] } as NostrEventDocument; + doc.docIndex = state.message.request.params[3]; + + let hdkIndex: HDKIndex; + const indexArgs: Nip76ProviderIndexArgs = state.message.request.params[1]; + if (indexArgs.publicIndex) { + hdkIndex = HDKIndex.fromJSON(indexArgs.publicIndex as HDKIndexDTO); + } else { + const { rootKey, wordset, documentsIndex } = await this.getRootKeyInfo(permission); + if (indexArgs.privateIndexId === null) { + hdkIndex = documentsIndex; + } else { + const keyset = documentsIndex.getDocumentKeyset(indexArgs.privateIndexId, bytesToHex(profileKey.privateKey)); + hdkIndex = new HDKIndex(HDKIndexType.Sequential | HDKIndexType.Private, keyset.signingKey, keyset.encryptKey); + } + } + const event = await hdkIndex.createDeleteEvent(doc, bytesToHex(profileKey.privateKey)); + const response = { event }; + return { key, response }; + } + case 'nostr.nip76.invite.read': { + const channelPointer = state.message.request.params[0]; + const p = await nip19Extension.decode(channelPointer, bytesToHex(profileKey.privateKey)); + const pointer = nip19Extension.pointerToDTO(p.data as nip19Extension.PrivateChannelPointer); + const response = { pointer }; + return { key, response }; + } + case 'nostr.nip76.invite.create': { + const pointer: nip19Extension.PrivateChannelPointerDTO = state.message.request.params[0]; + const forPubkey: string = state.message.request.params[1]; + const channelPointer = nip19Extension.pointerFromDTO(pointer); + const invitation = await nip19Extension.nprivateChannelEncode(channelPointer, bytesToHex(profileKey.privateKey), forPubkey); + const response = { invitation }; + return { key, response }; + } + default: + throw new Error(`method '${state.message.request.method}' not recognized.`); + }; + } +} diff --git a/extension/src/provider.ts b/extension/src/provider.ts index 4616dc9d..523db892 100644 --- a/extension/src/provider.ts +++ b/extension/src/provider.ts @@ -1,6 +1,7 @@ import { ActionMessage, ActionRequest, ActionResponse, EventEmitter, Listener } from '../../angular/src/shared'; import { Injector, RequestArguments, Web5RequestProvider } from '@blockcore/web5-injector'; - +import { ContentDocument, ContentTemplate, HDKIndex, nip19Extension, NostrEventDocument, + INostrNip76Provider, Nip76ProviderIndexArgs, Nip76ProviderIndexArgDefaults } from 'animiq-nip76-tools'; export class BlockcoreRequestProvider implements Web5RequestProvider { name = 'Blockcore'; #requests = {}; @@ -120,7 +121,7 @@ export class BlockcoreRequestProvider implements Web5RequestProvider { } class NostrProvider { - constructor(private provider: BlockcoreRequestProvider) {} + constructor(private provider: BlockcoreRequestProvider) { } /** Nostr NIP-07 function: https://github.com/nostr-protocol/nips/blob/master/07.md */ async getPublicKey(): Promise { @@ -154,10 +155,11 @@ class NostrProvider { } nip04 = new NostrNip04(this.provider); + nip76 = new NostrNip76(this.provider); } export class NostrNip04 { - constructor(private provider: BlockcoreRequestProvider) {} + constructor(private provider: BlockcoreRequestProvider) { } async encrypt(peer: string, plaintext: string): Promise { const result = (await this.provider.request({ @@ -178,6 +180,67 @@ export class NostrNip04 { } } +export class NostrNip76 implements INostrNip76Provider { + constructor(private provider: BlockcoreRequestProvider) { } + + async getIndex(privateIndexId?: number, keyPage?: number): Promise { + const indexArgs = Nip76ProviderIndexArgDefaults; + if(privateIndexId) indexArgs.privateIndexId = privateIndexId; + if(keyPage) indexArgs.keyPage = keyPage; + const result = (await this.provider.request({ + method: 'nostr.nip76.index', + params: [{}, indexArgs], + })) as any; + const hdkIndex = HDKIndex.fromJSON(result.response.hdkIndex); + return hdkIndex; + } + + async createEvent(doc: ContentDocument): Promise { + const indexArgs: Nip76ProviderIndexArgs = { + publicIndex: doc.dkxParent.isPrivate ? undefined : doc.dkxParent.toJSON(), + privateIndexId: doc.dkxParent.isPrivate ? doc.dkxParent.parentDocument?.docIndex : undefined + }; + const result = (await this.provider.request({ + method: 'nostr.nip76.event.create', + params: [{}, indexArgs, doc.serialize(), doc.docIndex], + })) as any; + + return result.response.event; + } + + async createDeleteEvent(doc: ContentDocument): Promise { + const indexArgs: Nip76ProviderIndexArgs = { + publicIndex: doc.dkxParent.isPrivate ? undefined : doc.dkxParent.toJSON(), + privateIndexId: doc.dkxParent.isPrivate ? doc.dkxParent.parentDocument?.docIndex : undefined + }; + const result = (await this.provider.request({ + method: 'nostr.nip76.event.delete', + params: [{}, indexArgs, doc.nostrEvent.id, doc.docIndex], + })) as any; + + return result.response.event; + } + + async readInvitation(channelPointer: string): Promise { + const result = (await this.provider.request({ + method: 'nostr.nip76.invite.read', + params: [channelPointer], + })) as any; + const pointer = nip19Extension.pointerFromDTO(result.response.pointer); + return pointer; + } + + async createInvitation(pointer: nip19Extension.PrivateChannelPointer, forPubkey: string): Promise { + const result = (await this.provider.request({ + method: 'nostr.nip76.invite.create', + params: [nip19Extension.pointerToDTO(pointer), forPubkey], + })) as any; + + return result.response.invitation; + } + +} + const provider = new BlockcoreRequestProvider(); Injector.register(provider); diff --git a/package-lock.json b/package-lock.json index 09ea35b8..328546d1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@scure/bip39": "^1.1.1", "@tbd54566975/dwn-sdk-js": "^0.0.25", "@types/qs": "^6.9.7", + "animiq-nip76-tools": "file:../../animiq-nip76-tools/dist", "async-mutex": "^0.4.0", "axios": "0.27.2", "axios-retry": "^3.3.1", @@ -4851,6 +4852,11 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "node_modules/animiq-nip76-tools": { + "version": "1.0.5", + "resolved": "file:../../animiq-nip76-tools/dist", + "license": "MIT" + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -22582,6 +22588,9 @@ "dev": true, "requires": {} }, + "animiq-nip76-tools": { + "version": "1.0.5" + }, "ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", diff --git a/package.json b/package.json index 3da9ed88..71e63d92 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "js-base64": "3.7.3", "ngx-logger": "^5.0.11", "nostr-tools": "1.7.4", + "animiq-nip76-tools": "file:../../animiq-nip76-tools/dist", "qrcode": "^1.5.1", "qs": "^6.11.0", "rxjs": "7.5.6",