diff --git a/.gitignore b/.gitignore index 5b4bdef..f7ad08c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,8 @@ _fresh/ # npm dependencies node_modules/ + +# Build artifacts and downloads +*.zip +deno +deno-* diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..a077d3c --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,62 @@ +# Combined Auth Key + Password Implementation Summary + +## Changes Made + +### 1. Enhanced Security Architecture +- **Before**: Either password OR auth key +- **After**: Always auth key + optional password for dual-layer security + +### 2. Key Components Modified + +#### Crypto Service (`lib/services/crypto-service.ts`) +- `prepareEncryption()` now always generates an auth key +- When password provided: encryption key = `${authKey}:${password}` +- When no password: encryption key = `${authKey}` +- Returns both `passwordHash` and `authKeyHash` for server storage + +#### Database Schema (`lib/types.ts`) +- Added `authKey?` field to Note interface + +#### API Endpoints +- **POST /api/notes**: Accepts both `password` and `authKey` hashes +- **GET/DELETE /api/notes/[id]**: Validates both credentials when both are required + +#### Client Components +- **ViewNote**: Handles combined authentication flow +- **Remote Storage**: Sends both auth key and password hashes + +### 3. Authentication Flow + +#### Creating Notes: +1. Always generate auth key +2. If password provided: combine for encryption key +3. Store both password hash and auth key hash on server +4. URL format: `https://vailnote.com/[noteId]#auth=[authKey]` + +#### Retrieving Notes: +1. Extract auth key from URL +2. Try auth key alone first +3. If fails and note requires password: prompt for password +4. Use combined key for decryption: `createDecryptionKey(authKey, password)` +5. Server validates both hashes + +### 4. Backward Compatibility +- Legacy notes with only password: still work +- Legacy notes with only auth key: still work +- New notes: require both when password was originally set + +### 5. Security Benefits +- Multiple layers of authentication +- URL auth key provides first barrier +- Password provides second barrier +- Combined encryption key stronger than individual components + +## Testing +- Created `tests/crypto-service_test.ts` for crypto functionality +- Updated existing tests in `tests/main_test.ts` +- Manual verification needed for UI flow + +## Error Handling +- Graceful fallbacks for different auth combinations +- Clear error messages for users +- Proper validation at all layers \ No newline at end of file diff --git a/islands/ViewNote.tsx b/islands/ViewNote.tsx index d4fc03c..eca351b 100644 --- a/islands/ViewNote.tsx +++ b/islands/ViewNote.tsx @@ -6,6 +6,7 @@ import SiteHeader from '../components/SiteHeader.tsx'; import { Button } from '../components/Button.tsx'; import { formatExpirationMessage, Note } from '../lib/types.ts'; import { decryptNoteContent } from '../lib/encryption.ts'; +import { createDecryptionKey } from '../lib/services/crypto-service.ts'; import LoadingPage from '../components/LoadingPage.tsx'; import NoteService from '../lib/services/note-service.ts'; import Card, { CardContent, CardFooter, CardHeader, CardTitle } from '../components/Card.tsx'; @@ -57,6 +58,7 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp const [confirmed, setConfirmed] = useState(manualDeletion ? true : false); const [decryptionError, setDecryptionError] = useState(undefined); const [message, setMessage] = useState(undefined); + const [passwordRequired, setPasswordRequired] = useState(false); const password = useRef(undefined); @@ -75,17 +77,22 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp setNote(null); setNeedsPassword(true); setLoading(false); + setPasswordRequired(true); }; - const handleAuthKey = async (authKey: string) => { + const handleAuthKey = async (authKey: string, providedPassword?: string) => { try { - const result = await NoteService.getNote(noteId, authKey); + const result = await NoteService.getNote(noteId, authKey, providedPassword); if (!result.success || !result.note) { throw new Error(result.message); } - password.current = manualDeletion ? authKey : undefined; - const decryptedContent = await decryptNoteContent(result.note.content, result.note.iv, authKey); + // Create the decryption key using the same logic as encryption + const decryptionKey = createDecryptionKey(authKey, providedPassword); + + // Store credentials for manual deletion + password.current = providedPassword; + const decryptedContent = await decryptNoteContent(result.note.content, result.note.iv, decryptionKey); setNote({ ...result.note, content: decryptedContent }); setMessage( @@ -93,7 +100,13 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp ); } catch (err) { console.error(MESSAGES.DECRYPTION_FAILED, err); - setError(MESSAGES.DECRYPTION_FAILED); + // If auth key alone fails and no password was provided, ask for password + if (!providedPassword) { + setError(undefined); + showPasswordPrompt(); + } else { + setError(MESSAGES.DECRYPTION_FAILED); + } } }; @@ -126,11 +139,11 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp return; } - // Fetch note only when confirmed and auth key is available - if (confirmed) { + // Fetch note when confirmed or when we need to check if password is required + if (confirmed || !passwordRequired) { fetchAndDecryptNote(); } - }, [confirmed, noteId, manualDeletion]); + }, [confirmed, noteId, manualDeletion, passwordRequired]); // Event handlers const handlePasswordSubmit = async (event: Event) => { @@ -141,9 +154,9 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp const data = Object.fromEntries(new FormData(form)); const formData = v.parse(viewNoteSchema, data) as ViewNoteSchema; - const password = formData.password; + const userPassword = formData.password; - if (!password.trim()) { + if (!userPassword.trim()) { setDecryptionError(MESSAGES.ENTER_PASSWORD); setLoading(false); return; @@ -151,20 +164,32 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp try { setDecryptionError(undefined); - const result = await NoteService.getNote(noteId, password); - if (!result.success || !result.note) { - setDecryptionError(MESSAGES.INVALID_PASSWORD); - setLoading(false); - return; - } + // Get auth key from URL if available + const authKey = getAuthKey(); - const decryptedContent = await decryptNoteContent(result.note.content, result.note.iv, password); + if (authKey) { + // Use both auth key and password + await handleAuthKey(authKey, userPassword); + setNeedsPassword(false); + setConfirmed(true); + } else { + // Fallback for legacy notes without auth key (shouldn't happen with new system) + const result = await NoteService.getNote(noteId, undefined, userPassword); - setNote({ ...result.note, content: decryptedContent }); - setNeedsPassword(false); - setConfirmed(true); - setMessage(manualDeletion ? MESSAGES.MANUAL_DELETION_PROMPT : MESSAGES.AUTO_DELETION_COMPLETE); + if (!result.success || !result.note) { + setDecryptionError(MESSAGES.INVALID_PASSWORD); + setLoading(false); + return; + } + + const decryptedContent = await decryptNoteContent(result.note.content, result.note.iv, userPassword); + + setNote({ ...result.note, content: decryptedContent }); + setNeedsPassword(false); + setConfirmed(true); + setMessage(manualDeletion ? MESSAGES.MANUAL_DELETION_PROMPT : MESSAGES.AUTO_DELETION_COMPLETE); + } } catch (error) { setDecryptionError(MESSAGES.INVALID_PASSWORD); console.error('Decryption failed:', error); @@ -174,13 +199,22 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp }; const handleDeleteNote = async () => { - if (!password.current) { + const authKey = getAuthKey(); + + if (!authKey && !password.current) { setMessage(MESSAGES.NO_PASSWORD); return; } try { - await NoteService.deleteNote(noteId, password.current); + // For manual deletion, we need to provide both auth key and password if both were used + if (authKey) { + await NoteService.deleteNote(noteId, authKey, password.current); + } else { + // Fallback for legacy notes + await NoteService.deleteNote(noteId, undefined, password.current); + } + setMessage(MESSAGES.DELETE_SUCCESS); globalThis.location.href = '/'; } catch (error) { @@ -213,7 +247,7 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp ); } - if (!confirmed) { + if (!confirmed && !passwordRequired) { return setConfirmed(true)} />; } @@ -316,7 +350,7 @@ function PasswordRequiredView({ onSubmit, manualDeletion, error }: PasswordRequi Enter Password

- This note is encrypted and requires a password + This note is password protected and requires both the URL auth key and password

diff --git a/lib/services/crypto-service.ts b/lib/services/crypto-service.ts index 55dade5..428ff93 100644 --- a/lib/services/crypto-service.ts +++ b/lib/services/crypto-service.ts @@ -4,22 +4,47 @@ import { generateDeterministicClientHash } from '../hashing.ts'; /** * Prepares the encryption of a note by generating the necessary keys and hashes. + * Always generates an auth key for URL-based access. When password is provided, + * combines both auth key and password for enhanced security. * @param content The content of the note to encrypt. * @param password An optional password for encrypting the note. - * @returns An object containing the encrypted content, password hash, and auth key. + * @returns An object containing the encrypted content, password hash, auth key, and auth key hash. */ export async function prepareEncryption(content: string, password?: string) { + // Always generate an auth key for URL-based access + const authKey = generateRandomId(8); + const hasPassword = password && password.trim() !== ''; - const authKey = !hasPassword ? generateRandomId(8) : undefined; - const encryptionKey = password || authKey; - if (!encryptionKey) { - throw new Error('No password or auth token provided'); + // Create encryption key: combine auth key and password if both present + let encryptionKey: string; + if (hasPassword) { + // Combine auth key and password for enhanced security + encryptionKey = `${authKey}:${password}`; + } else { + // Use only auth key when no password provided + encryptionKey = authKey; } + // Generate hashes for server-side verification const passwordHash = hasPassword ? await generateDeterministicClientHash(password) : undefined; + const authKeyHash = await generateDeterministicClientHash(authKey); const encryptedContent = await encryptNoteContent(content, encryptionKey); - return { encryptedContent, passwordHash, authKey }; + return { encryptedContent, passwordHash, authKey, authKeyHash }; +} + +/** + * Creates the decryption key from auth key and password. + * Matches the logic used in prepareEncryption. + * @param authKey The auth key from the URL + * @param password Optional password provided by user + * @returns The combined decryption key + */ +export function createDecryptionKey(authKey: string, password?: string): string { + if (password && password.trim() !== '') { + return `${authKey}:${password}`; + } + return authKey; } diff --git a/lib/services/note-service.ts b/lib/services/note-service.ts index 65804ec..af35ca4 100644 --- a/lib/services/note-service.ts +++ b/lib/services/note-service.ts @@ -12,11 +12,11 @@ export default class NoteService { return this.provider.create(data); } - static getNote(noteId: string, password?: string) { - return this.provider.get(noteId, password); + static getNote(noteId: string, authKey?: string, password?: string) { + return this.provider.get(noteId, authKey, password); } - static deleteNote(noteId: string, password?: string) { - return this.provider.delete(noteId, password); + static deleteNote(noteId: string, authKey?: string, password?: string) { + return this.provider.delete(noteId, authKey, password); } } diff --git a/lib/services/storage/remote-storage.ts b/lib/services/storage/remote-storage.ts index 2a70c1d..1fd001a 100644 --- a/lib/services/storage/remote-storage.ts +++ b/lib/services/storage/remote-storage.ts @@ -12,6 +12,7 @@ export interface ApiNoteRequest { content: string; iv: string; password?: string; + authKey?: string; expiresIn?: string; manualDeletion?: boolean; } @@ -29,7 +30,7 @@ export default class RemoteStorage implements StorageProvider { } try { - const { encryptedContent, passwordHash, authKey } = await prepareEncryption(content, password); + const { encryptedContent, passwordHash, authKey, authKeyHash } = await prepareEncryption(content, password); const response = await fetch('/api/notes', { method: 'POST', @@ -37,6 +38,7 @@ export default class RemoteStorage implements StorageProvider { body: JSON.stringify({ content: encryptedContent.encrypted, password: passwordHash, + authKey: authKeyHash, expiresIn, manualDeletion, iv: encryptedContent.iv, @@ -48,7 +50,10 @@ export default class RemoteStorage implements StorageProvider { } const { noteId, ...res } = await response.json(); - const link = password ? noteId : `${noteId}#auth=${authKey}`; + // Always include auth key in URL to ensure all notes have URL-based access control, + // regardless of password protection. The auth key acts as a unique, unguessable token + // required to access the note, enhancing security. + const link = `${noteId}#auth=${authKey}`; return { success: true, noteId, authKey, message: res.message, link }; } catch (_err) { @@ -56,14 +61,15 @@ export default class RemoteStorage implements StorageProvider { } } - async get(noteId: string, password?: string): Promise { + async get(noteId: string, authKey?: string, password?: string): Promise { try { const passwordHash = password ? await generateDeterministicClientHash(password) : undefined; + const authKeyHash = authKey ? await generateDeterministicClientHash(authKey) : undefined; const response = await fetch(`/api/notes/${noteId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passwordHash }), + body: JSON.stringify({ passwordHash, authKeyHash }), }); if (!response.ok) { @@ -77,13 +83,15 @@ export default class RemoteStorage implements StorageProvider { } } - async delete(noteId: string, password: string): Promise { + async delete(noteId: string, authKey?: string, password?: string): Promise { try { - const passwordHash = await generateDeterministicClientHash(password); + const passwordHash = password ? await generateDeterministicClientHash(password) : undefined; + const authKeyHash = authKey ? await generateDeterministicClientHash(authKey) : undefined; + const response = await fetch(`/api/notes/${noteId}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passwordHash }), + body: JSON.stringify({ passwordHash, authKeyHash }), }); return response.ok ? { success: true } : { success: false, message: await response.text() }; diff --git a/lib/services/storage/storage-provider.ts b/lib/services/storage/storage-provider.ts index 69eba37..ce176f2 100644 --- a/lib/services/storage/storage-provider.ts +++ b/lib/services/storage/storage-provider.ts @@ -29,6 +29,6 @@ export interface CreateNoteData { export interface StorageProvider { create(data: CreateNoteData): Promise; - get(noteId: string, password?: string): Promise; - delete(noteId: string, password?: string): Promise; + get(noteId: string, authKey?: string, password?: string): Promise; + delete(noteId: string, authKey?: string, password?: string): Promise; } diff --git a/lib/types.ts b/lib/types.ts index 17b8a0c..8ddf32c 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -7,6 +7,7 @@ export interface Note { // Optional fields for additional functionality password?: string; // password hash for private notes + authKey?: string; // auth key hash for URL-based access manualDeletion?: boolean; // Flag for manual deletion } diff --git a/lib/validation/note.ts b/lib/validation/note.ts index 3d7f3f4..900d380 100644 --- a/lib/validation/note.ts +++ b/lib/validation/note.ts @@ -18,6 +18,7 @@ export enum MANUAL_DELETION_OPTIONS { export const NOTE_CONTENT_MAX_LENGTH = 1024 * 1024; // 1 MB export const NOTE_PASSWORD_MAX_LENGTH = 256; // 256 characters +export const NOTE_AUTH_KEY_MAX_LENGTH = 256; // 256 characters for auth key export const createNoteSchema = v.object({ content: v.pipe( @@ -38,6 +39,31 @@ export const createNoteSchema = v.object({ ), // Allow empty string for default value }); +export const createNoteServerSchema = v.object({ + content: v.pipe( + v.string(), + v.nonEmpty('Note content is required'), + v.maxLength(NOTE_CONTENT_MAX_LENGTH, 'Note content is too long (max 1MB)'), + ), + password: v.optional( + v.pipe( + v.string(), + v.maxLength(NOTE_PASSWORD_MAX_LENGTH, 'Password is too long (max 256 characters)'), + ), + ), // Optional password with max length + authKey: v.optional( + v.pipe( + v.string(), + v.maxLength(NOTE_AUTH_KEY_MAX_LENGTH, 'Auth key is too long (max 256 characters)'), + ), + ), // Optional auth key with max length + expiresIn: v.enum(EXPIRY_OPTIONS, 'Invalid expiration time. Please select a valid option.'), + manualDeletion: v.union( + [v.optional(v.enum(MANUAL_DELETION_OPTIONS)), v.boolean()], + 'Invalid manual deletion setting. Please select a valid option.', + ), // Allow empty string for default value +}); + export const viewNoteSchema = v.object({ password: v.pipe( v.string(), @@ -47,6 +73,7 @@ export const viewNoteSchema = v.object({ }); export type CreateNoteSchema = v.InferOutput; +export type CreateNoteServerSchema = v.InferOutput; export type ViewNoteSchema = v.InferOutput; export const expirationOptions: string[] = Object.values(EXPIRY_OPTIONS); diff --git a/routes/api/notes.ts b/routes/api/notes.ts index 2c07c31..8aef756 100644 --- a/routes/api/notes.ts +++ b/routes/api/notes.ts @@ -1,4 +1,4 @@ -import { createNoteSchema } from '../../lib/validation/note.ts'; +import { createNoteServerSchema } from '../../lib/validation/note.ts'; import { formatExpiration, Note } from '../../lib/types.ts'; import { mergeWithRateLimitHeaders } from '../../lib/rate-limiting/rate-limit-headers.ts'; import * as v from '@valibot/valibot'; @@ -46,11 +46,28 @@ export const handler = { } try { - const { content, iv, password, expiresIn, manualDeletion } = await ctx.req.json(); + const { content, iv, password, authKey, expiresIn, manualDeletion } = await ctx.req.json(); - // Validate input using valibot + // Validate IV directly (not through schema as it's system-generated data) + if (!iv || typeof iv !== 'string' || iv.trim() === '') { + return new Response( + JSON.stringify({ + message: 'Invalid request data', + error: 'IV is required for encryption', + }), + { + headers: mergeWithRateLimitHeaders( + { 'Content-Type': 'application/json' }, + rateLimitResult, + ), + status: 400, + }, + ); + } + + // Validate other input using valibot try { - v.parse(createNoteSchema, { content, iv, password, expiresIn, manualDeletion }); + v.parse(createNoteServerSchema, { content, password, authKey, expiresIn, manualDeletion }); } catch (err) { return new Response( JSON.stringify({ @@ -69,15 +86,18 @@ export const handler = { const noteId = await db.generateNoteId(); const hasPassword = password && password.trim() !== ''; + const hasAuthKey = authKey && authKey.trim() !== ''; - // if password is provided, hash it with bcrypt (password should be PBKDF2 hashed on client before sending) + // Hash password and auth key with bcrypt for server storage const passwordHash = hasPassword ? generateHash(password) : undefined; + const authKeyHash = hasAuthKey ? generateHash(authKey) : undefined; // check if content is encrypted const result: Note = { id: noteId, content, // content should be encrypted before sending to this endpoint - password: passwordHash, // password is PBKDF2 non-deterministic hashed on client, then bcrypt hashed on server for secure storage + password: passwordHash, // password is PBKDF2 deterministic hashed on client, then bcrypt hashed on server for secure storage + authKey: authKeyHash, // auth key is PBKDF2 deterministic hashed on client, then bcrypt hashed on server for secure storage iv: iv, expiresIn: formatExpiration(expiresIn), manualDeletion: manualDeletion, diff --git a/routes/api/notes/[id].ts b/routes/api/notes/[id].ts index ac9c33c..6d41ba2 100644 --- a/routes/api/notes/[id].ts +++ b/routes/api/notes/[id].ts @@ -39,14 +39,34 @@ export const handler = async (ctx: Context): Promise => { if (ctx.req.method === 'POST') { const note = await db.getNoteById(id); - const { passwordHash } = await ctx.req.json(); + const { passwordHash, authKeyHash } = await ctx.req.json(); - if (!note || !passwordHash) { - return new Response('Note not found or password hash missing', { status: 404 }); + if (!note) { + return new Response('Note not found', { status: 404 }); + } + + // Check authentication - need either password+authKey combo or just authKey (for legacy notes) + let isAuthenticated = false; + + if (note.password && note.authKey) { + // Note requires both password and auth key + if (passwordHash && authKeyHash) { + isAuthenticated = compareHash(passwordHash, note.password) && compareHash(authKeyHash, note.authKey); + } + } else if (note.authKey && !note.password) { + // Note only requires auth key (legacy or no-password notes) + if (authKeyHash) { + isAuthenticated = compareHash(authKeyHash, note.authKey); + } + } else if (note.password && !note.authKey) { + // Legacy note with only password (shouldn't happen with new system but handle gracefully) + if (passwordHash) { + isAuthenticated = compareHash(passwordHash, note.password); + } } - if (note.password && !compareHash(passwordHash, note.password)) { - return new Response('Invalid password or auth key', { status: 403 }); + if (!isAuthenticated) { + return new Response('Invalid authentication credentials', { status: 403 }); } // If the note doesn't require manual deletion, delete it to ensure it has been destroyed @@ -72,14 +92,35 @@ export const handler = async (ctx: Context): Promise => { ); } else if (ctx.req.method === 'DELETE') { const note = await db.getNoteById(id); - const { passwordHash } = await ctx.req.json(); + const { passwordHash, authKeyHash } = await ctx.req.json(); if (!note) { return new Response('Note not found', { status: 404 }); } - if (note.password && !compareHash(passwordHash, note.password)) { - return new Response('Invalid password or auth key', { status: 403 }); + // Check authentication for deletion - same logic as retrieval + let isAuthenticated = false; + + if (note.password && note.authKey) { + // Note requires both password and auth key + if (passwordHash && authKeyHash) { + isAuthenticated = compareHash(passwordHash, note.password) && compareHash(authKeyHash, note.authKey); + } + } else if (note.authKey && !note.password) { + // Note only requires auth key (legacy or no-password notes) + if (authKeyHash) { + isAuthenticated = compareHash(authKeyHash, note.authKey); + } + } else if (note.password && !note.authKey) { + // Legacy note with only password (shouldn't happen with new system but handle gracefully) + if (passwordHash) { + isAuthenticated = compareHash(passwordHash, note.password); + } + } + + if (!isAuthenticated) { + return new Response('Invalid authentication credentials', { status: 403 }); } + await db.deleteNote(id); return new Response( JSON.stringify({ diff --git a/tests/crypto-service_test.ts b/tests/crypto-service_test.ts new file mode 100644 index 0000000..70a574e --- /dev/null +++ b/tests/crypto-service_test.ts @@ -0,0 +1,77 @@ +import { assertEquals, assertExists } from '$std/assert/mod.ts'; +import { createDecryptionKey, prepareEncryption } from '../lib/services/crypto-service.ts'; +import { decryptNoteContent } from '../lib/encryption.ts'; + +Deno.test({ + name: 'Crypto Service - Combined auth key and password', + fn: async (t) => { + const testContent = 'This is a test note for combined authentication.'; + const testPassword = 'testpassword123'; + + await t.step('should always generate auth key', async () => { + // Test without password + const resultNoPassword = await prepareEncryption(testContent); + assertExists(resultNoPassword.authKey, 'Auth key should be generated even without password'); + assertExists(resultNoPassword.authKeyHash, 'Auth key hash should be generated'); + assertEquals( + resultNoPassword.passwordHash, + undefined, + 'Password hash should be undefined when no password', + ); + + // Test with password + const resultWithPassword = await prepareEncryption(testContent, testPassword); + assertExists(resultWithPassword.authKey, 'Auth key should be generated with password'); + assertExists(resultWithPassword.authKeyHash, 'Auth key hash should be generated'); + assertExists(resultWithPassword.passwordHash, 'Password hash should be generated when password provided'); + }); + + await t.step('should create correct decryption key', () => { + const authKey = 'testAuthKey123'; + + // Without password + const keyNoPassword = createDecryptionKey(authKey); + assertEquals(keyNoPassword, authKey, 'Decryption key should be just auth key when no password'); + + // With password + const keyWithPassword = createDecryptionKey(authKey, testPassword); + assertEquals( + keyWithPassword, + `${authKey}:${testPassword}`, + 'Decryption key should combine auth key and password', + ); + }); + + await t.step('should encrypt and decrypt correctly with combined key', async () => { + const { encryptedContent, authKey } = await prepareEncryption(testContent, testPassword); + assertExists(authKey, 'Auth key should be generated'); + + // Decrypt using combined key + const decryptionKey = createDecryptionKey(authKey, testPassword); + const decryptedContent = await decryptNoteContent( + encryptedContent.encrypted, + encryptedContent.iv, + decryptionKey, + ); + + assertEquals(decryptedContent, testContent, 'Decrypted content should match original'); + }); + + await t.step('should encrypt and decrypt correctly with auth key only', async () => { + const { encryptedContent, authKey } = await prepareEncryption(testContent); + assertExists(authKey, 'Auth key should be generated'); + + // Decrypt using auth key only + const decryptionKey = createDecryptionKey(authKey); + const decryptedContent = await decryptNoteContent( + encryptedContent.encrypted, + encryptedContent.iv, + decryptionKey, + ); + + assertEquals(decryptedContent, testContent, 'Decrypted content should match original'); + }); + }, + sanitizeResources: false, + sanitizeOps: false, +}); diff --git a/tests/main_test.ts b/tests/main_test.ts index c7dfcd3..9bf81d9 100644 --- a/tests/main_test.ts +++ b/tests/main_test.ts @@ -61,7 +61,10 @@ Deno.test({ await t.step('should create note via API', async () => { const passwordClientHash = await generateDeterministicClientHash(testData.password); - const encryptedContent = await encryptNoteContent(testData.content, testData.password); + const authKey = 'testAuthKey123'; + const authKeyClientHash = await generateDeterministicClientHash(authKey); + const encryptionKey = `${authKey}:${testData.password}`; // Combined key for encryption + const encryptedContent = await encryptNoteContent(testData.content, encryptionKey); const response = await handler( new Request(`http://localhost/api/notes`, { @@ -70,6 +73,7 @@ Deno.test({ content: encryptedContent.encrypted, iv: encryptedContent.iv, password: passwordClientHash, + authKey: authKeyClientHash, expiresIn: testData.expiresIn, }), headers: { 'Content-Type': 'application/json' }, @@ -86,11 +90,14 @@ Deno.test({ await t.step('should retrieve note by ID', async () => { const passwordHash = await generateDeterministicClientHash(testData.password); + const authKey = 'testAuthKey123'; + const authKeyHash = await generateDeterministicClientHash(authKey); + const response = await handler( new Request(`http://localhost/api/notes/${apiNoteId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ passwordHash }), + body: JSON.stringify({ passwordHash, authKeyHash }), }), );