Skip to content

Commit 5a1371c

Browse files
committed
feat: upgrade link crypto and opening flow
1 parent 75fc6cb commit 5a1371c

24 files changed

Lines changed: 892 additions & 239 deletions

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
# SecURL
22

3-
SecURL is a self-hosted service for creating encrypted, protected links. The destination is encrypted in the browser, and the URL fragment needed to open it is never sent to the server. The server only keeps an opaque storage key and the encrypted envelope.
4-
5-
It works as a single Go service with the frontend embedded, so the default setup is intentionally small.
3+
SecURL is a secure URL shortener that respects user privacy. Links are encrypted in your browser, so the server cannot read their destinations.
64

75
## Quick start
86

@@ -37,6 +35,10 @@ The standalone command loads `.env` from the current working directory. Environm
3735

3836
Start with `.env.example`, then use the [configuration guide](docs/configuration.md) when you need persistent storage, a public deployment, an external frontend, Safe Browsing, or CAPTCHA. The guide includes the default, a working example, and the important constraints for every supported variable.
3937

38+
## Cryptography
39+
40+
The current browser-side key derivation, envelope encryption, padding, password, and CAPTCHA specifications are documented in the [cryptography guide](docs/cryptography.md).
41+
4042
## Useful commands
4143

4244
```sh

docs/cryptography.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Cryptography
2+
3+
SecURL protocol version 2 keeps the destination encrypted in the browser. The server receives a derived storage key, authenticated metadata, and ciphertext. The 64-bit fragment ID remains in the URL fragment and is not sent in HTTP requests.
4+
5+
## Link root key
6+
7+
The browser generates an eight-byte random ID and encodes it as an 11-character Base62 fragment. The normalized service domain is lowercase, IDNA-canonical, and has trailing dots removed.
8+
9+
The Argon2id salt is the first 16 bytes of:
10+
11+
```text
12+
SHA3-256("v2-root-key\0" || normalized_service_domain)
13+
```
14+
15+
The root key is derived with this fixed profile:
16+
17+
```text
18+
Argon2id v1.3
19+
password = id_bytes
20+
salt = root_key_salt
21+
m = 32768 KiB
22+
t = 2
23+
p = 1
24+
output = 32 bytes
25+
```
26+
27+
The domain and protocol context prevent generic precomputed tables from being reused across domains or protocol namespaces. The mapping remains deterministic within one domain, so a domain-specific exhaustive search is still possible in principle; Argon2id makes each candidate memory-hard rather than increasing the 64-bit ID entropy.
28+
29+
## Storage and encryption subkeys
30+
31+
The root key is separated into independent 32-byte subkeys with HKDF-SHA3-256:
32+
33+
```text
34+
storage_key = HKDF-SHA3-256(
35+
IKM = root_key,
36+
salt = "v2-storage-key\0" || normalized_service_domain,
37+
info = empty,
38+
L = 32
39+
)
40+
41+
encryption_key_material = HKDF-SHA3-256(
42+
IKM = root_key,
43+
salt = "v2-encryption-key",
44+
info = empty,
45+
L = 32
46+
)
47+
```
48+
49+
The storage key is Base64URL-encoded without padding for API lookup. The server never needs the fragment ID or root key.
50+
51+
Each envelope has a random 24-byte payload nonce. The final payload key is:
52+
53+
```text
54+
final_key = HKDF-SHA3-256(
55+
IKM = encryption_key_material,
56+
salt = id_bytes || payload_nonce,
57+
info = empty,
58+
L = 32
59+
)
60+
```
61+
62+
## Payload encryption
63+
64+
The canonical destination URL is serialized in the protobuf `Payload` message. Zero padding is appended after the URL with these rules:
65+
66+
- URL plus padding never exceeds 4096 UTF-8 bytes.
67+
- Padding is between 0 and 128 NUL bytes.
68+
- Candidate padding lengths align the padded URL length to a 32-byte boundary when space permits.
69+
- Decryption ignores the first NUL byte and everything after it.
70+
71+
The payload is encrypted with XChaCha20-Poly1305:
72+
73+
```text
74+
ciphertext_0 = XChaCha20-Poly1305(
75+
key = final_key,
76+
nonce = payload_nonce,
77+
data = protobuf_payload,
78+
AAD = canonical_envelope_metadata
79+
)
80+
```
81+
82+
The authenticated metadata contains protocol version 2, feature flags, TTL, the payload nonce, and the metadata required by optional password and CAPTCHA layers. Any metadata modification invalidates the AEAD tag.
83+
84+
## Password layer
85+
86+
Password protection uses a random 16-byte salt and Argon2id v1.3:
87+
88+
```text
89+
m = 65536 KiB
90+
t = 3
91+
p = 1
92+
output = 32 bytes
93+
```
94+
95+
The derived password key encrypts the payload ciphertext with a separate random 24-byte XChaCha20-Poly1305 nonce and the same authenticated metadata:
96+
97+
```text
98+
ciphertext_1 = XChaCha20-Poly1305(password_key, password_nonce, ciphertext_0, AAD)
99+
```
100+
101+
The per-link password salt prevents password precomputation from being reused across links. Password strength still determines resistance to dictionary attacks.
102+
103+
## CAPTCHA layer
104+
105+
CAPTCHA protection uses an independent random 32-byte client key and another random 24-byte XChaCha20-Poly1305 nonce:
106+
107+
```text
108+
ciphertext_2 = XChaCha20-Poly1305(captcha_key, captcha_nonce, ciphertext_1, AAD)
109+
```
110+
111+
The server wraps the CAPTCHA key with AES-256-GCM under `SECURL_CAPTCHA_WRAP_KEY`. The wrapping AAD is the storage key followed by the big-endian protocol version. After successful protected access, the server unwraps and returns the CAPTCHA key to the browser.
112+
113+
CAPTCHA is an access-control layer, not a confidentiality boundary against the server operator that controls the wrapping key.
114+
115+
## Decryption order
116+
117+
The browser performs the inverse operations:
118+
119+
1. Decode the 64-bit fragment ID.
120+
2. Derive the Argon2id root key and HKDF subkeys.
121+
3. Fetch and validate protocol version 2 metadata.
122+
4. Obtain the CAPTCHA key when required.
123+
5. Derive the password key when required.
124+
6. Remove the CAPTCHA layer.
125+
7. Remove the password layer.
126+
8. Derive the final payload key and decrypt the payload.
127+
9. Remove NUL padding and validate the destination URL again.
128+
129+
Temporary root, encryption, password, CAPTCHA, payload, and plaintext byte arrays are zero-filled at their final use sites where the runtime exposes mutable storage.

frontend/e2e/securl.spec.ts

Lines changed: 136 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ test('creator CAPTCHA retries after the provider script fails to load', async ({
139139
await expect(page.getByRole('heading', { name: 'Protected link ready' })).toBeVisible();
140140
});
141141

142-
test('clean lookup redirects only after the five-second gate and leaks no fragment or plaintext', async ({
142+
test('clean lookup runs in the final second and redirects immediately without leaking secrets', async ({
143143
page,
144144
context
145145
}) => {
@@ -158,22 +158,39 @@ test('clean lookup redirects only after the five-second gate and leaks no fragme
158158
expect(createRequest.captchaToken).toBe('e2e-token');
159159

160160
const openPage = await context.newPage();
161-
let scanCompletedAt = 0;
161+
let scanRequestedAt = 0;
162162
let destinationRequestedAt = 0;
163+
await openPage.route('**/api/v1/envelopes/**', async (route) => {
164+
const request = route.request();
165+
if (request.method() === 'GET') {
166+
const { promise, resolve } = Promise.withResolvers<void>();
167+
setTimeout(resolve, request.url().endsWith('/metadata') ? 700 : 1200);
168+
await promise;
169+
}
170+
await route.continue();
171+
});
163172
await openPage.route(safeLookupPattern, async (route) => {
164-
scanCompletedAt = Date.now();
173+
scanRequestedAt = Date.now();
165174
await route.fulfill({ status: 200, contentType: 'application/x-protobuf', body: lookupResponse() });
166175
});
167176
await openPage.route(destination, async (route) => {
168177
destinationRequestedAt = Date.now();
169178
await route.fulfill({ status: 200, contentType: 'text/html', body: '<title>clean destination</title>' });
170179
});
180+
const navigationStartedAt = Date.now();
171181
await openPage.goto(link);
172182
await expect(openPage.getByRole('heading', { name: 'Checking destination safety' })).toBeVisible();
173-
await openPage.waitForTimeout(4500);
183+
await expect(openPage.getByText('Decrypting destination…')).toBeVisible();
184+
await expect(openPage.locator('code.hostname')).toHaveText('example.com');
185+
const remainingBeforeScanBoundary = 3500 - (Date.now() - navigationStartedAt);
186+
if (remainingBeforeScanBoundary > 0) await openPage.waitForTimeout(remainingBeforeScanBoundary);
174187
expect(openPage.url()).toBe(link);
188+
expect(scanRequestedAt).toBe(0);
175189
await expect(openPage).toHaveURL(destination, { timeout: 3000 });
176-
expect(destinationRequestedAt - scanCompletedAt).toBeGreaterThanOrEqual(4800);
190+
expect(scanRequestedAt - navigationStartedAt).toBeGreaterThanOrEqual(3800);
191+
expect(scanRequestedAt - navigationStartedAt).toBeLessThan(5000);
192+
expect(destinationRequestedAt).toBeGreaterThanOrEqual(scanRequestedAt);
193+
expect(destinationRequestedAt - scanRequestedAt).toBeLessThan(1000);
177194
});
178195

179196
test('New link leaves the open-link state and returns to the creator', async ({ page, context }) => {
@@ -191,6 +208,65 @@ test('New link leaves the open-link state and returns to the creator', async ({
191208
await expect(openPage.getByRole('heading', { name: 'Open a protected link' })).toHaveCount(0);
192209
});
193210

211+
test('wrong password retries locally without refetching a non-burn envelope', async ({ page, context }) => {
212+
const destination = 'https://example.com/password-retry';
213+
const password = 'local-retry-password';
214+
const { link } = await createProtectedLink(page, destination, { password });
215+
const openPage = await context.newPage();
216+
let envelopeRequests = 0;
217+
openPage.on('request', (request) => {
218+
if (
219+
request.method() === 'GET' &&
220+
request.url().includes('/api/v1/envelopes/') &&
221+
!request.url().endsWith('/metadata')
222+
) {
223+
envelopeRequests += 1;
224+
}
225+
});
226+
227+
await openPage.goto(link);
228+
await expect(openPage.getByRole('heading', { name: 'Password required' })).toBeVisible();
229+
await openPage.getByLabel('Password', { exact: true }).fill('wrong password');
230+
await openPage.getByRole('button', { name: 'Continue' }).click();
231+
await expect(openPage.getByRole('alert')).toHaveText('Incorrect password. Try again.');
232+
expect(envelopeRequests).toBe(1);
233+
234+
await openPage.getByLabel('Password', { exact: true }).fill(password);
235+
await openPage.getByRole('button', { name: 'Continue' }).click();
236+
await expect(openPage.locator('code.hostname')).toHaveText('example.com', { timeout: 20_000 });
237+
expect(envelopeRequests).toBe(1);
238+
});
239+
240+
test('password KDF failure does not consume a burn-after-read link', async ({ page, context }) => {
241+
const password = 'burn-after-read-password';
242+
const { link } = await createProtectedLink(page, 'https://example.com/burn-after-read', {
243+
password,
244+
burn: true
245+
});
246+
const failingPage = await context.newPage();
247+
let accessRequests = 0;
248+
failingPage.on('request', (request) => {
249+
if (request.url().endsWith('/access')) accessRequests += 1;
250+
});
251+
await failingPage.route('**/password.worker-*.js', (route) => route.abort('failed'));
252+
253+
await failingPage.goto(link);
254+
await expect(failingPage.getByRole('heading', { name: 'Password required' })).toBeVisible();
255+
await failingPage.getByLabel('Password', { exact: true }).fill(password);
256+
await failingPage.getByRole('button', { name: 'Continue' }).click();
257+
await expect(failingPage.getByRole('heading', { name: 'Unable to open this link' })).toBeVisible();
258+
expect(accessRequests).toBe(0);
259+
260+
const retryPage = await context.newPage();
261+
await retryPage.goto(link);
262+
await expect(retryPage.getByRole('heading', { name: 'Password required' })).toBeVisible();
263+
await retryPage.getByLabel('Password', { exact: true }).fill(password);
264+
await retryPage.getByRole('button', { name: 'Continue' }).click();
265+
await expect(retryPage.getByRole('heading', { name: 'Checking destination safety' })).toBeVisible({
266+
timeout: 20_000
267+
});
268+
});
269+
194270
test('changing between non-empty fragments opens the new protected link', async ({ page, context }) => {
195271
const firstPassword = 'first-fragment-password';
196272
const secondPassword = 'second-fragment-password';
@@ -253,6 +329,33 @@ test('manual choice skips the delay but still waits for a clean safety check', a
253329
expect(Date.now() - startedAt).toBeLessThan(2000);
254330
});
255331

332+
test('immediate open bypasses the delay before the scheduled scan starts', async ({ page, context }) => {
333+
const destination = 'https://example.com/open-without-scanning';
334+
const { link } = await createProtectedLink(page, destination);
335+
const openPage = await context.newPage();
336+
let lookupRequests = 0;
337+
await openPage.route(safeLookupPattern, async (route) => {
338+
lookupRequests += 1;
339+
await route.fulfill({
340+
status: 200,
341+
contentType: 'application/x-protobuf',
342+
body: lookupResponse()
343+
});
344+
});
345+
await openPage.route(destination, (route) =>
346+
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>unscanned destination</title>' })
347+
);
348+
349+
await openPage.goto(link);
350+
await expect(openPage.getByRole('heading', { name: 'Checking destination safety' })).toBeVisible();
351+
await expect(openPage.locator('code.hostname')).toHaveText('example.com');
352+
const startedAt = Date.now();
353+
await openPage.getByRole('button', { name: 'Open without scanning' }).click();
354+
await expect(openPage).toHaveURL(destination, { timeout: 3000 });
355+
expect(Date.now() - startedAt).toBeLessThan(2000);
356+
expect(lookupRequests).toBe(0);
357+
});
358+
256359
test('threat response blocks every redirect', async ({ page, context }) => {
257360
const destination = 'https://example.com/clean?a=1';
258361
const { link } = await createProtectedLink(page, destination);
@@ -271,7 +374,9 @@ test('threat response blocks every redirect', async ({ page, context }) => {
271374
await route.abort();
272375
});
273376
await openPage.goto(link);
274-
await expect(openPage.getByRole('heading', { name: 'Deceptive site ahead' })).toBeVisible();
377+
await expect(openPage.getByRole('heading', { name: 'Deceptive site ahead' })).toBeVisible({
378+
timeout: 7000
379+
});
275380
await openPage.waitForTimeout(5500);
276381
expect(openPage.url()).toBe(link);
277382
expect(destinationRequests).toBe(0);
@@ -288,14 +393,16 @@ test('503 remains gated until explicit unscanned choice', async ({ page, context
288393
route.fulfill({ status: 200, contentType: 'text/html', body: '<title>unscanned destination</title>' })
289394
);
290395
await openPage.goto(link);
291-
await expect(openPage.getByRole('heading', { name: 'Safety check unavailable' })).toBeVisible();
396+
await expect(openPage.getByRole('heading', { name: 'Safety check unavailable' })).toBeVisible({
397+
timeout: 7000
398+
});
292399
await openPage.waitForTimeout(5200);
293400
expect(openPage.url()).toBe(link);
294401
await openPage.getByRole('button', { name: 'Open without safety check' }).click();
295402
await expect(openPage).toHaveURL(destination);
296403
});
297404

298-
test('password, CAPTCHA mock, and burn consume the link exactly once', async ({ page, context }) => {
405+
test('only the first burn-link client can retry a wrong password locally', async ({ page, context }) => {
299406
const destination = 'https://example.com/protected';
300407
const password = 'correct horse battery staple';
301408
const { link } = await createProtectedLink(page, destination, {
@@ -308,18 +415,30 @@ test('password, CAPTCHA mock, and burn consume the link exactly once', async ({
308415
await openPage.route(safeLookupPattern, (route) =>
309416
route.fulfill({ status: 200, contentType: 'application/x-protobuf', body: lookupResponse() })
310417
);
418+
let accessRequests = 0;
419+
openPage.on('request', (request) => {
420+
if (request.url().endsWith('/access')) accessRequests += 1;
421+
});
311422
await openPage.goto(link);
312423
await expect(openPage.getByRole('heading', { name: 'Password required' })).toBeVisible();
424+
await openPage.getByLabel('Password', { exact: true }).fill('wrong password');
425+
const consumed = openPage.waitForResponse(
426+
(response) =>
427+
response.url().endsWith('/access') &&
428+
response.request().method() === 'POST' &&
429+
response.status() === 200
430+
);
431+
await openPage.getByRole('button', { name: 'Continue' }).click();
432+
await consumed;
433+
await expect(openPage.getByRole('alert')).toHaveText('Incorrect password. Try again.');
434+
expect(accessRequests).toBe(1);
435+
313436
await openPage.getByLabel('Password', { exact: true }).fill(password);
314437
await openPage.getByRole('button', { name: 'Continue' }).click();
315-
await expect(openPage.getByRole('heading', { name: 'Checking destination safety' })).toBeVisible({
316-
timeout: 20_000
317-
});
438+
await expect(openPage.locator('code.hostname')).toHaveText('example.com', { timeout: 20_000 });
439+
expect(accessRequests).toBe(1);
318440

319-
const missingMetadata = openPage.waitForResponse(
320-
(response) => response.url().includes('/metadata') && response.status() === 404
321-
);
322-
await openPage.reload();
323-
await missingMetadata;
324-
await expect(openPage.getByText('This link is no longer available.')).toBeVisible();
441+
const secondClient = await context.newPage();
442+
await secondClient.goto(link);
443+
await expect(secondClient.getByText('This link is no longer available.')).toBeVisible();
325444
});

frontend/src/app.css

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,16 @@ input[type='checkbox'] {
502502
gap: var(--space-sm);
503503
}
504504

505+
.redirect-actions {
506+
display: grid;
507+
gap: var(--space-sm);
508+
margin-block-start: var(--space-lg);
509+
}
510+
511+
.redirect-actions > .btn {
512+
width: 100%;
513+
}
514+
505515
.result-row {
506516
margin-block: var(--space-lg);
507517
}

0 commit comments

Comments
 (0)