Release v1.1.3 - Add NIP-49 ncryptsec export feature

- Add ncryptsec page for exporting encrypted private keys (NIP-49)
- Implement password-based encryption using scrypt + XChaCha20-Poly1305
- Display QR code for easy mobile scanning of encrypted key
- Add click-to-copy functionality for ncryptsec string
- Add privkeyToNcryptsec() method to NostrHelper using nostr-tools nip49

Files modified:
- projects/common/src/lib/helpers/nostr-helper.ts
- projects/chrome/src/app/app.routes.ts
- projects/chrome/src/app/components/edit-identity/keys/keys.component.*
- projects/chrome/src/app/components/edit-identity/ncryptsec/ (new)
- projects/firefox/src/app/app.routes.ts
- projects/firefox/src/app/components/edit-identity/keys/keys.component.*
- projects/firefox/src/app/components/edit-identity/ncryptsec/ (new)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-27 19:39:47 +02:00
parent c11887dfa8
commit 2074c409f0
20 changed files with 555 additions and 7 deletions

View File

@@ -17,6 +17,7 @@ import { NewIdentityComponent } from './components/new-identity/new-identity.com
import { EditIdentityComponent } from './components/edit-identity/edit-identity.component';
import { HomeComponent as EditIdentityHomeComponent } from './components/edit-identity/home/home.component';
import { KeysComponent as EditIdentityKeysComponent } from './components/edit-identity/keys/keys.component';
import { NcryptsecComponent as EditIdentityNcryptsecComponent } from './components/edit-identity/ncryptsec/ncryptsec.component';
import { PermissionsComponent as EditIdentityPermissionsComponent } from './components/edit-identity/permissions/permissions.component';
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
import { VaultImportComponent } from './components/vault-import/vault-import.component';
@@ -112,6 +113,10 @@ export const routes: Routes = [
path: 'keys',
component: EditIdentityKeysComponent,
},
{
path: 'ncryptsec',
component: EditIdentityNcryptsecComponent,
},
{
path: 'permissions',
component: EditIdentityPermissionsComponent,

View File

@@ -136,6 +136,12 @@
</button>
</div>
</div>
<span class="sam-mt-2">Encrypted Key (NIP-49)</span>
<button class="btn btn-primary sam-mt-h" (click)="navigateToNcryptsec()">
Get ncryptsec
</button>
}
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -1,5 +1,5 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
@@ -29,6 +29,7 @@ export class KeysComponent extends NavComponent implements OnInit {
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
readonly #router = inject(Router);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
@@ -51,6 +52,11 @@ export class KeysComponent extends NavComponent implements OnInit {
}
}
navigateToNcryptsec() {
if (!this.identity) return;
this.#router.navigateByUrl(`/edit-identity/${this.identity.id}/ncryptsec`);
}
async #initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()

View File

@@ -0,0 +1,75 @@
<div class="header-pane">
<lib-icon-button
icon="chevron-left"
(click)="navigateBack()"
></lib-icon-button>
<span>Get ncryptsec</span>
</div>
<p class="description">
Enter a password to encrypt your private key. The resulting ncryptsec can be
used to securely backup or transfer your key.
</p>
<!-- PASSWORD INPUT -->
<div class="password-section">
<label for="ncryptsecPasswordInput">Password</label>
<div class="input-group sam-mt-h">
<input
id="ncryptsecPasswordInput"
type="password"
class="form-control"
placeholder="Enter encryption password"
[(ngModel)]="ncryptsecPassword"
[disabled]="isGenerating"
(keyup.enter)="generateNcryptsec()"
/>
</div>
</div>
<button
class="btn btn-primary generate-btn"
type="button"
(click)="generateNcryptsec()"
[disabled]="!ncryptsecPassword || isGenerating"
>
@if (isGenerating) {
<span class="spinner-border spinner-border-sm" role="status"></span>
Generating...
} @else {
Generate ncryptsec
}
</button>
<!-- NCRYPTSEC OUTPUT -->
@if (ncryptsec) {
<div class="result-section">
<!-- QR Code -->
<div class="qr-container">
<img [src]="ncryptsecQr" alt="ncryptsec QR code" class="qr-code" />
</div>
<!-- ncryptsec text -->
<div class="ncryptsec-container">
<input
type="text"
class="form-control ncryptsec-output"
[value]="ncryptsec"
readonly
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
title="Click to copy"
/>
<button
class="btn btn-outline-secondary"
type="button"
(click)="copyToClipboard(ncryptsec); toast.show('Copied to clipboard')"
>
<i class="bi bi-copy"></i> Copy
</button>
</div>
<p class="hint">Tap the text or button to copy to clipboard</p>
</div>
}
<lib-toast #toast [bottom]="16"></lib-toast>

View File

@@ -0,0 +1,89 @@
:host {
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-left: var(--size);
padding-right: var(--size);
.header-pane {
display: flex;
flex-direction: row;
column-gap: var(--size-h);
align-items: center;
padding-bottom: var(--size);
background-color: var(--background);
position: sticky;
top: 0;
}
}
.description {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: var(--size);
}
.password-section {
margin-bottom: var(--size);
label {
font-weight: 500;
margin-bottom: var(--size-q);
}
}
.generate-btn {
width: 100%;
margin-bottom: var(--size);
}
.result-section {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--size);
margin-top: var(--size);
}
.qr-container {
background: white;
padding: var(--size);
border-radius: 8px;
display: flex;
justify-content: center;
align-items: center;
}
.qr-code {
width: 250px;
height: 250px;
}
.ncryptsec-container {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--size-h);
.ncryptsec-output {
font-family: monospace;
font-size: 0.75rem;
word-break: break-all;
cursor: pointer;
&:hover {
background-color: var(--bs-gray-100);
}
}
button {
width: 100%;
}
}
.hint {
color: var(--text-muted);
font-size: 0.8rem;
text-align: center;
}

View File

@@ -0,0 +1,84 @@
import { Component, inject, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import {
IconButtonComponent,
NavComponent,
NostrHelper,
StorageService,
ToastComponent,
} from '@common';
import { FormsModule } from '@angular/forms';
import * as QRCode from 'qrcode';
@Component({
selector: 'app-ncryptsec',
imports: [IconButtonComponent, FormsModule, ToastComponent],
templateUrl: './ncryptsec.component.html',
styleUrl: './ncryptsec.component.scss',
})
export class NcryptsecComponent extends NavComponent implements OnInit {
privkeyHex = '';
ncryptsecPassword = '';
ncryptsec = '';
ncryptsecQr = '';
isGenerating = false;
readonly #activatedRoute = inject(ActivatedRoute);
readonly #storage = inject(StorageService);
ngOnInit(): void {
const identityId = this.#activatedRoute.parent?.snapshot.params['id'];
if (!identityId) {
return;
}
this.#initialize(identityId);
}
async generateNcryptsec() {
if (!this.privkeyHex || !this.ncryptsecPassword) {
return;
}
this.isGenerating = true;
this.ncryptsec = '';
this.ncryptsecQr = '';
try {
this.ncryptsec = await NostrHelper.privkeyToNcryptsec(
this.privkeyHex,
this.ncryptsecPassword
);
// Generate QR code
this.ncryptsecQr = await QRCode.toDataURL(this.ncryptsec, {
width: 250,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
} catch (error) {
console.error('Failed to generate ncryptsec:', error);
} finally {
this.isGenerating = false;
}
}
copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
#initialize(identityId: string) {
const identity = this.#storage
.getBrowserSessionHandler()
.browserSessionData?.identities.find((x) => x.id === identityId);
if (!identity) {
return;
}
this.privkeyHex = identity.privkey;
}
}