Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions backend/src/models/mission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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}");
}
}
Expand Down Expand Up @@ -440,6 +444,7 @@ pub enum DoKind {
ChainTip,
AddressReuse,
PassphraseFork,
SignEvent,
SeedWords,
DeriveAddress,
GithubPr,
Expand Down
45 changes: 45 additions & 0 deletions backend/src/routes/missions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -480,6 +485,46 @@ fn parse_reported_number(proof: &str) -> Result<u64, AppError> {
.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<String>,)> =
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();
Expand Down
26 changes: 21 additions & 5 deletions backend/src/services/nostr_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Event, AppError> {
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:
Expand All @@ -59,11 +79,7 @@ impl NostrService {
&self,
event_json: serde_json::Value,
) -> Result<Event, AppError> {
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.
Expand Down
19 changes: 18 additions & 1 deletion frontend/e2e/_lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions frontend/e2e/run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
107 changes: 107 additions & 0 deletions frontend/e2e/sign-event.test.mjs
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 13 additions & 3 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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: {
Expand All @@ -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: '🌐',
Expand Down
Loading
Loading