Skip to content
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@
_fresh/
# npm dependencies
node_modules/

# Build artifacts and downloads
*.zip
deno
deno-*
62 changes: 62 additions & 0 deletions IMPLEMENTATION_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -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
84 changes: 59 additions & 25 deletions islands/ViewNote.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -57,6 +58,7 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp
const [confirmed, setConfirmed] = useState(manualDeletion ? true : false);
const [decryptionError, setDecryptionError] = useState<string | undefined>(undefined);
const [message, setMessage] = useState<string | undefined>(undefined);
const [passwordRequired, setPasswordRequired] = useState(false);

const password = useRef<string | undefined>(undefined);

Expand All @@ -75,25 +77,36 @@ 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(
result.note.manualDeletion ? MESSAGES.MANUAL_DELETION_PROMPT : MESSAGES.AUTO_DELETION_COMPLETE,
);
} 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);
}
}
};

Expand Down Expand Up @@ -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) => {
Expand All @@ -141,30 +154,42 @@ 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;
}

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);
Expand All @@ -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) {
Expand Down Expand Up @@ -213,7 +247,7 @@ export default function ViewEncryptedNote({ noteId, manualDeletion }: ViewEncryp
);
}

if (!confirmed) {
if (!confirmed && !passwordRequired) {
return <ConfirmViewNote onSubmit={() => setConfirmed(true)} />;
}

Expand Down Expand Up @@ -316,7 +350,7 @@ function PasswordRequiredView({ onSubmit, manualDeletion, error }: PasswordRequi
Enter Password
</div>
<p class='text-yellow-300 text-sm font-medium'>
This note is encrypted and requires a password
This note is password protected and requires both the URL auth key and password
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This message assumes the note requires both auth key and password, but it could be shown for auth-key-only notes as well. The message should be more generic or conditionally displayed based on the actual authentication requirements.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, hence every URL now should contain a URL fragment, and remove the double confirmation if the note is password-protected. Also shows an appropriate error when the note doesn't have a URL fragment. Make the ViewNote handling more organized

</p>
</CardHeader>

Expand Down
37 changes: 31 additions & 6 deletions lib/services/crypto-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 4 additions & 4 deletions lib/services/note-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
22 changes: 15 additions & 7 deletions lib/services/storage/remote-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface ApiNoteRequest {
content: string;
iv: string;
password?: string;
authKey?: string;
expiresIn?: string;
manualDeletion?: boolean;
}
Expand All @@ -29,14 +30,15 @@ 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: encryptedContent.encrypted,
password: passwordHash,
authKey: authKeyHash,
expiresIn,
manualDeletion,
iv: encryptedContent.iv,
Expand All @@ -48,22 +50,26 @@ 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) {
return { success: false, message: 'Failed to create note' };
}
}

async get(noteId: string, password?: string): Promise<GetEncryptedNoteResult> {
async get(noteId: string, authKey?: string, password?: string): Promise<GetEncryptedNoteResult> {
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) {
Expand All @@ -77,13 +83,15 @@ export default class RemoteStorage implements StorageProvider {
}
}

async delete(noteId: string, password: string): Promise<DeleteNoteResult> {
async delete(noteId: string, authKey?: string, password?: string): Promise<DeleteNoteResult> {
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() };
Expand Down
4 changes: 2 additions & 2 deletions lib/services/storage/storage-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ export interface CreateNoteData {

export interface StorageProvider {
create(data: CreateNoteData): Promise<CreateNoteResult>;
get(noteId: string, password?: string): Promise<GetEncryptedNoteResult>;
delete(noteId: string, password?: string): Promise<DeleteNoteResult>;
get(noteId: string, authKey?: string, password?: string): Promise<GetEncryptedNoteResult>;
delete(noteId: string, authKey?: string, password?: string): Promise<DeleteNoteResult>;
}
Loading
Loading