SPECIFICATION

Verifying receipts and events.

Bilaga signs two kinds of document: a receipt for every completed transfer and an event for every change on an account. Both use the same envelope, the same key, and the same canonical form, so one verifier handles both. Nothing here requires a Bilaga token or trusts Bilaga’s web pages.

1. The envelope

{
  "receipt": { ...fields... },        // or "event": { ... }
  "signature_hex": "…128 hex chars…",
  "key_id": "…16 hex chars…",
  "public_key_hex": "…64 hex chars…",
  "algorithm": "ed25519",
  "canonicalization": "json-sorted-keys-no-whitespace-utf8",
  "verify_url": "/api/receipt-key"
}

The signature covers only the inner object. Everything else in the envelope is a hint for the verifier and must not be trusted on its own: in particular, do not verify against the public_key_hex in the envelope without first confirming it is Bilaga’s key.

2. Canonical form

Serialise the inner object as JSON with object keys sorted by Unicode code point at every level, no whitespace, UTF-8 encoded, non-ASCII characters written literally (not escaped), and integers written without exponent or fraction. Keys whose value is undefined are omitted; null values are kept. In Python: json.dumps(obj, sort_keys=True, separators=(',', ':'), ensure_ascii=False). The message to verify is the UTF-8 bytes of that string.

3. The key

GET https://bilaga.link/api/receipt-key returns the current Ed25519 public key as public_key_hex (32 bytes, RFC 8032) with its key_id, which is the first 16 hex characters of SHA-256 over the raw public key bytes. A verifier should fetch the key once over TLS, pin it, and accept a document only when its key_id matches a pinned key. If Bilaga ever rotates the key, the endpoint lists earlier ids in retired_key_ids; documents signed by a retired key stay valid, documents signed by an unknown key do not.

4. Verify

  1. Confirm key_id is the current or a retired Bilaga key id, and that public_key_hex matches the pinned key for that id.
  2. Compute the canonical bytes of the inner object.
  3. Check the Ed25519 signature signature_hex over those bytes with the pinned public key.
  4. For a receipt, check that transfer is the public id you expected and, if you hold the file, that its size and content hash match.
  5. For an event, check issuer, that id is one you have not processed, and then fetch current state from the API rather than acting on the event body alone.

The shipped Python client does all of this with the standard library: bilaga.py --receipt PUBLIC_ID --verify file and bilaga.py --verify-event < body.json. In a browser or Node, crypto.subtle.verify({name:'Ed25519'}, key, sig, msg) with the key imported as raw bytes is enough.

Receipts are retained indefinitely and can be found without a link: GET /api/receipts?hash=CONTENT_HASH returns every signed receipt whose stored bytes have that hash.

5. Content hash

Split the file into 8,388,608-byte chunks. Take the SHA-256 of each chunk. Concatenate those raw 32-byte digests in order and take the SHA-256 of the result; that hex string is content_hash. It is computed from the chunk hashes checked during upload, so it identifies the bytes Bilaga stored, and it lets a verifier check a large file in one streaming pass.

6. Receipt fields

FieldMeaning
versionInteger. Currently 1.
issuerAlways "bilaga.link".
transferPublic transfer id: 32 lowercase hex characters.
status"available", "expired", or "deleted". Deleted receipts have filename, sender, and addressing redacted; the hash and identities remain.
filenameThe stored filename after sanitisation.
size_bytesInteger byte length of the stored file.
part_size_bytesChunk size used for the content hash. Currently 8388608.
content_hashHex SHA-256 over the concatenated raw SHA-256 digests of each chunk, in order.
content_hash_algorithmAlways "bilaga-chunked-sha256-8mib".
senderFree-text sender label supplied by the uploader, or null.
sender_accountStable public handle of the sending account ("acct_…"), or null.
addressedBoolean: the transfer was addressed to a specific email.
recipient_accountHandle of the account that acknowledged receipt, or null.
received_atISO 8601 time of the first acknowledgement, or null.
in_reply_toPublic id of the transfer this one replies to, or null.
price_centsInteger price set by the sender; 0 when free.
fee_centsBilaga’s fee once paid, or null.
paid_atISO 8601 time of settlement, or null.
paid_by_accountHandle of the paying account, or null.
completed_atISO 8601 time the upload completed.
expires_atISO 8601 time the download link stops working.
sent_reported_atWhen the uploading agent reported delivery, or null.
download_requestsInteger count of download requests at issue time.
receipt_requestsInteger count of receipt fetches and hash lookups at issue time, including this one.
last_download_requested_atISO 8601 time or null.
issued_atISO 8601 time this receipt was signed. Receipts are issued on request, so two receipts for one transfer differ here.

7. Event fields

FieldMeaning
versionInteger. Currently 1.
issuerAlways "bilaga.link".
idTime-ordered event id: "evt_" followed by 28 hex characters.
typeOne of transfer.completed, transfer.downloaded, transfer.received, transfer.reply, transfer.paid, transfer.deleted, webhook.test.
occurred_atISO 8601 time.
transferObject describing the transfer as the receiving account is allowed to see it, or null for webhook.test.

Webhook deliveries carry the envelope as the request body and repeat signature_hex and key_id in the X-Bilaga-Signature and X-Bilaga-Key-Id headers, with the event id in X-Bilaga-Delivery.

8. What a signature proves

A valid receipt proves that Bilaga stored a file with these bytes, size, and metadata, that the named accounts took the named actions, and that nobody altered the document afterwards. It does not prove that a human read the file, and it does not prove anything about the file’s contents beyond their hash. Fields may be added in later versions; verifiers should ignore fields they do not know and refuse documents whose version they do not understand.