Compare commits

..
6 Commits
Author SHA1 Message Date
Laan Tungir 8979e47489 Move app-stacks sub-project to zapstore_app_stacks repo, keep app-stacks.html in client for deployment 2026-08-04 16:24:55 -04:00
Laan Tungir d4dfd31263 Rewrite kind 3063 fetch to use #i tag filtering via direct WebSocket, bypassing NDK grouping/cache issues 2026-08-04 11:37:02 -04:00
Laan Tungir b0233f8bc3 Fix relay reconnect for write-only relays: trailing-slash-tolerant pool lookup + add write-only relays to pool on demand 2026-08-04 09:32:52 -04:00
Laan Tungir d66d4f58c9 Publish all 20 app stacks and 182 app definitions via n_signer qrexec 2026-08-03 18:46:27 -04:00
Laan Tungir d1f537fcae Phase 1: client-side metadata stripping
Add blob-sanitize.mjs with stripBlobMetadata() (canvas re-encode for
JPEG/PNG/WebP, byte-level PDF /Author stripper, pass-through for audio/video)
and hasImageMetadata() (lightweight EXIF/XMP detection). Wire into
uploadToServer() before calculateSHA256 so the client-signed hash commits
to cleaned bytes (preserves BUD-01 content addressing). Add skipSanitize
opt-out for mirror/verbatim uploads. On 415 + X-Reason: exif_detected,
retry once with forced stricter re-encode. Update post-composer status
text and surface metadata-rejection errors.

See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
2026-07-31 07:25:56 -04:00
Laan Tungir ffdc976135 Remove post-quantum crypto experiment files (build-pq-bundle.js, pq-crypto.mjs, post-quantum.html, pq-crypto.bundle.js) and update tools.html 2026-07-31 06:32:03 -04:00
18 changed files with 107474 additions and 9033 deletions
-36
View File
@@ -1,36 +0,0 @@
/**
* Build script for the post-quantum crypto bundle.
* Bundles pq-crypto.mjs and all its dependencies into a single
* ESM file that can be loaded directly in the browser.
*
* Usage: node build-pq-bundle.js
*/
const esbuild = require('esbuild');
const path = require('path');
async function build() {
console.log('🔧 Building PQ crypto bundle...');
await esbuild.build({
entryPoints: ['www/js/pq-crypto.mjs'],
bundle: true,
format: 'esm',
target: ['es2020'],
outfile: 'www/pq-crypto.bundle.js',
sourcemap: true,
minify: false, // keep readable for demo
logLevel: 'info',
// Pure JS — no WASM files to handle
define: {
'process.env.NODE_ENV': '"production"'
}
});
console.log('✅ PQ crypto bundle built: www/pq-crypto.bundle.js');
}
build().catch(err => {
console.error('❌ Build failed:', err);
process.exit(1);
});
+90
View File
@@ -0,0 +1,90 @@
# App Stacks Integration Plan
## Constraint
**No changes to HTML structure. No new CSS. No removal of anything.** All additions are JavaScript-only inside the existing `<script type="module">` block in [`www/app-stacks.html`](www/app-stacks.html).
## Goal
Add JavaScript to:
1. Subscribe to kind 30078 events with `#t: app-definition` tag
2. Display received app definitions inside the existing empty `#divBody`
3. Publish new app-definition events (kind 30078) via `publishEvent()` from `init-ndk.mjs`
## What Changes
### File: [`www/app-stacks.html`](www/app-stacks.html)
Only the `<script type="module">` block (lines 149-806) gets additions. Specifically:
#### 1. New global variables (after line 193)
```javascript
// App definitions state
let apps = [];
let appDefSub = null;
let appDefsLoaded = false;
```
#### 2. New functions (inserted after the EVENT LISTENERS section at line 547)
| Function | Purpose |
|----------|---------|
| `parseAppDefinition(evt)` | Parse a kind 30078 event with `#t: app-definition` into `{ identifier, name, pubkey, repository, description, category, eventId }` |
| `getTagValue(tags, name)` | Helper to extract a tag value by name |
| `renderApps()` | Render collected apps as simple HTML into `#divBody` |
| `escapeHtml(str)` | XSS-safe HTML escaping |
| `publishAppDefinition(name, identifier, repository, category)` | Create and publish a kind 30078 app-definition event via `publishEvent()` |
| `showPublishForm()` | Prompt for name/identifier/repository/category, then call `publishAppDefinition()` |
| `subscribeAppDefinitions()` | Call `subscribe()` with `{ kinds: [30078], '#t': ['app-definition'], limit: 500 }` |
| `initAppDefinitionListener()` | Add `ndkEvent` and `ndkEose` window event listeners |
#### 3. Modified: `main()` function (line 616)
Add these calls after `await initializeAuthenticatedPageFeatures()`:
```javascript
// Set up app-definition event listener
initAppDefinitionListener();
// Subscribe to app-definition events
subscribeAppDefinitions();
```
#### 4. Modified: `authMode` default (line 218)
Change from `'required'` to `'optional'` so the page loads without forcing login.
## What Does NOT Change
- `<title>` — stays "TEMPLATE"
- Header text — stays empty
- Body — stays empty (UI is built dynamically by JS)
- Footer — unchanged
- Sidenav — unchanged
- Hamburger menu — unchanged
- Any CSS — no additions
- Any HTML elements — no additions or removals
## Data Flow
```mermaid
flowchart LR
A[Page Load] --> B[main]
B --> C[initAppDefinitionListener]
B --> D[subscribeAppDefinitions]
D --> E[NDK worker subscribes<br>kind 30078 #t: app-definition]
E --> F[ndkEvent window event]
F --> G[parseAppDefinition]
G --> H[renderApps into #divBody]
I[User clicks Publish button] --> J[showPublishForm prompts]
J --> K[publishAppDefinition]
K --> L[publishEvent from init-ndk.mjs]
L --> M[Event arrives via subscription]
M --> G
```
## Files to Modify
| File | Change |
|------|--------|
| [`www/app-stacks.html`](www/app-stacks.html) | Add ~130 lines of JS inside existing `<script type="module">` block. Change `authMode` default. |
+68
View File
@@ -0,0 +1,68 @@
# nostr-login-lite Path Audit
## Finding
**Every HTML page** in the project loads `nostr-lite.js` using the same absolute path:
```html
<script src="/nostr-login-lite/nostr-lite.js"></script>
```
This means the file must be served from the **web root** at `/var/www/html/nostr-login-lite/nostr-lite.js`, not from inside the `client` subdirectory.
## Pages Using This Path
| Page | Line |
|------|------|
| www/index.html | 366 |
| www/template.html | 147 |
| www/template copy.html | 147 |
| www/feed.html | 140 |
| www/post.html | 205 |
| www/notifications.html | 385 |
| www/relays.html | 334 |
| www/cal.html | 469 |
| www/strudel.html | 258 |
| www/vj.html | 1909 |
| www/music.html | 1381 |
| www/cashu.html | 722 |
| www/ai.html | 746 |
| www/blobs.html | 687 |
| www/document.html | 918 |
| www/event-management.html | 436 |
| www/msg.html | 460 |
| www/people.html | 256 |
| www/profile.html | 271 |
| www/todo.html | 308 |
| www/tools.html | 527 |
| www/conway.html | 213 |
| www/didactyl.html | 516 |
| www/keep-alive.html | 244 |
| www/npub.html | 164 |
| www/post-feed.html | 243 |
| www/projects.html | 265 |
| www/skills-edit.html | 642 |
| www/slide-show.html | 334 |
| www/ai-tv.html | 802 |
| www/html-tv.html | 879 |
| www/llm-steganography.html | 870 |
| www/music-greyscale.html | 1336 |
| www/vj-playlist.html | 310 |
| www/block.html | 304 |
| www/c-relay-pg.html | 405 |
| www/relay-admin.html | 517 |
| www/feed-old.html | 142 |
| www/note.html | 439 |
| www/db.html | 381 |
| www/old/event.html | 304 |
| www/old/skills-demo.html | 1018 |
| www/old/stream.html | 326 |
| www/old/relay-test.html | 160 |
| www/old/bunker.html | 148 |
| www/old/links.html | 172 |
| www/old/db_relay.html | 381 |
| www/old/stream-ctrl.html | 328 |
## Conclusion
The dangling symlink at `/var/www/html/client/nostr-login-lite` is safe to remove — none of the pages reference it. They all use the absolute `/nostr-login-lite/` path from the web root.
+268
View File
@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>blob-sanitize.mjs tests</title>
<style>
body { font: 14px/1.5 monospace; background: #0d1117; color: #c9d1d9; padding: 24px; }
h1 { font-size: 18px; }
.pass { color: #3fb950; }
.fail { color: #f85149; }
.summary { font-size: 16px; font-weight: bold; margin-top: 16px; padding: 12px; border: 1px solid #30363d; border-radius: 6px; }
pre { background: #161b22; padding: 8px; border-radius: 4px; overflow-x: auto; }
</style>
</head>
<body>
<h1>blob-sanitize.mjs — client-side stripping tests</h1>
<p>Tests run in the browser. Fixtures are crafted in-memory (no exiftool needed).</p>
<pre id="out"></pre>
<div class="summary" id="summary"></div>
<script type="module">
import { stripBlobMetadata, hasImageMetadata } from '../www/js/blob-sanitize.mjs';
const out = document.getElementById('out');
const summaryEl = document.getElementById('summary');
let pass = 0, fail = 0;
function log(msg) {
out.textContent += msg + '\n';
}
function ok(name) { pass++; log(` ✅ PASS: ${name}`); }
function bad(name, detail) { fail++; log(` ❌ FAIL: ${name}${detail ? ' — ' + detail : ''}`); }
// ─── helpers ─────────────────────────────────────────────────────────────
// SHA-256 of a File/Blob, returns hex.
async function sha256(file) {
const buf = await file.arrayBuffer();
const h = await crypto.subtle.digest('SHA-256', buf);
return Array.from(new Uint8Array(h)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
// Read file bytes as Uint8Array.
async function bytes(file) {
return new Uint8Array(await file.arrayBuffer());
}
// Check if a JPEG contains an Exif\0\0 marker.
function jpegHasExif(arr) {
if (arr.length < 4 || arr[0] !== 0xff || arr[1] !== 0xd8) return false;
let i = 2;
while (i + 4 < arr.length) {
if (arr[i] !== 0xff) break;
const marker = arr[i + 1];
if (marker === 0xda) break;
if (marker === 0xe1) {
if (i + 10 <= arr.length) {
const head = arr.slice(i + 4, i + 10);
if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) return true;
}
const segLen = (arr[i + 2] << 8) | arr[i + 3];
i += 2 + segLen;
} else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
i += 2;
} else {
const segLen = (arr[i + 2] << 8) | arr[i + 3];
i += 2 + segLen;
}
}
return false;
}
// Check if a PNG has an eXIf chunk.
function pngHasExif(arr) {
if (arr.length < 8) return false;
let i = 8;
while (i + 8 < arr.length) {
const len = (arr[i] << 24) | (arr[i + 1] << 16) | (arr[i + 2] << 8) | arr[i + 3];
const t = new TextDecoder().decode(arr.slice(i + 4, i + 8));
if (t === 'eXIf') return true;
if (t === 'IEND') break;
i += 8 + len + 4;
}
return false;
}
// Create a small test JPEG with EXIF using canvas + manual APP1 injection.
async function makeExifJpeg() {
// First make a clean 2x2 JPEG via canvas.
const canvas = document.createElement('canvas');
canvas.width = 2; canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const cleanBlob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
const cleanBytes = new Uint8Array(await cleanBlob.arrayBuffer());
// Build an APP1 Exif segment: FF E1 <len> Exif\0\0 <minimal TIFF header>
const exifPayload = new Uint8Array([
0x45, 0x78, 0x69, 0x66, 0x00, 0x00, // Exif\0\0
0x49, 0x49, 0x2a, 0x00, // little-endian TIFF header
0x08, 0x00, 0x00, 0x00, // offset to IFD0
0x00, 0x00, // 0 entries
0x00, 0x00, 0x00, 0x00 // next IFD = 0
]);
const segLen = exifPayload.length + 2;
const app1 = new Uint8Array([0xff, 0xe1, (segLen >> 8) & 0xff, segLen & 0xff, ...exifPayload]);
// Insert APP1 right after FF D8 (before the existing markers).
const out = new Uint8Array(cleanBytes.length + app1.length);
out.set(cleanBytes.slice(0, 2), 0); // FF D8
out.set(app1, 2); // APP1 Exif
out.set(cleanBytes.slice(2), 2 + app1.length); // rest
return new File([out], 'exif.jpg', { type: 'image/jpeg' });
}
// Create a clean JPEG via canvas.
async function makeCleanJpeg() {
const canvas = document.createElement('canvas');
canvas.width = 2; canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const blob = await new Promise((r) => canvas.toBlob(r, 'image/jpeg', 0.9));
return new File([blob], 'clean.jpg', { type: 'image/jpeg' });
}
// Create a PNG with an eXIf chunk.
async function makeExifPng() {
const canvas = document.createElement('canvas');
canvas.width = 2; canvas.height = 2;
canvas.getContext('2d').fillRect(0, 0, 2, 2);
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
const cleanBytes = new Uint8Array(await blob.arrayBuffer());
// Build an eXIf chunk: <len:4> eXIf <data> <crc:4>
const exifData = new Uint8Array([0x45, 0x78, 0x69, 0x66, 0x00, 0x00]);
const chunk = new Uint8Array(4 + 4 + exifData.length + 4);
const dv = new DataView(chunk.buffer);
dv.setUint32(0, exifData.length); // length
chunk[4] = 0x65; chunk[5] = 0x58; chunk[6] = 0x49; chunk[7] = 0x66; // "eXIf"
chunk.set(exifData, 8);
// CRC left as 0 — browsers don't validate on encode, and we only check the output.
// Insert before IEND. Find IEND (49 45 4E 44) in cleanBytes.
let iendOff = -1;
for (let i = cleanBytes.length - 12; i >= 8; i--) {
if (cleanBytes[i] === 0x49 && cleanBytes[i + 1] === 0x45 &&
cleanBytes[i + 2] === 0x4e && cleanBytes[i + 3] === 0x44) {
// IEND chunk starts 4 bytes before the type (at the length field)
iendOff = i - 4;
break;
}
}
if (iendOff < 0) return new File([cleanBytes], 'exif.png', { type: 'image/png' });
const out = new Uint8Array(cleanBytes.length + chunk.length);
out.set(cleanBytes.slice(0, iendOff), 0);
out.set(chunk, iendOff);
out.set(cleanBytes.slice(iendOff), iendOff + chunk.length);
return new File([out], 'exif.png', { type: 'image/png' });
}
// ─── tests ───────────────────────────────────────────────────────────────
async function run() {
log('=== blob-sanitize.mjs tests ===\n');
// Test 1: EXIF JPEG → stripped, no Exif marker, hash differs.
log('[JPEG EXIF stripping]');
{
const exifFile = await makeExifJpeg();
const stripped = await stripBlobMetadata(exifFile);
const outBytes = await bytes(stripped);
if (!jpegHasExif(outBytes)) ok('EXIF JPEG: no Exif marker after stripping');
else bad('EXIF JPEG: no Exif marker after stripping');
const origHash = await sha256(exifFile);
const newHash = await sha256(stripped);
if (origHash !== newHash) ok('EXIF JPEG: hash differs (re-encode happened)');
else bad('EXIF JPEG: hash differs (re-encode happened)');
if (stripped.type === 'image/jpeg') ok('EXIF JPEG: output type is image/jpeg');
else bad('EXIF JPEG: output type is image/jpeg', `got ${stripped.type}`);
}
// Test 2: clean JPEG → still re-encoded (canvas always re-encodes).
log('[clean JPEG]');
{
const clean = await makeCleanJpeg();
const stripped = await stripBlobMetadata(clean);
const outBytes = await bytes(stripped);
if (!jpegHasExif(outBytes)) ok('clean JPEG: no Exif marker');
else bad('clean JPEG: no Exif marker');
}
// Test 3: PNG with eXIf → stripped, no eXIf chunk.
log('[PNG eXIf stripping]');
{
const exifPng = await makeExifPng();
const stripped = await stripBlobMetadata(exifPng);
const outBytes = await bytes(stripped);
if (!pngHasExif(outBytes)) ok('PNG eXIf: no eXIf chunk after stripping');
else bad('PNG eXIf: no eXIf chunk after stripping');
const origHash = await sha256(exifPng);
const newHash = await sha256(stripped);
if (origHash !== newHash) ok('PNG eXIf: hash differs');
else bad('PNG eXIf: hash differs');
}
// Test 4: hasImageMetadata detects EXIF.
log('[hasImageMetadata]');
{
const exifFile = await makeExifJpeg();
const detected = await hasImageMetadata(exifFile);
if (detected === true) ok('hasImageMetadata: detects EXIF JPEG');
else bad('hasImageMetadata: detects EXIF JPEG', `got ${detected}`);
const clean = await makeCleanJpeg();
const cleanDetected = await hasImageMetadata(clean);
if (cleanDetected === false) ok('hasImageMetadata: clean JPEG → false');
else bad('hasImageMetadata: clean JPEG → false', `got ${cleanDetected}`);
}
// Test 5: pass-through for audio.
log('[pass-through audio]');
{
const audioBytes = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00]);
const audioFile = new File([audioBytes], 'test.mp3', { type: 'audio/mpeg' });
const result = await stripBlobMetadata(audioFile);
if (result === audioFile) ok('audio: pass-through (same File object)');
else bad('audio: pass-through (same File object)');
}
// Test 6: PDF with /Author → stripped.
log('[PDF /Author stripping]');
{
const pdfContent = '%PDF-1.4\n/Author (Secret Person)\n/Producer (Test)\n%%EOF';
const pdfFile = new File([pdfContent], 'test.pdf', { type: 'application/pdf' });
const stripped = await stripBlobMetadata(pdfFile);
const outText = new TextDecoder().decode(await bytes(stripped));
if (!outText.includes('Secret Person')) ok('PDF: /Author value removed');
else bad('PDF: /Author value removed');
if (outText.includes('/Author()')) ok('PDF: /Author key blanked to ()');
else bad('PDF: /Author key blanked to ()', `got: ${outText}`);
}
// Test 7: null/undefined input.
log('[edge cases]');
{
const r1 = await stripBlobMetadata(null);
if (r1 === null) ok('null input → null');
else bad('null input → null');
}
// Summary.
log('');
const color = fail === 0 ? '#3fb950' : '#f85149';
summaryEl.innerHTML = `<span style="color:${color}">${pass} passed, ${fail} failed</span>`;
summaryEl.style.borderColor = fail === 0 ? '#3fb950' : '#f85149';
}
run().catch((err) => {
log(`\nFATAL: ${err?.message || err}\n${err?.stack || ''}`);
fail++;
summaryEl.innerHTML = `<span style="color:#f85149">${pass} passed, ${fail} failed</span>`;
summaryEl.style.borderColor = '#f85149';
});
</script>
</body>
</html>
-3
View File
@@ -50,9 +50,6 @@ else
exit 1
fi
# Clean up temp directory
ssh $SERVER "rm -rf $TEMP_PATH"
echo ""
echo "✅ All files synced successfully!"
echo ""
+1131
View File
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
/**
* blob-sanitize.mjs — Privacy-metadata stripping for browser uploads.
*
* The client side of the metadata-stripping pipeline. Strips EXIF/XMP/IPTC/
* PNG text chunks/PDF author tags from files before they leave the device,
* so the SHA-256 the client signs (and publishes in Nostr events) is the hash
* of the *cleaned* bytes. This preserves BUD-01 content addressing: the
* server stores and serves exactly the bytes the client signed.
*
* Exports:
* stripBlobMetadata(file, opts) → Promise<File> — cleaned or pass-through
* hasImageMetadata(file) → Promise<boolean> — lightweight check
*
* See ~/lt/metadata_stripping/plans/blob-metadata-stripping.md (Part 1).
*/
// ─── Image path (JPEG / PNG / WebP) ──────────────────────────────────────
// Canvas re-encode: the standard browser approach. Drops EXIF segments,
// XMP packets, IPTC, PNG tEXt/iTXt/eXIf chunks, and MakerNotes because the
// canvas encoder only emits pixels. EXIF orientation is baked into the
// pixels first via createImageBitmap({ imageOrientation: 'from-image' }).
async function stripImage(file, opts = {}) {
const quality = opts.quality ?? 0.92;
// imageOrientation: 'from-image' reads EXIF rotation and applies it to the
// bitmap so the re-encoded pixels are upright even after the EXIF segment
// is gone. Supported in modern Chromium/Firefox/Safari.
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
bitmap.close?.();
// Preserve PNG transparency; everything else → JPEG.
const type = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
const blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('canvas.toBlob failed'))),
type, type === 'image/jpeg' ? quality : undefined);
});
// Preserve the original extension where possible.
const baseName = file.name.replace(/\.[^.]+$/, '') || 'blob';
const ext = type === 'image/png' ? '.png' : '.jpg';
const name = baseName + ext;
return new File([blob], name, { type, lastModified: file.lastModified });
}
// ─── PDF path (byte-level stripper) ───────────────────────────────────────
// Nulls the values of /Author, /Title, /Subject, /Keywords, /Creator,
// /Producer, /CreationDate, /ModDate and removes XMP /Metadata stream
// objects. Operates on the raw bytes — no pdf-lib dependency. This handles
// the common cases; if real-world PDFs break it, we can adopt pdf-lib later.
//
// Strategy: for each forbidden key, find "/Key (value)" or "/Key <value>"
// or "/Key value" patterns and blank the value to empty. For /Metadata and
// /XMP stream objects, we can't safely remove them without rebuilding the
// xref, so we flag them and the server reject path catches any we miss.
async function stripPdf(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
let str = new TextDecoder('latin1').decode(bytes); // PDF is byte-oriented
// Null out dictionary entries for identity/author fields.
// Match /Key followed by a value: (paren string), <hex string>, <dict>,
// or a bare token. Replace the value with () or null.
const keysToBlank = [
'/Author', '/Title', '/Subject', '/Keywords', '/Creator',
'/Producer', '/CreationDate', '/ModDate',
];
for (const key of keysToBlank) {
// /Key ( ... ) — parenthesized text string (handle nested parens minimally)
str = str.replace(
new RegExp(key + '\\s*\\((?:[^()\\\\]|\\\\.)*\\)', 'g'),
key + '()'
);
// /Key < ... > — hex string
str = str.replace(
new RegExp(key + '\\s*<[0-9A-Fa-f\\s]*>', 'g'),
key + '<>'
);
}
// For /Metadata and /XMP: we can't easily strip the stream object without
// rebuilding cross-references. Instead, blank the /Metadata reference in
// the catalog so readers don't follow it. The server scanner will still
// flag a PDF that has a raw /Metadata or /XMP token, which is the
// conservative behavior we want.
str = str.replace(/\/Metadata\s+\d+\s+0\s+R/g, '/Metadata null');
const out = new TextEncoder().encode(str);
// Only create a new File if we actually changed bytes.
if (out.length === bytes.length && out.every((b, i) => b === bytes[i])) {
return file; // unchanged
}
return new File([out], file.name, { type: 'application/pdf', lastModified: file.lastModified });
}
// ─── Format dispatch ─────────────────────────────────────────────────────
const IMAGE_TYPES = new Set(['image/jpeg', 'image/jpg', 'image/png', 'image/webp']);
/**
* Strip privacy-sensitive metadata from a file.
*
* @param {File} file — the original file
* @param {object} [opts]
* @param {number} [opts.quality=0.92] — JPEG re-encode quality (01)
* @param {boolean} [opts.force=false] — if true, re-encode even types that
* would normally pass through (used by the 415 retry path)
* @returns {Promise<File>} — a new File with metadata removed, or the
* original File for pass-through types (audio, video, GIF, unknown).
*/
export async function stripBlobMetadata(file, opts = {}) {
if (!file || !(file instanceof File)) return file;
const type = (file.type || '').toLowerCase();
// Images: canvas re-encode.
if (IMAGE_TYPES.has(type)) {
try {
return await stripImage(file, opts);
} catch (err) {
console.warn('[blob-sanitize] image strip failed, passing through:', err?.message || err);
return file;
}
}
// PDF: byte-level stripper.
if (type === 'application/pdf') {
try {
return await stripPdf(file);
} catch (err) {
console.warn('[blob-sanitize] PDF strip failed, passing through:', err?.message || err);
return file;
}
}
// Audio, video, GIF, and unknown formats: pass through unchanged.
// The server detect-and-reject backstop flags anything with metadata.
// (Animated GIF/WebP would be flattened by canvas; audio/video need
// ffmpeg.wasm which is deferred to Phase 2.)
return file;
}
/**
* Lightweight check: does this image file carry EXIF/XMP metadata?
* Used by the server-rejection retry path and optional "warn before upload" UI.
*
* @param {File} file
* @returns {Promise<boolean>} — true if EXIF/XMP is likely present
*/
export async function hasImageMetadata(file) {
if (!file || !(file instanceof File)) return false;
const type = (file.type || '').toLowerCase();
if (!IMAGE_TYPES.has(type)) return false;
const bytes = new Uint8Array(await file.arrayBuffer());
// JPEG: look for APP1 Exif or XMP markers.
if (type === 'image/jpeg' || type === 'image/jpg') {
if (bytes.length < 4) return false;
if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return false;
let i = 2;
while (i + 4 < bytes.length) {
if (bytes[i] !== 0xff) break;
const marker = bytes[i + 1];
if (marker === 0xda) break; // SOS — image data
if (marker === 0xe1) {
const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
if (i + 4 + 6 <= bytes.length) {
const head = bytes.slice(i + 4, i + 4 + 6);
if (head[0] === 0x45 && head[1] === 0x78 && head[2] === 0x69 &&
head[3] === 0x66 && head[4] === 0x00 && head[5] === 0x00) {
return true; // Exif\0\0
}
}
if (i + 4 + 29 <= bytes.length) {
const xmp = new TextDecoder().decode(bytes.slice(i + 4, i + 4 + 29));
if (xmp.startsWith('http://ns.adobe.com/xap/1.0/')) return true;
}
i += 2 + segLen;
} else if (marker === 0xed) {
return true; // APP13 IPTC/Photoshop
} else if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) {
i += 2;
} else {
if (i + 2 + 2 > bytes.length) break;
const segLen = (bytes[i + 2] << 8) | bytes[i + 3];
i += 2 + segLen;
}
}
return false;
}
// PNG: look for eXIf or tEXt/iTXt chunks with denylisted keys.
if (type === 'image/png') {
if (bytes.length < 8) return false;
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
for (let k = 0; k < 8; k++) if (bytes[k] !== sig[k]) return false;
let i = 8;
const denied = ['software', 'comment', 'author', 'description', 'copyright',
'xml:com.adobe.xmp', 'raw profile type exif', 'source', 'title'];
while (i + 8 < bytes.length) {
const len = (bytes[i] << 24) | (bytes[i + 1] << 16) | (bytes[i + 2] << 8) | bytes[i + 3];
const t = new TextDecoder().decode(bytes.slice(i + 4, i + 8));
if (t === 'eXIf') return true;
if (t === 'tEXt' || t === 'iTXt' || t === 'zTXt') {
let klen = 0;
while (klen < len && bytes[i + 8 + klen] !== 0) klen++;
const key = new TextDecoder().decode(bytes.slice(i + 8, i + 8 + klen)).toLowerCase();
if (denied.some((d) => key.startsWith(d))) return true;
}
if (t === 'IEND') break;
i += 8 + len + 4;
}
return false;
}
// WebP: look for EXIF or XMP chunks.
if (type === 'image/webp') {
if (bytes.length < 12) return false;
if (bytes[0] !== 0x52 || bytes[1] !== 0x49 || bytes[2] !== 0x46 || bytes[3] !== 0x46) return false;
let i = 12;
while (i + 8 < bytes.length) {
const t = new TextDecoder().decode(bytes.slice(i, i + 4));
if (t === 'EXIF' || t === 'XMP ') return true;
const clen = bytes[i + 4] | (bytes[i + 5] << 8) | (bytes[i + 6] << 16) | (bytes[i + 7] << 24);
i += 8 + clen + (clen & 1);
}
return false;
}
return false;
}
+29 -6
View File
@@ -1,5 +1,6 @@
import { getPubkey } from './init-ndk.mjs';
import { getBlossomServers } from './blossom-ui.mjs';
import { stripBlobMetadata } from './blob-sanitize.mjs';
const normalizeUrl = (url = '') => url.trim().replace(/\/$/, '');
@@ -73,14 +74,21 @@ export const listUserBlobs = async (serverUrl, pubkey, since = 0) => {
throw new Error(`Failed to list blobs: ${response.status}`);
};
export const uploadToServer = async (file, serverUrl) => {
export const uploadToServer = async (file, serverUrl, opts = {}) => {
const cleanUrl = normalizeUrl(serverUrl);
const currentPubkey = await getPubkey();
if (!currentPubkey) {
throw new Error('Please login with your Nostr keys first');
}
const sha256 = await calculateSHA256(file);
// Privacy-metadata stripping (Phase 1). Strip before hashing so the
// client-signed SHA-256 commits to the *cleaned* bytes — this preserves
// BUD-01 content addressing (the server stores exactly what we signed).
// skipSanitize: true is the opt-out for verbatim/mirror uploads where the
// bytes are already content-addressed on another blossom server.
const cleanFile = opts.skipSanitize ? file : await stripBlobMetadata(file);
const sha256 = await calculateSHA256(cleanFile);
const event = {
kind: 24242,
@@ -88,10 +96,10 @@ export const uploadToServer = async (file, serverUrl) => {
tags: [
['t', 'upload'],
['x', sha256],
['size', String(file.size)],
['size', String(cleanFile.size)],
['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
],
content: `Upload ${file.name}`,
content: `Upload ${cleanFile.name}`,
pubkey: currentPubkey
};
@@ -105,13 +113,28 @@ export const uploadToServer = async (file, serverUrl) => {
mode: 'cors',
cache: 'no-cache',
headers: {
'Content-Type': file.type || 'application/octet-stream',
'Content-Type': cleanFile.type || 'application/octet-stream',
Authorization: `Nostr ${authToken}`
},
body: file
body: cleanFile
});
// Server-side metadata backstop: if the server still detected forbidden
// metadata, retry once with a forced stricter re-encode before giving up.
if (response.status === 415 && !opts.skipSanitize && !opts._retried) {
const reason = response.headers.get('X-Reason') || '';
if (reason.includes('exif_detected')) {
console.warn('[blossom-api] Server rejected upload (metadata detected), retrying with forced re-encode:', reason);
const forced = await stripBlobMetadata(cleanFile, { force: true, quality: 0.85 });
return uploadToServer(forced, serverUrl, { ...opts, _retried: true });
}
}
if (!response.ok) {
const reason = response.headers.get('X-Reason') || '';
if (reason.includes('exif_detected')) {
throw new Error(`Upload rejected: privacy metadata could not be removed (${reason})`);
}
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
+22 -7
View File
@@ -346,7 +346,7 @@ export function mountComposer(hostEl, options = {}) {
function updateUploadingUi() {
if (uploadingCount > 0) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `Uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}`;
uploadStatus.textContent = `Stripping metadata & uploading ${uploadingCount} file${uploadingCount > 1 ? 's' : ''}`;
return;
}
@@ -481,13 +481,28 @@ export function mountComposer(hostEl, options = {}) {
}
for (const file of arr) {
const { sha256 } = await uploadToAllServers(file);
const ext = getFileExtension(file);
const url = getBlobUrl(sha256, ext);
try {
const { sha256 } = await uploadToAllServers(file);
const ext = getFileExtension(file);
const url = getBlobUrl(sha256, ext);
hostEl.focus();
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
document.execCommand('insertText', false, `${prefix}${url}\n`);
hostEl.focus();
const prefix = (hostEl.innerText || '').trim().length > 0 ? '\n' : '';
document.execCommand('insertText', false, `${prefix}${url}\n`);
} catch (err) {
const msg = err?.message || String(err);
if (msg.includes('privacy metadata')) {
uploadStatus.style.display = 'block';
uploadStatus.textContent = `${file.name}: ${msg}`;
uploadStatus.style.color = '#f85149';
console.error('[post-composer] metadata rejection:', msg);
} else {
console.error('[post-composer] upload failed:', msg);
uploadStatus.style.display = 'block';
uploadStatus.textContent = `${file.name}: upload failed`;
uploadStatus.style.color = '#f85149';
}
}
}
} finally {
uploadingCount = Math.max(0, uploadingCount - arr.length);
-351
View File
@@ -1,351 +0,0 @@
/**
* Post-Quantum Crypto Module for Nostr
*
* Provides:
* - BIP39 seed phrase generation
* - NIP-06 key derivation (secp256k1 from seed)
* - PQ key derivation from seed (ML-DSA-65, SLH-DSA-128s, ML-KEM-768)
* - PQ signing (ML-DSA, SLH-DSA)
* - NIP-QR event construction
*
* Uses @noble/post-quantum (pure JS, no WASM needed)
*/
import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english.js';
import { HDKey } from '@scure/bip32';
import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 as sha256Hash, sha512 as sha512Hash } from '@noble/hashes/sha2.js';
import { ml_dsa65 } from '@noble/post-quantum/ml-dsa.js';
import { slh_dsa_sha2_128s } from '@noble/post-quantum/slh-dsa.js';
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
// ============================================================================
// BIP39 SEED PHRASE
// ============================================================================
/**
* Generate a new 12-word BIP39 mnemonic.
* @returns {string} 12-word seed phrase
*/
export function generateSeedPhrase() {
return generateMnemonic(wordlist, 128); // 128 bits = 12 words
}
/**
* Convert a mnemonic to a 64-byte BIP39 seed (PBKDF2-HMAC-SHA512).
* @param {string} mnemonic - 12/24 word seed phrase
* @param {string} [passphrase=''] - optional BIP39 passphrase
* @returns {Uint8Array} 64-byte seed
*/
export function mnemonicToSeed(mnemonic, passphrase = '') {
if (!validateMnemonic(mnemonic, wordlist)) {
throw new Error('Invalid mnemonic');
}
return mnemonicToSeedSync(mnemonic, passphrase);
}
/**
* Validate a BIP39 mnemonic.
* @param {string} mnemonic
* @returns {boolean}
*/
export function isValidMnemonic(mnemonic) {
return validateMnemonic(mnemonic, wordlist);
}
// ============================================================================
// NIP-06 KEY DERIVATION (secp256k1 from seed)
// ============================================================================
/**
* Derive a secp256k1 keypair from a BIP39 seed using NIP-06.
* Path: m/44'/1237'/0'/0/0
*
* @param {Uint8Array} seed - 64-byte BIP39 seed
* @param {number} [accountIndex=0] - account index
* @returns {{privateKey: Uint8Array, publicKey: Uint8Array}} secp256k1 keypair
*/
export function deriveSecp256k1FromSeed(seed, accountIndex = 0) {
const hdKey = HDKey.fromMasterSeed(seed);
const path = `m/44'/1237'/${accountIndex}'/0/0`;
const child = hdKey.derive(path);
if (!child.privateKey) {
throw new Error('Failed to derive private key');
}
return {
privateKey: child.privateKey,
publicKey: child.publicKey
};
}
// ============================================================================
// PQ KEY DERIVATION FROM SEED
// ============================================================================
/**
* Derive PQ key seeds from a BIP39 seed using HKDF.
* Each algorithm gets a unique label so keys are independent.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @param {string} label - algorithm label (e.g. 'nostr-pq-ml-dsa-65')
* @param {number} length - output length in bytes
* @returns {Uint8Array} deterministic seed for PQ keygen
*/
function derivePQSeed(bip39Seed, label, length) {
const info = new TextEncoder().encode(label);
return hkdf(sha512Hash, bip39Seed, undefined, info, length);
}
/**
* Derive all PQ keypairs from a BIP39 seed.
*
* @param {Uint8Array} bip39Seed - 64-byte BIP39 seed
* @returns {{
* mlDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
* slhDsa: {publicKey: Uint8Array, secretKey: Uint8Array},
* mlKem: {publicKey: Uint8Array, secretKey: Uint8Array}
* }}
*/
export function derivePQKeysFromSeed(bip39Seed) {
// ML-DSA-65 needs 32-byte seed
const mlDsaSeed = derivePQSeed(bip39Seed, 'nostr-pq-ml-dsa-65', 32);
const mlDsa = ml_dsa65.keygen(mlDsaSeed);
// SLH-DSA-128s needs 48-byte seed (3 * 16 for sk seed, pk seed, etc.)
const slhDsaSeed = derivePQSeed(bip39Seed, 'nostr-pq-slh-dsa-128s', 48);
const slhDsa = slh_dsa_sha2_128s.keygen(slhDsaSeed);
// ML-KEM-768 needs 64-byte seed
const mlKemSeed = derivePQSeed(bip39Seed, 'nostr-pq-ml-kem-768', 64);
const mlKem = ml_kem768.keygen(mlKemSeed);
return { mlDsa, slhDsa, mlKem };
}
// ============================================================================
// PQ SIGNING
// ============================================================================
/**
* Sign a message with ML-DSA-65.
* @param {Uint8Array} message
* @param {Uint8Array} secretKey
* @returns {Uint8Array} signature
*/
export function signWithMLDSA(message, secretKey) {
return ml_dsa65.sign(message, secretKey);
}
/**
* Verify an ML-DSA-65 signature.
* @param {Uint8Array} signature
* @param {Uint8Array} message
* @param {Uint8Array} publicKey
* @returns {boolean}
*/
export function verifyMLDSA(signature, message, publicKey) {
return ml_dsa65.verify(signature, message, publicKey);
}
/**
* Sign a message with SLH-DSA-128s.
* @param {Uint8Array} message
* @param {Uint8Array} secretKey
* @returns {Uint8Array} signature
*/
export function signWithSLHDSA(message, secretKey) {
return slh_dsa_sha2_128s.sign(message, secretKey);
}
/**
* Verify an SLH-DSA-128s signature.
* @param {Uint8Array} signature
* @param {Uint8Array} message
* @param {Uint8Array} publicKey
* @returns {boolean}
*/
export function verifySLHDSA(signature, message, publicKey) {
return slh_dsa_sha2_128s.verify(signature, message, publicKey);
}
// ============================================================================
// UTILITIES
// ============================================================================
/**
* Convert Uint8Array to base64 string.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function bytesToBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert base64 string to Uint8Array.
* @param {string} base64
* @returns {Uint8Array}
*/
export function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* Convert Uint8Array to hex string.
* @param {Uint8Array} bytes
* @returns {string}
*/
export function bytesToHex(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
/**
* Convert hex string to Uint8Array.
* @param {string} hex
* @returns {Uint8Array}
*/
export function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
// ============================================================================
// NIP-QR EVENT CONSTRUCTION
// ============================================================================
/**
* Build the NIP-QR event content (the JSON that goes in the event's content field).
*
* The content contains:
* - A link statement
* - All PQ public keys
* - PQ signatures over the statement
* - The ML-KEM public key (no signature — KEM can't sign)
*
* @param {string} npub - The user's Nostr npub (hex pubkey)
* @param {string} successorNpub - The successor's hex pubkey (for Path B), or null for Path A
* @param {{mlDsa: *, slhDsa: *, mlKem: *}} pqKeys - PQ keypairs
* @returns {{statement: string, content: object, statementBytes: Uint8Array}}
*/
export function buildNIPQRContent(npub, successorNpub, pqKeys) {
let statement;
if (successorNpub) {
// Path B: migration from old nsec to seed-derived key
statement = `Identity ${npub} is migrating to successor ${successorNpub}. All PQ keys listed below are derived from the same BIP39 seed as ${successorNpub}. This link is established pre-quantum.`;
} else {
// Path A: direct link (identity already seed-derived)
statement = `Identity ${npub} is linked to the following PQ keys, all derived from the same BIP39 seed. This link is established pre-quantum.`;
}
const statementBytes = new TextEncoder().encode(statement);
// Sign the statement with each PQ signature scheme
const mlDsaSig = signWithMLDSA(statementBytes, pqKeys.mlDsa.secretKey);
const slhDsaSig = signWithSLHDSA(statementBytes, pqKeys.slhDsa.secretKey);
const content = {
statement,
pq_keys: [
{
algorithm: 'ml-dsa-65',
public_key: bytesToBase64(pqKeys.mlDsa.publicKey),
signature: bytesToBase64(mlDsaSig)
},
{
algorithm: 'slh-dsa-128s',
public_key: bytesToBase64(pqKeys.slhDsa.publicKey),
signature: bytesToBase64(slhDsaSig)
},
{
algorithm: 'ml-kem-768',
public_key: bytesToBase64(pqKeys.mlKem.publicKey),
note: 'KEM key for encryption; ownership asserted by secp256k1 signature over this content'
}
]
};
// If Path B, include successor info
if (successorNpub) {
content.successor_pubkey = successorNpub;
}
return { statement, content, statementBytes };
}
/**
* Verify a NIP-QR event's PQ signatures.
* @param {object} content - The parsed content object
* @returns {{valid: boolean, results: Array}} verification results
*/
export function verifyNIPQRContent(content) {
const results = [];
for (const keyEntry of content.pq_keys) {
if (keyEntry.algorithm === 'ml-kem-768') {
// KEM can't sign — skip verification
results.push({ algorithm: keyEntry.algorithm, valid: true, note: 'KEM (no signature to verify)' });
continue;
}
const pubKey = base64ToBytes(keyEntry.public_key);
const sig = base64ToBytes(keyEntry.signature);
const msg = new TextEncoder().encode(content.statement);
let valid = false;
if (keyEntry.algorithm === 'ml-dsa-65') {
valid = verifyMLDSA(sig, msg, pubKey);
} else if (keyEntry.algorithm === 'slh-dsa-128s') {
valid = verifySLHDSA(sig, msg, pubKey);
}
results.push({ algorithm: keyEntry.algorithm, valid });
}
return {
valid: results.every(r => r.valid),
results
};
}
// ============================================================================
// KEY SIZE INFO (for display)
// ============================================================================
export const PQ_KEY_INFO = {
'ml-dsa-65': {
name: 'ML-DSA-65 (Dilithium)',
publicKeySize: 1952,
signatureSize: 3309,
fips: 'FIPS 204',
type: 'signature'
},
'slh-dsa-128s': {
name: 'SLH-DSA-128s (SPHINCS+)',
publicKeySize: 32,
signatureSize: 7856,
fips: 'FIPS 205',
type: 'signature'
},
'ml-kem-768': {
name: 'ML-KEM-768 (Kyber)',
publicKeySize: 1184,
ciphertextSize: 1088,
fips: 'FIPS 203',
type: 'kem'
}
};
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.7.94",
"VERSION_NUMBER": "0.7.94",
"BUILD_DATE": "2026-07-12T14:10:59.968Z"
"VERSION": "v0.7.99",
"VERSION_NUMBER": "0.7.99",
"BUILD_DATE": "2026-08-04T20:24:55.694Z"
}
+89543 -31
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+84
View File
@@ -441,6 +441,22 @@
<div id="outNip44Decrypted" class="outDivTall clsClipboard">&nbsp;</div>
<div id="btnDecodeNip44" class="divButton btn">Decrypt</div>
</div>
<!-- HMAC-SHA256 D TAG (deterministic opaque NIP-33 d tag from privkey + path) -->
<div class="divTool">
<div class="divSideTitle">HMAC-SHA256 D TAG</div>
<div class="toolLabel">nsec/hex (privkey):</div>
<div id="inpHmacDTagPrivkey" class="inDiv" contenteditable="true"></div>
<div class="toolLabel">Path (e.g. Work/Projects/Secret):</div>
<div id="inpHmacDTagPath" class="inDiv" contenteditable="true"></div>
<div class="toolLabel">Key derivation label (optional, defaults to sovereign-browser/bookmarks-folder-id-v1):</div>
<div id="inpHmacDTagLabel" class="inDiv" contenteditable="true"></div>
<div class="toolLabel">HMAC key (hex, derived from privkey + label):</div>
<div id="outHmacDTagKey" class="outDiv clsClipboard">&nbsp;</div>
<div class="toolLabel">d tag (HMAC-SHA256(hmac_key, path), 64 hex chars):</div>
<div id="outHmacDTag" class="outDiv clsClipboard">&nbsp;</div>
<div id="btnComputeHmacDTag" class="divButton btn">Compute</div>
</div>
</div>
<!-- ================================================================
@@ -1069,6 +1085,71 @@ const versionInfo = await getVersion();
}
};
// HMAC-SHA256 deterministic opaque d tag (for NIP-33 parameterized replaceable
// events where the d tag must be (a) opaque to observers and (b) identical
// across re-publishes so relays replace the prior event. Encryption (NIP-04 /
// NIP-44) cannot be used because both use a random IV/nonce per call, which
// would break replaceability. A MAC is deterministic by construction.
//
// hmac_key = HMAC-SHA256(privkey_bytes, label_utf8)
// d = HMAC-SHA256(hmac_key, path_utf8) → 64 hex chars
//
// The same privkey + label + path always yields the same d tag, so relays
// replace the prior event. Observers see only an opaque hash and learn
// nothing about the path. Used by sovereign_browser for nested bookmark
// folders (NIP-51 kind 30003) where the real path lives inside the
// NIP-44 encrypted content.
const ComputeHmacDTag = async () => {
try {
const privkeyInput = document.getElementById(`inpHmacDTagPrivkey`).innerText.trim();
const path = document.getElementById(`inpHmacDTagPath`).innerText;
let label = document.getElementById(`inpHmacDTagLabel`).innerText.trim();
if (!label) label = 'sovereign-browser/bookmarks-folder-id-v1';
if (!privkeyInput) {
document.getElementById(`outHmacDTagKey`).innerText = 'Error: privkey is required';
document.getElementById(`outHmacDTag`).innerText = '';
return;
}
if (!path) {
document.getElementById(`outHmacDTagKey`).innerText = '';
document.getElementById(`outHmacDTag`).innerText = 'Error: path is required';
return;
}
const privkeyBytes = normalizeKey(privkeyInput, true);
// hmac_key = HMAC-SHA256(privkey, label)
const labelBytes = new TextEncoder().encode(label);
const hmacKeyBuf = await crypto.subtle.importKey(
'raw', privkeyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const hmacKeySig = await crypto.subtle.sign('HMAC', hmacKeyBuf, labelBytes);
const hmacKeyHex = Array.from(new Uint8Array(hmacKeySig))
.map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById(`outHmacDTagKey`).innerText = hmacKeyHex;
// d = HMAC-SHA256(hmac_key, path)
const hmacKeyBytes = new Uint8Array(hmacKeySig);
const pathBytes = new TextEncoder().encode(path);
const dKeyBuf = await crypto.subtle.importKey(
'raw', hmacKeyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false, ['sign']
);
const dSig = await crypto.subtle.sign('HMAC', dKeyBuf, pathBytes);
const dHex = Array.from(new Uint8Array(dSig))
.map(b => b.toString(16).padStart(2, '0')).join('');
document.getElementById(`outHmacDTag`).innerText = dHex;
} catch (error) {
console.error('HMAC d-tag error:', error);
document.getElementById(`outHmacDTagKey`).innerText = `Error: ${error.message}`;
document.getElementById(`outHmacDTag`).innerText = '';
}
};
// NSEC to NPUB Conversion
const NsecToNpub = async () => {
try {
@@ -1337,6 +1418,9 @@ const versionInfo = await getVersion();
if (btnEncodeNip44) btnEncodeNip44.addEventListener("click", EncodeNip44);
if (btnDecodeNip44) btnDecodeNip44.addEventListener("click", DecodeNip44);
const btnComputeHmacDTag = document.getElementById(`btnComputeHmacDTag`);
if (btnComputeHmacDTag) btnComputeHmacDTag.addEventListener("click", ComputeHmacDTag);
// NSEC to NPUB
const inpNsecToNpub = document.getElementById(`inpNsecToNpub`);
if (inpNsecToNpub) inpNsecToNpub.addEventListener("input", NsecToNpub);