Skip to content

Commit 14aa5e8

Browse files
nullcoderclaude
andauthored
feat: add encryption helper utilities and base64 module (#49)
- Create crypto-utils.ts with high-level encryption functions - encryptGist: simplified gist encryption with metadata handling - decryptGist: simplified gist decryption - generateShareableUrl: create URLs with encryption keys - extractKeyFromUrl: extract keys from URL fragments - validateGistPin: PIN validation for gist editing - createGist: complete gist creation with shareable URLs - loadGistFromUrl: load and decrypt gists from URLs - Add base64.ts module to consolidate encoding/decoding - base64Encode/Decode: standard base64 for internal storage - base64UrlEncode/Decode: URL-safe base64 for keys in URLs - isValidBase64: validation utility - Replaces redundant implementations in crypto.ts and auth.ts - Refactor to use common utilities - Update crypto.ts to use base64 module - Update auth.ts to use base64 module - Replace custom generateGistId with generateShortId from id.ts - Add comprehensive test suites - 28 tests for crypto-utils covering all functions - 23 tests for base64 utilities - Tests include edge cases, Unicode, large files Closes #40 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent b03260a commit 14aa5e8

File tree

7 files changed

+1219
-59
lines changed

7 files changed

+1219
-59
lines changed

docs/TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks
9393
### Integration Testing
9494

9595
- [x] Create integration tests for encryption workflow - [#39](https://github.com/nullcoder/ghostpaste/issues/39)
96-
- [ ] Add encryption helper utilities - [#40](https://github.com/nullcoder/ghostpaste/issues/40)
96+
- [x] Add encryption helper utilities - [#40](https://github.com/nullcoder/ghostpaste/issues/40)
9797
- [ ] Document encryption architecture - [#41](https://github.com/nullcoder/ghostpaste/issues/41)
9898

9999
## 🎨 Phase 4: UI Components

lib/auth.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import { BadRequestError, UnauthorizedError } from "./errors";
99
import { logger } from "./logger";
10+
import { base64Encode, base64Decode } from "./base64";
1011

1112
/**
1213
* Configuration for PBKDF2 hashing
@@ -46,7 +47,7 @@ export async function generateSalt(): Promise<string> {
4647
crypto.getRandomValues(saltBuffer);
4748

4849
// Convert to base64 for storage
49-
const salt = btoa(String.fromCharCode(...saltBuffer));
50+
const salt = base64Encode(saltBuffer);
5051

5152
logger.debug("Generated salt for PIN hashing", {
5253
saltLength: SALT_LENGTH,
@@ -81,7 +82,7 @@ export async function hashPin(pin: string, salt: string): Promise<string> {
8182
const pinBuffer = encoder.encode(pin);
8283

8384
// Decode salt from base64
84-
const saltBuffer = Uint8Array.from(atob(salt), (c) => c.charCodeAt(0));
85+
const saltBuffer = base64Decode(salt);
8586

8687
// Import PIN as key material
8788
const keyMaterial = await crypto.subtle.importKey(
@@ -106,7 +107,7 @@ export async function hashPin(pin: string, salt: string): Promise<string> {
106107

107108
// Convert to base64 for storage
108109
const hashArray = new Uint8Array(derivedBits);
109-
const hash = btoa(String.fromCharCode(...hashArray));
110+
const hash = base64Encode(hashArray);
110111

111112
logger.debug("Successfully hashed PIN", {
112113
iterations: PBKDF2_CONFIG.iterations,
@@ -141,8 +142,8 @@ export async function validatePin(
141142
const computedHash = await hashPin(pin, salt);
142143

143144
// Constant-time comparison to prevent timing attacks
144-
const storedBytes = atob(storedHash);
145-
const computedBytes = atob(computedHash);
145+
const storedBytes = base64Decode(storedHash);
146+
const computedBytes = base64Decode(computedHash);
146147

147148
// Ensure both are the same length
148149
if (storedBytes.length !== computedBytes.length) {
@@ -152,7 +153,7 @@ export async function validatePin(
152153
// XOR all bytes and accumulate differences
153154
let difference = 0;
154155
for (let i = 0; i < storedBytes.length; i++) {
155-
difference |= storedBytes.charCodeAt(i) ^ computedBytes.charCodeAt(i);
156+
difference |= storedBytes[i] ^ computedBytes[i];
156157
}
157158

158159
// If difference is 0, hashes match

lib/base64.test.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { describe, it, expect, vi } from "vitest";
2+
import {
3+
base64Encode,
4+
base64Decode,
5+
base64UrlEncode,
6+
base64UrlDecode,
7+
isValidBase64,
8+
} from "./base64";
9+
10+
// Mock logger
11+
vi.mock("./logger", () => ({
12+
logger: {
13+
error: vi.fn(),
14+
},
15+
}));
16+
17+
describe("Base64 Utilities", () => {
18+
describe("base64Encode", () => {
19+
it("should encode simple data", () => {
20+
const data = new Uint8Array([1, 2, 3, 4, 5]);
21+
const encoded = base64Encode(data);
22+
expect(encoded).toBe("AQIDBAU=");
23+
});
24+
25+
it("should encode empty data", () => {
26+
const data = new Uint8Array(0);
27+
const encoded = base64Encode(data);
28+
expect(encoded).toBe("");
29+
});
30+
31+
it("should encode large data", () => {
32+
// Create 10KB of data
33+
const data = new Uint8Array(10000);
34+
for (let i = 0; i < data.length; i++) {
35+
data[i] = i % 256;
36+
}
37+
const encoded = base64Encode(data);
38+
expect(encoded).toBeTruthy();
39+
expect(encoded.length).toBeGreaterThan(10000);
40+
});
41+
42+
it("should handle binary data with all byte values", () => {
43+
const data = new Uint8Array(256);
44+
for (let i = 0; i < 256; i++) {
45+
data[i] = i;
46+
}
47+
const encoded = base64Encode(data);
48+
const decoded = base64Decode(encoded);
49+
expect(decoded).toEqual(data);
50+
});
51+
});
52+
53+
describe("base64Decode", () => {
54+
it("should decode simple data", () => {
55+
const decoded = base64Decode("AQIDBAU=");
56+
expect(decoded).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
57+
});
58+
59+
it("should decode empty string", () => {
60+
const decoded = base64Decode("");
61+
expect(decoded).toEqual(new Uint8Array(0));
62+
});
63+
64+
it("should throw on invalid base64", () => {
65+
expect(() => base64Decode("!@#$%")).toThrow(
66+
"Failed to decode data from base64"
67+
);
68+
});
69+
70+
it("should handle padding correctly", () => {
71+
const cases = [
72+
{ encoded: "YQ==", expected: new Uint8Array([97]) }, // "a"
73+
{ encoded: "YWI=", expected: new Uint8Array([97, 98]) }, // "ab"
74+
{ encoded: "YWJj", expected: new Uint8Array([97, 98, 99]) }, // "abc"
75+
];
76+
77+
for (const { encoded, expected } of cases) {
78+
const decoded = base64Decode(encoded);
79+
expect(decoded).toEqual(expected);
80+
}
81+
});
82+
});
83+
84+
describe("base64UrlEncode", () => {
85+
it("should encode to URL-safe format", () => {
86+
// Data that would produce + and / in standard base64
87+
const data = new Uint8Array([255, 254, 253, 252, 251]);
88+
const encoded = base64UrlEncode(data);
89+
90+
// Should not contain URL-unsafe characters
91+
expect(encoded).not.toContain("+");
92+
expect(encoded).not.toContain("/");
93+
expect(encoded).not.toContain("=");
94+
95+
// Should contain URL-safe replacements
96+
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
97+
});
98+
99+
it("should remove padding", () => {
100+
const data = new Uint8Array([1]);
101+
const standardEncoded = base64Encode(data);
102+
const urlEncoded = base64UrlEncode(data);
103+
104+
expect(standardEncoded).toContain("=");
105+
expect(urlEncoded).not.toContain("=");
106+
});
107+
108+
it("should be reversible", () => {
109+
const data = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
110+
const encoded = base64UrlEncode(data);
111+
const decoded = base64UrlDecode(encoded);
112+
expect(decoded).toEqual(data);
113+
});
114+
});
115+
116+
describe("base64UrlDecode", () => {
117+
it("should decode URL-safe format", () => {
118+
// Test with known URL-safe encoded value
119+
const decoded = base64UrlDecode("_-79_Ps");
120+
expect(decoded).toBeTruthy();
121+
expect(decoded.length).toBeGreaterThan(0);
122+
});
123+
124+
it("should handle missing padding", () => {
125+
// These would need padding in standard base64
126+
const cases = [
127+
"YQ", // Would be "YQ==" in standard base64
128+
"YWI", // Would be "YWI=" in standard base64
129+
"YWJj", // No padding needed
130+
];
131+
132+
for (const encoded of cases) {
133+
const decoded = base64UrlDecode(encoded);
134+
expect(decoded).toBeTruthy();
135+
}
136+
});
137+
138+
it("should convert URL-safe characters", () => {
139+
// Manually create a URL-safe string with - and _
140+
const urlSafe = "ab-cd_ef";
141+
const decoded = base64UrlDecode(urlSafe);
142+
143+
// Re-encode to verify it works
144+
const reencoded = base64UrlEncode(decoded);
145+
expect(reencoded).toBeTruthy();
146+
});
147+
148+
it("should throw on invalid input", () => {
149+
expect(() => base64UrlDecode("!@#$%")).toThrow(
150+
"Failed to decode data from base64url"
151+
);
152+
});
153+
});
154+
155+
describe("isValidBase64", () => {
156+
describe("standard base64", () => {
157+
it("should accept valid base64", () => {
158+
expect(isValidBase64("AQIDBAU=")).toBe(true);
159+
expect(isValidBase64("YWJjZGVmZ2hpams=")).toBe(true);
160+
expect(isValidBase64("YQ==")).toBe(true);
161+
expect(isValidBase64("")).toBe(true);
162+
});
163+
164+
it("should reject invalid base64", () => {
165+
expect(isValidBase64("!@#$%")).toBe(false);
166+
expect(isValidBase64("ABC DEF")).toBe(false);
167+
expect(isValidBase64("===")).toBe(false);
168+
expect(isValidBase64(null as unknown as string)).toBe(false);
169+
expect(isValidBase64(undefined as unknown as string)).toBe(false);
170+
});
171+
172+
it("should accept base64 with + and /", () => {
173+
expect(isValidBase64("ab+cd/ef==")).toBe(true);
174+
expect(isValidBase64("+/+/+/==")).toBe(true);
175+
});
176+
});
177+
178+
describe("URL-safe base64", () => {
179+
it("should accept valid URL-safe base64", () => {
180+
expect(isValidBase64("AQIDBAU", true)).toBe(true);
181+
expect(isValidBase64("ab-cd_ef", true)).toBe(true);
182+
expect(isValidBase64("", true)).toBe(true);
183+
});
184+
185+
it("should reject standard base64 characters", () => {
186+
expect(isValidBase64("ab+cd/ef", true)).toBe(false);
187+
expect(isValidBase64("AQIDBAU=", true)).toBe(false);
188+
});
189+
190+
it("should reject invalid characters", () => {
191+
expect(isValidBase64("!@#$%", true)).toBe(false);
192+
expect(isValidBase64("ABC DEF", true)).toBe(false);
193+
});
194+
});
195+
});
196+
197+
describe("Round-trip encoding/decoding", () => {
198+
it("should handle various data sizes", () => {
199+
const sizes = [0, 1, 16, 100, 1000, 10000];
200+
201+
for (const size of sizes) {
202+
const data = new Uint8Array(size);
203+
crypto.getRandomValues(data);
204+
205+
// Test standard base64
206+
const encoded = base64Encode(data);
207+
const decoded = base64Decode(encoded);
208+
expect(decoded).toEqual(data);
209+
210+
// Test URL-safe base64
211+
const urlEncoded = base64UrlEncode(data);
212+
const urlDecoded = base64UrlDecode(urlEncoded);
213+
expect(urlDecoded).toEqual(data);
214+
}
215+
});
216+
217+
it("should handle Unicode strings when encoded as UTF-8", () => {
218+
const text = "Hello 世界! 🌍 Привет мир!";
219+
const encoder = new TextEncoder();
220+
const data = encoder.encode(text);
221+
222+
// Standard base64
223+
const encoded = base64Encode(data);
224+
const decoded = base64Decode(encoded);
225+
const decodedText = new TextDecoder().decode(decoded);
226+
expect(decodedText).toBe(text);
227+
228+
// URL-safe base64
229+
const urlEncoded = base64UrlEncode(data);
230+
const urlDecoded = base64UrlDecode(urlEncoded);
231+
const urlDecodedText = new TextDecoder().decode(urlDecoded);
232+
expect(urlDecodedText).toBe(text);
233+
});
234+
});
235+
});

0 commit comments

Comments
 (0)