Back to projects
Nov 04, 2024
13 min

SecShare: an open-source secret sharing tool

A project for a client who wanted to share sensitive information

Introduction

In February 2025, I hope to graduate at a company that I consider to be a great place, focused on building custom software. The tech stack includes .NET, Laravel, and Vue/Nuxt. Part of the application process involves completing a task to demonstrate my knowledge and skills and to clarify my working methods.

Since I like to learn as much as possible and engage in useful activities, I’ve decided to build this project in such a way that I can also use it myself. This provided extra motivation to tackle the assignment.

Assignment

A client has expressed the need for a tool that allows them to share sensitive information without involving third parties.

A user must be able to do several things with the tool:

  1. As a user, I want to create a link with my password so that I can share it.
  2. As a user, I want to view a password after opening a link so that I can save it.
  3. As a user, I want my password to be deleted after opening a link so that the tool is GDPR compliant.
  4. (optional) As a user, I want to set a maximum validity period for my link so that it doesn’t remain available forever.
  5. (optional) As a user, I want to set the maximum number of uses for my link so that it can be used by multiple people.

The solution must be built in Laravel and use Bootstrap or TailwindCSS for the UI. Additionally, it is important that the solution can be used through a UI on desktop, does not display error messages during normal use (duh), and is sufficiently documented in the README.

And then the most important part: I was allowed to feel free to adapt or expand the assignment as I see fit. This is something the company appreciates. This reinforced the idea that it is a great company with a good attitude and openness to ideas. Therefore, I am determined to make this a beautiful project.

So, let’s get started. This begins with thinking and preparing.

By failing to prepare, you’re preparing to fail. — Benjamin Franklin

The most important component of this tool is the security it can guarantee. The client explicitly stated that they want to share sensitive information without involving third parties. Therefore, it must be possible to encrypt the sensitive data.

Security

Various techniques exist to encrypt data. Therefore, we need to determine the best technique for this tool.

Server-side vs client-side

Encryption can happen on the server, by encrypting incoming data and decrypting outgoing data. The downside of this is that the server and database administrator can also decrypt all data. I don’t find this acceptable when it comes to sensitive data.

Encryption can also happen client-side, in the browser. This way, the server only receives encrypted data, and no one other than the person with the link can decrypt it. This is much safer. Therefore, I will choose this option.

There are two types of encryption: asymmetric (with a private and public key pair per party) and symmetric (with a shared key). The former is safer (because the private key does not need to be shared), but requires the creation of such a key pair. This is not convenient if you want to send a secret to someone. Therefore, symmetric encryption is the best option here.

Sharing the key with the recipient

That doesn’t cover everything. How is the key shared with the recipient? If it’s sent along in the link, the server receives the key as well, and end-to-end encryption is not achieved.

URL hash

For this problem, I stood on the shoulders of the giants behind my favorite drawing tool Excalidraw.

If I have seen further, it is by standing on the shoulders of giants. — Isaac Newton

Excalidraw has cleverly used the URL hash of the browser for end-to-end encryption. The URL hash looks like this: https://secshare.com/#id_of_element and is intended for the browser to navigate to the element with the id following the #. This URL hash is a client-side function and is not sent to the server. The browser even automatically removes it from the URL during a server request. This function has full browser compatibility.

This can be nicely used for the tool by first encrypting the data client-side and then placing the key in the URL hash to share it with the recipient: #key=reallysecurekeyhere. In this way, the server only receives encrypted data without the key, while the recipient receives the key to decrypt the encrypted data from the server.

Tech stack

The solution will be built with Laravel and a UI framework. The choice between Bootstrap and Tailwind is not difficult: I dislike Bootstrap and have a passion for Tailwind.

Laravel can be used in several ways:

  • Server Side Rendered (SSR) with Blade or JS components
  • API + front-end framework of choice (possibly with Inertia)
  • Volt (class-based or functional style)

I find it important to look at what a solution requires rather than using the latest, trendiest, and most popular tools. Although I know that the company I am applying to uses Nuxt, I choose not to use it because it simply isn’t necessary and adds overhead.

The tool is a simple CRUD (Create, Read, Update, Delete), only requires forms, and demands little client-side interaction. A SPA is therefore absolutely unnecessary. Since Blade has nice helper methods, and there is little need for client-side interaction, using JS components would only complicate the development process unnecessarily. For a small amount of interaction, such as toggling secrets visible and invisible, AlpineJS can be used: a lightweight JavaScript library.

Thus, the tool is built in Laravel with Blade, Tailwind, and AlpineJS.

Installation

A name for the project didn’t take long to come up: SecShare: a combination of secure/secret and share. Not very original, but hey, I can remember it, it’s short enough to type quickly, and it gives a good impression of the app :)

I start a new Laravel project without a starter kit. I use SQLite as the database because I don’t feel like setting up a MySQL instance (I’ve been taught that developers should be lazy). All environment variables are set up correctly. Time for modeling.

Modeling

Only one model is needed: a Secret. Since no authentication is used, the default User model and its associated migration can be removed.

I want to implement all the requested functions and have come up with a few additional ideas. It could easily happen that a secret is accidentally shared or the link is shared in the wrong channel. It would be nice if the link could be revoked. I want to make that functionality possible.

The Secret model needs several properties:

  • id: primary key (big integer)
  • content: encrypted data
  • token: unique random string generated for the shared link
  • expires_at: the moment the link loses its validity
  • views: the number of times a link has been viewed
  • max_views: the number of times a link can be viewed
  • revoke_token: random string to delete a link

With this simple setup, it is possible to build SecShare.

Functions

There are four different functions that need to be built:

  1. Creating a secret (with max_views and expiration_date)
  2. Viewing a secret
  3. Revoking a secret
  4. Cleaning up secrets

1. Creating a Secret

The user must be able to create a link to share sensitive data. For this, various input fields with validation need to be included:

  • secret (with hidden/visible toggle)
  • expires_in (with options for the number of hours)
  • max_views (a number between 1 and 15)

To implement end-to-end encryption, a key is generated on this page. Once the user clicks the submit button, the data is encrypted and sent to the server. The key is stored in the browser’s localSessionStorage for later use.

Screenshot of create page

The input is validated based on the SecretStoreRequest, ensuring that validation is separated from the logic and is always executed first in the flow before the logic in the controller is processed. Forgetting validation is then not possible.

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'  
    ];  
}

After the user’s input has been validated, a new Secret must be created and stored in the 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,  
    ]);}

When it comes to dates, I have one rule: always store everything in UTC. This way, I always know I have the correct date and can convert it if necessary. Although my TZ environment variable is set to UTC, it seems that now() does not respect that, so I pass it manually. The number of hours that is chosen is added on.

The token that is generated is a UUID to ensure that it is unique. There is no need to fear brute forcing, as a hacker gains nothing from the link without also having the key from the URL hash. They would only have encrypted data.

The revoke_token is also assigned a random string, allowing the owner of the link to revoke it if they wish. This is stored as a hash in the database, so that a malicious hacker gaining access to the database (with read-only access) cannot delete the links themselves.

The link is generated using Laravel’s signed route. This becomes automatically invalid once the expires_at date is reached.

After saving, the created secret is passed to the link page being referenced.

Screenshot of link page

This link page retrieves the stored key from the localSessionStorage and appends it to the generated link as a hash. For example:

https://secshare.com/secrets/4642bb947-b4a4-4290-9309-c7c2e6244873?expires=1730585002&signature=b4007eaecc2c626e7f60556831d17c33feccbe3bdec79296d4acec5d67c3b4d4#key=C9k-8hW6P-c_FeEZxxEsvg

Some information about the link is also visible, such as when it expires and how many times it can be viewed. The revoke token can also be obtained here.

2. Viewing the secret

When the recipient opens the link, they must be able to see the sensitive data. This only applies if the link is valid. So that must be checked first.

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,  
    ]);
}

If the URL is not valid or the secret has expired, the user receives a 403 Forbidden. If the user does have access to the data, the number of views will be incremented and the display page will show.

Screenshot of show page

There, a user can view the data and easily copy it. Additionally, information about the secret can be seen, such as expires_in and how many times the secret can still be viewed. The option to revoke the secret is present as well.

3. Revoking the secret

If a user wants to revoke the secret, the correct token must be entered, which was visible when creating the link.

Screenshot of revoke page

If the hash matches the one in the database, the link will be removed.

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!');  
}

The user receives feedback through a success message.

4. Cleaning up secrets

Once a secret has reached its maximum number of views, it is removed. However, this does not apply if the secret has expired, as the validity of the URL is checked in the controller. Validity is not only based on the expiration date but also on the signature. If a user sends an incorrect signature, the Secret should not be removed.

Therefore, a command has been created: RemoveExpiredSecrets. This command removes all secrets that have expired.

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.");  
    }
}

To execute this command regularly, it is registered in routes/console.php, making it easy to run in a cron job. This way, the database remains free of unnecessary data, and users can be assured that their secrets will be removed once they have become invalid.

Landing page

This is a tool that I would find quite useful myself, so perhaps there are others who would want it too. That’s why I’ve decided to make this project open-source. To clarify what the tool entails, I created a landing page, including feature descriptions and a FAQ.

I personally have a homelab, and since this tool is also suitable for self-hosting, I have added a Dockerfile and Docker Compose file. See the README on GitHub for installation instructions.

Notes

  • Various methods in the SecretController utilize Laravel’s Route Model Binding based on the token. This is configured with getRouteKeyName in the Secret model.
  • All text used in the tool is displayed with Laravel’s localization features and can easily be translated in the future.

Tests

To ensure the quality and security of the tool, various integration tests have been written (using Dusk). These tests check whether the secret is created correctly, whether the secret is revoked properly, whether the secret is inaccessible with an invalid URL signature, and whether the secret is deleted correctly after reaching the maximum number of views. This way, you can confidently share sensitive information and refactor or adjust this project.

Reflection

Overall, it was a fun task to create a tool for a client who wanted to share sensitive information. Security had to be carefully considered. I learned two new things during this project:

  • End-to-end encryption using a URL hash
  • Laravel Signed Routes

I found it to be a rewarding project. You can find the code and a demo at the top of the page.

Do you have feedback, questions, or comments? Or do you need someone to build an app or other software? Feel free to get in touch!