Spécifications : stockage cloud chiffré de bout en bout sur Telegram

Version révisée 0.2 (2026-10-27). Les changements par rapport à la version initiale sont listés en annexe A. Les formats binaires et les messages signés décrits ici sont normatifs : un client tiers doit les reproduire à l'octet près.

1. Vue d'ensemble

Le service stocke des fichiers chiffrés côté client dans des canaux Telegram, envoyés par des bots. Les métadonnées (utilisateurs, dossiers, pointeurs vers les fichiers, quotas, plans, partages) sont dans une base de données MySQL. L'API est servie par des Cloudflare Workers répartis sur plusieurs comptes « serveurs ». Un compte « maître » supervise leur charge.

Le système repose sur quatre principes :

  1. Le serveur ne voit jamais de clé privée, de mot de passe, de contenu en clair, ni de nom de fichier ou de dossier en clair.
  2. Le client chiffre, découpe et signe. Le serveur valide la forme, applique les quotas et contrôle l'accès.
  3. Toute donnée temporaire (morceaux non indexés) est purgée automatiquement.
  4. Le nombre de comptes serveurs est piloté par un simple fichier de configuration (servers.json).

2. Architecture

Composant Rôle Hébergement
Serveurs API (N) Authentification, upload, téléchargement, dossiers, partages, publication du quota Un Worker par compte Cloudflare serveur
Compte maître Registre des serveurs, collecte des quotas, tableau de bord, API des serveurs disponibles Worker drive-master et Durable Object SQLite, sur un compte dédié (dépôt 014-Drive-Worker-Master)
Base partagée Toutes les métadonnées MySQL HeatWave (Oracle Cloud), via Hyperdrive
KV local Quota du serveur Un namespace KV par compte serveur
Telegram Morceaux chiffrés et manifestes Pool de bots et de canaux
Application web Client 100 % navigateur : chiffrement, interface, liens publics Cloudflare Pages, compte dédié (dépôt 014-Drive-Worker-WEBCLIENT) : https://app-014drive.pages.dev
Documentation Site de documentation et référence de l'API Cloudflare Pages, compte dédié (dépôt 014-Drive-DOCS) : https://docs-014drive.pages.dev

2.1 Base de données partagée

D1 étant lié à un compte, la base partagée est une base MySQL externe (MySQL HeatWave). Hyperdrive est lui aussi lié à un compte : chaque compte serveur possède sa propre configuration Hyperdrive, qui pointe vers la même base. Son identifiant figure dans servers.json.

Contraintes :

Le registre des serveurs n'est pas dans cette base : il est tenu par le maître sur son propre compte (11.2), pour que le maître ne dépende pas de ce qu'il surveille.

2.2 Secrets et configuration

Nom Type Portée Usage
TOKEN_HMAC_SECRET secret partagé Signature des jetons de session
CHALLENGE_HMAC_SECRET secret partagé Signature des challenges sans état
FAKE_SALT_SECRET secret partagé Faux sels et faux bundles (anti-énumération)
BOT_TOKENS secret partagé JSON {bot_id: token} du pool de bots
CF_ANALYTICS_TOKEN secret par compte Lecture des analytics du compte (« Account Analytics: Read »)
CF_ACCOUNT_ID variable par compte Compte interrogé pour le quota
SERVER_NAME variable par compte Nom du serveur (srv-01…)
WEBAUTHN_RP_ID variable partagé Domaine de l'application web
WEBAUTHN_ORIGINS variable partagé Origines autorisées, séparées par des virgules
DAILY_REQUEST_LIMIT variable partagé Quota quotidien de requêtes (100 000 en gratuit)
HYPERDRIVE binding par compte Accès à la base partagée
USAGE_KV binding par compte Quota du serveur

3. Modèle cryptographique

3.1 Algorithmes

Usage Algorithme
Dérivation depuis le mot de passe Argon2id v1.3 (19), m = 64 Mio, t = 3, p = 1, sortie 32 octets
Dérivation de sous-clés HKDF-SHA256 (sel vide sauf mention contraire, info = libellé ASCII)
Chiffrement symétrique XChaCha20-Poly1305 IETF (nonce 24 octets, tag 16 octets)
Signature Ed25519
Chiffrement de clé pour un destinataire X25519 en sealed box (libsodium crypto_box_seal)
Empreinte de nom (doublons) HMAC-SHA256
Déverrouillage par passkey WebAuthn PRF, puis HKDF-SHA256

libsodium (wasm dans le navigateur) couvre tous les besoins côté client. Côté Worker : Ed25519 et HMAC via WebCrypto ; pour les passkeys, ECDSA P-256 et RSA (WebCrypto) et un décodeur CBOR léger.

3.2 Encodages

3.3 Identité de l'utilisateur et bundle de clés

Le mot de passe protège un bundle de clés aléatoires : changer de mot de passe ne demande ni de rechiffrer les fichiers ni de refaire les partages.

mot de passe + sel (16 octets) ──Argon2id──► KEK (32 octets)

Bundle en clair (97 octets) :
  offset 0   1   version = 0x01
  offset 1   32  sign_seed  (graine Ed25519 ; sign_sk = keypair(sign_seed))
  offset 33  32  box_sk     (clé privée X25519)
  offset 65  32  root_key

bundle_enc (137 octets) = nonce (24) ‖ XChaCha20-Poly1305(KEK, bundle, AAD = "TGC-bundle-v1") (97 + 16)

kdf_params est transmis sous la forme {"alg":"argon2id","v":19,"m":65536,"t":3,"p":1} (m en Kio). Le serveur accepte m ∈ [65536, 1048576], t ∈ [3, 10], p ∈ [1, 4], et stocke la sérialisation dans cet ordre de clés.

Le serveur stocke le sel, les paramètres, bundle_enc et les clés publiques sign_pk et box_pk. La taille fixe de bundle_enc permet de renvoyer des faux bundles indiscernables pour les comptes inexistants.

Comme bundle_enc est récupérable avant authentification, une attaque hors ligne par dictionnaire reste possible sur un mot de passe faible. Le client impose au moins 12 caractères avec une estimation d'entropie (zxcvbn), ou propose une phrase de passe générée.

3.4 Hiérarchie de clés

root_key (dans le bundle)
 └── Clé du dossier racine (aléatoire)         wrappée par root_key
      └── Clé de sous-dossier (aléatoire)       wrappée par la clé du dossier parent
           └── FEK, clé de fichier (aléatoire)  wrappée par la clé du dossier qui le contient

Toute clé wrappée a la forme nonce (24) ‖ ciphertext (32) ‖ tag (16) = 72 octets, avec en AAD "TGC-wrap-v1" ‖ item_id (UUID ASCII de l'objet protégé), ce qui empêche de déplacer un blob d'un objet vers un autre.

Chaque clé de dossier K_d donne :

4. Authentification

4.1 Messages signés

Les messages sont des octets ; ‖ est la concaténation et \n l'octet 0x0A.

Nom Message
Inscription "REGISTER-v1" ‖ utf8(username) ‖ sign_pk (32) ‖ box_pk (32)
Connexion utf8("AUTH-v1\n" + api_origin + "\n" + challenge)
Mot de passe utf8("PASSWORD-v1\n" + user_id + "\n" + timestamp + "\n" + b64(salt) + "\n" + kdf_params + "\n" + b64(bundle_enc))
Lien public utf8("LINK-v1\n" + api_origin + "\n" + challenge), signé par la clé dérivée du lien
Ajout de passkey "PASSKEY-ADD-v1" ‖ utf8(user_id) ‖ SHA-256(credential_id) ‖ SHA-256(clé COSE) ‖ SHA-256(bundle_enc_pk)
Suppression de passkey utf8("PASSKEY-DEL-v1\n" + passkey_id + "\n" + timestamp)
Réglages utf8("SETTINGS-v1\n" + user_id + "\n" + timestamp + "\npassword_login_enabled=" + (0 ou 1))
Code de récupération utf8("RECOVERY-v1\n" + user_id + "\n" + timestamp + "\n" + b64(recovery_bundle_enc))

api_origin est l'origine du serveur appelé (https://srv-01.exemple.workers.dev), sans barre finale. Elle lie la signature au serveur : une signature obtenue par un faux serveur n'est pas rejouable sur un vrai. Les requêtes signées avec horodatage sont acceptées à ± 300 s.

4.2 Challenges sans état

challenge = base64url(
  version (1) = 0x01
  purpose (1)        1 = connexion, 2 = création de passkey, 3 = connexion par passkey, 4 = lien public
  expires_at (8)     secondes Unix, big-endian
  subject_len (1) ‖ subject (≤ 64 octets UTF-8)     username, user_id ou share_id selon l'usage
  random (32)
  mac (32)           HMAC-SHA256(CHALLENGE_HMAC_SECRET, tout ce qui précède)
)

Validité 60 s. Tout serveur peut vérifier un challenge émis par un autre. À la consommation, SHA-256(challenge décodé) est inséré dans used_challenges ; une clé dupliquée signale un rejeu. Les lignes expirées sont purgées par le cron.

4.3 Jetons de session

token   = "v1." ‖ base64url(payload) ‖ "." ‖ base64url(mac)
payload = utf8(sub + "|" + exp + "|" + scope)
mac     = HMAC-SHA256(TOKEN_HMAC_SECRET, "TOKEN-v1|" ‖ payload)

scope vaut user pour une session, ou share:<share_id> pour un jeton de lien public. Durée : 15 minutes. Le jeton est transmis dans Authorization: Bearer <token> et accepté par tous les serveurs sans accès à la base.

4.4 Inscription

POST /v1/auth/register

{
  "username": "alice",
  "salt": "b64 (16)",
  "kdf_params": {"alg":"argon2id","v":19,"m":65536,"t":3,"p":1},
  "bundle_enc": "b64 (137)",
  "sign_pk": "b64 (32)",
  "box_pk": "b64 (32)",
  "root_folder": {"folder_id": "uuid", "wrapped_key": "b64 (72)"},
  "proof": "b64 (64)"
}

Le serveur vérifie la forme, la preuve (message d'inscription), l'unicité du nom, puis crée dans une transaction l'utilisateur (avec un user_handle aléatoire de 16 octets pour WebAuthn), le dossier racine et l'abonnement au plan par défaut. Réponse 201 {user_id, root_folder_id}. Erreurs : USERNAME_TAKEN, ID_CONFLICT, INVALID_PROOF.

La clé du dossier racine est wrappée par root_key avec l'AAD "TGC-wrap-v1" ‖ folder_id.

4.5 Connexion par mot de passe

Étape Requête Description
1 POST /v1/auth/prelogin {username} Retourne salt, kdf_params, bundle_enc. Pour un nom inconnu, ou si la connexion par mot de passe est désactivée, renvoie des valeurs factices déterministes : HMAC(FAKE_SALT_SECRET, label ‖ 0x00 ‖ i ‖ 0x00 ‖ username) concaténés, avec les paramètres KDF par défaut.
2 Local Le client dérive la KEK et déchiffre le bundle. Un échec du tag signifie un mauvais mot de passe ou un compte inexistant, sans distinction possible.
3 POST /v1/auth/challenge {username} Retourne {challenge, expires_at}. Aucune lecture en base : un challenge est émis pour n'importe quel nom.
4 POST /v1/auth/verify {challenge, signature} Vérifie le challenge (HMAC, usage, expiration), la signature avec sign_pk, le statut du compte, puis consomme le challenge. Erreur unique AUTH_FAILED pour un nom inconnu ou une mauvaise signature.
5 Réponse {token, expires_at, user_id, root_folder_id}

À l'expiration du jeton, le client refait les étapes 3 et 4 sans redemander le mot de passe, puisque le bundle reste en mémoire.

4.6 Changement de mot de passe

POST /v1/auth/password (session) avec {salt, kdf_params, bundle_enc, timestamp, signature}. Le client dérive une nouvelle KEK avec un nouveau sel et rechiffre le même bundle. La signature porte sur le message « Mot de passe » (4.1). Les clés, les fichiers et les copies de bundle par passkey ne changent pas. Réponse 204.

4.7 Connexion par passkey

4.7.1 Principe

L'extension PRF de WebAuthn (fondée sur hmac-secret de CTAP2) fait renvoyer par l'authentificateur, à chaque assertion, prf_out = HMAC(secret_interne, sel). Ce secret ne quitte jamais l'authentificateur. Le client en dérive une clé qui chiffre une copie du bundle :

prf_salt      = SHA-256("TGC-PRF-bundle-v1")                (constante globale versionnée)
K_pk          = HKDF(prf_out, info = "TGC-passkey-bundle-v1")
bundle_enc_pk = nonce (24) ‖ XChaCha20-Poly1305(K_pk, bundle, AAD = user_id ‖ SHA-256(credential_id) ‖ prf_version)

Le bundle est le même (mêmes sign_sk, box_sk, root_key) ; chaque passkey a sa propre copie chiffrée. Le sel constant permet une connexion sans saisie du nom d'utilisateur.

4.7.2 Paramètres WebAuthn

Paramètre Valeur Raison
rp.id WEBAUTHN_RP_ID = app-014drive.pages.dev (application web) Les serveurs API sont sur d'autres domaines ; seule l'origine du client compte
residentKey required Passkey découvrable : connexion sans nom d'utilisateur ni allowCredentials
userVerification required La passkey déverrouille toutes les clés : biométrie ou PIN obligatoire
attestation none Inutile, et plus respectueux de la vie privée
pubKeyCredParams ES256 (−7), EdDSA (−8), RS256 (−257) Authentificateurs courants
user.id user_handle (16 octets aléatoires) Aucune donnée identifiante
extensions.prf { eval: { first: prf_salt } } Indispensable

Les challenges réutilisent le mécanisme de 4.2 (usages 2 et 3).

4.7.3 Ajout d'une passkey (utilisateur connecté)

Étape Requête Description
1 POST /v1/auth/passkeys/options (session) Challenge, rp, user, excludeCredentials et paramètres de 4.7.2
2 Local navigator.credentials.create(...). Si prf.enabled est faux, refus expliqué à l'utilisateur. Si prf.results est absent, enchaîner un navigator.credentials.get() limité à la nouvelle passkey.
3 Local Dérivation de K_pk, chiffrement du bundle, libellé chiffré avec HKDF(root_key, "passkey-label"), preuve signée "PASSKEY-ADD-v1" ‖ user_id ‖ SHA-256(credential_id) ‖ SHA-256(cose_public_key) ‖ SHA-256(bundle_enc_pk)
4 POST /v1/auth/passkeys {attestation, bundle_enc_pk, label_enc, proof} (session) Vérifie challenge, type = "webauthn.create", origine, rpIdHash, drapeaux UP et UV, extrait la clé COSE, vérifie proof avec sign_pk

La preuve signée empêche qu'un jeton de session volé suffise à enregistrer la passkey d'un attaquant.

4.7.4 Connexion

Étape Requête Description
1 POST /v1/auth/passkey/challenge Challenge sans nom d'utilisateur (usage 3)
2 Local navigator.credentials.get({ publicKey: { challenge, rpId, userVerification: "required", extensions: { prf: { eval: { first: prf_salt } } } }, mediation: "conditional" })
3 POST /v1/auth/passkey/verify {challenge, credential_id, authenticatorData, clientDataJSON, signature, userHandle} Recherche par SHA-256(credential_id), contrôle de userHandle, du challenge, de type = "webauthn.get", de l'origine, du rpIdHash, de UP et UV, de la signature COSE et du compteur (si ancien et nouveau sont non nuls et que le nouveau n'augmente pas : suspicion de clonage)
4 Réponse Jeton de session, bundle_enc_pk, sign_pk, box_pk, root_folder_id
5 Local Déchiffrement du bundle, puis contrôle que la clé publique dérivée de sign_sk est bien sign_pk

Le renouvellement du jeton se fait ensuite par le challenge Ed25519 de 4.5, sans nouveau geste biométrique.

4.7.5 Sécurité

bundle_enc_pk n'est renvoyé qu'après une assertion valide : il n'est pas exposé à l'attaque hors ligne de 3.3, et même en cas de fuite de la base il est chiffré par une clé de 256 bits non dérivée d'un secret humain.

Option « passkey uniquement » : users.password_login_enabled = 0. prelogin renvoie alors des valeurs factices. Le client ne l'autorise qu'avec au moins deux passkeys ou un code de récupération : 128 bits aléatoires montrés une seule fois, qui chiffrent une copie supplémentaire du bundle (HKDF(code, "recovery-bundle-v1"), stockée dans users.recovery_bundle_enc).

À faire figurer dans la documentation utilisateur :

4.7.6 Passkeys sans PRF et appareils de confiance

Certains gestionnaires (Bitwarden, quand il stocke la passkey) ne fournissent pas PRF : la passkey authentifie mais ne donne aucun secret, elle ne peut donc pas déverrouiller les clés seule. Elle est acceptée sur un appareil de confiance :

Ni une copie du profil du navigateur (il manque device_key), ni le serveur (il manque la clé locale) ne suffisent à ouvrir le bundle. Les passkeys sans PRF ne comptent pas comme moyen de récupération (mode « passkey uniquement », protection de la dernière passkey).

4.7.7 Format des échanges WebAuthn

Les réponses WebAuthn sont transmises au format JSON standard (PublicKeyCredential.toJSON()), champs binaires en base64url : {"credential": {"id", "response": {"clientDataJSON", "attestationObject"}}} à l'ajout, {"credential": {"id", "response": {"clientDataJSON", "authenticatorData", "signature", "userHandle"}}} à la connexion. Le serveur ne vérifie pas l'attestation (demandée à none) : il lit seulement authData. Il vérifie lui-même les signatures ES256, EdDSA et RS256 via WebCrypto, avec un décodeur CBOR minimal, sans bibliothèque WebAuthn (celles-ci embarquent un analyseur X.509 inutile ici et coûteux au démarrage).

Code de récupération : PUT /v1/auth/recovery {recovery_bundle_enc, timestamp, signature} (session), et POST /v1/auth/recovery/prelogin {username} (sans authentification) qui renvoie la copie de récupération, ou une valeur factice de même taille. Le client la déchiffre avec la clé dérivée du code, puis se connecte par challenge Ed25519.

4.7.8 Gestion

Requête Description
GET /v1/auth/passkeys Identifiant, libellé chiffré, dates de création et de dernière utilisation, drapeaux BE et BS
DELETE /v1/auth/passkeys/{id} Signature sign_sk de "PASSKEY-DEL-v1" ‖ id ‖ timestamp. Refusée s'il s'agit du dernier moyen de connexion.
PATCH /v1/auth/settings {password_login_enabled}, signé par sign_sk, avec les contrôles de 4.7.5

5. Format des données

5.1 Morceau chiffré

Taille maximale : 19 Mio (19 922 944 octets), en-tête et tag compris, sous la limite de 20 Mo de getFile.

offset  taille  champ
0       4       magic "TGC1"
4       1       version = 0x01
5       24      nonce
29      n       ciphertext
29+n    16      tag Poly1305
AAD = file_id (16 octets binaires de l'UUID) ‖ chunk_index (uint32 big-endian)

Le serveur valide le magic, la version et les bornes : au moins 46 octets, au plus 19 Mio, et tous les morceaux sauf le dernier exactement à la taille maximale. Il ne calcule pas de hash (hacher 19 Mio dépasserait 10 ms de CPU) ; l'intégrité est garantie par le tag AEAD, vérifié par le client.

5.2 Index (fourni par le client)

{
  "v": 1,
  "file_id": "uuid",
  "upload_id": "uuid",
  "chunk_count": 53,
  "chunks": [ { "i": 0, "size": 19922944 } ],
  "total_size": 1048576000,
  "meta_enc": "b64(nonce ‖ ct ‖ tag)",
  "author_pk": "b64",
  "sig": "b64"
}

meta_enc est chiffré avec la FEK (AAD = "TGC-meta-v1" ‖ file_id) et contient le nom réel, le type MIME, la taille en clair et les dates. sig est une signature Ed25519 sur la sérialisation canonique : JSON avec clés triées récursivement, sans espaces, sans le champ sig (RFC 8785, JCS).

5.3 Manifeste (construit par le serveur)

{ "v": 1, "index": { …index client intact, signature comprise… }, "replicas": [ [ {bot_id, chat_id, message_id, file_id}, {…} ], … ] }

replicas[i] liste les copies du morceau i. Le manifeste est envoyé sur Telegram ; la base ne garde que ses propres répliques (files.manifest_refs). Le serveur le garde 10 minutes en cache dans l'isolate. Un file_id Telegram n'est utilisable que par le bot qui l'a obtenu : bot_id est toujours stocké avec chaque réplique.

6. Flux d'upload

Étape Requête Traitement serveur
1 POST /v1/uploads {folder_id, chunk_count, total_size} Droits d'écriture sur le dossier, plan actif, quota en octets et en fichiers en tenant compte des réservations en cours (ligne du propriétaire verrouillée). chunk_count ≤ 1000 et total_size doit être cohérent : la taille de chaque morceau est fixée dès cette étape. Crée la session (upload_id, file_id généré par le serveur, expiration 24 h) et réserve le quota. Réponse {upload_id, file_id, expires_at}.
2 PUT /v1/uploads/{upload_id}/chunks/{i} (corps binaire) Valide le format (5.1), tire au hasard deux canaux actifs distincts du pool, envoie le morceau en flux avec sendDocument, écrit les deux répliques dans pending_chunks. Gère les 429 de Telegram (retry_after, relayé au client en Retry-After). Renvoyer un morceau déjà présent le remplace ; l'ancien est supprimé.
3 POST /v1/uploads/{upload_id}/commit {index, wrapped_fek, name_enc, name_hmac} Valide l'index (6.1). Si valide : manifeste envoyé sur Telegram, insertion dans files et names, suppression des pending_chunks, conversion de la réservation en consommation. Sinon : 422 INVALID_INDEX, session annulée et morceaux supprimés. Un nom déjà pris donne 409 NAME_EXISTS sans annuler la session : le client peut recommencer avec un autre nom.
4 DELETE /v1/uploads/{upload_id} Annulation volontaire, même purge.

Emplacements de stockage. Un emplacement est un couple (bot, chat) enregistré dans channels. Deux sortes de chats sont acceptées :

Les deux répliques d'un morceau vont toujours dans deux emplacements distincts.

Jeton d'upload et limite de CPU. POST /v1/uploads renvoie aussi upload_token : "u1." ‖ base64url(upload_id|file_id|user_id|chunk_count|total_size|exp) ‖ "." ‖ base64url(HMAC-SHA256(TOKEN_HMAC_SECRET, "UPLOAD-v1|" ‖ payload)). Présenté dans l'en-tête X-Upload-Token, il permet de valider un morceau sans lire la base : l'envoi d'un morceau ne fait qu'une écriture SQL. Le corps du morceau n'est jamais traité en JavaScript : seul l'en-tête TGC1 est lu, puis le flux est relayé nativement vers les deux répliques (tee(), FixedLengthStream), ce qui garde chaque requête sous la limite de CPU du plan gratuit.

Envoi parallèle. Le client envoie jusqu'à 5 morceaux à la fois. Le serveur choisit les répliques du morceau i par rotation (i × 2, i × 2 + 1 dans le pool trié) plutôt qu'au hasard : des morceaux simultanés tombent sur des bots différents.

Limite de débit Telegram : un bot ne peut envoyer qu'environ 20 messages par minute dans un même canal. Un fichier de 1 Go représente 106 messages (53 morceaux × 2 répliques). Le pool doit compter assez de canaux pour que le tirage aléatoire répartisse la charge ; en cas de 429, le serveur réessaie sur un autre canal avant de renvoyer l'erreur.

6.1 Validation de l'index

L'index est valide si : upload_id et file_id correspondent à la session ; chunk_count est égal au nombre de morceaux reçus, indices de 0 à n−1 sans trou ; chaque size correspond à la taille reçue ; la somme est égale à total_size et respecte la réservation ; author_pk est la clé de l'utilisateur authentifié ; sig est valide ; name_hmac n'existe pas déjà dans le dossier de destination.

6.2 Suppressions Telegram et purge des orphelins

Toutes les suppressions de messages Telegram (upload annulé ou expiré, morceau remplacé, fichier supprimé, réplique orpheline après un échec partiel) passent par la table tg_trash, remplie dans la transaction qui déréférence les messages. Une requête utilisateur ne fait donc jamais d'appel de suppression, quelle que soit la taille du fichier.

À chaque passage, le cron prend un lot de tg_trash (FOR UPDATE SKIP LOCKED, pour que plusieurs serveurs travaillent en parallèle sans conflit), regroupe les messages par (bot, canal), et appelle deleteMessages (100 messages au plus par appel, 40 appels au plus par passage). Les bots administrateurs avec le droit de suppression ne sont pas limités à 48 h. Les erreurs 4xx autres que 429 abandonnent le lot ; les 429 et 5xx le laissent pour le passage suivant.

Les suppressions de fichiers et de dossiers ne touchent pas Telegram pendant la requête : chaque fichier supprimé passe dans manifest_trash avec les références de son manifeste. À chaque passage, le cron en développe quelques-uns (télécharge le manifeste, met ses répliques et celles des morceaux dans tg_trash). Si Telegram est indisponible, la ligne est retentée ; au-delà de 7 jours, seul le manifeste est supprimé. Budget d'un passage : quota (2 sous-requêtes), manifestes (16 au pire), suppressions (30).

Un passage sur trois, le cron sélectionne aussi les sessions expirées non validées (même verrouillage), met leurs morceaux dans tg_trash et supprime les sessions, ce qui libère la réservation.

7. Flux de téléchargement

Étape Requête Traitement serveur
1 GET /v1/files/{id} Contrôle d'accès : propriétaire, partage actif ou jeton de lien public. Retourne l'index client (sans références Telegram), wrapped_fek et name_enc.
2 GET /v1/files/{id}/chunks/{i} Même contrôle. getFile puis téléchargement de la réplique principale, renvoyée en flux. Bascule sur la seconde en cas d'échec.
3 Local Vérification de sig, déchiffrement de la FEK, puis de chaque morceau (AEAD avec AAD), réassemblage.

Une requête par morceau garantit qu'on reste sous la limite de sous-requêtes. Le jeton du bot ne sort jamais du serveur.

8. Dossiers

8.1 Noms chiffrés et doublons

name_enc = XChaCha20-Poly1305(K_parent_name, nom, AAD = UUID de l'élément), nonce aléatoire. name_hmac = HMAC-SHA256(K_parent_hmac, lower(NFC(nom))) : les noms sont insensibles à la casse. La table names (clé primaire (parent_id, name_hmac)), commune aux fichiers et aux dossiers, détecte les doublons sans révéler les noms.

8.2 Opérations

Requête Description
POST /v1/folders {parent_id, folder_id, wrapped_key, name_enc, name_hmac}
GET /v1/folders/{id} Enfants : identifiants, clés wrappées, noms chiffrés, tailles
PATCH /v1/folders/{id} Renommage : nouveaux name_enc et name_hmac
POST /v1/folders/{id}/move {new_parent_id, wrapped_key, name_enc, name_hmac}
PATCH /v1/files/{id} Renommage d'un fichier
POST /v1/files/{id}/move {new_folder_id, wrapped_fek, name_enc, name_hmac}
DELETE /v1/files/{id} Supprime le fichier, son nom et ses partages directs, libère le quota, et met le fichier dans manifest_trash
DELETE /v1/folders/{id} Suppression récursive en base, instantanée (5000 éléments au plus, sinon 413 FOLDER_TOO_LARGE) : uploads en cours annulés, partages supprimés, fichiers mis dans manifest_trash, quota libéré

8.3 Déplacement

Le client rechiffre la clé de l'élément avec la clé du nouveau parent, rechiffre le nom et recalcule le HMAC. Le serveur met à jour l'enregistrement et names dans une transaction. Aucun morceau ni manifeste n'est modifié. Déplacer un dossier emmène toute sa sous-arborescence.

Le serveur vérifie les droits d'écriture sur l'origine et la destination, rejette le déplacement d'un dossier dans l'un de ses descendants (400 MOVE_INTO_DESCENDANT), et, si la destination appartient à un autre utilisateur, transfère le quota et la propriété de toute la sous-arborescence après avoir vérifié la place disponible. Les partages émis par l'ancien propriétaire sur ces éléments sont révoqués (disabled_reason = 'moved'). Le dossier racine ne peut être ni renommé, ni déplacé, ni supprimé (400 ROOT_FOLDER).

Sortir un fichier d'un dossier partagé coupe l'accès côté serveur, mais un destinataire qui a déjà obtenu la FEK peut toujours lire ce qu'il a téléchargé. Le client propose l'option « rechiffrer » (nouvelle FEK et nouvel upload). Quand un élément entre dans un dossier partagé, le client indique avec combien de personnes il est partagé.

9. Partage

9.1 Avec un utilisateur inscrit

Étape Description
1 GET /v1/users/{username}/keys renvoie box_pk et sign_pk. Le client affiche une empreinte courte à comparer avec le destinataire, et mémorise la clé (confiance au premier usage).
2 Le client chiffre la FEK ou la clé du dossier en sealed box pour box_pk, ainsi que le nom de la ressource.
3 POST /v1/shares {resource_type, resource_id, recipient_username, wrapped_key, name_enc, permissions, expires_at}
4 Le destinataire liste ses partages (GET /v1/shares/incoming) et ouvre la clé avec box_sk.

Le partage de dossier est « vivant » : les éléments ajoutés ensuite sont accessibles.

9.2 Par lien public

https://<app>/s/{share_id}#{link_secret}

Le fragment n'est jamais envoyé au serveur. link_secret fait 32 octets aléatoires. K_link = HKDF(link_secret, "link-enc") chiffre la FEK ou la clé du dossier ; une paire Ed25519 de graine HKDF(link_secret, "link-sign") donne link_verify_pk, envoyée au serveur. Avec un mot de passe optionnel, link_secret est remplacé par Argon2id(mot de passe, link_salt) ‖ link_secret passé dans HKDF ; link_salt et les paramètres sont stockés dans le partage.

Accès : POST /v1/s/{share_id}/challenge (usage 4), signature du challenge avec la clé du lien, POST /v1/s/{share_id}/verify, qui renvoie un jeton de scope share:<share_id> (expirant au plus tard avec le lien), la clé chiffrée et le nom chiffré. Chaque ouverture incrémente le compteur ; au-delà de max_downloads, 403 LINK_EXHAUSTED. Le jeton ne donne accès qu'en lecture, à la ressource partagée et à sa sous-arborescence ; le partage est revérifié à chaque requête, donc une révocation est immédiate. Seul le propriétaire peut créer un partage : un destinataire ne peut pas re-partager.

9.3 Droits d'écriture

Avec write sur un dossier partagé, le destinataire peut y déposer des fichiers chiffrés avec la clé du dossier. Le quota est imputé au propriétaire du dossier (upload_sessions.owner_id). Chaque index est signé par son auteur et le client affiche qui a déposé quoi.

9.4 Révocation

DELETE /v1/shares/{id} coupe immédiatement l'accès côté serveur. La révocation n'est pas rétroactive. Pour un dossier, le client peut faire une rotation : nouvelle clé de dossier, rechiffrement des clés et noms des enfants directs et réémission des partages restants. Ce comportement doit figurer dans la documentation utilisateur.

10. Plans, quotas et marque

Un plan définit une limite en octets, une limite en nombre de fichiers et une durée. Le plan marqué is_default est attribué à l'inscription. Chaque abonnement relie un utilisateur à un plan avec une date de début et une date d'expiration (NULL = sans fin). users.brand indique la marque de rattachement.

Le quota consommé est la somme des fichiers validés (users.used_bytes, users.file_count, mis à jour dans les mêmes transactions que les fichiers) et des réservations en cours (upload_sessions.reserved_bytes). À l'expiration d'un abonnement, le compte passe en lecture seule : téléchargement autorisé, upload bloqué. Le sort des données après un délai de grâce reste à décider.

11. Supervision des quotas Cloudflare

11.1 Sur chaque serveur

Un cron unique */3 * * * * :

Chaque réponse inclut X-Server-Usage: 42.7 (exposé par CORS). La valeur est mise en cache 60 s dans l'isolate. GET /v1/status renvoie {server, usage} sans authentification. Avec le délai de 3 minutes, la valeur peut être légèrement sous-estimée : le seuil de 80 % sert de marge.

11.2 Compte maître

Le Worker drive-master est dans son propre dépôt (014-Drive-Worker-Master) et sur son propre compte Cloudflare : https://drive-master.master-014drive.workers.dev. Son registre est un Durable Object SQLite (disponible sur le plan gratuit) : le maître ne dépend ni de la base partagée ni des serveurs qu'il surveille. Son cron (*/3 * * * *) appelle GET /v1/status sur chaque serveur actif (45 au plus, sous la limite de sous-requêtes) et conserve 30 jours d'historique.

Requête Authentification Description
GET / Aucune Tableau de bord : état des serveurs, usage sur 24 h
GET /api/servers/available Aucune Serveurs actifs, sous 80 %, vus depuis moins de 10 minutes, triés par usage croissant (cache 30 s)
GET /api/servers Aucune Tous les serveurs et leur état
GET /api/history?hours=24 Aucune Historique d'usage
POST /api/servers Jeton d'administration {name, base_url} : enregistrement par la CI d'un serveur après son déploiement
PATCH /api/servers/{name} Jeton d'administration {enabled} : retirer un serveur de la liste proposée aux clients, ou l'y remettre
DELETE /api/servers/{name} Jeton d'administration Supprimer un serveur du registre

L'usage des serveurs n'a rien de confidentiel : la lecture est publique. Les écritures exigent l'ADMIN_TOKEN des serveurs, dont le maître ne connaît que l'empreinte SHA-256 (publiable, le jeton faisant 256 bits aléatoires).

Le client appelle /api/servers/available au démarrage, choisit un serveur, et bascule sur le suivant en cas d'erreur ou si X-Server-Usage dépasse 80 %. Les jetons étant valables partout, la bascule est transparente. Attention : le message de connexion contient api_origin, donc un challenge doit être signé pour le serveur auquel il sera soumis.

12. Schéma de base de données

Le DDL MySQL de référence est migrations/0001_init.sql dans le dépôt du serveur. Différences avec la version initiale :

13. Référence de l'API

Toutes les réponses incluent X-Server-Usage. Les erreurs ont la forme {"error": {"code": "QUOTA_EXCEEDED", "message": "…"}}. La référence détaillée est openapi/openapi.yaml.

Méthode et chemin Authentification Rôle
POST /v1/auth/register Aucune Inscription
POST /v1/auth/prelogin Aucune Sel, paramètres KDF, bundle
POST /v1/auth/challenge Aucune Challenge
POST /v1/auth/verify Aucune Jeton de session
POST /v1/auth/password Session Changement de mot de passe
POST /v1/auth/passkeys/options Session Options de création de passkey
POST /v1/auth/passkeys Session Enregistrer une passkey
GET /v1/auth/passkeys Session Lister ses passkeys
DELETE /v1/auth/passkeys/{id} Session Supprimer une passkey
POST /v1/auth/passkey/challenge Aucune Challenge de connexion par passkey
POST /v1/auth/passkey/verify Aucune Connexion par passkey
PATCH /v1/auth/settings Session Activer ou désactiver la connexion par mot de passe
PUT /v1/auth/recovery Session Enregistrer la copie de récupération du bundle
POST /v1/auth/recovery/prelogin Aucune Copie de récupération (ou valeur factice)
GET /v1/me Session Profil, plan, quotas consommés
GET /v1/users/{username}/keys Session Clés publiques d'un utilisateur
POST /v1/uploads Session Ouvrir une session d'upload
PUT /v1/uploads/{id}/chunks/{i} Session Envoyer un morceau
POST /v1/uploads/{id}/commit Session Envoyer l'index
DELETE /v1/uploads/{id} Session Annuler
GET /v1/files/{id} Session ou partage Index client et clé wrappée
GET /v1/files/{id}/chunks/{i} Session ou partage Télécharger un morceau
PATCH /v1/files/{id} Session Renommer un fichier
POST /v1/files/{id}/move Session Déplacer un fichier
DELETE /v1/files/{id} Session Supprimer un fichier
POST /v1/folders Session Créer un dossier
GET /v1/folders/{id} Session ou partage Lister un dossier
PATCH /v1/folders/{id} Session Renommer un dossier
POST /v1/folders/{id}/move Session Déplacer un dossier
DELETE /v1/folders/{id} Session Supprimer un dossier
POST /v1/shares Session Créer un partage
GET /v1/shares/incoming Session Partages reçus
GET /v1/shares/outgoing Session Partages émis
DELETE /v1/shares/{id} Session Révoquer
POST /v1/s/{share_id}/challenge Aucune Challenge d'un lien public
POST /v1/s/{share_id}/verify Aucune Jeton limité au partage
POST /v1/reports Aucune Signaler un lien
GET /v1/status Aucune Usage du serveur
/v1/admin/* ADMIN_TOKEN migrate, bots, channels, channels/sync, usage/refresh, queues

14. Déploiement multi-comptes

Le déploiement se fait uniquement par GitHub Actions. servers.json à la racine du dépôt du serveur :

[
  { "name": "srv-01", "account_id": "…", "secret_ref": "CF_TOKEN_SRV_01", "analytics_secret_ref": "CF_ANALYTICS_TOKEN_SRV_01" }
]

Champ optionnel hyperdrive_name, si le compte a plusieurs configurations Hyperdrive.

Le workflow lance les tests, lit ce fichier, génère une matrice et, pour chaque compte :

  1. prépare le compte via l'API Cloudflare : crée le namespace KV drive-usage s'il manque, retrouve la configuration Hyperdrive et désactive son cache, crée le sous-domaine workers.dev s'il manque, génère la configuration wrangler ;
  2. lance wrangler deploy ;
  3. pousse les secrets (wrangler secret bulk) : le secret GitHub SHARED_SECRETS (JSON des secrets partagés, jetons des bots et ADMIN_TOKEN) complété du jeton Analytics du compte ;
  4. sur le premier serveur uniquement : applique les migrations (POST /v1/admin/migrate) et synchronise les canaux Telegram (POST /v1/admin/channels/sync).

Routes d'administration (/v1/admin/*, jeton ADMIN_TOKEN) : migrate, bots, channels, channels/sync. La synchronisation enregistre les couples (bot, canal) où le bot est administrateur avec les droits de publication et de suppression, découverts via getUpdates (ajouts de moins de 24 h) ou fournis explicitement.

Ajouter un serveur : créer le compte et sa configuration Hyperdrive, ajouter ses deux jetons dans les secrets GitHub et une entrée dans servers.json, puis pousser. Après son déploiement, chaque serveur s'enregistre auprès du maître dont l'URL figure dans master.json. Le maître et la documentation ont leurs propres dépôts et déploiements. Aucune donnée n'étant stockée sur un serveur, en retirer un ne perd rien.

15. Documentation

Le site (dépôt 014-Drive-DOCS, publié sur Cloudflare Pages) comprend :

Formats précisés par la bibliothèque de référence :

Élément Chiffrement AAD
Bundle (mot de passe) XChaCha20-Poly1305, KEK Argon2id "TGC-bundle-v1"
Bundle (passkey) clé HKDF(prf_out, "TGC-passkey-bundle-v1") user_id ‖ SHA-256(credential_id) ‖ 0x01
Bundle (récupération) clé HKDF(code, "recovery-bundle-v1") "TGC-recovery-v1" ‖ username
Clé de dossier ou de fichier clé du parent "TGC-wrap-v1" ‖ item_id
Nom HKDF(clé du parent, "name-enc") item_id
Métadonnées du fichier (JSON {name, mime, size, modified}) FEK "TGC-meta-v1" ‖ file_id
Clé partagée avec un utilisateur, nom du partage sealed box X25519 —
Clé d'un lien public HKDF(secret, "link-enc") "TGC-link-key-v1" ‖ resource_id
Nom d'un lien public HKDF(secret, "link-enc") "TGC-link-name-v1" ‖ resource_id

Avec un mot de passe de lien, le matériau d'entrée de HKDF est Argon2id(mot de passe, link_salt) ‖ secret. L'empreinte affichée pour comparer des clés est SHA-256("TGC-fingerprint-v1" ‖ sign_pk ‖ box_pk), 10 premiers octets en hexadécimal par groupes de 4.

16. Application web

L'application (https://app-014drive.pages.dev) s'exécute entièrement dans le navigateur ; Cloudflare Pages ne sert que des fichiers statiques. Elle utilise la bibliothèque de référence et ajoute :

Le bundle de clés n'est conservé qu'en mémoire : un rechargement demande de se reconnecter. Une politique de sécurité du contenu stricte (public/_headers) n'autorise que les scripts du site et les appels aux Workers de 014-Drive, car une application chiffrée de bout en bout ne vaut que si son code n'est pas altéré.

Annexe A. Changements par rapport à la version initiale

Sujet Changement
Hyperdrive Une configuration par compte, cache désactivé ; DB_URL/DB_TOKEN supprimés
Secrets CF_ANALYTICS_TOKEN propre à chaque compte ; ajout de CF_ACCOUNT_ID, SERVER_NAME, variables WebAuthn
Formats Encodages, bundle (97/137 octets), kdf_params, clés wrappées (72 octets) et AAD précisés
Messages signés Séparateurs \n pour éviter toute ambiguïté entre l'origine et le challenge
Challenges Format binaire avec champ d'usage, pour qu'un challenge de connexion ne serve pas à un lien public
Jetons Charge utile en clair (`sub
Cron Un seul cron toutes les 3 minutes ; purges un passage sur trois
Purge File tg_trash vidée par le cron, deleteMessages par lots de 100, verrouillage SKIP LOCKED entre serveurs
Upload file_id généré par le serveur ; tailles des morceaux fixées à l'ouverture ; 1000 morceaux au plus ; nom en doublon → 409 sans annulation
Manifeste Structure {v, index, replicas}
Telegram Tirage aléatoire de deux canaux distincts ; limite d'environ 20 messages par minute et par canal
Schéma DDL MySQL, tables passkeys et deletion_jobs, colonnes ajoutées (section 12)
Maître Dépôt et compte dédiés ; registre en Durable Object SQLite ; lecture et tableau de bord publics ; écritures par jeton d'administration
API Routes passkeys et PATCH /v1/auth/settings ajoutées à la table