diff --git a/backend/src/models/mission.rs b/backend/src/models/mission.rs index fb91798..7e61560 100644 --- a/backend/src/models/mission.rs +++ b/backend/src/models/mission.rs @@ -308,7 +308,7 @@ impl Mission { // Listed explicitly so adding a mission doesn't silently fall // through to a default that bypasses verification. 0 | 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 => DoKind::Knowledge, - 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 => DoKind::Knowledge, + 12 | 13 | 15 | 16 | 18 | 19 | 20 => DoKind::Knowledge, 21 | 22 | 25 | 28 | 29 => DoKind::Knowledge, 31 | 32 | 35 | 37 | 38 | 39 | 40 => DoKind::Knowledge, 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 => DoKind::Knowledge, @@ -330,6 +330,9 @@ impl Mission { // public explorer; the verifier re-reads it and compares. // 92 derives the same path twice, with and without a BIP39 // passphrase, and submits the two resulting addresses. + // 17 signs an event locally so the learner can read its + // anatomy. It is never broadcast; 26 does that later. + 17 => DoKind::SignEvent, 92 => DoKind::PassphraseFork, 6 => DoKind::ChainTip, 51 => DoKind::AddressReuse, @@ -394,8 +397,9 @@ mod tests { assert_eq!(Mission::do_kind(6), DoKind::ChainTip); assert_eq!(Mission::do_kind(51), DoKind::AddressReuse); assert_eq!(Mission::do_kind(92), DoKind::PassphraseFork); + assert_eq!(Mission::do_kind(17), DoKind::SignEvent); // Neighbours in the same knowledge runs must be unaffected. - for n in [5u8, 7, 52, 91, 93] { + for n in [5u8, 7, 52, 91, 93, 16, 18] { assert_eq!(Mission::do_kind(n), DoKind::Knowledge, "mission {n}"); } } @@ -440,6 +444,7 @@ pub enum DoKind { ChainTip, AddressReuse, PassphraseFork, + SignEvent, SeedWords, DeriveAddress, GithubPr, diff --git a/backend/src/routes/missions.rs b/backend/src/routes/missions.rs index 6cc6d4d..48a0b11 100644 --- a/backend/src/routes/missions.rs +++ b/backend/src/routes/missions.rs @@ -245,6 +245,11 @@ async fn verify_proof( // so the same verifier works. DoKind::NostrZap => verify_nostr_event(state, participant_id, proof).await, + // Mission 17: a locally signed event, submitted whole so the + // learner can see its anatomy. We check the cryptography and that + // the key is theirs. Nothing is broadcast. + DoKind::SignEvent => verify_signed_own_event(state, participant_id, proof).await, + // Mission 42: signet on-chain — proof is a 64-hex txid. We ask // Mutinynet, then mempool.space/signet, whether the tx exists. DoKind::OnchainSignet => verify_signet_txid(proof).await, @@ -480,6 +485,46 @@ fn parse_reported_number(proof: &str) -> Result { .map_err(|_| AppError::BadRequest("that number is out of range".into())) } +/// Mission 17: the learner signs an event in the browser and submits the +/// whole thing, so the lesson can show them what an event actually is. +/// +/// Two checks, and they are the lesson. `verify_signed_event` recomputes +/// the canonical hash and confirms the `id` field really is that hash, then +/// checks the BIP340 signature against the embedded pubkey, so no field can +/// be hand-edited. Then we compare that pubkey to the npub they registered +/// in mission 14, so it has to be their own key and not one copied from a +/// friend or lifted off a relay. +/// +/// Deliberately no network: this event is for reading, not publishing. +/// Mission 26 is where broadcasting happens. +async fn verify_signed_own_event( + state: &AppState, + participant_id: &str, + proof: &str, +) -> Result<(), AppError> { + let json: serde_json::Value = serde_json::from_str(proof) + .map_err(|e| AppError::BadRequest(format!("proof must be the signed event JSON: {e}")))?; + let event = crate::services::nostr_service::NostrService::verify_signed_event(json)?; + + let row: Option<(Option,)> = + sqlx::query_as("SELECT nostr_pubkey FROM participants WHERE id = ?") + .bind(participant_id) + .fetch_optional(&state.db) + .await?; + let stored_npub = row.and_then(|(npub,)| npub).ok_or_else(|| { + AppError::BadRequest("generate your Nostr identity first (mission 14)".into()) + })?; + let stored_pk = nostr_sdk::PublicKey::parse(&stored_npub) + .map_err(|e| AppError::Internal(anyhow::anyhow!("stored npub is malformed: {e}")))?; + + if event.pubkey != stored_pk { + return Err(AppError::BadRequest( + "that event was signed by a different key than the identity you registered".into(), + )); + } + Ok(()) +} + /// True for the bech32 segwit addresses this curriculum derives. fn looks_like_segwit_address(s: &str) -> bool { let lower = s.to_lowercase(); diff --git a/backend/src/services/nostr_service.rs b/backend/src/services/nostr_service.rs index d36c67b..ca738ee 100644 --- a/backend/src/services/nostr_service.rs +++ b/backend/src/services/nostr_service.rs @@ -45,6 +45,26 @@ impl NostrService { &self.relays } + /// Parse and cryptographically check a signed event, without touching + /// the network. + /// + /// `Event::verify` does two things worth naming, because mission 17 is + /// built on both: it recomputes the canonical hash and checks the + /// event's `id` really is that hash, and it checks the BIP340 + /// signature against the embedded pubkey. So a learner cannot hand-edit + /// any field, and cannot claim someone else's key. + /// + /// Offline by design: mission 17 has the learner sign an event to look + /// inside it, not to publish it, so this must not reach a relay. + pub fn verify_signed_event(event_json: serde_json::Value) -> Result { + let event: Event = serde_json::from_value(event_json) + .map_err(|e| AppError::BadRequest(format!("malformed nostr event: {e}")))?; + event + .verify() + .map_err(|e| AppError::BadRequest(format!("invalid nostr event signature: {e}")))?; + Ok(event) + } + /// Broadcast an already-signed event to all configured relays. /// /// The event must: @@ -59,11 +79,7 @@ impl NostrService { &self, event_json: serde_json::Value, ) -> Result { - let event: Event = serde_json::from_value(event_json) - .map_err(|e| AppError::BadRequest(format!("malformed nostr event: {e}")))?; - event - .verify() - .map_err(|e| AppError::BadRequest(format!("invalid nostr event signature: {e}")))?; + let event = Self::verify_signed_event(event_json)?; // The Client wants *some* signer to construct, but we never call // any signing path here — `send_event` takes the event as-is. diff --git a/frontend/e2e/_lib.mjs b/frontend/e2e/_lib.mjs index a77aa8d..1bb27e1 100644 --- a/frontend/e2e/_lib.mjs +++ b/frontend/e2e/_lib.mjs @@ -12,6 +12,7 @@ * completions are ever needed. */ import crypto from 'node:crypto' +import { finalizeEvent, getPublicKey, nip19 } from 'nostr-tools' import { ensureExplorerStub, STUB_GENESIS_TX_COUNT, @@ -52,8 +53,24 @@ export async function apiPost(path, body, token) { */ export { ensureExplorerStub } +/** Deterministic Nostr identity for seeding. Test-only, never a real key. */ +const E2E_SK = new Uint8Array(32).fill(7) +export const E2E_NPUB = nip19.npubEncode(getPublicKey(E2E_SK)) + export function proofFor(mission) { - if (mission === 14) return 'npub1' + 'q'.repeat(50) // NostrIdentity: format-checked only + // NostrIdentity (14) and SignEvent (17) are linked: mission 17's + // verifier checks the event's signature AND that its pubkey matches the + // npub registered at 14. So seeding needs one real keypair, not a + // placeholder npub. Fixed secret key keeps runs deterministic. + if (mission === 14) return E2E_NPUB + if (mission === 17) { + return JSON.stringify( + finalizeEvent( + { kind: 1, tags: [], content: 'e2e seed event', created_at: 1750000000 }, + E2E_SK, + ), + ) + } if (mission === 11) return crypto.createHash('sha256').update('seed').digest('hex') // SeedWords: 64 hex // ChainTip / AddressReuse: verified against a live explorer, so these // only pass when the backend is pointed at the stub. See diff --git a/frontend/e2e/run.mjs b/frontend/e2e/run.mjs index 40181ae..67bf9b9 100644 --- a/frontend/e2e/run.mjs +++ b/frontend/e2e/run.mjs @@ -14,6 +14,7 @@ const here = dirname(fileURLToPath(import.meta.url)) const suite = [ ['secret-reveal.test.mjs', 'seed'], ['secret-reveal.test.mjs', 'nostrid'], + ['sign-event.test.mjs'], ['publish-confirm.test.mjs'], ['badge-share.test.mjs'], ['passphrase-fork.test.mjs'], diff --git a/frontend/e2e/sign-event.test.mjs b/frontend/e2e/sign-event.test.mjs new file mode 100644 index 0000000..8edb37d --- /dev/null +++ b/frontend/e2e/sign-event.test.mjs @@ -0,0 +1,107 @@ +/** + * Smoke test: mission 17 (events, everything is one) must sign a real + * event in the browser, show the learner its anatomy, and publish nothing. + * + * The lesson claims the id is a hash of the event and the sig is that id + * signed by your key. This drives the flow and checks the app actually + * demonstrates it, that the private key never leaves the browser, and that + * no relay is contacted, since publishing is mission 26's job. + * + * node e2e/sign-event.test.mjs + */ +import { + enterTree, + launch, + makeReporter, + openApp, + passLearnAndQuiz, + seedParticipant, + sleep, +} from './_lib.mjs' +import { finalizeEvent, getPublicKey, nip19 } from 'nostr-tools' + +const report = makeReporter('sign-event') + +// Same deterministic identity the seeding uses, so the key the browser +// signs with matches the npub registered at mission 14. +const SK = new Uint8Array(32).fill(7) +const NSEC = nip19.nsecEncode(SK) +const NPUB = nip19.npubEncode(getPublicKey(SK)) + +// Nostr tree order: [13, 14, 15, 97, 16, 17, ...]; seed up to mission 17. +const creds = await seedParticipant([13, 14, 15, 97, 16]) +const browser = await launch() +try { + const page = await openApp(browser, creds, { nsec: NSEC, npub: NPUB }) + + const posted = [] + let relayCalls = 0 + page.on('request', (req) => { + const u = req.url() + if (u.includes('/missions/complete') && req.method() === 'POST') { + posted.push(req.postData() ?? '') + } + if (u.includes('/nostr/broadcast') || u.startsWith('ws')) relayCalls++ + }) + + await enterTree(page, 'Nostr') + await passLearnAndQuiz(page, 'Kind 1', report) + + const textarea = page.locator('textarea') + report.assert((await textarea.count()) > 0, 'reached the Do step with a text input') + await textarea.first().fill('learning what an event really is') + + const action = page.getByRole('button', { name: /Sign an event and open it up/i }) + report.assert((await action.count()) > 0, 'the sign action is offered') + if (await action.count()) await action.first().click({ force: true }) + await sleep(1800) + + const body = await page.locator('#main-content, main, body').first().innerText() + + // The five fields the lesson names should be visible, not just described. + for (const field of ['kind', 'content', 'pubkey', 'id', 'sig']) { + report.assert( + new RegExp(`\\b${field}\\b`, 'i').test(body), + `the ${field} field is shown to the learner`, + ) + } + + // Nothing was published: mission 26 is where that happens. + report.assert(relayCalls === 0, 'no relay or broadcast call was made') + report.assert( + !/published to|broadcast/i.test(body), + 'the outcome does not claim the event was published', + ) + + // The submitted proof is the signed event, and it verifies. + report.assert(posted.length > 0, 'the completion was posted') + const proof = JSON.parse(JSON.parse(posted[0]).proof) + report.assert( + proof.pubkey === getPublicKey(SK), + 'the event was signed by the learner\'s own registered key', + ) + // Re-sign the same content and confirm the id is content-derived: same + // inputs give the same id, which is what "the id is a hash" means. + const same = finalizeEvent( + { kind: proof.kind, tags: proof.tags, content: proof.content, created_at: proof.created_at }, + SK, + ) + report.assert(same.id === proof.id, 'the id is a hash of the event contents, reproducibly') + report.assert( + finalizeEvent( + { kind: 1, tags: [], content: proof.content + '!', created_at: proof.created_at }, + SK, + ).id !== proof.id, + 'changing one character changes the id, as the lesson claims', + ) + + // The private key must never appear in anything we sent. + report.assert( + posted.every((b) => !b.includes(NSEC)), + 'the nsec was never sent to the server', + ) +} finally { + await browser.close() +} + +process.exit(report.finish() ? 0 : 1) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 0ae0bf8..3c5877d 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -166,6 +166,7 @@ export type DoKind = | 'seed-words' /* generate BIP39 mnemonic client-side; quiz on a word */ | 'derive-address' /* derive an address from the mnemonic and submit */ | 'passphrase-fork' /* derive with and without a BIP39 passphrase, compare */ + | 'sign-event' /* sign a Nostr event locally and read its anatomy, no relay */ | 'paste-value' /* generic "type or paste this thing" reflection input */ | 'github-pr' /* mission 105: backend asks the GitHub API if the PR is merged and yours */ @@ -673,17 +674,18 @@ export const MISSIONS: MissionDef[] = [ ], }, }), - knowledge({ + { id: 17, emoji: '🧬', topic: 'Nostr', tech: 'nostr', + simulated: false, name: 'Events, everything is one', tagline: 'Posts, profiles, follows: all the same shape', learn: { heading: 'A Nostr event has 5 fields', body: - "Everything on Nostr is an 'event': a JSON object with five fields, id, pubkey, kind, content, signature. The 'kind' number says what type of thing it is.\n\nKind 1: a short text note (a tweet, basically).\nKind 0: profile metadata (name, about, picture).\nKind 3: contact list (your follows).\nKind 7: a reaction (like/dislike).\nKind 9735: a zap receipt (zaps come later on this flight path).\n\nThat's the whole protocol. Add new kinds, build new apps, same plumbing.", + "Everything on Nostr is an 'event': a JSON object with five fields, id, pubkey, kind, content, signature. The 'kind' number says what type of thing it is.\n\nKind 1: a short text note (a tweet, basically).\nKind 0: profile metadata (name, about, picture).\nKind 3: contact list (your follows).\nKind 7: a reaction (like/dislike).\nKind 9735: a zap receipt (zaps come later on this flight path).\n\nThat's the whole protocol. Add new kinds, build new apps, same plumbing.\n\nTwo of those five fields are not written by you, they are computed. The **id** is a hash of the event's own contents, so changing a single character of the text changes the id. The **sig** is that id signed by your private key. Together they mean anyone can check, without asking a server, that this exact text came from this exact key and has not been altered since.\n\nRather than take that on faith, you are about to sign one and look inside it. Nothing gets published: this event is for reading. Publishing comes later on this flight path.", tip: "Every post, follow, reaction, and zap is a JSON object you cryptographically signed.", }, quiz: { @@ -694,7 +696,15 @@ export const MISSIONS: MissionDef[] = [ { text: 'Kind 9735', correct: false, why: 'Kind 9735 is a zap receipt, coming soon.' }, ], }, - }), + do: { + kind: 'sign-event', + actionLabel: 'Sign an event and open it up', + helper: + "Write anything. We sign it with your key in this browser and show you the raw event, field by field. It is not published anywhere, and your private key never leaves this device. Our server checks the signature and confirms the key is the one you registered.", + placeholder: 'anything at all, this is not published', + maxLength: 200, + }, + }, knowledge({ id: 18, emoji: '🌐', diff --git a/frontend/src/views/LearnerView.tsx b/frontend/src/views/LearnerView.tsx index 2de8619..02a746f 100644 --- a/frontend/src/views/LearnerView.tsx +++ b/frontend/src/views/LearnerView.tsx @@ -447,6 +447,51 @@ export default function LearnerView({ participantId }: { participantId: string } break } + // Mission 17: sign an event locally and read its anatomy. + // Deliberately never broadcast, that is mission 26. The + // whole signed event is submitted so the server can check + // the id-is-the-hash and signature claims the lesson makes. + case 'sign-event': { + if (!doInput.trim()) { + setDoError('Write something for the event to carry.') + setLoading(false) + return + } + // Prefer the browser-stored key; fall back to an nsec the + // learner pasted to recover an identity they made earlier. + const nsec = + getNsec() ?? (isValidNsec(nsecRecover) ? nsecRecover.trim() : null) + if (!nsec) { + setDoError( + nsecRecover.trim() + ? "That doesn't look like a valid nsec, it should start with nsec1…" + : 'Paste the nsec you saved when you generated your Nostr identity, or make one first in the Nostr flight path.', + ) + setLoading(false) + return + } + if (!getNsec()) setNsec(nsec) + const signed = signNostrTextNote(nsec, doInput.trim()) + proof = JSON.stringify(signed) + outcome = { + summary: + 'Signed with your key, in this browser, and not published anywhere. This is what every note, profile and follow on Nostr looks like underneath.', + details: [ + { label: 'kind', value: `${signed.kind} (short text note)` }, + { label: 'content', value: signed.content }, + { label: 'pubkey (who signed it)', value: signed.pubkey }, + { label: 'id (hash of this event)', value: signed.id }, + { label: 'sig (that id, signed)', value: signed.sig }, + { + label: 'try this', + value: 'Run it again with one character changed. The content moves by one letter, and the id and sig change completely.', + }, + ], + simulated: false, + } + break + } + case 'nostr-publish': { if (!doInput.trim()) { setDoError("Your note can't be empty.") @@ -2000,7 +2045,14 @@ function DoPanel({ // Signing needs the nsec. If the browser has none (cleared storage, or // the identity was made on another device), offer a recovery input so a // learner who already finished the identity mission isn't stuck. - const needsNsec = needsPublishConfirm && !getNsec() + // + // Not the same set as the confirmation above: mission 17 signs an event + // to read it and never publishes, so it needs the key but must not get + // a "this is public and permanent" warning about something nobody will + // ever see. + const needsSigningKey = + needsPublishConfirm || mission.do.kind === 'sign-event' + const needsNsec = needsSigningKey && !getNsec() const [confirming, setConfirming] = useState(false) // Reset both gates whenever we move to a different mission, so a prior @@ -2269,6 +2321,15 @@ function uiForKind(kind: MissionDef['do']['kind']): DoUi { return { primary: { label: 'Lightning address', placeholder: 'demo@ln.tips', maxLength: 80, mono: true } } case 'ecash-spend': return { primary: { label: 'eCash token', placeholder: 'cashuB…', maxLength: 400, mono: true } } + case 'sign-event': + return { + primary: { + label: 'Text for your event', + type: 'textarea', + placeholder: 'anything, this one is not published', + maxLength: 200, + }, + } case 'nostr-publish': return { primary: {