@nuxt-customer-portal/core 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # @nuxt-customer-portal/core
2
2
 
3
+ ## 0.4.3
4
+
5
+ ### Patch Changes
6
+
7
+ - eb2a85c: Add a single base64-encoded `PORTAL_ENCRYPTION_KEY` that derives isolated keys for credentials saved by Portal Core, Products, and Planning. Existing module-specific keys remain supported as overrides and can decrypt their legacy ciphertext formats.
8
+
9
+ ## 0.4.2
10
+
3
11
  ## 0.4.1
4
12
 
5
13
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/core",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'core',
3
- version: '0.4.1',
3
+ version: '0.4.3',
4
4
  source: '@nuxt-customer-portal/core',
5
5
  dependsOn: [],
6
6
  schema: './server/db/schema/index.ts',
@@ -1,5 +1,5 @@
1
1
  import { isPortalDemo, rejectDemoAction } from './demo'
2
- import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
2
+ import { createHash } from 'node:crypto'
3
3
  import { eq } from 'drizzle-orm'
4
4
  import { createError } from 'h3'
5
5
  import { Resend } from 'resend'
@@ -11,6 +11,12 @@ import type {
11
11
  } from '@nuxt-customer-portal/core/shared/types/feature'
12
12
  import { portalEmailSettings } from '@nuxt-customer-portal/core/server/db/schema/auth-schema'
13
13
  import { db, pool } from './db'
14
+ import {
15
+ decryptLegacyDotSecret,
16
+ decryptPortalSecret,
17
+ encryptPortalSecret,
18
+ isPortalSecretCiphertext
19
+ } from './portal-encryption'
14
20
 
15
21
  export type PortalEmailTextOverrides = Record<string, Partial<PortalEmailText>>
16
22
 
@@ -147,37 +153,23 @@ const inlineBrandAsset = (
147
153
  return renderBrandAsset(value, brandName, `cid:${contentId}`)
148
154
  }
149
155
 
150
- const encryptionKey = () => {
151
- const value = process.env.PORTAL_EMAIL_ENCRYPTION_KEY
152
- if (!value) {
153
- throw new Error('PORTAL_EMAIL_ENCRYPTION_KEY is required to store email credentials')
154
- }
155
- return createHash('sha256').update(value).digest()
156
+ const emailEncryption = {
157
+ purpose: 'portal/email',
158
+ overrides: [{ env: 'PORTAL_EMAIL_ENCRYPTION_KEY', format: 'sha256' as const }]
156
159
  }
157
160
 
158
- export const encryptPortalEmailSecret = (value: string) => {
159
- const iv = randomBytes(12)
160
- const cipher = createCipheriv('aes-256-gcm', encryptionKey(), iv)
161
- const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
162
- return [
163
- 'v1',
164
- iv.toString('base64url'),
165
- cipher.getAuthTag().toString('base64url'),
166
- encrypted.toString('base64url')
167
- ].join('.')
168
- }
161
+ export const encryptPortalEmailSecret = (value: string) => encryptPortalSecret(value, emailEncryption)
169
162
 
170
163
  export const decryptPortalEmailSecret = (value: string) => {
171
- const [version, iv, tag, encrypted] = value.split('.')
172
- if (version !== 'v1' || !iv || !tag || !encrypted) {
173
- throw new Error('Stored email credential has an invalid format')
174
- }
175
164
  try {
176
- const decipher = createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(iv, 'base64url'))
177
- decipher.setAuthTag(Buffer.from(tag, 'base64url'))
178
- return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8')
179
- } catch {
180
- throw new Error('Stored email credential could not be decrypted')
165
+ return isPortalSecretCiphertext(value)
166
+ ? decryptPortalSecret(value, emailEncryption)
167
+ : decryptLegacyDotSecret(value, emailEncryption.overrides)
168
+ } catch (error) {
169
+ if (error instanceof Error && (/^Configure /.test(error.message) || /^Retain /.test(error.message))) {
170
+ throw error
171
+ }
172
+ throw new Error('Stored email credential could not be decrypted', { cause: error })
181
173
  }
182
174
  }
183
175
 
@@ -0,0 +1,171 @@
1
+ import { createCipheriv, createDecipheriv, createHash, hkdfSync, randomBytes } from 'node:crypto'
2
+
3
+ export type PortalEncryptionOverride = {
4
+ env: string
5
+ format: 'sha256' | 'base64-32'
6
+ }
7
+
8
+ export type PortalEncryptionOptions = {
9
+ purpose: string
10
+ overrides?: PortalEncryptionOverride[]
11
+ }
12
+
13
+ const VERSION = 'v2'
14
+ const ROOT_KEY_ENV = 'PORTAL_ENCRYPTION_KEY'
15
+
16
+ const decodeBase64Key = (value: string, name: string) => {
17
+ const normalized = value.trim()
18
+ if (!/^[A-Za-z0-9+/_-]+={0,2}$/.test(normalized)) {
19
+ throw new Error(`${name} must be a base64-encoded 32-byte key`)
20
+ }
21
+ const key = Buffer.from(normalized, normalized.includes('-') || normalized.includes('_') ? 'base64url' : 'base64')
22
+ if (key.length !== 32) {
23
+ throw new Error(`${name} must be a base64-encoded 32-byte key`)
24
+ }
25
+ return key
26
+ }
27
+
28
+ const overrideKey = (override: PortalEncryptionOverride) => {
29
+ const value = process.env[override.env]
30
+ if (!value) {
31
+ return null
32
+ }
33
+ return override.format === 'sha256'
34
+ ? createHash('sha256').update(value).digest()
35
+ : decodeBase64Key(value, override.env)
36
+ }
37
+
38
+ const configuredOverride = (overrides: PortalEncryptionOverride[] = []) => {
39
+ for (const override of overrides) {
40
+ const key = overrideKey(override)
41
+ if (key) {
42
+ return { key, source: override.env }
43
+ }
44
+ }
45
+ return null
46
+ }
47
+
48
+ const missingKeyMessage = (overrides: PortalEncryptionOverride[] = []) => {
49
+ const names = [ROOT_KEY_ENV, ...overrides.map(({ env }) => env)]
50
+ return `Configure ${names.join(' or ')}`
51
+ }
52
+
53
+ const resolvePortalEncryptionMaterial = ({ purpose, overrides = [] }: PortalEncryptionOptions) => {
54
+ const override = configuredOverride(overrides)
55
+ if (override) {
56
+ return override
57
+ }
58
+
59
+ const rootValue = process.env.PORTAL_ENCRYPTION_KEY
60
+ if (!rootValue) {
61
+ throw new Error(missingKeyMessage(overrides))
62
+ }
63
+ const rootKey = decodeBase64Key(rootValue, ROOT_KEY_ENV)
64
+ return {
65
+ key: Buffer.from(hkdfSync('sha256', rootKey, Buffer.alloc(0), `nuxt-customer-portal:${purpose}`, 32)),
66
+ source: ROOT_KEY_ENV
67
+ }
68
+ }
69
+
70
+ export const resolvePortalEncryptionKey = (options: PortalEncryptionOptions) =>
71
+ resolvePortalEncryptionMaterial(options).key
72
+
73
+ const materialForSource = (source: string, options: PortalEncryptionOptions) => {
74
+ if (source === ROOT_KEY_ENV) {
75
+ const rootValue = process.env.PORTAL_ENCRYPTION_KEY
76
+ if (!rootValue) {
77
+ throw new Error(`Configure ${ROOT_KEY_ENV} to decrypt this credential`)
78
+ }
79
+ const rootKey = decodeBase64Key(rootValue, ROOT_KEY_ENV)
80
+ return Buffer.from(hkdfSync('sha256', rootKey, Buffer.alloc(0), `nuxt-customer-portal:${options.purpose}`, 32))
81
+ }
82
+ const override = options.overrides?.find(({ env }) => env === source)
83
+ if (!override) {
84
+ throw new Error('Stored credential uses an unsupported encryption key source')
85
+ }
86
+ const key = overrideKey(override)
87
+ if (!key) {
88
+ throw new Error(`Configure ${source} to decrypt this credential`)
89
+ }
90
+ return key
91
+ }
92
+
93
+ const associatedData = (purpose: string, source: string) =>
94
+ Buffer.from(`nuxt-customer-portal:${VERSION}:${purpose}:${source}`)
95
+
96
+ export const encryptPortalSecret = (value: string, options: PortalEncryptionOptions) => {
97
+ const iv = randomBytes(12)
98
+ const { key, source } = resolvePortalEncryptionMaterial(options)
99
+ const cipher = createCipheriv('aes-256-gcm', key, iv)
100
+ cipher.setAAD(associatedData(options.purpose, source))
101
+ const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
102
+ return [
103
+ VERSION,
104
+ Buffer.from(options.purpose).toString('base64url'),
105
+ Buffer.from(source).toString('base64url'),
106
+ iv.toString('base64url'),
107
+ cipher.getAuthTag().toString('base64url'),
108
+ encrypted.toString('base64url')
109
+ ].join('.')
110
+ }
111
+
112
+ export const isPortalSecretCiphertext = (value: string) => value.startsWith(`${VERSION}.`)
113
+
114
+ export const decryptPortalSecret = (value: string, options: PortalEncryptionOptions) => {
115
+ const [version, encodedPurpose, encodedSource, iv, tag, encrypted] = value.split('.')
116
+ if (version !== VERSION || !encodedPurpose || !encodedSource || !iv || !tag || !encrypted) {
117
+ throw new Error('Stored credential has an invalid encryption format')
118
+ }
119
+ const purpose = Buffer.from(encodedPurpose, 'base64url').toString('utf8')
120
+ if (purpose !== options.purpose) {
121
+ throw new Error('Stored credential belongs to a different encryption purpose')
122
+ }
123
+ const source = Buffer.from(encodedSource, 'base64url').toString('utf8')
124
+ const key = materialForSource(source, options)
125
+ try {
126
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
127
+ decipher.setAAD(associatedData(options.purpose, source))
128
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'))
129
+ return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8')
130
+ } catch {
131
+ throw new Error('Stored credential could not be decrypted')
132
+ }
133
+ }
134
+
135
+ const requireLegacyOverrideKey = (overrides: PortalEncryptionOverride[]) => {
136
+ const material = configuredOverride(overrides)
137
+ if (material) {
138
+ return material.key
139
+ }
140
+ throw new Error(`Retain ${overrides.map(({ env }) => env).join(' or ')} to decrypt legacy credentials`)
141
+ }
142
+
143
+ export const decryptLegacyDotSecret = (value: string, overrides: PortalEncryptionOverride[]) => {
144
+ const [version, iv, tag, encrypted] = value.split('.')
145
+ if (version !== 'v1' || !iv || !tag || !encrypted) {
146
+ throw new Error('Stored credential has an invalid encryption format')
147
+ }
148
+ const key = requireLegacyOverrideKey(overrides)
149
+ try {
150
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64url'))
151
+ decipher.setAuthTag(Buffer.from(tag, 'base64url'))
152
+ return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8')
153
+ } catch {
154
+ throw new Error('Stored credential could not be decrypted')
155
+ }
156
+ }
157
+
158
+ export const decryptLegacyBinarySecret = (value: string, overrides: PortalEncryptionOverride[]) => {
159
+ const bytes = Buffer.from(value, 'base64')
160
+ if (bytes.length < 29) {
161
+ throw new Error('Stored credential has an invalid encryption format')
162
+ }
163
+ const key = requireLegacyOverrideKey(overrides)
164
+ try {
165
+ const decipher = createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12))
166
+ decipher.setAuthTag(bytes.subarray(12, 28))
167
+ return Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8')
168
+ } catch {
169
+ throw new Error('Stored credential could not be decrypted')
170
+ }
171
+ }