Skip to content

abilian_cdn.core

Pure functions and values: no database, no framework, no I/O.

core.paths

abilian_cdn.core.paths

What a client may address: zone slugs, and object paths inside them.

A path is the object's identity — it becomes part of a public URL and part of an S3 key — so it is checked once, here, on the way in. Everything downstream takes a validated path as a fact.

No Unicode normalisation happens: the byte sequence that was uploaded is the key that gets served. A macOS client sending a decomposed "é" and a Linux one sending the composed form address two different objects, exactly as they do in S3 itself. Normalising would be a silent rename, which is worse.

InvalidPathError

Bases: ValueError

A path a client may not use, with the rule it broke.

InvalidSlugError

Bases: ValueError

A name that cannot be a zone or organisation, with the rule it broke.

validate_object_path

validate_object_path(path: str) -> str

Return path unchanged, or raise :exc:InvalidPathError saying why not.

Deliberately not a normaliser. Cleaning a path up would mean a client's a//b and a/b silently become the same object and the client is never told which one it got; refusing says so at the moment of the mistake.

contains_encoded_separator

contains_encoded_separator(raw_path: bytes) -> bool

Whether a request's undecoded path smuggles a / as %2F.

The server decodes before we see the path, so a%2Fb and a/b arrive identical — two spellings of one object, and a prefix check that passes on one would pass on the other. Rejected rather than decoded, which is why this looks at the raw bytes.

validate_slug

validate_slug(slug: str) -> str

Return slug unchanged, or raise :exc:InvalidSlugError.

core.signing

abilian_cdn.core.signing

Signed URLs: how a private object is served without a login.

A signature says "this service agreed to serve this path until this moment". It carries no identity and grants nothing else — anyone holding the URL can fetch it until it expires, which is the point: it goes in an email, a ticket, or an tag that has no credentials.

The key is the zone's, so a leaked key opens that zone and no other.

InvalidSignatureError

Bases: ValueError

The URL was not signed by us, or is no longer valid.

sign

sign(
    path: str,
    key: str,
    lifetime: int = DEFAULT_LIFETIME,
    now: int | None = None,
) -> tuple[int, str]

Sign one object path. Returns the expiry and the signature.

The path is the object's, without the zone: the key already identifies the zone, so a signature cannot be carried across to another one.

verify

verify(
    path: str,
    expiry: str,
    signature: str,
    key: str,
    now: int | None = None,
) -> None

Raise unless this signature covers this path and has not expired.

Signature first, expiry second: an unsigned expiry is a number the client chose.

core.tokens

abilian_cdn.core.tokens

API tokens: cdn_<key_id>_<secret>.

The key id is stored and indexed so a lookup is one query; only a hash of the secret is kept. SHA-256 rather than a password KDF on purpose — the secret is 160 bits from the system RNG, so there is no dictionary to slow down, and every authenticated request would pay for the KDF.

InvalidTokenError

Bases: ValueError

A credential that is not a well-formed token of ours.

NewToken dataclass

A freshly minted token: the only moment the secret exists in the clear.

s3_secret

s3_secret(key_id: str, service_key: str) -> str

The secret an S3 client is configured with, derived rather than stored.

SigV4 is checked by recomputing the client's HMAC, so the service needs the secret itself — a hash cannot do it, which is why every S3 provider holds its keys reversibly. Rather than keep a reversible copy of a token's own secret, the S3 credential is derived from the service key and the token's key id: there is nothing in the database to steal, and rotating CDN_SECRET_KEY invalidates every S3 credential exactly as it invalidates every session.

It is therefore a different string from the bearer secret. Both are printed when a token is made.

mint

mint() -> NewToken

Create a token. The plaintext is shown once and never stored.

parse

parse(credential: str) -> tuple[str, str]

Split a credential into (key_id, secret), or raise.

Shape is checked before any database work: a malformed credential must cost a string comparison, not a query.

hash_secret

hash_secret(secret: str) -> str

The stored form of a secret.

matches

matches(secret: str, secret_hash: str) -> bool

Constant-time comparison, so a wrong secret leaks nothing by timing.

core.sigv4

abilian_cdn.core.sigv4

AWS Signature Version 4, from the side that checks it.

Every S3 client signs the same way, so the only way to be compatible is to recompute what the client computed and compare. That means this module is a transcription of the AWS specification rather than a design: the order of the headers, the encoding of the path and the exact newlines are all load-bearing, and a difference of one byte is a 403 nobody can debug from the outside.

The tests sign with botocore's own SigV4Auth, so what is checked here is interoperability with the signer real clients use, not agreement with a second implementation of the same idea.

InvalidSignatureError

Bases: ValueError

A request that does not carry a signature we can agree with.

Credential dataclass

The Credential= part: who is calling, and what they scoped it to.

Signature dataclass

What a client claims, before we have agreed with any of it.

parse_authorization

parse_authorization(
    header: str, amz_date: str, payload: str
) -> Signature

Take apart Authorization: AWS4-HMAC-SHA256 Credential=…, ….

Anything malformed raises: a request we cannot read is a request we cannot agree with, and guessing at the parts would be guessing at who is calling.

signing_key

signing_key(secret: str, credential: Credential) -> bytes

The key the client derived from its secret, derived again.

Four nested HMACs, each over the last: the point is that the key handed to the final signature is specific to one day, one region and one service, so a captured signature cannot be replayed against another.

canonical_request

canonical_request(
    method: str,
    raw_path: bytes,
    query_string: bytes,
    headers: Mapping[str, str],
    signed_headers: Iterable[str],
    payload: str,
) -> str

The request as the specification says to write it down before signing.

The path comes in raw — percent-encoded exactly as the client sent it — because re-encoding a decoded path is where implementations disagree: a space, a plus and a %20 are three different signatures.

string_to_sign

string_to_sign(
    amz_date: str, credential: Credential, canonical: str
) -> str

What the final HMAC is actually taken over.

verify

verify(
    claimed: Signature, secret: str, canonical: str
) -> None

Agree with the client's signature, or refuse.

Constant-time, because a comparison that returns early tells an attacker how much of a guess was right.

chunk_signature

chunk_signature(
    previous: str,
    amz_date: str,
    credential: Credential,
    secret: str,
    chunk: bytes,
) -> str

What the client signed one chunk of a streamed body with.

Each chunk's signature covers the one before it, so the chain cannot be reordered or cut short without the last chunk failing.

unframe async

unframe(
    stream: AsyncIterator[bytes],
) -> AsyncIterator[bytes]
Take `aws-chunked` framing off a body, leaving the file.

Each frame is `<hex length>;chunk-signature=<signature>

` and a zero-length frame ends it. The frames do not line up with the packets, so this is a state machine over whatever arrives: a header being assembled, bytes being passed through, or the two characters that close a frame being skipped.

The chunk signatures are not verified. The seed signature already proved
who is calling and this runs under TLS, so the ceiling is a man in the
middle who can also rewrite bodies — which would need TLS to be broken
first. Named here rather than left unsaid.

core.passwords

abilian_cdn.core.passwords

Password hashing.

hashlib.scrypt from the standard library, with the parameters OWASP recommends (N=2^17, r=8, p=1, 64 MiB of memory per hash). The specification named Argon2id; scrypt is the same shape of defence — memory-hard, salted, tuneable — and it is already here, which means no C extension to build on the deployment host and one less dependency to keep patched.

A stored hash carries its own parameters, so raising the cost later re-hashes old passwords on next login instead of invalidating them.

InvalidHashError

Bases: ValueError

A stored hash this code cannot read — corrupt, or from another scheme.

hash_password

hash_password(password: str) -> str

Hash a password for storage: scrypt$N$r$p$salt$key, all hex.

verify_password

verify_password(password: str, stored: str) -> bool

Whether password produced stored, in constant time.

Reads the parameters from the hash rather than from the constants above, so a password hashed under an older cost still verifies.

needs_rehash

needs_rehash(stored: str) -> bool

Whether a stored hash was made with a weaker cost than we use now.

core.sessions

abilian_cdn.core.sessions

Browser sessions: a signed cookie, and nothing else.

The cookie carries a user id and an expiry, signed with the service's secret. It holds no secret of its own — a user id is not one — so it needs signing rather than encryption, and hmac covers that without a dependency or a session store to keep in sync across processes.

Changing CDN_SECRET_KEY signs everyone out, which is the intended way to revoke every session at once.

InvalidSessionError

Bases: ValueError

A cookie that was not issued by us, or is no longer valid.

Session dataclass

Who the cookie says is here, and until when.

issue

issue(
    user_id: UUID, secret: str, now: int | None = None
) -> str

Mint a cookie value for user_id.

read

read(
    cookie: str, secret: str, now: int | None = None
) -> Session

The session a cookie carries, or :exc:InvalidSessionError.

Signature first, expiry second: an unsigned cookie's claimed expiry is not evidence of anything.

core.sync

abilian_cdn.core.sync

Working out what a sync has to do, before it does any of it.

A pure function over two listings: what the local directory holds, and what the zone holds. Separated from the transfers so the decision can be tested without a server, and shown with --dry-run without touching one.

Plan dataclass

What a sync would do. Printed by --dry-run, executed otherwise.

local_files

local_files(root: Path) -> dict[str, Path]

Every file under root, keyed by the object path it would take.

Directories are not objects — there is nothing to store for one — and a path that could not be an object key is reported rather than mangled into one that could.

plan

plan(
    local: Mapping[str, Path],
    remote: Iterable[Mapping[str, object]],
    *,
    delete: bool = False,
) -> Plan

Compare a directory with a zone and say what differs.

Comparison is by digest, not by timestamp: a checked-out repository has the times of the checkout rather than of the content, so mtimes would re-upload everything on every fresh clone. The digest is the ETag the server already stores, so deciding costs no downloads.