@djangocfg/crypto 2.1.111

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/dist/react.mjs ADDED
@@ -0,0 +1,279 @@
1
+ "use client";
2
+ var __defProp = Object.defineProperty;
3
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
4
+
5
+ // src/react/hooks.ts
6
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
+
8
+ // src/types.ts
9
+ function isEncryptedField(value) {
10
+ if (typeof value !== "object" || value === null) return false;
11
+ const obj = value;
12
+ return obj.encrypted === true && typeof obj.algorithm === "string" && typeof obj.iv === "string" && typeof obj.data === "string" && typeof obj.auth_tag === "string";
13
+ }
14
+ __name(isEncryptedField, "isEncryptedField");
15
+ function isEncryptedResponse(value) {
16
+ if (typeof value !== "object" || value === null) return false;
17
+ const obj = value;
18
+ return obj.encrypted === true && typeof obj.algorithm === "string" && typeof obj.salt === "string" && typeof obj.iv === "string" && typeof obj.data === "string" && typeof obj.auth_tag === "string";
19
+ }
20
+ __name(isEncryptedResponse, "isEncryptedResponse");
21
+
22
+ // src/key-derivation.ts
23
+ async function deriveKey(password, salt, iterations = 1e5, keyLength = 32) {
24
+ const encoder = new TextEncoder();
25
+ const passwordBuffer = encoder.encode(password);
26
+ const keyMaterial = await crypto.subtle.importKey(
27
+ "raw",
28
+ passwordBuffer,
29
+ "PBKDF2",
30
+ false,
31
+ ["deriveBits", "deriveKey"]
32
+ );
33
+ return crypto.subtle.deriveKey(
34
+ {
35
+ name: "PBKDF2",
36
+ salt: salt.buffer,
37
+ iterations,
38
+ hash: "SHA-256"
39
+ },
40
+ keyMaterial,
41
+ { name: "AES-GCM", length: keyLength * 8 },
42
+ false,
43
+ ["decrypt"]
44
+ );
45
+ }
46
+ __name(deriveKey, "deriveKey");
47
+ async function buildSalt(keyPrefix = "djangocfg_encryption", userId, sessionId) {
48
+ const parts = [keyPrefix];
49
+ if (sessionId) {
50
+ parts.push(`session:${sessionId}`);
51
+ } else if (userId !== void 0) {
52
+ parts.push(`user:${userId}`);
53
+ } else {
54
+ parts.push("global");
55
+ }
56
+ const saltInput = parts.join(":");
57
+ const encoder = new TextEncoder();
58
+ const inputBuffer = encoder.encode(saltInput);
59
+ const hashBuffer = await crypto.subtle.digest("SHA-256", inputBuffer);
60
+ return new Uint8Array(hashBuffer).slice(0, 16);
61
+ }
62
+ __name(buildSalt, "buildSalt");
63
+ async function deriveKeyFromConfig(config) {
64
+ const {
65
+ secretKey,
66
+ userId,
67
+ sessionId,
68
+ iterations = 1e5,
69
+ keyPrefix = "djangocfg_encryption"
70
+ } = config;
71
+ const salt = await buildSalt(keyPrefix, userId, sessionId);
72
+ return deriveKey(secretKey, salt, iterations);
73
+ }
74
+ __name(deriveKeyFromConfig, "deriveKeyFromConfig");
75
+
76
+ // src/decryption.ts
77
+ function base64ToBytes(base64) {
78
+ const binary = atob(base64);
79
+ const bytes = new Uint8Array(binary.length);
80
+ for (let i = 0; i < binary.length; i++) {
81
+ bytes[i] = binary.charCodeAt(i);
82
+ }
83
+ return bytes;
84
+ }
85
+ __name(base64ToBytes, "base64ToBytes");
86
+ async function decryptAES256GCM(ciphertext, key, iv, authTag) {
87
+ const combined = new Uint8Array(ciphertext.length + authTag.length);
88
+ combined.set(ciphertext);
89
+ combined.set(authTag, ciphertext.length);
90
+ const decrypted = await crypto.subtle.decrypt(
91
+ {
92
+ name: "AES-GCM",
93
+ iv: iv.buffer,
94
+ tagLength: 128
95
+ // 16 bytes = 128 bits
96
+ },
97
+ key,
98
+ combined
99
+ );
100
+ return new Uint8Array(decrypted);
101
+ }
102
+ __name(decryptAES256GCM, "decryptAES256GCM");
103
+ async function decryptField(field, key) {
104
+ if (field.algorithm !== "AES-256-GCM") {
105
+ throw new Error(`Unsupported algorithm: ${field.algorithm}`);
106
+ }
107
+ const iv = base64ToBytes(field.iv);
108
+ const ciphertext = base64ToBytes(field.data);
109
+ const authTag = base64ToBytes(field.auth_tag);
110
+ const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag);
111
+ const text = new TextDecoder().decode(decrypted);
112
+ return JSON.parse(text);
113
+ }
114
+ __name(decryptField, "decryptField");
115
+ async function decryptObject(data, key) {
116
+ if (data === null || data === void 0) {
117
+ return data;
118
+ }
119
+ if (isEncryptedField(data)) {
120
+ return decryptField(data, key);
121
+ }
122
+ if (Array.isArray(data)) {
123
+ const decrypted = await Promise.all(
124
+ data.map((item) => decryptObject(item, key))
125
+ );
126
+ return decrypted;
127
+ }
128
+ if (typeof data === "object") {
129
+ const result = {};
130
+ const entries = Object.entries(data);
131
+ for (const [objKey, value] of entries) {
132
+ result[objKey] = await decryptObject(value, key);
133
+ }
134
+ return result;
135
+ }
136
+ return data;
137
+ }
138
+ __name(decryptObject, "decryptObject");
139
+ async function createDecryptionClient(config) {
140
+ const key = await deriveKeyFromConfig(config);
141
+ return {
142
+ /**
143
+ * Decrypt a single encrypted field.
144
+ */
145
+ decryptField: /* @__PURE__ */ __name((field) => decryptField(field, key), "decryptField"),
146
+ /**
147
+ * Recursively decrypt all encrypted fields in an object.
148
+ */
149
+ decryptObject: /* @__PURE__ */ __name((data) => decryptObject(data, key), "decryptObject"),
150
+ /**
151
+ * Check if a value is an encrypted field.
152
+ */
153
+ isEncryptedField,
154
+ /**
155
+ * Check if a value is an encrypted response.
156
+ */
157
+ isEncryptedResponse
158
+ };
159
+ }
160
+ __name(createDecryptionClient, "createDecryptionClient");
161
+
162
+ // src/react/hooks.ts
163
+ function useDecrypt(encryptedData, config) {
164
+ const [state, setState] = useState({
165
+ data: void 0,
166
+ isLoading: true,
167
+ error: void 0,
168
+ isDecrypted: false
169
+ });
170
+ const keyRef = useRef(null);
171
+ const configRef = useRef(config);
172
+ const configChanged = configRef.current.secretKey !== config.secretKey || configRef.current.userId !== config.userId || configRef.current.sessionId !== config.sessionId;
173
+ if (configChanged) {
174
+ configRef.current = config;
175
+ keyRef.current = null;
176
+ }
177
+ useEffect(() => {
178
+ let cancelled = false;
179
+ async function decrypt() {
180
+ try {
181
+ setState((s) => ({ ...s, isLoading: true, error: void 0 }));
182
+ if (!keyRef.current) {
183
+ keyRef.current = await deriveKeyFromConfig(config);
184
+ }
185
+ const decrypted = await decryptObject(encryptedData, keyRef.current);
186
+ if (!cancelled) {
187
+ setState({
188
+ data: decrypted,
189
+ isLoading: false,
190
+ error: void 0,
191
+ isDecrypted: true
192
+ });
193
+ }
194
+ } catch (err) {
195
+ if (!cancelled) {
196
+ setState({
197
+ data: void 0,
198
+ isLoading: false,
199
+ error: err instanceof Error ? err : new Error("Decryption failed"),
200
+ isDecrypted: false
201
+ });
202
+ }
203
+ }
204
+ }
205
+ __name(decrypt, "decrypt");
206
+ decrypt();
207
+ return () => {
208
+ cancelled = true;
209
+ };
210
+ }, [encryptedData, config.secretKey, config.userId, config.sessionId]);
211
+ return state;
212
+ }
213
+ __name(useDecrypt, "useDecrypt");
214
+ function useDecryptionClient(config) {
215
+ const [client, setClient] = useState(null);
216
+ useEffect(() => {
217
+ createDecryptionClient(config).then(setClient);
218
+ }, [config.secretKey, config.userId, config.sessionId]);
219
+ return client;
220
+ }
221
+ __name(useDecryptionClient, "useDecryptionClient");
222
+ function useLazyDecrypt(config) {
223
+ const [state, setState] = useState({
224
+ data: void 0,
225
+ isLoading: false,
226
+ error: void 0,
227
+ isDecrypted: false
228
+ });
229
+ const keyRef = useRef(null);
230
+ const decrypt = useCallback(
231
+ async (encryptedData) => {
232
+ try {
233
+ setState((s) => ({ ...s, isLoading: true, error: void 0 }));
234
+ if (!keyRef.current) {
235
+ keyRef.current = await deriveKeyFromConfig(config);
236
+ }
237
+ const decrypted = await decryptObject(encryptedData, keyRef.current);
238
+ setState({
239
+ data: decrypted,
240
+ isLoading: false,
241
+ error: void 0,
242
+ isDecrypted: true
243
+ });
244
+ return decrypted;
245
+ } catch (err) {
246
+ const error = err instanceof Error ? err : new Error("Decryption failed");
247
+ setState({
248
+ data: void 0,
249
+ isLoading: false,
250
+ error,
251
+ isDecrypted: false
252
+ });
253
+ return void 0;
254
+ }
255
+ },
256
+ [config.secretKey, config.userId, config.sessionId]
257
+ );
258
+ const reset = useCallback(() => {
259
+ setState({
260
+ data: void 0,
261
+ isLoading: false,
262
+ error: void 0,
263
+ isDecrypted: false
264
+ });
265
+ }, []);
266
+ return { ...state, decrypt, reset };
267
+ }
268
+ __name(useLazyDecrypt, "useLazyDecrypt");
269
+ function useIsEncrypted(value) {
270
+ return useMemo(() => isEncryptedField(value), [value]);
271
+ }
272
+ __name(useIsEncrypted, "useIsEncrypted");
273
+ export {
274
+ useDecrypt,
275
+ useDecryptionClient,
276
+ useIsEncrypted,
277
+ useLazyDecrypt
278
+ };
279
+ //# sourceMappingURL=react.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/hooks.ts","../src/types.ts","../src/key-derivation.ts","../src/decryption.ts"],"sourcesContent":["/**\n * React hooks for Django-CFG encryption.\n */\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\nimport type { DecryptionConfig, EncryptedField } from '../types';\nimport { isEncryptedField } from '../types';\nimport { createDecryptionClient, decryptObject } from '../decryption';\nimport { deriveKeyFromConfig } from '../key-derivation';\n\n/**\n * Hook state for decryption operations.\n */\ninterface UseDecryptState<T> {\n /** Decrypted data */\n data: T | undefined;\n /** Loading state */\n isLoading: boolean;\n /** Error if decryption failed */\n error: Error | undefined;\n /** Whether data has been decrypted */\n isDecrypted: boolean;\n}\n\n/**\n * Hook to decrypt data on mount or when dependencies change.\n *\n * @param encryptedData - Data potentially containing encrypted fields\n * @param config - Decryption configuration\n * @returns Decrypted data state\n *\n * @example\n * ```typescript\n * function ProductPrice({ product }: { product: Product }) {\n * const { data, isLoading, error } = useDecrypt(product, {\n * secretKey: process.env.NEXT_PUBLIC_DECRYPT_KEY!,\n * userId: user.id\n * });\n *\n * if (isLoading) return <Skeleton />;\n * if (error) return <ErrorMessage error={error} />;\n * return <span>{data.price}</span>;\n * }\n * ```\n */\nexport function useDecrypt<T>(\n encryptedData: unknown,\n config: DecryptionConfig\n): UseDecryptState<T> {\n const [state, setState] = useState<UseDecryptState<T>>({\n data: undefined,\n isLoading: true,\n error: undefined,\n isDecrypted: false,\n });\n\n // Cache the key to avoid re-deriving on every render\n const keyRef = useRef<CryptoKey | null>(null);\n const configRef = useRef(config);\n\n // Check if config changed\n const configChanged =\n configRef.current.secretKey !== config.secretKey ||\n configRef.current.userId !== config.userId ||\n configRef.current.sessionId !== config.sessionId;\n\n if (configChanged) {\n configRef.current = config;\n keyRef.current = null;\n }\n\n useEffect(() => {\n let cancelled = false;\n\n async function decrypt() {\n try {\n setState((s) => ({ ...s, isLoading: true, error: undefined }));\n\n // Get or derive key\n if (!keyRef.current) {\n keyRef.current = await deriveKeyFromConfig(config);\n }\n\n const decrypted = await decryptObject<T>(encryptedData, keyRef.current);\n\n if (!cancelled) {\n setState({\n data: decrypted,\n isLoading: false,\n error: undefined,\n isDecrypted: true,\n });\n }\n } catch (err) {\n if (!cancelled) {\n setState({\n data: undefined,\n isLoading: false,\n error: err instanceof Error ? err : new Error('Decryption failed'),\n isDecrypted: false,\n });\n }\n }\n }\n\n decrypt();\n\n return () => {\n cancelled = true;\n };\n }, [encryptedData, config.secretKey, config.userId, config.sessionId]);\n\n return state;\n}\n\n/**\n * Hook to create a memoized decryption client.\n *\n * @param config - Decryption configuration\n * @returns Decryption client or undefined while loading\n *\n * @example\n * ```typescript\n * function App() {\n * const crypto = useDecryptionClient({\n * secretKey: process.env.NEXT_PUBLIC_DECRYPT_KEY!\n * });\n *\n * const handleFetch = async () => {\n * const response = await fetch('/api/products/?encrypt=true');\n * const data = await response.json();\n * const decrypted = await crypto?.decryptObject(data);\n * };\n * }\n * ```\n */\nexport function useDecryptionClient(config: DecryptionConfig) {\n const [client, setClient] = useState<Awaited<\n ReturnType<typeof createDecryptionClient>\n > | null>(null);\n\n useEffect(() => {\n createDecryptionClient(config).then(setClient);\n }, [config.secretKey, config.userId, config.sessionId]);\n\n return client;\n}\n\n/**\n * Hook for lazy decryption with manual trigger.\n *\n * @param config - Decryption configuration\n * @returns Decrypt function and state\n *\n * @example\n * ```typescript\n * function LazyProduct({ product }: { product: Product }) {\n * const { decrypt, data, isLoading } = useLazyDecrypt({\n * secretKey: process.env.NEXT_PUBLIC_DECRYPT_KEY!\n * });\n *\n * return (\n * <div>\n * <button onClick={() => decrypt(product)}>Show Price</button>\n * {isLoading && <Spinner />}\n * {data && <span>{data.price}</span>}\n * </div>\n * );\n * }\n * ```\n */\nexport function useLazyDecrypt<T>(config: DecryptionConfig) {\n const [state, setState] = useState<UseDecryptState<T>>({\n data: undefined,\n isLoading: false,\n error: undefined,\n isDecrypted: false,\n });\n\n const keyRef = useRef<CryptoKey | null>(null);\n\n const decrypt = useCallback(\n async (encryptedData: unknown): Promise<T | undefined> => {\n try {\n setState((s) => ({ ...s, isLoading: true, error: undefined }));\n\n if (!keyRef.current) {\n keyRef.current = await deriveKeyFromConfig(config);\n }\n\n const decrypted = await decryptObject<T>(encryptedData, keyRef.current);\n\n setState({\n data: decrypted,\n isLoading: false,\n error: undefined,\n isDecrypted: true,\n });\n\n return decrypted;\n } catch (err) {\n const error =\n err instanceof Error ? err : new Error('Decryption failed');\n setState({\n data: undefined,\n isLoading: false,\n error,\n isDecrypted: false,\n });\n return undefined;\n }\n },\n [config.secretKey, config.userId, config.sessionId]\n );\n\n const reset = useCallback(() => {\n setState({\n data: undefined,\n isLoading: false,\n error: undefined,\n isDecrypted: false,\n });\n }, []);\n\n return { ...state, decrypt, reset };\n}\n\n/**\n * Hook to check if a value needs decryption.\n *\n * @param value - Value to check\n * @returns Whether the value is encrypted\n */\nexport function useIsEncrypted(value: unknown): boolean {\n return useMemo(() => isEncryptedField(value), [value]);\n}\n","/**\n * TypeScript interfaces for Django-CFG encryption.\n *\n * These types match the encrypted response format from Django-CFG backend.\n */\n\n/**\n * Encrypted field envelope returned by Django-CFG API.\n *\n * When a serializer field is encrypted, it returns this structure\n * instead of the plain value.\n *\n * @example\n * ```json\n * {\n * \"encrypted\": true,\n * \"field\": \"price\",\n * \"algorithm\": \"AES-256-GCM\",\n * \"iv\": \"base64...\",\n * \"data\": \"base64...\",\n * \"auth_tag\": \"base64...\"\n * }\n * ```\n */\nexport interface EncryptedField {\n /** Always true for encrypted fields */\n encrypted: true;\n /** Field name that was encrypted */\n field?: string;\n /** Encryption algorithm used */\n algorithm: 'AES-256-GCM' | 'AES-256-CBC';\n /** Base64-encoded initialization vector */\n iv: string;\n /** Base64-encoded ciphertext */\n data: string;\n /** Base64-encoded authentication tag (GCM only) */\n auth_tag: string;\n}\n\n/**\n * Full encrypted response envelope.\n *\n * When response-level encryption is enabled, the entire response\n * body is wrapped in this structure.\n *\n * @example\n * ```json\n * {\n * \"encrypted\": true,\n * \"algorithm\": \"AES-256-GCM\",\n * \"salt\": \"base64...\",\n * \"iv\": \"base64...\",\n * \"data\": \"base64...\",\n * \"auth_tag\": \"base64...\"\n * }\n * ```\n */\nexport interface EncryptedResponse {\n /** Always true for encrypted responses */\n encrypted: true;\n /** Encryption algorithm used */\n algorithm: 'AES-256-GCM' | 'AES-256-CBC';\n /** Base64-encoded salt for key derivation */\n salt: string;\n /** Base64-encoded initialization vector */\n iv: string;\n /** Base64-encoded ciphertext */\n data: string;\n /** Base64-encoded authentication tag (GCM only) */\n auth_tag: string;\n}\n\n/**\n * Configuration for the decryption client.\n */\nexport interface DecryptionConfig {\n /**\n * Secret key for key derivation.\n * Should match the Django SECRET_KEY or a derived key.\n */\n secretKey: string;\n\n /**\n * User ID for per-user key derivation (optional).\n * When provided, keys are derived per-user for isolation.\n */\n userId?: string | number;\n\n /**\n * Session ID for per-session key derivation (optional).\n * Takes precedence over userId if both provided.\n */\n sessionId?: string;\n\n /**\n * Number of PBKDF2 iterations (default: 100000).\n * Must match backend configuration.\n */\n iterations?: number;\n\n /**\n * Key prefix for derivation (default: \"djangocfg_encryption\").\n * Must match backend configuration.\n */\n keyPrefix?: string;\n}\n\n/**\n * Result of a decryption operation.\n */\nexport interface DecryptionResult<T = unknown> {\n /** Decrypted data */\n data: T;\n /** Whether decryption was successful */\n success: true;\n}\n\n/**\n * Error from a decryption operation.\n */\nexport interface DecryptionError {\n /** Error message */\n message: string;\n /** Error code */\n code: 'INVALID_FORMAT' | 'DECRYPTION_FAILED' | 'AUTH_FAILED' | 'KEY_ERROR';\n /** Whether decryption was successful */\n success: false;\n}\n\n/**\n * Type guard to check if a value is an encrypted field.\n */\nexport function isEncryptedField(value: unknown): value is EncryptedField {\n if (typeof value !== 'object' || value === null) return false;\n const obj = value as Record<string, unknown>;\n return (\n obj.encrypted === true &&\n typeof obj.algorithm === 'string' &&\n typeof obj.iv === 'string' &&\n typeof obj.data === 'string' &&\n typeof obj.auth_tag === 'string'\n );\n}\n\n/**\n * Type guard to check if a value is an encrypted response.\n */\nexport function isEncryptedResponse(value: unknown): value is EncryptedResponse {\n if (typeof value !== 'object' || value === null) return false;\n const obj = value as Record<string, unknown>;\n return (\n obj.encrypted === true &&\n typeof obj.algorithm === 'string' &&\n typeof obj.salt === 'string' &&\n typeof obj.iv === 'string' &&\n typeof obj.data === 'string' &&\n typeof obj.auth_tag === 'string'\n );\n}\n","/**\n * PBKDF2 key derivation using Web Crypto API.\n *\n * Matches Django-CFG backend key derivation for decryption compatibility.\n */\n\n/**\n * Derive an encryption key using PBKDF2.\n *\n * Uses Web Crypto API for secure key derivation that matches\n * the Django-CFG backend implementation.\n *\n * @param password - The password/secret key to derive from\n * @param salt - Salt bytes for key derivation\n * @param iterations - Number of PBKDF2 iterations (default: 100000)\n * @param keyLength - Desired key length in bytes (default: 32 for AES-256)\n * @returns Promise resolving to derived key as CryptoKey\n *\n * @example\n * ```typescript\n * const salt = new TextEncoder().encode('my-salt');\n * const key = await deriveKey('secret', salt, 100000);\n * ```\n */\nexport async function deriveKey(\n password: string,\n salt: Uint8Array,\n iterations: number = 100000,\n keyLength: number = 32\n): Promise<CryptoKey> {\n const encoder = new TextEncoder();\n const passwordBuffer = encoder.encode(password);\n\n // Import password as raw key material\n const keyMaterial = await crypto.subtle.importKey(\n 'raw',\n passwordBuffer,\n 'PBKDF2',\n false,\n ['deriveBits', 'deriveKey']\n );\n\n // Derive AES-GCM key using PBKDF2\n return crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt.buffer as ArrayBuffer,\n iterations: iterations,\n hash: 'SHA-256',\n },\n keyMaterial,\n { name: 'AES-GCM', length: keyLength * 8 },\n false,\n ['decrypt']\n );\n}\n\n/**\n * Derive raw key bytes using PBKDF2.\n *\n * @param password - The password/secret key to derive from\n * @param salt - Salt bytes for key derivation\n * @param iterations - Number of PBKDF2 iterations (default: 100000)\n * @param keyLength - Desired key length in bytes (default: 32 for AES-256)\n * @returns Promise resolving to derived key as Uint8Array\n */\nexport async function deriveKeyBytes(\n password: string,\n salt: Uint8Array,\n iterations: number = 100000,\n keyLength: number = 32\n): Promise<Uint8Array> {\n const encoder = new TextEncoder();\n const passwordBuffer = encoder.encode(password);\n\n // Import password as raw key material\n const keyMaterial = await crypto.subtle.importKey(\n 'raw',\n passwordBuffer,\n 'PBKDF2',\n false,\n ['deriveBits']\n );\n\n // Derive raw bits\n const keyBits = await crypto.subtle.deriveBits(\n {\n name: 'PBKDF2',\n salt: salt.buffer as ArrayBuffer,\n iterations: iterations,\n hash: 'SHA-256',\n },\n keyMaterial,\n keyLength * 8\n );\n\n return new Uint8Array(keyBits);\n}\n\n/**\n * Build a deterministic salt from context components.\n *\n * Matches Django-CFG backend salt generation for key derivation.\n *\n * @param keyPrefix - Key prefix (default: \"djangocfg_encryption\")\n * @param userId - Optional user ID for per-user keys\n * @param sessionId - Optional session ID for per-session keys\n * @returns Salt as Uint8Array (first 16 bytes of SHA-256 hash)\n */\nexport async function buildSalt(\n keyPrefix: string = 'djangocfg_encryption',\n userId?: string | number,\n sessionId?: string\n): Promise<Uint8Array> {\n const parts = [keyPrefix];\n\n if (sessionId) {\n parts.push(`session:${sessionId}`);\n } else if (userId !== undefined) {\n parts.push(`user:${userId}`);\n } else {\n parts.push('global');\n }\n\n const saltInput = parts.join(':');\n const encoder = new TextEncoder();\n const inputBuffer = encoder.encode(saltInput);\n\n // SHA-256 hash and take first 16 bytes\n const hashBuffer = await crypto.subtle.digest('SHA-256', inputBuffer);\n return new Uint8Array(hashBuffer).slice(0, 16);\n}\n\n/**\n * Derive encryption key from Django-CFG config.\n *\n * Convenience function that matches backend key derivation.\n *\n * @param config - Configuration object with secretKey and optional context\n * @returns Promise resolving to CryptoKey for decryption\n *\n * @example\n * ```typescript\n * const key = await deriveKeyFromConfig({\n * secretKey: 'django-secret-key',\n * userId: 123,\n * iterations: 100000\n * });\n * ```\n */\nexport async function deriveKeyFromConfig(config: {\n secretKey: string;\n userId?: string | number;\n sessionId?: string;\n iterations?: number;\n keyPrefix?: string;\n}): Promise<CryptoKey> {\n const {\n secretKey,\n userId,\n sessionId,\n iterations = 100000,\n keyPrefix = 'djangocfg_encryption',\n } = config;\n\n const salt = await buildSalt(keyPrefix, userId, sessionId);\n return deriveKey(secretKey, salt, iterations);\n}\n","/**\n * AES-256-GCM decryption using Web Crypto API.\n *\n * Decrypts data encrypted by Django-CFG backend.\n */\n\nimport type {\n DecryptionConfig,\n DecryptionError,\n DecryptionResult,\n EncryptedField,\n EncryptedResponse,\n} from './types';\nimport { isEncryptedField, isEncryptedResponse } from './types';\nimport { deriveKeyFromConfig } from './key-derivation';\n\n/**\n * Decode base64 string to Uint8Array.\n */\nfunction base64ToBytes(base64: string): Uint8Array {\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n return bytes;\n}\n\n/**\n * Decrypt AES-256-GCM ciphertext.\n *\n * @param ciphertext - Encrypted data bytes\n * @param key - CryptoKey for decryption\n * @param iv - Initialization vector\n * @param authTag - Authentication tag\n * @returns Promise resolving to decrypted bytes\n */\nexport async function decryptAES256GCM(\n ciphertext: Uint8Array,\n key: CryptoKey,\n iv: Uint8Array,\n authTag: Uint8Array\n): Promise<Uint8Array> {\n // GCM expects ciphertext + authTag concatenated\n const combined = new Uint8Array(ciphertext.length + authTag.length);\n combined.set(ciphertext);\n combined.set(authTag, ciphertext.length);\n\n const decrypted = await crypto.subtle.decrypt(\n {\n name: 'AES-GCM',\n iv: iv.buffer as ArrayBuffer,\n tagLength: 128, // 16 bytes = 128 bits\n },\n key,\n combined\n );\n\n return new Uint8Array(decrypted);\n}\n\n/**\n * Decrypt a single encrypted field value.\n *\n * @param field - Encrypted field envelope\n * @param key - CryptoKey for decryption\n * @returns Promise resolving to decrypted value\n *\n * @example\n * ```typescript\n * const key = await deriveKeyFromConfig({ secretKey: '...' });\n * const price = await decryptField(response.price, key);\n * console.log(price); // 99.99\n * ```\n */\nexport async function decryptField<T = unknown>(\n field: EncryptedField,\n key: CryptoKey\n): Promise<T> {\n if (field.algorithm !== 'AES-256-GCM') {\n throw new Error(`Unsupported algorithm: ${field.algorithm}`);\n }\n\n const iv = base64ToBytes(field.iv);\n const ciphertext = base64ToBytes(field.data);\n const authTag = base64ToBytes(field.auth_tag);\n\n const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag);\n const text = new TextDecoder().decode(decrypted);\n\n return JSON.parse(text) as T;\n}\n\n/**\n * Decrypt an entire encrypted response.\n *\n * @param response - Encrypted response envelope\n * @param secretKey - Secret key for key derivation\n * @param config - Additional config (userId, sessionId, etc.)\n * @returns Promise resolving to decrypted response data\n */\nexport async function decryptResponse<T = unknown>(\n response: EncryptedResponse,\n secretKey: string,\n config?: Partial<Omit<DecryptionConfig, 'secretKey'>>\n): Promise<T> {\n if (response.algorithm !== 'AES-256-GCM') {\n throw new Error(`Unsupported algorithm: ${response.algorithm}`);\n }\n\n // Use salt from response for key derivation\n const salt = base64ToBytes(response.salt);\n const iterations = config?.iterations ?? 100000;\n\n // Import secret key for PBKDF2\n const encoder = new TextEncoder();\n const keyMaterial = await crypto.subtle.importKey(\n 'raw',\n encoder.encode(secretKey),\n 'PBKDF2',\n false,\n ['deriveKey']\n );\n\n // Derive decryption key\n const key = await crypto.subtle.deriveKey(\n {\n name: 'PBKDF2',\n salt: salt.buffer as ArrayBuffer,\n iterations: iterations,\n hash: 'SHA-256',\n },\n keyMaterial,\n { name: 'AES-GCM', length: 256 },\n false,\n ['decrypt']\n );\n\n const iv = base64ToBytes(response.iv);\n const ciphertext = base64ToBytes(response.data);\n const authTag = base64ToBytes(response.auth_tag);\n\n const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag);\n const text = new TextDecoder().decode(decrypted);\n\n return JSON.parse(text) as T;\n}\n\n/**\n * Recursively decrypt all encrypted fields in an object.\n *\n * @param data - Object potentially containing encrypted fields\n * @param key - CryptoKey for decryption\n * @returns Promise resolving to object with all fields decrypted\n *\n * @example\n * ```typescript\n * const key = await deriveKeyFromConfig({ secretKey: '...' });\n * const product = await decryptObject(response, key);\n * // product.price is now decrypted\n * ```\n */\nexport async function decryptObject<T>(\n data: unknown,\n key: CryptoKey\n): Promise<T> {\n if (data === null || data === undefined) {\n return data as T;\n }\n\n // Check if this is an encrypted field\n if (isEncryptedField(data)) {\n return decryptField<T>(data, key);\n }\n\n // Handle arrays\n if (Array.isArray(data)) {\n const decrypted = await Promise.all(\n data.map((item) => decryptObject(item, key))\n );\n return decrypted as T;\n }\n\n // Handle objects\n if (typeof data === 'object') {\n const result: Record<string, unknown> = {};\n const entries = Object.entries(data as Record<string, unknown>);\n\n for (const [objKey, value] of entries) {\n result[objKey] = await decryptObject(value, key);\n }\n\n return result as T;\n }\n\n // Primitive values pass through\n return data as T;\n}\n\n/**\n * Create a decryption client with pre-configured key.\n *\n * @param config - Decryption configuration\n * @returns Object with decryption methods\n *\n * @example\n * ```typescript\n * const crypto = await createDecryptionClient({\n * secretKey: 'django-secret-key',\n * userId: currentUser.id\n * });\n *\n * const response = await fetch('/api/products/?encrypt=true');\n * const data = await crypto.decryptObject(await response.json());\n * ```\n */\nexport async function createDecryptionClient(config: DecryptionConfig) {\n const key = await deriveKeyFromConfig(config);\n\n return {\n /**\n * Decrypt a single encrypted field.\n */\n decryptField: <T = unknown>(field: EncryptedField) =>\n decryptField<T>(field, key),\n\n /**\n * Recursively decrypt all encrypted fields in an object.\n */\n decryptObject: <T>(data: unknown) => decryptObject<T>(data, key),\n\n /**\n * Check if a value is an encrypted field.\n */\n isEncryptedField,\n\n /**\n * Check if a value is an encrypted response.\n */\n isEncryptedResponse,\n };\n}\n\n/**\n * Safe decryption wrapper that returns result or error.\n *\n * @param fn - Async function to execute\n * @returns Promise resolving to DecryptionResult or DecryptionError\n */\nexport async function safeDecrypt<T>(\n fn: () => Promise<T>\n): Promise<DecryptionResult<T> | DecryptionError> {\n try {\n const data = await fn();\n return { success: true, data };\n } catch (error) {\n const message = error instanceof Error ? error.message : 'Unknown error';\n\n // Determine error code\n let code: DecryptionError['code'] = 'DECRYPTION_FAILED';\n if (message.includes('format') || message.includes('parse')) {\n code = 'INVALID_FORMAT';\n } else if (message.includes('auth') || message.includes('tag')) {\n code = 'AUTH_FAILED';\n } else if (message.includes('key')) {\n code = 'KEY_ERROR';\n }\n\n return { success: false, message, code };\n }\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "@djangocfg/crypto",
3
+ "version": "2.1.111",
4
+ "description": "Client-side AES-256-GCM decryption for Django-CFG encrypted API responses using Web Crypto API",
5
+ "keywords": [
6
+ "crypto",
7
+ "encryption",
8
+ "decryption",
9
+ "aes-256-gcm",
10
+ "web-crypto",
11
+ "pbkdf2",
12
+ "django",
13
+ "api",
14
+ "react",
15
+ "typescript",
16
+ "security"
17
+ ],
18
+ "author": {
19
+ "name": "DjangoCFG",
20
+ "url": "https://djangocfg.com"
21
+ },
22
+ "homepage": "https://djangocfg.com",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/markolofsen/django-cfg.git",
26
+ "directory": "packages/crypto"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/markolofsen/django-cfg/issues"
30
+ },
31
+ "license": "MIT",
32
+ "type": "module",
33
+ "main": "./dist/index.cjs",
34
+ "module": "./dist/index.mjs",
35
+ "types": "./dist/index.d.ts",
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.mjs",
40
+ "require": "./dist/index.cjs"
41
+ },
42
+ "./react": {
43
+ "types": "./dist/react.d.ts",
44
+ "import": "./dist/react.mjs",
45
+ "require": "./dist/react.cjs"
46
+ }
47
+ },
48
+ "files": [
49
+ "dist",
50
+ "src",
51
+ "README.md",
52
+ "LICENSE"
53
+ ],
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "clean": "rm -rf dist",
57
+ "dev": "tsup --watch",
58
+ "check": "tsc --noEmit"
59
+ },
60
+ "peerDependencies": {
61
+ "react": "^18.0.0 || ^19.0.0"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "react": {
65
+ "optional": true
66
+ }
67
+ },
68
+ "devDependencies": {
69
+ "@djangocfg/typescript-config": "^2.1.111",
70
+ "@types/node": "^24.7.2",
71
+ "@types/react": "^19.0.0",
72
+ "react": "^19.0.0",
73
+ "tsup": "^8.5.0",
74
+ "typescript": "^5.9.3"
75
+ },
76
+ "publishConfig": {
77
+ "access": "public"
78
+ }
79
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * AES-256-GCM decryption using Web Crypto API.
3
+ *
4
+ * Decrypts data encrypted by Django-CFG backend.
5
+ */
6
+
7
+ import type {
8
+ DecryptionConfig,
9
+ DecryptionError,
10
+ DecryptionResult,
11
+ EncryptedField,
12
+ EncryptedResponse,
13
+ } from './types';
14
+ import { isEncryptedField, isEncryptedResponse } from './types';
15
+ import { deriveKeyFromConfig } from './key-derivation';
16
+
17
+ /**
18
+ * Decode base64 string to Uint8Array.
19
+ */
20
+ function base64ToBytes(base64: string): Uint8Array {
21
+ const binary = atob(base64);
22
+ const bytes = new Uint8Array(binary.length);
23
+ for (let i = 0; i < binary.length; i++) {
24
+ bytes[i] = binary.charCodeAt(i);
25
+ }
26
+ return bytes;
27
+ }
28
+
29
+ /**
30
+ * Decrypt AES-256-GCM ciphertext.
31
+ *
32
+ * @param ciphertext - Encrypted data bytes
33
+ * @param key - CryptoKey for decryption
34
+ * @param iv - Initialization vector
35
+ * @param authTag - Authentication tag
36
+ * @returns Promise resolving to decrypted bytes
37
+ */
38
+ export async function decryptAES256GCM(
39
+ ciphertext: Uint8Array,
40
+ key: CryptoKey,
41
+ iv: Uint8Array,
42
+ authTag: Uint8Array
43
+ ): Promise<Uint8Array> {
44
+ // GCM expects ciphertext + authTag concatenated
45
+ const combined = new Uint8Array(ciphertext.length + authTag.length);
46
+ combined.set(ciphertext);
47
+ combined.set(authTag, ciphertext.length);
48
+
49
+ const decrypted = await crypto.subtle.decrypt(
50
+ {
51
+ name: 'AES-GCM',
52
+ iv: iv.buffer as ArrayBuffer,
53
+ tagLength: 128, // 16 bytes = 128 bits
54
+ },
55
+ key,
56
+ combined
57
+ );
58
+
59
+ return new Uint8Array(decrypted);
60
+ }
61
+
62
+ /**
63
+ * Decrypt a single encrypted field value.
64
+ *
65
+ * @param field - Encrypted field envelope
66
+ * @param key - CryptoKey for decryption
67
+ * @returns Promise resolving to decrypted value
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * const key = await deriveKeyFromConfig({ secretKey: '...' });
72
+ * const price = await decryptField(response.price, key);
73
+ * console.log(price); // 99.99
74
+ * ```
75
+ */
76
+ export async function decryptField<T = unknown>(
77
+ field: EncryptedField,
78
+ key: CryptoKey
79
+ ): Promise<T> {
80
+ if (field.algorithm !== 'AES-256-GCM') {
81
+ throw new Error(`Unsupported algorithm: ${field.algorithm}`);
82
+ }
83
+
84
+ const iv = base64ToBytes(field.iv);
85
+ const ciphertext = base64ToBytes(field.data);
86
+ const authTag = base64ToBytes(field.auth_tag);
87
+
88
+ const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag);
89
+ const text = new TextDecoder().decode(decrypted);
90
+
91
+ return JSON.parse(text) as T;
92
+ }
93
+
94
+ /**
95
+ * Decrypt an entire encrypted response.
96
+ *
97
+ * @param response - Encrypted response envelope
98
+ * @param secretKey - Secret key for key derivation
99
+ * @param config - Additional config (userId, sessionId, etc.)
100
+ * @returns Promise resolving to decrypted response data
101
+ */
102
+ export async function decryptResponse<T = unknown>(
103
+ response: EncryptedResponse,
104
+ secretKey: string,
105
+ config?: Partial<Omit<DecryptionConfig, 'secretKey'>>
106
+ ): Promise<T> {
107
+ if (response.algorithm !== 'AES-256-GCM') {
108
+ throw new Error(`Unsupported algorithm: ${response.algorithm}`);
109
+ }
110
+
111
+ // Use salt from response for key derivation
112
+ const salt = base64ToBytes(response.salt);
113
+ const iterations = config?.iterations ?? 100000;
114
+
115
+ // Import secret key for PBKDF2
116
+ const encoder = new TextEncoder();
117
+ const keyMaterial = await crypto.subtle.importKey(
118
+ 'raw',
119
+ encoder.encode(secretKey),
120
+ 'PBKDF2',
121
+ false,
122
+ ['deriveKey']
123
+ );
124
+
125
+ // Derive decryption key
126
+ const key = await crypto.subtle.deriveKey(
127
+ {
128
+ name: 'PBKDF2',
129
+ salt: salt.buffer as ArrayBuffer,
130
+ iterations: iterations,
131
+ hash: 'SHA-256',
132
+ },
133
+ keyMaterial,
134
+ { name: 'AES-GCM', length: 256 },
135
+ false,
136
+ ['decrypt']
137
+ );
138
+
139
+ const iv = base64ToBytes(response.iv);
140
+ const ciphertext = base64ToBytes(response.data);
141
+ const authTag = base64ToBytes(response.auth_tag);
142
+
143
+ const decrypted = await decryptAES256GCM(ciphertext, key, iv, authTag);
144
+ const text = new TextDecoder().decode(decrypted);
145
+
146
+ return JSON.parse(text) as T;
147
+ }
148
+
149
+ /**
150
+ * Recursively decrypt all encrypted fields in an object.
151
+ *
152
+ * @param data - Object potentially containing encrypted fields
153
+ * @param key - CryptoKey for decryption
154
+ * @returns Promise resolving to object with all fields decrypted
155
+ *
156
+ * @example
157
+ * ```typescript
158
+ * const key = await deriveKeyFromConfig({ secretKey: '...' });
159
+ * const product = await decryptObject(response, key);
160
+ * // product.price is now decrypted
161
+ * ```
162
+ */
163
+ export async function decryptObject<T>(
164
+ data: unknown,
165
+ key: CryptoKey
166
+ ): Promise<T> {
167
+ if (data === null || data === undefined) {
168
+ return data as T;
169
+ }
170
+
171
+ // Check if this is an encrypted field
172
+ if (isEncryptedField(data)) {
173
+ return decryptField<T>(data, key);
174
+ }
175
+
176
+ // Handle arrays
177
+ if (Array.isArray(data)) {
178
+ const decrypted = await Promise.all(
179
+ data.map((item) => decryptObject(item, key))
180
+ );
181
+ return decrypted as T;
182
+ }
183
+
184
+ // Handle objects
185
+ if (typeof data === 'object') {
186
+ const result: Record<string, unknown> = {};
187
+ const entries = Object.entries(data as Record<string, unknown>);
188
+
189
+ for (const [objKey, value] of entries) {
190
+ result[objKey] = await decryptObject(value, key);
191
+ }
192
+
193
+ return result as T;
194
+ }
195
+
196
+ // Primitive values pass through
197
+ return data as T;
198
+ }
199
+
200
+ /**
201
+ * Create a decryption client with pre-configured key.
202
+ *
203
+ * @param config - Decryption configuration
204
+ * @returns Object with decryption methods
205
+ *
206
+ * @example
207
+ * ```typescript
208
+ * const crypto = await createDecryptionClient({
209
+ * secretKey: 'django-secret-key',
210
+ * userId: currentUser.id
211
+ * });
212
+ *
213
+ * const response = await fetch('/api/products/?encrypt=true');
214
+ * const data = await crypto.decryptObject(await response.json());
215
+ * ```
216
+ */
217
+ export async function createDecryptionClient(config: DecryptionConfig) {
218
+ const key = await deriveKeyFromConfig(config);
219
+
220
+ return {
221
+ /**
222
+ * Decrypt a single encrypted field.
223
+ */
224
+ decryptField: <T = unknown>(field: EncryptedField) =>
225
+ decryptField<T>(field, key),
226
+
227
+ /**
228
+ * Recursively decrypt all encrypted fields in an object.
229
+ */
230
+ decryptObject: <T>(data: unknown) => decryptObject<T>(data, key),
231
+
232
+ /**
233
+ * Check if a value is an encrypted field.
234
+ */
235
+ isEncryptedField,
236
+
237
+ /**
238
+ * Check if a value is an encrypted response.
239
+ */
240
+ isEncryptedResponse,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Safe decryption wrapper that returns result or error.
246
+ *
247
+ * @param fn - Async function to execute
248
+ * @returns Promise resolving to DecryptionResult or DecryptionError
249
+ */
250
+ export async function safeDecrypt<T>(
251
+ fn: () => Promise<T>
252
+ ): Promise<DecryptionResult<T> | DecryptionError> {
253
+ try {
254
+ const data = await fn();
255
+ return { success: true, data };
256
+ } catch (error) {
257
+ const message = error instanceof Error ? error.message : 'Unknown error';
258
+
259
+ // Determine error code
260
+ let code: DecryptionError['code'] = 'DECRYPTION_FAILED';
261
+ if (message.includes('format') || message.includes('parse')) {
262
+ code = 'INVALID_FORMAT';
263
+ } else if (message.includes('auth') || message.includes('tag')) {
264
+ code = 'AUTH_FAILED';
265
+ } else if (message.includes('key')) {
266
+ code = 'KEY_ERROR';
267
+ }
268
+
269
+ return { success: false, message, code };
270
+ }
271
+ }