From 3d1cf04324f7a4edf9a3db6e32510f72ddb49d0e Mon Sep 17 00:00:00 2001 From: David Peng Date: Mon, 22 Apr 2024 17:01:24 +0800 Subject: [PATCH] feat: add jwt utils (#379) --- .changeset/silent-moons-boil.md | 5 ++ .changeset/tricky-planets-poke.md | 5 ++ packages/karbon-utils/moon.yml | 7 +++ packages/karbon-utils/package.json | 1 + .../karbon-utils/src/__tests__/cipher.spec.ts | 10 +--- .../src/__tests__/encoding.spec.ts | 26 ++++++++++ .../karbon-utils/src/__tests__/jwt.spec.ts | 38 ++++++++++++++ packages/karbon-utils/src/cipher.ts | 4 -- packages/karbon-utils/src/encoding.ts | 17 ++++++ packages/karbon-utils/src/jwt.ts | 15 ++++++ packages/karbon-utils/src/shared.ts | 4 +- packages/karbon/package.json | 1 + packages/karbon/src/internal.ts | 1 - .../api/__tests__/encrypt-article.spec.ts | 42 +++++++++++++++ packages/karbon/src/runtime/api/article.ts | 43 +++------------ .../karbon/src/runtime/api/encrypt-article.ts | 52 +++++++++++++++++++ .../src/runtime/routes/api/decrypt-key.ts | 13 +++-- yarn.lock | 11 +++- 18 files changed, 236 insertions(+), 59 deletions(-) create mode 100644 .changeset/silent-moons-boil.md create mode 100644 .changeset/tricky-planets-poke.md create mode 100644 packages/karbon-utils/src/__tests__/encoding.spec.ts create mode 100644 packages/karbon-utils/src/__tests__/jwt.spec.ts create mode 100644 packages/karbon-utils/src/encoding.ts create mode 100644 packages/karbon-utils/src/jwt.ts create mode 100644 packages/karbon/src/runtime/api/__tests__/encrypt-article.spec.ts create mode 100644 packages/karbon/src/runtime/api/encrypt-article.ts diff --git a/.changeset/silent-moons-boil.md b/.changeset/silent-moons-boil.md new file mode 100644 index 00000000..8e9b3606 --- /dev/null +++ b/.changeset/silent-moons-boil.md @@ -0,0 +1,5 @@ +--- +'@storipress/karbon-utils': minor +--- + +feat: add jwt utils diff --git a/.changeset/tricky-planets-poke.md b/.changeset/tricky-planets-poke.md new file mode 100644 index 00000000..7362d16c --- /dev/null +++ b/.changeset/tricky-planets-poke.md @@ -0,0 +1,5 @@ +--- +'@storipress/karbon': patch +--- + +refactor: use jwt utils diff --git a/packages/karbon-utils/moon.yml b/packages/karbon-utils/moon.yml index 842dc0ce..f0053038 100644 --- a/packages/karbon-utils/moon.yml +++ b/packages/karbon-utils/moon.yml @@ -1,3 +1,6 @@ +dependsOn: + - jose-browser + tasks: build: command: tsup @@ -6,8 +9,12 @@ tasks: - tsup.config.ts outputs: - dist/**/* + deps: + - ^:build test: command: vitest inputs: - src/**/* - vitest.config.ts + deps: + - build diff --git a/packages/karbon-utils/package.json b/packages/karbon-utils/package.json index 2e5efe60..9e7ae4f7 100644 --- a/packages/karbon-utils/package.json +++ b/packages/karbon-utils/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@noble/ciphers": "^0.5.2", + "@storipress/jose-browser": "workspace:*", "entities": "^4.5.0" }, "devDependencies": { diff --git a/packages/karbon-utils/src/__tests__/cipher.spec.ts b/packages/karbon-utils/src/__tests__/cipher.spec.ts index a2db1909..3aeaba33 100644 --- a/packages/karbon-utils/src/__tests__/cipher.spec.ts +++ b/packages/karbon-utils/src/__tests__/cipher.spec.ts @@ -1,9 +1,8 @@ import '../crypto-polyfill' -import { Buffer } from 'node:buffer' import { describe, expect } from 'vitest' import { fc, it } from '@fast-check/vitest' -import { base64ToUint8Array, createDecrypt, createEncrypt } from '../cipher' +import { createDecrypt, createEncrypt } from '../cipher' describe('createEncrypt & createDecrypt', () => { it.prop({ plaintext: fc.string() })('can encrypt and decrypt', async ({ plaintext }) => { @@ -13,10 +12,3 @@ describe('createEncrypt & createDecrypt', () => { await expect(decrypt(content)).resolves.toBe(plaintext) }) }) - -describe('base64ToUint8Array', () => { - it.prop({ s: fc.string() })('can convert base64 to Uint8Array', ({ s }) => { - const base64 = Buffer.from(s).toString('base64') - expect(Buffer.from(base64ToUint8Array(base64)).toString()).toEqual(s) - }) -}) diff --git a/packages/karbon-utils/src/__tests__/encoding.spec.ts b/packages/karbon-utils/src/__tests__/encoding.spec.ts new file mode 100644 index 00000000..000e9b4c --- /dev/null +++ b/packages/karbon-utils/src/__tests__/encoding.spec.ts @@ -0,0 +1,26 @@ +import { Buffer } from 'node:buffer' +import { describe, expect } from 'vitest' +import { fc, it } from '@fast-check/vitest' +import { base64ToUint8Array, textToUint8Array, uint8ArrayToBase64, uint8ArrayToText } from '../encoding' + +describe('textToUint8Array & uint8ArrayToText', () => { + it.prop({ text: fc.string() })('can convert text to and from uint8array', ({ text }) => { + const array = textToUint8Array(text) + const result = uint8ArrayToText(array) + expect(result).toBe(text) + }) +}) + +describe('base64ToUint8Array', () => { + it.prop({ s: fc.string() })('can convert base64 to Uint8Array', ({ s }) => { + const base64 = Buffer.from(s).toString('base64') + expect(Buffer.from(base64ToUint8Array(base64)).toString()).toEqual(s) + }) +}) + +describe('base64ToUint8Array & uint8ArrayToBase64', () => { + it.prop({ s: fc.string() })('can convert base64 string from and to uint8array', ({ s }) => { + const base64 = Buffer.from(s).toString('base64') + expect(uint8ArrayToBase64(base64ToUint8Array(base64))).toEqual(base64) + }) +}) diff --git a/packages/karbon-utils/src/__tests__/jwt.spec.ts b/packages/karbon-utils/src/__tests__/jwt.spec.ts new file mode 100644 index 00000000..2684ea6b --- /dev/null +++ b/packages/karbon-utils/src/__tests__/jwt.spec.ts @@ -0,0 +1,38 @@ +import '../crypto-polyfill' +import { describe, expect } from 'vitest' +import { fc, it } from '@fast-check/vitest' +import { createEncryptJWT, decryptJWT } from '../jwt' + +describe('createEncryptJWT', () => { + // Returns an encrypted JWT string when given a valid key and plaintext + it('should return an encrypted JWT string when given a valid key and plaintext', async () => { + const key = new Uint8Array(32) + const plaintext = 'Hello, world!' + const result = await createEncryptJWT(key, plaintext) + expect(typeof result).toBe('string') + }) + + // Returns an error when given a key of incorrect length + it('should return an error when given a key of incorrect length', async () => { + const key = new Uint8Array(16) + const plaintext = 'Hello, world!' + await expect(createEncryptJWT(key, plaintext)).rejects.toThrowError() + }) +}) + +describe('createEncryptJWT & decryptJWT', () => { + it.prop({ plaintext: fc.string(), key: fc.uint8Array({ minLength: 32, maxLength: 32 }) })( + 'can encrypt and decrypt', + async ({ plaintext, key }) => { + const result = await createEncryptJWT(key, plaintext) + + expect(typeof result).toBe('string') + + expect(result).not.toBe(plaintext) + + const decrypted = await decryptJWT(key, result) + + expect(decrypted).toBe(plaintext) + }, + ) +}) diff --git a/packages/karbon-utils/src/cipher.ts b/packages/karbon-utils/src/cipher.ts index 481dccda..948feeba 100644 --- a/packages/karbon-utils/src/cipher.ts +++ b/packages/karbon-utils/src/cipher.ts @@ -26,7 +26,3 @@ export function createDecrypt(key: Uint8Array, iv: Uint8Array) { }, } } - -export function base64ToUint8Array(s: string) { - return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) -} diff --git a/packages/karbon-utils/src/encoding.ts b/packages/karbon-utils/src/encoding.ts new file mode 100644 index 00000000..cf28ccdc --- /dev/null +++ b/packages/karbon-utils/src/encoding.ts @@ -0,0 +1,17 @@ +export function base64ToUint8Array(s: string) { + return Uint8Array.from(atob(s), (c) => c.charCodeAt(0)) +} + +export function uint8ArrayToBase64(array: Uint8Array) { + return btoa(String.fromCharCode(...array)) +} + +export function textToUint8Array(text: string) { + const encoder = new TextEncoder() + return encoder.encode(text) +} + +export function uint8ArrayToText(array: Uint8Array) { + const decoder = new TextDecoder() + return decoder.decode(array) +} diff --git a/packages/karbon-utils/src/jwt.ts b/packages/karbon-utils/src/jwt.ts new file mode 100644 index 00000000..bc3a71e5 --- /dev/null +++ b/packages/karbon-utils/src/jwt.ts @@ -0,0 +1,15 @@ +import { CompactEncrypt, compactDecrypt } from '@storipress/jose-browser' +import { textToUint8Array, uint8ArrayToText } from './encoding' + +export async function createEncryptJWT(key: Uint8Array, plaintext: string) { + const compactEncrypt = new CompactEncrypt(textToUint8Array(plaintext)).setProtectedHeader({ + enc: 'A256GCM', + alg: 'dir', + }) + return await compactEncrypt.encrypt(key) +} + +export async function decryptJWT(key: Uint8Array, jwt: string) { + const { plaintext } = await compactDecrypt(jwt, key) + return uint8ArrayToText(plaintext) +} diff --git a/packages/karbon-utils/src/shared.ts b/packages/karbon-utils/src/shared.ts index dd145333..b67947b9 100644 --- a/packages/karbon-utils/src/shared.ts +++ b/packages/karbon-utils/src/shared.ts @@ -1,2 +1,4 @@ -export { createEncrypt, base64ToUint8Array, createDecrypt } from './cipher' +export { createEncrypt, createDecrypt } from './cipher' export { filterHTMLTag } from './html-filter' +export * from './jwt' +export * from './encoding' diff --git a/packages/karbon/package.json b/packages/karbon/package.json index 178a75b7..e00c8fd9 100644 --- a/packages/karbon/package.json +++ b/packages/karbon/package.json @@ -147,6 +147,7 @@ "msw": "2.2.13", "nuxt": "3.7.3", "prettier": "3.2.5", + "proper-tags": "^2.0.2", "tsup": "8.0.2", "tsx": "4.7.2", "typescript": "5.4.5", diff --git a/packages/karbon/src/internal.ts b/packages/karbon/src/internal.ts index 0b3d37ec..4f7e077d 100644 --- a/packages/karbon/src/internal.ts +++ b/packages/karbon/src/internal.ts @@ -15,5 +15,4 @@ export { getDeskWithSlug, listFeedArticles } from './runtime/api/feed' export { getDesk, listDesks } from './runtime/api/desk' export { getTag, listTags } from './runtime/api/tag' export { getAuthor, listAuthors } from './runtime/api/author' -export { compactDecrypt } from '@storipress/jose-browser' export { getResources, payloadScopes } from './runtime/api/sitemap' diff --git a/packages/karbon/src/runtime/api/__tests__/encrypt-article.spec.ts b/packages/karbon/src/runtime/api/__tests__/encrypt-article.spec.ts new file mode 100644 index 00000000..7fe85e5e --- /dev/null +++ b/packages/karbon/src/runtime/api/__tests__/encrypt-article.spec.ts @@ -0,0 +1,42 @@ +import { decryptJWT, uint8ArrayToBase64 } from '@storipress/karbon-utils' +import { describe, expect, it } from 'vitest' +import { html } from 'proper-tags' +import { encryptArticle } from '../encrypt-article' +import { ArticlePlan } from '../../types' +import { splitArticle } from '../../lib/split-article' + +const HTML_FIXTURE = html` +
free paragraph 1
+
free paragraph 2
+
paid paragraph 1
+
paid paragraph 2
+
paid paragraph 3
+` + +describe('encryptArticle', () => { + it('should encrypt article', async () => { + const key = new Uint8Array(32).fill(0) + const encryptKey = uint8ArrayToBase64(key) + + const article = await encryptArticle({ + id: '1', + encryptKey, + html: HTML_FIXTURE, + plan: ArticlePlan.Member, + segments: splitArticle(HTML_FIXTURE), + }) + + expect(article.freeHTML).toBe( + html` +
free paragraph 1
+
free paragraph 2
+ `.trim(), + ) + + const meta = JSON.parse(await decryptJWT(key, article.paidContent.key)) + expect(meta.id).toBe('1') + expect(meta.plan).toBe(ArticlePlan.Member) + expect(typeof meta.key).toBe('string') + expect(article.segments).toBeInstanceOf(Array) + }) +}) diff --git a/packages/karbon/src/runtime/api/article.ts b/packages/karbon/src/runtime/api/article.ts index 3c5e8577..c503f499 100644 --- a/packages/karbon/src/runtime/api/article.ts +++ b/packages/karbon/src/runtime/api/article.ts @@ -1,17 +1,12 @@ -import { Buffer } from 'node:buffer' import util from 'node:util' import type { ZodError } from 'zod' import { gql } from '@apollo/client/core/index.js' -import { createEncrypt } from '@storipress/karbon-utils' import type { SearchResponse } from '@storipress/typesense-xior' // This file contains global crypto polyfill -import { CompactEncrypt } from '@storipress/jose-browser' import { useStoripressClient } from '../composables/storipress-client' import type { TypesenseFilter } from '../composables/typesense-client' import { getSearchQuery, useTypesenseClient } from '../composables/typesense-client' -import { splitPaidContent } from '../lib/split-paid-content' -import type { NormalSegment } from '../lib/split-article' import { splitArticle } from '../lib/split-article' import { _karbonClientHooks, getStoripressConfig } from '../composables/storipress-base-client' import { verboseInvariant } from '../utils/verbose-invariant' @@ -20,6 +15,7 @@ import { ArticleSchema } from './schema/typesense-article' import { QueryArticleSchema } from './schema/query-article' import { normalizeArticle } from './normalize-article' import { listArticlesFromTypesense } from './list-articles' +import { encryptArticle } from './encrypt-article' export type { NormalizeArticle, PaidContent } from './normalize-article' @@ -332,11 +328,11 @@ export async function getArticle(id: string) { ) } } - const res = await encryptArticle(normalizeArticle(data.article)) + const res = await maybeEncryptArticle(normalizeArticle(data.article)) return res } -async function encryptArticle({ plan, html, id, ...rest }: _NormalizeArticle) { +async function maybeEncryptArticle({ plan, html, id, ...rest }: _NormalizeArticle) { let freeHTML = html let paidContent: PaidContent | undefined @@ -346,35 +342,10 @@ async function encryptArticle({ plan, html, id, ...rest }: _NormalizeArticle) { const storipress = getStoripressConfig() verboseInvariant(storipress.encryptKey, 'No encrypt key') const previewParagraph = storipress.previewParagraph ?? 3 - const [preview, paid] = splitPaidContent(html, storipress.previewParagraph ?? 3) - freeHTML = preview - - const { key, content, iv, encrypt } = await createEncrypt(paid) - - const compactEncrypt = new CompactEncrypt( - Buffer.from(JSON.stringify({ id, plan, key: Buffer.from(key).toString('base64') })), - ).setProtectedHeader({ enc: 'A256GCM', alg: 'dir' }) - const encryptedKey = await compactEncrypt.encrypt(Buffer.from(storipress.encryptKey, 'base64')) - paidContent = { - key: encryptedKey, - content: Buffer.from(content).toString('base64'), - iv: Buffer.from(iv).toString('base64'), - } - - segments = await Promise.all( - segments.map(async (segment, index, source) => { - const html = (segment as NormalSegment).html - const noEncrypt = html === undefined || (index < previewParagraph && source.length > previewParagraph) - if (noEncrypt) return segment - - const content = await encrypt(html) - return { - id: 'paid', - type: segment.type, - paidContent: Buffer.from(content).toString('base64'), - } - }), - ) + const res = await encryptArticle({ html, id, plan, segments, encryptKey: storipress.encryptKey, previewParagraph }) + freeHTML = res.freeHTML + paidContent = res.paidContent + segments = res.segments } return { diff --git a/packages/karbon/src/runtime/api/encrypt-article.ts b/packages/karbon/src/runtime/api/encrypt-article.ts new file mode 100644 index 00000000..533dc871 --- /dev/null +++ b/packages/karbon/src/runtime/api/encrypt-article.ts @@ -0,0 +1,52 @@ +import { base64ToUint8Array, createEncrypt, createEncryptJWT, uint8ArrayToBase64 } from '@storipress/karbon-utils' +import { splitPaidContent } from '../lib/split-paid-content' +import type { NormalSegment, Segment } from '../lib/split-article' +import type { ArticlePlan } from '../types' + +export interface EncryptArticleInput { + id: string + html: string + plan: ArticlePlan + segments: Segment[] + encryptKey: string + previewParagraph?: number +} +export async function encryptArticle({ + html, + id, + plan, + segments, + encryptKey, + previewParagraph = 3, +}: EncryptArticleInput) { + const [preview, paid] = splitPaidContent(html, previewParagraph) + const freeHTML = preview + + const { key, content, iv, encrypt } = await createEncrypt(paid) + + const encryptedKey = await createEncryptJWT( + base64ToUint8Array(encryptKey), + JSON.stringify({ id, plan, key: uint8ArrayToBase64(key) }), + ) + const paidContent = { + key: encryptedKey, + content: uint8ArrayToBase64(content), + iv: uint8ArrayToBase64(iv), + } + + const encryptedSegments = await Promise.all( + segments.map(async (segment, index, source): Promise => { + const html = (segment as NormalSegment).html + const noEncrypt = html === undefined || (index < previewParagraph && source.length > previewParagraph) + if (noEncrypt) return segment + + const content = await encrypt(html) + return { + id: 'paid', + type: segment.type, + paidContent: uint8ArrayToBase64(content), + } + }), + ) + return { freeHTML, paidContent, segments: encryptedSegments } +} diff --git a/packages/karbon/src/runtime/routes/api/decrypt-key.ts b/packages/karbon/src/runtime/routes/api/decrypt-key.ts index baa84ae9..89cc7d73 100644 --- a/packages/karbon/src/runtime/routes/api/decrypt-key.ts +++ b/packages/karbon/src/runtime/routes/api/decrypt-key.ts @@ -1,8 +1,7 @@ import { Buffer } from 'node:buffer' import { destr } from 'destr' - -// @ts-expect-error self reference -import { DECRYPT_AUTH_HEADER, DECRYPT_KEY_HEADER, compactDecrypt } from '@storipress/karbon/internal' +import { base64ToUint8Array, decryptJWT } from '@storipress/karbon-utils' +import { DECRYPT_AUTH_HEADER, DECRYPT_KEY_HEADER } from '@storipress/karbon/internal' import { defineEventHandler, getHeader, isMethod, readBody, setResponseStatus } from 'h3' import type { DetailedViewableResult, @@ -53,11 +52,11 @@ export default defineEventHandler(async (event): Promise => { } let meta: DecryptedKey try { + const encryptKey = (storipress as any).encryptKey as string // eslint-disable-next-line no-console - console.log('encrypt key', storipress.encryptKey) - const { plaintext } = await compactDecrypt(key, Buffer.from(storipress.encryptKey, 'base64')) - const decoder = new TextDecoder() - meta = destr(decoder.decode(plaintext)) + console.log('encrypt key', encryptKey) + const plaintext = await decryptJWT(base64ToUint8Array(encryptKey), key) + meta = destr(plaintext) // eslint-disable-next-line no-console console.log('decrypt meta:', meta) verboseInvariant(meta.id && meta.plan && meta.key, 'invalid body') diff --git a/yarn.lock b/yarn.lock index 10e750d5..439a68e4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3176,7 +3176,7 @@ __metadata: languageName: unknown linkType: soft -"@storipress/jose-browser@npm:^1.1.5, @storipress/jose-browser@workspace:packages/jose-browser": +"@storipress/jose-browser@npm:^1.1.5, @storipress/jose-browser@workspace:*, @storipress/jose-browser@workspace:packages/jose-browser": version: 0.0.0-use.local resolution: "@storipress/jose-browser@workspace:packages/jose-browser" dependencies: @@ -3293,6 +3293,7 @@ __metadata: "@fast-check/vitest": "npm:0.1.0" "@moonrepo/cli": "npm:1.23.4" "@noble/ciphers": "npm:^0.5.2" + "@storipress/jose-browser": "workspace:*" entities: "npm:^4.5.0" fast-check: "npm:3.17.2" happy-dom: "npm:14.7.1" @@ -3379,6 +3380,7 @@ __metadata: prettier: "npm:3.2.5" pretty-bytes: "npm:^6.1.1" prismjs: "npm:^1.29.0" + proper-tags: "npm:^2.0.2" remeda: "npm:^1.58.1" sass: "npm:^1.75.0" scule: "npm:^1.3.0" @@ -13746,6 +13748,13 @@ __metadata: languageName: node linkType: hard +"proper-tags@npm:^2.0.2": + version: 2.0.2 + resolution: "proper-tags@npm:2.0.2" + checksum: 10/f0c0014ccedb51bb15d6ff1eb5855c943c010c8d8786968a49b51c6b1e45c2199937d751c86cfc9a71e99ca9ee7a3bd81ade1755617fe1d3319a1155c0316eb5 + languageName: node + linkType: hard + "property-expr@npm:^2.0.5": version: 2.0.5 resolution: "property-expr@npm:2.0.5"