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
- Confirm
key_idis the current or a retired Bilaga key id, and thatpublic_key_hexmatches the pinned key for that id. - Compute the canonical bytes of the inner object.
- Check the Ed25519 signature
signature_hexover those bytes with the pinned public key. - For a receipt, check that
transferis the public id you expected and, if you hold the file, that its size and content hash match. - For an event, check
issuer, thatidis 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
| Field | Meaning |
|---|---|
version | Integer. Currently 1. |
issuer | Always "bilaga.link". |
transfer | Public transfer id: 32 lowercase hex characters. |
status | "available", "expired", or "deleted". Deleted receipts have filename, sender, and addressing redacted; the hash and identities remain. |
filename | The stored filename after sanitisation. |
size_bytes | Integer byte length of the stored file. |
part_size_bytes | Chunk size used for the content hash. Currently 8388608. |
content_hash | Hex SHA-256 over the concatenated raw SHA-256 digests of each chunk, in order. |
content_hash_algorithm | Always "bilaga-chunked-sha256-8mib". |
sender | Free-text sender label supplied by the uploader, or null. |
sender_account | Stable public handle of the sending account ("acct_…"), or null. |
addressed | Boolean: the transfer was addressed to a specific email. |
recipient_account | Handle of the account that acknowledged receipt, or null. |
received_at | ISO 8601 time of the first acknowledgement, or null. |
in_reply_to | Public id of the transfer this one replies to, or null. |
price_cents | Integer price set by the sender; 0 when free. |
fee_cents | Bilaga’s fee once paid, or null. |
paid_at | ISO 8601 time of settlement, or null. |
paid_by_account | Handle of the paying account, or null. |
completed_at | ISO 8601 time the upload completed. |
expires_at | ISO 8601 time the download link stops working. |
sent_reported_at | When the uploading agent reported delivery, or null. |
download_requests | Integer count of download requests at issue time. |
receipt_requests | Integer count of receipt fetches and hash lookups at issue time, including this one. |
last_download_requested_at | ISO 8601 time or null. |
issued_at | ISO 8601 time this receipt was signed. Receipts are issued on request, so two receipts for one transfer differ here. |
7. Event fields
| Field | Meaning |
|---|---|
version | Integer. Currently 1. |
issuer | Always "bilaga.link". |
id | Time-ordered event id: "evt_" followed by 28 hex characters. |
type | One of transfer.completed, transfer.downloaded, transfer.received, transfer.reply, transfer.paid, transfer.deleted, webhook.test. |
occurred_at | ISO 8601 time. |
transfer | Object 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.