Introductie
In februari 2025 hoop ik te gaan afstuderen bij een in mijn ogen mooi bedrijf, dat zich bezighoudt met het bouwen van maatwerk software. De tech stack bestaat onder andere uit .NET, Laravel en Vue/Nuxt. Een onderdeel van de sollicitatie bestaat uit het doen van een opdracht, om mijn kennis en vaardigheden te tonen en om mijn werkwijze duidelijk te maken.
Aangezien ik graag zoveel mogelijk leer en op een nuttige manier bezig ben, heb ik besloten om dit project zo te bouwen dat ik het zelf ook zou kunnen gebruiken. Dat gaf extra motivatie om de opdracht te doen.
Opdracht
Een klant heeft de behoefte geuit om een tool te realiseren waarmee zij zonder de betrokkenheid van derde partijen gevoelige informatie kunnen gaan delen.
Een gebruiker moet verschillende dingen met de tool kunnen doen:
- Als gebruiker wil ik met mijn wachtwoord een link kunnen aanmaken zodat ik deze kan delen.
- Als gebruiker wil ik een wachtwoord kunnen bekijken na het openen van een link zodat ik deze kan opslaan.
- Als gebruiker wil ik dat mijn wachtwoord wordt verwijderd na het openen van een link zodat de tool AVG compliant is.
- (optioneel) Als gebruiker wil ik een maximale geldigheidsduur van mijn link kunnen instellen zodat deze niet eeuwig beschikbaar blijft.
- (optioneel) Als gebruiker wil ik het maximaal aantal uses van mijn link kunnen instellen zodat deze door meerdere personen te gebruiken is.
De oplossing moet gemaakt worden in Laravel en voor het UI moet Bootstrap of TailwindCSS gebruikt worden. Verder is het belangrijk dat de oplossing is te gebruiken middels een UI op desktop, geen foutmeldingen vertoont bij normaal gebruik ( duh) en voldoende is gedocumenteerd in de README.
En dan het belangrijkste: ik mocht me vrij voelen om de opdracht naar eigen inzicht aan te passen of uit te breiden waar ik dat nodig vind. Dat waardeert het bedrijf juist. Dit versterkte het idee dat het een mooi bedrijf is met een goede houding en een openheid voor ideeën. Daarom ben ik vastbesloten om hier een mooi project van te maken.
Dus, aan de slag. Dat begint bij nadenken en voorbereiden.
By failing to prepare, you’re preparing to fail. — Benjamin Franklin
Het belangrijkste component van deze tool is de veiligheid die het kan waarborgen. De klant gaf uitdrukkelijk aan dat hij gevoelige informatie wil kunnen gaan delen en dat zonder betrokkenheid van derde partijen. Het moet dus mogelijk zijn de gevoelige data te versleutelen.
Veiligheid
Er zijn verschillende technieken om data te versleutelen. Daarom moet bepaald worden wat de beste techniek is voor deze tool.
Server-side vs client-side
Encryptie kan op de server gebeuren, door de data die binnenkomt te versleutelen en data die eruitgaat te ontsleutelen. Dit heeft als nadeel dat de beheerder van de server en de database zelf ook alle data kan ontsleutelen. Dat vind ik niet goed als het gaat om gevoelige data.
Encryptie kan ook client-side gebeuren, dus in de browser. Op die manier krijgt de server alleen versleutelde data binnen en kan niemand anders dan degene met de link deze ontsleutelen. Dat is dus veel veiliger. Daarom ga ik voor deze optie.
Er zijn twee soorten encryptie: asymmetrisch (met een private en public key pair per partij) en symmetrisch (met een gedeelde sleutel). De eerste is veiliger (omdat de private key niet gedeeld hoeft te worden), maar vereist het aanmaken van zo’n sleutelpaar. Dat is niet handig als je een secret naar iemand wilt sturen. Daarom is symmetrische encryptie hier de beste optie.
Sleutel delen met ontvanger
Daarmee is nog niet alles gezegd. Hoe wordt de sleutel gedeeld met de ontvanger? Als die in de link wordt meegestuurd, krijgt de server de sleutel ook binnen en is er nog geen sprake van end-to-end encryptie.
Url hash
Voor dit probleem ging ik op de schouders van de reuzen achter mijn favoriete tekentool Excalidraw staan.
If I have seen further, it is by standing on the shoulders of giants. — Isaac Newton
Excalidraw heeft op een geniale manier de url hash van de
browser gebruikt voor end-end-encryption. De url hash ziet er bijvoorbeeld zo uit https://secshare.com/#id_of_element
en is bedoeld voor de browser om te navigeren naar het element met het id dat achter de #
staat. Deze url hash is een
client-side functie en wordt niet naar de server gestuurd. De browser haalt deze zelfs automatisch weg uit de url bij
een server request. Deze functie heeft volledige browser compatibility.
Dit kan mooi gebruikt worden voor de tool, door eerst de data client-side te versleutelen en dan de sleutel in de url
hash te zetten om te delen met de ontvanger: #key=reallysecurekeyhere
. Op die manier krijgt de server alleen
versleutelde data zonder sleutel en krijgt de ontvanger wel de sleutel, om de versleutelde data van de server weer te
ontsleutelen.
Tech stack
De oplossing zal gebouwd worden met Laravel en een UI framework. De keuze tussen Bootstrap en Tailwind is niet zo moeilijk: ik heb een hekel aan Bootstrap en een passie voor Tailwind.
Laravel kan op verschillende manieren gebruikt worden:
- Server Side Rendered (SSR) met Blade of JS components
- API + front-end framework naar keuze (evt. met Inertia)
- Volt (class based of functional style)
Ik vind het belangrijk om te kijken naar wat een oplossing vereist in plaats van de nieuwste, hipste en populairste middelen te gebruiken. Hoewel ik weet dat het bedrijf waar ik solliciteer Nuxt gebruikt, kies ik ervoor om dat niet te doen, omdat het simpelweg niet nodig is en dus overhead geeft.
De tool is een simpele CRUD (Create, Read, Update, Delete), heeft alleen formulieren nodig, en vereist weinig client-side interactie. Een SPA is dus absoluut niet nodig. Aangezien Blade mooie helper methods heeft, en er weinig client-side interactie nodig is, zou het gebruik van JS components het ontwikkelproces alleen maar moeilijker maken dan nodig. Voor een klein beetje interactie, zoals het zichtbaar en onzichtbaar maken van secrets, kan AlpineJS gebruikt worden: een lichtgewicht Javascript library.
De tool wordt dus gebouwd in Laravel met Blade, Tailwind en AlpineJS.
Installatie
Een naam voor het project laat niet lang op zich wachten: SecShare: een samenstelling van secure/secret en share. Niet erg origineel, maar hé, ik kan het onthouden, het is kort genoeg om snel te typen en het geeft een aardig beeld van de app :)
Ik start een nieuw Laravel project zonder starterkit. Als database gebruik ik SQLite, omdat ik geen zin heb om een MySQL instance aan te zetten (ze hebben mij geleerd dat developers lui moeten zijn). Alle environment variables zijn goed ingesteld. Tijd voor modelling.
Modelling
Er is maar één model nodig: een Secret
. Aangezien geen authenticatie wordt gebruikt, kan het standaard User model en
de bijbehorende migratie worden verwijderd.
Ik wil alle gevraagde functies implementeren en heb er zelf nog wat bij bedacht. Het kan zomaar voorkomen dat per ongeluk een secret wordt gedeeld of de link in een verkeerd kanaal wordt gedeeld. Dan is het fijn als de link weer kan worden ingetrokken. Die functionaliteit wil ik mogelijk maken.
Het model Secret
heeft een aantal properties nodig:
id
: primary key (big integer)content
: versleutelde datatoken
: unieke random string die wordt gegenereerd voor de gedeelde linkexpires_at
: moment waarop de link zijn geldigheid verliestviews
: het aantal keer dat een link is bekekenmax_views
: het aantal keer dat een link bekeken mag wordenrevoke_token
: random string om een link te verwijderen
Met deze eenvoudige setup is het mogelijk om SecShare te bouwen.
Functies
Er zijn vier verschillende functies die gebouwd moeten worden:
- Aanmaken van secret (met max_views en expiration_date)
- Inzien van secret
- Intrekken van secret
- Opschonen van secrets
1.Aanmaken van secret
De gebruiker moet een link aan kunnen maken waarmee hij gevoelige data kan delen. Daarvoor moeten verschillende invoervelden met validatie komen:
- secret (met hidden/visible toggle)
- expires_in (met opties voor aantal uren)
- max_views (getal tussen 1 en 15)
Om end-to-end encryptie te implementeren, wordt op deze pagina een sleutel gegenereerd. Zodra de gebruiker op de submit knop klikt, wordt de data versleuteld naar de server verzonden. De sleutel wordt opgeslagen in de localSessionStorage van de browser voor later gebruik.
De invoer wordt gevalideerd aan de hand van de SecretStoreRequest, zodat de validatie gescheiden is van de logica en in de flow ook altijd eerst wordt uitgevoerd, voordat de logica in de controller aan de beurt komt. Vergeten van validatie is dan niet mogelijk.
public function rules(): array
{
return [
'content' => 'required',
'expires_in' => 'required|integer|in:1,12,24,48,72,168',
'max_views' => 'required|integer|min:1|max:15'
];
}
Nadat de input van de user is gevalideerd, moet een nieuwe Secret worden aangemaakt en opgeslagen in de database.
public function store(SecretStoreRequest $request)
{
$validatedData = $request->validated();
$expiresAt = now('UTC')->addHours((int)$validatedData['expires_in']);
$token = Str::uuid();
$revoke_token = Str::random(15);
Secret::create([
...$validatedData,
'expires_at' => $expiresAt,
'token' => $token,
'revoke_token' => password_hash($revoke_token, PASSWORD_DEFAULT),
]);
return view('secrets.link', [
'link' => url('/') . URL::temporarySignedRoute('secrets.show', $expiresAt, ['secret' => $token], false),
'expires_in' => $expiresAt->diffForHumans(),
'max_views' => $validatedData['max_views'],
'revoke_token' => $revoke_token,
]);}
Als het om datums gaat heb ik één regel: sla altijd alles op in UTC. Op die manier weet ik altijd dat ik de correcte
datum heb en kan ik deze eventueel omzetten. Hoewel mijn TZ
environment variable op UTC staat, lijkt now()
dat niet
te respecteren, dus geeft ik dat handmatig mee. Het aantal uren dat is gekozen wordt erbij opgeteld.
De token die wordt gegenereerd is een UUID om te garanderen dat deze uniek is. Er hoeft niet te worden gevreesd voor brute forcing, aangezien een hacker niets heeft aan de link zonder dat hij ook de sleutel heeft uit de url hash. Hij heeft dan alleen nog maar versleutelde data.
De revoke_token krijgt ook een random string toegewezen, zodat de eigenaar van de link zijn link kan intrekken als hij dat wilt. Deze wordt opgeslagen als hash in de database, zodat een kwaadaardige hacker die toegang krijgt tot de database (met read-only access), de links niet zelf kan verwijderen.
De link wordt gegenereerd met behulp van Laravels signed route. Deze wordt automatisch ongeldig als de expires_at datum is bereikt.
Na het opslaan, wordt de gemaakte secret meegegeven aan de link page waar naartoe verwezen wordt.
Deze link pagina haalt de opgeslagen sleutel uit de localSessionStorage op, en plakt deze achter de gegenereerde link als hash. Bijvoorbeeld:
https://secshare.com/secrets/4642bb947-b4a4-4290-9309-c7c2e6244873?expires=1730585002&signature=b4007eaecc2c626e7f60556831d17c33feccbe3bdec79296d4acec5d67c3b4d4#key=C9k-8hW6P-c_FeEZxxEsvg
Er is nog wat informatie over de link te zien, zoals wanneer deze verloopt en hoe vaak deze bekeken kan worden. Ook de revoke token kan hier verkregen worden.
2. Inzien van secret
Als de ontvanger de link opent, moet hij de gevoelige data kunnen zien. Dit geldt alleen als de link geldig is. Dus dat moet eerst gecontroleerd worden.
public function show(Secret $secret)
{
abort_unless(request()->hasValidRelativeSignature(), 403);
$secret->increment('views');
if ($secret->views >= $secret->max_views) {
$secret->delete();
}
return view('secrets.show', [
'secret' => $secret->content,
'expires_in' => $secret->expires_at->diffForHumans(),
'views' => $secret->max_views - $secret->views,
'token' => $secret->token,
]);
}
Als de url niet geldig is of de secret is verlopen, krijgt de gebruiker een 403 Forbidden. Als de gebruiker wel toegang heeft tot de data, wordt het aantal views opgehoogd en krijgt hij de show pagina te zien.
Daar kan hij de data inzien en gemakkelijk kopiëren. Verder ziet hij informatie zoals expires_in en het aantal keer dat de secret nog mag worden bekeken. Ook is er de optie om de secret in te trekken.
3. Intrekken van secret
Als een gebruiker de secret wil intrekken, zal hij de juiste token in moeten voeren, die hij kreeg tijdens het aanmaken van de link.
Als de hash daarvan overeenkomt met die in de database, zal de link worden verwijderd.
public function destroy(Secret $secret, SecretDeleteRequest $request)
{
if (!password_verify($request->revoke_token, $secret->revoke_token)) {
return back()->withErrors(['revoke_token' => 'The provided token is incorrect.']);
}
$secret->delete();
return redirect()->route('secrets.create')->with('success', 'Secret deleted successfully!');
}
De gebruiker krijgt hiervan feedback door middel van een succes bericht.
4. Opschonen van secrets
Zodra een secret zijn maximaal aantal views heeft bereikt, wordt deze verwijderd. Dit geldt echter niet als de secret is verlopen, omdat wordt gecheckt of de url nog wel geldig is in de controller. Geldigheid wordt niet alleen gebaseerd op de expiration date, maar ook op de signature. Als een gebruiker een verkeerde signature stuurt, moet de Secret natuurlijk niet worden verwijderd.
Daarom wordt een command gemaakt: RemoveExpiredSecrets
. Dit command verwijdert alle secrets die verlopen zijn.
protected $signature = 'secrets:remove-expired';
protected $description = 'Remove expired secrets from the database';
public function handle(): void
{
$count = Secret::where('expires_at', '<', Carbon::now())->delete();
if ($count === 0) {
$this->info('No expired secrets found.');
} else {
$this->info("Deleted {$count} expired secrets successfully.");
}
}
Om dit command regelmatig uit te voeren, wordt deze geregistreerd in routes/console.php
, waarna het gemakkelijk in een
cron job kan worden uitgevoerd. Op die manier blijft de database vrij van onnodige data en zijn de gebruikers ervan
verzekerd dat hun secrets worden verwijderd als deze ongeldig zijn geworden.
Landing page
Dit is een tool die ik zelf best zou kunnen gebruiken, dus wellicht zijn er meer mensen die dat willen. Daarom heb ik besloten dit project open-source te maken. Om duidelijk te maken wat de tool inhoudt, heb ik een landing page gemaakt, inclusief feature beschrijvingen en een FAQ.
Zelf heb ik een homelab en aangezien deze tool ook geschikt is voor self-hosting, heb ik ook een Dockerfile en Docker compose file toegevoegd. Zie de README op Github voor installatie-instructies.
Opmerkingen
- Verschillende methoden in de SecretController maken gebruik van Laravels Route Model Binding aan de hand van de token.
Dit is geconfigureerd met
getRouteKeyName
in hetSecret
model. - Alle teksten die in de tool zijn gebruikt, worden getoond met Laravels localization features en kunnen gemakkelijk worden vertaald in de toekomst.
Tests
Om de kwaliteit en veiligheid van de tool te waarborgen, zijn er verschillende integratie tests geschreven (met Dusk). Zo wordt gecontroleerd of de secret correct wordt aangemaakt, of de secret correct wordt ingetrokken, of de secret onbereikbaar is bij een ongeldige url signature en of de secret correct wordt verwijderd na het bereiken van het maximaal aantal views. Op deze manier kun je met een gerust hart gevoelige informatie delen en dit project refactoren of aanpassen.
Reflectie
Al met al was het een leuke opdracht om een tool te maken voor een klant die gevoelige informatie wilde delen. Er moest goed worden nagedacht over veiligheid. Ik heb twee nieuwe dingen geleerd tijdens dit project:
- End-to-end encryptie door middel van een url hash
- Laravel Signed Routes
Ik vond het een mooi project en de moeite waard. De code en een demo kun je bovenaan de pagina vinden.
Heb jij feedback, vragen of opmerkingen? Of heb je iemand nodig om een app of andere software te bouwen? Neem gerust contact op!