You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: README.md
+5-3Lines changed: 5 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,8 +1,6 @@
1
1
# SecURL
2
2
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.
6
4
7
5
## Quick start
8
6
@@ -37,6 +35,10 @@ The standalone command loads `.env` from the current working directory. Environm
37
35
38
36
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.
39
37
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).
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.
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:
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:
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.
0 commit comments