From 3da82a8a81e1fcbb16bd44261b130e3f6d000db0 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 20 Jun 2025 02:34:57 +0200 Subject: [PATCH 1/3] wip: per-device keys proposal with ECDH and ECDSA --- .../secure-custom-domain-sessions.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/dev/proposals/secure-custom-domain-sessions.md diff --git a/docs/dev/proposals/secure-custom-domain-sessions.md b/docs/dev/proposals/secure-custom-domain-sessions.md new file mode 100644 index 000000000..8a2d7444d --- /dev/null +++ b/docs/dev/proposals/secure-custom-domain-sessions.md @@ -0,0 +1,56 @@ +## Problem +Synchronizing a user session between stacker.news and a user-owned custom domain, means giving up the whole JWT, which can be stolen and replayed. + +For example, in a scenario like DNS hijacking, the malicious user that owns the custom domain can point their DNS record to a server of their own, **log** the incoming requests, **collect** the JWTs and **replay** them. + +Usually, mitigations are used for this kind of problem: +- fingerprinting +- dns polling +- short-lived sessions, with refresh tokens +- aggressive checks on custom domains + +I think that some mitigations are correct, such as **dns polling** to take measures against the malicious owner; others might be too much. + +## Per-device keys +**The case for per-device ECDH Key Pairs** + +As a form of authentication, we can use ECDH shared secrets to either +- sign requests for the GraphQL endpoint +- encrypt JWT payloads +To accomplish this, each device will have its own ECDH key pair, generated and stored in IndexedDB on first visit. +It can also be used in the future for encrypted messaging + +An example flow can be: +``` + +1. Device generates key pair +2. On login, server creates an ECDH key pair +3. Client and server exchanges public keys +4. Server and device derives the same shared secret with each other +-- Stores: + user, server_priv_key, client_pub_key, shared_secret_hash +5. Server issues an encrypted JWT using the shared secret +6. Device decrypts JWT with the same shared secret +``` + +Shared secret rotation is part of best practices in this context. + +**The case for ECDSA** + +ECDSA, much like the ECDH route above, can be used to generate a key pair on the first visit and share its public key with the server, to bind it to the user we're importing from `stacker.news`. + +ECDSA can be used to sign every request or JWTs, the server will then verify the signature and accept the request if the signature is valid. + +An example flow can be: +``` +1. Device generates key pair +2. Public key is sent to server and bound to a user +3. Server issues a JWT that includes the public key fingerprint +4. On each requests, client signs a message with private key +5. Server verifies the JWT and the signature, proving possession +``` + +If the JWT gets stolen, it's useless without the device private key. + +###### Bonus +A bonus point of using key pairs, is the capability of tracking devices connected to an account, enabling session revocation. \ No newline at end of file From a0c0c4ce33c96d9b157c8a9c7d9778e5adc1a6e6 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Fri, 20 Jun 2025 02:46:29 +0200 Subject: [PATCH 2/3] md: fix spacing on lists --- docs/dev/proposals/secure-custom-domain-sessions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev/proposals/secure-custom-domain-sessions.md b/docs/dev/proposals/secure-custom-domain-sessions.md index 8a2d7444d..53998babe 100644 --- a/docs/dev/proposals/secure-custom-domain-sessions.md +++ b/docs/dev/proposals/secure-custom-domain-sessions.md @@ -17,12 +17,12 @@ I think that some mitigations are correct, such as **dns polling** to take measu As a form of authentication, we can use ECDH shared secrets to either - sign requests for the GraphQL endpoint - encrypt JWT payloads + To accomplish this, each device will have its own ECDH key pair, generated and stored in IndexedDB on first visit. -It can also be used in the future for encrypted messaging +It can also be used in the future for encrypted messaging. An example flow can be: ``` - 1. Device generates key pair 2. On login, server creates an ECDH key pair 3. Client and server exchanges public keys From c682c48b697c9b8ce12018c20bd342c710f20a17 Mon Sep 17 00:00:00 2001 From: Soxasora Date: Sun, 22 Jun 2025 00:03:34 +0200 Subject: [PATCH 3/3] High level ECDH flow explanation --- .../secure-custom-domain-sessions.md | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/docs/dev/proposals/secure-custom-domain-sessions.md b/docs/dev/proposals/secure-custom-domain-sessions.md index 53998babe..e040e946b 100644 --- a/docs/dev/proposals/secure-custom-domain-sessions.md +++ b/docs/dev/proposals/secure-custom-domain-sessions.md @@ -22,17 +22,84 @@ To accomplish this, each device will have its own ECDH key pair, generated and s It can also be used in the future for encrypted messaging. An example flow can be: +
+Flow with OAuth +Step 1: Device generates a key pair on first visit + ``` -1. Device generates key pair -2. On login, server creates an ECDH key pair -3. Client and server exchanges public keys -4. Server and device derives the same shared secret with each other --- Stores: - user, server_priv_key, client_pub_key, shared_secret_hash -5. Server issues an encrypted JWT using the shared secret -6. Device decrypts JWT with the same shared secret +{ privateKey, publicKey } = await crypto.subtle.generateKey(ECDH P-256, non-extractable) + +{ set, get } = useIndexedDB(idbConfig) +set(privateKey) +set(publicKey) +``` + +##### Step 2: Initiate login using OAuth + +``` +state = getRandomValues -> store in sessionStorage +code_verifier = getRandomValues -> store in sessionStorage +code_challenge = sha256(code_verifier) + +GET https://stacker.news/api/auth/sync/authorize + ?state= + &code_challenge= + &redirect_uri=https://www.pizza.com/api/auth/sync/callback + ?callbackUrl=/items/960002 +``` + +The `sync/authorize` endpoint checks the session on stacker.news and creates a **verification token** that is bound to the code_challenge. +We'll send this token along with state to `sync/callback` + +``` +302 https://www.pizza.com/api/auth/sync/callback + ?callbackUrl=/items/960002 + &token= + &state= +``` + +Here we check if the received `state` matches the state we saved in the client's `sessionStorage`. If it does, we'll POST the `sync/complete` endpoint with the device ECDH public key, to exchange the token for a JWT and the server public key. + +The `code_verifier` is how PKCE will confirm that we're exchanging with the user that initiated. + +##### Step 3: POST stacker.news to exchange the session cookie + +``` +device_pubkey = IndexedDB +code_verifier = sessionStorage + +POST https://stacker.news/api/auth/sync/complete +body { + token, + code_verifier, + device_pubkey +} + +RESPONSE { + session_token, + server_pubkey +} ``` +The server pubkey is saved in IndexedDB, and both client and server derive the shared secret using ECDH. +This shared secret is then used to create HMAC signatures for requests. + +##### Step 4: Sign a request +For example, on GraphQL requests, the client: + +``` +req_payload = payload + timestamp +signature = hmac(shared_secret, req_payload) +Headers: { + 'X-Timestamp': timestamp, + 'X-Signature': signature, + 'Authorization': etc. +} +``` + +The server verifies that the timestamp isn't too old and that the HMAC signature and session token are valid. +
+ Shared secret rotation is part of best practices in this context. **The case for ECDSA**