Compare commits

...
3 Commits
5 changed files with 209 additions and 63 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.7",
"version": "0.0.10",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+50 -5
View File
@@ -497,9 +497,6 @@
<div class="pq-button-row">
<button class="pq-button pq-button-secondary pq-hidden" id="pqOtsContinueBtn">Continue to OpenTimestamps</button>
</div>
<div class="pq-button-row">
<button class="pq-button pq-button-secondary" id="pqDoneBtn">Done</button>
</div>
</div>
<!-- STEP 6: OPENTIMESTAMPS -->
@@ -737,6 +734,26 @@
e.preventDefault();
for (let i = 1; i <= 5; i++) setStepDone(i);
setStepActive(6);
// Fetch the full kind 11112 event from relays so that pqEvent has
// the tags and content needed for buildUpgradedEvent() when the
// Bitcoin attestation is confirmed and we republish.
if (!pqEvent.tags || !pqEvent.content) {
otsLog('Resuming OTS workflow: fetching full event from relays...');
const relayUrls = getRelayUrls();
let fetched = null;
await Promise.all(relayUrls.map(async (url) => {
try {
const ev = await queryRelayForKind11112(currentPubkey, url);
if (ev && (!fetched || ev.created_at > fetched.created_at)) fetched = ev;
} catch (e) { /* ignore */ }
}));
if (fetched) {
pqEvent = fetched;
otsLog(`Resuming OTS workflow: fetched event ${pqEvent.id.substring(0, 16)}... from relays.`);
} else {
otsLog('WARNING: Could not fetch the full event from relays. Republishing may fail.');
}
}
await startOtsFlow();
});
}
@@ -1149,6 +1166,36 @@
}));
}
/* ================================================================
RELAY QUERY (fetch kind 11112 event for OTS resume)
================================================================ */
function queryRelayForKind11112(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) { resolved = true; try { ws.close(); } catch (e) {} reject(new Error('timeout')); }
}, 15000);
ws.onopen = () => {
const subId = 'pq_resume_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(data[2]); }
} else if (data[0] === 'EOSE') {
if (!resolved) { resolved = true; clearTimeout(timeout); try { ws.close(); } catch (e) {} resolve(null); }
}
} catch (e) {}
};
ws.onerror = () => { if (!resolved) { resolved = true; clearTimeout(timeout); reject(new Error('connection error')); } };
} catch (e) { reject(e); }
});
}
/* ================================================================
RELAY LIST MANAGEMENT
================================================================ */
@@ -1359,8 +1406,6 @@
}
});
document.getElementById('pqDoneBtn').addEventListener('click', () => { showAuthedView(); });
// Continue to OpenTimestamps step (shown after successful publish)
document.getElementById('pqOtsContinueBtn').addEventListener('click', () => { prepareOtsStep(); });
+101 -31
View File
@@ -769,9 +769,18 @@ export const PQ_KEY_INFO = {
// OPENTIMESTAMPS (NIP-03)
// ============================================================================
// Calendar servers to submit to. We submit to ALL of them in parallel and
// merge the returned fragments into a single .ots proof with multiple
// attestation branches. This provides redundancy (if one calendar goes
// offline, others still provide a path to Bitcoin confirmation) and faster
// confirmation (whichever calendar gets the hash into a Bitcoin block first
// wins). Servers are ordered by observed uptime/reliability.
const OTS_CALENDAR_SERVERS = [
'https://alice.btc.calendar.opentimestamps.org',
'https://bob.btc.calendar.opentimestamps.org',
'https://a.pool.opentimestamps.org',
'https://b.pool.opentimestamps.org',
'https://ots.btc.catallaxy.com',
];
// Detached .ots file prefix:
@@ -807,48 +816,109 @@ export function isDetachedOtsFile(otsBytes) {
}
/**
* Submit a hash to OpenTimestamps for timestamping.
* Returns a pending .ots file (binary).
* Submit a hash to a single OpenTimestamps calendar server.
* Returns the raw timestamp fragment bytes (NOT a full .ots file).
*
* The API endpoint is POST {server}/digest with the raw 32-byte hash as the body.
* @param {string} server - Calendar server base URL
* @param {Uint8Array} hashBytes - 32-byte SHA-256 hash to timestamp
* @returns {Promise<Uint8Array>} timestamp fragment bytes
*/
async function submitToCalendar(server, hashBytes) {
const response = await fetch(`${server}/digest`, {
method: 'POST',
body: hashBytes,
headers: {
'Accept': 'application/vnd.opentimestamps.v1',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (!response.ok) {
throw new Error(`Calendar ${server} returned HTTP ${response.status}`);
}
const fragmentBuffer = await response.arrayBuffer();
return new Uint8Array(fragmentBuffer);
}
/**
* Submit a hash to OpenTimestamps for timestamping.
*
* Submits to ALL configured calendar servers in parallel and merges the
* returned fragments into a single detached .ots file with multiple
* attestation branches. This provides:
* - Redundancy: if one calendar goes offline, others still provide a path
* to Bitcoin confirmation.
* - Faster confirmation: whichever calendar gets the hash into a Bitcoin
* block first wins.
* - Stronger proof: multiple attestations are harder to forge.
*
* The .ots timestamp tree format supports multiple parallel attestations via
* the 0xff continuation marker. Each calendar's fragment is a self-contained
* timestamp subtree (ops + pending attestation). We join them at the top
* level: [0xff][fragment1][0xff][fragment2]...[fragmentN].
*
* @param {string} eventIdHex - The Nostr event id (hex string, 32 bytes)
* @returns {Promise<Uint8Array>} pending .ots file bytes
* @returns {Promise<Uint8Array>} pending .ots file bytes with multiple calendar attestations
*/
export async function timestampEvent(eventIdHex) {
const hashBytes = hexToBytes(eventIdHex);
// Try each calendar server until one succeeds
for (const server of OTS_CALENDAR_SERVERS) {
try {
const response = await fetch(`${server}/digest`, {
method: 'POST',
body: hashBytes,
headers: {
'Accept': 'application/vnd.opentimestamps.v1',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
// Submit to all calendar servers in parallel
const results = await Promise.allSettled(
OTS_CALENDAR_SERVERS.map(server => submitToCalendar(server, hashBytes))
);
if (!response.ok) {
console.warn(`[ots] Calendar ${server} returned ${response.status}`);
continue;
}
// Calendar response is a serialized timestamp fragment, not a full
// detached .ots file. Wrap it with the OTS header, SHA-256 op, and
// the original 32-byte event digest.
const fragmentBuffer = await response.arrayBuffer();
const fragmentBytes = new Uint8Array(fragmentBuffer);
const otsBytes = concatBytes(OTS_DETACHED_PREFIX, hashBytes, fragmentBytes);
console.log(`[ots] Received calendar fragment (${fragmentBytes.length} bytes); built detached .ots file (${otsBytes.length} bytes) from ${server}`);
return otsBytes;
} catch (e) {
console.warn(`[ots] Calendar ${server} failed:`, e.message);
// Collect successful fragments
const fragments = [];
const succeeded = [];
const failed = [];
results.forEach((result, i) => {
const server = OTS_CALENDAR_SERVERS[i];
if (result.status === 'fulfilled' && result.value && result.value.length > 0) {
fragments.push(result.value);
succeeded.push(server);
console.log(`[ots] Calendar ${server} returned fragment (${result.value.length} bytes)`);
} else {
const reason = result.status === 'rejected' ? result.reason.message : 'empty response';
failed.push(`${server}: ${reason}`);
console.warn(`[ots] Calendar ${server} failed: ${reason}`);
}
});
if (fragments.length === 0) {
throw new Error(`All OpenTimestamps calendar servers failed (${failed.join('; ')})`);
}
throw new Error('All OpenTimestamps calendar servers failed');
// Merge fragments into a single .ots file.
//
// The detached .ots file format is:
// [magic header][version][hash op tag][32-byte digest][timestamp tree]
//
// The timestamp tree for a single attestation is just the fragment bytes.
// For multiple attestations, we use 0xff continuation markers to join them:
// [0xff][fragment1][0xff][fragment2]...[fragmentN]
//
// The 0xff byte means "another attestation/op follows for this timestamp
// node." The last fragment has no 0xff prefix. This is the same format the
// reference `ots-cli stamp` tool produces when stamping with multiple
// calendars.
const FF = new Uint8Array([0xff]);
const treeParts = [];
for (let i = 0; i < fragments.length; i++) {
if (i < fragments.length - 1) {
treeParts.push(FF, fragments[i]);
} else {
treeParts.push(fragments[i]);
}
}
const timestampTree = concatBytes(...treeParts);
const otsBytes = concatBytes(OTS_DETACHED_PREFIX, hashBytes, timestampTree);
console.log(`[ots] Built merged .ots file (${otsBytes.length} bytes) from ${fragments.length}/${OTS_CALENDAR_SERVERS.length} calendars (${succeeded.join(', ')})`);
if (failed.length > 0) {
console.warn(`[ots] ${failed.length} calendar(s) failed: ${failed.join('; ')}`);
}
return otsBytes;
}
/**
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.7",
"VERSION_NUMBER": "0.0.7",
"BUILD_DATE": "2026-07-19T11:33:54.796Z"
"VERSION": "v0.0.10",
"VERSION_NUMBER": "0.0.10",
"BUILD_DATE": "2026-07-19T12:34:09.698Z"
}
+54 -23
View File
@@ -9993,7 +9993,10 @@ var PQ_KEY_INFO = {
};
var OTS_CALENDAR_SERVERS = [
"https://alice.btc.calendar.opentimestamps.org",
"https://bob.btc.calendar.opentimestamps.org"
"https://bob.btc.calendar.opentimestamps.org",
"https://a.pool.opentimestamps.org",
"https://b.pool.opentimestamps.org",
"https://ots.btc.catallaxy.com"
];
var OTS_DETACHED_PREFIX = hexToBytes3(
"004f70656e54696d657374616d7073000050726f6f6600bf89e2e884e892940108"
@@ -10015,32 +10018,60 @@ function isDetachedOtsFile(otsBytes) {
}
return true;
}
async function submitToCalendar(server, hashBytes) {
const response = await fetch(`${server}/digest`, {
method: "POST",
body: hashBytes,
headers: {
"Accept": "application/vnd.opentimestamps.v1",
"Content-Type": "application/x-www-form-urlencoded"
}
});
if (!response.ok) {
throw new Error(`Calendar ${server} returned HTTP ${response.status}`);
}
const fragmentBuffer = await response.arrayBuffer();
return new Uint8Array(fragmentBuffer);
}
async function timestampEvent(eventIdHex) {
const hashBytes = hexToBytes3(eventIdHex);
for (const server of OTS_CALENDAR_SERVERS) {
try {
const response = await fetch(`${server}/digest`, {
method: "POST",
body: hashBytes,
headers: {
"Accept": "application/vnd.opentimestamps.v1",
"Content-Type": "application/x-www-form-urlencoded"
}
});
if (!response.ok) {
console.warn(`[ots] Calendar ${server} returned ${response.status}`);
continue;
}
const fragmentBuffer = await response.arrayBuffer();
const fragmentBytes = new Uint8Array(fragmentBuffer);
const otsBytes = concatBytes4(OTS_DETACHED_PREFIX, hashBytes, fragmentBytes);
console.log(`[ots] Received calendar fragment (${fragmentBytes.length} bytes); built detached .ots file (${otsBytes.length} bytes) from ${server}`);
return otsBytes;
} catch (e) {
console.warn(`[ots] Calendar ${server} failed:`, e.message);
const results = await Promise.allSettled(
OTS_CALENDAR_SERVERS.map((server) => submitToCalendar(server, hashBytes))
);
const fragments = [];
const succeeded = [];
const failed = [];
results.forEach((result, i) => {
const server = OTS_CALENDAR_SERVERS[i];
if (result.status === "fulfilled" && result.value && result.value.length > 0) {
fragments.push(result.value);
succeeded.push(server);
console.log(`[ots] Calendar ${server} returned fragment (${result.value.length} bytes)`);
} else {
const reason = result.status === "rejected" ? result.reason.message : "empty response";
failed.push(`${server}: ${reason}`);
console.warn(`[ots] Calendar ${server} failed: ${reason}`);
}
});
if (fragments.length === 0) {
throw new Error(`All OpenTimestamps calendar servers failed (${failed.join("; ")})`);
}
const FF = new Uint8Array([255]);
const treeParts = [];
for (let i = 0; i < fragments.length; i++) {
if (i < fragments.length - 1) {
treeParts.push(FF, fragments[i]);
} else {
treeParts.push(fragments[i]);
}
}
throw new Error("All OpenTimestamps calendar servers failed");
const timestampTree = concatBytes4(...treeParts);
const otsBytes = concatBytes4(OTS_DETACHED_PREFIX, hashBytes, timestampTree);
console.log(`[ots] Built merged .ots file (${otsBytes.length} bytes) from ${fragments.length}/${OTS_CALENDAR_SERVERS.length} calendars (${succeeded.join(", ")})`);
if (failed.length > 0) {
console.warn(`[ots] ${failed.length} calendar(s) failed: ${failed.join("; ")}`);
}
return otsBytes;
}
async function upgradeOts(otsBytes) {
const upgradeUrl = typeof window !== "undefined" && window.location ? `${window.location.origin}/ots-upgrade` : "https://laantungir.net/ots-upgrade";