Security Model
This is the flagship document. Read it before deploying. Aliran's security rests only on per-deployment secrets (panel signing key, OPRF key, user passwords). It never rests on code obscurity.
Assets & authorities
- Panel signing keypair — signs the account/catalog Hyperbee. Its public key is pinned/configured in the client. Every record is verifiable and tamper-evident.
- OPRF secret key — the brute-force choke point (see below). Critical asset.
- Per-stream content encryption key — confidentiality of the feed.
- Publisher keys — authorize broadcasters to write catalog records over the register RPC. Per-site enrolled keys carry channel scopes (below); the panel stores only their public halves.
- User passwords — never stored; only Argon2id verifiers + OPRF-bound wrap keys.
Account database
A single-writer, panel-signed Hyperbee. Clients replicate it read-only and pin the panel public key, so records are provably authentic. Namespaces:
catalog/<streamId>→ OTT metadata{ title, description, category[], type, protection, isLive, poster, backdrop, logo, feedKey, blobsKey, status }(protectionis a reserved field that always reads'self'in current builds). Note: the stream's content encryption key is not in the catalog. It is held in a panel-private, non-replicated secrets file and delivered per-user (below).blobsKey(the feed drive's blobs-core key, published by the panel for keyless repeater nodes) is deliberately public: knowing it lets a peer replicate the encrypted video blocks, nothing more. Every block is ciphertext under the stream key, which still only travels sealed per-user through a grant.user/<username>→{ salt, verifier, argon, pub, encPriv, wrapped:{ [streamId]: sealedStreamKey }, manualGrants[], packages[], devices[], tokenVersion, maxDevices, status }.manualGrants/packagesrecord why each grant exists — granted by hand, or granted through a channel package. They are plain provenance metadata, not secrets, and nothing outside the panel reads them.wrappedstays the wire format clients unseal at login. A package cannot be a runtime check, because a grant is a sealed key, so the panel's reconcile engine (panel/src/packages.js) materializes package changes intowrapped. The package registry itself is a plain panel-local file (DATA_DIR/packages.json— names and member selectors only, nothing secret). Revocation semantics are unchanged: removing a package deletes sealed keys from the record, and a client that already unsealed a key is only locked out by a stream-key rotation.
Login without reading secrets
- Passwords are never stored. Login runs an OPRF with the panel to produce
rwd(see below). The record stores onlyverifier = Argon2id(rwd, salt)(sodium-native crypto_pwhash), which confirms a correct password but reveals nothing. - Each user has an X25519 keypair. The public key
pubis stored in the clear. The private key is stored sealed (encPriv) under a key derived fromrwd, so only the correct password can recover it. - A stream is granted by sealing its content key to the user's
pub(crypto_box_seal→wrapped[streamId]). This needs no password, so grants can be added any time after enrollment. Only the user — after logging in and recovering their private key — can open it. - So clients can view the replicated DB to validate it, but see only ciphertext for every secret. They cannot read protected data in clear.
Brute-force protections (layered)
- OPRF-bound key derivation (kills offline attack). Login runs an Oblivious
PRF with the panel: the client blinds the password, the panel evaluates it with
its secret OPRF key and returns the result, and the client unblinds it into
rwd. Fromrwdthe client derives theverifier(Argon2id, memory-hard) and the key that unseals its private key. Without the panel's OPRF key, an attacker with a DB copy cannot computerwd, and so cannot test guesses offline. - Panel-side throttling + lockout per username and per peer key (exponential backoff, temporary lockout). This is enforceable because #1 funnels guesses through the panel.
- Proof-of-work admission on each login attempt.
- Memory-hard Argon2id tuned to target hardware.
- High-entropy credentials + policy, unique salts.
- Blast-radius containment: per-user wrapped keys, stream-key rotation, device binding via Android Keystore/StrongBox.
Sessions, expiry, device limits
- Panel-signed session token
{ userId, deviceId, issuedAt, expiresAt, tokenVersion }. - Absolute TTL (offline-checkable) + version/epoch revocation (online).
- Decided policy: returning users keep working offline via cached sessions. New or expired logins require a panel node. The configured TTL is long by design.
- maxDevices is enforced at the panel — the single writer serializes the count
check and the add together. Eviction bumps a device's
tokenVersion. Enforcement latency equals the session TTL.
Broadcaster registration: per-publisher keys & channel scopes
Anyone on the DHT can reach the register RPC. An Ed25519 signature over
hash(challenge || payload) is what authorizes a catalog write. Two identity
models exist:
- Enrolled publishers (recommended for more than one broadcaster). You enroll
each broadcaster site with
add-publisher <name> --scopes …. The panel mints the site an own keypair (the secret is shown once, and goes in that site'sPUBLISHER_KEY+PUBLISHER_NAME) and records only the public key plus admin-assigned channel scopes (streamId globs, for exampleeast-*) in the panel-privateDATA_DIR/secrets/publishers.json(0600, never replicated — the same handling as admin credentials). A named register is verified against that site's key, and itsstreamIdis scope-checked before any write — the same gate covers the catalog record, the private stream-secret store, andisLive, because one responder writes all three. Accepted writes are stampedorigin:<name>in the (public) catalog record and the activity feed, giving real attribution per site. Containment: a key stolen from one downlink site can only touch that site's channel ids. It cannot re-point, black out, or rewrite the rest of the lineup — the classic broadcast-intrusion move. Revocation is a per-site status flip (revoked) — there is no need to re-key every other site — and scope edits apply from the site's next register (the registry file is re-read each time). Safe failover follows from scoping: re-scope a dead site's channels to the standby box, and when the dead box comes back, its stale re-asserts bounce without-of-scopeinstead of fighting the standby for the feedKey. - Legacy shared key.
initalso mints one shared publisher keypair. Payloads without apublishername verify against it at implicit scope*. This is fine for a single-broadcaster deployment. With several sites, it is a shared secret with none of the properties above — any holder can rewrite any channel, unattributed, and revocation means re-keying everyone. SetLEGACY_PUBLISHER=0on the panel once every site is enrolled, to close this path.
What scoping does NOT give you: content integrity. A rogue operator at a site that legitimately carries a channel can still feed bad content into its own encoder input. Scopes give containment, attribution, and one-click revocation — not a review of the pixels. Multi-writer (Autobase) catalogs remain the roadmap answer for multi-admin trust.
Discovery, firewall, IP
- The panel is found by public key over the DHT — no IP or DNS needed. It runs
behind a firewall or NAT with no inbound ports (hole-punching). The optional
relayOnlysetting hides the origin IP. - A directly connected peer can observe the panel's public IP. This is not anonymous unless you add a relay or VPN.
The service pairing code
A viewer can reach a panel with a 12-character pairing code (A3K7-9QF2-M4XR)
instead of the 64-character panel public key. See
the operator guide.
The panel calculates the code from its own public key with Argon2id, then keeps the first 60 bits and writes them in Crockford base32. Thus:
- No registry exists. The panel creates nothing and stores nothing.
- The code holds no password. It is not a secret.
- The panel announces on a DHT topic that comes from the code.
How the app verifies the service. The topic is public, so any peer can answer on it. The app therefore trusts no answer. It calculates the code again from the panel key in the answer. If that code does not equal the code the viewer typed, the app refuses the answer and continues to look. After a match, the app opens the panel database by key, exactly as a typed key does. The panel key stays the root of trust.
The attack this prices out. An attacker wants a keypair whose code is the same as a true operator's code. A viewer would then pair with the attacker panel and type a true username and password into it. The attacker holds that panel's OPRF key, so the attacker can attack the password offline afterwards.
Two properties make this expensive:
- 60 bits. The attacker must search approximately 2^60 keypairs.
- A memory-hard step. Each candidate costs one Argon2id evaluation at interactive limits (approximately 70 ms and 64 MiB on a desktop CPU). Memory hardness limits how much a GPU or an ASIC helps.
The length is 12 characters, not 8, to keep this margin. The purpose is not to hide the panel key: the panel key is public, and every viewer replicates the catalog by it.
What the code does not do. It gives no entitlement. A viewer who pairs still signs in with a username and a password, and still receives only the channels the operator granted.
No DRM, no geo-locking — deliberately
Aliran does not implement DRM or geo-restriction, and neither is planned. The content-protection model is honest access control: feeds are encrypted end to end, each user's stream keys are sealed individually at grant time, and sessions are cooperative. The real revocation boundary is stream-key rotation — rotating the channel's encryption key stops old keys from decrypting new segments for everyone. That protects against non-entitled parties. It does not — and does not claim to — stop an entitled viewer from capturing what they can lawfully decrypt. Commercial DRM makes the same admission behind more machinery. Operators whose licensing demands hardware-enforced DRM or territorial enforcement should recognize that this platform is the wrong tool for that content.
What this does NOT protect against
- Blocking peers from connecting to a public swarm topic (confidentiality comes from encryption, not from connection-gating).
- Offline brute-force if you enable a fully-offline login fallback (we did not).
- An entitled user retaining decrypted content (no DRM, see above).
- Panel OPRF-key compromise (this re-enables offline attack) — protect and back it up.
Implementation audit (hardening pass)
This section records a wire-compatible implementation audit of the shipped crypto/auth paths: no protocol change, no redesign, and deployed players, SDKs, and apps are unaffected. It is the standing record of what was checked, the parameter verdicts, and the residual risks — it doubles as the package for any future external review.
Parameters (verdicts)
All values are deployment-tunable via env; the audit confirmed the defaults are sound and left them unchanged. Argon2id memory is well above the OWASP 2024 floor (19 MiB, t=2) and in RFC 9106 territory.
| Parameter | Default | Verdict |
|---|---|---|
| Argon2id — panel login | 256 MiB, t=3 | Strong. Runs in a worker thread, single-flight, so cost cannot stall the loop. |
| Argon2id — control/reseller admins | 64 MiB, t=2 | Adequate for interactive admin login (≥ OWASP floor); same worker/single-flight protection. |
POW_DIFFICULTY |
16 leading zero bits | Reasonable admission control; per-attempt, connection-bound (below). |
SESSION_TTL_DAYS |
30 | Intentional (returning users work offline); revocation is online via tokenVersion. |
| admin/control session TTL | 12 h | Appropriate for a privileged HTTP session. |
LOCKOUT_THRESHOLD / LOCKOUT_SECONDS |
10 / 900 s | Reasonable fixed window; the counter map is now bounded (below). |
Surfaces audited
- Timing safety. Every comparison on secret-derived material is
constant-time: password verifiers via
sodium_memcmp(core/password.js), session/register signatures via libsodiumcrypto_sign_verify_detached, and the reseller top-up webhook viacrypto.timingSafeEqualwith a length pre-check. No comparison uses===/!==on secret hex. - Malformed-input safety (fixed). Every attacker-controlled hex field on the
login RPC (
panel/src/rpc.js) now decodes through a stricthexField()guard: a non-string, bad-hex, or wrong-length value fails closed with a JSON error. Before the fix, a non-string field madeb4a.from(x,'hex')throw aTypeError. protomux-rpc funnels that tosafety-catch, andsafety-catchrethrows TypeErrors into a microtask, crashing the process. That meantlogin {"powNonce":{}}was an unauthenticated remote panel kill. Regression test:npm run test:rpc-hardening. - Replay. The
registerand login flows bind their Ed25519/PoW proof to a per-connection random challenge that rotates one-shot per use. A captured, validly-signedregistercannot be replayed on a fresh connection (the challenge is fresh) or re-submitted on the same one (the challenge has rotated) — so a channel'sfeedKeycannot be rolled back by replay. The PoW challenge is likewise connection-bound and single-use. The reseller webhook adds a ±300 s timestamp window plus event-id idempotency. Regression tests:test:rpc-hardeningpart B,test:reseller. - Revocation.
tokenValidchecks signature and expiry everywhere a token is accepted. Every authenticated HTTP route then re-checks the live record:adminTokenLive(panel/broadcaster/library) andprincipalTokenLive(reseller) confirm the account still exists, is active, and the token'stokenVersionmatches. AtokenVersionbump (password rotate, disable, logout-all) invalidates live sessions on their next online check. User session tokens carryrole-less payloads and are rejected by the admin gate (role !== 'admin'), so a viewer token cannot reach an admin route. - Resource exhaustion. The fixed-window throttle map is now bounded
(expired-window sweep + oldest-eviction past a cap) in all four copies, so a
flood of junk usernames or peers cannot exhaust memory. JSON bodies are capped
(1 MiB; 10 MiB for art) and enforced by destroy-on-exceed.
/healthzand/metricsanswer from cheap synchronous sources only. Admin login verifies single-flight in a worker (503 on overlap), so a login flood cannot stall the event loop or replication. - Key hygiene. No secret is logged: the config-validation echoes cover
only non-secret ints and bools, hex key env vars print length-only, and error
messages carry no key material. Key and credential files are
0600; their directories (keys/,secrets/) are now created0700.
Residual risks (accepted — wire-compatible constraints)
These are inherent to the shipped protocol. Removing them would need a breaking change (new player/SDK/app builds), which is explicitly out of scope. They are documented rather than implemented:
- Bearer session tokens are replayable across devices. The token embeds a
deviceIdbut is not cryptographically bound to the device — anyone holding a valid, unexpired token can present it. This is the deliberate cooperative-sessions model: the real revocation boundary for live content is grant-revoke plus stream-key rotation, not the token. Hardware device-binding (Android Keystore attestation in the proof) would be a protocol change. - Offline token validity until expiry. Signed tokens are offline-checkable
by design, so for an offline client, revocation (a
tokenVersionbump) only bites on its next online check — the worst-case latency is the session TTL. This is an accepted trade for offline playback. - Legacy shared publisher key. With
LEGACY_PUBLISHER=1(the default, for single-broadcaster deployments), unnamed registers verify against the shared init key at implicit scope*. The panel now warns at boot when this is on while named publishers are enrolled. SetLEGACY_PUBLISHER=0to close it once every broadcaster carriesPUBLISHER_NAME. - In-memory key material is not zeroed. sodium key buffers live on the
JS/GC heap. Best-effort wiping is unreliable in a managed runtime, so it is
not attempted. Protect the host — the OPRF/signing keys on disk are the
crown jewels (
0600, backed up encrypted). - The panel catalog swarm has no connection cap. Every client replicates the signed catalog over one swarm, so it is intentionally open. Confidentiality comes from encryption, not connection-gating (see above).
- OPRF construction is not independently certified. The 2HashDH login
follows RFC 9497 over the audited
@noble/curvesristretto255, but the construction itself has not had a certified third-party review. PANEL_ADMIN_URLshould not embed credentials. Supply the reseller/library service credentials viaPANEL_ADMIN_USER/PANEL_ADMIN_PASS, never as URL userinfo. The URL is surfaced in diagnostics, and would carry embedded credentials with it.
Dependencies
The shipped crypto path carries no known advisories (sodium-native 4.3.3 / 5.1.0,
hypercore-crypto 3.7.0, protomux-rpc 1.10.0, @noble/curves 1.9.7,
@noble/hashes 1.8.0, b4a 1.8.1, safety-catch 1.0.3). npm audit reports one
high-severity advisory against electron (the optional desktop player's
build-time dependency, renderer-process CVEs). This is not a shipped crypto path;
its fix is a breaking major bump and is tracked separately from this pass.