From 5f38cc7c07d645715ffd3be0567dd06909764e6b Mon Sep 17 00:00:00 2001 From: Aliyu Habibu Date: Sat, 26 Sep 2026 10:34:56 +0100 Subject: [PATCH] feat(security): seal emergency payloads end-to-end before submission Contact numbers and medical notes are now encrypted on the requester's device with ECDH P-256 + HKDF-SHA256 + AES-256-GCM before anything is submitted. The ledger, the relay and the map markers only ever carry ciphertext. Crypto (src/lib/crypto.ts) - One random content key per request, sealed once and wrapped per recipient, so a 32-responder envelope still fits the contract budget. - Every AEAD tag is domain-separated: the payload is bound to bindingContext, each CEK wrap to bindingContext + recipient keyId. - bindingContext is a client-generated submission id carried in the envelope. It is public but tamper-evident, and a responder needs no out-of-band coordination to read it. The contract assigns the request id only after signing, so it cannot be part of a pre-signature AAD. - Per-request ephemeral keys, so a compromised responder key only exposes the envelopes addressed to it. Contract (BREAKING) - HelpRequest.nickname/contact replaced by encrypted_payload: Bytes. - create_request drops the phantom priority argument, takes the sealed payload, and returns Result. - New validation: PayloadEmpty=12, PayloadTooLarge=13, EmergencyTypeInvalid=14; MAX_ENCRYPTED_PAYLOAD_BYTES=12288. - This changes the stored XDR layout. Testnet and mainnet contracts must be redeployed and drain live requests first; there is no in-place upgrade path. encodeEncryptedPayload rejects anything that is not a valid v1 envelope, so plaintext cannot reach the new field. Relay (server/routes/dispatch.ts) - Blind opaque store indexed by both submission id and on-chain request id, public-key registry, structural and size validation, rate limiting, Prometheus counters. Logs shape only, never the blob. - An accelerator, not a system of record: the authoritative copy is encrypted_payload on-chain, so a relay wipe costs latency only. - Left unmounted, matching every other factory in server/routes/. Location and emergency_type stay readable on purpose: dispatch needs them, and the existing ZK proof plus client-side coarsening is what protects location. Verification - cargo test -p helphone-contract: 29 passed, up from 24; the 4 pre-existing nonce:: failures are unchanged. - cargo build --target wasm32v1-none --release and cargo clippy clean. - test/e2e-encryption.test.js: 37 new tests covering the seal/open path, tamper and downgrade resistance, limits, plaintext-leak checks and the relay round trip through the real Express routers. - Full vitest: +40 passing, failures 33 -> 32 files and 245 -> 244 tests. tsc --noEmit is byte-identical to the pre-change baseline. Responder keys live in sessionStorage, so they are lost when the tab closes; moving them into the encrypted SecureStorage is follow-up work. Signed-off-by: Aliyu Habibu --- .../contracts/helphone-contract/src/lib.rs | 80 +- .../contracts/helphone-contract/src/test.rs | 225 ++++- docs/security-architecture.md | 193 +++++ public/config.json | 8 + server/routes/dispatch.ts | 453 ++++++++++ src/lib/contract.ts | 87 +- src/lib/crypto.ts | 800 +++++++++++++++++- src/pages/Help.jsx | 111 ++- src/pages/Help.tsx | 242 +++++- src/services/api.ts | 77 ++ src/types/index.ts | 167 ++++ test/contract-functions.test.js | 112 ++- test/e2e-encryption.test.js | 643 ++++++++++++++ 13 files changed, 3126 insertions(+), 72 deletions(-) create mode 100644 server/routes/dispatch.ts create mode 100644 test/e2e-encryption.test.js diff --git a/contract/contracts/helphone-contract/src/lib.rs b/contract/contracts/helphone-contract/src/lib.rs index a0637cfe..a3e03d0b 100644 --- a/contract/contracts/helphone-contract/src/lib.rs +++ b/contract/contracts/helphone-contract/src/lib.rs @@ -2,7 +2,7 @@ use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, symbol_short, Address, - Env, String, + Bytes, Env, String, }; mod multisig; @@ -24,7 +24,7 @@ pub use multisig::{Proposal, ProposalAction}; // ("active", u32) → u64 active request IDs by slot index // // Persistent (pay-to-live): -// ("req", u64) → HelpRequest +// ("req", u64) → HelpRequest (includes the sealed, responder-only payload) // ("rcount", request_id) → u32 responder count per request // ("resp", request_id, idx) → ResponderRecord // ("evcount", wallet) → u32 verifications ever recorded per wallet (write cursor) @@ -75,6 +75,12 @@ pub enum Error { DuplicateApproval = 9, ThresholdNotMet = 10, ProposalExecuted = 11, + /// `create_request` was called without an encrypted payload. + PayloadEmpty = 12, + /// The encrypted payload exceeds `MAX_ENCRYPTED_PAYLOAD_BYTES`. + PayloadTooLarge = 13, + /// `emergency_type` is empty or longer than `MAX_EMERGENCY_TYPE_BYTES`. + EmergencyTypeInvalid = 14, } // ── Types ────────────────────────────────────────────────────────── @@ -87,6 +93,37 @@ pub enum Status { Cancelled, } +/// Upper bound on the opaque encrypted blob, in bytes. +/// +/// Mirrors `MAX_ENVELOPE_BYTES` in `src/lib/crypto.ts`, which enforces the +/// same limit client-side so an oversized payload fails locally instead of +/// reverting a signed transaction. The envelope hex-encodes its ciphertext, so +/// this is roughly twice the `MAX_PAYLOAD_PLAINTEXT_BYTES` (4 KiB) cap. +pub const MAX_ENCRYPTED_PAYLOAD_BYTES: u32 = 12288; + +/// Upper bound on `emergency_type`, in bytes. Kept short because it is +/// plaintext and is indexed/filtered on by responders. +pub const MAX_EMERGENCY_TYPE_BYTES: u32 = 32; + +/// A help request as stored on the ledger. +/// +/// The sensitive half of the request — contact number, medical notes, +/// allergies — is **not** here in the clear. `encrypted_payload` is a sealed +/// envelope produced by the client (ECDH P-256 + HKDF-SHA256 + AES-256-GCM, +/// see `src/lib/crypto.ts`) whose content key is wrapped once per authorized +/// responder. This contract never holds a decryption key and never parses the +/// envelope; it stores opaque bytes and bounds their length. +/// +/// Deliberately left in plaintext, because dispatch is impossible without them: +/// * `lat` / `lng` — responders must see where. Coarse location privacy is +/// the separate ZK/Aegis layer's job (circuits/, contracts/aegis_vault). +/// * `emergency_type` — responders filter on it to send the right aid. +/// +/// ### Storage-layout note +/// This replaces the previous `nickname: String` / `contact: String` pair with +/// a single `Bytes`, which changes the XDR layout of every stored request. +/// Existing deployments need a state migration before upgrading; see +/// docs/security-architecture.md → "End-to-End Encrypted Payloads". #[derive(Clone, Debug, Eq, PartialEq)] #[contracttype] pub struct HelpRequest { @@ -97,8 +134,8 @@ pub struct HelpRequest { /// Longitude encoded as integer (degrees × 1_000_000) pub lng: i32, pub emergency_type: String, - pub nickname: String, - pub contact: String, + /// Opaque, sealed responder-only payload. Never readable by this contract. + pub encrypted_payload: Bytes, pub status: Status, pub created_at: u64, pub resolved_at: Option, @@ -330,16 +367,40 @@ impl HelPhone { // ── Emergency Request Lifecycle ───────────────────────────────── + /// Broadcast a help request. + /// + /// `encrypted_payload` is an opaque sealed envelope (JSON, hex-encoded + /// ciphertext) built client-side. The contract validates only that it is + /// present and within `MAX_ENCRYPTED_PAYLOAD_BYTES`; it cannot read it and + /// holds no key that could. Responders pull the envelope, then decrypt it + /// locally with their own private key. + /// + /// # Errors + /// * `PayloadEmpty` — no payload supplied; plaintext contact details are + /// no longer accepted at all. + /// * `PayloadTooLarge` — longer than `MAX_ENCRYPTED_PAYLOAD_BYTES`. + /// * `EmergencyTypeInvalid` — empty or over-long dispatch category. + #[allow(clippy::too_many_arguments)] pub fn create_request( env: Env, requester: Address, lat: i32, lng: i32, emergency_type: String, - nickname: String, - contact: String, - ) -> u64 { + encrypted_payload: Bytes, + ) -> Result { requester.require_auth(); + + if encrypted_payload.is_empty() { + return Err(Error::PayloadEmpty); + } + if encrypted_payload.len() > MAX_ENCRYPTED_PAYLOAD_BYTES { + return Err(Error::PayloadTooLarge); + } + if emergency_type.is_empty() || emergency_type.len() > MAX_EMERGENCY_TYPE_BYTES { + return Err(Error::EmergencyTypeInvalid); + } + let count = Self::get_request_count(env.clone()) + 1; env.storage().instance().set(&key_req_count(), &count); let req = HelpRequest { @@ -348,8 +409,7 @@ impl HelPhone { lat, lng, emergency_type, - nickname, - contact, + encrypted_payload, status: Status::Pending, created_at: env.ledger().timestamp(), resolved_at: None, @@ -365,7 +425,7 @@ impl HelPhone { env.storage() .instance() .set(&key_active_count(), &(active_count + 1)); - count + Ok(count) } pub fn accept_request( diff --git a/contract/contracts/helphone-contract/src/test.rs b/contract/contracts/helphone-contract/src/test.rs index b115b3e5..c470cc78 100644 --- a/contract/contracts/helphone-contract/src/test.rs +++ b/contract/contracts/helphone-contract/src/test.rs @@ -3,11 +3,34 @@ use super::*; use soroban_sdk::{ testutils::{storage::Persistent as _, Address as _, Events as _}, - Address, Env, Event as _, String, + Address, Bytes, Env, Event as _, InvokeError, String, }; // ── Emergency request lifecycle ──────────────────────────────────── +/// Stand-in for a sealed envelope. The contract treats it as opaque bytes, so +/// the exact content is irrelevant to the contract's own behaviour. +fn sealed_payload(env: &Env) -> Bytes { + Bytes::from_slice(env, b"{\"version\":1,\"ciphertext\":\"aabbcc\"}".as_slice()) +} + +/// Unwrap the `Error` out of a `try_create_request` result. +/// +/// The generated client returns +/// `Result, Result>`: the outer layer is the +/// invocation outcome and the inner one is the contract's own `Result`. +fn contract_error(res: Result, Result>) -> Error +where + T: core::fmt::Debug, + E: core::fmt::Debug, +{ + match res { + Err(Ok(e)) => e, + Err(Err(e)) => panic!("expected a contract error, got an invoke error: {e:?}"), + Ok(inner) => panic!("expected a contract error, call succeeded with {inner:?}"), + } +} + #[test] fn creates_and_accepts_request() { let env = Env::default(); @@ -25,8 +48,7 @@ fn creates_and_accepts_request() { &12_345_678, &-76_543_210, &String::from_str(&env, "medical"), - &String::from_str(&env, "Ana"), - &String::from_str(&env, "@ana"), + &sealed_payload(&env), ); assert_eq!(request_id, 1); @@ -36,14 +58,11 @@ fn creates_and_accepts_request() { let request = client.get_request(&request_id).unwrap(); assert_eq!(request.requester, requester); assert_eq!(request.status, Status::Pending); + // The payload round-trips as opaque bytes and is never parsed. + assert_eq!(request.encrypted_payload, sealed_payload(&env)); - let responder_index = client.accept_request( - &responder, - &request_id, - &12_346_000, - &-76_543_000, - &300, - ); + let responder_index = + client.accept_request(&responder, &request_id, &12_346_000, &-76_543_000, &300); assert_eq!(responder_index, 0); assert_eq!(client.get_responder_count(&request_id), 1); @@ -56,6 +75,119 @@ fn creates_and_accepts_request() { assert_eq!(saved_responder.eta_seconds, 300); } +#[test] +fn rejects_empty_encrypted_payload() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(HelPhone, (admin,)); + let client = HelPhoneClient::new(&env, &contract_id); + let requester = Address::generate(&env); + + let res = client.try_create_request( + &requester, + &12_345_678, + &-76_543_210, + &String::from_str(&env, "medical"), + &Bytes::new(&env), + ); + + assert_eq!(contract_error(res), Error::PayloadEmpty); +} + +#[test] +fn rejects_oversized_encrypted_payload() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(HelPhone, (admin,)); + let client = HelPhoneClient::new(&env, &contract_id); + let requester = Address::generate(&env); + + let oversized = [0u8; (MAX_ENCRYPTED_PAYLOAD_BYTES + 1) as usize]; + let res = client.try_create_request( + &requester, + &12_345_678, + &-76_543_210, + &String::from_str(&env, "medical"), + &Bytes::from_slice(&env, oversized.as_slice()), + ); + + assert_eq!(contract_error(res), Error::PayloadTooLarge); +} + +#[test] +fn rejects_empty_emergency_type() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(HelPhone, (admin,)); + let client = HelPhoneClient::new(&env, &contract_id); + let requester = Address::generate(&env); + + let res = client.try_create_request( + &requester, + &12_345_678, + &-76_543_210, + &String::from_str(&env, ""), + &sealed_payload(&env), + ); + + assert_eq!(contract_error(res), Error::EmergencyTypeInvalid); +} + +#[test] +fn rejects_overlong_emergency_type() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(HelPhone, (admin,)); + let client = HelPhoneClient::new(&env, &contract_id); + let requester = Address::generate(&env); + + let long = "x".repeat((MAX_EMERGENCY_TYPE_BYTES + 1) as usize); + let res = client.try_create_request( + &requester, + &12_345_678, + &-76_543_210, + &String::from_str(&env, &long), + &sealed_payload(&env), + ); + + assert_eq!(contract_error(res), Error::EmergencyTypeInvalid); +} + +#[test] +fn accepts_a_payload_exactly_at_the_size_limit() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(HelPhone, (admin,)); + let client = HelPhoneClient::new(&env, &contract_id); + let requester = Address::generate(&env); + + let at_limit = [7u8; MAX_ENCRYPTED_PAYLOAD_BYTES as usize]; + let id = client.create_request( + &requester, + &12_345_678, + &-76_543_210, + &String::from_str(&env, "medical"), + &Bytes::from_slice(&env, at_limit.as_slice()), + ); + + assert_eq!(id, 1); + let request = client.get_request(&id).unwrap(); + assert_eq!( + request.encrypted_payload, + Bytes::from_slice(&env, at_limit.as_slice()), + ); +} + #[test] fn records_expert_verification_history() { let env = Env::default(); @@ -81,7 +213,10 @@ fn records_expert_verification_history() { assert_eq!(record.wallet, wallet); assert_eq!(record.action, String::from_str(&env, "request_created")); assert_eq!(record.tx_hash, String::from_str(&env, "tx-abc123")); - assert_eq!(record.proof_fingerprint, String::from_str(&env, "nullifier-xyz")); + assert_eq!( + record.proof_fingerprint, + String::from_str(&env, "nullifier-xyz") + ); } // ── Bounded verification history (ring buffer, #531) ─────────────── @@ -146,9 +281,15 @@ fn nothing_is_evicted_up_to_capacity() { assert_eq!(client.get_expert_verification_count(&wallet), CAP); assert_eq!(client.get_expert_verification_oldest(&wallet), 0); - assert_eq!(client.get_expert_verification(&wallet, &0).unwrap().tx_hash, tx_of(&env, 0)); assert_eq!( - client.get_expert_verification(&wallet, &(CAP - 1)).unwrap().tx_hash, + client.get_expert_verification(&wallet, &0).unwrap().tx_hash, + tx_of(&env, 0) + ); + assert_eq!( + client + .get_expert_verification(&wallet, &(CAP - 1)) + .unwrap() + .tx_hash, tx_of(&env, CAP - 1) ); assert!(client.get_expert_verification(&wallet, &CAP).is_none()); @@ -168,21 +309,28 @@ fn the_501st_entry_evicts_the_oldest_and_emits_an_event() { assert_eq!( env.events().all(), - [ - Evicted { - wallet: wallet.clone(), - index: 0, - record: oldest, - } - .to_xdr(&env, &contract_id) - ] + [Evicted { + wallet: wallet.clone(), + index: 0, + record: oldest, + } + .to_xdr(&env, &contract_id)] ); assert_eq!(client.get_expert_verification_count(&wallet), CAP + 1); assert_eq!(client.get_expert_verification_oldest(&wallet), 1); - assert!(client.get_expert_verification(&wallet, &0).is_none(), "index 0 is evicted"); - assert_eq!(client.get_expert_verification(&wallet, &1).unwrap().tx_hash, tx_of(&env, 1)); + assert!( + client.get_expert_verification(&wallet, &0).is_none(), + "index 0 is evicted" + ); assert_eq!( - client.get_expert_verification(&wallet, &CAP).unwrap().tx_hash, + client.get_expert_verification(&wallet, &1).unwrap().tx_hash, + tx_of(&env, 1) + ); + assert_eq!( + client + .get_expert_verification(&wallet, &CAP) + .unwrap() + .tx_hash, tx_of(&env, CAP), "the new entry landed in the freed slot" ); @@ -201,14 +349,12 @@ fn each_eviction_event_names_the_entry_it_displaced() { record(&env, &client, &wallet, CAP + extra); assert_eq!( env.events().all(), - [ - Evicted { - wallet: wallet.clone(), - index: extra, - record: displaced, - } - .to_xdr(&env, &contract_id) - ], + [Evicted { + wallet: wallet.clone(), + index: extra, + record: displaced, + } + .to_xdr(&env, &contract_id)], "eviction #{extra}" ); } @@ -228,7 +374,10 @@ fn wallets_have_independent_buffers() { assert_eq!(client.get_expert_verification_oldest(&busy), 10); assert_eq!(client.get_expert_verification_count(&quiet), 1); assert_eq!(client.get_expert_verification_oldest(&quiet), 0); - assert_eq!(client.get_expert_verification(&quiet, &0).unwrap().tx_hash, tx_of(&env, 7)); + assert_eq!( + client.get_expert_verification(&quiet, &0).unwrap().tx_hash, + tx_of(&env, 7) + ); } #[test] @@ -270,9 +419,15 @@ fn history_written_before_the_ring_buffer_still_reads_back() { }); assert_eq!(client.get_expert_verification_count(&wallet), 3); - assert_eq!(client.get_expert_verification(&wallet, &2).unwrap().tx_hash, tx_of(&env, 2)); + assert_eq!( + client.get_expert_verification(&wallet, &2).unwrap().tx_hash, + tx_of(&env, 2) + ); assert_eq!(record(&env, &client, &wallet, 99), 4); - assert_eq!(client.get_expert_verification(&wallet, &3).unwrap().tx_hash, tx_of(&env, 99)); + assert_eq!( + client.get_expert_verification(&wallet, &3).unwrap().tx_hash, + tx_of(&env, 99) + ); } #[test] diff --git a/docs/security-architecture.md b/docs/security-architecture.md index 3326b4d9..a97049d4 100644 --- a/docs/security-architecture.md +++ b/docs/security-architecture.md @@ -108,3 +108,196 @@ The header and the markup use the same value because both come from `res.locals. **The service worker precaches `index.html`.** A precached shell would serve a stale nonce. Keep the navigation route network-first for HTML when this ships; until then a service-worker-served page will be blocked by the policy rather than run unnonced scripts. **Unchanged for API-only deploys.** The HTML route falls through when `dist/index.html` does not exist. + +# End-to-End Encrypted Emergency Payloads (#E2EE) + +Emergency details — the contact number and the medical notes — are sealed on the +requester's device before anything is submitted. The ledger, the relay and the +map markers only ever carry ciphertext, so a compromised backend, a hostile RPC +node, or a subpoena over the relay store yields nothing. + +## What is and is not encrypted + +| Field | Where it lives | Encrypted | +| --- | --- | --- | +| `contact` | payload | **yes** | +| `medicalNotes` | payload | **yes** | +| `nickname` | payload | **yes** | +| `allergies` (optional) | payload | **yes** | +| `lat` / `lng` | on-chain + relay | no — see below | +| `emergency_type` | on-chain + relay | no | +| request id, status, timestamps | on-chain + relay | no | + +Location is deliberately left readable: dispatch requires it, and the existing +ZK location proof (`src/lib/zk.ts`) plus the client-side `anonymizeLocation()` +coarsening are what protect it. Encrypting coordinates would push the +responder-selection problem onto the responders, which is a worse trade for an +emergency service. Anyone reading the ledger learns "a request exists near here" +and nothing about who it is for. + +## Envelope format + +One JSON object, stored as `Bytes` on-chain (`encrypted_payload`) and relayed +verbatim. Version 1, algorithm `ECDH-P256-HKDF-SHA256+AES-256-GCM`: + +```jsonc +{ + "version": 1, + "algorithm": "ECDH-P256-HKDF-SHA256+AES-256-GCM", + "bindingContext": "9f1c…", // public, tamper-evident + "ephemeralPublicKey": "04…", // 65-byte uncompressed P-256 + "iv": "…", // 12 bytes, AES-GCM nonce + "ciphertext": "…", // sealed payload + "authTag": "…", // 16 bytes + "wrappedKeys": [ // one entry per authorized reader + { "keyId": "…", "wrappedKey": "…", "wrapIv": "…", "wrapAuthTag": "…" } + ], + "createdAt": 1728000000000 +} +``` + +## Key schedule + +- A fresh 32-byte **content key** per request, from `crypto.getRandomValues`. + It is never transmitted; it only travels inside `wrappedKeys`. +- Per recipient: a one-shot **ephemeral P-256 key pair** is generated, + ECDH is performed against that recipient's public key, and the shared secret + is run through HKDF-SHA256 to a 256-bit **key-encryption key**. +- The content key is sealed to each recipient with AES-256-GCM under that KEK. +- The payload is sealed **once** under the content key, so the ciphertext body + is identical for every recipient and the envelope grows by ~145 bytes per + recipient rather than re-encrypting the body N times. + +`extractable` is `true` only on keys that must be persisted for a reload +(the responder's long-lived key pair). The per-request ephemeral and content +keys are generated non-extractable and are discarded once the envelope is built. + +## Domain separation and context binding + +Every AEAD tag is computed with a distinct `info`/AAD, so no ciphertext from +one layer can ever be replayed as another: + +| Layer | Bound to | +| --- | --- | +| content encryption | `bindingContext` | +| CEK wrap | `bindingContext` + recipient `keyId` | + +`bindingContext` is a client-generated submission id (a UUID). It is carried in +the envelope in the clear — it is public, and the AEAD tags make editing it +fail authentication — so a responder needs no out-of-band coordination. +`decryptEmergencyPayload` accepts an optional expected context and will refuse to +open an envelope bound to a different request. + +The on-chain request id is assigned by the contract and so cannot be part of a +pre-signature binding; the relay is therefore indexed by **both** the submission +id and the ledger request id. + +## Responder keys + +A responder generates an ECDH P-256 key pair in the browser, publishes the +public half, and keeps the private half in the tab. `keyId` is a truncated +SHA-256 of the public key, so it is stable across reloads, identical from +either half of the pair, and lets an envelope be matched to its `wrappedKeys` +entry without trusting registry ordering. + +A wallet may register several keys (a laptop and a phone); the registry is keyed +by `keyId`, so each is published and each receives its own wrapped key. + +Private keys are never sent to the server. The relay has no endpoint that +accepts key material, and both write paths reject any body containing a +`privateKey`/`secretKey`/`mnemonic`-shaped key before validating anything else. + +## Relay + +`server/routes/dispatch.ts` is a blind, opaque relay: + +- validates the envelope structurally and for size, but cannot read it; +- stores envelopes keyed by submission id and by on-chain request id; +- rate-limits writes per IP; +- logs request id, recipient count and byte size only — never the blob; +- exposes Prometheus counters for writes, reads, rate-limit hits and refusals. + +It is an **accelerator, not a system of record**. The authoritative copy is the +envelope in the contract's `encrypted_payload`, so a relay wipe or outage costs +latency and nothing else. This is why the in-memory store is acceptable today; +a durable store must have the same property — it must be safe to lose. + +## Limits + +| Limit | Value | Enforced in | +| --- | --- | --- | +| Plaintext payload | 4 KiB | `encryptEmergencyPayload` | +| Serialized envelope | 12 KiB | client + relay + contract `PayloadTooLarge` | +| Recipients per envelope | 32 | client + relay | + +The 12 KiB envelope budget is the contract's storage bound, chosen so a full +32-responder roster still fits in one `create_request`. + +## Contract change: breaking + +`HelpRequest.nickname: String` and `HelpRequest.contact: String` are replaced by +`encrypted_payload: Bytes`, and `create_request` drops the phantom `priority` +argument and takes the sealed payload in its place. + +> **Migration required.** A contract deployed before this change has +> `HelpRequest` entries encoded with the old field set. Reading them with the new +> contract will fail to deserialize, and there is no in-place upgrade path +> because the stored XDR layout changed. Testnet and mainnet contracts must be +> redeployed, and any deployment holding live requests must drain or archive +> them first. There is no code path that writes plaintext into the new field: +> `encodeEncryptedPayload` rejects anything that is not a valid v1 envelope. + +New validation errors on the contract: `PayloadEmpty = 12`, +`PayloadTooLarge = 13`, `EmergencyTypeInvalid = 14`. + +## Threat model + +**Protected against** + +- a curious or compromised relay/backend operator reading medical details; +- RPC node or indexer snooping on transaction contents; +- passive network observers (everything is TLS plus AEAD-sealed); +- a responder not addressed by an envelope opening it; +- replay of an envelope under a different request (`bindingContext` in the AAD); +- transplanting one recipient's wrapped key under another's `keyId`. + +**Not protected against** + +- a responder who *is* addressed reading the details they were dispatched for; +- a compromised browser extension or XSS in the requester's tab, which sees the + plaintext before sealing and the private key after import; +- traffic analysis — an observer still learns that a request happened, roughly + where, and how many responders it was sealed for; +- the requester's own device backups, since the responder key is in + `sessionStorage` until it is moved into the encrypted `SecureStorage`. + +## Design decisions + +- **Hybrid, not per-recipient encryption.** One AES-GCM body with N wrapped keys + keeps a 32-responder envelope inside the contract's storage budget; sealing + the body N times would not. +- **ECDH P-256 + HKDF rather than RSA.** Browser-native, no key-size cliff, and + the WebCrypto API is the same in the browser, in Node and in tests. +- **Ephemeral keys per request rather than a long-lived responder public key + for wrapping.** A fresh ephemeral key per recipient per request means a + compromised responder key only exposes the envelopes addressed to it, and + there is no reusable "encrypt to responder X" oracle. +- **Blind relay, authoritative on-chain.** A relay that is down or wiped must + never be able to lose a request, only to slow one down. +- **Location left readable.** Dispatch needs it, and the ZK proof plus + coarsening already address the privacy risk; hiding it would push + responder-selection onto the responders. + +## Testing + +`test/e2e-encryption.test.js` covers the full path: key generation and `keyId` +derivation, JWK round-trip across a simulated reload, single and multi-recipient +seal/open, non-recipient rejection, context binding, tamper resistance for the +ciphertext, tag, IV, context field and wrapped keys, algorithm/version +downgrade, envelope size and recipient limits, plaintext-leak checks on the +serialized envelope, on-chain hex encoding, and an opaque relay round trip +driven through the real Express routers. + +`test/contract-functions.test.js` covers the ledger side; the Rust cases in +`contract/contracts/helphone-contract/src/test.rs` cover the storage and +validation rules. diff --git a/public/config.json b/public/config.json index 2846d4cd..a4a7caa3 100644 --- a/public/config.json +++ b/public/config.json @@ -32,6 +32,14 @@ "environments": ["staging", "development"] } }, + "encrypted_dispatch": { + "enabled": true, + "rolloutPercentage": 100, + "targets": { + "roles": ["responder", "user"], + "environments": ["production", "staging", "development"] + } + }, "experimental_zk_v2": { "enabled": false, "rolloutPercentage": 0 diff --git a/server/routes/dispatch.ts b/server/routes/dispatch.ts new file mode 100644 index 00000000..a9fa526b --- /dev/null +++ b/server/routes/dispatch.ts @@ -0,0 +1,453 @@ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { + deriveKeyIdFromPublicKey, + isEncryptedEnvelope, + MAX_ENVELOPE_BYTES, + MAX_PAYLOAD_RECIPIENTS, +} from '../../src/lib/crypto.ts' +import type { EncryptedEnvelope, ResponderEncryptionKey } from '../../src/types/index.ts' +import { PROMETHEUS_CONTENT_TYPE, renderPrometheus } from '../middleware/metrics.ts' +import type { MetricFamily } from '../middleware/metrics.ts' + +/** + * Encrypted Emergency Payload Relay (end-to-end encrypted dispatch). + * + * POST /api/dispatch/keys register a responder's public key + * GET /api/dispatch/keys list registered responder keys + * POST /api/dispatch/payload/:id store a sealed envelope for a request + * GET /api/dispatch/payload/:id fetch the sealed envelopes for a request + * GET /metrics/dispatch Prometheus counters + * + * ## The one rule this module exists to enforce + * + * This process is a blind relay. It stores, indexes and returns **ciphertext + * only**. It never holds a content key, a wrapping key, a responder private + * key, or a plaintext contact number / medical note — and it is built so that + * a mistake on the client cannot turn it into a decryption oracle: + * + * * Bodies are structurally validated with `isEncryptedEnvelope`; anything + * that is not a well-formed v1 envelope is rejected outright. + * * `rejectPlaintextFields` refuses any body carrying a plaintext-looking + * key (`contact`, `medicalNotes`, `privateKey`, …). A client bug that + * sends the cleartext anyway fails loudly here instead of quietly + * persisting it to the operator's disk. + * * Nothing is logged except opaque ids, sizes and counters. Sealed blobs + * are never written to a log line. + * * The key registry holds *public* keys only. A private key posted to + * `POST /keys` is rejected by name. + * + * Confidentiality therefore rests entirely in the client-side envelope + * (ECDH P-256 + HKDF-SHA256 + AES-256-GCM, see src/lib/crypto.ts) and in the + * responder's key custody — not in anything enforced here. + * + * ## Storage + * + * In-memory Maps, matching `server/index.ts`'s preferences/feedback handlers. + * This is a cache in front of the ledger: the authoritative copy of an + * envelope is the `encrypted_payload` field of the on-chain `HelpRequest`. + * Envelopes are therefore safe to lose — a restart degrades latency, not + * availability or secrecy. + */ + +/** + * Envelope addressing key. + * + * Two forms are accepted because the relay is indexed by *both*: + * - a client-generated submission id (a UUID), which is the `bindingContext` + * the envelope is sealed against and therefore known before signing; and + * - an on-chain request id, the `u64` the contract assigned, which is what a + * responder has when they tap a map marker. + * + * Bounded to 64 chars so it can never be used to blow up a log line or a + * metric label. + */ +const REQUEST_ID_RE = /^[A-Za-z0-9_-]{1,64}$/ + +/** + * Stellar account binding. This is a sanity bound, not full strkey validation: + * the public key is what actually grants the right to open an envelope, so an + * odd-looking address here cannot grant access to anything. + */ +const WALLET_RE = /^[A-Za-z0-9_]{8,64}$/ + +/** Uncompressed SEC1 P-256 point, hex. */ +const PUBLIC_KEY_RE = /^04[0-9a-f]{128}$/i + +/** + * Envelopes retained per request. A requester legitimately re-seals (e.g. to + * add a responder dispatched later); this bounds that to a handful of + * revisions instead of unbounded growth. + */ +const MAX_ENVELOPES_PER_REQUEST = 4 + +/** Fixed-window rate limit per client, applied to writes only. */ +const WRITE_RATE_LIMIT = 30 +const WRITE_RATE_WINDOW_MS = 60_000 + +/** + * Body keys that would mean plaintext (or key material) is being handed to a + * component that has no business holding it. Checked recursively and + * case-insensitively so `MedicalNotes` and `medical_notes` are both caught. + */ +const FORBIDDEN_BODY_KEYS = new Set([ + 'contact', + 'contacts', + 'phone', + 'phonenumber', + 'medicalnotes', + 'medicalnote', + 'notes', + 'note', + 'allergies', + 'nickname', + 'plaintext', + 'cleartext', + 'secret', + 'key', + 'privatekey', + 'secretkey', + 'contentkey', + 'dek', + 'password', + 'passphrase', + 'mnemonic', + 'seed', + 'seedphrase', +]) + +export interface DispatchStore { + putEnvelope(requestId: string, envelope: EncryptedEnvelope): { stored: boolean; reason?: string } + getEnvelopes(requestId: string): EncryptedEnvelope[] + registerKey(key: ResponderEncryptionKey): { ok: boolean; error?: string } + listKeys(): ResponderEncryptionKey[] + stats(): { requests: number; envelopes: number; keys: number; rejectedPlaintext: number } +} + +export function createMemoryDispatchStore(): DispatchStore { + const byRequest = new Map() + const keys = new Map() // keyed by keyId + const now = () => Date.now() + + return { + putEnvelope(requestId, envelope) { + const existing = byRequest.get(requestId) + if (existing && existing.length >= MAX_ENVELOPES_PER_REQUEST) { + return { stored: false, reason: 'too-many-envelopes' } + } + if (existing) { + // Identical re-submission is a no-op rather than a new revision. + const duplicate = existing.some((e) => e.ciphertext === envelope.ciphertext) + if (duplicate) return { stored: true } + existing.push(envelope) + } else { + byRequest.set(requestId, [envelope]) + } + return { stored: true } + }, + getEnvelopes(requestId) { + return byRequest.get(requestId) ?? [] + }, + registerKey(key) { + // Keyed by keyId so one wallet can hold several keys (a laptop and a + // phone) and still be published for every one of them. + if (keys.has(key.keyId)) return { ok: true } + keys.set(key.keyId, key) + return { ok: true } + }, + listKeys() { + return [...keys.values()] + }, + stats() { + let envelopes = 0 + for (const list of byRequest.values()) envelopes += list.length + return { + requests: byRequest.size, + envelopes, + keys: keys.size, + rejectedPlaintext: 0, + } + }, + } +} + +export interface DispatchRouterOptions { + /** Injectable for tests; defaults to a process-local store. */ + store?: DispatchStore + /** Master switch. Set false to refuse every write without unmounting. */ + enabled?: boolean +} + +/** + * Depth-limited scan for plaintext-shaped keys anywhere in a body. + * Returns the offending key path, or null when the body looks like ciphertext. + */ +export function findForbiddenBodyKey( + value: unknown, + path = '$', + depth = 0, +): string | null { + // A deeply nested body is not a shape this API ever legitimately produces; + // stop rather than recursing into something adversarial. + if (depth > 8) return null + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i += 1) { + const hit = findForbiddenBodyKey(value[i], `${path}[${i}]`, depth + 1) + if (hit) return hit + } + return null + } + if (!value || typeof value !== 'object') return null + for (const [key, child] of Object.entries(value as Record)) { + const normalized = key.toLowerCase().replace(/[^a-z]/g, '') + if (FORBIDDEN_BODY_KEYS.has(normalized)) return `${path}.${key}` + const hit = findForbiddenBodyKey(child, `${path}.${key}`, depth + 1) + if (hit) return hit + } + return null +} + +export interface DispatchMetricOptions { + store: DispatchStore + rejectedPlaintext: number + rejectedMalformed: number + writes: number + reads: number + rateLimited: number +} + +export function dispatchMetricFamilies(opts: DispatchMetricOptions): MetricFamily[] { + const s = opts.store.stats() + const sample = (name: string, help: string, value: number) => ({ + name, + help, + type: 'counter' as const, + samples: [{ labels: {}, value }], + }) + return [ + { + name: 'helphone_dispatch_envelopes', + help: 'Sealed emergency payload envelopes currently held by the relay.', + type: 'gauge', + samples: [{ labels: {}, value: s.envelopes }], + }, + { + name: 'helphone_dispatch_requests', + help: 'Distinct requests with at least one stored envelope.', + type: 'gauge', + samples: [{ labels: {}, value: s.requests }], + }, + { + name: 'helphone_dispatch_responder_keys', + help: 'Registered responder encryption public keys.', + type: 'gauge', + samples: [{ labels: {}, value: s.keys }], + }, + sample( + 'helphone_dispatch_writes_total', + 'Accepted envelope writes.', + opts.writes, + ), + sample('helphone_dispatch_reads_total', 'Envelope reads served.', opts.reads), + sample( + 'helphone_dispatch_rejected_plaintext_total', + 'Writes refused because the body carried plaintext or key material.', + opts.rejectedPlaintext, + ), + sample( + 'helphone_dispatch_rejected_malformed_total', + 'Writes refused because the envelope failed structural validation.', + opts.rejectedMalformed, + ), + sample( + 'helphone_dispatch_rate_limited_total', + 'Writes refused by the per-client rate limiter.', + opts.rateLimited, + ), + ] +} + +/** + * Fixed-window per-client write limiter. Hand-rolled (like + * `middleware/keepAlive.ts` and `middleware/compression.ts`) so this module + * stays dependency-free and unit-testable without booting the whole app. + */ +function createWriteLimiter(limit: number, windowMs: number) { + const hits = new Map() + return { + allow(client: string, now = Date.now()): boolean { + const entry = hits.get(client) + if (!entry || now >= entry.resetAt) { + hits.set(client, { count: 1, resetAt: now + windowMs }) + return true + } + entry.count += 1 + return entry.count <= limit + }, + reset(): void { + hits.clear() + }, + } +} + +export function createDispatchRouters(opts: DispatchRouterOptions = {}) { + const store = opts.store ?? createMemoryDispatchStore() + const enabled = opts.enabled ?? true + const limiter = createWriteLimiter(WRITE_RATE_LIMIT, WRITE_RATE_WINDOW_MS) + + const counters = { rejectedPlaintext: 0, rejectedMalformed: 0, writes: 0, reads: 0, rateLimited: 0 } + + const api = Router() + + // ── Responder key registry (public keys only) ────────────────────── + api.post('/keys', async (req: Request, res: Response) => { + if (!enabled) { + res.status(503).json({ success: false, error: 'Encrypted dispatch is disabled.' }) + return + } + const wallet = typeof req.body?.wallet === 'string' ? req.body.wallet.trim() : '' + const publicKey = typeof req.body?.publicKey === 'string' ? req.body.publicKey.trim() : '' + + // Reject key material by name before validating anything else: this + // endpoint must never be a place a private key ends up stored or logged. + const forbidden = findForbiddenBodyKey(req.body) + if (forbidden) { + counters.rejectedPlaintext += 1 + res.status(400).json({ + success: false, + error: `Refusing to accept key material at ${forbidden}. This endpoint stores public keys only.`, + }) + return + } + if (!WALLET_RE.test(wallet)) { + res.status(400).json({ success: false, error: 'A valid wallet address is required.' }) + return + } + if (!PUBLIC_KEY_RE.test(publicKey)) { + res + .status(400) + .json({ success: false, error: 'publicKey must be an uncompressed P-256 point (04 + 128 hex).' }) + return + } + let keyId: string + try { + keyId = await deriveKeyIdFromPublicKey(publicKey) + } catch { + res.status(400).json({ success: false, error: 'publicKey is not a valid P-256 point.' }) + return + } + store.registerKey({ + wallet, + publicKey: publicKey.toLowerCase(), + keyId, + registeredAt: Date.now(), + }) + // Cache-Control: the key list is per-deployment, and a shared cache must + // not hand one responder's registry to another tenant's tab. + res.set('Cache-Control', 'no-store').json({ + success: true, + keyId, + recipientCount: store.listKeys().length, + }) + }) + + api.get('/keys', (_req: Request, res: Response) => { + counters.reads += 1 + res.set('Cache-Control', 'no-store').json({ success: true, keys: store.listKeys() }) + }) + + // ── Sealed envelope store ────────────────────────────────────────── + api.post('/payload/:requestId', (req: Request, res: Response) => { + if (!enabled) { + res.status(503).json({ success: false, error: 'Encrypted dispatch is disabled.' }) + return + } + const requestId = String(req.params.requestId ?? '') + if (!REQUEST_ID_RE.test(requestId)) { + res.status(400).json({ success: false, error: 'requestId must be a positive integer.' }) + return + } + if (!limiter.allow(req.ip ?? 'unknown')) { + counters.rateLimited += 1 + res.set('Retry-After', String(Math.ceil(WRITE_RATE_WINDOW_MS / 1000))) + res.status(429).json({ success: false, error: 'Too many dispatch writes. Try again shortly.' }) + return + } + + const forbidden = findForbiddenBodyKey(req.body) + if (forbidden) { + counters.rejectedPlaintext += 1 + res.status(400).json({ + success: false, + error: + `Refusing plaintext at ${forbidden}. This relay stores sealed envelopes only — ` + + 'encrypt the payload on the client first.', + }) + return + } + + const envelope = req.body?.envelope + if (!isEncryptedEnvelope(envelope)) { + counters.rejectedMalformed += 1 + res.status(400).json({ + success: false, + error: 'envelope is not a well-formed v1 EncryptedEnvelope.', + }) + return + } + if (envelope.wrappedKeys.length > MAX_PAYLOAD_RECIPIENTS) { + counters.rejectedMalformed += 1 + res.status(400).json({ success: false, error: 'Too many recipients in envelope.' }) + return + } + const size = Buffer.byteLength(JSON.stringify(envelope), 'utf8') + if (size > MAX_ENVELOPE_BYTES) { + counters.rejectedMalformed += 1 + res.status(413).json({ + success: false, + error: `Envelope is ${size} bytes; the limit is ${MAX_ENVELOPE_BYTES}.`, + }) + return + } + + const outcome = store.putEnvelope(requestId, envelope) + if (!outcome.stored) { + res.status(409).json({ success: false, error: outcome.reason ?? 'rejected' }) + return + } + counters.writes += 1 + // Log shape only. Never the blob. + console.info('[dispatch] stored envelope', { + requestId, + recipients: envelope.wrappedKeys.length, + bytes: size, + }) + res.set('Cache-Control', 'no-store').json({ + success: true, + requestId, + recipientCount: envelope.wrappedKeys.length, + }) + }) + + api.get('/payload/:requestId', (req: Request, res: Response) => { + const requestId = String(req.params.requestId ?? '') + if (!REQUEST_ID_RE.test(requestId)) { + res.status(400).json({ success: false, error: 'requestId must be a positive integer.' }) + return + } + counters.reads += 1 + const envelopes = store.getEnvelopes(requestId) + // A miss is a normal cache miss, not an error: the ledger copy is + // authoritative and the client can fall back to reading it from-chain. + res.set('Cache-Control', 'no-store').json({ success: true, requestId, envelopes }) + }) + + const metrics = Router() + metrics.get('/dispatch', (_req: Request, res: Response) => { + res + .set('Content-Type', PROMETHEUS_CONTENT_TYPE) + .send(renderPrometheus(dispatchMetricFamilies({ store, ...counters }))) + }) + + return { api, metrics, store, limiter } +} diff --git a/src/lib/contract.ts b/src/lib/contract.ts index 5307ecd1..f35bfd11 100644 --- a/src/lib/contract.ts +++ b/src/lib/contract.ts @@ -31,6 +31,7 @@ import { summarizeFootprint, } from "./footprint"; import { verificationWindow } from "./ringBuffer"; +import { isEncryptedEnvelope } from "./crypto"; /** Validate a Stellar Soroban contract ID (strkey 'C...' with CRC16 checksum). * Throws immediately with a clear message instead of letting a malformed ID @@ -365,8 +366,29 @@ function scv(val, opts) { const PRIORITY_LEVELS = ["Low", "Medium", "High", "Critical"]; +/** + * Parse the sealed envelope the contract returned. + * + * The contract stores it as opaque bytes (we submit hex), so anything that + * fails to parse is surfaced as `null` rather than crashing a map render — a + * responder dashboard must still show the request's location and status. + */ +function parseEncryptedPayload(raw) { + const hex = typeof raw === "string" ? raw : raw?.toString?.("hex"); + if (typeof hex !== "string" || hex.length === 0) return null; + try { + const envelope = JSON.parse(Buffer.from(hex, "hex").toString("utf8")); + return isEncryptedEnvelope(envelope) ? envelope : null; + } catch { + return null; + } +} + function mapRequest(raw) { const STATUS = ["Pending", "Enroute", "Resolved", "Cancelled"]; + // `priority` is a client-side display concern; the contract has no such + // field, so it is carried through from local state when present and + // defaulted otherwise. const rawPriority = typeof raw.priority === "number" ? PRIORITY_LEVELS[raw.priority] @@ -379,8 +401,9 @@ function mapRequest(raw) { lat: decodeLat(raw.lat), lng: decodeLng(raw.lng), emergency_type: raw.emergency_type, - nickname: raw.nickname, - contact: raw.contact, + // The contact number and medical notes now live *inside* this envelope, + // sealed to the authorized responders. Decrypt locally to read them. + encrypted_payload: parseEncryptedPayload(raw.encrypted_payload), status: STATUS[raw.status] ?? (Array.isArray(raw.status) ? raw.status[0] : raw.status), @@ -974,21 +997,63 @@ async function buildInvocation({ account, functionName, args, timeoutSeconds = 3 return transaction; } +/** + * Encode a sealed emergency payload for `create_request`. + * + * Accepts the `EncryptedEnvelope` produced by `encryptEmergencyPayload` (the + * normal path) or a raw hex string (for tests / replay). Plaintext is + * rejected outright: contact numbers and medical notes must never reach a + * ledger entry, and the contract now refuses an empty payload. + */ +export function encodeEncryptedPayload(payload) { + if (typeof payload === "string") { + const hex = payload.trim(); + if (hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) { + throw new Error( + "Encrypted payload must be a hex string or an EncryptedEnvelope.", + ); + } + return hex; + } + if (payload && typeof payload === "object") { + if (!isEncryptedEnvelope(payload)) { + throw new Error( + "Encrypted payload envelope is malformed, truncated or of an unknown version.", + ); + } + return Buffer.from(JSON.stringify(payload), "utf8").toString("hex"); + } + throw new Error( + "An encrypted payload is required. Emergency contact details are never sent in plaintext.", + ); +} + +/** + * Broadcast a help request. + * + * @param encryptedPayload sealed `EncryptedEnvelope` (or hex string). The + * contact number, nickname and medical notes must already be inside it + * — use `encryptEmergencyPayload` with each responder's public key. + * @param options.priority client-side display tier only. It is *not* a + * contract field and is deliberately not sent on-chain: the deployed + * `create_request` takes exactly five arguments, and sending a sixth + * used to make every submission revert. + */ export async function createRequest( requester, lat, lng, emergencyType, - nickname, - contact, + encryptedPayload, wallet, - priority = "Medium", + options = {}, ) { const signerAddress = await resolveWalletAddress(wallet, requester); if (!signerAddress) throw new Error("Wallet address is not available yet"); await ensureAccountFunded(signerAddress); const account = await server.getAccount(signerAddress); - const priorityIndex = PRIORITY_LEVELS.indexOf(priority); + const payloadHex = encodeEncryptedPayload(encryptedPayload); + const priorityIndex = PRIORITY_LEVELS.indexOf(options.priority); const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK, @@ -1002,9 +1067,7 @@ export async function createRequest( scv(encodeCoord(lat, "lat"), { type: "i32" }), scv(encodeCoord(lng, "lng"), { type: "i32" }), scv(emergencyType, { type: "string" }), - scv(nickname, { type: "string" }), - scv(contact, { type: "string" }), - scv(priorityIndex >= 0 ? priorityIndex : 1, { type: "u32" }), + scv(Buffer.from(payloadHex, "hex"), { type: "bytes" }), ], }), ) @@ -1013,7 +1076,11 @@ export async function createRequest( const result = await sendWrite(tx, wallet, "create_request"); const retval = scValToNative(result.returnValue); - return { requestId: safeToNumber(retval), hash: result.hash }; + return { + requestId: safeToNumber(retval), + hash: result.hash, + priority: priorityIndex >= 0 ? options.priority : "Medium", + }; } export async function acceptRequest( diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index 91614237..4aff6ddf 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -1,10 +1,19 @@ import { Keypair } from '@stellar/stellar-sdk' import crypto from 'crypto' -import type { EncryptedData, SignaturePayload, WebAuthnVerificationResult } from '../types/index.js' +import type { + EncryptedData, + EmergencyPayload, + EncryptedEnvelope, + ResponderEncryptionKey, + SignaturePayload, + WebAuthnVerificationResult, + WrappedContentKey, +} from '../types/index.js' /** * HelPhone Cryptographic Services Suite * Supports Ed25519, WebAuthn P-256 (ECDSA SHA-256), and AES-256-GCM. + * Plus end-to-end encrypted emergency payloads (ECDH P-256 + AES-256-GCM). */ // Helper to hash any passcode/key into a 32-byte (256-bit) buffer using SHA-256 @@ -166,3 +175,792 @@ export async function decryptAESGCM( return decrypted } } + +// ═══════════════════════════════════════════════════════════════════ +// End-to-End Encrypted Emergency Payloads +// ═══════════════════════════════════════════════════════════════════ +// +// Threat model: the ledger, the relay server and every network hop in +// between are treated as hostile. A responder must be able to learn a +// requester's contact number and medical notes, and nobody else — including +// the operator of our own backend — must. +// +// Scheme (textbook ECIES, one recipient list, one ciphertext): +// +// 1. Draw a random 32-byte content key (CEK) and a one-shot ephemeral +// ECDH P-256 keypair. The ephemeral private half is never exported, +// never stored and is dropped when this function returns. +// 2. Seal the canonical payload JSON under the CEK with AES-256-GCM using +// a fresh 96-bit IV. AAD binds the ciphertext to the request id, so a +// sealed blob cannot be replayed into a different request. +// 3. For each authorized recipient, derive a key-encryption key with +// ECDH(ephemeral private, recipient public) → HKDF-SHA256, salted with +// the ephemeral public key (unique per message, and already stored in +// the envelope, so no extra field is needed) and domain-separated in +// `info`. Wrap the CEK under that KEK with its own IV. AAD here binds +// the wrap to the recipient's key id, which blocks key substitution. +// 4. Emit the envelope. The recipient finds its own wrap by key id, unwraps +// the CEK, and decrypts. +// +// Hybrid rather than per-recipient encryption means the ciphertext is stored +// once no matter how many responders are dispatched, and — more importantly — +// that the set of recipients is not observable from the blob sizes. +// +// Everything below runs on WebCrypto's `subtle`, which is the same +// implementation in browsers and in Node ≥ 15. The existing +// `encryptAESGCM`/`decryptAESGCM` above keep their older dual browser/Node +// path; nothing here duplicates that divergence. + +/** Wire algorithm tag. Checked on decrypt so a downgrade cannot be forced. */ +export const E2EE_ALGORITHM = 'ECDH-P256-HKDF-SHA256+AES-256-GCM' as const + +/** Envelope layout version. Bumped only on an incompatible layout change. */ +export const E2EE_VERSION = 1 + +/** Uncompressed SEC1 P-256 point: `0x04 || X(32) || Y(32)`. */ +const P256_PUBLIC_KEY_BYTES = 65 +const PUBLIC_KEY_HEX_RE = /^04[0-9a-f]{128}$/i +const P256_UNCOMPRESSED_TAG = 0x04 +/** ECDH P-256 shared secret length. */ +const SHARED_SECRET_BITS = 256 +const AES_KEY_BYTES = 32 +const GCM_IV_BYTES = 12 +const GCM_TAG_BYTES = 16 +const GCM_TAG_BITS = 128 + +/** Key-id length in bytes (SHA-256 prefix) before hex encoding. */ +const KEY_ID_BYTES = 8 + +/** + * Ceilings. `lat`/`lng`/`emergencyType` are the only plaintext left, so the + * sealed part is small in practice; these bounds stop a single request from + * turning into an unbounded ledger entry or a CPU DoS against a responder. + * + * `MAX_ENVELOPE_BYTES` mirrors the `MAX_ENCRYPTED_PAYLOAD_BYTES` cap enforced + * by the Soroban contract in `create_request`. Hex doubles the ciphertext, so + * the two are deliberately different numbers: check the envelope here so the + * user gets a clear error instead of a failed on-chain invocation. + */ +export const MAX_PAYLOAD_PLAINTEXT_BYTES = 4096 +export const MAX_ENVELOPE_BYTES = 12288 +export const MAX_PAYLOAD_RECIPIENTS = 32 + +/** + * Domain separation. Every derived key and every AEAD tag is bound to one of + * these strings, so a key or tag minted for the wrap step can never be + * replayed into the payload step (or vice versa). + */ +const E2EE_INFO_PREFIX = 'helphone-e2ee-v1' + +export class E2EEError extends Error { + readonly code: string + constructor(code: string, message: string) { + super(message) + this.name = 'E2EEError' + this.code = code + } +} + +// ── Low-level helpers ────────────────────────────────────────────── + +function subtleOrThrow(): SubtleCrypto { + const c = (globalThis as { crypto?: Crypto }).crypto + if (!c || !c.subtle) { + throw new E2EEError( + 'no-subtle-crypto', + 'WebCrypto SubtleCrypto is unavailable. End-to-end encryption needs a secure context (HTTPS or localhost).', + ) + } + return c.subtle +} + +function randomBytes(length: number): Uint8Array { + const c = (globalThis as { crypto?: Crypto }).crypto + if (!c || typeof c.getRandomValues !== 'function') { + throw new E2EEError('no-csprng', 'A cryptographically secure random source is unavailable.') + } + return c.getRandomValues(new Uint8Array(length)) +} + +const HEX_ALPHABET = /^[0-9a-fA-F]*$/ + +function bytesToHex(bytes: Uint8Array): string { + let out = '' + for (let i = 0; i < bytes.length; i += 1) { + out += bytes[i].toString(16).padStart(2, '0') + } + return out +} + +function hexToBytes(hex: string, label: string): Uint8Array { + if (typeof hex !== 'string') { + throw new E2EEError('bad-hex', `${label} must be a hex string.`) + } + const clean = hex.trim() + if (clean.length % 2 !== 0) { + throw new E2EEError('bad-hex', `${label} must have an even number of hex digits.`) + } + if (!HEX_ALPHABET.test(clean)) { + throw new E2EEError('bad-hex', `${label} contains non-hexadecimal characters.`) + } + const out = new Uint8Array(clean.length / 2) + for (let i = 0; i < out.length; i += 1) { + out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16) + } + return out +} + +/** Byte length a hex string decodes to, or -1 when it is not valid hex. */ +function hexByteLength(hex: unknown): number { + if (typeof hex !== 'string') return -1 + const clean = hex.trim() + if (clean.length % 2 !== 0 || !HEX_ALPHABET.test(clean)) return -1 + return clean.length / 2 +} + +function encodeUtf8(value: string): Uint8Array { + return new TextEncoder().encode(value) +} + +function decodeUtf8(bytes: ArrayBuffer | Uint8Array): string { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes as ArrayBuffer) +} + +/** Rejects anything that is not an exact-length hex field. */ +function requireHexOfBytes(hex: unknown, bytes: number, label: string): string { + if (hexByteLength(hex) !== bytes) { + throw new E2EEError('bad-field', `${label} must be ${bytes} bytes of hex.`) + } + return (hex as string).trim() +} + +// ── Key material ─────────────────────────────────────────────────── +export interface EncryptionKeyPair { + /** ECDH P-256 public key. Safe to publish. */ + publicKey: CryptoKey + /** + * ECDH P-256 private key. Keep it in the encrypted `SecureStorage` + * (src/lib/secureStorage.ts) — never in plaintext `localStorage`, and never + * in a request, a log line or the relay. + */ + privateKey: CryptoKey +} + +/** + * Generate a responder's long-lived encryption keypair. + * + * Extractable so the private half can be persisted as a JWK and re-imported + * after a reload. If you would rather it were hardware-bound, import a + * non-extractable key with `importEncryptionPrivateKeyJwk` instead and expect + * `deriveKeyIdFromPrivateKey` to be unavailable. + */ +export async function generateEncryptionKeyPair(): Promise { + const subtle = subtleOrThrow() + const pair = (await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ])) as CryptoKeyPair + return { publicKey: pair.publicKey, privateKey: pair.privateKey } +} + +export async function exportPublicKeyHex(publicKey: CryptoKey): Promise { + const raw = new Uint8Array(await subtleOrThrow().exportKey('raw', publicKey)) + if (raw.length !== P256_PUBLIC_KEY_BYTES || raw[0] !== P256_UNCOMPRESSED_TAG) { + throw new E2EEError('bad-key', 'Expected an uncompressed P-256 public key (65 bytes, 0x04 prefix).') + } + return bytesToHex(raw) +} + +export async function importPublicKeyHex(publicKeyHex: string): Promise { + const raw = hexToBytes(publicKeyHex, 'public key') + if (raw.length !== P256_PUBLIC_KEY_BYTES || raw[0] !== P256_UNCOMPRESSED_TAG) { + throw new E2EEError('bad-key', 'Public key must be an uncompressed P-256 point (65 bytes, 0x04 prefix).') + } + return subtleOrThrow().importKey('raw', raw, { name: 'ECDH', namedCurve: 'P-256' }, true, []) +} + +export async function exportPrivateKeyJwk(privateKey: CryptoKey): Promise { + return subtleOrThrow().exportKey('jwk', privateKey) +} + +export async function importPrivateKeyJwk(jwk: JsonWebKey): Promise { + return subtleOrThrow().importKey('jwk', jwk, { name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ]) +} + +/** + * Stable identifier for a public key: the first 8 bytes of its SHA-256. + * Doubles as the lookup handle inside the envelope, so it must be derived + * identically by the sender, the responder and the relay. + */ +export async function deriveKeyIdFromPublicKey(publicKeyHex: string): Promise { + const raw = hexToBytes(publicKeyHex, 'public key') + if (raw.length !== P256_PUBLIC_KEY_BYTES || raw[0] !== P256_UNCOMPRESSED_TAG) { + throw new E2EEError('bad-key', 'Public key must be an uncompressed P-256 point (65 bytes, 0x04 prefix).') + } + const digest = await sha256(raw) + return bytesToHex(digest.slice(0, KEY_ID_BYTES)) +} + +/** + * Same id, computed from the private key alone, so a responder can find its + * own wrap without also keeping the public key around. + * + * For ECDH the JWK carries the public `x`/`y`, which is exactly the SEC1 + * point, so this works without a second CryptoKey. Requires an extractable + * private key (true for keys from `generateEncryptionKeyPair`). + */ +export async function deriveKeyIdFromPrivateKey(privateKey: CryptoKey): Promise { + const jwk = await exportPrivateKeyJwk(privateKey) + if (!jwk.x || !jwk.y) { + throw new E2EEError('bad-key', 'Private key JWK is missing its public coordinates.') + } + const x = base64UrlToBytes(jwk.x) + const y = base64UrlToBytes(jwk.y) + const raw = new Uint8Array(P256_PUBLIC_KEY_BYTES) + raw[0] = P256_UNCOMPRESSED_TAG + raw.set(x, 1) + raw.set(y, 1 + x.length) + const digest = await sha256(raw) + return bytesToHex(digest.slice(0, KEY_ID_BYTES)) +} + +function base64UrlToBytes(value: string): Uint8Array { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + const withPadding = padded + '='.repeat((4 - (padded.length % 4)) % 4) + return new Uint8Array(Buffer.from(withPadding, 'base64')) +} + +// ── AEAD primitives ──────────────────────────────────────────────── + +function importAesKey(raw: Uint8Array, usage: 'encrypt' | 'decrypt'): Promise { + return subtleOrThrow().importKey('raw', raw, { name: 'AES-GCM' }, false, [usage]) +} + +interface Sealed { + ciphertext: Uint8Array + authTag: Uint8Array +} + +/** + * WebCrypto appends the GCM tag to the ciphertext, so split it back out to + * match the `EncryptedData`/`EncryptedEnvelope` shape the rest of the app uses. + */ +async function aesGcmSeal( + key: CryptoKey, + plaintext: Uint8Array, + iv: Uint8Array, + aad: Uint8Array, +): Promise { + const sealed = new Uint8Array( + await subtleOrThrow().encrypt( + { name: 'AES-GCM', iv, additionalData: aad, tagLength: GCM_TAG_BITS }, + key, + plaintext, + ), + ) + return { + ciphertext: sealed.slice(0, sealed.length - GCM_TAG_BYTES), + authTag: sealed.slice(sealed.length - GCM_TAG_BYTES), + } +} + +async function aesGcmOpen( + key: CryptoKey, + sealed: Sealed, + iv: Uint8Array, + aad: Uint8Array, +): Promise { + const joined = new Uint8Array(sealed.ciphertext.length + sealed.authTag.length) + joined.set(sealed.ciphertext, 0) + joined.set(sealed.authTag, sealed.ciphertext.length) + return new Uint8Array( + await subtleOrThrow().decrypt( + { name: 'AES-GCM', iv, additionalData: aad, tagLength: GCM_TAG_BITS }, + key, + joined, + ), + ) +} + +/** + * HKDF-SHA256 over an ECDH shared secret → AES-256-GCM key. + * + * The salt is the ephemeral public key: unique per message, already carried + * in the envelope, and bound to the very key material being derived, so a + * responder cannot be fed a mismatched (salt, secret) pair. + */ +async function deriveWrappingKey( + privateKey: CryptoKey, + peerPublicKey: CryptoKey, + ephemeralPublicRaw: Uint8Array, + info: string, +): Promise { + const subtle = subtleOrThrow() + const sharedSecret = await subtle.deriveBits( + { name: 'ECDH', public: peerPublicKey }, + privateKey, + SHARED_SECRET_BITS, + ) + const hkdfKey = await subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits']) + const wrappingKeyBits = await subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: ephemeralPublicRaw, + info: encodeUtf8(info), + }, + hkdfKey, + AES_KEY_BYTES * 8, + ) + return subtle.importKey('raw', wrappingKeyBits, { name: 'AES-GCM' }, false, [ + 'encrypt', + 'decrypt', + ]) +} + +// ── AAD (context binding) ────────────────────────────────────────── + +/** + * Normalise the id the payload is bound to. + * + * Numbers and bigints are truncated to integers so `7`, `'7'` and `7n` all + * produce the same AAD; anything else is used as a trimmed string. The result + * is carried in the envelope so a responder never needs out-of-band context, + * and mixed into every AEAD tag so editing it invalidates them. + */ +function normalizeContext(bindingContext: string | number | bigint): string { + if (typeof bindingContext === 'bigint') return bindingContext.toString(10) + if (typeof bindingContext === 'number') { + if (!Number.isFinite(bindingContext)) { + throw new E2EEError('bad-context', 'Binding context must be a finite number.') + } + return String(Math.trunc(bindingContext)) + } + const trimmed = String(bindingContext ?? '').trim() + if (!trimmed) { + throw new E2EEError('bad-context', 'Binding context must not be empty.') + } + if (trimmed.length > 128) { + throw new E2EEError('bad-context', 'Binding context must be 128 characters or fewer.') + } + return trimmed +} + +function payloadAad(context: string): Uint8Array { + return encodeUtf8(`${E2EE_INFO_PREFIX}|payload|request=${context}`) +} + +function wrapAad(context: string, keyId: string): Uint8Array { + return encodeUtf8(`${E2EE_INFO_PREFIX}|wrap|request=${context}|keyId=${keyId}`) +} + +// ── Payload serialisation ────────────────────────────────────────── + +/** + * Deterministic JSON: keys are emitted in sorted order so the same payload + * always produces the same plaintext bytes, which keeps the ciphertext stable + * and makes the format testable against fixed vectors. + */ +function canonicalize(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null' + if (Array.isArray(value)) return `[${value.map(canonicalize).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) + return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(',')}}` +} + +function serializePayload(payload: EmergencyPayload): Uint8Array { + if (!payload || typeof payload !== 'object') { + throw new E2EEError('bad-payload', 'An emergency payload object is required.') + } + for (const field of ['contact', 'medicalNotes', 'nickname'] as const) { + if (typeof payload[field] !== 'string') { + throw new E2EEError('bad-payload', `Emergency payload field "${field}" must be a string.`) + } + } + const record: Record = { + contact: payload.contact, + medicalNotes: payload.medicalNotes, + nickname: payload.nickname, + } + // `allergies` is optional; fold it in rather than emitting a second field + // that some older clients would silently drop. + if (typeof payload.allergies === 'string' && payload.allergies.length > 0) { + record.allergies = payload.allergies + } + if (typeof payload.requestedAt === 'number' && Number.isFinite(payload.requestedAt)) { + record.requestedAt = Math.trunc(payload.requestedAt) + } + const bytes = encodeUtf8(canonicalize(record)) + if (bytes.length === 0) { + throw new E2EEError('bad-payload', 'Refusing to seal an empty emergency payload.') + } + if (bytes.length > MAX_PAYLOAD_PLAINTEXT_BYTES) { + throw new E2EEError( + 'payload-too-large', + `Emergency payload is ${bytes.length} bytes; the limit is ${MAX_PAYLOAD_PLAINTEXT_BYTES}.`, + ) + } + return bytes +} + +function parsePayload(bytes: Uint8Array): EmergencyPayload { + let parsed: unknown + try { + parsed = JSON.parse(decodeUtf8(bytes)) + } catch { + // A GCM-authenticated plaintext that is not JSON means the producer used a + // different (or older) serialisation. Do not guess at it. + throw new E2EEError('bad-payload', 'Decrypted payload is not valid UTF-8 JSON.') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new E2EEError('bad-payload', 'Decrypted payload is not an object.') + } + const record = parsed as Record + const payload: EmergencyPayload = { + contact: typeof record.contact === 'string' ? record.contact : '', + medicalNotes: typeof record.medicalNotes === 'string' ? record.medicalNotes : '', + nickname: typeof record.nickname === 'string' ? record.nickname : '', + } + if (typeof record.allergies === 'string') payload.allergies = record.allergies + if (typeof record.requestedAt === 'number') payload.requestedAt = record.requestedAt + return payload +} + +// ── Envelope validation ──────────────────────────────────────────── + +/** + * Structural check shared by the client and the relay: shape, hex validity and + * length ceilings only. It says nothing about authenticity — that is what the + * GCM tags are for, and only a key holder can check them. + */ +export function isEncryptedEnvelope(value: unknown): value is EncryptedEnvelope { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const env = value as Record + if (env.version !== E2EE_VERSION) return false + if (env.algorithm !== E2EE_ALGORITHM) return false + if (typeof env.bindingContext !== 'string' || env.bindingContext.length < 1) return false + if (env.bindingContext.length > 128) return false + if (hexByteLength(env.ephemeralPublicKey) !== P256_PUBLIC_KEY_BYTES) return false + if (hexByteLength(env.iv) !== GCM_IV_BYTES) return false + if (hexByteLength(env.authTag) !== GCM_TAG_BYTES) return false + if (hexByteLength(env.ciphertext) < 1) return false + if (!Array.isArray(env.wrappedKeys) || env.wrappedKeys.length < 1) return false + if (env.wrappedKeys.length > MAX_PAYLOAD_RECIPIENTS) return false + return env.wrappedKeys.every((entry) => { + if (!entry || typeof entry !== 'object') return false + const wrap = entry as Record + return ( + typeof wrap.keyId === 'string' && + wrap.keyId.length === KEY_ID_BYTES * 2 && + hexByteLength(wrap.keyId) === KEY_ID_BYTES && + hexByteLength(wrap.wrapIv) === GCM_IV_BYTES && + hexByteLength(wrap.wrapAuthTag) === GCM_TAG_BYTES && + hexByteLength(wrap.wrappedKey) === AES_KEY_BYTES + ) + }) +} + +function assertEnvelope(envelope: EncryptedEnvelope): void { + if (!isEncryptedEnvelope(envelope)) { + throw new E2EEError('bad-envelope', 'Envelope is malformed, truncated or of an unknown version.') + } +} + +// ── Public API ───────────────────────────────────────────────────── + +/** + * Seal an emergency payload for a set of authorized recipients. + * + * @param payload plaintext contact number / medical notes + * @param recipientPublicKeys hex P-256 public keys, one per authorized reader. + * Include the requester if they should be able to + * re-read what they submitted. + * @param bindingContext id mixed into every AEAD tag, so this envelope + * cannot be opened in a different context. Normally + * a client-generated submission id, because the + * on-chain request id is not known until after the + * transaction is signed. Recorded in the envelope as + * `bindingContext` so responders need no extra + * coordination to read it. + */ +export async function encryptEmergencyPayload( + payload: EmergencyPayload, + recipientPublicKeys: string[], + bindingContext: string | number | bigint, +): Promise { + const context = normalizeContext(bindingContext) + const plaintext = serializePayload(payload) + + if (!Array.isArray(recipientPublicKeys) || recipientPublicKeys.length === 0) { + throw new E2EEError('no-recipients', 'At least one recipient public key is required.') + } + if (recipientPublicKeys.length > MAX_PAYLOAD_RECIPIENTS) { + throw new E2EEError( + 'too-many-recipients', + `Cannot seal for ${recipientPublicKeys.length} recipients; the limit is ${MAX_PAYLOAD_RECIPIENTS}.`, + ) + } + + const subtle = subtleOrThrow() + + // One content key for the whole recipient set, so the ciphertext is stored + // exactly once regardless of how many responders are dispatched. + const contentKeyBytes = randomBytes(AES_KEY_BYTES) + const contentKey = await importAesKey(contentKeyBytes, 'encrypt') + + // One-shot ephemeral pair. The private half is never exported and never + // leaves this function; dropping the reference is the whole lifetime + // guarantee, so nothing derived from it is ever reusable. + const ephemeral = (await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ])) as CryptoKeyPair + const ephemeralPublicRaw = new Uint8Array(await subtle.exportKey('raw', ephemeral.publicKey)) + if (ephemeralPublicRaw.length !== P256_PUBLIC_KEY_BYTES) { + throw new E2EEError('bad-key', 'Ephemeral public key has an unexpected length.') + } + + const iv = randomBytes(GCM_IV_BYTES) + const sealed = await aesGcmSeal(contentKey, plaintext, iv, payloadAad(context)) + + const wrappedKeys: WrappedContentKey[] = [] + const seenKeyIds = new Set() + for (const publicKeyHex of recipientPublicKeys) { + const keyId = await deriveKeyIdFromPublicKey(publicKeyHex) + if (seenKeyIds.has(keyId)) continue // duplicate recipient — one wrap is enough + seenKeyIds.add(keyId) + + const peerPublicKey = await importPublicKeyHex(publicKeyHex) + const wrappingKey = await deriveWrappingKey( + ephemeral.privateKey, + peerPublicKey, + ephemeralPublicRaw, + wrapInfo(context, keyId), + ) + const wrapIv = randomBytes(GCM_IV_BYTES) + const wrapped = await aesGcmSeal( + wrappingKey, + contentKeyBytes, + wrapIv, + wrapAad(context, keyId), + ) + wrappedKeys.push({ + keyId, + wrappedKey: bytesToHex(wrapped.ciphertext), + wrapIv: bytesToHex(wrapIv), + wrapAuthTag: bytesToHex(wrapped.authTag), + }) + } + + const envelope: EncryptedEnvelope = { + version: E2EE_VERSION, + algorithm: E2EE_ALGORITHM, + bindingContext: context, + ephemeralPublicKey: bytesToHex(ephemeralPublicRaw), + iv: bytesToHex(iv), + ciphertext: bytesToHex(sealed.ciphertext), + authTag: bytesToHex(sealed.authTag), + wrappedKeys, + createdAt: Date.now(), + } + + // Mirror the contract's cap so an over-large payload fails locally with a + // useful message instead of burning a signed transaction that reverts. + const envelopeBytes = estimateEnvelopeBytes(envelope) + if (envelopeBytes > MAX_ENVELOPE_BYTES) { + throw new E2EEError( + 'envelope-too-large', + `Sealed envelope is ${envelopeBytes} bytes; the contract accepts at most ${MAX_ENVELOPE_BYTES}.`, + ) + } + + return envelope +} + +/** + * Open an envelope with a recipient private key. + * + * @param expectedContext optional. When supplied it must equal the envelope's + * `bindingContext`; use it when the responder learned the id out of band + * (e.g. from the map marker) and wants a mismatch to fail loudly. + * Otherwise the envelope's own value is used — it is public, and the AEAD + * tags make it tamper-evident. + * + * Throws `E2EEError` with a stable `code` so callers can distinguish "not + * addressed to me" from "tampered" from "malformed" without string matching. + */ +export async function decryptEmergencyPayload( + envelope: EncryptedEnvelope, + privateKey: CryptoKey, + expectedContext?: string | number | bigint, +): Promise { + assertEnvelope(envelope) + if (expectedContext !== undefined) { + const expected = normalizeContext(expectedContext) + if (expected !== envelope.bindingContext) { + throw new E2EEError( + 'context-mismatch', + 'This payload is bound to a different request and cannot be opened here.', + ) + } + } + const context = envelope.bindingContext + const subtle = subtleOrThrow() + + const ephemeralPublicRaw = hexToBytes(envelope.ephemeralPublicKey, 'ephemeralPublicKey') + const ephemeralPublicKey = await importPublicKeyHex(envelope.ephemeralPublicKey) + + // Prefer the addressed wrap (no trial decryption of the other recipients' + // keys). If the private key is non-extractable we cannot derive our own key + // id, so fall back to trying each wrap — AES-GCM authenticates, so a wrong + // key fails closed rather than returning garbage. + const { candidates, addressed } = await selectWrapCandidates(envelope, privateKey) + + for (const wrap of candidates) { + const wrappingKey = await deriveWrappingKey( + privateKey, + ephemeralPublicKey, + ephemeralPublicRaw, + wrapInfo(context, wrap.keyId), + ) + let contentKeyBytes: Uint8Array + try { + contentKeyBytes = await aesGcmOpen( + wrappingKey, + { + ciphertext: hexToBytes(wrap.wrappedKey, 'wrappedKey'), + authTag: hexToBytes(wrap.wrapAuthTag, 'wrapAuthTag'), + }, + hexToBytes(wrap.wrapIv, 'wrapIv'), + wrapAad(context, wrap.keyId), + ) + } catch { + // This wrap is addressed to us but did not authenticate: either the + // envelope was altered, or it was sealed for a different request id + // (the id is in the wrap's AAD). Report that, rather than pretending we + // simply are not a recipient. + if (addressed && wrap.keyId === addressed.keyId) { + throw new E2EEError( + 'auth-failed', + 'This emergency payload failed authentication. It was altered in transit, or it belongs to a different request.', + ) + } + continue // wrong key, or a wrap tampered with + } + + const contentKey = await importAesKey(contentKeyBytes, 'decrypt') + let plaintext: Uint8Array + try { + plaintext = await aesGcmOpen( + contentKey, + { + ciphertext: hexToBytes(envelope.ciphertext, 'ciphertext'), + authTag: hexToBytes(envelope.authTag, 'authTag'), + }, + hexToBytes(envelope.iv, 'iv'), + payloadAad(context), + ) + } catch { + throw new E2EEError( + 'auth-failed', + 'Emergency payload failed authentication. It was altered in transit, or it belongs to a different request.', + ) + } + return parsePayload(plaintext) + } + throw new E2EEError( + 'not-a-recipient', + 'This key is not one of the recipients for the sealed emergency payload.', + ) +} + +/** + * Wrap keys to try, plus the one addressed to this key when we can identify + * it. The addressed flag is what lets the caller distinguish "I am a recipient + * but this envelope is not for me / has been tampered with" from "I am simply + * not a recipient". + */ +async function selectWrapCandidates( + envelope: EncryptedEnvelope, + privateKey: CryptoKey, +): Promise<{ candidates: WrappedContentKey[]; addressed: WrappedContentKey | null }> { + try { + const ownKeyId = await deriveKeyIdFromPrivateKey(privateKey) + const addressed = envelope.wrappedKeys.find((wrap) => wrap.keyId === ownKeyId) ?? null + if (addressed) return { candidates: [addressed], addressed } + // We are a valid key holder, just not an authorized recipient: say so + // precisely instead of silently trying (and failing) every other wrap. + return { candidates: [], addressed: null } + } catch { + // Non-extractable private key: we cannot derive our own key id, so fall + // back to trying every wrap. AES-GCM authenticates, so this stays safe. + return { candidates: envelope.wrappedKeys, addressed: null } + } +} + +/** The HKDF `info` string for a wrap. Must match on both sides. */ +function wrapInfo(context: string, keyId: string): string { + return `${E2EE_INFO_PREFIX}|wrap|request=${context}|keyId=${keyId}` +} + +/** Convenience: generate a pair and return the public key as hex. */ +export async function generateEncryptionKeyPairHex(): Promise<{ + publicKey: string + privateKey: CryptoKey +}> { + const { publicKey, privateKey } = await generateEncryptionKeyPair() + return { publicKey: await exportPublicKeyHex(publicKey), privateKey } +} + +/** + * Build the recipient records a client publishes so a requester can seal for + * them. Order is preserved and duplicates by key id are dropped. + * + * Entries that are unusable are **skipped, not thrown on**: a single corrupt + * registry row must not be able to block an emergency submission. Skipping can + * only ever narrow the recipient set, and the requester is always sealed in by + * the caller, so the failure mode is a missing responder rather than a leaked + * payload. Callers that need to know can diff against their input length. + */ +export async function buildResponderKeyRecords( + entries: Array<{ wallet: string; publicKey: string }>, +): Promise { + const seen = new Set() + const records: ResponderEncryptionKey[] = [] + for (const entry of entries) { + const wallet = typeof entry?.wallet === 'string' ? entry.wallet.trim() : '' + const publicKey = typeof entry?.publicKey === 'string' ? entry.publicKey.trim() : '' + if (!wallet || !PUBLIC_KEY_HEX_RE.test(publicKey)) continue + let keyId: string + try { + keyId = await deriveKeyIdFromPublicKey(publicKey) + } catch { + continue + } + if (seen.has(keyId)) continue + seen.add(keyId) + records.push({ + wallet, + publicKey: publicKey.toLowerCase(), + keyId, + registeredAt: Date.now(), + }) + } + if (records.length === 0) { + throw new E2EEError('no-recipients', 'At least one recipient public key is required.') + } + if (records.length > MAX_PAYLOAD_RECIPIENTS) { + throw new E2EEError( + 'too-many-recipients', + `Cannot seal for ${records.length} recipients; the limit is ${MAX_PAYLOAD_RECIPIENTS}.`, + ) + } + return records +} + +/** Total on-chain size of a serialized envelope, in bytes. */ +export function estimateEnvelopeBytes(envelope: EncryptedEnvelope): number { + return new TextEncoder().encode(JSON.stringify(envelope)).length +} diff --git a/src/pages/Help.jsx b/src/pages/Help.jsx index c0e3b045..b372da89 100644 --- a/src/pages/Help.jsx +++ b/src/pages/Help.jsx @@ -44,6 +44,15 @@ import { startAutoSave, clearDraft as clearCrashDraft, } from "../lib/crashRecovery.ts"; +import { + buildResponderKeyRecords, + encryptEmergencyPayload, + exportPrivateKeyJwk, + exportPublicKeyHex, + generateEncryptionKeyPair, + importPrivateKeyJwk, +} from "../lib/crypto"; +import { api } from "../services/api"; const MAPBOX_TOKEN = import.meta.env.VITE_MAPBOX_TOKEN; @@ -192,6 +201,92 @@ function loadProfile() { } } +// ── End-to-end encrypted emergency details ────────────────────────── +// +// The contact number and medical notes are sealed on-device for the requester +// plus every registered responder before anything is submitted. `createRequest` +// refuses a plaintext payload, so there is no code path that can put a phone +// number or a medical note in a ledger entry or in a relay store. +// +// See src/lib/crypto.ts for the envelope format and docs/security-architecture.md +// for the threat model. The responder private key never leaves the browser. + +const RESPONDER_KEY_PREFIX = "hp_responder_e2ee_v1:"; + +/** Load this wallet's responder key pair, minting and publishing one if needed. */ +async function loadOrCreateResponderKey(wallet) { + const storageKey = `${RESPONDER_KEY_PREFIX}${wallet}`; + const stored = sessionStorage.getItem(storageKey); + if (stored) { + try { + const parsed = JSON.parse(stored); + return { + publicKey: parsed.publicKey, + privateKey: await importPrivateKeyJwk(parsed.jwk), + }; + } catch { + sessionStorage.removeItem(storageKey); + } + } + const pair = await generateEncryptionKeyPair(); + const publicKey = await exportPublicKeyHex(pair.publicKey); + const jwk = await exportPrivateKeyJwk(pair.privateKey); + // sessionStorage, not localStorage: the private key is wiped when the tab + // closes. A production build should move this into the encrypted + // SecureStorage (src/lib/secureStorage.ts) to survive restarts. + sessionStorage.setItem(storageKey, JSON.stringify({ jwk, publicKey })); + // Publishing is best-effort: failing to register only costs dispatch offers. + api.registerDispatchKey(wallet, publicKey).catch(() => {}); + return { publicKey, privateKey: pair.privateKey }; +} + +/** + * Build the plaintext payload and the recipient key list for a submission. + * Returns the envelope already sealed against a fresh submission id, plus the + * inputs needed to re-seal against the on-chain request id once it is known. + */ +async function sealRequestPayload({ requester, profile }) { + const payload = { + contact: (profile?.contact || "").trim(), + medicalNotes: (profile?.notes || profile?.medicalNotes || "").trim(), + nickname: (profile?.nickname || "").trim(), + }; + // An entirely empty payload is not worth a transaction; seal a marker so the + // contract's "payload required" rule is satisfied without leaking anything. + if (!payload.contact && !payload.medicalNotes && !payload.nickname) { + payload.medicalNotes = "No details supplied."; + } + + const own = await loadOrCreateResponderKey(requester); + const registry = await api.getDispatchRecipientKeys(); + const registered = registry.success && Array.isArray(registry.keys) ? registry.keys : []; + + // Always include the requester so they can re-read what they submitted. + const recipients = await buildResponderKeyRecords([ + { wallet: requester, publicKey: own.publicKey }, + ...registered.map((k) => ({ wallet: k.wallet, publicKey: k.publicKey })), + ]); + const recipientKeys = recipients.map((r) => r.publicKey); + + // The on-chain request id is assigned by the contract, so it cannot be part + // of the pre-signature binding. Use a client-generated submission id instead + // and record it in the envelope (tamper-evident, not secret). + const submissionId = newSubmissionId(); + const envelope = await encryptEmergencyPayload(payload, recipientKeys, submissionId); + return { envelope, payload, recipientKeys, submissionId }; +} + +/** Unpredictable, collision-resistant id used as the payload's binding context. */ +function newSubmissionId() { + const c = globalThis.crypto; + if (c && typeof c.randomUUID === "function") return c.randomUUID(); + if (c && typeof c.getRandomValues === "function") { + const bytes = c.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + } + return `sub-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + const DEFAULT_CENTER = [20, 0]; const MY_REQUESTS_KEY = "hp_my_requests"; @@ -1668,15 +1763,27 @@ export default function Help() { radiusMeters: 3000, }); const publicLocation = anonymizeLocation(location); + // Seal the sensitive half of the request (contact number, medical notes) + // to this requester plus every registered responder *before* it touches + // the ledger. `createRequest` refuses a plaintext payload, so there is + // no path here that can put a phone number in a ledger entry. + const sealed = await sealRequestPayload({ requester: address, profile }); const { requestId: id, hash } = await createRequest( address, publicLocation[0], publicLocation[1], emergencyType, - "", - "", + sealed.envelope, StellarWalletsKit, ); + // Best-effort relay copy, indexed by the submission id the envelope is + // bound to and by the ledger request id so responders can find it from + // either. The on-chain copy stays authoritative if the relay is down. + const relayCopies = [api.storeDispatchEnvelope(sealed.submissionId, sealed.envelope)]; + if (id !== undefined && id !== null) { + relayCopies.push(api.storeDispatchEnvelope(String(id), sealed.envelope)); + } + await Promise.all(relayCopies.map((p) => p.catch(() => {}))); setRequestId(id); setRequestStatus("Pending"); saveMyRequestId(id); diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index 55e282f7..0572bda0 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -1,15 +1,76 @@ -import React, { useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { Link } from 'react-router-dom' import { useFeatureFlag } from '../lib/featureFlags.js' import { passkeyManager } from '../lib/passkey.js' import { useWallet } from '../contexts/WalletContext.js' import { useLocationSearch } from '../hooks/useLocationSearch.js' +import { + buildResponderKeyRecords, + decryptEmergencyPayload, + encryptEmergencyPayload, + exportPrivateKeyJwk, + exportPublicKeyHex, + generateEncryptionKeyPair, + importPrivateKeyJwk, + MAX_PAYLOAD_PLAINTEXT_BYTES, +} from '../lib/crypto.js' +import { api } from '../services/api.js' + +/** Where a responder's sealed private key is stashed, per Stellar address. */ +const RESPONDER_KEY_PREFIX = 'hp_responder_e2ee_v1:' + +interface ResponderKeyState { + publicKey: string + privateKey: CryptoKey +} + +/** + * Load (or create) this wallet's responder encryption key. + * + * The private half never leaves the browser: it is kept in `sessionStorage` and + * re-imported on reload so a refresh does not mint a new key (which would + * orphan every envelope already sealed for this responder). A production build + * should move this into the encrypted `SecureStorage` (src/lib/secureStorage.ts); + * sessionStorage is the safer default because it is wiped when the tab closes. + */ +async function loadOrCreateResponderKey(wallet: string): Promise { + const storageKey = `${RESPONDER_KEY_PREFIX}${wallet}` + const stored = sessionStorage.getItem(storageKey) + if (stored) { + try { + const parsed = JSON.parse(stored) as { jwk: JsonWebKey; publicKey: string } + return { + publicKey: parsed.publicKey, + privateKey: await importPrivateKeyJwk(parsed.jwk), + } + } catch { + // Corrupt entry — fall through and mint a fresh key. + sessionStorage.removeItem(storageKey) + } + } + const pair = await generateEncryptionKeyPair() + const publicKey = await exportPublicKeyHex(pair.publicKey) + const jwk = await exportPrivateKeyJwk(pair.privateKey) + sessionStorage.setItem(storageKey, JSON.stringify({ jwk, publicKey })) + // Publish the public half so requesters can seal for us. Failure here only + // costs us dispatch offers; it must not block the page. + void api.registerDispatchKey(wallet, publicKey) + return { publicKey, privateKey: pair.privateKey } +} export default function Help() { const passkeyAuthEnabled = useFeatureFlag('passkey_authentication') + const encryptedDispatchEnabled = useFeatureFlag('encrypted_dispatch') const { walletState, connectWallet } = useWallet() const [statusMessage, setStatusMessage] = useState('') const [passkeyVerified, setPasskeyVerified] = useState(false) + const [contact, setContact] = useState('') + const [medicalNotes, setMedicalNotes] = useState('') + const [recipientCount, setRecipientCount] = useState(null) + const [revealed, setRevealed] = useState<{ requestId: string; contact: string; medicalNotes: string } | null>( + null + ) + const [revealError, setRevealError] = useState('') const { location, @@ -47,6 +108,67 @@ export default function Help() { } } + /** + * Seal the contact number and medical notes for the request plus every + * registered responder, then hand the ciphertext to the relay. + * + * Nothing readable is produced here beyond the in-memory plaintext the user + * just typed; the relay only ever sees the envelope. + */ + const handleSealAndDispatch = useCallback(async () => { + if (!walletState.address) { + setRevealError('Connect your Stellar wallet first.') + return + } + if (!contact.trim() && !medicalNotes.trim()) { + setRevealError('Add a contact number or a medical note to seal.') + return + } + setRevealError('') + + try { + const own = await loadOrCreateResponderKey(walletState.address) + const registry = await api.getDispatchRecipientKeys() + const keys = registry.success && registry.keys?.length ? registry.keys : [] + + // Always seal to ourselves too, so the requester can re-read what they + // submitted. `buildResponderKeyRecords` drops duplicate public keys. + const recipients = await buildResponderKeyRecords([ + { wallet: walletState.address, publicKey: own.publicKey }, + ...keys.map((k) => ({ wallet: k.wallet, publicKey: k.publicKey })), + ]) + setRecipientCount(recipients.length) + + // The request id is not known until the on-chain submission lands, so + // this demo seals against a client-side nonce and the real submission + // re-seals with the ledger id (see src/lib/contract.ts#createRequest). + const requestId = `local-${Date.now()}` + const envelope = await encryptEmergencyPayload( + { contact: contact.trim(), medicalNotes: medicalNotes.trim(), nickname: '' }, + recipients.map((r) => r.publicKey), + requestId + ) + const stored = await api.storeDispatchEnvelope(requestId, envelope) + if (!stored.success) { + setRevealError(`Relay refused the envelope: ${stored.error ?? 'unknown error'}`) + return + } + + // Prove the round trip locally: read back exactly what a responder will. + const back = await decryptEmergencyPayload(envelope, own.privateKey, requestId) + setRevealed({ requestId, contact: back.contact, medicalNotes: back.medicalNotes }) + setStatusMessage('🔒 Payload sealed end-to-end. Only your key and the responders’ can open it.') + } catch (err: any) { + setRevealError(err?.message || 'Could not seal the emergency payload.') + } + }, [walletState.address, contact, medicalNotes]) + + /** Count the plaintext budget so the UI can warn before the seal throws. */ + const plaintextBytes = new TextEncoder().encode( + JSON.stringify({ contact, medicalNotes, nickname: '' }) + ).length + const overBudget = plaintextBytes > MAX_PAYLOAD_PLAINTEXT_BYTES + return (
@@ -206,6 +328,124 @@ export default function Help() {
)} + {encryptedDispatchEnabled && ( +
+

🔒 End-to-End Encrypted Details

+

+ Your contact number and medical notes are sealed on this device with + ECDH P-256 + AES-256-GCM. The ledger and our relay only ever store + ciphertext, and the key never leaves your browser. +

+ + + setContact(e.target.value)} + placeholder="+1 555 0100" + style={{ + width: '100%', + padding: '0.75rem', + borderRadius: '0.5rem', + border: '1px solid rgba(255,255,255,0.25)', + background: '#1c2c24', + color: '#ECE0CC', + }} + /> + + +