├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── application_server.ts ├── cmd └── generate-vapid-keys.ts ├── crypto_utils.ts ├── crypto_utils_test.ts ├── deno.json ├── deno.lock ├── deps.ts ├── dev_deps.ts ├── example ├── index.html ├── main.ts ├── sw.js └── vapid.json ├── flake.lock ├── flake.nix ├── mod.ts ├── subscriber.ts ├── subscriber_test.ts ├── test_data └── vapid.json ├── vapid.ts └── vapid_test.ts /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: ['*'] 6 | pull_request: 7 | 8 | permissions: {} 9 | 10 | defaults: 11 | run: 12 | shell: bash 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | id-token: write 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | - uses: cachix/install-nix-action@v21 24 | 25 | - name: Extract Tag 26 | id: extract_tag 27 | uses: actions/github-script@v6 28 | with: 29 | script: | 30 | const prefix = 'refs/tags/v'; 31 | const ref = context.ref; 32 | return ref.startsWith(prefix) ? ref.substring(prefix.length) : ''; 33 | result-encoding: string 34 | 35 | - name: Lint 36 | run: nix develop --command make lint 37 | 38 | - name: Check Format 39 | run: nix develop --command make fmt-check 40 | 41 | - name: Run Tests 42 | run: nix develop --command make test 43 | 44 | - name: Publish to JSR 45 | run: nix develop --command deno publish 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .direnv/ 2 | .envrc 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alexandre Negrel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint 2 | lint: 3 | deno lint 4 | 5 | .PHONY: fmt 6 | fmt: 7 | deno fmt 8 | 9 | .PHONY: fmt-check 10 | fmt-check: 11 | deno fmt --check 12 | 13 | .PHONY: test 14 | test: lint fmt 15 | deno test 16 | 17 | .PHONY: tag 18 | tag/%: test 19 | jq '.version = "$(@F)"' deno.json | sponge deno.json 20 | git commit -m "tag version $(@F)" .; \ 21 | git tag $(@F) 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `webpush` - Web Push library for Deno and other web compatible runtime. 2 | 3 | ![jsr badge](https://jsr.io/badges/@negrel/webpush) 4 | ![license MIT badge](https://img.shields.io/github/license/negrel/webpush) 5 | ![code size badge](https://img.shields.io/github/languages/code-size/negrel/webpush) 6 | 7 | `webpush` is a Web Push library 8 | ([RFC 8291](https://www.rfc-editor.org/rfc/rfc8291) and 9 | [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)) based on Web APIs. 10 | 11 | > NOTE: This library hasn't been reviewed by crypto experts and may be unsecure. 12 | > I've done my best to follow RFC recommandation and I only used primitives 13 | > provided by the SubtleCrypto API. 14 | 15 | `webpush` is available on [JSR](https://jsr.io/@negrel/webpush). 16 | 17 | ## Getting started 18 | 19 | Before sending Web Push message to a user agent, you need to create VAPID keys 20 | (see [RFC 8292](https://www.rfc-editor.org/rfc/rfc8292)) to identify your server 21 | (`Application`) to the `Push Service`: 22 | 23 | ``` 24 | +-------+ +--------------+ +-------------+ 25 | | UA | | Push Service | | Application | 26 | +-------+ +--------------+ +-------------+ 27 | | | | 28 | | Setup | | 29 | |<====================>| | 30 | | Provide Subscription | 31 | |-------------------------------------------->| 32 | | | | 33 | : : : 34 | | | Push Message | 35 | | Push Message |<---------------------| 36 | |<---------------------| | 37 | | | | 38 | ``` 39 | 40 | Run 41 | [`generate-vapid-keys`](https://github.com/negrel/webpush/blob/master/cmd/generate-vapid-keys.ts) 42 | script part of this repository to generate new keys: 43 | 44 | ```sh 45 | # You can use any Web compatible runtime. 46 | $ deno run https://raw.githubusercontent.com/negrel/webpush/master/cmd/generate-vapid-keys.ts 47 | ``` 48 | 49 | Copy the output of the command and save it in `example/vapid.json`. Now you can 50 | run example server. 51 | 52 | ``` 53 | $ cd example/ 54 | $ deno run -A ./main.ts 55 | ``` 56 | 57 | Code is commented be sure to read it. 58 | 59 | I also wrote an 60 | [ok-ish blog post about Web Push and this library](https://www.negrel.dev/blog/deno-web-push-notifications/). 61 | 62 | ## Dependencies 63 | 64 | This library tries its best at keeping the minimum number of dependencies. It 65 | has no external dependencies except some runtime agnostic 66 | [`@std/`](https://jsr.io/@std/) packages maintained by Deno team and 67 | [`http-ece`](https://github.com/negrel/http-ece), which I maintain. 68 | 69 | [`http-ece`](https://github.com/negrel/http-ece) also only depends on 70 | [`@std/`](https://jsr.io/@std/) packages. 71 | 72 | ## Contributing 73 | 74 | If you want to contribute to `webpush` to add a feature or improve the code 75 | contact me at [alexandre@negrel.dev](mailto:alexandre@negrel.dev), open an 76 | [issue](https://github.com/negrel/webpush/issues) or make a 77 | [pull request](https://github.com/negrel/webpush/pulls). 78 | 79 | ## :stars: Show your support 80 | 81 | Please give a :star: if this project helped you! 82 | 83 | [![buy me a coffee](https://github.com/negrel/.github/blob/master/.github/images/bmc-button.png?raw=true)](https://www.buymeacoffee.com/negrel) 84 | 85 | ## :scroll: License 86 | 87 | MIT © [Alexandre Negrel](https://www.negrel.dev/) 88 | -------------------------------------------------------------------------------- /application_server.ts: -------------------------------------------------------------------------------- 1 | import { PushSubscriber, PushSubscription } from "./subscriber.ts"; 2 | 3 | /** 4 | * ApplicationServer define a Push application server as specified in RFC 8291. 5 | * 6 | * See https://www.rfc-editor.org/rfc/rfc8291#section-1 7 | */ 8 | export class ApplicationServer { 9 | public readonly crypto: SubtleCrypto; 10 | 11 | // Application Server (AS) keys. 12 | public readonly keys: CryptoKeyPair; 13 | 14 | // AS public key in raw format. 15 | private publicKeyRaw: Uint8Array | null = null; 16 | 17 | // VAPID AS contact information. 18 | public readonly contactInformation: string; 19 | public readonly vapidKeys: CryptoKeyPair; 20 | private publicVapidKeyRaw: Uint8Array | null = null; 21 | 22 | constructor( 23 | { 24 | crypto = globalThis.crypto.subtle, 25 | keys, 26 | contactInformation, 27 | vapidKeys, 28 | }: { 29 | crypto?: SubtleCrypto; 30 | keys: CryptoKeyPair; 31 | contactInformation: string; 32 | vapidKeys: CryptoKeyPair; 33 | }, 34 | ) { 35 | this.crypto = crypto; 36 | this.contactInformation = contactInformation; 37 | this.keys = keys; 38 | this.vapidKeys = vapidKeys; 39 | } 40 | 41 | static async new( 42 | { crypto = globalThis.crypto.subtle, contactInformation, vapidKeys }: { 43 | crypto?: SubtleCrypto; 44 | contactInformation: string; 45 | vapidKeys: CryptoKeyPair; 46 | }, 47 | ): Promise { 48 | const keys = await crypto.generateKey( 49 | { 50 | name: "ECDH", 51 | namedCurve: "P-256", 52 | }, 53 | false, 54 | ["deriveKey"], 55 | ); 56 | 57 | return new ApplicationServer({ 58 | crypto, 59 | keys, 60 | contactInformation, 61 | vapidKeys, 62 | }); 63 | } 64 | 65 | async getPublicKeyRaw(): Promise { 66 | if (this.publicKeyRaw === null) { 67 | this.publicKeyRaw = new Uint8Array( 68 | await this.crypto.exportKey( 69 | "raw", 70 | this.keys.publicKey, 71 | ), 72 | ); 73 | } 74 | 75 | return this.publicKeyRaw; 76 | } 77 | 78 | async getVapidPublicKeyRaw(): Promise { 79 | if (this.publicVapidKeyRaw === null) { 80 | this.publicVapidKeyRaw = new Uint8Array( 81 | await this.crypto.exportKey( 82 | "raw", 83 | this.vapidKeys.publicKey, 84 | ), 85 | ); 86 | } 87 | 88 | return this.publicVapidKeyRaw; 89 | } 90 | 91 | subscribe(sub: PushSubscription): PushSubscriber { 92 | return new PushSubscriber(this, sub); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /cmd/generate-vapid-keys.ts: -------------------------------------------------------------------------------- 1 | import { exportVapidKeys, generateVapidKeys } from "../vapid.ts"; 2 | 3 | const keys = await generateVapidKeys({ extractable: true }); 4 | 5 | const vapidJwks = await exportVapidKeys(keys); 6 | console.log(JSON.stringify(vapidJwks, undefined, " ")); 7 | -------------------------------------------------------------------------------- /crypto_utils.ts: -------------------------------------------------------------------------------- 1 | import { bytes, encodeBase64Url } from "./deps.ts"; 2 | 3 | export async function hmacSha256Digest( 4 | crypto: SubtleCrypto, 5 | secret: CryptoKey | ArrayBuffer, 6 | body: BufferSource, 7 | length?: number, 8 | ): Promise { 9 | if (secret instanceof ArrayBuffer) { 10 | secret = await crypto.importKey( 11 | "raw", 12 | secret, 13 | { 14 | name: "HMAC", 15 | hash: "SHA-256", 16 | }, 17 | false, 18 | ["sign"], 19 | ); 20 | } 21 | 22 | const result = await crypto.sign("HMAC", secret, body); 23 | 24 | if (length !== null) return result.slice(0, length); 25 | 26 | return result; 27 | } 28 | 29 | export function hkdfSha256Extract( 30 | crypto: SubtleCrypto, 31 | salt: ArrayBuffer, 32 | ikm: ArrayBuffer, 33 | ) { 34 | if (salt.byteLength === 0) { 35 | salt = new Uint8Array(256).fill(0).buffer.slice(0); 36 | } 37 | 38 | return hmacSha256Digest(crypto, salt, ikm); 39 | } 40 | 41 | export async function hkdfSha256Expand( 42 | crypto: SubtleCrypto, 43 | prk: ArrayBuffer, 44 | info: ArrayBuffer, 45 | length: number, 46 | ) { 47 | const infoBytes = new Uint8Array(info); 48 | 49 | let t = new Uint8Array(0); 50 | let okm = new Uint8Array(0); 51 | let i = 0; 52 | while (okm.byteLength < length) { 53 | i++; 54 | t = new Uint8Array( 55 | await hmacSha256Digest(crypto, prk, bytes.concat([t, infoBytes])), 56 | ); 57 | okm = bytes.concat([okm, t]); 58 | } 59 | 60 | return okm.slice(0, length); 61 | } 62 | 63 | export async function ecdh( 64 | crypto: SubtleCrypto, 65 | { privateKey, publicKey }: CryptoKeyPair, 66 | ) { 67 | const ecdhKey = await crypto.deriveKey( 68 | { name: "ECDH", public: publicKey }, 69 | privateKey, 70 | { 71 | name: "AES-GCM", 72 | length: 256, 73 | }, 74 | true, 75 | ["encrypt", "decrypt"], 76 | ); 77 | 78 | return crypto.exportKey("raw", ecdhKey); 79 | } 80 | 81 | const encoder = new TextEncoder(); 82 | 83 | export async function forgeJwt( 84 | key: CryptoKey, 85 | // deno-lint-ignore no-explicit-any 86 | payload: Record, 87 | { crypto = globalThis.crypto.subtle }: { crypto?: SubtleCrypto } = {}, 88 | ): Promise { 89 | const jwt = [{ typ: "JWT", alg: "ES256" }, payload].map((p) => 90 | encodeBase64Url(JSON.stringify(p)) 91 | ).join("."); 92 | 93 | const digest = await crypto.sign( 94 | { "name": "ECDSA", "hash": "SHA-256" }, 95 | key, 96 | encoder.encode(jwt), 97 | ); 98 | 99 | const signature = encodeBase64Url(digest); 100 | 101 | return jwt + "." + signature; 102 | } 103 | -------------------------------------------------------------------------------- /crypto_utils_test.ts: -------------------------------------------------------------------------------- 1 | import { importVapidKeys } from "./vapid.ts"; 2 | import { forgeJwt } from "./crypto_utils.ts"; 3 | import { jose } from "./dev_deps.ts"; 4 | 5 | import vapidJwks from "./test_data/vapid.json" with { type: "json" }; 6 | 7 | Deno.test("forgeJwt", async () => { 8 | const vapidKeys = await importVapidKeys(vapidJwks); 9 | 10 | const jwt = await forgeJwt(vapidKeys.privateKey, { 11 | sub: "mailto:john@example.com", 12 | aud: "example.com", 13 | exp: 2716428940, 14 | }); 15 | 16 | await jose.jwtVerify(jwt, vapidKeys.publicKey); 17 | }); 18 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@negrel/webpush", 3 | "version": "0.4.0", 4 | "exports": "./mod.ts", 5 | "compilerOptions": { 6 | "allowJs": false, 7 | "strict": true 8 | }, 9 | "lint": { 10 | "rules": { 11 | "tags": [ 12 | "recommended" 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "jsr:@negrel/http-ece@0.7.0": "jsr:@negrel/http-ece@0.7.0", 6 | "jsr:@std/assert@1": "jsr:@std/assert@1.0.13", 7 | "jsr:@std/bytes@1": "jsr:@std/bytes@1.0.6", 8 | "jsr:@std/encoding@1": "jsr:@std/encoding@1.0.10", 9 | "jsr:@std/internal@^1.0.6": "jsr:@std/internal@1.0.7" 10 | }, 11 | "jsr": { 12 | "@negrel/http-ece@0.7.0": { 13 | "integrity": "068e61ba6f52da5454051a283cc19621607f8dd6e74d55c01bb365b8c6644076", 14 | "dependencies": [ 15 | "jsr:@std/encoding@1" 16 | ] 17 | }, 18 | "@std/assert@1.0.13": { 19 | "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", 20 | "dependencies": [ 21 | "jsr:@std/internal@^1.0.6" 22 | ] 23 | }, 24 | "@std/bytes@1.0.6": { 25 | "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 26 | }, 27 | "@std/encoding@1.0.10": { 28 | "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 29 | }, 30 | "@std/internal@1.0.7": { 31 | "integrity": "39eeb5265190a7bc5d5591c9ff019490bd1f2c3907c044a11b0d545796158a0f" 32 | } 33 | } 34 | }, 35 | "remote": { 36 | "https://deno.land/x/jose@v5.3.0/index.ts": "1c955404289ff08dc03d31fc8ce3186ffeaae9ac0fdcf3f128f047ce770bf559", 37 | "https://deno.land/x/jose@v5.3.0/jwe/compact/decrypt.ts": "2d32f0b41e1cc4f428b6dcae1c8931fae9756d7ece45d9e870c558ed1ab27bc8", 38 | "https://deno.land/x/jose@v5.3.0/jwe/compact/encrypt.ts": "d147705bc7e2675cfb546f78676ec91f432c93ff28022f7125a71d72454bec90", 39 | "https://deno.land/x/jose@v5.3.0/jwe/flattened/decrypt.ts": "3393ab6561e532c8a3d472c5174c7b7c630cc36f06560cc71d6b7a0b69371825", 40 | "https://deno.land/x/jose@v5.3.0/jwe/flattened/encrypt.ts": "de3572efbc65a79cd8ef21d85adc55fa71bd98bf204b44fd8974f3d3dfe773cc", 41 | "https://deno.land/x/jose@v5.3.0/jwe/general/decrypt.ts": "f9c702cbc7d380b8f314bd001f5ac339b1505754c0d22348135fe05ff98306bf", 42 | "https://deno.land/x/jose@v5.3.0/jwe/general/encrypt.ts": "760901c766461a4256dbcc7691354a1a66519c82c87615c01c2e49660207c040", 43 | "https://deno.land/x/jose@v5.3.0/jwk/embedded.ts": "eaa14282f8e0cf757ed3d26a2d278e1bc15a178954e86fc601719be8f32abd74", 44 | "https://deno.land/x/jose@v5.3.0/jwk/thumbprint.ts": "dddb12cfccb323b1d49e56ac8250bcaa3740d632376dc58ebb16491e3e574e3d", 45 | "https://deno.land/x/jose@v5.3.0/jwks/local.ts": "d521cfdbe9b667b9ba2491be46f659556a2c0f695c6ee51455c577836e74ff99", 46 | "https://deno.land/x/jose@v5.3.0/jwks/remote.ts": "767bdc6d276cbbd2adcfdb4029e87a0abe834bf2e79ebb205615d611a008e85a", 47 | "https://deno.land/x/jose@v5.3.0/jws/compact/sign.ts": "1e88d19dc26d3ea5f2ce3da989813daf5c555d8dec6528051530f7b5a3e87bae", 48 | "https://deno.land/x/jose@v5.3.0/jws/compact/verify.ts": "ec53206a9b8732117a93b928acb134a13abdd7803dd09835b597867678107426", 49 | "https://deno.land/x/jose@v5.3.0/jws/flattened/sign.ts": "0ccf19562026e0678b0e62c0788b375b443b597986cd0c84f1929cdb2bd1048d", 50 | "https://deno.land/x/jose@v5.3.0/jws/flattened/verify.ts": "cdd8ede7d302cd0411983305fa5186f76119b6dd50a85fcfac19a0e1a9ec1796", 51 | "https://deno.land/x/jose@v5.3.0/jws/general/sign.ts": "b40b40ce59f12dc58d30a553cb865821d8c2487513c144f608163e847d5a5375", 52 | "https://deno.land/x/jose@v5.3.0/jws/general/verify.ts": "46043b2c4e2829e88d889a434f4dd3cab4d9865b324abc3b02a61cb738e59d74", 53 | "https://deno.land/x/jose@v5.3.0/jwt/decrypt.ts": "2d541db2380760634bf20e8dfacabbf97aa5f80eb77735400c9e43cfe397234c", 54 | "https://deno.land/x/jose@v5.3.0/jwt/encrypt.ts": "b9a6de7c7ac4797d2f97f47481538752e0623e40c99cc98056cc2ae957129875", 55 | "https://deno.land/x/jose@v5.3.0/jwt/produce.ts": "c1f66aedfa626e81d34ab44330d580cbfe4588f57a6ff7669b986d909fae02aa", 56 | "https://deno.land/x/jose@v5.3.0/jwt/sign.ts": "54d0a761056edeec428a956abd8db034a20536d87fc7b7a2d4fbf953e931aa37", 57 | "https://deno.land/x/jose@v5.3.0/jwt/unsecured.ts": "6d930c64c05477b1e9c95219f766816698b9a099213270f6d8059af414767aca", 58 | "https://deno.land/x/jose@v5.3.0/jwt/verify.ts": "905cb1f2ca01157a9f38e5dfd3495306e348b9ca84e70c359d4731afcec19a64", 59 | "https://deno.land/x/jose@v5.3.0/key/export.ts": "5addf06a10ce16bb24e630a8f0e5473eff2cbc1ac549ba29fd8ecf0610778edc", 60 | "https://deno.land/x/jose@v5.3.0/key/generate_key_pair.ts": "6ef50b33427eab5c895189774b562e61c7195609d5701a8e5b0357181a8062e4", 61 | "https://deno.land/x/jose@v5.3.0/key/generate_secret.ts": "3f87f5df76b8d05613e134660297285bece39a40b10640b7cf66417cfcaee32f", 62 | "https://deno.land/x/jose@v5.3.0/key/import.ts": "697efbc4621f1707784bbba6e204145c6756536bdad13851226faff6f40a155c", 63 | "https://deno.land/x/jose@v5.3.0/lib/aesgcmkw.ts": "6d307f6710b2879fdde07c8e39688ece9019f02170334aa216db4878fc7a6ac8", 64 | "https://deno.land/x/jose@v5.3.0/lib/buffer_utils.ts": "0b64941bf694bc810b2c917b61474be46c54854da76bb42b20f1642102542ff1", 65 | "https://deno.land/x/jose@v5.3.0/lib/cek.ts": "a474becfe1c2d86fbcf3a24cdcd96274beb8d61bd17926e5f345001f39df922b", 66 | "https://deno.land/x/jose@v5.3.0/lib/check_iv_length.ts": "118eb531167126c8421d71a21f2cfdc10a658c933e178b33395ef3e962c54f80", 67 | "https://deno.land/x/jose@v5.3.0/lib/check_key_type.ts": "81f5a2c3764464d2b917a1c55c6011d312184e8c68e43bee51565c2db1b166c4", 68 | "https://deno.land/x/jose@v5.3.0/lib/check_p2s.ts": "2f5549e121c43019ac85a3bb3fe8cb98a397122dcaa80f3cd8bf5fcf314e1f67", 69 | "https://deno.land/x/jose@v5.3.0/lib/crypto_key.ts": "b75ba81390a90ea741cf174117d96e7851b221ebd9b0dfb806061175c24aed4f", 70 | "https://deno.land/x/jose@v5.3.0/lib/decrypt_key_management.ts": "9551fc03b4952c7771e76aec46b568f28c34950811d86eb13893eb565946ddf6", 71 | "https://deno.land/x/jose@v5.3.0/lib/encrypt_key_management.ts": "ec08f264e7dde341171d6272e9db639ae3718cc7047b3117735ab960d9bd01d4", 72 | "https://deno.land/x/jose@v5.3.0/lib/epoch.ts": "cd608f73f6c100e8156c6020ec2bce6757e01759793f0d6aab23908d3d2ea933", 73 | "https://deno.land/x/jose@v5.3.0/lib/format_pem.ts": "b5230682e7a89609238015b77f33afd248f3e0f69bcb5895eece2f86d83100f6", 74 | "https://deno.land/x/jose@v5.3.0/lib/invalid_key_input.ts": "55cebdd0eaf3f2bc3c9a2a814bef94e7fc72ed58faafaad8d6c2e4b759139427", 75 | "https://deno.land/x/jose@v5.3.0/lib/is_disjoint.ts": "b5ea1cb260899f5cfb04f032029956e637067714a275d13be429fc53287a4b17", 76 | "https://deno.land/x/jose@v5.3.0/lib/is_object.ts": "43549ddc51a3b3d4c22b953b191a961fbb61fb5081e8efb88ad075afa1f4d214", 77 | "https://deno.land/x/jose@v5.3.0/lib/iv.ts": "4766d9ad87b478bb7344094f38c8561181b72f8678edd1b5226d1e0f9ab677fc", 78 | "https://deno.land/x/jose@v5.3.0/lib/jwt_claims_set.ts": "927514104e4572604a7a00b6c32cdff5cee19423483d9a495a97195136aa4f96", 79 | "https://deno.land/x/jose@v5.3.0/lib/secs.ts": "40e09916007b3a393f20265ea84dc1df43974e86ec796ce0e5e6724f5917a2b8", 80 | "https://deno.land/x/jose@v5.3.0/lib/validate_algorithms.ts": "6b20f4b5f6935cd9edcd6eb2128226144ba792eaa7c47966c666a51baf1682eb", 81 | "https://deno.land/x/jose@v5.3.0/lib/validate_crit.ts": "b1214ae1413c969efdca2392c6feea9c3b92c531fc7e137c23397e0430421b86", 82 | "https://deno.land/x/jose@v5.3.0/runtime/aeskw.ts": "a9bd412c6d07b2606520972671a545cdb205f79f0a2e9ec0fb145fb2147319e1", 83 | "https://deno.land/x/jose@v5.3.0/runtime/asn1.ts": "013a633b824c18663c8c7f7ec8691bfe3dfef82ee58650de0c6d8ee26154bb6a", 84 | "https://deno.land/x/jose@v5.3.0/runtime/base64url.ts": "74ecb18b90de56bcc4424b6c911cfe49b56dd4a316978f5c8ee9a23560069636", 85 | "https://deno.land/x/jose@v5.3.0/runtime/bogus.ts": "4f1c967b0d9b4e7105e16ad8c173da55708f377ee5e201d8ee5fc613f3417f08", 86 | "https://deno.land/x/jose@v5.3.0/runtime/check_cek_length.ts": "2ee093fcb273602449ed7863ab6bd4dd2a7aeff99507e0573e3cd4b8d8099e5d", 87 | "https://deno.land/x/jose@v5.3.0/runtime/check_key_length.ts": "5656870cc4460602775134280bf46f17e27100b237bdb5c687aceabeffc8f2a2", 88 | "https://deno.land/x/jose@v5.3.0/runtime/decrypt.ts": "c53c80b90892b45f39d58e75a46f53032b131f2d9f5b6b74ddd215c79ca3ed8b", 89 | "https://deno.land/x/jose@v5.3.0/runtime/digest.ts": "cee73fad56ce596ffedc56811d174ab413e7981eb847eb67c0e77f213cc2ac2d", 90 | "https://deno.land/x/jose@v5.3.0/runtime/ecdhes.ts": "74a6f43dde87736179d814312b010087bea0c834f51adc9a93f79664f78cb75f", 91 | "https://deno.land/x/jose@v5.3.0/runtime/encrypt.ts": "a5a89b21010d3dda51544020fe4b2ab0d5d91e10044490fccc1af3d5104e8547", 92 | "https://deno.land/x/jose@v5.3.0/runtime/fetch_jwks.ts": "34b71aa6bbd51984d1009792499ea17133dcb11bf2f2d8fba60e8090d900fc20", 93 | "https://deno.land/x/jose@v5.3.0/runtime/generate.ts": "12473775d22140aa6ec9697a5b83cfb63581831f435b005577613b0ecaf33774", 94 | "https://deno.land/x/jose@v5.3.0/runtime/get_sign_verify_key.ts": "f08a086524491a54fb2a0e203819d4ca2b193444aecf6dfbaf6d400431cb439c", 95 | "https://deno.land/x/jose@v5.3.0/runtime/is_key_like.ts": "d9660a40820d254843721a53b51eb94a4db39a1ee36103ea8c28e2cc2207599b", 96 | "https://deno.land/x/jose@v5.3.0/runtime/jwk_to_key.ts": "ec0ce6cbbe2e91ee666d7c1cb8e42751dc56a79af3d8183b715874d24f489b4d", 97 | "https://deno.land/x/jose@v5.3.0/runtime/key_to_jwk.ts": "2af6f52abbe59316fa96771da48538c8f718b6fdd8703d185f8a87ef44c0f064", 98 | "https://deno.land/x/jose@v5.3.0/runtime/pbes2kw.ts": "ad084eb8dd144df8fd5d7e0b824806e35475398be0d3871c2016c3c467e24b19", 99 | "https://deno.land/x/jose@v5.3.0/runtime/random.ts": "3e9c8d08208e5dc186ae659535f0018303ff3b56615335bf0dfb5904fe36aab7", 100 | "https://deno.land/x/jose@v5.3.0/runtime/rsaes.ts": "124b6e87d8569b39d6f550b371303c79e9eb28aa0d9b14025eeaffe50282c987", 101 | "https://deno.land/x/jose@v5.3.0/runtime/runtime.ts": "d748ecb0c1b57020d6f6c41b4458cce74b4d15ab919c38a0ec32d12cee7f5976", 102 | "https://deno.land/x/jose@v5.3.0/runtime/sign.ts": "1524f8855538ca5fd607cd681f804ba421b0ec58f562118f3c635324ba053f90", 103 | "https://deno.land/x/jose@v5.3.0/runtime/subtle_dsa.ts": "bab28945afd1aac19d3142a70f0c44c4b283b581d33cf8e1234889b58338e80d", 104 | "https://deno.land/x/jose@v5.3.0/runtime/subtle_rsaes.ts": "26147da83932ebf7266d69ddd408f269395de05ddde64ba4e1585571bb61bd93", 105 | "https://deno.land/x/jose@v5.3.0/runtime/timing_safe_equal.ts": "fc5b3f4132cec56630eac4677fef2b519707a9c6a257f8ae86515b0d86ff5f6b", 106 | "https://deno.land/x/jose@v5.3.0/runtime/verify.ts": "d391e2286b47485247b475016c66999f5146a1cf8331115385a9ba629251c15e", 107 | "https://deno.land/x/jose@v5.3.0/runtime/webcrypto.ts": "3365b7d62eaa7e6befe5e2f4f67aa7859805c41b87064433b6e70f94501aa36e", 108 | "https://deno.land/x/jose@v5.3.0/util/base64url.ts": "d2567042684de8bf1e4f1ad67be40ab343ee192ddf1cf12ff1d940d6c1fa2bcb", 109 | "https://deno.land/x/jose@v5.3.0/util/decode_jwt.ts": "a7ea5eaf0a2fb99252eda082e9d25bc47181962fb42806b28a12723197bc7814", 110 | "https://deno.land/x/jose@v5.3.0/util/decode_protected_header.ts": "546eac15dd97d267373cb3d8d9a94ad03481f1328c4457560ffde9a5a595ebe8", 111 | "https://deno.land/x/jose@v5.3.0/util/errors.ts": "ca227b0dfbc2464bd0940969d2fbffcb80803d25b1dba6385dfe486e2aa0a089", 112 | "https://deno.land/x/jose@v5.3.0/util/runtime.ts": "088cae7df0d285065f141d880c5c21a572234997082337d1125d7f1603059642" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | decodeBase64Url, 3 | encodeBase64Url, 4 | } from "jsr:@std/encoding@1/base64url"; 5 | 6 | export * as bytes from "jsr:@std/bytes@1"; 7 | 8 | export * as ece from "jsr:@negrel/http-ece@0.7.0"; 9 | -------------------------------------------------------------------------------- /dev_deps.ts: -------------------------------------------------------------------------------- 1 | export * from "jsr:@std/assert@1"; 2 | export * as jose from "https://deno.land/x/jose@v5.3.0/index.ts"; 3 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | WebPush example 7 | 95 | 96 | 97 | 98 |

WebPush example

99 |

Open DevTools to see logs in console.

100 |

First we must create a subscription.

101 | 102 |

Then we must send it to application server.

103 | 104 | 105 | 106 | 107 | -------------------------------------------------------------------------------- /example/main.ts: -------------------------------------------------------------------------------- 1 | // import * as webpush from "jsr:@negrel/webpush"; 2 | import * as webpush from "../mod.ts"; 3 | 4 | // Used by HTTP server. 5 | import { encodeBase64Url } from "jsr:@std/encoding@0.224.0/base64url"; 6 | 7 | // Put your email here so Push Service admin from Firefox / Google can contact 8 | // you if there is a problem with your application server. 9 | const adminEmail = "john@example.com"; 10 | 11 | // Read vapid.json file and import keys. 12 | const exportedVapidKeys = JSON.parse(await Deno.readTextFile("./vapid.json")); 13 | const vapidKeys = await webpush.importVapidKeys(exportedVapidKeys, { 14 | extractable: false, 15 | }); 16 | 17 | // Create an application server object. 18 | const appServer = await webpush.ApplicationServer.new({ 19 | contactInformation: "mailto:" + adminEmail, 20 | vapidKeys, 21 | }); 22 | 23 | // Start an http server. 24 | Deno.serve(async (req: Request) => { 25 | const url = new URL(req.url); 26 | switch (url.pathname) { 27 | case "/": // HTML page. 28 | return new Response(await Deno.readTextFile("./index.html"), { 29 | headers: { "Content-Type": "text/html" }, 30 | }); 31 | 32 | case "/sw.js": // Service Worker. 33 | return new Response(await Deno.readTextFile("./sw.js"), { 34 | headers: { "Content-Type": "application/javascript" }, 35 | }); 36 | 37 | case "/api/vapid": { 38 | // We send public key only! 39 | const publicKey = encodeBase64Url( 40 | await crypto.subtle.exportKey( 41 | "raw", 42 | vapidKeys.publicKey, 43 | ), 44 | ); 45 | 46 | return new Response(JSON.stringify(publicKey), { 47 | headers: { 48 | "Content-Type": "application/json", 49 | }, 50 | }); 51 | } 52 | 53 | case "/api/subscribe": { 54 | // Retrieve subscription. 55 | const subscription = await req.json(); 56 | // You can store it in a DB to reuse it later. 57 | // ... 58 | 59 | // Create a subscriber object. 60 | const subscriber = appServer.subscribe(subscription); 61 | 62 | // Send notification. 63 | await subscriber.pushTextMessage( 64 | JSON.stringify({ title: "Hello from application server!" }), 65 | {}, 66 | ); 67 | 68 | // OK. 69 | return new Response(); 70 | } 71 | 72 | default: 73 | return new Response(null, { status: 404 }); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /example/sw.js: -------------------------------------------------------------------------------- 1 | // Listen for push messages and show a notification. 2 | self.addEventListener("push", (e) => { 3 | const data = e.data.json(); 4 | // See https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerRegistration/showNotification 5 | self.registration.showNotification(data.title, data); 6 | }); 7 | -------------------------------------------------------------------------------- /example/vapid.json: -------------------------------------------------------------------------------- 1 | { 2 | "publicKey": { 3 | "kty": "EC", 4 | "crv": "P-256", 5 | "alg": "ES256", 6 | "x": "qtHXcngWNZEynU5XY2y3920dQJvYWuMp7UBNwD-PI8I", 7 | "y": "Jz_9VRFbxOKS-oOEaICaEdEApyXuJqP0IZPZlqDDSDw", 8 | "key_ops": [ 9 | "verify" 10 | ], 11 | "ext": true 12 | }, 13 | "privateKey": { 14 | "kty": "EC", 15 | "crv": "P-256", 16 | "alg": "ES256", 17 | "x": "qtHXcngWNZEynU5XY2y3920dQJvYWuMp7UBNwD-PI8I", 18 | "y": "Jz_9VRFbxOKS-oOEaICaEdEApyXuJqP0IZPZlqDDSDw", 19 | "d": "J65AfTxOLCONdxzwyZjGtKojEHymWODUy9GvWJb7g4E", 20 | "key_ops": [ 21 | "sign" 22 | ], 23 | "ext": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-utils": { 4 | "inputs": { 5 | "systems": "systems" 6 | }, 7 | "locked": { 8 | "lastModified": 1710146030, 9 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 10 | "owner": "numtide", 11 | "repo": "flake-utils", 12 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "numtide", 17 | "repo": "flake-utils", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1714906307, 24 | "narHash": "sha256-UlRZtrCnhPFSJlDQE7M0eyhgvuuHBTe1eJ9N9AQlJQ0=", 25 | "owner": "nixos", 26 | "repo": "nixpkgs", 27 | "rev": "25865a40d14b3f9cf19f19b924e2ab4069b09588", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "nixos", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "root": { 38 | "inputs": { 39 | "flake-utils": "flake-utils", 40 | "nixpkgs": "nixpkgs" 41 | } 42 | }, 43 | "systems": { 44 | "locked": { 45 | "lastModified": 1681028828, 46 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 47 | "owner": "nix-systems", 48 | "repo": "default", 49 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 50 | "type": "github" 51 | }, 52 | "original": { 53 | "owner": "nix-systems", 54 | "repo": "default", 55 | "type": "github" 56 | } 57 | } 58 | }, 59 | "root": "root", 60 | "version": 7 61 | } 62 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | flake-utils.url = "github:numtide/flake-utils"; 4 | nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; 5 | }; 6 | 7 | outputs = { flake-utils, nixpkgs, ... }: 8 | flake-utils.lib.eachDefaultSystem (system: 9 | let 10 | pkgs = import nixpkgs { inherit system; }; 11 | lib = pkgs.lib; 12 | in 13 | { 14 | devShells = { 15 | default = pkgs.mkShell { 16 | buildInputs = with pkgs; [ deno ]; 17 | }; 18 | }; 19 | } 20 | ); 21 | } 22 | 23 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./vapid.ts"; 2 | export * from "./subscriber.ts"; 3 | export * from "./application_server.ts"; 4 | -------------------------------------------------------------------------------- /subscriber.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationServer } from "./application_server.ts"; 2 | import { 3 | ecdh, 4 | forgeJwt, 5 | hkdfSha256Expand, 6 | hkdfSha256Extract, 7 | } from "./crypto_utils.ts"; 8 | import { bytes, decodeBase64Url, ece, encodeBase64Url } from "./deps.ts"; 9 | 10 | const encoder = new TextEncoder(); 11 | 12 | /** 13 | * PushSubscriber define a Web Push subscriber. 14 | */ 15 | export class PushSubscriber { 16 | public readonly as: ApplicationServer; 17 | public readonly subscription: PushSubscription; 18 | 19 | private uaPublicKey: CryptoKey | null = null; 20 | private ecdhSecret: ArrayBuffer | null = null; 21 | 22 | constructor(as: ApplicationServer, subscription: PushSubscription) { 23 | this.as = as; 24 | this.subscription = subscription; 25 | } 26 | 27 | private uaPublicKeyRaw(): Uint8Array { 28 | return decodeBase64Url(this.subscription.keys.p256dh); 29 | } 30 | 31 | authSecret(): ArrayBuffer { 32 | return decodeBase64Url(this.subscription.keys.auth).slice(0).buffer; 33 | } 34 | 35 | async getUaPublicKey(): Promise { 36 | if (this.uaPublicKey === null) { 37 | this.uaPublicKey = await this.as.crypto.importKey( 38 | "raw", 39 | this.uaPublicKeyRaw(), 40 | { 41 | "name": "ECDH", 42 | namedCurve: "P-256", 43 | }, 44 | true, 45 | [], 46 | ); 47 | } 48 | 49 | return this.uaPublicKey; 50 | } 51 | 52 | async getEcdhSecret(): Promise { 53 | if (this.ecdhSecret === null) { 54 | this.ecdhSecret = await ecdh(this.as.crypto, { 55 | privateKey: this.as.keys.privateKey, 56 | publicKey: await this.getUaPublicKey(), 57 | }); 58 | } 59 | 60 | return this.ecdhSecret; 61 | } 62 | 63 | async getKeyInfo(): Promise { 64 | return bytes.concat([ 65 | encoder.encode("WebPush: info\0"), 66 | this.uaPublicKeyRaw(), 67 | await this.as.getPublicKeyRaw(), 68 | ]); 69 | } 70 | 71 | getSubscriptionOrigin(): string { 72 | return new URL(this.subscription.endpoint).origin; 73 | } 74 | 75 | forgeVapidToken(): Promise { 76 | return forgeJwt(this.as.vapidKeys.privateKey, { 77 | sub: this.as.contactInformation, 78 | aud: this.getSubscriptionOrigin(), 79 | exp: (Math.round(Date.now() / 1000) + 3600), // Expire in 1 hour. 80 | }); 81 | } 82 | 83 | async pushMessage( 84 | message: ArrayBuffer, 85 | { urgency = Urgency.Normal, ttl = 2419200, topic }: PushMessageOptions, 86 | ): Promise { 87 | const prkKey = await hkdfSha256Extract( 88 | this.as.crypto, 89 | this.authSecret(), 90 | await this.getEcdhSecret(), 91 | ); 92 | 93 | const ikm = await hkdfSha256Expand( 94 | this.as.crypto, 95 | prkKey, 96 | bytes.concat([await this.getKeyInfo(), ece.ONE_BYTE]), 97 | 32, 98 | ); 99 | 100 | const record = await ece.encrypt(message, ikm, { 101 | header: { 102 | keyid: await this.as.getPublicKeyRaw(), 103 | }, 104 | }); 105 | 106 | const headers: HeadersInit = { 107 | "Content-Encoding": "aes128gcm", 108 | "Content-Type": "application/octet-stream", 109 | "Urgency": urgency, 110 | TTL: ttl.toFixed(0), 111 | Authorization: `vapid t=${await this.forgeVapidToken()}, k=${ 112 | encodeBase64Url( 113 | await this.as.getVapidPublicKeyRaw(), 114 | ) 115 | }`, 116 | }; 117 | if (topic !== undefined) { 118 | headers["Topic"] = topic; 119 | } 120 | 121 | const response = await fetch(this.subscription.endpoint, { 122 | method: "POST", 123 | headers, 124 | body: record, 125 | }); 126 | 127 | if (!response.ok) { 128 | throw new PushMessageError(response); 129 | } 130 | } 131 | 132 | pushTextMessage(message: string, options: PushMessageOptions): Promise { 133 | return this.pushMessage(encoder.encode(message), options); 134 | } 135 | } 136 | 137 | /** 138 | * PushMessageError define error returned by push service when push fails. 139 | */ 140 | export class PushMessageError extends Error { 141 | public readonly response: Response; 142 | 143 | constructor(response: Response) { 144 | super(); 145 | this.response = response; 146 | } 147 | 148 | /** 149 | * isGone returns true if subscription doesn't exist anymore. 150 | */ 151 | isGone(): boolean { 152 | return this.response.status === 410; 153 | } 154 | 155 | toString(): string { 156 | return `pushing message failed: ${this.response.status} ${this.response.statusText}`; 157 | } 158 | } 159 | 160 | /** 161 | * PushSubscription interface contains subscription's information send by user 162 | * agent after subscribing. 163 | * 164 | * See https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription 165 | */ 166 | export interface PushSubscription { 167 | endpoint: string; 168 | keys: { 169 | auth: string; 170 | p256dh: string; 171 | }; 172 | } 173 | 174 | /** 175 | * Urgency define urgency level of a push message. 176 | * To attempt to deliver the notification immediately, specify High. 177 | */ 178 | export enum Urgency { 179 | VeryLow = "very-low", 180 | Low = "low", 181 | Normal = "normal", 182 | High = "high", 183 | } 184 | 185 | /** 186 | * Push message options. 187 | */ 188 | export interface PushMessageOptions { 189 | urgency?: Urgency; 190 | ttl?: number; 191 | topic?: string; 192 | } 193 | -------------------------------------------------------------------------------- /subscriber_test.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/negrel/webpush/2fe0a3756374268dd35dcf2250ea01981abcf23c/subscriber_test.ts -------------------------------------------------------------------------------- /test_data/vapid.json: -------------------------------------------------------------------------------- 1 | { 2 | "publicKey": { 3 | "kty": "EC", 4 | "crv": "P-256", 5 | "alg": "ES256", 6 | "x": "ZHHYTWl6KDRQXoIztkVIpmgfCu1_KiF5_b13L0fwsjc", 7 | "y": "HxJjYllRcXTueSy0JfslwZLXgs9IBj2pyYeJ98ah0lw", 8 | "key_ops": [ 9 | "verify" 10 | ], 11 | "ext": true 12 | }, 13 | "privateKey": { 14 | "kty": "EC", 15 | "crv": "P-256", 16 | "alg": "ES256", 17 | "x": "ZHHYTWl6KDRQXoIztkVIpmgfCu1_KiF5_b13L0fwsjc", 18 | "y": "HxJjYllRcXTueSy0JfslwZLXgs9IBj2pyYeJ98ah0lw", 19 | "d": "IIoggteXc-hIT8Y1nx8Uym9b9KNSFILqh8E6NV4Ufhw", 20 | "key_ops": [ 21 | "sign" 22 | ], 23 | "ext": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /vapid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Output of exportVapidKeys() that can be stored serialized in JSON and stored. 3 | */ 4 | export interface ExportedVapidKeys { 5 | publicKey: JsonWebKey; 6 | privateKey: JsonWebKey; 7 | } 8 | 9 | const vapidKeysAlgo = { 10 | name: "ECDSA", 11 | namedCurve: "P-256", 12 | }; 13 | 14 | /** 15 | * Generates a new pair of VAPID keys. 16 | * See https://www.rfc-editor.org/rfc/rfc8292. 17 | */ 18 | export async function generateVapidKeys( 19 | { extractable = false, crypto = globalThis.crypto.subtle }: { 20 | extractable?: boolean; 21 | crypto?: SubtleCrypto; 22 | } = {}, 23 | ): Promise { 24 | return await crypto.generateKey( 25 | vapidKeysAlgo, 26 | extractable, 27 | ["sign", "verify"], 28 | ); 29 | } 30 | 31 | /** 32 | * Import VAPID keys previously exported using exportVapidKeys() (JWK format). 33 | */ 34 | export async function importVapidKeys( 35 | exportedKeys: ExportedVapidKeys, 36 | { crypto = globalThis.crypto.subtle, extractable = false }: { 37 | crypto?: SubtleCrypto; 38 | extractable?: boolean; 39 | } = {}, 40 | ): Promise { 41 | return { 42 | publicKey: await crypto.importKey( 43 | "jwk", 44 | exportedKeys.publicKey, 45 | vapidKeysAlgo, 46 | true, 47 | ["verify"], 48 | ), 49 | privateKey: await crypto.importKey( 50 | "jwk", 51 | exportedKeys.privateKey, 52 | vapidKeysAlgo, 53 | extractable, 54 | ["sign"], 55 | ), 56 | }; 57 | } 58 | 59 | /** 60 | * Export VAPID keys in JWK format. 61 | */ 62 | export async function exportVapidKeys( 63 | keys: CryptoKeyPair, 64 | { crypto = globalThis.crypto.subtle }: { crypto?: SubtleCrypto } = {}, 65 | ): Promise { 66 | return { 67 | publicKey: await crypto.exportKey("jwk", keys.publicKey), 68 | privateKey: await crypto.exportKey("jwk", keys.privateKey), 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /vapid_test.ts: -------------------------------------------------------------------------------- 1 | import { generateVapidKeys, importVapidKeys } from "./vapid.ts"; 2 | import { assert, assertEquals } from "./dev_deps.ts"; 3 | import { exportVapidKeys } from "./vapid.ts"; 4 | 5 | function assertValidVapidKeyPair(keys: CryptoKeyPair, extractable: boolean) { 6 | assert(keys.privateKey.extractable === extractable); 7 | assertEquals(keys.privateKey.algorithm as unknown, { 8 | name: "ECDSA", 9 | namedCurve: "P-256", 10 | }); 11 | assertEquals(keys.privateKey.usages, ["sign"]); 12 | assertEquals(keys.privateKey.type, "private"); 13 | 14 | assert(keys.publicKey.extractable); // Always extractable. 15 | assertEquals(keys.publicKey.algorithm as unknown, { 16 | name: "ECDSA", 17 | namedCurve: "P-256", 18 | }); 19 | assertEquals(keys.publicKey.usages, ["verify"]); 20 | assertEquals(keys.publicKey.type, "public"); 21 | } 22 | 23 | Deno.test("generateVapidKeys/notExtractable", async () => { 24 | const keys = await generateVapidKeys(); 25 | assertValidVapidKeyPair(keys, false); 26 | }); 27 | 28 | Deno.test("generateVapidKeys/extractable", async () => { 29 | const keys = await generateVapidKeys({ extractable: true }); 30 | assertValidVapidKeyPair(keys, true); 31 | }); 32 | 33 | Deno.test("vapidKeys/import/export", async () => { 34 | const keys = await generateVapidKeys({ extractable: true }); 35 | const exportedKeys = await exportVapidKeys(keys); 36 | const importedKeys = await importVapidKeys(exportedKeys, { 37 | extractable: true, 38 | }); 39 | assertValidVapidKeyPair(importedKeys, true); 40 | assertEquals(keys, importedKeys); 41 | }); 42 | --------------------------------------------------------------------------------