-
-
Notifications
You must be signed in to change notification settings - Fork 131
proposal: secure sessions via per-device key pairs #2243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
## 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: | ||
<details> | ||
<summary>Flow with OAuth</summary> | ||
Step 1: Device generates a key pair on first visit | ||
|
||
``` | ||
{ 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=<state> | ||
&code_challenge=<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=<verificationToken> | ||
&state=<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. | ||
</details> | ||
|
||
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. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How would signing JWTs help when they get stolen? |
||
|
||
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 | ||
Comment on lines
+116
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You need to include the timestamp in the request payload and sign it, else the attacker can replay signed requests. The server also needs to verify that the timestamp is not too old. This means that client and server clock must be in sync though, but I think we can live with that. |
||
``` | ||
|
||
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How does encrypting the JWTs help with authentication?
You mentioned in the example flow below that the server encrypts the JWT and the device decrypts it.
But the device still sends the JWT to the server on each request (encrypted or decrypted, doesn't really matter). So the attacker can still steal and use them, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mmh, you're right I got off track on the ECDH route. I was thinking that the shared secret should be used to create an HMAC to sign each request, the server will then verify the HMAC and check its timestamp.