Release 0.1: Complete BinectChrome implementation

Implements all requirements from ProductRequirementsDocument.md:
- PDF detection via Chrome Downloads API
- Secure credential storage with AES-GCM encryption
- Binect API integration for PDF uploads
- Popup UI with Binect branding
- Local transfer tracking (500 entry cap)
- Help page with tracking view and CSV export
- 60-day credential retention with auto-expiry
- Accessibility compliance (WCAG 2.1 AA)

Technical implementation:
- Chrome Extension Manifest V3
- TypeScript with strict mode
- Webpack build system
- Jest test suite (22/22 passing)
- ESLint configured (0 errors)

Build output: 13 KB total (production minified)
Test coverage: crypto, pdf-detector, tracker, binect-api

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-13 00:30:39 +01:00
parent 8f85c51d4e
commit b09290cb83
43 changed files with 12078 additions and 2 deletions

137
src/utils/binect-api.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* Binect API client
*/
const API_BASE_URL = 'https://api.binect.de';
export interface AuthToken {
token: string;
expiresAt: string;
}
export interface UploadResult {
documentId: string;
status: string;
uploadedAt: string;
}
export class BinectAPIError extends Error {
constructor(
message: string,
public statusCode?: number,
public response?: unknown
) {
super(message);
this.name = 'BinectAPIError';
}
}
/**
* Authenticate with Binect API
*/
export async function authenticate(
username: string,
password: string
): Promise<AuthToken> {
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ username, password })
});
if (!response.ok) {
if (response.status === 401) {
throw new BinectAPIError('Invalid credentials', 401);
}
throw new BinectAPIError(
`Authentication failed: ${response.statusText}`,
response.status
);
}
return await response.json();
} catch (error) {
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Upload PDF to Binect
*/
export async function uploadPDF(
pdfData: ArrayBuffer,
filename: string,
token: string
): Promise<UploadResult> {
try {
const formData = new FormData();
const blob = new Blob([pdfData], { type: 'application/pdf' });
formData.append('file', blob, filename);
formData.append('filename', filename);
const response = await fetch(`${API_BASE_URL}/documents/upload`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`
},
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401) {
throw new BinectAPIError('Authentication required', 401, errorData);
}
if (response.status === 400) {
throw new BinectAPIError(
errorData.error || 'Invalid file format',
400,
errorData
);
}
if (response.status === 413) {
throw new BinectAPIError('File size exceeds limit', 413, errorData);
}
throw new BinectAPIError(
`Upload failed: ${response.statusText}`,
response.status,
errorData
);
}
return await response.json();
} catch (error) {
if (error instanceof BinectAPIError) {
throw error;
}
throw new BinectAPIError(
`Network error: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
/**
* Test API connectivity
*/
export async function testConnection(): Promise<boolean> {
try {
const response = await fetch(`${API_BASE_URL}/health`, {
method: 'GET'
});
return response.ok;
} catch {
return false;
}
}

129
src/utils/crypto.ts Normal file
View File

@@ -0,0 +1,129 @@
/**
* Cryptographic utilities for credential encryption
* Uses Web Crypto API with AES-GCM encryption
*/
const ALGORITHM = 'AES-GCM';
const KEY_LENGTH = 256;
const IV_LENGTH = 12; // 96 bits for GCM
export interface EncryptedData {
ciphertext: string; // Base64 encoded
iv: string; // Base64 encoded
}
/**
* Generate a new AES-GCM encryption key
*/
export async function generateEncryptionKey(): Promise<CryptoKey> {
return await crypto.subtle.generateKey(
{
name: ALGORITHM,
length: KEY_LENGTH
},
true, // extractable
['encrypt', 'decrypt']
);
}
/**
* Export key to raw format for storage
*/
export async function exportKey(key: CryptoKey): Promise<string> {
const exported = await crypto.subtle.exportKey('raw', key);
return arrayBufferToBase64(exported);
}
/**
* Import key from raw format
*/
export async function importKey(keyData: string): Promise<CryptoKey> {
const buffer = base64ToArrayBuffer(keyData);
return await crypto.subtle.importKey(
'raw',
buffer,
{
name: ALGORITHM,
length: KEY_LENGTH
},
true,
['encrypt', 'decrypt']
);
}
/**
* Encrypt data using AES-GCM
*/
export async function encrypt(
data: string,
key: CryptoKey
): Promise<EncryptedData> {
// Generate random IV
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
// Encode data
const encoder = new TextEncoder();
const encodedData = encoder.encode(data);
// Encrypt
const ciphertext = await crypto.subtle.encrypt(
{
name: ALGORITHM,
iv: iv
},
key,
encodedData
);
return {
ciphertext: arrayBufferToBase64(ciphertext),
iv: arrayBufferToBase64(iv)
};
}
/**
* Decrypt data using AES-GCM
*/
export async function decrypt(
encryptedData: EncryptedData,
key: CryptoKey
): Promise<string> {
const ciphertext = base64ToArrayBuffer(encryptedData.ciphertext);
const iv = base64ToArrayBuffer(encryptedData.iv);
const decrypted = await crypto.subtle.decrypt(
{
name: ALGORITHM,
iv: new Uint8Array(iv)
},
key,
new Uint8Array(ciphertext)
);
const decoder = new TextDecoder();
return decoder.decode(decrypted);
}
/**
* Convert ArrayBuffer to Base64 string
*/
function arrayBufferToBase64(buffer: ArrayBuffer | Uint8Array): string {
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/**
* Convert Base64 string to ArrayBuffer
*/
function base64ToArrayBuffer(base64: string): ArrayBuffer {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer as ArrayBuffer;
}

125
src/utils/pdf-detector.ts Normal file
View File

@@ -0,0 +1,125 @@
/**
* PDF detection system
* Detects PDF downloads using Chrome Downloads API
*/
export interface DetectedPDF {
id: string; // unique identifier
filename: string;
url: string; // original URL
size: number; // bytes
timestamp: number; // detection time
sourceDomain: string; // domain where PDF originated
}
/**
* Check if a download item is a PDF
*/
function isPDF(item: chrome.downloads.DownloadItem): boolean {
// Check file extension
if (item.filename.toLowerCase().endsWith('.pdf')) {
return true;
}
// Check MIME type
if (item.mime === 'application/pdf') {
return true;
}
return false;
}
/**
* Extract domain from URL
*/
function extractDomain(url: string): string {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
return 'unknown';
}
}
/**
* Convert download item to DetectedPDF
*/
function downloadItemToPDF(item: chrome.downloads.DownloadItem): DetectedPDF {
return {
id: `download-${item.id}`,
filename: item.filename.split('/').pop() || item.filename,
url: item.url,
size: item.fileSize,
timestamp: Date.now(),
sourceDomain: extractDomain(item.url)
};
}
/**
* Start listening for PDF downloads
*/
export function startPDFDetection(
onPDFDetected: (pdf: DetectedPDF) => void
): void {
// Listen for download changes
chrome.downloads.onChanged.addListener((delta) => {
// Only process completed downloads
if (delta.state?.current !== 'complete') {
return;
}
// Get full download item details
chrome.downloads.search({ id: delta.id }, (items) => {
if (items.length === 0) return;
const item = items[0];
if (isPDF(item)) {
const pdf = downloadItemToPDF(item);
onPDFDetected(pdf);
}
});
});
}
/**
* Get the most recent PDF download
*/
export async function getLastPDFDownload(): Promise<DetectedPDF | null> {
return new Promise((resolve) => {
chrome.downloads.search(
{
limit: 100, // check last 100 downloads
orderBy: ['-startTime']
},
(items) => {
const pdfItem = items.find(isPDF);
if (pdfItem && pdfItem.state === 'complete') {
resolve(downloadItemToPDF(pdfItem));
} else {
resolve(null);
}
}
);
});
}
/**
* Fetch PDF bytes from original URL
* Uses the user's session cookies automatically
*/
export async function fetchPDFBytes(url: string): Promise<ArrayBuffer> {
const response = await fetch(url, {
credentials: 'include' // include cookies
});
if (!response.ok) {
throw new Error(`Failed to fetch PDF: ${response.status} ${response.statusText}`);
}
const contentType = response.headers.get('Content-Type');
if (contentType && !contentType.includes('application/pdf')) {
throw new Error(`URL did not return a PDF (got ${contentType})`);
}
return await response.arrayBuffer();
}

117
src/utils/storage.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Storage utilities for credentials and configuration
*/
import { encrypt, decrypt, generateEncryptionKey, exportKey, importKey, EncryptedData } from './crypto';
const STORAGE_KEYS = {
ENCRYPTION_KEY: 'encryptionKey',
CREDENTIALS: 'credentials',
LAST_USE: 'lastCredentialUse'
};
export interface Credentials {
username: string;
password: string;
}
interface StoredCredentials {
encrypted: EncryptedData;
lastUse: number; // timestamp
}
/**
* Initialize encryption key if not exists
*/
async function ensureEncryptionKey(): Promise<CryptoKey> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.ENCRYPTION_KEY);
if (stored[STORAGE_KEYS.ENCRYPTION_KEY]) {
return await importKey(stored[STORAGE_KEYS.ENCRYPTION_KEY]);
}
// Generate new key
const key = await generateEncryptionKey();
const exported = await exportKey(key);
await chrome.storage.local.set({ [STORAGE_KEYS.ENCRYPTION_KEY]: exported });
return key;
}
/**
* Save credentials encrypted
*/
export async function saveCredentials(credentials: Credentials): Promise<void> {
const key = await ensureEncryptionKey();
const data = JSON.stringify(credentials);
const encrypted = await encrypt(data, key);
const storedData: StoredCredentials = {
encrypted,
lastUse: Date.now()
};
await chrome.storage.local.set({ [STORAGE_KEYS.CREDENTIALS]: storedData });
}
/**
* Load and decrypt credentials
* Returns null if no credentials stored or expired
*/
export async function loadCredentials(): Promise<Credentials | null> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.CREDENTIALS);
const storedData: StoredCredentials | undefined = stored[STORAGE_KEYS.CREDENTIALS];
if (!storedData) {
return null;
}
// Check expiry (60 days = 60 * 24 * 60 * 60 * 1000 ms)
const SIXTY_DAYS_MS = 60 * 24 * 60 * 60 * 1000;
const now = Date.now();
if (now - storedData.lastUse > SIXTY_DAYS_MS) {
// Credentials expired, delete them
await deleteCredentials();
return null;
}
try {
const key = await ensureEncryptionKey();
const decrypted = await decrypt(storedData.encrypted, key);
return JSON.parse(decrypted);
} catch (error) {
console.error('Failed to decrypt credentials:', error);
// If decryption fails, delete corrupted data
await deleteCredentials();
return null;
}
}
/**
* Update last use timestamp
*/
export async function updateLastUse(): Promise<void> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.CREDENTIALS);
const storedData: StoredCredentials | undefined = stored[STORAGE_KEYS.CREDENTIALS];
if (storedData) {
storedData.lastUse = Date.now();
await chrome.storage.local.set({ [STORAGE_KEYS.CREDENTIALS]: storedData });
}
}
/**
* Delete stored credentials
*/
export async function deleteCredentials(): Promise<void> {
await chrome.storage.local.remove(STORAGE_KEYS.CREDENTIALS);
}
/**
* Check if credentials exist (without decrypting)
*/
export async function hasCredentials(): Promise<boolean> {
const stored = await chrome.storage.local.get(STORAGE_KEYS.CREDENTIALS);
return !!stored[STORAGE_KEYS.CREDENTIALS];
}