Skip to content
This repository has been archived by the owner on Nov 20, 2024. It is now read-only.

Commit

Permalink
feat: add jwt utils (#379)
Browse files Browse the repository at this point in the history
  • Loading branch information
DanSnow authored Apr 22, 2024
1 parent 4607e32 commit 3d1cf04
Show file tree
Hide file tree
Showing 18 changed files with 236 additions and 59 deletions.
5 changes: 5 additions & 0 deletions .changeset/silent-moons-boil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storipress/karbon-utils': minor
---

feat: add jwt utils
5 changes: 5 additions & 0 deletions .changeset/tricky-planets-poke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@storipress/karbon': patch
---

refactor: use jwt utils
7 changes: 7 additions & 0 deletions packages/karbon-utils/moon.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
dependsOn:
- jose-browser

tasks:
build:
command: tsup
Expand All @@ -6,8 +9,12 @@ tasks:
- tsup.config.ts
outputs:
- dist/**/*
deps:
- ^:build
test:
command: vitest
inputs:
- src/**/*
- vitest.config.ts
deps:
- build
1 change: 1 addition & 0 deletions packages/karbon-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"dependencies": {
"@noble/ciphers": "^0.5.2",
"@storipress/jose-browser": "workspace:*",
"entities": "^4.5.0"
},
"devDependencies": {
Expand Down
10 changes: 1 addition & 9 deletions packages/karbon-utils/src/__tests__/cipher.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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)
})
})
26 changes: 26 additions & 0 deletions packages/karbon-utils/src/__tests__/encoding.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
38 changes: 38 additions & 0 deletions packages/karbon-utils/src/__tests__/jwt.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
},
)
})
4 changes: 0 additions & 4 deletions packages/karbon-utils/src/cipher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
17 changes: 17 additions & 0 deletions packages/karbon-utils/src/encoding.ts
Original file line number Diff line number Diff line change
@@ -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)
}
15 changes: 15 additions & 0 deletions packages/karbon-utils/src/jwt.ts
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion packages/karbon-utils/src/shared.ts
Original file line number Diff line number Diff line change
@@ -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'
1 change: 1 addition & 0 deletions packages/karbon/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 0 additions & 1 deletion packages/karbon/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
42 changes: 42 additions & 0 deletions packages/karbon/src/runtime/api/__tests__/encrypt-article.spec.ts
Original file line number Diff line number Diff line change
@@ -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`
<div>free paragraph 1</div>
<div>free paragraph 2</div>
<div>paid paragraph 1</div>
<div>paid paragraph 2</div>
<div>paid paragraph 3</div>
`

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`
<div>free paragraph 1</div>
<div>free paragraph 2</div>
`.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)
})
})
43 changes: 7 additions & 36 deletions packages/karbon/src/runtime/api/article.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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'

Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand Down
52 changes: 52 additions & 0 deletions packages/karbon/src/runtime/api/encrypt-article.ts
Original file line number Diff line number Diff line change
@@ -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<Segment> => {
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 }
}
13 changes: 6 additions & 7 deletions packages/karbon/src/runtime/routes/api/decrypt-key.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -53,11 +52,11 @@ export default defineEventHandler(async (event): Promise<ViewableApiResult> => {
}
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')
Expand Down
Loading

0 comments on commit 3d1cf04

Please sign in to comment.