@nuxt-customer-portal/core 0.4.2 → 0.4.4
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,17 @@
|
|
|
1
1
|
# @nuxt-customer-portal/core
|
|
2
2
|
|
|
3
|
+
## 0.4.4
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 67b5b80: Assemble authenticated OpenAPI documents through Nitro's local request context so HTTPS frontends do not break the raw schema request.
|
|
8
|
+
|
|
9
|
+
## 0.4.3
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 0.4.2
|
|
4
16
|
|
|
5
17
|
## 0.4.1
|
package/package.json
CHANGED
package/portal.manifest.mjs
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { fetchWithEvent, getRequestURL } from 'h3'
|
|
2
1
|
import { auth } from '../../utils/auth'
|
|
3
2
|
import { mergeBetterAuthOpenApi } from '../../utils/openapi-auth'
|
|
4
3
|
import { enrichOpenApiContracts } from '../../utils/openapi-contracts'
|
|
@@ -15,8 +14,7 @@ defineRouteMeta({
|
|
|
15
14
|
})
|
|
16
15
|
|
|
17
16
|
export default defineEventHandler(async (event) => {
|
|
18
|
-
const
|
|
19
|
-
const document = (await response.json()) as OpenApiDocument
|
|
17
|
+
const document = await event.$fetch<OpenApiDocument>('/api-docs/openapi.raw.json')
|
|
20
18
|
const authDocument = (await auth.api.generateOpenAPISchema()) as unknown as OpenApiDocument
|
|
21
19
|
return orderOpenApiDocument(enrichOpenApiContracts(mergeBetterAuthOpenApi(document, authDocument)))
|
|
22
20
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPortalDemo, rejectDemoAction } from './demo'
|
|
2
|
-
import {
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
} catch {
|
|
180
|
-
|
|
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
|
+
}
|