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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
parse ¶
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.
matches ¶
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
¶
Take `aws-chunked` framing off a body, leaving the file.
Each frame is `<hex length>;chunk-signature=<signature>
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 a password for storage: scrypt$N$r$p$salt$key, all hex.
verify_password ¶
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 ¶
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.
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 ¶
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.