Skip to content

feat: use jwks to fetch public keys #113

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

Draft
wants to merge 38 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
cf79d82
feat: init jwks with mock json
littlemight Feb 13, 2025
046b527
feat: fetch using axios
littlemight Feb 13, 2025
efe6d5d
feat: fallback to prev keys if jwks fail
littlemight Feb 13, 2025
44d4ba9
feat: prepare boilerplate for cache logic
littlemight Feb 13, 2025
46757a6
feat: barebones string cache
littlemight Feb 14, 2025
12716e6
test: make sure DIY cache works
littlemight Feb 14, 2025
0afa54a
chore: readd comment back
littlemight Feb 14, 2025
efdabba
refactor: modules to use async pub key getters
littlemight Feb 14, 2025
cec90c8
chore: meaningful kid
littlemight Feb 14, 2025
c0585d7
refactor: group util tests in same folder
littlemight Feb 14, 2025
f4ca1ca
test: jwks util
littlemight Feb 14, 2025
36707f4
test: init spec failing
littlemight Feb 14, 2025
1735e43
feat: fetch jwks during init
littlemight Feb 14, 2025
8dd5e6a
test: init SDK with JWKS
littlemight Feb 14, 2025
86556b8
test: webhook spec
littlemight Feb 14, 2025
04dc557
test: crypto spec
littlemight Feb 14, 2025
ba43a2a
fix: quirk when making public key getter optional
littlemight Feb 15, 2025
868f52f
test: verification spec
littlemight Feb 15, 2025
9939655
chore: cleanup resource
littlemight Feb 15, 2025
18340eb
chore: alpha versioning
littlemight Feb 15, 2025
372338c
feat: rotate thru keys
littlemight Feb 15, 2025
67ee3c6
chore: major ver bump
littlemight Feb 21, 2025
b4e465c
chore: ver on package-lovk
littlemight Feb 21, 2025
58cac69
fix: naming and spec
littlemight Feb 21, 2025
37eefe1
feat: exponential backoff and fix tests
littlemight Feb 26, 2025
91c1719
feat: use axios-retry instead + integration test
littlemight Feb 26, 2025
f7188ea
feat: allow overriding backoffs
littlemight Feb 26, 2025
a3e8665
chore: add examples folder for easier SDK sandbox
littlemight Mar 4, 2025
c8a15c0
chore: node keypair generator
littlemight Mar 4, 2025
2fae696
docs: init migration.md
littlemight Mar 4, 2025
1ad412e
docs: redo MIGRATION.md
littlemight Mar 7, 2025
4aac9aa
feat: pass optional keyId on headers, allow custom static pubkey on init
littlemight Mar 8, 2025
cb67814
refactor: examples folder
littlemight Mar 8, 2025
9b26722
test: key fallbacks
littlemight Mar 8, 2025
16809ef
docs: comment regarding compile and runtime validation
littlemight Mar 8, 2025
080bd8b
docs: b64 vs b64url diff
littlemight Mar 9, 2025
848cee4
fix(test): trust me bro
littlemight Mar 11, 2025
705251e
chore(ci): don't be greedy
littlemight Mar 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ jobs:
${{ runner.OS }}-
- run: npm ci
- run: npm run test-ci
env:
NODE_OPTIONS: '--max-old-space-size=8192'
- name: Submit test coverage to Coveralls
uses: coverallsapp/github-action@v1.1.2
with:
Expand Down
138 changes: 138 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Migration Guide for FormSG SDK from 0.x.x to 1.0.0

## Major Changes Regarding Public Keys
Prior to version 1.0.0, **all signing keys are hardcoded in the SDK**.
Making the SDK convenient to use, but with poor security posture in case of a key compromise.

With hardcoded keys, key rotation involves changing the hardcoded keys, publish a new patch version, and tell all clients using our SDK to urgently update to that new version.

**Anyway, why does rotating keys matter?**
1. **Limiting exposure after compromise**: If a private key is compromised, having a rotation process ensures the exposure window is limited to the rotation period rather than indefinitely.
2. **Defense against undetected breaches**: Even if a key compromise goes undetected, regular rotation ensures the compromised key eventually becomes invalid.

### Open Source and International Consideration
Given the open source nature of FormSG. Moving away from hardcoded keys to better key management significantly improves FormSG's ecosystem as an open source project.
1. **Separation of security concerns**: With JWKS, sensitive key material is no longer embedded in the open source codebase, allowing anyone to use, review, and contribute to the SDK without access to production keys.
2. **Environment flexibility**: Open source contributors can point the SDK to their own JWKS endpoints for development and testing, making contributions easier without depending on official FormSG infrastructure.

We already have fully working forks of FormSG, if interested parties want to further explore FormSG's capabilities, they will likely need this SDK down the line.

### Fetching keys from JWKS endpoint
Blabla
```json
{
"keys": [
{
"kty": "OKP",
"kid": "signing-webhook-key-staging-v1",
"use": "sig",
"alg": "EdDSA",
"crv": "Ed25519",
"x": "<public key in base64url>"
},
{
"kty": "OKP",
"kid": "signing-otp-key-staging-v1",
"use": "verify",
"alg": "EdDSA",
"crv": "Ed25519",
"x": "<public key in base64url>"
}
]
}
```


#### Why JWKS?
JWKS (JSON Web Key Set) provides a standardized way to distribute cryptographic keys used for signature verification. It offers several advantages:

1. Keys can be rotated without requiring SDK updates
2. All public keys are hosted in one discoverable location
3. Follows well-established security standards (RFC 7517)
5. Allows multiple key versions to exist simultaneously during rotation periods

#### Why does the key need to be in base64url format?
The key is encoded in base64url format as per the JWKS specification. This encoding ensures the key material can be safely transported in URLs and JSON documents without special character escaping issues. Base64url is a URL-safe variant of base64 that replaces '+' with '-', '/' with '_', and omits padding characters ('=').

So a base64 key such as
```
Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE=
```

In base64url it would be
```
Tl5gfszlKcQj99_0uafLwVpT6JAu4C0dHGvLq1cHzFE
```

Notice the difference
```
```diff
- Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE=
+ Tl5gfszlKcQj99_0uafLwVpT6JAu4C0dHGvLq1cHzFE
```

#### What happens during key rotation?
1. A new key pair is generated and the new public key is added to the JWKS endpoint with a new kid (key ID)
2. Both the old and new keys remain available in the JWKS for a transition period
3. The SDK fetches the latest keys from the JWKS endpoint automatically
4. When verifying signatures, SDK tries keys matching the kid in the signature header
5. This allows for a seamless transition as systems gradually start using the new key
6. After the transition period, the old key may be removed from the JWKS

#### How do I generate a new pair of keys?
The keys are ED25519 keys, which, in theory, can be generated in any way you want. Be it via a script using `openssl`, or any cryptographic library you're comfortable with.

For convenience, we have provided a `generateKey.ts` using `nacl.sign.keypair()` to generate the keys. You can just run it and just copy paste the public key in base64url format.

See examples/generate/README.md for more details.

#### Lightweight Caching
The JWKS response are cached in-memory, this is useful for long running applications.

When a key rotation happens, the cache will have old keys, since we're passing the `kid` in the signature header, SDK will try refetching JWKS to get the fresh keys, ensuring no failed signature verification due to stale cache.

### Using custom keys injected at SDK initialisation
If you don't have a JWKS endpoint set up, you can inject your custom keys when initialising the SDK instance.
```typescript
import { FormSgSdk } from '@opengovsg/formsg-sdk'

const formsg = new FormSgSdk({
mode: 'production',
...
})
todo...
```

#### What happens during a key rotation?
When using custom keys, the SDK instance does not automatically update when keys are rotated. You'll need to manually re-initialise the SDK instance with the fresh set of keys. This requires code changes to update the keys and restart any services using the SDK to pick up the new keys.

### Hardcoded FormSG keys

### Key Resolution Strategy
The SDK follows a hierarchical approach to resolving keys:

1. **In-memory Cache**: First checks for cached keys to minimize network requests
2. **JWKS Endpoint**: If cache misses or verification fails, fetches fresh keys from the JWKS endpoint (when configured)
3. **Custom Injected Keys**: Falls back to keys provided during SDK initialization (if available)
4. **Hardcoded Keys**: As a final fallback, uses built-in keys (these will be deprecated in future versions)

This strategy ensures maximum reliability while transitioning to the new key management system. Note that hardcoded keys will be gradually phased out once version 1.0.0 is fully adopted.

## Method Changes

| 0.x.x | 1.0.0 | Notes |
|-------|-------|-------|
| `some.method.before (sync)` | `some.method.after (async)` | The method is now part of the webhook verifier class |

## Example Migrations
```typescript
// 0.x.x
const { FormSgSdk } = require('@opengovsg/formsg-sdk')
const formsg = FormSgSdk()
todo...

// 1.0.0
const { FormSgSdk } = require('@opengovsg/formsg-sdk')
const formsg = new FormSgSdk()
todo...
```
14 changes: 14 additions & 0 deletions examples/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Your form's secret key downloaded from FormSG
FORM_SECRET_KEY=YOUR_FORM_SECRET_KEY_HERE

# Set to true if your form has file attachments
HAS_ATTACHMENTS=false

# FormSG environment
FORMSG_ENV=staging

# Server port
PORT=3000

# Optional: JWKS URL if needed
# JWKS_URL=https://your-jwks-server/.well-known/jwks.json
2 changes: 2 additions & 0 deletions examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.env
node_modules/
66 changes: 66 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# FormSG Webhook Demo Server

A simple Express server that demonstrates how to use the FormSG JavaScript SDK to receive and process form submissions via webhooks.

## Setup

1. Copy `.env.example` to `.env`:
```bash
cp .env.example .env
```

2. Edit `.env` and add your FormSG form secret key

3. Install dependencies:
```bash
npm install
```

4. Start the server:
```bash
npx nodemon webhook-server.ts
```

Or use the npm script:
```bash
npm start
```

## Exposing to the internet with ngrok

To receive webhooks from FormSG, your server needs to be accessible from the internet. You can use ngrok for this:

1. Install ngrok if you haven't already. You can do so via brew/any other means, here's how to using npm
```bash
npm install -g ngrok
```

2. Start ngrok in a new terminal:
```bash
ngrok http 3000
```

Or use the npm script:
```bash
npm run start:ngrok
```

3. Copy the HTTPS URL provided by ngrok (example: `https://a1b2c3d4.ngrok.io`)

4. Configure your FormSG form's webhook to point to this URL + `/submissions` (e.g., `https://a1b2c3d4.ngrok.io/submissions`)

## How it works

This example server:

1. Authenticates incoming webhook requests using the FormSG signature
2. Decrypts the form submission using your form secret key
3. Handles form submissions with or without file attachments
4. Logs the decrypted submission data

## Environment Variables

- `FORM_SECRET_KEY`: Your form's secret key from FormSG (required)
- `HAS_ATTACHMENTS`: Set to 'true' if your form contains file upload fields
- `FORMSG_ENV`: 'production' or 'staging' depending on which FormSG environment you're using
- `PORT`: The port to run the server on (default: 3000)
43 changes: 43 additions & 0 deletions examples/generate-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as nacl from 'tweetnacl'
import crypto from 'crypto'

// Generate Ed25519 keypair using NaCl
const generateKeysAndJWKS = () => {
const generateUuidKid = () => {
return crypto.randomUUID()
}

const keypair = nacl.sign.keyPair()

// generate timestamp to epoch
const timestamp = Math.floor(Date.now() / 1000)

// Convert keys to Base64 for storage/display
const publicKeyBase64 = Buffer.from(keypair.publicKey).toString('base64')
const privateKeyBase64 = Buffer.from(keypair.secretKey).toString('base64')

// Convert public key to base64url format (required for JWKS format)
const publicKeyBase64Url = publicKeyBase64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')

return {
privateKey: privateKeyBase64,
publicKey: publicKeyBase64,
publicKeyBase64Url: publicKeyBase64Url,
kid: `${timestamp}-${generateUuidKid()}`,
}
}

const result = generateKeysAndJWKS()
console.log('Generated Ed25519 keypair:')
console.log('-------------------------')
console.log('Private key (base64):')
console.log(result.privateKey)
console.log('\nPublic key (base64):')
console.log(result.publicKey)
console.log('\nPublic key (base64url, what to put in JWKS):')
console.log(result.publicKeyBase64Url)
console.log('\nExample Key ID (UUID format):')
console.log(result.kid)
Loading
Loading