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

POST /api/v1/shares
POST /api/v1/shares/{id}/complete
GET /api/v1/shares/{id}
POST /api/v1/shares/{id}/download
GET /api/v1/shares/{id}/audit
GET /api/v1/shares/{id}/audit.csv
DELETE /api/v1/shares/{id}
GET /api/v1/account/shares
GET /api/v1/health
POST /api/v1/keys
GET /api/v1/keys
DELETE /api/v1/keys/{id}

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"