Release v1.0.0 - Major security upgrade with Argon2id encryption
- Upgrade vault encryption from PBKDF2 (1000 iterations) to Argon2id
(256MB memory, 8 iterations, 4 threads, ~3 second derivation)
- Add automatic migration from v1 to v2 vault format on unlock
- Add WebAssembly CSP support for hash-wasm Argon2id implementation
- Add NIP-42 relay authentication support for auth-required relays
- Add profile edit feature with pencil icon on identity page
- Add direct NIP-05 validation (removes NDK dependency for validation)
- Add deriving modal with progress timer during key derivation
- Add client tag "plebeian-signer" to profile events
- Fix modal colors (dark theme for visibility)
- Fix NIP-05 badge styling to include check/error indicator
- Add release zip packages for Chrome and Firefox
New files:
- projects/common/src/lib/helpers/argon2-crypto.ts
- projects/common/src/lib/helpers/websocket-auth.ts
- projects/common/src/lib/helpers/nip05-validator.ts
- projects/common/src/lib/components/deriving-modal/
- projects/{chrome,firefox}/src/app/components/profile-edit/
- releases/plebeian-signer-{chrome,firefox}-v1.0.0.zip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
15
package-lock.json
generated
15
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "plebian-signer",
|
"name": "plebeian-signer",
|
||||||
"version": "0.0.4",
|
"version": "v0.0.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "plebian-signer",
|
"name": "plebeian-signer",
|
||||||
"version": "0.0.4",
|
"version": "v0.0.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^19.0.0",
|
"@angular/animations": "^19.0.0",
|
||||||
"@angular/common": "^19.0.0",
|
"@angular/common": "^19.0.0",
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"hash-wasm": "^4.11.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
@@ -12320,6 +12321,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash-wasm": {
|
||||||
|
"version": "4.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.11.0.tgz",
|
||||||
|
"integrity": "sha512-HVusNXlVqHe0fzIzdQOGolnFN6mX/fqcrSAOcTBXdvzrXVHwTz11vXeKRmkR5gTuwVpvHZEIyKoePDvuAR+XwQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "plebeian-signer",
|
"name": "plebeian-signer",
|
||||||
"version": "v0.0.9",
|
"version": "v1.0.0",
|
||||||
"custom": {
|
"custom": {
|
||||||
"chrome": {
|
"chrome": {
|
||||||
"version": "v0.0.9"
|
"version": "v1.0.0"
|
||||||
},
|
},
|
||||||
"firefox": {
|
"firefox": {
|
||||||
"version": "v0.0.9"
|
"version": "v1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
|
"hash-wasm": "^4.11.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
|
|||||||
3
projects/chrome/public/edit.svg
Normal file
3
projects/chrome/public/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 364 B |
@@ -2,13 +2,16 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
"name": "Plebeian Signer - Nostr Identity Manager & Signer",
|
||||||
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
"description": "Manage and switch between multiple identities while interacting with Nostr apps",
|
||||||
"version": "0.0.9",
|
"version": "1.0.0",
|
||||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"windows",
|
"windows",
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "index.html",
|
"default_popup": "index.html",
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PermissionsComponent as EditIdentityPermissionsComponent } from './comp
|
|||||||
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
import { RelaysComponent as EditIdentityRelaysComponent } from './components/edit-identity/relays/relays.component';
|
||||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||||
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
||||||
|
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -75,6 +76,10 @@ export const routes: Routes = [
|
|||||||
path: 'whitelisted-apps',
|
path: 'whitelisted-apps',
|
||||||
component: WhitelistedAppsComponent,
|
component: WhitelistedAppsComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile-edit',
|
||||||
|
component: ProfileEditComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'edit-identity/:id',
|
path: 'edit-identity/:id',
|
||||||
component: EditIdentityComponent,
|
component: EditIdentityComponent,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||||
|
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||||
<div class="sam-text-header">
|
<div class="sam-text-header">
|
||||||
<span>You</span>
|
<span>You</span>
|
||||||
|
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||||
|
<img src="edit.svg" alt="Edit" class="edit-icon" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="identity-container">
|
<div class="identity-container">
|
||||||
@@ -22,7 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display name (primary, large) -->
|
<!-- Display name (primary, large) -->
|
||||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
|
||||||
<div class="name-badge-container" (click)="onClickShowDetails()">
|
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||||
<span class="display-name">
|
<span class="display-name">
|
||||||
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||||
|
|||||||
@@ -3,6 +3,34 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
.sam-text-header {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--size);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.identity-container {
|
.identity-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -123,6 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nip05-row {
|
.nip05-row {
|
||||||
|
@extend %text-badge;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -134,7 +163,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nip05-badge {
|
.nip05-badge {
|
||||||
@extend %text-badge;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
StorageService,
|
StorageService,
|
||||||
ToastComponent,
|
ToastComponent,
|
||||||
VisualNip05Pipe,
|
VisualNip05Pipe,
|
||||||
|
validateNip05,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import NDK from '@nostr-dev-kit/ndk';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-identity',
|
selector: 'app-identity',
|
||||||
@@ -67,6 +67,13 @@ export class IdentityComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClickEditProfile() {
|
||||||
|
if (!this.selectedIdentity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#router.navigateByUrl('/profile-edit');
|
||||||
|
}
|
||||||
|
|
||||||
async #loadData() {
|
async #loadData() {
|
||||||
try {
|
try {
|
||||||
const selectedIdentityId =
|
const selectedIdentityId =
|
||||||
@@ -125,28 +132,18 @@ export class IdentityComponent implements OnInit {
|
|||||||
try {
|
try {
|
||||||
this.validating = true;
|
this.validating = true;
|
||||||
|
|
||||||
// Get relays for validation
|
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
|
||||||
const relays =
|
const result = await validateNip05(nip05, pubkey);
|
||||||
this.#storage
|
this.nip05isValidated = result.valid;
|
||||||
.getBrowserSessionHandler()
|
|
||||||
.browserSessionData?.relays.filter(
|
|
||||||
(x) => x.identityId === this.selectedIdentity?.id
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
if (!result.valid) {
|
||||||
|
console.log('NIP-05 validation failed:', result.error);
|
||||||
if (relevantRelays.length > 0) {
|
|
||||||
const ndk = new NDK({
|
|
||||||
explicitRelayUrls: relevantRelays,
|
|
||||||
});
|
|
||||||
await ndk.connect();
|
|
||||||
const user = ndk.getUser({ pubkey });
|
|
||||||
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validating = false;
|
this.validating = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('NIP-05 validation failed:', error);
|
console.error('NIP-05 validation failed:', error);
|
||||||
|
this.nip05isValidated = false;
|
||||||
this.validating = false;
|
this.validating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<div class="sam-text-header">
|
||||||
|
<span>Edit Profile</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(loading) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<span class="sam-text-muted">Loading profile...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_name">Display Name</label>
|
||||||
|
<input
|
||||||
|
id="display_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.display_name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="picture">Avatar URL</label>
|
||||||
|
<input
|
||||||
|
id="picture"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/avatar.jpg"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.picture"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="banner">Banner URL</label>
|
||||||
|
<input
|
||||||
|
id="banner"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/banner.jpg"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.banner"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input
|
||||||
|
id="website"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://yourwebsite.com"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.website"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="about">About</label>
|
||||||
|
<textarea
|
||||||
|
id="about"
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
class="form-control"
|
||||||
|
rows="4"
|
||||||
|
[(ngModel)]="profile.about"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nip05">NIP-05 Identifier</label>
|
||||||
|
<input
|
||||||
|
id="nip05"
|
||||||
|
type="text"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.nip05"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lud16">Lightning Address (LUD-16)</label>
|
||||||
|
<input
|
||||||
|
id="lud16"
|
||||||
|
type="text"
|
||||||
|
placeholder="you@getalby.com"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.lud16"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lnurl">LNURL</label>
|
||||||
|
<input
|
||||||
|
id="lnurl"
|
||||||
|
type="text"
|
||||||
|
placeholder="lnurl1..."
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.lnurl"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sam-footer-grid-2">
|
||||||
|
<button type="button" class="btn btn-secondary" (click)="onClickCancel()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
[disabled]="saving"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
(click)="onClickSave()"
|
||||||
|
>
|
||||||
|
@if(saving) {
|
||||||
|
Saving...
|
||||||
|
} @else {
|
||||||
|
Save
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(alertMessage) {
|
||||||
|
<div class="alert-container">
|
||||||
|
<div class="alert alert-danger sam-flex-row gap" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<span>{{ alertMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<lib-toast #toast></lib-toast>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-left: var(--size);
|
||||||
|
padding-right: var(--size);
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: var(--size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--background-light);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 70px;
|
||||||
|
left: var(--size);
|
||||||
|
right: var(--size);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import {
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
NavComponent,
|
||||||
|
NostrHelper,
|
||||||
|
ProfileMetadataService,
|
||||||
|
RelayListService,
|
||||||
|
StorageService,
|
||||||
|
ToastComponent,
|
||||||
|
publishToRelaysWithAuth,
|
||||||
|
} from '@common';
|
||||||
|
import { SimplePool } from 'nostr-tools/pool';
|
||||||
|
import { finalizeEvent } from 'nostr-tools';
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
interface ProfileFormData {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
picture: string;
|
||||||
|
banner: string;
|
||||||
|
website: string;
|
||||||
|
about: string;
|
||||||
|
nip05: string;
|
||||||
|
lud16: string;
|
||||||
|
lnurl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile-edit',
|
||||||
|
templateUrl: './profile-edit.component.html',
|
||||||
|
styleUrl: './profile-edit.component.scss',
|
||||||
|
imports: [FormsModule, ToastComponent],
|
||||||
|
})
|
||||||
|
export class ProfileEditComponent extends NavComponent implements OnInit {
|
||||||
|
readonly #storage = inject(StorageService);
|
||||||
|
readonly #router = inject(Router);
|
||||||
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||||
|
readonly #relayList = inject(RelayListService);
|
||||||
|
|
||||||
|
profile: ProfileFormData = {
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
picture: '',
|
||||||
|
banner: '',
|
||||||
|
website: '',
|
||||||
|
about: '',
|
||||||
|
nip05: '',
|
||||||
|
lud16: '',
|
||||||
|
lnurl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store original event content to preserve extra fields
|
||||||
|
#originalContent: Record<string, unknown> = {};
|
||||||
|
#originalTags: string[][] = [];
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
saving = false;
|
||||||
|
alertMessage: string | undefined;
|
||||||
|
#privkey: string | undefined;
|
||||||
|
#pubkey: string | undefined;
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
await this.#loadProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadProfile() {
|
||||||
|
try {
|
||||||
|
const selectedIdentityId =
|
||||||
|
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||||
|
?.selectedIdentityId ?? null;
|
||||||
|
|
||||||
|
const identity = this.#storage
|
||||||
|
.getBrowserSessionHandler()
|
||||||
|
.browserSessionData?.identities.find(
|
||||||
|
(x) => x.id === selectedIdentityId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#privkey = identity.privkey;
|
||||||
|
this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
await this.#profileMetadata.initialize();
|
||||||
|
|
||||||
|
// Try to get cached profile first
|
||||||
|
const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
|
||||||
|
if (cachedProfile) {
|
||||||
|
this.profile = {
|
||||||
|
name: cachedProfile.name || '',
|
||||||
|
display_name: cachedProfile.display_name || cachedProfile.displayName || '',
|
||||||
|
picture: cachedProfile.picture || '',
|
||||||
|
banner: cachedProfile.banner || '',
|
||||||
|
website: cachedProfile.website || '',
|
||||||
|
about: cachedProfile.about || '',
|
||||||
|
nip05: cachedProfile.nip05 || '',
|
||||||
|
lud16: cachedProfile.lud16 || '',
|
||||||
|
lnurl: cachedProfile.lud06 || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the actual kind 0 event to get original content and tags
|
||||||
|
await this.#fetchOriginalEvent();
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profile:', error);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #fetchOriginalEvent() {
|
||||||
|
if (!this.#pubkey) return;
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
try {
|
||||||
|
const events = await this.#queryWithTimeout(
|
||||||
|
pool,
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
[{ kinds: [0], authors: [this.#pubkey] }],
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
// Get the most recent event
|
||||||
|
const latestEvent = events.reduce((latest, event) =>
|
||||||
|
event.created_at > latest.created_at ? event : latest
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store original tags (excluding the ones we'll update)
|
||||||
|
this.#originalTags = latestEvent.tags.filter(
|
||||||
|
(tag: string[]) =>
|
||||||
|
tag[0] !== 'name' &&
|
||||||
|
tag[0] !== 'display_name' &&
|
||||||
|
tag[0] !== 'picture' &&
|
||||||
|
tag[0] !== 'banner' &&
|
||||||
|
tag[0] !== 'website' &&
|
||||||
|
tag[0] !== 'about' &&
|
||||||
|
tag[0] !== 'nip05' &&
|
||||||
|
tag[0] !== 'lud16' &&
|
||||||
|
tag[0] !== 'client'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse and store original content
|
||||||
|
try {
|
||||||
|
this.#originalContent = JSON.parse(latestEvent.content);
|
||||||
|
|
||||||
|
// Update form with values from event content
|
||||||
|
this.profile = {
|
||||||
|
name: (this.#originalContent['name'] as string) || '',
|
||||||
|
display_name:
|
||||||
|
(this.#originalContent['display_name'] as string) ||
|
||||||
|
(this.#originalContent['displayName'] as string) ||
|
||||||
|
'',
|
||||||
|
picture: (this.#originalContent['picture'] as string) || '',
|
||||||
|
banner: (this.#originalContent['banner'] as string) || '',
|
||||||
|
website: (this.#originalContent['website'] as string) || '',
|
||||||
|
about: (this.#originalContent['about'] as string) || '',
|
||||||
|
nip05: (this.#originalContent['nip05'] as string) || '',
|
||||||
|
lud16: (this.#originalContent['lud16'] as string) || '',
|
||||||
|
lnurl: (this.#originalContent['lnurl'] as string) || '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to parse profile content');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pool.close(FALLBACK_PROFILE_RELAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const events: any[] = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const sub = pool.subscribeMany(relays, filters, {
|
||||||
|
onevent(event) {
|
||||||
|
events.push(event);
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
sub.close();
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClickSave() {
|
||||||
|
if (this.saving || !this.#privkey || !this.#pubkey) return;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.alertMessage = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build the content JSON, preserving extra fields
|
||||||
|
const content: Record<string, unknown> = { ...this.#originalContent };
|
||||||
|
|
||||||
|
// Update with form values
|
||||||
|
content['name'] = this.profile.name;
|
||||||
|
content['display_name'] = this.profile.display_name;
|
||||||
|
content['displayName'] = this.profile.display_name; // Some clients use this
|
||||||
|
content['picture'] = this.profile.picture;
|
||||||
|
content['banner'] = this.profile.banner;
|
||||||
|
content['website'] = this.profile.website;
|
||||||
|
content['about'] = this.profile.about;
|
||||||
|
content['nip05'] = this.profile.nip05;
|
||||||
|
content['lud16'] = this.profile.lud16;
|
||||||
|
if (this.profile.lnurl) {
|
||||||
|
content['lnurl'] = this.profile.lnurl;
|
||||||
|
}
|
||||||
|
content['pubkey'] = this.#pubkey;
|
||||||
|
|
||||||
|
// Build tags array, preserving extra tags
|
||||||
|
const tags: string[][] = [...this.#originalTags];
|
||||||
|
|
||||||
|
// Add standard tags
|
||||||
|
if (this.profile.name) tags.push(['name', this.profile.name]);
|
||||||
|
if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
|
||||||
|
if (this.profile.picture) tags.push(['picture', this.profile.picture]);
|
||||||
|
if (this.profile.banner) tags.push(['banner', this.profile.banner]);
|
||||||
|
if (this.profile.website) tags.push(['website', this.profile.website]);
|
||||||
|
if (this.profile.about) tags.push(['about', this.profile.about]);
|
||||||
|
if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
|
||||||
|
if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
|
||||||
|
|
||||||
|
// Add alt tag if not present
|
||||||
|
if (!tags.some(t => t[0] === 'alt')) {
|
||||||
|
tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add client tag
|
||||||
|
tags.push(['client', 'plebeian-signer']);
|
||||||
|
|
||||||
|
// Create the unsigned event
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: 0,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
const privkeyBytes = hexToBytes(this.#privkey);
|
||||||
|
const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
|
||||||
|
|
||||||
|
// Get write relays from NIP-65 or use fallback
|
||||||
|
await this.#relayList.initialize();
|
||||||
|
const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
|
||||||
|
let relayUrls: string[];
|
||||||
|
|
||||||
|
if (writeRelays.length > 0) {
|
||||||
|
// Filter to write relays only
|
||||||
|
relayUrls = writeRelays
|
||||||
|
.filter(r => r.write)
|
||||||
|
.map(r => r.url);
|
||||||
|
|
||||||
|
// If no write relays found, use all relays
|
||||||
|
if (relayUrls.length === 0) {
|
||||||
|
relayUrls = writeRelays.map(r => r.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use fallback relays
|
||||||
|
relayUrls = FALLBACK_PROFILE_RELAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to relays with NIP-42 authentication support
|
||||||
|
const results = await publishToRelaysWithAuth(
|
||||||
|
relayUrls,
|
||||||
|
signedEvent,
|
||||||
|
this.#privkey
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count successes
|
||||||
|
const successes = results.filter(r => r.success);
|
||||||
|
const failures = results.filter(r => !r.success);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successes.length === 0) {
|
||||||
|
throw new Error('Failed to publish to any relay');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Profile published to ${successes.length}/${results.length} relays`);
|
||||||
|
|
||||||
|
// Clear cached profile and refetch
|
||||||
|
await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
|
||||||
|
await this.#profileMetadata.fetchProfile(this.#pubkey);
|
||||||
|
|
||||||
|
// Navigate back to identity page
|
||||||
|
this.#router.navigateByUrl('/home/identity');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save profile:', error);
|
||||||
|
this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.alertMessage = undefined;
|
||||||
|
}, 4500);
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickCancel() {
|
||||||
|
this.#router.navigateByUrl('/home/identity');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||||
|
|
||||||
<div class="sam-text-header">
|
<div class="sam-text-header">
|
||||||
<span>Plebeian Signer</span>
|
<span>Plebeian Signer</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, ViewChild } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NavComponent, StorageService } from '@common';
|
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-new',
|
selector: 'app-new',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, DerivingModalComponent],
|
||||||
templateUrl: './new.component.html',
|
templateUrl: './new.component.html',
|
||||||
styleUrl: './new.component.scss',
|
styleUrl: './new.component.scss',
|
||||||
})
|
})
|
||||||
export class NewComponent extends NavComponent {
|
export class NewComponent extends NavComponent {
|
||||||
|
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||||
|
|
||||||
password = '';
|
password = '';
|
||||||
|
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
@@ -28,7 +30,15 @@ export class NewComponent extends NavComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show deriving modal during key derivation (~3-6 seconds)
|
||||||
|
this.derivingModal.show('Creating secure vault');
|
||||||
|
try {
|
||||||
await this.#storage.createNewVault(this.password);
|
await this.#storage.createNewVault(this.password);
|
||||||
|
this.derivingModal.hide();
|
||||||
this.#router.navigateByUrl('/home/identities');
|
this.#router.navigateByUrl('/home/identities');
|
||||||
|
} catch (error) {
|
||||||
|
this.derivingModal.hide();
|
||||||
|
console.error('Failed to create vault:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||||
|
|
||||||
<div class="sam-text-header">
|
<div class="sam-text-header">
|
||||||
<span class="brand">Plebeian Signer</span>
|
<span class="brand">Plebeian Signer</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
ConfirmComponent,
|
ConfirmComponent,
|
||||||
|
DerivingModalComponent,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
ProfileMetadataService,
|
ProfileMetadataService,
|
||||||
StartupService,
|
StartupService,
|
||||||
@@ -14,10 +15,11 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
|
|||||||
selector: 'app-vault-login',
|
selector: 'app-vault-login',
|
||||||
templateUrl: './vault-login.component.html',
|
templateUrl: './vault-login.component.html',
|
||||||
styleUrl: './vault-login.component.scss',
|
styleUrl: './vault-login.component.scss',
|
||||||
imports: [FormsModule, ConfirmComponent],
|
imports: [FormsModule, ConfirmComponent, DerivingModalComponent],
|
||||||
})
|
})
|
||||||
export class VaultLoginComponent implements AfterViewInit {
|
export class VaultLoginComponent implements AfterViewInit {
|
||||||
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
|
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||||
|
|
||||||
loginPassword = '';
|
loginPassword = '';
|
||||||
showInvalidPasswordAlert = false;
|
showInvalidPasswordAlert = false;
|
||||||
@@ -40,24 +42,38 @@ export class VaultLoginComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginVault() {
|
async loginVault() {
|
||||||
|
console.log('[login] loginVault called');
|
||||||
if (!this.loginPassword) {
|
if (!this.loginPassword) {
|
||||||
|
console.log('[login] No password, returning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[login] Showing deriving modal');
|
||||||
|
// Show deriving modal during key derivation (~3-6 seconds)
|
||||||
|
this.derivingModal.show('Unlocking vault');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[login] Calling unlockVault...');
|
||||||
await this.#storage.unlockVault(this.loginPassword);
|
await this.#storage.unlockVault(this.loginPassword);
|
||||||
|
console.log('[login] unlockVault succeeded!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[login] unlockVault FAILED:', error);
|
||||||
|
this.derivingModal.hide();
|
||||||
|
this.showInvalidPasswordAlert = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.showInvalidPasswordAlert = false;
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock succeeded - hide modal and navigate
|
||||||
|
console.log('[login] Hiding modal and navigating');
|
||||||
|
this.derivingModal.hide();
|
||||||
|
|
||||||
// Fetch profile metadata for all identities in the background
|
// Fetch profile metadata for all identities in the background
|
||||||
this.#fetchAllProfiles();
|
this.#fetchAllProfiles();
|
||||||
|
|
||||||
this.#router.navigateByUrl('/home/identity');
|
this.#router.navigateByUrl('/home/identity');
|
||||||
} catch (error) {
|
|
||||||
this.showInvalidPasswordAlert = true;
|
|
||||||
console.log(error);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.showInvalidPasswordAlert = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
import { Event, EventTemplate } from 'nostr-tools';
|
import { Event, EventTemplate } from 'nostr-tools';
|
||||||
import { Nip07Method } from '@common';
|
import { Nip07Method } from '@common';
|
||||||
|
|
||||||
|
// Extend Window interface for NIP-07
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||||
|
|
||||||
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable
|
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable
|
||||||
|
|||||||
@@ -101,3 +101,30 @@ button {
|
|||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap modal overrides - always use dark theme for modals
|
||||||
|
.modal-content {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-color: #3d3d3d;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom-color: #3d3d3d;
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top-color: #3d3d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
@if (visible) {
|
||||||
|
<div class="deriving-overlay">
|
||||||
|
<div class="deriving-modal">
|
||||||
|
<div class="deriving-spinner"></div>
|
||||||
|
<h3>{{ message }}</h3>
|
||||||
|
<div class="deriving-timer">{{ elapsed.toFixed(1) }}s</div>
|
||||||
|
<p class="deriving-note">This may take 3-6 seconds for security</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
// Modal always uses dark theme for visibility over any content
|
||||||
|
.deriving-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-modal {
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
min-width: 280px;
|
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid #3d3d3d;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 1rem 0 0.5rem;
|
||||||
|
color: #fafafa;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-timer {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #ff3eb5;
|
||||||
|
font-family: monospace;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-note {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
color: #a1a1a1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deriving-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 4px solid #3d3d3d;
|
||||||
|
border-top-color: #ff3eb5;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
} from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-deriving-modal',
|
||||||
|
templateUrl: './deriving-modal.component.html',
|
||||||
|
styleUrl: './deriving-modal.component.scss',
|
||||||
|
})
|
||||||
|
export class DerivingModalComponent implements OnDestroy {
|
||||||
|
visible = false;
|
||||||
|
elapsed = 0;
|
||||||
|
message = 'Deriving encryption key';
|
||||||
|
|
||||||
|
#startTime: number | null = null;
|
||||||
|
#animationFrame: number | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the deriving modal and start the timer
|
||||||
|
* @param message Optional custom message
|
||||||
|
*/
|
||||||
|
show(message?: string): void {
|
||||||
|
if (message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
this.visible = true;
|
||||||
|
this.elapsed = 0;
|
||||||
|
this.#startTime = performance.now();
|
||||||
|
this.#updateTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide the modal and stop the timer
|
||||||
|
*/
|
||||||
|
hide(): void {
|
||||||
|
this.visible = false;
|
||||||
|
this.#stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy(): void {
|
||||||
|
this.#stopTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateTimer(): void {
|
||||||
|
if (this.#startTime !== null) {
|
||||||
|
this.elapsed = (performance.now() - this.#startTime) / 1000;
|
||||||
|
this.#animationFrame = requestAnimationFrame(() => this.#updateTimer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#stopTimer(): void {
|
||||||
|
this.#startTime = null;
|
||||||
|
if (this.#animationFrame !== null) {
|
||||||
|
cancelAnimationFrame(this.#animationFrame);
|
||||||
|
this.#animationFrame = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
150
projects/common/src/lib/helpers/argon2-crypto.ts
Normal file
150
projects/common/src/lib/helpers/argon2-crypto.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Secure vault encryption/decryption using Argon2id + AES-GCM
|
||||||
|
*
|
||||||
|
* - Argon2id key derivation with ~3 second computation time
|
||||||
|
* - AES-256-GCM authenticated encryption
|
||||||
|
* - Random 32-byte salt per vault
|
||||||
|
* - Random 12-byte IV per encryption
|
||||||
|
*
|
||||||
|
* Note: Uses main thread for Argon2id (via WebAssembly) because Web Workers
|
||||||
|
* in browser extensions cannot load external scripts due to CSP restrictions.
|
||||||
|
* The deriving modal provides user feedback during the ~3 second derivation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { argon2id } from 'hash-wasm';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
|
||||||
|
// Argon2id parameters tuned for ~3 second derivation on typical hardware
|
||||||
|
const ARGON2_CONFIG = {
|
||||||
|
parallelism: 4, // 4 threads
|
||||||
|
iterations: 8, // Time cost
|
||||||
|
memorySize: 262144, // 256 MB memory
|
||||||
|
hashLength: 32, // 256-bit key for AES-256
|
||||||
|
outputType: 'binary' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive an encryption key from password using Argon2id
|
||||||
|
* @param password - User's password
|
||||||
|
* @param salt - Random 32-byte salt
|
||||||
|
* @returns 32-byte derived key
|
||||||
|
*/
|
||||||
|
export async function deriveKeyArgon2(
|
||||||
|
password: string,
|
||||||
|
salt: Uint8Array
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
// Use hash-wasm's argon2id (WebAssembly-based, runs on main thread)
|
||||||
|
// This blocks the UI for ~3 seconds, which is why we show a modal
|
||||||
|
const result = await argon2id({
|
||||||
|
password: password,
|
||||||
|
salt: salt,
|
||||||
|
...ARGON2_CONFIG,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random salt for Argon2id
|
||||||
|
* @returns Base64 encoded 32-byte salt
|
||||||
|
*/
|
||||||
|
export function generateSalt(): string {
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||||||
|
return Buffer.from(salt).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random IV for AES-GCM
|
||||||
|
* @returns Base64 encoded 12-byte IV
|
||||||
|
*/
|
||||||
|
export function generateIV(): string {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
return Buffer.from(iv).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt data using Argon2id-derived key + AES-256-GCM
|
||||||
|
* @param plaintext - Data to encrypt
|
||||||
|
* @param password - User's password
|
||||||
|
* @param saltBase64 - Base64 encoded 32-byte salt
|
||||||
|
* @param ivBase64 - Base64 encoded 12-byte IV
|
||||||
|
* @returns Base64 encoded ciphertext
|
||||||
|
*/
|
||||||
|
export async function encryptWithArgon2(
|
||||||
|
plaintext: string,
|
||||||
|
password: string,
|
||||||
|
saltBase64: string,
|
||||||
|
ivBase64: string
|
||||||
|
): Promise<string> {
|
||||||
|
const salt = Buffer.from(saltBase64, 'base64');
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
|
||||||
|
// Derive key using Argon2id (~3 seconds, in worker)
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, salt);
|
||||||
|
|
||||||
|
// Import key for AES-GCM
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encrypt the data
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv },
|
||||||
|
key,
|
||||||
|
encoder.encode(plaintext)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Buffer.from(encrypted).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt data using Argon2id-derived key + AES-256-GCM
|
||||||
|
* @param ciphertextBase64 - Base64 encoded ciphertext
|
||||||
|
* @param password - User's password
|
||||||
|
* @param saltBase64 - Base64 encoded 32-byte salt
|
||||||
|
* @param ivBase64 - Base64 encoded 12-byte IV
|
||||||
|
* @returns Decrypted plaintext
|
||||||
|
* @throws Error if password is wrong or data is corrupted
|
||||||
|
*/
|
||||||
|
export async function decryptWithArgon2(
|
||||||
|
ciphertextBase64: string,
|
||||||
|
password: string,
|
||||||
|
saltBase64: string,
|
||||||
|
ivBase64: string
|
||||||
|
): Promise<string> {
|
||||||
|
const salt = Buffer.from(saltBase64, 'base64');
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const ciphertext = Buffer.from(ciphertextBase64, 'base64');
|
||||||
|
|
||||||
|
// Derive key using Argon2id (~3 seconds, in worker)
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, salt);
|
||||||
|
|
||||||
|
// Import key for AES-GCM
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
let decrypted;
|
||||||
|
try {
|
||||||
|
decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: iv },
|
||||||
|
key,
|
||||||
|
ciphertext
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Decryption failed - invalid password or corrupted data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
127
projects/common/src/lib/helpers/nip05-validator.ts
Normal file
127
projects/common/src/lib/helpers/nip05-validator.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* NIP-05 Verification Helper
|
||||||
|
*
|
||||||
|
* Directly validates NIP-05 identifiers by fetching the .well-known/nostr.json
|
||||||
|
* file and comparing the pubkey.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface Nip05ValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
pubkey?: string;
|
||||||
|
relays?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a NIP-05 identifier into its components
|
||||||
|
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev" or "_@mleku.dev")
|
||||||
|
* @returns Object with name and domain, or null if invalid
|
||||||
|
*/
|
||||||
|
export function parseNip05(nip05: string): { name: string; domain: string } | null {
|
||||||
|
if (!nip05 || typeof nip05 !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = nip05.toLowerCase().trim().split('@');
|
||||||
|
if (parts.length !== 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [name, domain] = parts;
|
||||||
|
if (!name || !domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic domain validation
|
||||||
|
if (!domain.includes('.') || domain.includes('/')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a NIP-05 identifier against a pubkey
|
||||||
|
*
|
||||||
|
* @param nip05 - The NIP-05 identifier (e.g., "me@mleku.dev")
|
||||||
|
* @param expectedPubkey - The expected pubkey in hex format
|
||||||
|
* @param timeoutMs - Fetch timeout in milliseconds
|
||||||
|
* @returns Validation result with status and any discovered relays
|
||||||
|
*/
|
||||||
|
export async function validateNip05(
|
||||||
|
nip05: string,
|
||||||
|
expectedPubkey: string,
|
||||||
|
timeoutMs = 10000
|
||||||
|
): Promise<Nip05ValidationResult> {
|
||||||
|
const parsed = parseNip05(nip05);
|
||||||
|
if (!parsed) {
|
||||||
|
return { valid: false, error: 'Invalid NIP-05 format' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, domain } = parsed;
|
||||||
|
const url = `https://${domain}/.well-known/nostr.json?name=${encodeURIComponent(name)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Check if the names object exists and contains the requested name
|
||||||
|
if (!data.names || typeof data.names !== 'object') {
|
||||||
|
return { valid: false, error: 'Invalid nostr.json structure: missing names' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIP-05 names are case-insensitive
|
||||||
|
const pubkeyFromJson = data.names[name] || data.names[name.toLowerCase()];
|
||||||
|
|
||||||
|
if (!pubkeyFromJson) {
|
||||||
|
return { valid: false, error: `Name "${name}" not found in nostr.json` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare pubkeys (case-insensitive hex comparison)
|
||||||
|
const normalizedExpected = expectedPubkey.toLowerCase();
|
||||||
|
const normalizedFound = pubkeyFromJson.toLowerCase();
|
||||||
|
const valid = normalizedExpected === normalizedFound;
|
||||||
|
|
||||||
|
// Extract relays if present
|
||||||
|
let relays: string[] | undefined;
|
||||||
|
if (data.relays && typeof data.relays === 'object') {
|
||||||
|
const relayList = data.relays[pubkeyFromJson] || data.relays[normalizedFound];
|
||||||
|
if (Array.isArray(relayList)) {
|
||||||
|
relays = relayList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid,
|
||||||
|
pubkey: pubkeyFromJson,
|
||||||
|
relays,
|
||||||
|
error: valid ? undefined : 'Pubkey mismatch',
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
return { valid: false, error: 'Request timeout' };
|
||||||
|
}
|
||||||
|
return { valid: false, error: error.message };
|
||||||
|
}
|
||||||
|
return { valid: false, error: 'Unknown error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
324
projects/common/src/lib/helpers/websocket-auth.ts
Normal file
324
projects/common/src/lib/helpers/websocket-auth.ts
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
/**
|
||||||
|
* NIP-42 Relay Authentication
|
||||||
|
*
|
||||||
|
* Handles WebSocket connections to relays that require authentication.
|
||||||
|
* When a relay sends an AUTH challenge, this module signs the challenge
|
||||||
|
* and authenticates before proceeding with event publishing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { finalizeEvent, getPublicKey } from 'nostr-tools';
|
||||||
|
|
||||||
|
export interface AuthenticatedRelayConnection {
|
||||||
|
ws: WebSocket;
|
||||||
|
url: string;
|
||||||
|
authenticated: boolean;
|
||||||
|
pubkey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublishResult {
|
||||||
|
relay: string;
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a NIP-42 authentication event (kind 22242)
|
||||||
|
*/
|
||||||
|
function createAuthEvent(
|
||||||
|
relayUrl: string,
|
||||||
|
challenge: string,
|
||||||
|
privateKeyHex: string
|
||||||
|
): ReturnType<typeof finalizeEvent> {
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: 22242,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['relay', relayUrl],
|
||||||
|
['challenge', challenge],
|
||||||
|
],
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert hex private key to Uint8Array
|
||||||
|
const privkeyBytes = hexToBytes(privateKeyHex);
|
||||||
|
return finalizeEvent(unsignedEvent, privkeyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to a relay with NIP-42 authentication support
|
||||||
|
*
|
||||||
|
* @param relayUrl - The relay WebSocket URL (e.g., wss://relay.example.com)
|
||||||
|
* @param privateKeyHex - The private key in hex format for signing
|
||||||
|
* @param timeoutMs - Connection and authentication timeout in milliseconds
|
||||||
|
* @returns Promise resolving to authenticated connection or null if failed
|
||||||
|
*/
|
||||||
|
export async function connectWithAuth(
|
||||||
|
relayUrl: string,
|
||||||
|
privateKeyHex: string,
|
||||||
|
timeoutMs = 10000
|
||||||
|
): Promise<AuthenticatedRelayConnection | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
ws.close();
|
||||||
|
resolve(null);
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const ws = new WebSocket(relayUrl);
|
||||||
|
const pubkey = getPublicKey(hexToBytes(privateKeyHex));
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Connection open, wait for AUTH challenge or proceed directly
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
const messageType = message[0];
|
||||||
|
|
||||||
|
if (messageType === 'AUTH') {
|
||||||
|
// Relay sent an auth challenge
|
||||||
|
const challenge = message[1];
|
||||||
|
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
|
||||||
|
|
||||||
|
// Send AUTH response
|
||||||
|
ws.send(JSON.stringify(['AUTH', authEvent]));
|
||||||
|
} else if (messageType === 'OK') {
|
||||||
|
// Check if this is the AUTH response
|
||||||
|
const success = message[2];
|
||||||
|
const msg = message[3] || '';
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
ws,
|
||||||
|
url: relayUrl,
|
||||||
|
authenticated: true,
|
||||||
|
pubkey,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error(`Auth failed for ${relayUrl}: ${msg}`);
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
} else if (messageType === 'NOTICE') {
|
||||||
|
// Some relays don't require auth - connection is ready
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
ws,
|
||||||
|
url: relayUrl,
|
||||||
|
authenticated: false,
|
||||||
|
pubkey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
// For relays that don't send AUTH challenge, resolve after short delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
ws,
|
||||||
|
url: relayUrl,
|
||||||
|
authenticated: false, // No auth was required
|
||||||
|
pubkey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 2000); // Wait 2 seconds for potential AUTH challenge
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event to a relay with NIP-42 authentication support
|
||||||
|
*
|
||||||
|
* This function handles the complete flow:
|
||||||
|
* 1. Connect to relay
|
||||||
|
* 2. Handle AUTH challenge if sent
|
||||||
|
* 3. Publish the event
|
||||||
|
* 4. Wait for OK response
|
||||||
|
* 5. Close connection
|
||||||
|
*
|
||||||
|
* @param relayUrl - The relay WebSocket URL
|
||||||
|
* @param signedEvent - The already-signed Nostr event to publish
|
||||||
|
* @param privateKeyHex - Private key for AUTH (if required)
|
||||||
|
* @param timeoutMs - Timeout for the entire operation
|
||||||
|
* @returns Promise resolving to publish result
|
||||||
|
*/
|
||||||
|
export async function publishEventWithAuth(
|
||||||
|
relayUrl: string,
|
||||||
|
signedEvent: ReturnType<typeof finalizeEvent>,
|
||||||
|
privateKeyHex: string,
|
||||||
|
timeoutMs = 15000
|
||||||
|
): Promise<PublishResult> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: false,
|
||||||
|
message: 'Timeout',
|
||||||
|
});
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
let ws: WebSocket;
|
||||||
|
let authenticated = false;
|
||||||
|
let eventSent = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(relayUrl);
|
||||||
|
} catch (e) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: false,
|
||||||
|
message: `Connection failed: ${e}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendEvent = () => {
|
||||||
|
if (!eventSent && ws.readyState === WebSocket.OPEN) {
|
||||||
|
eventSent = true;
|
||||||
|
ws.send(JSON.stringify(['EVENT', signedEvent]));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
// Wait a moment for potential AUTH challenge before sending event
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!authenticated) {
|
||||||
|
// No auth challenge received, try sending event directly
|
||||||
|
sendEvent();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
const messageType = message[0];
|
||||||
|
|
||||||
|
if (messageType === 'AUTH') {
|
||||||
|
// Relay requires authentication
|
||||||
|
const challenge = message[1];
|
||||||
|
const authEvent = createAuthEvent(relayUrl, challenge, privateKeyHex);
|
||||||
|
ws.send(JSON.stringify(['AUTH', authEvent]));
|
||||||
|
authenticated = true;
|
||||||
|
} else if (messageType === 'OK') {
|
||||||
|
const eventId = message[1];
|
||||||
|
const success = message[2];
|
||||||
|
const msg = message[3] || '';
|
||||||
|
|
||||||
|
// Check if this is our event or AUTH response
|
||||||
|
if (eventId === signedEvent.id) {
|
||||||
|
// This is the response to our published event
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: true,
|
||||||
|
message: 'Published successfully',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if we need to retry after auth
|
||||||
|
if (msg.includes('auth-required') && !authenticated) {
|
||||||
|
// Relay requires auth but didn't send challenge
|
||||||
|
// This shouldn't normally happen
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: false,
|
||||||
|
message: 'Auth required but no challenge received',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: false,
|
||||||
|
message: msg || 'Publish rejected',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (authenticated && !eventSent) {
|
||||||
|
// This is the OK response to our AUTH
|
||||||
|
if (success) {
|
||||||
|
// Auth succeeded, now send the event
|
||||||
|
sendEvent();
|
||||||
|
} else {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: false,
|
||||||
|
message: `Authentication failed: ${msg}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (messageType === 'NOTICE') {
|
||||||
|
// Log notices but don't fail
|
||||||
|
console.log(`Relay ${relayUrl} notice: ${message[1]}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
relay: relayUrl,
|
||||||
|
success: false,
|
||||||
|
message: 'Connection error',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// If we haven't resolved yet, treat as failure
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish an event to multiple relays with NIP-42 support
|
||||||
|
*
|
||||||
|
* @param relayUrls - Array of relay WebSocket URLs
|
||||||
|
* @param signedEvent - The already-signed Nostr event to publish
|
||||||
|
* @param privateKeyHex - Private key for AUTH (if required)
|
||||||
|
* @returns Promise resolving to array of publish results
|
||||||
|
*/
|
||||||
|
export async function publishToRelaysWithAuth(
|
||||||
|
relayUrls: string[],
|
||||||
|
signedEvent: ReturnType<typeof finalizeEvent>,
|
||||||
|
privateKeyHex: string
|
||||||
|
): Promise<PublishResult[]> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
relayUrls.map((url) => publishEventWithAuth(url, signedEvent, privateKeyHex))
|
||||||
|
);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
@@ -166,10 +166,19 @@ export const encryptIdentity = async function (
|
|||||||
return encryptedIdentity;
|
return encryptedIdentity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locked vault context for decryption during unlock
|
||||||
|
* - v1 vaults use password (PBKDF2)
|
||||||
|
* - v2 vaults use keyBase64 (pre-derived Argon2id key)
|
||||||
|
*/
|
||||||
|
export type LockedVaultContext =
|
||||||
|
| { iv: string; password: string; keyBase64?: undefined }
|
||||||
|
| { iv: string; keyBase64: string; password?: undefined };
|
||||||
|
|
||||||
export const decryptIdentities = async function (
|
export const decryptIdentities = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
identities: Identity_ENCRYPTED[],
|
identities: Identity_ENCRYPTED[],
|
||||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
withLockedVault: LockedVaultContext | undefined = undefined
|
||||||
): Promise<Identity_DECRYPTED[]> {
|
): Promise<Identity_DECRYPTED[]> {
|
||||||
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
const decryptedIdentities: Identity_DECRYPTED[] = [];
|
||||||
|
|
||||||
@@ -188,7 +197,7 @@ export const decryptIdentities = async function (
|
|||||||
export const decryptIdentity = async function (
|
export const decryptIdentity = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
identity: Identity_ENCRYPTED,
|
identity: Identity_ENCRYPTED,
|
||||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
withLockedVault: LockedVaultContext | undefined = undefined
|
||||||
): Promise<Identity_DECRYPTED> {
|
): Promise<Identity_DECRYPTED> {
|
||||||
if (typeof withLockedVault === 'undefined') {
|
if (typeof withLockedVault === 'undefined') {
|
||||||
const decryptedIdentity: Identity_DECRYPTED = {
|
const decryptedIdentity: Identity_DECRYPTED = {
|
||||||
@@ -201,30 +210,62 @@ export const decryptIdentity = async function (
|
|||||||
return decryptedIdentity;
|
return decryptedIdentity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2: Use pre-derived key
|
||||||
|
if (withLockedVault.keyBase64) {
|
||||||
|
const decryptedIdentity: Identity_DECRYPTED = {
|
||||||
|
id: await this.decryptWithLockedVaultV2(
|
||||||
|
identity.id,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
nick: await this.decryptWithLockedVaultV2(
|
||||||
|
identity.nick,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
createdAt: await this.decryptWithLockedVaultV2(
|
||||||
|
identity.createdAt,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
privkey: await this.decryptWithLockedVaultV2(
|
||||||
|
identity.privkey,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return decryptedIdentity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1: Use password (PBKDF2)
|
||||||
const decryptedIdentity: Identity_DECRYPTED = {
|
const decryptedIdentity: Identity_DECRYPTED = {
|
||||||
id: await this.decryptWithLockedVault(
|
id: await this.decryptWithLockedVault(
|
||||||
identity.id,
|
identity.id,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
nick: await this.decryptWithLockedVault(
|
nick: await this.decryptWithLockedVault(
|
||||||
identity.nick,
|
identity.nick,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
createdAt: await this.decryptWithLockedVault(
|
createdAt: await this.decryptWithLockedVault(
|
||||||
identity.createdAt,
|
identity.createdAt,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
privkey: await this.decryptWithLockedVault(
|
privkey: await this.decryptWithLockedVault(
|
||||||
identity.privkey,
|
identity.privkey,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Permission_ENCRYPTED,
|
Permission_ENCRYPTED,
|
||||||
StorageService,
|
StorageService,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
|
import { LockedVaultContext } from './identity';
|
||||||
|
|
||||||
export const deletePermission = async function (
|
export const deletePermission = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
@@ -32,7 +33,7 @@ export const deletePermission = async function (
|
|||||||
export const decryptPermission = async function (
|
export const decryptPermission = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
permission: Permission_ENCRYPTED,
|
permission: Permission_ENCRYPTED,
|
||||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
withLockedVault: LockedVaultContext | undefined = undefined
|
||||||
): Promise<Permission_DECRYPTED> {
|
): Promise<Permission_DECRYPTED> {
|
||||||
if (typeof withLockedVault === 'undefined') {
|
if (typeof withLockedVault === 'undefined') {
|
||||||
const decryptedPermission: Permission_DECRYPTED = {
|
const decryptedPermission: Permission_DECRYPTED = {
|
||||||
@@ -48,36 +49,82 @@ export const decryptPermission = async function (
|
|||||||
return decryptedPermission;
|
return decryptedPermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2: Use pre-derived key
|
||||||
|
if (withLockedVault.keyBase64) {
|
||||||
|
const decryptedPermission: Permission_DECRYPTED = {
|
||||||
|
id: await this.decryptWithLockedVaultV2(
|
||||||
|
permission.id,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
identityId: await this.decryptWithLockedVaultV2(
|
||||||
|
permission.identityId,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
method: await this.decryptWithLockedVaultV2(
|
||||||
|
permission.method,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
methodPolicy: await this.decryptWithLockedVaultV2(
|
||||||
|
permission.methodPolicy,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
host: await this.decryptWithLockedVaultV2(
|
||||||
|
permission.host,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (permission.kind) {
|
||||||
|
decryptedPermission.kind = await this.decryptWithLockedVaultV2(
|
||||||
|
permission.kind,
|
||||||
|
'number',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decryptedPermission;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1: Use password (PBKDF2)
|
||||||
const decryptedPermission: Permission_DECRYPTED = {
|
const decryptedPermission: Permission_DECRYPTED = {
|
||||||
id: await this.decryptWithLockedVault(
|
id: await this.decryptWithLockedVault(
|
||||||
permission.id,
|
permission.id,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
identityId: await this.decryptWithLockedVault(
|
identityId: await this.decryptWithLockedVault(
|
||||||
permission.identityId,
|
permission.identityId,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
method: await this.decryptWithLockedVault(
|
method: await this.decryptWithLockedVault(
|
||||||
permission.method,
|
permission.method,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
methodPolicy: await this.decryptWithLockedVault(
|
methodPolicy: await this.decryptWithLockedVault(
|
||||||
permission.methodPolicy,
|
permission.methodPolicy,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
host: await this.decryptWithLockedVault(
|
host: await this.decryptWithLockedVault(
|
||||||
permission.host,
|
permission.host,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
if (permission.kind) {
|
if (permission.kind) {
|
||||||
@@ -85,7 +132,7 @@ export const decryptPermission = async function (
|
|||||||
permission.kind,
|
permission.kind,
|
||||||
'number',
|
'number',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return decryptedPermission;
|
return decryptedPermission;
|
||||||
@@ -94,7 +141,7 @@ export const decryptPermission = async function (
|
|||||||
export const decryptPermissions = async function (
|
export const decryptPermissions = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
permissions: Permission_ENCRYPTED[],
|
permissions: Permission_ENCRYPTED[],
|
||||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
withLockedVault: LockedVaultContext | undefined = undefined
|
||||||
): Promise<Permission_DECRYPTED[]> {
|
): Promise<Permission_DECRYPTED[]> {
|
||||||
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
const decryptedPermissions: Permission_DECRYPTED[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Relay_ENCRYPTED,
|
Relay_ENCRYPTED,
|
||||||
StorageService,
|
StorageService,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
|
import { LockedVaultContext } from './identity';
|
||||||
|
|
||||||
export const addRelay = async function (
|
export const addRelay = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
@@ -126,7 +127,7 @@ export const updateRelay = async function (
|
|||||||
export const decryptRelay = async function (
|
export const decryptRelay = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
relay: Relay_ENCRYPTED,
|
relay: Relay_ENCRYPTED,
|
||||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
withLockedVault: LockedVaultContext | undefined = undefined
|
||||||
): Promise<Relay_DECRYPTED> {
|
): Promise<Relay_DECRYPTED> {
|
||||||
if (typeof withLockedVault === 'undefined') {
|
if (typeof withLockedVault === 'undefined') {
|
||||||
const decryptedRelay: Relay_DECRYPTED = {
|
const decryptedRelay: Relay_DECRYPTED = {
|
||||||
@@ -139,36 +140,74 @@ export const decryptRelay = async function (
|
|||||||
return decryptedRelay;
|
return decryptedRelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2: Use pre-derived key
|
||||||
|
if (withLockedVault.keyBase64) {
|
||||||
|
const decryptedRelay: Relay_DECRYPTED = {
|
||||||
|
id: await this.decryptWithLockedVaultV2(
|
||||||
|
relay.id,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
identityId: await this.decryptWithLockedVaultV2(
|
||||||
|
relay.identityId,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
url: await this.decryptWithLockedVaultV2(
|
||||||
|
relay.url,
|
||||||
|
'string',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
read: await this.decryptWithLockedVaultV2(
|
||||||
|
relay.read,
|
||||||
|
'boolean',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
write: await this.decryptWithLockedVaultV2(
|
||||||
|
relay.write,
|
||||||
|
'boolean',
|
||||||
|
withLockedVault.iv,
|
||||||
|
withLockedVault.keyBase64
|
||||||
|
),
|
||||||
|
};
|
||||||
|
return decryptedRelay;
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1: Use password (PBKDF2)
|
||||||
const decryptedRelay: Relay_DECRYPTED = {
|
const decryptedRelay: Relay_DECRYPTED = {
|
||||||
id: await this.decryptWithLockedVault(
|
id: await this.decryptWithLockedVault(
|
||||||
relay.id,
|
relay.id,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
identityId: await this.decryptWithLockedVault(
|
identityId: await this.decryptWithLockedVault(
|
||||||
relay.identityId,
|
relay.identityId,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
url: await this.decryptWithLockedVault(
|
url: await this.decryptWithLockedVault(
|
||||||
relay.url,
|
relay.url,
|
||||||
'string',
|
'string',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
read: await this.decryptWithLockedVault(
|
read: await this.decryptWithLockedVault(
|
||||||
relay.read,
|
relay.read,
|
||||||
'boolean',
|
'boolean',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
write: await this.decryptWithLockedVault(
|
write: await this.decryptWithLockedVault(
|
||||||
relay.write,
|
relay.write,
|
||||||
'boolean',
|
'boolean',
|
||||||
withLockedVault.iv,
|
withLockedVault.iv,
|
||||||
withLockedVault.password
|
withLockedVault.password!
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
return decryptedRelay;
|
return decryptedRelay;
|
||||||
@@ -177,7 +216,7 @@ export const decryptRelay = async function (
|
|||||||
export const decryptRelays = async function (
|
export const decryptRelays = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
relays: Relay_ENCRYPTED[],
|
relays: Relay_ENCRYPTED[],
|
||||||
withLockedVault: { iv: string; password: string } | undefined = undefined
|
withLockedVault: LockedVaultContext | undefined = undefined
|
||||||
): Promise<Relay_DECRYPTED[]> {
|
): Promise<Relay_DECRYPTED[]> {
|
||||||
const decryptedRelays: Relay_DECRYPTED[] = [];
|
const decryptedRelays: Relay_DECRYPTED[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import {
|
|||||||
BrowserSyncData,
|
BrowserSyncData,
|
||||||
CryptoHelper,
|
CryptoHelper,
|
||||||
StorageService,
|
StorageService,
|
||||||
|
generateSalt,
|
||||||
|
generateIV,
|
||||||
|
deriveKeyArgon2,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import { decryptIdentities } from './identity';
|
import { Buffer } from 'buffer';
|
||||||
|
import { decryptIdentities, encryptIdentity, LockedVaultContext } from './identity';
|
||||||
import { decryptPermissions } from './permission';
|
import { decryptPermissions } from './permission';
|
||||||
import { decryptRelays } from './relay';
|
import { decryptRelays, encryptRelay } from './relay';
|
||||||
|
|
||||||
export const createNewVault = async function (
|
export const createNewVault = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
@@ -16,9 +20,17 @@ export const createNewVault = async function (
|
|||||||
|
|
||||||
const vaultHash = await CryptoHelper.hash(password);
|
const vaultHash = await CryptoHelper.hash(password);
|
||||||
|
|
||||||
|
// v2: Generate random salt and derive key with Argon2id
|
||||||
|
const salt = generateSalt();
|
||||||
|
const iv = generateIV();
|
||||||
|
const saltBytes = Buffer.from(salt, 'base64');
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||||
|
const vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||||
|
|
||||||
const sessionData: BrowserSessionData = {
|
const sessionData: BrowserSessionData = {
|
||||||
iv: CryptoHelper.generateIV(),
|
iv,
|
||||||
vaultPassword: password,
|
salt,
|
||||||
|
vaultKey, // v2: Store pre-derived key instead of password
|
||||||
identities: [],
|
identities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
relays: [],
|
relays: [],
|
||||||
@@ -29,7 +41,8 @@ export const createNewVault = async function (
|
|||||||
|
|
||||||
const syncData: BrowserSyncData = {
|
const syncData: BrowserSyncData = {
|
||||||
version: this.latestVersion,
|
version: this.latestVersion,
|
||||||
iv: sessionData.iv,
|
salt, // v2: Random salt for Argon2id
|
||||||
|
iv,
|
||||||
vaultHash,
|
vaultHash,
|
||||||
identities: [],
|
identities: [],
|
||||||
permissions: [],
|
permissions: [],
|
||||||
@@ -44,6 +57,7 @@ export const unlockVault = async function (
|
|||||||
password: string
|
password: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.assureIsInitialized();
|
this.assureIsInitialized();
|
||||||
|
console.log('[vault] Starting unlock...');
|
||||||
|
|
||||||
let browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
let browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||||
if (browserSessionData) {
|
if (browserSessionData) {
|
||||||
@@ -59,55 +73,190 @@ export const unlockVault = async function (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[vault] Checking password hash...');
|
||||||
const passwordHash = await CryptoHelper.hash(password);
|
const passwordHash = await CryptoHelper.hash(password);
|
||||||
if (passwordHash !== browserSyncData.vaultHash) {
|
if (passwordHash !== browserSyncData.vaultHash) {
|
||||||
throw new Error('Invalid password.');
|
throw new Error('Invalid password.');
|
||||||
}
|
}
|
||||||
|
console.log('[vault] Password hash verified');
|
||||||
|
|
||||||
// Ok. Everything is fine. We can unlock the vault now.
|
// Detect vault version
|
||||||
|
const isV2 = !!browserSyncData.salt;
|
||||||
|
console.log('[vault] Vault version:', isV2 ? 'v2' : 'v1');
|
||||||
|
|
||||||
// Decrypt the identities.
|
let withLockedVault: LockedVaultContext;
|
||||||
const withLockedVault = {
|
let vaultKey: string | undefined;
|
||||||
|
let vaultPassword: string | undefined;
|
||||||
|
|
||||||
|
if (isV2) {
|
||||||
|
// v2: Derive key with Argon2id (~3 seconds)
|
||||||
|
console.log('[vault] Deriving key with Argon2id...');
|
||||||
|
const saltBytes = Buffer.from(browserSyncData.salt!, 'base64');
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||||
|
console.log('[vault] Key derived, length:', keyBytes.length);
|
||||||
|
vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||||
|
withLockedVault = {
|
||||||
|
iv: browserSyncData.iv,
|
||||||
|
keyBase64: vaultKey,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// v1: Use password with PBKDF2
|
||||||
|
vaultPassword = password;
|
||||||
|
withLockedVault = {
|
||||||
iv: browserSyncData.iv,
|
iv: browserSyncData.iv,
|
||||||
password,
|
password,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the data
|
||||||
|
console.log('[vault] Decrypting identities...');
|
||||||
const decryptedIdentities = await decryptIdentities.call(
|
const decryptedIdentities = await decryptIdentities.call(
|
||||||
this,
|
this,
|
||||||
browserSyncData.identities,
|
browserSyncData.identities,
|
||||||
withLockedVault
|
withLockedVault
|
||||||
);
|
);
|
||||||
|
console.log('[vault] Decrypted', decryptedIdentities.length, 'identities');
|
||||||
|
|
||||||
|
console.log('[vault] Decrypting permissions...');
|
||||||
const decryptedPermissions = await decryptPermissions.call(
|
const decryptedPermissions = await decryptPermissions.call(
|
||||||
this,
|
this,
|
||||||
browserSyncData.permissions,
|
browserSyncData.permissions,
|
||||||
withLockedVault
|
withLockedVault
|
||||||
);
|
);
|
||||||
|
console.log('[vault] Decrypted', decryptedPermissions.length, 'permissions');
|
||||||
|
|
||||||
|
console.log('[vault] Decrypting relays...');
|
||||||
const decryptedRelays = await decryptRelays.call(
|
const decryptedRelays = await decryptRelays.call(
|
||||||
this,
|
this,
|
||||||
browserSyncData.relays,
|
browserSyncData.relays,
|
||||||
withLockedVault
|
withLockedVault
|
||||||
);
|
);
|
||||||
const decryptedSelectedIdentityId =
|
console.log('[vault] Decrypted', decryptedRelays.length, 'relays');
|
||||||
browserSyncData.selectedIdentityId === null
|
|
||||||
? null
|
console.log('[vault] Decrypting selectedIdentityId...');
|
||||||
: await this.decryptWithLockedVault(
|
let decryptedSelectedIdentityId: string | null = null;
|
||||||
|
if (browserSyncData.selectedIdentityId !== null) {
|
||||||
|
if (isV2) {
|
||||||
|
decryptedSelectedIdentityId = await this.decryptWithLockedVaultV2(
|
||||||
|
browserSyncData.selectedIdentityId,
|
||||||
|
'string',
|
||||||
|
browserSyncData.iv,
|
||||||
|
vaultKey!
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
decryptedSelectedIdentityId = await this.decryptWithLockedVault(
|
||||||
browserSyncData.selectedIdentityId,
|
browserSyncData.selectedIdentityId,
|
||||||
'string',
|
'string',
|
||||||
browserSyncData.iv,
|
browserSyncData.iv,
|
||||||
password
|
password
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('[vault] selectedIdentityId:', decryptedSelectedIdentityId);
|
||||||
|
|
||||||
browserSessionData = {
|
browserSessionData = {
|
||||||
vaultPassword: password,
|
vaultPassword: isV2 ? undefined : vaultPassword,
|
||||||
|
vaultKey: isV2 ? vaultKey : undefined,
|
||||||
iv: browserSyncData.iv,
|
iv: browserSyncData.iv,
|
||||||
|
salt: browserSyncData.salt,
|
||||||
permissions: decryptedPermissions,
|
permissions: decryptedPermissions,
|
||||||
identities: decryptedIdentities,
|
identities: decryptedIdentities,
|
||||||
selectedIdentityId: decryptedSelectedIdentityId,
|
selectedIdentityId: decryptedSelectedIdentityId,
|
||||||
relays: decryptedRelays,
|
relays: decryptedRelays,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[vault] Saving session data...');
|
||||||
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||||
this.getBrowserSessionHandler().setFullData(browserSessionData);
|
this.getBrowserSessionHandler().setFullData(browserSessionData);
|
||||||
|
console.log('[vault] Session data saved');
|
||||||
|
|
||||||
|
// Auto-migrate v1 to v2 after successful unlock
|
||||||
|
if (!isV2) {
|
||||||
|
console.log('[vault] Migrating v1 to v2...');
|
||||||
|
await migrateVaultV1ToV2.call(this, password);
|
||||||
|
console.log('[vault] Migration complete');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[vault] Unlock complete!');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate a v1 vault (PBKDF2) to v2 (Argon2id)
|
||||||
|
* Called automatically after successful v1 unlock
|
||||||
|
*/
|
||||||
|
async function migrateVaultV1ToV2(
|
||||||
|
this: StorageService,
|
||||||
|
password: string
|
||||||
|
): Promise<void> {
|
||||||
|
const browserSyncData = this.getBrowserSyncHandler().browserSyncData;
|
||||||
|
const browserSessionData = this.getBrowserSessionHandler().browserSessionData;
|
||||||
|
if (!browserSyncData || !browserSessionData) {
|
||||||
|
throw new Error('Cannot migrate: data not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new salt and derive Argon2id key
|
||||||
|
const newSalt = generateSalt();
|
||||||
|
const newIv = generateIV();
|
||||||
|
const saltBytes = Buffer.from(newSalt, 'base64');
|
||||||
|
const keyBytes = await deriveKeyArgon2(password, saltBytes);
|
||||||
|
const vaultKey = Buffer.from(keyBytes).toString('base64');
|
||||||
|
|
||||||
|
// Update session data with new v2 credentials
|
||||||
|
browserSessionData.salt = newSalt;
|
||||||
|
browserSessionData.iv = newIv;
|
||||||
|
browserSessionData.vaultKey = vaultKey;
|
||||||
|
browserSessionData.vaultPassword = undefined; // Remove v1 password
|
||||||
|
|
||||||
|
// Re-encrypt all data with new v2 key
|
||||||
|
const encryptedIdentities = [];
|
||||||
|
for (const identity of browserSessionData.identities) {
|
||||||
|
const encrypted = await encryptIdentity.call(this, identity);
|
||||||
|
encryptedIdentities.push(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedRelays = [];
|
||||||
|
for (const relay of browserSessionData.relays) {
|
||||||
|
const encrypted = await encryptRelay.call(this, relay);
|
||||||
|
encryptedRelays.push(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For permissions, we need to re-encrypt them too
|
||||||
|
const encryptedPermissions = [];
|
||||||
|
for (const permission of browserSessionData.permissions) {
|
||||||
|
const encryptedPermission = {
|
||||||
|
id: await this.encrypt(permission.id),
|
||||||
|
identityId: await this.encrypt(permission.identityId),
|
||||||
|
host: await this.encrypt(permission.host),
|
||||||
|
method: await this.encrypt(permission.method),
|
||||||
|
methodPolicy: await this.encrypt(permission.methodPolicy),
|
||||||
|
kind: permission.kind !== undefined ? await this.encrypt(permission.kind.toString()) : undefined,
|
||||||
|
};
|
||||||
|
encryptedPermissions.push(encryptedPermission);
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedSelectedIdentityId = browserSessionData.selectedIdentityId
|
||||||
|
? await this.encrypt(browserSessionData.selectedIdentityId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Update sync data with v2 format
|
||||||
|
const migratedSyncData: BrowserSyncData = {
|
||||||
|
version: this.latestVersion,
|
||||||
|
salt: newSalt,
|
||||||
|
iv: newIv,
|
||||||
|
vaultHash: browserSyncData.vaultHash, // Keep same password hash
|
||||||
|
identities: encryptedIdentities,
|
||||||
|
permissions: encryptedPermissions,
|
||||||
|
relays: encryptedRelays,
|
||||||
|
selectedIdentityId: encryptedSelectedIdentityId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save migrated data
|
||||||
|
await this.getBrowserSyncHandler().saveAndSetFullData(migratedSyncData);
|
||||||
|
await this.getBrowserSessionHandler().saveFullData(browserSessionData);
|
||||||
|
|
||||||
|
console.log('Vault migrated from v1 (PBKDF2) to v2 (Argon2id)');
|
||||||
|
}
|
||||||
|
|
||||||
export const deleteVault = async function (
|
export const deleteVault = async function (
|
||||||
this: StorageService,
|
this: StorageService,
|
||||||
doNotSetIsInitializedToFalse: boolean
|
doNotSetIsInitializedToFalse: boolean
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { SignerMetaHandler } from './signer-meta-handler';
|
import { SignerMetaHandler } from './signer-meta-handler';
|
||||||
import { CryptoHelper } from '@common';
|
import { CryptoHelper } from '@common';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
import {
|
import {
|
||||||
addIdentity,
|
addIdentity,
|
||||||
deleteIdentity,
|
deleteIdentity,
|
||||||
@@ -31,7 +32,7 @@ export interface StorageServiceConfig {
|
|||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
readonly latestVersion = 1;
|
readonly latestVersion = 2;
|
||||||
isInitialized = false;
|
isInitialized = false;
|
||||||
|
|
||||||
#browserSessionHandler!: BrowserSessionHandler;
|
#browserSessionHandler!: BrowserSessionHandler;
|
||||||
@@ -231,10 +232,19 @@ export class StorageService {
|
|||||||
async encrypt(value: string): Promise<string> {
|
async encrypt(value: string): Promise<string> {
|
||||||
const browserSessionData =
|
const browserSessionData =
|
||||||
this.getBrowserSessionHandler().browserSessionData;
|
this.getBrowserSessionHandler().browserSessionData;
|
||||||
if (!browserSessionData || !browserSessionData.vaultPassword) {
|
if (!browserSessionData) {
|
||||||
throw new Error('Browser session data is undefined.');
|
throw new Error('Browser session data is undefined.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2: Use pre-derived key directly with AES-GCM
|
||||||
|
if (browserSessionData.vaultKey) {
|
||||||
|
return this.encryptV2(value, browserSessionData.iv, browserSessionData.vaultKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1: Use PBKDF2 with password
|
||||||
|
if (!browserSessionData.vaultPassword) {
|
||||||
|
throw new Error('No vault password or key available.');
|
||||||
|
}
|
||||||
return CryptoHelper.encrypt(
|
return CryptoHelper.encrypt(
|
||||||
value,
|
value,
|
||||||
browserSessionData.iv,
|
browserSessionData.iv,
|
||||||
@@ -242,16 +252,54 @@ export class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 encryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
|
||||||
|
*/
|
||||||
|
async encryptV2(text: string, ivBase64: string, keyBase64: string): Promise<string> {
|
||||||
|
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
const cipherText = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
new TextEncoder().encode(text)
|
||||||
|
);
|
||||||
|
|
||||||
|
return Buffer.from(cipherText).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
async decrypt(
|
async decrypt(
|
||||||
value: string,
|
value: string,
|
||||||
returnType: 'string' | 'number' | 'boolean'
|
returnType: 'string' | 'number' | 'boolean'
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const browserSessionData =
|
const browserSessionData =
|
||||||
this.getBrowserSessionHandler().browserSessionData;
|
this.getBrowserSessionHandler().browserSessionData;
|
||||||
if (!browserSessionData || !browserSessionData.vaultPassword) {
|
if (!browserSessionData) {
|
||||||
throw new Error('Browser session data is undefined.');
|
throw new Error('Browser session data is undefined.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v2: Use pre-derived key directly with AES-GCM
|
||||||
|
if (browserSessionData.vaultKey) {
|
||||||
|
const decryptedValue = await this.decryptV2(
|
||||||
|
value,
|
||||||
|
browserSessionData.iv,
|
||||||
|
browserSessionData.vaultKey
|
||||||
|
);
|
||||||
|
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1: Use PBKDF2 with password
|
||||||
|
if (!browserSessionData.vaultPassword) {
|
||||||
|
throw new Error('No vault password or key available.');
|
||||||
|
}
|
||||||
return this.decryptWithLockedVault(
|
return this.decryptWithLockedVault(
|
||||||
value,
|
value,
|
||||||
returnType,
|
returnType,
|
||||||
@@ -260,6 +308,52 @@ export class StorageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2 decryption: Use pre-derived key bytes directly with AES-GCM (no key derivation)
|
||||||
|
*/
|
||||||
|
async decryptV2(encryptedBase64: string, ivBase64: string, keyBase64: string): Promise<string> {
|
||||||
|
const keyBytes = Buffer.from(keyBase64, 'base64');
|
||||||
|
const iv = Buffer.from(ivBase64, 'base64');
|
||||||
|
const cipherText = Buffer.from(encryptedBase64, 'base64');
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
cipherText
|
||||||
|
);
|
||||||
|
|
||||||
|
return new TextDecoder().decode(decrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a decrypted string value into the desired type
|
||||||
|
*/
|
||||||
|
private parseDecryptedValue(
|
||||||
|
decryptedValue: string,
|
||||||
|
returnType: 'string' | 'number' | 'boolean'
|
||||||
|
): any {
|
||||||
|
switch (returnType) {
|
||||||
|
case 'number':
|
||||||
|
return parseInt(decryptedValue);
|
||||||
|
case 'boolean':
|
||||||
|
return decryptedValue === 'true';
|
||||||
|
case 'string':
|
||||||
|
default:
|
||||||
|
return decryptedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v1: Decrypt with locked vault using password (PBKDF2)
|
||||||
|
*/
|
||||||
async decryptWithLockedVault(
|
async decryptWithLockedVault(
|
||||||
value: string,
|
value: string,
|
||||||
returnType: 'string' | 'number' | 'boolean',
|
returnType: 'string' | 'number' | 'boolean',
|
||||||
@@ -267,18 +361,20 @@ export class StorageService {
|
|||||||
password: string
|
password: string
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
|
const decryptedValue = await CryptoHelper.decrypt(value, iv, password);
|
||||||
|
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||||
switch (returnType) {
|
|
||||||
case 'number':
|
|
||||||
return parseInt(decryptedValue);
|
|
||||||
|
|
||||||
case 'boolean':
|
|
||||||
return decryptedValue === 'true';
|
|
||||||
|
|
||||||
case 'string':
|
|
||||||
default:
|
|
||||||
return decryptedValue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v2: Decrypt with locked vault using pre-derived key (Argon2id)
|
||||||
|
*/
|
||||||
|
async decryptWithLockedVaultV2(
|
||||||
|
value: string,
|
||||||
|
returnType: 'string' | 'number' | 'boolean',
|
||||||
|
iv: string,
|
||||||
|
keyBase64: string
|
||||||
|
): Promise<any> {
|
||||||
|
const decryptedValue = await this.decryptV2(value, iv, keyBase64);
|
||||||
|
return this.parseDecryptedValue(decryptedValue, returnType);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export interface BrowserSyncData_PART_Unencrypted {
|
|||||||
version: number;
|
version: number;
|
||||||
iv: string;
|
iv: string;
|
||||||
vaultHash: string;
|
vaultHash: string;
|
||||||
|
// Version 2+: Random 32-byte salt for Argon2id key derivation (base64)
|
||||||
|
// Version 1: Not present (uses PBKDF2 with hardcoded salt)
|
||||||
|
salt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BrowserSyncData_PART_Encrypted {
|
export interface BrowserSyncData_PART_Encrypted {
|
||||||
@@ -69,10 +72,13 @@ export enum BrowserSyncFlow {
|
|||||||
export interface BrowserSessionData {
|
export interface BrowserSessionData {
|
||||||
// The following properties purely come from the browser session storage
|
// The following properties purely come from the browser session storage
|
||||||
// and will never be going into the browser sync storage.
|
// and will never be going into the browser sync storage.
|
||||||
vaultPassword?: string;
|
vaultPassword?: string; // v1 only: raw password for PBKDF2
|
||||||
|
vaultKey?: string; // v2+: pre-derived key bytes (base64) from Argon2id
|
||||||
|
|
||||||
// The following properties initially come from the browser sync storage.
|
// The following properties initially come from the browser sync storage.
|
||||||
iv: string;
|
iv: string;
|
||||||
|
// Version 2+: Random salt for Argon2id (base64)
|
||||||
|
salt?: string;
|
||||||
permissions: Permission_DECRYPTED[];
|
permissions: Permission_DECRYPTED[];
|
||||||
identities: Identity_DECRYPTED[];
|
identities: Identity_DECRYPTED[];
|
||||||
selectedIdentityId: string | null;
|
selectedIdentityId: string | null;
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ export * from './lib/constants/fallback-relays';
|
|||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
export * from './lib/helpers/crypto-helper';
|
export * from './lib/helpers/crypto-helper';
|
||||||
|
export * from './lib/helpers/argon2-crypto';
|
||||||
export * from './lib/helpers/nostr-helper';
|
export * from './lib/helpers/nostr-helper';
|
||||||
export * from './lib/helpers/text-helper';
|
export * from './lib/helpers/text-helper';
|
||||||
export * from './lib/helpers/date-helper';
|
export * from './lib/helpers/date-helper';
|
||||||
|
export * from './lib/helpers/websocket-auth';
|
||||||
|
export * from './lib/helpers/nip05-validator';
|
||||||
|
|
||||||
// Models
|
// Models
|
||||||
export * from './lib/models/nostr';
|
export * from './lib/models/nostr';
|
||||||
@@ -35,6 +38,7 @@ export * from './lib/components/toast/toast.component';
|
|||||||
export * from './lib/components/nav-item/nav-item.component';
|
export * from './lib/components/nav-item/nav-item.component';
|
||||||
export * from './lib/components/pubkey/pubkey.component';
|
export * from './lib/components/pubkey/pubkey.component';
|
||||||
export * from './lib/components/relay-rw/relay-rw.component';
|
export * from './lib/components/relay-rw/relay-rw.component';
|
||||||
|
export * from './lib/components/deriving-modal/deriving-modal.component';
|
||||||
|
|
||||||
// Pipes
|
// Pipes
|
||||||
export * from './lib/pipes/visual-relay.pipe';
|
export * from './lib/pipes/visual-relay.pipe';
|
||||||
|
|||||||
3
projects/firefox/public/edit.svg
Normal file
3
projects/firefox/public/edit.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.06 9L15 9.94L5.92 19H5V18.08L14.06 9ZM17.66 3C17.41 3 17.15 3.1 16.96 3.29L15.13 5.12L18.88 8.87L20.71 7.04C21.1 6.65 21.1 6 20.71 5.63L18.37 3.29C18.17 3.09 17.92 3 17.66 3ZM14.06 6.19L3 17.25V21H6.75L17.81 9.94L14.06 6.19Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 364 B |
@@ -2,12 +2,15 @@
|
|||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Plebeian Signer",
|
"name": "Plebeian Signer",
|
||||||
"description": "Nostr Identity Manager & Signer",
|
"description": "Nostr Identity Manager & Signer",
|
||||||
"version": "0.0.9",
|
"version": "1.0.0",
|
||||||
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
"homepage_url": "https://git.mleku.dev/mleku/plebeian-signer",
|
||||||
"options_page": "options.html",
|
"options_page": "options.html",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage"
|
"storage"
|
||||||
],
|
],
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
|
||||||
|
},
|
||||||
"action": {
|
"action": {
|
||||||
"default_popup": "index.html",
|
"default_popup": "index.html",
|
||||||
"default_icon": {
|
"default_icon": {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { VaultLoginComponent } from './components/vault-login/vault-login.compon
|
|||||||
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
|
import { VaultCreateComponent } from './components/vault-create/vault-create.component';
|
||||||
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
import { VaultImportComponent } from './components/vault-import/vault-import.component';
|
||||||
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
import { WhitelistedAppsComponent } from './components/whitelisted-apps/whitelisted-apps.component';
|
||||||
|
import { ProfileEditComponent } from './components/profile-edit/profile-edit.component';
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{
|
{
|
||||||
@@ -75,6 +76,10 @@ export const routes: Routes = [
|
|||||||
path: 'whitelisted-apps',
|
path: 'whitelisted-apps',
|
||||||
component: WhitelistedAppsComponent,
|
component: WhitelistedAppsComponent,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'profile-edit',
|
||||||
|
component: ProfileEditComponent,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'edit-identity/:id',
|
path: 'edit-identity/:id',
|
||||||
component: EditIdentityComponent,
|
component: EditIdentityComponent,
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
<!-- eslint-disable @angular-eslint/template/interactive-supports-focus -->
|
||||||
|
<!-- eslint-disable @angular-eslint/template/click-events-have-key-events -->
|
||||||
<div class="sam-text-header">
|
<div class="sam-text-header">
|
||||||
<span>You</span>
|
<span>You</span>
|
||||||
|
<button class="edit-btn" title="Edit profile" (click)="onClickEditProfile()">
|
||||||
|
<img src="edit.svg" alt="Edit" class="edit-icon" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="identity-container">
|
<div class="identity-container">
|
||||||
@@ -22,7 +26,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Display name (primary, large) -->
|
<!-- Display name (primary, large) -->
|
||||||
<!-- eslint-disable-next-line @angular-eslint/template/click-events-have-key-events -->
|
|
||||||
<div class="name-badge-container" (click)="onClickShowDetails()">
|
<div class="name-badge-container" (click)="onClickShowDetails()">
|
||||||
<span class="display-name">
|
<span class="display-name">
|
||||||
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
{{ displayName || selectedIdentity?.nick || 'Unknown' }}
|
||||||
|
|||||||
@@ -3,6 +3,34 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
.sam-text-header {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--size);
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--background-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
filter: brightness(0) invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.identity-container {
|
.identity-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -123,6 +151,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nip05-row {
|
.nip05-row {
|
||||||
|
@extend %text-badge;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -134,7 +163,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nip05-badge {
|
.nip05-badge {
|
||||||
@extend %text-badge;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
StorageService,
|
StorageService,
|
||||||
ToastComponent,
|
ToastComponent,
|
||||||
VisualNip05Pipe,
|
VisualNip05Pipe,
|
||||||
|
validateNip05,
|
||||||
} from '@common';
|
} from '@common';
|
||||||
import NDK from '@nostr-dev-kit/ndk';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-identity',
|
selector: 'app-identity',
|
||||||
@@ -67,6 +67,13 @@ export class IdentityComponent implements OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onClickEditProfile() {
|
||||||
|
if (!this.selectedIdentity) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.#router.navigateByUrl('/profile-edit');
|
||||||
|
}
|
||||||
|
|
||||||
async #loadData() {
|
async #loadData() {
|
||||||
try {
|
try {
|
||||||
const selectedIdentityId =
|
const selectedIdentityId =
|
||||||
@@ -125,28 +132,18 @@ export class IdentityComponent implements OnInit {
|
|||||||
try {
|
try {
|
||||||
this.validating = true;
|
this.validating = true;
|
||||||
|
|
||||||
// Get relays for validation
|
// Direct NIP-05 validation - fetches .well-known/nostr.json directly
|
||||||
const relays =
|
const result = await validateNip05(nip05, pubkey);
|
||||||
this.#storage
|
this.nip05isValidated = result.valid;
|
||||||
.getBrowserSessionHandler()
|
|
||||||
.browserSessionData?.relays.filter(
|
|
||||||
(x) => x.identityId === this.selectedIdentity?.id
|
|
||||||
) ?? [];
|
|
||||||
|
|
||||||
const relevantRelays = relays.filter((x) => x.write).map((x) => x.url);
|
if (!result.valid) {
|
||||||
|
console.log('NIP-05 validation failed:', result.error);
|
||||||
if (relevantRelays.length > 0) {
|
|
||||||
const ndk = new NDK({
|
|
||||||
explicitRelayUrls: relevantRelays,
|
|
||||||
});
|
|
||||||
await ndk.connect();
|
|
||||||
const user = ndk.getUser({ pubkey });
|
|
||||||
this.nip05isValidated = (await user.validateNip05(nip05)) ?? undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.validating = false;
|
this.validating = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('NIP-05 validation failed:', error);
|
console.error('NIP-05 validation failed:', error);
|
||||||
|
this.nip05isValidated = false;
|
||||||
this.validating = false;
|
this.validating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<div class="sam-text-header">
|
||||||
|
<span>Edit Profile</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(loading) {
|
||||||
|
<div class="loading-container">
|
||||||
|
<span class="sam-text-muted">Loading profile...</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="content">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Your name"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="display_name">Display Name</label>
|
||||||
|
<input
|
||||||
|
id="display_name"
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.display_name"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="picture">Avatar URL</label>
|
||||||
|
<input
|
||||||
|
id="picture"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/avatar.jpg"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.picture"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="banner">Banner URL</label>
|
||||||
|
<input
|
||||||
|
id="banner"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://example.com/banner.jpg"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.banner"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="website">Website</label>
|
||||||
|
<input
|
||||||
|
id="website"
|
||||||
|
type="url"
|
||||||
|
placeholder="https://yourwebsite.com"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.website"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="about">About</label>
|
||||||
|
<textarea
|
||||||
|
id="about"
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
class="form-control"
|
||||||
|
rows="4"
|
||||||
|
[(ngModel)]="profile.about"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="nip05">NIP-05 Identifier</label>
|
||||||
|
<input
|
||||||
|
id="nip05"
|
||||||
|
type="text"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.nip05"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lud16">Lightning Address (LUD-16)</label>
|
||||||
|
<input
|
||||||
|
id="lud16"
|
||||||
|
type="text"
|
||||||
|
placeholder="you@getalby.com"
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.lud16"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="lnurl">LNURL</label>
|
||||||
|
<input
|
||||||
|
id="lnurl"
|
||||||
|
type="text"
|
||||||
|
placeholder="lnurl1..."
|
||||||
|
class="form-control"
|
||||||
|
[(ngModel)]="profile.lnurl"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sam-footer-grid-2">
|
||||||
|
<button type="button" class="btn btn-secondary" (click)="onClickCancel()">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
[disabled]="saving"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
(click)="onClickSave()"
|
||||||
|
>
|
||||||
|
@if(saving) {
|
||||||
|
Saving...
|
||||||
|
} @else {
|
||||||
|
Save
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(alertMessage) {
|
||||||
|
<div class="alert-container">
|
||||||
|
<div class="alert alert-danger sam-flex-row gap" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
<span>{{ alertMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<lib-toast #toast></lib-toast>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
:host {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-left: var(--size);
|
||||||
|
padding-right: var(--size);
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: var(--size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--background-light);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--foreground);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 8px 12px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 70px;
|
||||||
|
left: var(--size);
|
||||||
|
right: var(--size);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
import {
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
NavComponent,
|
||||||
|
NostrHelper,
|
||||||
|
ProfileMetadataService,
|
||||||
|
RelayListService,
|
||||||
|
StorageService,
|
||||||
|
ToastComponent,
|
||||||
|
publishToRelaysWithAuth,
|
||||||
|
} from '@common';
|
||||||
|
import { SimplePool } from 'nostr-tools/pool';
|
||||||
|
import { finalizeEvent } from 'nostr-tools';
|
||||||
|
import { hexToBytes } from '@noble/hashes/utils';
|
||||||
|
|
||||||
|
interface ProfileFormData {
|
||||||
|
name: string;
|
||||||
|
display_name: string;
|
||||||
|
picture: string;
|
||||||
|
banner: string;
|
||||||
|
website: string;
|
||||||
|
about: string;
|
||||||
|
nip05: string;
|
||||||
|
lud16: string;
|
||||||
|
lnurl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-profile-edit',
|
||||||
|
templateUrl: './profile-edit.component.html',
|
||||||
|
styleUrl: './profile-edit.component.scss',
|
||||||
|
imports: [FormsModule, ToastComponent],
|
||||||
|
})
|
||||||
|
export class ProfileEditComponent extends NavComponent implements OnInit {
|
||||||
|
readonly #storage = inject(StorageService);
|
||||||
|
readonly #router = inject(Router);
|
||||||
|
readonly #profileMetadata = inject(ProfileMetadataService);
|
||||||
|
readonly #relayList = inject(RelayListService);
|
||||||
|
|
||||||
|
profile: ProfileFormData = {
|
||||||
|
name: '',
|
||||||
|
display_name: '',
|
||||||
|
picture: '',
|
||||||
|
banner: '',
|
||||||
|
website: '',
|
||||||
|
about: '',
|
||||||
|
nip05: '',
|
||||||
|
lud16: '',
|
||||||
|
lnurl: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store original event content to preserve extra fields
|
||||||
|
#originalContent: Record<string, unknown> = {};
|
||||||
|
#originalTags: string[][] = [];
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
saving = false;
|
||||||
|
alertMessage: string | undefined;
|
||||||
|
#privkey: string | undefined;
|
||||||
|
#pubkey: string | undefined;
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
await this.#loadProfile();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #loadProfile() {
|
||||||
|
try {
|
||||||
|
const selectedIdentityId =
|
||||||
|
this.#storage.getBrowserSessionHandler().browserSessionData
|
||||||
|
?.selectedIdentityId ?? null;
|
||||||
|
|
||||||
|
const identity = this.#storage
|
||||||
|
.getBrowserSessionHandler()
|
||||||
|
.browserSessionData?.identities.find(
|
||||||
|
(x) => x.id === selectedIdentityId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!identity) {
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#privkey = identity.privkey;
|
||||||
|
this.#pubkey = NostrHelper.pubkeyFromPrivkey(identity.privkey);
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
await this.#profileMetadata.initialize();
|
||||||
|
|
||||||
|
// Try to get cached profile first
|
||||||
|
const cachedProfile = this.#profileMetadata.getCachedProfile(this.#pubkey);
|
||||||
|
if (cachedProfile) {
|
||||||
|
this.profile = {
|
||||||
|
name: cachedProfile.name || '',
|
||||||
|
display_name: cachedProfile.display_name || cachedProfile.displayName || '',
|
||||||
|
picture: cachedProfile.picture || '',
|
||||||
|
banner: cachedProfile.banner || '',
|
||||||
|
website: cachedProfile.website || '',
|
||||||
|
about: cachedProfile.about || '',
|
||||||
|
nip05: cachedProfile.nip05 || '',
|
||||||
|
lud16: cachedProfile.lud16 || '',
|
||||||
|
lnurl: cachedProfile.lud06 || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the actual kind 0 event to get original content and tags
|
||||||
|
await this.#fetchOriginalEvent();
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profile:', error);
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async #fetchOriginalEvent() {
|
||||||
|
if (!this.#pubkey) return;
|
||||||
|
|
||||||
|
const pool = new SimplePool();
|
||||||
|
try {
|
||||||
|
const events = await this.#queryWithTimeout(
|
||||||
|
pool,
|
||||||
|
FALLBACK_PROFILE_RELAYS,
|
||||||
|
[{ kinds: [0], authors: [this.#pubkey] }],
|
||||||
|
10000
|
||||||
|
);
|
||||||
|
|
||||||
|
if (events.length > 0) {
|
||||||
|
// Get the most recent event
|
||||||
|
const latestEvent = events.reduce((latest, event) =>
|
||||||
|
event.created_at > latest.created_at ? event : latest
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store original tags (excluding the ones we'll update)
|
||||||
|
this.#originalTags = latestEvent.tags.filter(
|
||||||
|
(tag: string[]) =>
|
||||||
|
tag[0] !== 'name' &&
|
||||||
|
tag[0] !== 'display_name' &&
|
||||||
|
tag[0] !== 'picture' &&
|
||||||
|
tag[0] !== 'banner' &&
|
||||||
|
tag[0] !== 'website' &&
|
||||||
|
tag[0] !== 'about' &&
|
||||||
|
tag[0] !== 'nip05' &&
|
||||||
|
tag[0] !== 'lud16' &&
|
||||||
|
tag[0] !== 'client'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse and store original content
|
||||||
|
try {
|
||||||
|
this.#originalContent = JSON.parse(latestEvent.content);
|
||||||
|
|
||||||
|
// Update form with values from event content
|
||||||
|
this.profile = {
|
||||||
|
name: (this.#originalContent['name'] as string) || '',
|
||||||
|
display_name:
|
||||||
|
(this.#originalContent['display_name'] as string) ||
|
||||||
|
(this.#originalContent['displayName'] as string) ||
|
||||||
|
'',
|
||||||
|
picture: (this.#originalContent['picture'] as string) || '',
|
||||||
|
banner: (this.#originalContent['banner'] as string) || '',
|
||||||
|
website: (this.#originalContent['website'] as string) || '',
|
||||||
|
about: (this.#originalContent['about'] as string) || '',
|
||||||
|
nip05: (this.#originalContent['nip05'] as string) || '',
|
||||||
|
lud16: (this.#originalContent['lud16'] as string) || '',
|
||||||
|
lnurl: (this.#originalContent['lnurl'] as string) || '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
console.error('Failed to parse profile content');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
pool.close(FALLBACK_PROFILE_RELAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async #queryWithTimeout(pool: SimplePool, relays: string[], filters: any[], timeoutMs: number): Promise<any[]> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const events: any[] = [];
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
const sub = pool.subscribeMany(relays, filters, {
|
||||||
|
onevent(event) {
|
||||||
|
events.push(event);
|
||||||
|
},
|
||||||
|
oneose() {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
sub.close();
|
||||||
|
resolve(events);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async onClickSave() {
|
||||||
|
if (this.saving || !this.#privkey || !this.#pubkey) return;
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.alertMessage = undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build the content JSON, preserving extra fields
|
||||||
|
const content: Record<string, unknown> = { ...this.#originalContent };
|
||||||
|
|
||||||
|
// Update with form values
|
||||||
|
content['name'] = this.profile.name;
|
||||||
|
content['display_name'] = this.profile.display_name;
|
||||||
|
content['displayName'] = this.profile.display_name; // Some clients use this
|
||||||
|
content['picture'] = this.profile.picture;
|
||||||
|
content['banner'] = this.profile.banner;
|
||||||
|
content['website'] = this.profile.website;
|
||||||
|
content['about'] = this.profile.about;
|
||||||
|
content['nip05'] = this.profile.nip05;
|
||||||
|
content['lud16'] = this.profile.lud16;
|
||||||
|
if (this.profile.lnurl) {
|
||||||
|
content['lnurl'] = this.profile.lnurl;
|
||||||
|
}
|
||||||
|
content['pubkey'] = this.#pubkey;
|
||||||
|
|
||||||
|
// Build tags array, preserving extra tags
|
||||||
|
const tags: string[][] = [...this.#originalTags];
|
||||||
|
|
||||||
|
// Add standard tags
|
||||||
|
if (this.profile.name) tags.push(['name', this.profile.name]);
|
||||||
|
if (this.profile.display_name) tags.push(['display_name', this.profile.display_name]);
|
||||||
|
if (this.profile.picture) tags.push(['picture', this.profile.picture]);
|
||||||
|
if (this.profile.banner) tags.push(['banner', this.profile.banner]);
|
||||||
|
if (this.profile.website) tags.push(['website', this.profile.website]);
|
||||||
|
if (this.profile.about) tags.push(['about', this.profile.about]);
|
||||||
|
if (this.profile.nip05) tags.push(['nip05', this.profile.nip05]);
|
||||||
|
if (this.profile.lud16) tags.push(['lud16', this.profile.lud16]);
|
||||||
|
|
||||||
|
// Add alt tag if not present
|
||||||
|
if (!tags.some(t => t[0] === 'alt')) {
|
||||||
|
tags.push(['alt', `User profile for ${this.profile.name || this.profile.display_name || 'user'}`]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add client tag
|
||||||
|
tags.push(['client', 'plebeian-signer']);
|
||||||
|
|
||||||
|
// Create the unsigned event
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: 0,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags,
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sign the event
|
||||||
|
const privkeyBytes = hexToBytes(this.#privkey);
|
||||||
|
const signedEvent = finalizeEvent(unsignedEvent, privkeyBytes);
|
||||||
|
|
||||||
|
// Get write relays from NIP-65 or use fallback
|
||||||
|
await this.#relayList.initialize();
|
||||||
|
const writeRelays = await this.#relayList.fetchRelayList(this.#pubkey);
|
||||||
|
let relayUrls: string[];
|
||||||
|
|
||||||
|
if (writeRelays.length > 0) {
|
||||||
|
// Filter to write relays only
|
||||||
|
relayUrls = writeRelays
|
||||||
|
.filter(r => r.write)
|
||||||
|
.map(r => r.url);
|
||||||
|
|
||||||
|
// If no write relays found, use all relays
|
||||||
|
if (relayUrls.length === 0) {
|
||||||
|
relayUrls = writeRelays.map(r => r.url);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use fallback relays
|
||||||
|
relayUrls = FALLBACK_PROFILE_RELAYS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish to relays with NIP-42 authentication support
|
||||||
|
const results = await publishToRelaysWithAuth(
|
||||||
|
relayUrls,
|
||||||
|
signedEvent,
|
||||||
|
this.#privkey
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count successes
|
||||||
|
const successes = results.filter(r => r.success);
|
||||||
|
const failures = results.filter(r => !r.success);
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.log('Some relays failed:', failures.map(f => `${f.relay}: ${f.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successes.length === 0) {
|
||||||
|
throw new Error('Failed to publish to any relay');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Profile published to ${successes.length}/${results.length} relays`);
|
||||||
|
|
||||||
|
// Clear cached profile and refetch
|
||||||
|
await this.#profileMetadata.clearCacheForPubkey(this.#pubkey);
|
||||||
|
await this.#profileMetadata.fetchProfile(this.#pubkey);
|
||||||
|
|
||||||
|
// Navigate back to identity page
|
||||||
|
this.#router.navigateByUrl('/home/identity');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save profile:', error);
|
||||||
|
this.alertMessage = error instanceof Error ? error.message : 'Failed to save profile';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.alertMessage = undefined;
|
||||||
|
}, 4500);
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onClickCancel() {
|
||||||
|
this.#router.navigateByUrl('/home/identity');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||||
|
|
||||||
<div class="sam-text-header">
|
<div class="sam-text-header">
|
||||||
<span>Plebeian Signer</span>
|
<span>Plebeian Signer</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, ViewChild } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { NavComponent, StorageService } from '@common';
|
import { NavComponent, StorageService, DerivingModalComponent } from '@common';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-new',
|
selector: 'app-new',
|
||||||
imports: [FormsModule],
|
imports: [FormsModule, DerivingModalComponent],
|
||||||
templateUrl: './new.component.html',
|
templateUrl: './new.component.html',
|
||||||
styleUrl: './new.component.scss',
|
styleUrl: './new.component.scss',
|
||||||
})
|
})
|
||||||
export class NewComponent extends NavComponent {
|
export class NewComponent extends NavComponent {
|
||||||
|
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||||
|
|
||||||
password = '';
|
password = '';
|
||||||
|
|
||||||
readonly #router = inject(Router);
|
readonly #router = inject(Router);
|
||||||
@@ -28,7 +30,15 @@ export class NewComponent extends NavComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show deriving modal during key derivation (~3-6 seconds)
|
||||||
|
this.derivingModal.show('Creating secure vault');
|
||||||
|
try {
|
||||||
await this.#storage.createNewVault(this.password);
|
await this.#storage.createNewVault(this.password);
|
||||||
|
this.derivingModal.hide();
|
||||||
this.#router.navigateByUrl('/home/identities');
|
this.#router.navigateByUrl('/home/identities');
|
||||||
|
} catch (error) {
|
||||||
|
this.derivingModal.hide();
|
||||||
|
console.error('Failed to create vault:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<app-deriving-modal #derivingModal></app-deriving-modal>
|
||||||
|
|
||||||
<div class="sam-text-header">
|
<div class="sam-text-header">
|
||||||
<span class="brand">Plebeian Signer</span>
|
<span class="brand">Plebeian Signer</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
ConfirmComponent,
|
ConfirmComponent,
|
||||||
|
DerivingModalComponent,
|
||||||
NostrHelper,
|
NostrHelper,
|
||||||
ProfileMetadataService,
|
ProfileMetadataService,
|
||||||
StartupService,
|
StartupService,
|
||||||
@@ -14,10 +15,11 @@ import { getNewStorageServiceConfig } from '../../common/data/get-new-storage-se
|
|||||||
selector: 'app-vault-login',
|
selector: 'app-vault-login',
|
||||||
templateUrl: './vault-login.component.html',
|
templateUrl: './vault-login.component.html',
|
||||||
styleUrl: './vault-login.component.scss',
|
styleUrl: './vault-login.component.scss',
|
||||||
imports: [FormsModule, ConfirmComponent],
|
imports: [FormsModule, ConfirmComponent, DerivingModalComponent],
|
||||||
})
|
})
|
||||||
export class VaultLoginComponent implements AfterViewInit {
|
export class VaultLoginComponent implements AfterViewInit {
|
||||||
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
|
@ViewChild('passwordInputElement') passwordInput!: ElementRef<HTMLInputElement>;
|
||||||
|
@ViewChild('derivingModal') derivingModal!: DerivingModalComponent;
|
||||||
|
|
||||||
loginPassword = '';
|
loginPassword = '';
|
||||||
showInvalidPasswordAlert = false;
|
showInvalidPasswordAlert = false;
|
||||||
@@ -40,24 +42,38 @@ export class VaultLoginComponent implements AfterViewInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async loginVault() {
|
async loginVault() {
|
||||||
|
console.log('[login] loginVault called');
|
||||||
if (!this.loginPassword) {
|
if (!this.loginPassword) {
|
||||||
|
console.log('[login] No password, returning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('[login] Showing deriving modal');
|
||||||
|
// Show deriving modal during key derivation (~3-6 seconds)
|
||||||
|
this.derivingModal.show('Unlocking vault');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('[login] Calling unlockVault...');
|
||||||
await this.#storage.unlockVault(this.loginPassword);
|
await this.#storage.unlockVault(this.loginPassword);
|
||||||
|
console.log('[login] unlockVault succeeded!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[login] unlockVault FAILED:', error);
|
||||||
|
this.derivingModal.hide();
|
||||||
|
this.showInvalidPasswordAlert = true;
|
||||||
|
window.setTimeout(() => {
|
||||||
|
this.showInvalidPasswordAlert = false;
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock succeeded - hide modal and navigate
|
||||||
|
console.log('[login] Hiding modal and navigating');
|
||||||
|
this.derivingModal.hide();
|
||||||
|
|
||||||
// Fetch profile metadata for all identities in the background
|
// Fetch profile metadata for all identities in the background
|
||||||
this.#fetchAllProfiles();
|
this.#fetchAllProfiles();
|
||||||
|
|
||||||
this.#router.navigateByUrl('/home/identity');
|
this.#router.navigateByUrl('/home/identity');
|
||||||
} catch (error) {
|
|
||||||
this.showInvalidPasswordAlert = true;
|
|
||||||
console.log(error);
|
|
||||||
window.setTimeout(() => {
|
|
||||||
this.showInvalidPasswordAlert = false;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
import { Event, EventTemplate } from 'nostr-tools';
|
import { Event, EventTemplate } from 'nostr-tools';
|
||||||
import { Nip07Method } from '@common';
|
import { Nip07Method } from '@common';
|
||||||
|
|
||||||
|
// Extend Window interface for NIP-07
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
nostr?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type Relays = Record<string, { read: boolean; write: boolean }>;
|
type Relays = Record<string, { read: boolean; write: boolean }>;
|
||||||
|
|
||||||
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable
|
// Fallback UUID generator for contexts where crypto.randomUUID is unavailable
|
||||||
|
|||||||
@@ -101,3 +101,30 @@ button {
|
|||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bootstrap modal overrides - always use dark theme for modals
|
||||||
|
.modal-content {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
border-color: #3d3d3d;
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom-color: #3d3d3d;
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
filter: invert(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top-color: #3d3d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
color: #fafafa;
|
||||||
|
}
|
||||||
|
|||||||
BIN
releases/plebeian-signer-chrome-v1.0.0.zip
Normal file
BIN
releases/plebeian-signer-chrome-v1.0.0.zip
Normal file
Binary file not shown.
BIN
releases/plebeian-signer-firefox-v1.0.0.zip
Normal file
BIN
releases/plebeian-signer-firefox-v1.0.0.zip
Normal file
Binary file not shown.
Reference in New Issue
Block a user