├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── deps.ts ├── keygen.ts ├── mod.ts ├── spec.md └── test.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request, release] 4 | 5 | jobs: 6 | test: 7 | name: test bwt on ${{ matrix.os }} 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, windows-latest, macos-latest] 12 | steps: 13 | - name: clone repo 14 | uses: actions/checkout@v2.0.0 15 | - name: install deno 16 | uses: denolib/setup-deno@v1.2.0 17 | with: 18 | deno-version: v0.35.0 19 | - name: run tests 20 | run: deno test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2020 Noah Anabiik Schwarz 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bwt 2 | 3 | ![ci](https://github.com/chiefbiiko/bwt/workflows/ci/badge.svg) 4 | 5 | **B**etter **W**eb **T**oken 6 | 7 | ... a web token format, generation, and verification scheme 8 | 9 | _Powered by Curve25519, ChaCha20 derivatives, and Poly1305_ 10 | 11 | :warning: **Not yet formally reviewed** :construction: 12 | 13 | ## Features 14 | 15 | - tokens are [encrypted and authenticated](https://en.wikipedia.org/wiki/Authenticated_encryption) using [`XChaCha20-Poly1305`](https://tools.ietf.org/html/draft-irtf-cfrg-xchacha-01) 16 | 17 | - stoopid simple - no [crypto agility](https://en.wikipedia.org/wiki/Crypto_agility) available to module users 18 | 19 | - secure by design, secure by default 20 | 21 | ## What a BWT Looks Like 22 | 23 | `QldUAAAAAXOzcH1DAAABc7NwfuYuk46BldvzkOVc5e_iBOS7fT5VI8SfXlYsACKM_wUcwgOsTId7Df1D.QcVaDwjUCk0jT0hznutUxlo.uTuS46By3obgzi7Ec05ELw` 24 | 25 | ## Usage 26 | 27 | Below is an Alice and Bob example. Note that in the real world Alice and Bob are 28 | typically an auth and a resource endpoint respectively. 29 | 30 | ```ts 31 | import * as bwt from "https://denopkg.com/chiefbiiko/bwt@v0.6.0/mod.ts"; 32 | 33 | const alice = { ...bwt.generateKeyPair() }; 34 | const bob = { ...bwt.generateKeyPair() }; 35 | 36 | alice.stringify = bwt.createStringify(alice.secretKey, { 37 | kid: bob.kid, 38 | publicKey: bob.publicKey 39 | }); 40 | 41 | bob.parse = bwt.createParse(bob.secretKey, { 42 | kid: alice.kid, 43 | publicKey: alice.publicKey 44 | }); 45 | 46 | const iat = Date.now(); 47 | const exp = iat + 1000; 48 | 49 | const token = alice.stringify( 50 | { typ: bwt.Typ.BWTv0, kid: alice.kid, iat, exp }, 51 | { info: "jwt sucks" } 52 | ); 53 | 54 | console.log("alice seals and gets this token to bob:", token); 55 | 56 | const contents = bob.parse(token); 57 | 58 | console.log("bob opens it...:", JSON.stringify(contents)); 59 | ``` 60 | 61 | ## API 62 | 63 | ### Basics 64 | 65 | Besides a few constants and interfaces, the module's main exports are two factory functions, `createStringify -> stringify` and `createParse -> parse`. 66 | 67 | As `BWT` uses asymmetric keys the module also exports a key generation function: `generateKeyPair`. More on [key management](#managing-keys). 68 | 69 | Find basic interfaces and constants below. 70 | 71 | ```ts 72 | /** Supported BWT versions. */ 73 | export const SUPPORTED_VERSIONS: Set = new Set([0]); 74 | 75 | /** Maximum allowed number of characters of a token. */ 76 | export const MAX_TOKEN_CHARS: number = 4096; 77 | 78 | /** Byte length of a Curve25519 secret key. */ 79 | export const SECRET_KEY_BYTES: number = 32; 80 | 81 | /** Byte length of a Curve25519 public key. */ 82 | export const PUBLIC_KEY_BYTES: number = 32; 83 | 84 | /** Byte length of a BWT kid. */ 85 | export const KID_BYTES: number = 16; 86 | 87 | /** Typ enum indicating a BWT version @ the Header.typ field. */ 88 | export const enum Typ { 89 | BWTv0 90 | } 91 | 92 | /** 93 | * BWT header object. 94 | * 95 | * typ must be a supported BWT version, currently that is Typ.BWTv0 only. 96 | * iat and exp denote the issued-at and expiry ms timestamps of a token. 97 | * kid is the public key identifier of the issuing peer. 98 | */ 99 | export interface Header { 100 | typ: Typ; 101 | iat: number; 102 | exp: number; 103 | kid: Uint8Array; 104 | } 105 | 106 | /** BWT body object. */ 107 | export interface Body { 108 | [key: string]: unknown; 109 | } 110 | 111 | /** BWT contents. */ 112 | export interface Contents { 113 | header: Header; 114 | body: Body; 115 | } 116 | 117 | /** BWT stringify function. */ 118 | export interface Stringify { 119 | (header: Header, body: Body): null | string; 120 | } 121 | 122 | /** BWT parse function. */ 123 | export interface Parse { 124 | (token: string): null | Contents; 125 | } 126 | 127 | /** 128 | * BWT keypair object including a key identifier for the public key. 129 | * 130 | * secretKey is the 32-byte secret key. 131 | * publicKey is the 32-byte public key. 132 | * kid is a 16-byte key identifier for the public key. 133 | */ 134 | export interface KeyPair { 135 | secretKey: Uint8Array; 136 | publicKey: Uint8Array; 137 | kid: Uint8Array; 138 | } 139 | 140 | /** 141 | * BWT public key of a peer. 142 | * 143 | * publicKey is the 32-byte public key. 144 | * kid is a 16-byte key identifer for the public key. 145 | * name can be an arbitrarily encoded string. 146 | */ 147 | export interface PeerPublicKey { 148 | publicKey: Uint8Array; 149 | kid: Uint8Array; 150 | name?: string; 151 | } 152 | ``` 153 | 154 | ### Core Callables 155 | 156 | #### `generateKeyPair(): KeyPair` 157 | 158 | Generates a new keypair. 159 | 160 | #### `createStringify(ownSecretKey: Uint8Array, peerPublicKey: PeerPublicKey): Stringify` 161 | 162 | Creates a stringify function. 163 | 164 | `ownSecretKey` is the secret key of the issuing peer's key pair. 165 | 166 | `peerPublicKey` must be the peer public key object of the peer that the to-be-generated tokens are meant for. 167 | 168 | #### `createParse(ownSecretKey: Uint8Array, ...peerPublicKeys: PeerPublicKey[]): Parse` 169 | 170 | Creates a parse function. 171 | 172 | `ownSecretKey` is the secret key of the keypair of the peer that is going to parse and verify tokens. 173 | 174 | `peerPublicKeys` must be a non-empty list of peer public key objects to be used for verification of incoming tokens. 175 | 176 | #### `stringify(header: Header, body: Body): null | string` 177 | 178 | Stringifies a token. 179 | 180 | `header` must contain four props: 181 | 182 | - `typ` set to one of the `Typ` enum variants, currently that is `Typ.BWTv0` only 183 | 184 | - `iat` a millisecond timestamp indicating the current time 185 | 186 | - `exp` a millisecond timestamp indicating the expiry of the token, must be greater than `iat` 187 | 188 | - `kid` a binary of 16 bytes, the public key identifier of the issuing peer 189 | 190 | `body` must be an object. Apart from that it can contain any type of fields. Nonetheless, make sure not to bloat the body as `stringify` will return `null` if a generated token exceeds 4KiB. 191 | 192 | In case of invalid inputs or any other exceptions `stringify` returns `null`, otherwise a `BWT` token. 193 | 194 | #### `parse(token: string): null | Contents` 195 | 196 | Parses a token. 197 | 198 | Returns `null` if the token is malformatted, corrupt, invalid, expired, from an unknown issuer, or if any other exceptions occur while marshalling, such as `JSON.parse(body)` -> 💥 199 | 200 | In case of a valid token `parse` returns an object containing the token `header` and `body`. 201 | 202 | This function encapsulates all validation and cryptographic verification of a token. Note that, as `BWT` requires every token to expire, `parse` does this basic metadata check. 203 | 204 | Additional application-specific metadata checks can be made as `parse`, besides the main body, returns the token header that contains metadata. Fx, an app could choose to reject all tokens of a certain age by additionally checking the mandatory `iat` claim of a token header. 205 | 206 | ## Managing Keys 207 | 208 | Any peer must own a static key pair and possess its peer's public keys and key identifiers for token generation and verification. Since a shared symmetric key would allow impersonation `BWT` requires key pairs. 209 | 210 | You can generate a key pair and the corresponding peer public key from the terminal by simply running `deno run https://deno.land/x/bwt/keygen.ts [name of key pair owner]`. 211 | 212 | Make sure to store the key pair somewhere safe (some kind of secret store) so that the included secret key remains private. 213 | 214 | Narrow the set of owners of a particular key pair as much as possible. Particularly, any token-issuing peer should own a key pair exclusively. Peers that only parse/verify tokens, fx a set of CRUD endpoints for a specific resource, may share a key pair. 215 | 216 | Do renew all key pairs involved in your application setting regularly! 217 | 218 | ## Dear Reviewers 219 | 220 | **Quick setup:** 221 | 222 | 1. Install `deno`: 223 | 224 | `curl -fsSL https://deno.land/x/install/install.sh | sh -s v1.0.0` 225 | 226 | 2. Get this repo: 227 | 228 | `git clone https://github.com/chiefbiiko/bwt@v0.6.0 && cd ./bwt && mkdir ./cache` 229 | 230 | 3. Cache all dependencies and run tests: 231 | 232 | `DENO_DIR=./cache $HOME/.deno/bin/deno run --reload ./test.ts` 233 | 234 | 4. Find all non-dev dependencies in the following two directories: 235 | 236 | **`./cache/deps/https/raw.githubusercontent.com/chiefbiiko/`** 237 | 238 | [`curve25519`](https://github.com/chiefbiiko/curve25519), [`chacha20`](https://github.com/chiefbiiko/chacha20), [`hchacha20`](https://github.com/chiefbiiko/hchacha20), [`poly1305`](https://github.com/chiefbiiko/poly1305), [`chacha20-poly1305`](https://github.com/chiefbiiko/chacha20-poly1305), [`xchacha20-poly1305`](https://github.com/chiefbiiko/xchacha20-poly1305), [`std-encoding`](https://github.com/chiefbiiko/std-encoding) 239 | 240 | **`./cache/deps/https/deno.land/x/`** 241 | 242 | [`base64`](https://github.com/chiefbiiko/base64) 243 | 244 | In addition to the bare code find a definition of the BWT scheme in the 245 | [specification](./spec.md). Please open an issue for your review findings. 246 | Looking forward to your feedback! 247 | 248 | **_Thank you for reviewing!_** 249 | 250 | ## License 251 | 252 | [MIT](./LICENSE) 253 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Curve25519, 3 | } from "https://denopkg.com/chiefbiiko/curve25519@v0.1.0/mod.ts"; 4 | 5 | export { 6 | seal, 7 | open, 8 | NONCE_BYTES as XCHACHA20_POLY1305_NONCE_BYTES, 9 | AAD_BYTES_MAX as XCHACHA20_POLY1305_AAD_BYTES_MAX, 10 | PLAINTEXT_BYTES_MAX as XCHACHA20_POLY1305_PLAINTEXT_BYTES_MAX, 11 | CIPHERTEXT_BYTES_MAX as XCHACHA20_CIPHERTEXT_BYTES_MAX, 12 | } from "https://denopkg.com/chiefbiiko/xchacha20-poly1305@v0.2.0/mod.ts"; 13 | 14 | export { 15 | hchacha20, 16 | OUTPUT_BYTES as HCHACHA20_OUTPUT_BYTES, 17 | NONCE_BYTES as HCHACHA20_NONCE_BYTES, 18 | } from "https://denopkg.com/chiefbiiko/hchacha20@v0.1.0/mod.ts"; 19 | 20 | export { 21 | encode, 22 | decode, 23 | } from "https://denopkg.com/chiefbiiko/std-encoding@v1.1.1/mod.ts"; 24 | -------------------------------------------------------------------------------- /keygen.ts: -------------------------------------------------------------------------------- 1 | import { decode } from "https://denopkg.com/chiefbiiko/std-encoding/mod.ts"; 2 | import { generateKeyPair, KeyPair } from "./mod.ts"; 3 | 4 | function main(): void { 5 | let keyPair: KeyPair; 6 | let secretKey: string; 7 | let stringKeyPair: string; 8 | 9 | try { 10 | keyPair = generateKeyPair(); 11 | 12 | const publicKey: string = decode(keyPair.publicKey, "base64url"); 13 | 14 | const kid: string = decode(keyPair.kid, "base64url"); 15 | 16 | const stringPeerPublicKey: string = JSON.stringify( 17 | { publicKey, kid, name: Deno.args[1] }, 18 | null, 19 | 2, 20 | ); 21 | 22 | secretKey = decode(keyPair.secretKey, "base64url"); 23 | 24 | stringKeyPair = JSON.stringify({ secretKey, publicKey, kid }, null, 2); 25 | 26 | console.log(`key pair\n${stringKeyPair}`); 27 | console.log(`peer public key\n${stringPeerPublicKey}`); 28 | } catch (err) { 29 | console.error(err.stack); 30 | } finally { 31 | keyPair.secretKey.fill(0x00, 0, keyPair.secretKey.byteLength); 32 | secretKey = null; 33 | stringKeyPair = null; 34 | } 35 | } 36 | 37 | main(); 38 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Curve25519, 3 | encode, 4 | decode, 5 | hchacha20, 6 | HCHACHA20_OUTPUT_BYTES, 7 | HCHACHA20_NONCE_BYTES, 8 | seal, 9 | open, 10 | XCHACHA20_POLY1305_NONCE_BYTES, 11 | XCHACHA20_POLY1305_AAD_BYTES_MAX, 12 | XCHACHA20_POLY1305_PLAINTEXT_BYTES_MAX, 13 | XCHACHA20_CIPHERTEXT_BYTES_MAX, 14 | } from "./deps.ts"; 15 | 16 | /** Supported BWT versions. */ 17 | export const SUPPORTED_VERSIONS: Set = new Set([0]); 18 | 19 | /** Maximum allowed number of characters of a token. */ 20 | export const MAX_TOKEN_CHARS: number = 4096; 21 | 22 | /** Byte length of a Curve25519 secret key. */ 23 | export const SECRET_KEY_BYTES: number = 32; 24 | 25 | /** Byte length of a Curve25519 public key. */ 26 | export const PUBLIC_KEY_BYTES: number = 32; 27 | 28 | /** Byte length of a BWT kid. */ 29 | export const KID_BYTES: number = 16; 30 | 31 | /** Byte length of a serialized header. */ 32 | const HEADER_BYTES: number = 60; 33 | 34 | /** Global Curve25519 instance provding a scalar multiplication op. */ 35 | const CURVE25519: Curve25519 = new Curve25519(); 36 | 37 | /** BigInt byte mask. */ 38 | const BIGINT_BYTE_MASK: bigint = 255n; 39 | 40 | /** BigInt 8. */ 41 | const BIGINT_BYTE_SHIFT: bigint = 8n; 42 | 43 | /** "BWT" as buffer - magic bytes. */ 44 | const BWT_MAGIC: Uint8Array = Uint8Array.from([66, 87, 84]); 45 | 46 | /** HChacha20 all-zero nonce used for key stretching. */ 47 | const HCHACHA20_ZERO_NONCE: Uint8Array = new Uint8Array(HCHACHA20_NONCE_BYTES); 48 | 49 | /** BWT context constant used with HChaCha20 for key stretching. */ 50 | const BWT_CONTEXT: Uint8Array = encode("BETTER_WEB_TOKEN", "utf8"); 51 | 52 | /** BWT format regex. */ 53 | const BWT_PATTERN: RegExp = 54 | /^QldU[A-Za-z0-9-_]{76}\.[A-Za-z0-9-_]{3,3992}\.[A-Za-z0-9-_]{22}$/; 55 | 56 | /** Curve25519 low-order public keys. https://cr.yp.to/ecdh.html#validate */ 57 | const LOW_ORDER_PUBLIC_KEYS: Uint8Array[] = [ 58 | "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 59 | "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", 60 | "4Ot6fDtBuK4WVuP68Z_EatoJjeucMrH9hmIFFl9JuAA=", 61 | "X5yVvKNQjCSx0LFVnIPvWwREXMRYHI6G2CJO3dCfEVc=", 62 | "7P_______________________________________38=", 63 | "7f_______________________________________38=", 64 | "7v_______________________________________38=", 65 | "zet6fDtBuK4WVuP68Z_EatoJjeucMrH9hmIFFl9JuIA=", 66 | "TJyVvKNQjCSx0LFVnIPvWwREXMRYHI6G2CJO3dCfEdc=", 67 | "2f________________________________________8=", 68 | "2v________________________________________8=", 69 | "2_________________________________________8=", 70 | ].map((publicKey: string): Uint8Array => encode(publicKey, "base64url")); 71 | 72 | /** Typ enum indicating a BWT version @ the Header.typ field. */ 73 | export const enum Typ { 74 | BWTv0, 75 | } 76 | 77 | /** 78 | * BWT header object. 79 | * 80 | * typ must be a supported BWT version, currently that is Typ.BWTv0 only. 81 | * iat and exp denote the issued-at and expiry ms timestamps of a token. 82 | * kid is the public key identifier of the issuing party. 83 | */ 84 | export interface Header { 85 | typ: Typ; 86 | iat: number; 87 | exp: number; 88 | kid: Uint8Array; 89 | } 90 | 91 | /** BWT body object. */ 92 | export interface Body { 93 | [key: string]: unknown; 94 | } 95 | 96 | /** Parsed contents of a token. */ 97 | export interface Contents { 98 | header: Header; 99 | body: Body; 100 | } 101 | 102 | /** BWT stringify function. */ 103 | export interface Stringify { 104 | (header: Header, body: Body): null | string; 105 | } 106 | 107 | /** BWT parse function. */ 108 | export interface Parse { 109 | (token: string): null | Contents; 110 | } 111 | 112 | /** 113 | * BWT keypair object including a key identifier for the public key. 114 | * 115 | * secretKey is the 32-byte secret key. 116 | * publicKey is the 32-byte public key. 117 | * kid is a 16-byte key identifier for the public key. 118 | */ 119 | export interface KeyPair { 120 | secretKey: Uint8Array; 121 | publicKey: Uint8Array; 122 | kid: Uint8Array; 123 | } 124 | 125 | /** 126 | * BWT public key of a peer. 127 | * 128 | * publicKey is the 32-byte public key. 129 | * kid is a 16-byte key identifer for the public key. 130 | * name can be an arbitrarily encoded string. 131 | */ 132 | export interface PeerPublicKey { 133 | publicKey: Uint8Array; 134 | kid: Uint8Array; 135 | name?: string; 136 | } 137 | 138 | /** Return values of the xchacha20-poly1305 seal op. */ 139 | interface Sealed { 140 | aad: Uint8Array; 141 | ciphertext: Uint8Array; 142 | tag: Uint8Array; 143 | } 144 | 145 | /** Branchless buffer equality check. */ 146 | function constantTimeEqual( 147 | actual: Uint8Array, 148 | expected: Uint8Array, 149 | length: number, 150 | ): boolean { 151 | let diff: number = 0; 152 | 153 | for (let i: number = 0; i < length; ++i) { 154 | diff |= actual[i] ^ expected[i]; 155 | } 156 | 157 | return diff === 0; 158 | } 159 | 160 | /** Whether given public key has a low order? */ 161 | function isLowOrderPublicKey(publicKey: Uint8Array): boolean { 162 | return LOW_ORDER_PUBLIC_KEYS.some((lowOrderPublicKey: Uint8Array): boolean => 163 | constantTimeEqual(publicKey, lowOrderPublicKey, PUBLIC_KEY_BYTES) 164 | ); 165 | } 166 | 167 | /** Reads given bytes as an unsigned big-endian bigint. */ 168 | function bytesToBigIntBE(buf: Uint8Array): bigint { 169 | return buf.reduce( 170 | (acc: bigint, byte: number): bigint => 171 | (acc << BIGINT_BYTE_SHIFT) | (BigInt(byte) & BIGINT_BYTE_MASK), 172 | 0n, 173 | ); 174 | } 175 | 176 | /** Writes given timestamp to big-endian bytes of an 8-byte out buffer. */ 177 | function bigintToBytesBE(b: bigint, out: Uint8Array): void { 178 | for (let i: number = out.byteLength - 1; i >= 0; --i) { 179 | out[i] = Number(b & BIGINT_BYTE_MASK); 180 | b >>= BIGINT_BYTE_SHIFT; 181 | } 182 | } 183 | 184 | /** Converts a header and nonce to a 60-byte buffer. */ 185 | function headerAndNonceToBuffer( 186 | header: Header, 187 | nonce: Uint8Array, 188 | ): Uint8Array { 189 | const buf: Uint8Array = new Uint8Array(HEADER_BYTES); 190 | 191 | buf.set(BWT_MAGIC, 0); 192 | buf[3] = header.typ; 193 | 194 | bigintToBytesBE(BigInt(header.iat), buf.subarray(4, 12)); 195 | bigintToBytesBE(BigInt(header.exp), buf.subarray(12, 20)); 196 | 197 | buf.set(header.kid, 20); 198 | buf.set(nonce, 36); 199 | 200 | return buf; 201 | } 202 | 203 | /** Converts a buffer to metadata of the form: [header, kid, nonce]. */ 204 | function bufferToMetadata(buf: Uint8Array): [Header, string, Uint8Array] { 205 | return [ 206 | { 207 | typ: buf[3], 208 | iat: Number(bytesToBigIntBE(buf.subarray(4, 12))), 209 | exp: Number(bytesToBigIntBE(buf.subarray(12, 20))), 210 | kid: buf.subarray(20, 36), 211 | }, 212 | decode(buf.subarray(20, 36), "base64url"), 213 | buf.subarray(36, HEADER_BYTES), 214 | ]; 215 | } 216 | 217 | /** Shared key derivation. */ 218 | function deriveSharedKey( 219 | secretKey: Uint8Array, 220 | publicKey: Uint8Array, 221 | ): Uint8Array { 222 | const sharedSecret: Uint8Array = CURVE25519.scalarMult(secretKey, publicKey); 223 | 224 | const sharedKey: Uint8Array = new Uint8Array(HCHACHA20_OUTPUT_BYTES); 225 | 226 | hchacha20(sharedKey, sharedSecret, HCHACHA20_ZERO_NONCE, BWT_CONTEXT); 227 | 228 | sharedSecret.fill(0x00); 229 | 230 | return sharedKey; 231 | } 232 | 233 | /** Transforms a collection of peer public keys to a shared key map. */ 234 | function toSharedKeyMap( 235 | ownSecretKey: Uint8Array, 236 | peerPublicKeys: PeerPublicKey[], 237 | ): Map { 238 | return new Map( 239 | peerPublicKeys.map((peerPublicKey: PeerPublicKey): [string, Uint8Array] => [ 240 | decode(peerPublicKey.kid, "base64url"), 241 | deriveSharedKey(ownSecretKey, peerPublicKey.publicKey), 242 | ]), 243 | ); 244 | } 245 | 246 | /** Concatenates aad, ciphertext, and tag to a token. */ 247 | function assembleToken( 248 | aad: Uint8Array, 249 | ciphertext: Uint8Array, 250 | tag: Uint8Array, 251 | ): string { 252 | return ( 253 | decode(aad, "base64url") + 254 | "." + 255 | decode(ciphertext, "base64url") + 256 | "." + 257 | decode(tag, "base64url") 258 | ); 259 | } 260 | 261 | /** Whether given input is a valid BWT header object. */ 262 | function isValidHeader(x: any): boolean { 263 | const now: number = Date.now(); 264 | return ( 265 | x && 266 | SUPPORTED_VERSIONS.has(x.typ) && 267 | x.kid && 268 | x.kid.byteLength === KID_BYTES && 269 | x.iat >= 0 && 270 | x.iat % 1 === 0 && 271 | x.iat <= now && 272 | x.exp >= 0 && 273 | x.exp % 1 === 0 && 274 | x.exp > now 275 | ); 276 | } 277 | 278 | /** Whether given input is a valid BWT secret key. */ 279 | function isValidSecretKey(x: Uint8Array): boolean { 280 | return x && x.byteLength === SECRET_KEY_BYTES; 281 | } 282 | 283 | /** Whether given input is a valid BWT peer public key. */ 284 | function isValidPeerPublicKey(x: PeerPublicKey): boolean { 285 | return ( 286 | x && 287 | x.kid && 288 | x.kid.byteLength === KID_BYTES && 289 | x.publicKey && 290 | x.publicKey.byteLength === PUBLIC_KEY_BYTES && 291 | !isLowOrderPublicKey(x.publicKey) 292 | ); 293 | } 294 | 295 | /** Whether given input string has a valid token size. */ 296 | function hasValidTokenSize(x: string): boolean { 297 | return x.length <= MAX_TOKEN_CHARS; 298 | } 299 | 300 | /** Naive BWT format validation. */ 301 | function hasValidTokenFormat(x: string): boolean { 302 | return BWT_PATTERN.test(x); 303 | } 304 | 305 | /** Generates a BWT key pair. */ 306 | export function generateKeyPair(): KeyPair { 307 | const seed: Uint8Array = new Uint8Array(SECRET_KEY_BYTES); 308 | const kid: Uint8Array = new Uint8Array(KID_BYTES); 309 | 310 | crypto.getRandomValues(seed); 311 | 312 | // keypair is null only if seed.length != 32 :: SECRET_KEY_BYTES === 32 313 | const keypair: { 314 | secretKey: Uint8Array; 315 | publicKey: Uint8Array; 316 | } = CURVE25519.generateKeys(seed) as { 317 | secretKey: Uint8Array; 318 | publicKey: Uint8Array; 319 | }; 320 | 321 | seed.fill(0x00); 322 | 323 | if (isLowOrderPublicKey(keypair.publicKey)) { 324 | keypair.secretKey.fill(0x00); 325 | 326 | return generateKeyPair(); 327 | } 328 | 329 | crypto.getRandomValues(kid); 330 | 331 | return { ...keypair, kid }; 332 | } 333 | 334 | /** 335 | * Creates a BWT stringify function. 336 | * 337 | * ownSecretKey must be a buffer of 32 bytes. 338 | * peerPublicKey must be a peer public key object. 339 | * 340 | * Throws TypeErrors if any of its arguments are invalid. 341 | */ 342 | export function createStringify( 343 | ownSecretKey: Uint8Array, 344 | peerPublicKey: PeerPublicKey, 345 | ): Stringify { 346 | if (!isValidSecretKey(ownSecretKey)) { 347 | throw new TypeError("invalid secret key"); 348 | } 349 | 350 | if (!isValidPeerPublicKey(peerPublicKey)) { 351 | throw new TypeError("invalid peer public key"); 352 | } 353 | 354 | const sharedKey: Uint8Array = deriveSharedKey( 355 | ownSecretKey, 356 | peerPublicKey.publicKey, 357 | ); 358 | 359 | /** 360 | * Stringifies header and body to an authenticated and encrypted token. 361 | * 362 | * header must be a BWT header object. 363 | * body must be a serializable object with string keys. 364 | * 365 | * Returns null in case of invalid inputs, if the body is too big 366 | * (token.length > 4096), or other exceptions, fx JSON.stringify(body) -> 💥 367 | */ 368 | return function stringify(header: Header, body: Body): null | string { 369 | if (!isValidHeader(header) || !body) { 370 | return null; 371 | } 372 | 373 | let token: string; 374 | 375 | try { 376 | const nonce: Uint8Array = crypto.getRandomValues( 377 | new Uint8Array(XCHACHA20_POLY1305_NONCE_BYTES), 378 | ); 379 | 380 | const aad: Uint8Array = headerAndNonceToBuffer(header, nonce); 381 | 382 | if (aad.byteLength > XCHACHA20_POLY1305_AAD_BYTES_MAX) { 383 | return null; 384 | } 385 | 386 | const plaintext: Uint8Array = encode(JSON.stringify(body), "utf8"); 387 | 388 | if (plaintext.byteLength > XCHACHA20_POLY1305_PLAINTEXT_BYTES_MAX) { 389 | return null; 390 | } 391 | 392 | // NOTE: all args to seal r of correct length - will return Sealed 393 | const sealed: Sealed = seal(sharedKey, nonce, plaintext, aad) as Sealed; 394 | 395 | plaintext.fill(0x00); 396 | 397 | token = assembleToken(sealed.aad, sealed.ciphertext, sealed.tag); 398 | } catch (_) { 399 | return null; 400 | } 401 | 402 | if (!hasValidTokenSize(token)) { 403 | return null; 404 | } 405 | 406 | return token; 407 | }; 408 | } 409 | 410 | /** 411 | * Creates a BWT parse function. 412 | * 413 | * ownSecretKey must be a buffer of 32 bytes. 414 | * peerPublicKeys must be a non-empty peer public key collection to be used for 415 | * verification of incoming tokens. 416 | * 417 | * Throws TypeErrors if any of its arguments are invalid. 418 | */ 419 | export function createParse( 420 | ownSecretKey: Uint8Array, 421 | ...peerPublicKeys: PeerPublicKey[] 422 | ): Parse { 423 | if (!isValidSecretKey(ownSecretKey)) { 424 | throw new TypeError("invalid secret key"); 425 | } 426 | 427 | if (!peerPublicKeys.length) { 428 | throw new TypeError("no peer public keys provided"); 429 | } 430 | 431 | if (!peerPublicKeys.every(isValidPeerPublicKey)) { 432 | throw new TypeError("invalid peer public keys"); 433 | } 434 | 435 | const sharedKeyMap: Map = toSharedKeyMap( 436 | ownSecretKey, 437 | peerPublicKeys, 438 | ); 439 | 440 | /** 441 | * Parses the contents of a BWT token. 442 | * 443 | * token must be a BWT token. 444 | * 445 | * Returns null if the token is malformatted, corrupt, expired, from an 446 | * unknown issuer, or if any other exceptions occur while marshalling, such as 447 | * JSON.parse(body) -> 💥 448 | * 449 | * In case of a valid token parse returns an object containing the token 450 | * header and body. 451 | * 452 | * This function encapsulates all validation and cryptographic verification of 453 | * a token. Note that, as BWT requires every token to expire, parse does this 454 | * basic metadata check. 455 | * 456 | * Additional application-specific metadata checks can be made as parse, 457 | * besides the main body, returns the token header that contains metadata. Fx, 458 | * an app could choose to reject all tokens of a certain age by additionally 459 | * checking the mandatory iat claim of a token header. 460 | */ 461 | return function parse(token: string): null | Contents { 462 | if (!hasValidTokenFormat(token)) { 463 | return null; 464 | } 465 | 466 | let header: Header; 467 | let body: Body; 468 | 469 | try { 470 | let kid: string; 471 | let nonce: Uint8Array; 472 | 473 | const parts: string[] = token.split("."); 474 | 475 | const aad: Uint8Array = encode(parts[0], "base64url"); 476 | 477 | if (aad.byteLength > XCHACHA20_POLY1305_AAD_BYTES_MAX) { 478 | return null; 479 | } 480 | 481 | [header, kid, nonce] = bufferToMetadata(aad); 482 | 483 | const ciphertext: Uint8Array = encode(parts[1], "base64url"); 484 | 485 | if (ciphertext.byteLength > XCHACHA20_CIPHERTEXT_BYTES_MAX) { 486 | return null; 487 | } 488 | 489 | const tag: Uint8Array = encode(parts[2], "base64url"); 490 | 491 | const sharedKey: undefined | Uint8Array = sharedKeyMap.get(kid); 492 | 493 | if (!sharedKey) { 494 | return null; 495 | } 496 | 497 | const plaintext: null | Uint8Array = open( 498 | sharedKey, 499 | nonce, 500 | ciphertext, 501 | aad, 502 | tag, 503 | ); 504 | 505 | if (!plaintext) { 506 | return null; 507 | } 508 | 509 | const jsonPlaintext: string = decode(plaintext, "utf8"); 510 | 511 | plaintext.fill(0x00); 512 | 513 | body = JSON.parse(jsonPlaintext); 514 | } catch (_) { 515 | return null; 516 | } 517 | 518 | if (!body || body.constructor !== Object || !isValidHeader(header)) { 519 | return null; 520 | } 521 | 522 | return { header, body }; 523 | }; 524 | } 525 | -------------------------------------------------------------------------------- /spec.md: -------------------------------------------------------------------------------- 1 | # Better Web Token Spec 2 | 3 | ## Summary 4 | 5 | The Better Web Token (BWT) scheme specifies a web token format, the 6 | corresponding token generation, token verification, as well as key generation 7 | and derivation procedures. 8 | 9 | The JOSE standards and its popular manifestation JWT have numerous 10 | [design flaws](https://www.chosenplaintext.ca/2015/03/31/jwt-algorithm-confusion.html) and [deployment pitfalls](https://auth0.com/blog/a-look-at-the-latest-draft-for-jwt-bcp/#Pitfalls-and-Common-Attacks). In contrast, BWT 11 | utilizes a fixed AEAD scheme, [XChaCha20-Poly1305](https://tools.ietf.org/html/draft-irtf-cfrg-xchacha-01#section-2), encapsulates all cryptographic 12 | operations, and exposes only lean and simple APIs. By design, BWT aims to 13 | minimize the possibility of deployment vulnerabilities. 14 | 15 | ## Design Goals 16 | 17 | + secure by default 18 | + simple to use 19 | + hard to misuse 20 | 21 | ## Prior Art 22 | 23 | Over the years a number of JWT alternatives, [PASETO](https://paseto.io/), [Branca](https://branca.io/), have 24 | been developed. BWT is most similar to Branca which also uses 25 | [XChaCha20-Poly1305](https://tools.ietf.org/html/draft-irtf-cfrg-xchacha-01#section-2). In contrast to Branca BWT utilizes key pairs instead 26 | of symmetric keys. This reduces the risk of impersonation. Another notable 27 | difference in comparison to Branca is the requirement that every BWT token 28 | expires. 29 | 30 | XChaCha20-Poly1305 is a recently standardized, AEAD construction that requires 31 | a 192-bit nonce which due to that length can be generated with a CSPRNG. This 32 | approach is, given the PRNG is cryptographically secure, more robust than 33 | common counter-based generation techniques usually used for shorter nonces. 34 | 35 | ## Token Format 36 | 37 | Basically, a BWT token has the following textual shape `header.body.signature`. 38 | All three token components are URL-safe base64 strings concatenated with 39 | a dot. The header basically encompasses the AEAD construct's additional 40 | authenticated data. The body part represents the actual ciphertext, whereas the 41 | signature is the corresponding Poly1305 MAC. 42 | 43 | Find the binary format of a header depicted below. 44 | 45 | |Byte Range|Content| 46 | ------|-------| 47 | `0..3` | `0x42 0x57 0x54` 48 | `3` | version 49 | `4..12` | big-endian issuance ms timestamp 50 | `12..20`| big-endian expiry ms timestamp 51 | `20..36`| issuer kid 52 | `36..60`| nonce 53 | 54 | ## Public Key Validation 55 | 56 | A BWT key pair consists of a Curve25519 key pair, with the secret and public 57 | keys having a length of 32, enriched with a 16-byte public key 58 | identifier. BWT requires contributory behavior, therefore the following 59 | low-order public keys are invalid and must be rejected by any BWT procedure. 60 | 61 | ``` 62 | [ 63 | 0000000000000000000000000000000000000000000000000000000000000000, 64 | 0100000000000000000000000000000000000000000000000000000000000000, 65 | e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800, 66 | 5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f1157, 67 | ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f, 68 | edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f, 69 | eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f, 70 | cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880, 71 | 4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7, 72 | d9ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, 73 | daffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, 74 | dbffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 75 | ] 76 | ``` 77 | 78 | Obtained from 79 | [Daniel J. Bernstein's webpage on ECDH](https://cr.yp.to/ecdh.html#validate). 80 | 81 | ## Procedures 82 | 83 | ### Header Serialization 84 | 85 | #### Procedure 86 | 87 | **Inputs:** version, issuance ms timestamp (iat), expiry ms timestamp 88 | (exp), public key identifier (kid), nonce 89 | 90 | + obtain a buffer by acquiring (allocate or require as additional input) 60 91 | bytes of memory 92 | 93 | + set the buffer's byte range 0..3 to `0x42 0x57 0x54` 94 | 95 | + set the buffer's byte 3..4 of the buffer to the version input parameter 96 | 97 | + set the buffer's byte range 4..12 to the big-endian representation of iat 98 | 99 | + set the buffer's byte range 12..20 to the big-endian representation of exp 100 | 101 | + set the buffer's byte range 20..36 to the kid 102 | 103 | + set the buffer's byte range 36..60 to the nonce 104 | 105 | **Outputs:** buffer 106 | 107 | ### Header Deserialization 108 | 109 | #### Procedure 110 | 111 | **Inputs:** buffer 112 | 113 | + obtain the version by reading the fourth byte of the buffer 114 | 115 | + obtain iat by reading the buffer's byte range 4..12 as a big-endian integer 116 | 117 | + obtain exp by reading the buffer's byte range 12..20 as a big-endian integer 118 | 119 | + obtain the kid from the buffer's byte range 20..36 120 | 121 | + obtain the nonce from the buffer's byte range 36..60 122 | 123 | **Outputs:** version, issuance ms timestamp (iat), expiry ms timestamp 124 | (exp), public key identifier (kid), nonce 125 | 126 | ### Key Pair Generation 127 | 128 | A BWT key pair is essentially a Curve25519 key pair enriched by a 16-byte 129 | public key identifier (kid). 130 | 131 | #### Procedure 132 | 133 | **Inputs:** none 134 | 135 | + obtain a seed by generating 32 bytes from a CSPRNG 136 | 137 | + obtain the secret key by clearing bit 0, 1, 2, 255 and setting bit 254 of the 138 | seed 139 | 140 | + zero out the seed memory 141 | 142 | + obtain the public key by performing a Curve25519 scalar multiplication of the 143 | secret key and the constant value 9 144 | 145 | + assert that the public key is not among the set defined in 146 | [Public Key Validation](#public-key-validation) 147 | 148 | + if the public key is in fact of low order, the corresponding secret key 149 | memory must be zeroed - thereafter, implementations are free to either 150 | fallback to another key pair generation attempt or return a null value 151 | 152 | + obtain the kid by generating 16 bytes from a CSPRNG 153 | 154 | **Outputs:** secret key, public key, kid 155 | 156 | ### Shared Key Derivation 157 | 158 | BWT uses [HChaCha20](https://tools.ietf.org/html/draft-irtf-cfrg-xchacha-01#section-2.2) to derive a shared key from a X25519 shared secret. 159 | 160 | The secret and public key must have been generated using the procedure 161 | specified in [Key Pair Generation](#key-pair-generation). 162 | 163 | #### Procedure 164 | 165 | **Inputs:** secret key, public key 166 | 167 | + assert that the public key is not among the set defined in 168 | [Public Key Validation](#public-key-validation) 169 | 170 | + obtain the shared secret by performing X25519 with the secret and public key 171 | 172 | + create the shared key by applying [HChaCha20](https://tools.ietf.org/html/draft-irtf-cfrg-xchacha-01#section-2.2) with the shared secret, a 16-byte all-zero nonce, and the 16-byte binary representation of the UTF-8 173 | string "BETTER_WEB_TOKEN" as a constant context value 174 | 175 | + zero out the shared secret memory 176 | 177 | **Outputs:** shared key 178 | 179 | ### Token Generation 180 | 181 | The token generation procedure takes the shared key between the issuing and 182 | addressed peer as input, see [Shared Key Derivation](#shared-key-derivation) 183 | for details. 184 | 185 | Any unexpected state encountered during the following procedure (i.e. negative 186 | asserts) must not raise an exception but rather return a null value. 187 | 188 | #### Procedure 189 | 190 | **Inputs:** shared key, version, issuance ms timestamp (iat), 191 | expiry ms timestamp (exp), public key identifier (kid), 192 | body (must be coercible to a JSON object) 193 | 194 | + assert that the version is an unsigned integer among the following set: 0 195 | 196 | + assert that iat is an unsigned integer less than or equal the current time 197 | 198 | + assert that exp is an unsigned integer greater than the current time 199 | 200 | + assert that kid has a byte length of 16 201 | 202 | + obtain a nonce by generating 24 bytes from a CSPRNG 203 | 204 | + obtain the additional authenticated data (aad) from the version, iat and 205 | exp timestamps, the kid, and the nonce as defined in 206 | [Header Serialization](#header-serialization) 207 | 208 | + assert that the aad has a byte length not greater than 18446744073709551615 209 | 210 | + obtain the JSON body by stringifying the body to a valid JSON object 211 | 212 | + obtain the plaintext by serializing the JSON body to its binary 213 | representation assuming UTF-8 encoding 214 | 215 | + assert that the plaintext has a byte length not greater than 274877906880 216 | 217 | + obtain the ciphertext and signature by applying XChaCha20-Poly1305 with the 218 | shared key, nonce, plaintext, and aad 219 | 220 | + zero out the plaintext memory 221 | 222 | + obtain the token by concatenating the URL-safe base64 representations of the 223 | aad, ciphertext, and signature, in this order, with a dot 224 | 225 | + assert that the total token byte length is not greater than 4096 226 | 227 | **Outputs:** token 228 | 229 | ### Token Verification 230 | 231 | The token verification procedure takes the shared key between the issuing and 232 | addressed peer as input, see [Shared Key Derivation](#shared-key-derivation) 233 | for details. 234 | 235 | Any unexpected state encountered during the following procedure (i.e. negative 236 | asserts) must not raise an exception but rather return a null value. 237 | 238 | #### Procedure 239 | 240 | **Inputs:** shared key, token 241 | 242 | + assert that the token matches this regular expression: 243 | `^QldU[A-Za-z0-9-_=]{76}\.[A-Za-z0-9-_=]{4,3990}\.[A-Za-z0-9-_=]{24}$` 244 | 245 | + split the token into three pieces on the dot character, discarding it 246 | 247 | + obtain the authenticated additional data (aad) by serializing the first 248 | part from a URL-safe base64 string to a buffer 249 | 250 | + assert that the aad has a byte length not greater than 18446744073709551615 251 | 252 | + obtain the ciphertext by serializing the second part from a URL-safe base64 string to a buffer 253 | 254 | + assert that the ciphertext has a byte length not greater than 274877906896 255 | 256 | + obtain the received tag by serializing the third part from a URL-safe base64 string to a buffer 257 | 258 | + obtain the version, issuance millisecond timestamp (iat), expiry millisecond timestamp 259 | (exp), public key identifier (kid), and nonce by applying the 260 | [Header Deserialization](#header-deserialization) procedure with the aad as 261 | input 262 | 263 | + obtain the plaintext by applying XChaCha20-Poly1305 with the 264 | shared key, nonce, ciphertext, aad, and received tag 265 | 266 | + obtain the JSON plaintext by deserializing the binary plaintext assuming 267 | UTF-8 encoding 268 | 269 | + zero out the plaintext memory 270 | 271 | + obtain the body by parsing the JSON plaintext 272 | 273 | + assert that the body is an object 274 | 275 | + assert that the version is an unsigned integer among the following set: 0 276 | 277 | + assert that iat is an unsigned integer less than or equal the current time 278 | 279 | + assert that exp is an unsigned integer greater than the current time 280 | 281 | + assert that kid has a byte length of 16 282 | 283 | **Outputs:** body, version, issuance ms timestamp (iat), expiry ms timestamp 284 | (exp), public key identifier (kid) 285 | 286 | ## Test Vectors 287 | 288 | TODO 289 | -------------------------------------------------------------------------------- /test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | assertThrows, 4 | } from "https://deno.land/std@v0.51.0/testing/asserts.ts"; 5 | 6 | import { encode, decode } from "./deps.ts"; 7 | 8 | import * as bwt from "./mod.ts"; 9 | 10 | function createHeader(source: { [key: string]: any } = {}): bwt.Header { 11 | return { 12 | typ: bwt.Typ.BWTv0, 13 | iat: Date.now(), 14 | exp: Date.now() + 419, 15 | kid: source.kid, 16 | ...source, 17 | }; 18 | } 19 | 20 | function createBody(...sources: bwt.Body[]): bwt.Body { 21 | return { fraud: "fraud", ...sources }; 22 | } 23 | 24 | interface Peer { 25 | name: string; 26 | kid: Uint8Array; 27 | secretKey: Uint8Array; 28 | publicKey: Uint8Array; 29 | } 30 | 31 | interface Alice extends Peer { 32 | stringify: bwt.Stringify; 33 | } 34 | 35 | interface Bob extends Peer { 36 | parse: bwt.Parse; 37 | } 38 | 39 | interface C extends Peer { 40 | stringify: bwt.Stringify; 41 | } 42 | 43 | const a: Alice = { ...bwt.generateKeyPair(), name: "alice" } as Alice; 44 | const b: Bob = { ...bwt.generateKeyPair(), name: "bob" } as Bob; 45 | const c: C = { ...bwt.generateKeyPair(), name: "chief" } as C; 46 | 47 | a.stringify = bwt.createStringify(a.secretKey, { 48 | name: "bob", 49 | kid: b.kid, 50 | publicKey: b.publicKey, 51 | }); 52 | 53 | b.parse = bwt.createParse( 54 | b.secretKey, 55 | { 56 | name: a.name, 57 | kid: a.kid, 58 | publicKey: a.publicKey, 59 | }, 60 | { 61 | name: c.name, 62 | kid: c.kid, 63 | publicKey: c.publicKey, 64 | }, 65 | ); 66 | 67 | c.stringify = bwt.createStringify(c.secretKey, { 68 | name: b.name, 69 | kid: b.kid, 70 | publicKey: b.publicKey, 71 | }); 72 | 73 | Deno.test({ 74 | name: "alice and bob", 75 | fn(): void { 76 | const inputHeader: bwt.Header = createHeader({ kid: a.kid }); 77 | const inputBody: bwt.Body = createBody(); 78 | 79 | const token: null | string = a.stringify(inputHeader, inputBody); 80 | 81 | const contents: null | bwt.Contents = b.parse(token!); 82 | 83 | assertEquals(contents?.header, inputHeader); 84 | assertEquals(contents?.body, inputBody); 85 | }, 86 | }); 87 | 88 | Deno.test({ 89 | name: "parse from multiple peers", 90 | fn(): void { 91 | const aliceInputHeader: bwt.Header = createHeader({ kid: a.kid }); 92 | 93 | const chiefInputHeader: bwt.Header = createHeader({ kid: c.kid }); 94 | 95 | const inputBody: bwt.Body = createBody(); 96 | 97 | const aliceToken: null | string = a.stringify(aliceInputHeader, inputBody); 98 | 99 | const chiefToken: null | string = c.stringify(chiefInputHeader, inputBody); 100 | 101 | const fromAlice: null | bwt.Contents = b.parse(aliceToken!); 102 | 103 | const fromChiefbiiko: null | bwt.Contents = b.parse(chiefToken!); 104 | 105 | assertEquals(fromAlice?.header, aliceInputHeader); 106 | assertEquals(fromAlice?.body, inputBody); 107 | assertEquals(fromChiefbiiko?.header, chiefInputHeader); 108 | assertEquals(fromChiefbiiko?.body, inputBody); 109 | }, 110 | }); 111 | 112 | Deno.test({ 113 | name: "createStringify throws if ownSecretKey is invalid", 114 | fn(): void { 115 | assertThrows( 116 | (): void => { 117 | bwt.createStringify(new Uint8Array(bwt.SECRET_KEY_BYTES - 1), { 118 | publicKey: new Uint8Array(bwt.PUBLIC_KEY_BYTES), 119 | kid: new Uint8Array(bwt.KID_BYTES), 120 | }); 121 | }, 122 | TypeError, 123 | "invalid secret key", 124 | ); 125 | }, 126 | }); 127 | 128 | Deno.test({ 129 | name: "createStringify throws if peerPublicKey.publicKey is invalid", 130 | fn(): void { 131 | assertThrows( 132 | (): void => { 133 | bwt.createStringify(new Uint8Array(bwt.SECRET_KEY_BYTES), { 134 | publicKey: new Uint8Array(bwt.PUBLIC_KEY_BYTES - 1), 135 | kid: new Uint8Array(bwt.KID_BYTES), 136 | }); 137 | }, 138 | TypeError, 139 | "invalid peer public key", 140 | ); 141 | }, 142 | }); 143 | 144 | Deno.test({ 145 | name: "createStringify throws if peerPublicKey.kid is invalid", 146 | fn(): void { 147 | assertThrows( 148 | (): void => { 149 | bwt.createStringify(new Uint8Array(bwt.SECRET_KEY_BYTES), { 150 | publicKey: new Uint8Array(bwt.PUBLIC_KEY_BYTES), 151 | kid: new Uint8Array(bwt.KID_BYTES - 1), 152 | }); 153 | }, 154 | TypeError, 155 | "invalid peer public key", 156 | ); 157 | }, 158 | }); 159 | 160 | Deno.test({ 161 | name: "createParse throws if ownSecretKey is invalid", 162 | fn(): void { 163 | assertThrows( 164 | (): void => { 165 | bwt.createParse(Uint8Array.from([1, 2, 3])); 166 | }, 167 | TypeError, 168 | "invalid secret key", 169 | ); 170 | }, 171 | }); 172 | 173 | Deno.test({ 174 | name: "createParse throws if peerPublicKey.publicKey is invalid", 175 | fn(): void { 176 | assertThrows( 177 | (): void => { 178 | bwt.createParse(new Uint8Array(bwt.SECRET_KEY_BYTES), { 179 | publicKey: new Uint8Array(bwt.PUBLIC_KEY_BYTES - 1), 180 | kid: new Uint8Array(bwt.KID_BYTES), 181 | }); 182 | }, 183 | TypeError, 184 | "invalid peer public keys", 185 | ); 186 | }, 187 | }); 188 | 189 | Deno.test({ 190 | name: "createParse throws if peerPublicKey.kid is invalid", 191 | fn(): void { 192 | assertThrows( 193 | (): void => { 194 | bwt.createParse(new Uint8Array(bwt.SECRET_KEY_BYTES), { 195 | publicKey: new Uint8Array(bwt.PUBLIC_KEY_BYTES), 196 | kid: new Uint8Array(bwt.KID_BYTES - 1), 197 | }); 198 | }, 199 | TypeError, 200 | "invalid peer public keys", 201 | ); 202 | }, 203 | }); 204 | 205 | Deno.test({ 206 | name: "createParse throws if no peer public keys are provided", 207 | fn(): void { 208 | assertThrows( 209 | (): void => { 210 | bwt.createParse(new Uint8Array(bwt.SECRET_KEY_BYTES)); 211 | }, 212 | TypeError, 213 | "no peer public keys provided", 214 | ); 215 | }, 216 | }); 217 | 218 | Deno.test({ 219 | name: "createParse throws if a low-order public key is passed", 220 | fn(): void { 221 | assertThrows( 222 | (): void => { 223 | bwt.createParse(new Uint8Array(bwt.SECRET_KEY_BYTES), { 224 | publicKey: new Uint8Array(bwt.PUBLIC_KEY_BYTES), 225 | kid: new Uint8Array(bwt.KID_BYTES), 226 | }); 227 | }, 228 | TypeError, 229 | "invalid peer public keys", 230 | ); 231 | }, 232 | }); 233 | 234 | Deno.test({ 235 | name: "stringify nulls if header is nullish", 236 | fn(): void { 237 | assertEquals(a.stringify(null!, createBody()), null); 238 | }, 239 | }); 240 | 241 | Deno.test({ 242 | name: "stringify nulls if body is nullish", 243 | fn(): void { 244 | const inputHeader: bwt.Header = createHeader({ kid: a.kid }); 245 | 246 | assertEquals(a.stringify(inputHeader, null!), null); 247 | }, 248 | }); 249 | 250 | Deno.test({ 251 | name: "stringify nulls if version is unsupported", 252 | fn(): void { 253 | const inputHeader: bwt.Header = createHeader({ typ: 255, kid: a.kid }); 254 | 255 | assertEquals(a.stringify(inputHeader, createBody()), null); 256 | }, 257 | }); 258 | 259 | Deno.test({ 260 | name: "stringify nulls if kid is nullish", 261 | fn(): void { 262 | const inputHeader: bwt.Header = createHeader({ kid: null }); 263 | 264 | assertEquals(a.stringify(inputHeader, createBody()), null); 265 | }, 266 | }); 267 | 268 | Deno.test({ 269 | name: "stringify nulls if iat is negative", 270 | fn(): void { 271 | const inputHeader: bwt.Header = createHeader({ iat: -1, kid: a.kid }); 272 | 273 | assertEquals(a.stringify(inputHeader, createBody()), null); 274 | }, 275 | }); 276 | 277 | Deno.test({ 278 | name: "stringify nulls if iat is NaN", 279 | fn(): void { 280 | const inputHeader: bwt.Header = createHeader({ iat: NaN, kid: a.kid }); 281 | 282 | assertEquals(a.stringify(inputHeader, createBody()), null); 283 | }, 284 | }); 285 | 286 | Deno.test({ 287 | name: "stringify nulls if iat is Infinity", 288 | fn(): void { 289 | const inputHeader: bwt.Header = createHeader( 290 | { iat: Infinity, kid: a.kid }, 291 | ); 292 | 293 | assertEquals(a.stringify(inputHeader, createBody()), null); 294 | }, 295 | }); 296 | 297 | Deno.test({ 298 | name: "stringify nulls if iat is nullish", 299 | fn(): void { 300 | const inputHeader: bwt.Header = createHeader({ iat: null, kid: a.kid }); 301 | 302 | assertEquals(a.stringify(inputHeader, createBody()), null); 303 | }, 304 | }); 305 | 306 | Deno.test({ 307 | name: "stringify nulls if exp is negative", 308 | fn(): void { 309 | const inputHeader: bwt.Header = createHeader({ exp: -1, kid: a.kid }); 310 | 311 | assertEquals(a.stringify(inputHeader, createBody()), null); 312 | }, 313 | }); 314 | 315 | Deno.test({ 316 | name: "stringify nulls if exp is NaN", 317 | fn(): void { 318 | const inputHeader: bwt.Header = createHeader({ exp: NaN, kid: a.kid }); 319 | 320 | assertEquals(a.stringify(inputHeader, createBody()), null); 321 | }, 322 | }); 323 | 324 | Deno.test({ 325 | name: "stringify nulls if exp is Infinity", 326 | fn(): void { 327 | const inputHeader: bwt.Header = createHeader( 328 | { exp: Infinity, kid: a.kid }, 329 | ); 330 | 331 | assertEquals(a.stringify(inputHeader, createBody()), null); 332 | }, 333 | }); 334 | 335 | Deno.test({ 336 | name: "stringify nulls if exp is nullish", 337 | fn(): void { 338 | const inputHeader: bwt.Header = createHeader({ exp: null, kid: a.kid }); 339 | 340 | assertEquals(a.stringify(inputHeader, createBody()), null); 341 | }, 342 | }); 343 | 344 | Deno.test({ 345 | name: "stringify nulls if exp is due", 346 | fn(): void { 347 | const inputHeader: bwt.Header = createHeader({ 348 | exp: Date.now() - 1, 349 | kid: a.kid, 350 | }); 351 | 352 | assertEquals(a.stringify(inputHeader, createBody()), null); 353 | }, 354 | }); 355 | 356 | Deno.test({ 357 | name: "parse nulls if kid is unknown", 358 | fn(): void { 359 | const inputHeader: bwt.Header = createHeader({ 360 | kid: encode("deadbeefdeadbeef", "utf8"), 361 | }); 362 | 363 | const token: null | string = a.stringify(inputHeader, createBody()); 364 | 365 | assertEquals(b.parse(token!), null); 366 | }, 367 | }); 368 | 369 | Deno.test({ 370 | name: "parse nulls if exp is due", 371 | fn() { 372 | const exp: number = Date.now() + 10; 373 | 374 | const token: null | string = a.stringify( 375 | createHeader({ kid: a.kid, exp }), 376 | createBody(), 377 | ); 378 | 379 | assertEquals(typeof token, "string"); 380 | 381 | // NOTE: awaiting token expiry 382 | while (Date.now() < exp) {} 383 | 384 | assertEquals(b.parse(token!), null); 385 | }, 386 | }); 387 | 388 | Deno.test({ 389 | name: "parse nulls if aad is corrupt", 390 | fn(): void { 391 | const inputHeader: bwt.Header = createHeader({ kid: a.kid }); 392 | const inputBody: bwt.Body = createBody(); 393 | 394 | let token: null | string = a.stringify(inputHeader, inputBody); 395 | 396 | const parts: string[] = token!.split("."); 397 | 398 | const headerBuf: Uint8Array = encode(parts[0], "base64url"); 399 | 400 | headerBuf[36] ^= 0x99; 401 | 402 | parts[0] = decode(headerBuf, "base64url"); 403 | 404 | token = parts.join("."); 405 | 406 | assertEquals(b.parse(token), null); 407 | }, 408 | }); 409 | 410 | Deno.test({ 411 | name: "parse nulls if nonce is corrupt", 412 | fn(): void { 413 | const inputHeader: bwt.Header = createHeader({ kid: a.kid }); 414 | const inputBody: bwt.Body = createBody(); 415 | 416 | let token: null | string = a.stringify(inputHeader, inputBody); 417 | 418 | const parts: string[] = token!.split("."); 419 | 420 | const headerBuf: Uint8Array = encode(parts[0], "base64url"); 421 | 422 | headerBuf[headerBuf.byteLength - 1] ^= 0x99; 423 | 424 | parts[0] = decode(headerBuf, "base64url"); 425 | 426 | token = parts.join("."); 427 | 428 | assertEquals(b.parse(token), null); 429 | }, 430 | }); 431 | 432 | Deno.test({ 433 | name: "parse nulls if tag is corrupt", 434 | fn(): void { 435 | const inputHeader: bwt.Header = createHeader({ kid: a.kid }); 436 | const inputBody: bwt.Body = createBody(); 437 | 438 | let token: null | string = a.stringify(inputHeader, inputBody); 439 | 440 | const parts: string[] = token!.split("."); 441 | 442 | let corruptTag: Uint8Array = encode(parts[2], "base64url"); 443 | 444 | corruptTag[0] ^= 0x99; 445 | 446 | parts[2] = decode(corruptTag, "base64url"); 447 | 448 | token = parts.join("."); 449 | 450 | assertEquals(b.parse(token), null); 451 | }, 452 | }); 453 | 454 | Deno.test({ 455 | name: "parse nulls if ciphertext is corrupt", 456 | fn(): void { 457 | const inputHeader: bwt.Header = createHeader({ kid: a.kid }); 458 | const inputBody: bwt.Body = createBody(); 459 | 460 | let token: null | string = a.stringify(inputHeader, inputBody); 461 | 462 | const parts: string[] = token!.split("."); 463 | 464 | let corruptCiphertext: Uint8Array = encode(parts[1], "base64url"); 465 | 466 | corruptCiphertext[0] ^= 0x99; 467 | 468 | parts[1] = decode(corruptCiphertext, "base64url"); 469 | 470 | token = parts.join("."); 471 | 472 | assertEquals(b.parse(token), null); 473 | }, 474 | }); 475 | --------------------------------------------------------------------------------