Providing downloadable files is often required within web applications, e.g. if your user needs to be able to download contract documents as PDF files. Typically, when you are dealing with that you will see requirements like the following:

  • REQ#1: As a user I want to download files from the application.
  • REQ#2: As a user I want to send the download link to another person.
  • REQ#3: As a user I want to download a files to a later point in time even if I am not logged in.
  • REQ#4: As a security officer I want files only be downloaded from eligible users.
  • REQ#5: As a compliance officer I demand having files on our own hosted storage and not in a cloud environment.
  • REQ#6: As a compliance officer I don't want a user to re-use the same download link again in the future.
  • REQ#7: As a DevOps engineer I want all credentials to be automatically rotated within a regular interval.
  • REQ#8: As a software architect I want to have managing file downloads in its own deployable module or microservice.

In general, there are a lot of considerations to do. For example REQ#2 and REQ#3 are in direct conflict with REQ#4. One possible solution is the usage of one-time links (OTL). By definition, one-time links can only be clicked once. The link can be used to initiate a file download. After the first use, the link loses its validity. In practice, however, links that are only valid for a short period of time are also referred to as one-time links. These are described below as pseudo OTLs.

Before generating an OTL, a random marker like a GUID is stored somewhere, e.g. in a database table or some kind of shared cache. As soon as the link is clicked for the first time, the memory is checked whether this marker still exists. If so, the marker is deleted and the user can download the file. If the marker no longer exists, the user is presented with a corresponding error message. A read-lock is set when reading the marker. Otherwise, it is possible for the user to download the same file twice due to parallel requests.

_attachments/10-context.png

Basically, you should ask yourself: Do your requirements really necessitate that a file can only be downloaded once via a link? Credentials or certificate files may need to be generated and downloaded only once. In most other use cases, however, it is often enough that the same download link cannot be used at a slightly later point in time.

Pseudo OTLs

Unlike OTLs, pseudo OTLs do not require a marker to be stored in memory. Instead, an expiry date and a signature are sent within the link. The expiry date is calculated from the current time + the validity period (TTL, time-to-live) of the link. The signature can be created and verified using a shared secret (preshared key, certificate). It ensures that the expiry date has not been tampered with. With a low TTL, you ensure that the file can only be downloaded within a very limited time. However, it is still technically possible to click on the link several times and send it to other people within the time window. In AWS S3 and Minio, you will find pseudo OTLs as "presigned URLs". With Azure Blob Storage, you can achieve this using SAS tokens.

If you implement it yourself, for example because you store the files locally, the workflow looks like this:

_attachments/20-sequence-diagram.png

That results into this type of pseudo code:

// `certificatestore` is a facade of your Windows/Linux certificate store or e.g. Vault

createSignedUrl(string uuid, Date expires_at) {
	toSign = createUrl(uuid, expires_at.to_string)
	return toSign "&signature=" . sign(toSign, findActiveCertificate())
}

sign(string value, certificate): string {
	// RSA, ECDSA or preshared-key
	sign(value, certificate)
}

checkSignature(http_request) {
	require(http_request.get_parameters, "expires_at")
	require(http_request.get_parameters, "signature")
	failIf(http_request.get_parameters["expires_at"].to_date.is_after(now())) 

	var path_segment = http_request.path_segment
	var signature = http_request.get_parameters["signature"]
	var expires_at = http_request.get_parameters["expires_at"]
	toCheck = createUrl(path_segment, expires_at)
	
	checkAllCertificates(toCheck, get_parameters, signature)
	// get_parameters["fingerprint"] would be the certificate's fingerprint in hexadecimal
	checkByFingerprint(toCheck, get_parameters["fingerprint"], signature)
}

createUrl(string path_segment, string expires_at): string {
	return "/${path_segment}?expires_at=${expires_at}"
}

findActiveCertificate(): certificate {
	return certificateStored.findNewest()
}

checkAllCertificates(string value, string expectedSignature): bool {
	for (certifcate in certificateStore) {
		if (certificate.isExpired()) {
			continue
		}
		
		if (expectedSignature == sign(value, certificate)) {
			return true
		}
	}
	
	return false
}

checkByFingerprint(value, fingerprint, expectedSignature): bool {
	certificate = certificateStore.findByFingerprint(fingerprint)
	
	if (!certificate) {
		return false
	}

	if (certificate.isExpired()) {
		return false
	}
	
	return expectedSignature == sign(value, certificate)
}

Considerations

Temporal aspects

When saving the expiry date, please note that all servers must be in the same time zone or be able to handle time zones.

If

  1. the environment on which the OTL was created is configured for time zone A
  2. and the environment on which the signature and expiry date are checked is configured for time zone B, which is ahead of time zone A
  3. and the expiry date does not have any time zone information in it the expiration check will fail.

Therefore, either always send the time zone information with the signature or configure all environments to the same time zone.

Working with a shared secret

Both the link-generating service and the file-sending service must either know the shared secret/certificate or use the same signing component.

If you are working in an environment where access data and secrets are rotated regularly, you must take this into account during implementation. To ensure that an old shared secret can still be used during rotation, you can simply use two secrets that are both valid during a transition period. After the transition period - which is longer than the validity period of a link - the old secret can be deactivated. If you are using using Vault as your certificate store, you could implement it like the following pseudocode:

var configPath = "/my/certificate/property"
// value of configPath would be JSON like `{"key": "BASE64", "fingerprint": "0F..."}`

certificateStore.findByFingerprint(fingerprint): certificate {
    revisions = vaultClient.findProperty(configPath)
    // only check the last two certificates
    certificateChecksLeft = 2
    lastCertificate = null
    
    for (revision in revisions) {
        if (lastCertificate != revision.key) {
            certificateChecksLeft--
        }
        
        if (certificateChecksLeft < 0) {
            // no active certificate found
            return null
        }
        
        if (revision.fingerprint == fingerprint) {
            return revision.key
        }
    }   
    
    return null
}

When using certificates, you can also store the fingerprint of the certificate within the link so that the correct certificate is loaded directly. This prevents the checking of all available certificates.

What to choose?

Pseudo OTLs might be more favorable because you do not need some sort of persistence storage. Also, it makes it easier to migrate later on to an external object store like S3, Azure Blob storage or a self-hosted Minio instance. In the end - as always - it depends upon your concrete requirements.