key = AES-GCM, 256 bits
iv = 12 random bytes, fresh per file
object = iv || ciphertext || tag # 12 | n | 16 bytes
fragment = base64url(raw key)
https://sikkerfil.no/s/<id>#k=<base64url key>
x-sikkerfil-token: <write token> # per share, issued once
x-sikkerfil-key: <api key> # per account, business plan
x-sikkerfil-key: sikkerfil_sk_<43 characters>
POST /api/v1/shares
{ "sizeBytes": 1234, "contentType": "application/pdf",
"encryptedName": "<sealed>", "expiresInSeconds": 86400,
"maxDownloads": 5 }
-> 201 { "id", "writeToken", "uploadUrl", "expiresAt" }
PUT <uploadUrl> # iv || ciphertext
Content-Length: 1234
Content-Type: application/octet-stream
POST /api/v1/shares/<id>/complete
x-sikkerfil-token: <writeToken>
-> 200
# Send. Needs an API key from /innstillinger/api.
from sikkerfil import Sikkerfil
sent = Sikkerfil().send("kvartalsrapport.pdf", max_downloads=2)
print(sent.url) # give this away — it carries the key after #k=
print(sent.write_token) # keep this: revoking and the audit trail need it
# Receive. Needs no account and no key from us.
import sikkerfil
sikkerfil.receive("https://sikkerfil.no/s/ABCD1234#k=…").save("~/Downloads")
KEY=sikkerfil_sk_…
# Does the key work? A GET has no body, so no digest.
curl -sS https://sikkerfil.no/api/v1/account/shares \
-H "x-sikkerfil-key: $KEY"
# 1. Create the share.
BODY='{"sizeBytes":1234,"contentType":"application/pdf","expiresInSeconds":86400}'
curl -sS https://sikkerfil.no/api/v1/shares \
-H "x-sikkerfil-key: $KEY" \
-H "content-type: application/json" \
-H "x-amz-content-sha256: $(printf %s "$BODY" | shasum -a 256 | cut -d' ' -f1)" \
-d "$BODY"
# -> {"id":"ABCD1234","writeToken":"…","uploadUrl":"https://…","expiresAt":…}
# 2. Upload iv || ciphertext — NOT the file itself.
curl -sS -X PUT "$UPLOAD_URL" \
-H "content-type: application/octet-stream" \
-H "content-length: 1234" \
--data-binary @encrypted.bin
# 3. Complete it, with the write token from step 1.
curl -sS -X POST "https://sikkerfil.no/api/v1/shares/$ID/complete" \
-H "x-sikkerfil-token: $WRITE_TOKEN" \
-H "x-amz-content-sha256: $(printf %s '{}' | shasum -a 256 | cut -d' ' -f1)" \
-d '{}'
# The link, with the key in the fragment. We never see this part.
# https://sikkerfil.no/s/ABCD1234#k=<base64url key>
GET /api/v1/shares/<id>
POST /api/v1/shares/<id>/download
{ "password": "..." } # only if passwordRequired
-> 200 { "url" } # then GET it, then decrypt
ID=ABCD1234 # from https://sikkerfil.no/s/ABCD1234#k=…
KEY=… # the part after #k=, which we have never seen
# 1. What is it? No account, no key — the link is the credential.
curl -sS https://sikkerfil.no/api/v1/shares/$ID
# -> {"sizeBytes":1234,"expiresAt":…,"downloadsRemaining":2,
# "passwordRequired":false,"encryptedName":"…"}
# 2. Ask for the bytes. A POST, so it needs the digest.
BODY='{}' # {"password":"…"} when passwordRequired is true
curl -sS -X POST https://sikkerfil.no/api/v1/shares/$ID/download \
-H "content-type: application/json" \
-H "x-amz-content-sha256: $(printf %s "$BODY" | shasum -a 256 | cut -d' ' -f1)" \
-d "$BODY"
# -> {"url":"https://…"} a short-lived link to the object itself
# 3. Fetch the ciphertext.
curl -sS -o encrypted.bin "$URL"
# 4. Decrypt. Not curl, and not openssl either — see below.
# pip install cryptography
python3 -c '
import sys
from base64 import urlsafe_b64decode as b64
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = b64(sys.argv[1] + "=" * (-len(sys.argv[1]) % 4))
blob = open("encrypted.bin", "rb").read()
# The tag is the last 16 bytes. Leave it on — AESGCM expects it there.
sys.stdout.buffer.write(AESGCM(key).decrypt(blob[:12], blob[12:], None))
' "$KEY" > rapport.pdf
GET /api/v1/shares/<id>/audit
GET /api/v1/shares/<id>/audit.csv
pip install git+https://github.com/motorvei/sikkerfil-py
# or
uv add git+https://github.com/motorvei/sikkerfil-py
from sikkerfil import Sikkerfil
sf = Sikkerfil() # SIKKERFIL_API_KEY, or api_key="sikkerfil_sk_..."
sent = sf.send(
"kvartalsrapport.pdf", # a path, raw bytes, or an open binary file
filename="rapport.pdf", # sealed under the file's own key; "" sends none
content_type="application/pdf",
expires_in=86400, # clamped to the plan's maximum
max_downloads=2, # clamped to the plan's cap
password="hemmelig", # scrypt-hashed by the service
name="kvartalsrapport", # claims sikkerfil.no/kvartalsrapport
)
print(sent.url) # the link, key in the fragment. Give this away.
print(sent.write_token) # issued ONCE. Revoke and audit need it.
print(sent.id, sent.key) # keep these two to fetch it back yourself
import sikkerfil
# A recipient holds a link and needs no account.
file = sikkerfil.receive("https://sikkerfil.no/s/ABCD1234#k=...")
file.save("~/Downloads") # returns the path written
print(file.filename, len(file)) # the sealed name, opened locally
# A sender who kept the share has the id and the key in two columns.
sikkerfil.receive("ABCD1234", key="...", password="hemmelig")
# The domain is not routing: one distribution serves all three markets.
sikkerfil.receive("ABCD1234", key="...", market="dk")
for share in sf.shares(): # every live share this account sent
print(share.id, share.size_bytes, share.downloads_remaining)
share = sikkerfil.inspect("ABCD1234") # no account, no key: the id is enough
print(share.expires, share.password_required, share.is_ready)
for event in sf.audit(sent): # the write token, not the API key
print(event.when, event.action, event.country)
open("trail.csv", "w", newline="").write(sf.audit_csv(sent)) # CRLF: see below
sf.revoke(sent) # also the write token
from sikkerfil import (
SikkerfilError, # everything below is one of these
PasswordRequiredError, # 403 — a wrong guess costs no download
DownloadsExhaustedError, # 410 — the sender's limit is spent
ShareGoneError, # expired, revoked, or never finished uploading
BudgetError, # 503 — .retry_after seconds
DecryptionError, # wrong key, or bytes altered in transit
AuthenticationError, # the credential, or the wrong kind of it
TransportError, # nothing answered; a retry is reasonable
)
sikkerfil send rapport.pdf --max-downloads 2 --expires 24h
sikkerfil receive 'https://sikkerfil.no/s/ABCD1234#k=…' -o ~/Downloads
sikkerfil list
sikkerfil inspect ABCD1234
sikkerfil audit ABCD1234 --write-token wt_… --csv
sikkerfil revoke ABCD1234 --write-token wt_…
from sikkerfil import crypto
sealed = crypto.seal(b"plaintext") # iv || ciphertext || tag
crypto.open_sealed(sealed.blob, sealed.key) # DecryptionError on a tamper
# A filename is sealed under the file's own key, and opened the same way.
name = crypto.seal_name("rapport.pdf", sealed.key)
crypto.open_name(name, sealed.key) # -> "rapport.pdf"