Sous le capot de Bluesky : l’ATProto)
Bientôt quelques années que j’ai migré de X vers Bluesky. Le choix de Bluesky s’est fait assez naturellement (j’ai déjà tenté Mastodon). mais en dehors de lire quelques papiers sur d’où venait Bluesky et la stack technique, je n’ai jamais pris le temps de creuser ce qui se passait “derrière” bluesky.
Parce qu’en effet, quand on pense à Bluesky, on voit l’interface web, la timeline, les likes et followers, les “skeets” (tweets), mais derrière ça, il y a une énorme brique nommée l’ATProto.
Dans cet article, on va décortiquer ce que c’est, pourquoi c’est intéressant et comment l’utiliser.
Mais d’abord, un petit description s’impose.
Qu’est-ce que l’ATProto ?
L’ATProto (pour AT Protocol, ou Authenticated Transfer Protocol) est un protocole ouvert pour bâtir des réseaux sociaux décentralisés, lancé par Bluesky. L’information à prendre en compte direct: Bluesky n’est qu’une application posée sur l’ATProto, un peu comme un client mail au-dessus de SMTP. D’autres apps tournent sur le même protocole, et rien n’empêche d’en écrire de nouvelles.
Si en parlant d’un “réseau social décentralisé”, on pense plutôt à Mastodon, c’est normal : lui repose sur ActivityPub, l’autre grand protocole du domaine (celui du fediverse). Les deux veulent la même chose mais font des paris techniques très différents.
Là où Mastodon éclate le réseau en instances qui se recopient les unes les autres et où un compte est soudé à son serveur, Bluesky (ATProto) sépare l’identité de ton hébergeur pour que tu puisses changer de serveur sans rien perdre (mais ça on creusera un peu plus tard).
À qui appartient quoi : handle, DID et PDS
Avant d’aller plus loin, nous devons comprendre la chaîne d’identité. Dans l’ATProto, notre identité n’est pas notre serveur. C’est une distinction fondamentale, et c’est elle qui permet la portabilité.
Trois objets entrent en jeu :
- Le handle : un nom lisible sous la forme d’un nom de domaine (e.g.
une-tasse-de.cafepour moi), il est modifiable. - Le DID (Decentralized Identifier) : votre identifiant stable et permanent, du genre
did:plc:hqnyog7skad6m4aejb2yujxy. Il ne change jamais, même si vous changez de handle ou de serveur. - Le PDS : le serveur qui héberge physiquement votre dépôt. Il est indiqué dans votre document DID, donc lui aussi peut changer.
Résolvons le handle une-tasse-de.cafe pour retrouver son DID :
curl -s "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=une-tasse-de.cafe"
{ "did": "did:plc:hqnyog7skad6m4aejb2yujxy" }
Avec ce DID, on peut aller lire son document complet. Pour un did:plc, il est publié dans un annuaire dédié, plc.directory :
curl -s "https://plc.directory/did:plc:hqnyog7skad6m4aejb2yujxy"
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:plc:hqnyog7skad6m4aejb2yujxy",
"alsoKnownAs": [
"at://une-tasse-de.cafe"
],
"verificationMethod": [
{
"id": "did:plc:hqnyog7skad6m4aejb2yujxy#atproto",
"type": "Multikey",
"controller": "did:plc:hqnyog7skad6m4aejb2yujxy",
"publicKeyMultibase": "zQ3shvi9RBYFYwfzbkju871YNjv9EDAZ5Cy52PY5nNkQiH215"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://eurosky.social"
}
]
}
Ce petit document dit tout :
alsoKnownAs: le handle actuellement associé au DID (une-tasse-de.cafe)verificationMethod: la clé publique du compte (zQ3shvi9RBYFYwfzbkju871YNjv9EDAZ5Cy52PY5nNkQiH215)service: l’adresse du PDS,https://eurosky.social. C’est là que le dépôt est stocké.
Information
Comme je disais plus haut, on peut query un registre public pour trouver qui héberge nos données (et par exemple demander à eurosky d’afficher les tweets que je publie sur Bluesky), techniquement il m’est possible de migrer mes données vers un autre PDS pour qu’il me représente.
À partir de maintenant, on a tout ce qu’il faut pour parler directement au PDS : un DID et l’URL du serveur.
Le repository
Chaque compte possède exactement un dépôt (repository) sur son PDS. Ce dépôt est un magasin clé → valeur trié, mais avec une structure particulière qui va nous occuper toute la suite de l’article : un Merkle Search Tree.

Commençons par demander au PDS de se décrire :
PDS="https://eurosky.social"
DID="did:plc:hqnyog7skad6m4aejb2yujxy"
curl -s "$PDS/xrpc/com.atproto.repo.describeRepo?repo=$DID"
{
"handle": "une-tasse-de.cafe",
"did": "did:plc:hqnyog7skad6m4aejb2yujxy",
"didDoc": {
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:plc:hqnyog7skad6m4aejb2yujxy",
"alsoKnownAs": [
"at://une-tasse-de.cafe"
],
"verificationMethod": [
{
"id": "did:plc:hqnyog7skad6m4aejb2yujxy#atproto",
"type": "Multikey",
"controller": "did:plc:hqnyog7skad6m4aejb2yujxy",
"publicKeyMultibase": "zQ3shvi9RBYFYwfzbkju871YNjv9EDAZ5Cy52PY5nNkQiH215"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://eurosky.social"
}
]
},
"collections": [
"app.bsky.actor.profile",
"app.bsky.feed.like",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
"app.bsky.graph.list",
"app.bsky.graph.listitem",
"blog.pckt.publication",
"chat.bsky.actor.declaration",
"dev.at-intent.usage",
"id.sifa.graph.follow",
"id.sifa.profile.certification",
"id.sifa.profile.education",
"id.sifa.profile.externalAccount",
"id.sifa.profile.position",
"id.sifa.profile.presentation",
"id.sifa.profile.presentationDelivery",
"id.sifa.profile.project",
"id.sifa.profile.self",
"id.sifa.profile.skill",
"sh.tangled.actor.profile",
"sh.tangled.feed.comment",
"sh.tangled.feed.star",
"sh.tangled.graph.follow",
"sh.tangled.graph.vouch",
"sh.tangled.knot",
"sh.tangled.publicKey",
"sh.tangled.repo",
"sh.tangled.repo.pull",
"sh.tangled.repo.pull.status",
"sh.tangled.spindle",
"site.standard.publication"
],
"handleIsCorrect": true
}
Déjà, une remarque : ce dépôt ne contient pas que du Bluesky. On y trouve du app.bsky.*, mais aussi tangled le serveur Git (sh.tangled.*), un LinkedIn alternatif nommé Sifa (id.sifa.profile.*) et même une plateforme de blogging pckt (blog.pckt.publication).
On verra un peu plus tard comment ça fonctionne mais c’est grâce à l’ATProto que les applications que j’utilise sont liées à mon PDS (et celui-ci n’a pas son mot à dire sur ce que je stocke et pourquoi).
Astuce
Dans mes bookmarks, j’ai PDSls, un explorateur de dépôts ATProto dans le navigateur : on peut lui donner un handle, un DID ou un AT-URI, et il déballe tout, collections, records (en JSON et en CBOR brut), blobs, jusqu’à l’export CAR.

Les clés : {collection}/{rkey}
Dans le Merkle Search Tree, chaque clé suit une structure stricte à deux segments : {collection}/{rkey}.
{collection}est le nom de la branche qu’on va creuser, un peu comme une table dans une bdd (app.bsky.feed.post).{rkey}est la record key, la clé de l’enregistrement, ça peut être un timestamp.
On peut donc directement lire ces données en faisant une query.
DID=did:plc:hqnyog7skad6m4aejb2yujxy
PDS=https://eurosky.social
curl -s "$PDS/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=app.bsky.feed.post&limit=2"
{
"records": [
{
"uri": "at://did:plc:hqnyog7skad6m4aejb2yujxy/app.bsky.feed.post/3mstlqrvvhk2t",
"cid": "bafyreicyfr56447e57x3e2e34xy5pywvp446zfs2bcdeocsswhvno5fl5m",
"value": {
"text": "Ça y est, j'ai mon knot (équivalent de PDS BlueSky)",
"$type": "app.bsky.feed.post",
"embed": {
"$type": "app.bsky.embed.images",
"images": [
{
"alt": "",
"image": {
"ref": {
"$link": "bafkreidp2smbxtqh2yctyskltgerho2wsshukuxe5jqikfkjhhiq6giyfe"
},
"size": 232678,
"$type": "blob",
"mimeType": "image/jpeg"
},
"aspectRatio": {
"width": 1400,
"height": 810
}
}
]
},
"langs": [
"fr"
],
"createdAt": "2026-08-11T21:48:13.806Z"
}
},
{
"uri": "at://did:plc:hqnyog7skad6m4aejb2yujxy/app.bsky.feed.post/3mstbs5wvlk2a",
"cid": "bafyreiakxbkpv7vei7wlbfvob44xctxutvgnsbpeq3du7sm4syuy4bi5uq",
"value": {
"text": "Honnêtement, c'est vraiment pas mal Tangled. La CI est pratique, l'interface est belle (sauf en PR, là c'est immonde), l'équivalent de Github Pages est bien. \n\nIl manque que les releases et je pourrais migrer un gros nombre de mes repos perso.",
"$type": "app.bsky.feed.post",
"embed": {
"$type": "app.bsky.embed.record",
"record": {
"cid": "bafyreigilfsgfgq4nzfeyxx6zzwv4hbbnq55sv4l6ua2obpelbblrmih6e",
"uri": "at://did:plc:hqnyog7skad6m4aejb2yujxy/app.bsky.feed.post/3mstb5jqqi22a"
}
},
"langs": [
"fr"
],
"createdAt": "2026-08-11T18:50:02.558Z"
}
}
],
"cursor": "3mstbs5wvlk2a"
}
Quelques choses à observer :
- La clé du record est
app.bsky.feed.post/3mstbs5wvlk2a: collection + TID. - L’
uriest un AT-URI :at://{did}/{collection}/{rkey}. C’est l’adresse universelle d’un enregistrement dans tout le réseau qui contient le DID, la clé et l’id de l’élément. - Le
cidest l’identifiant du fichier
Records et blobs : une séparation stricte
… si jusqu’à présent on faisait qu’un équivalent de “REST”, il faut savoir qu’un PDS n’est pas obligé de stocker que du texte. Concrètement la spec distingue 2 informations :
- Les records : les métadonnées légères, du JSON structuré.
- Les blobs : les fichiers binaires (images, vidéos, PDF…) On les envoie au PDS via
com.atproto.repo.uploadBlob, le serveur renvoie un Content ID et l’application colle cet ID dans un record.
Le commit signé : l’auto-certification
Voilà le cœur de la spec. Chaque fois qu’un record est ajouté, modifié ou supprimé, le hash de la racine de l’arbre change. Le PDS génère alors un commit qui capture ce nouvel état, et le signe avec la clé privée du compte.
Ce commit contient :
did: le propriétaire du dépôt.data: le CID de la racine du MST (l’état complet du dépôt à cet instant).rev: un TimeStamp ID de révision, monotone croissant.prev: un lien vers le commit précédent (aujourd’huinulldans le format courant).version: la version du format de dépôt (aujourd’hui3).sig: la signature cryptographique de tout ce qui précède.
Récupérons l’état signé actuel du dépôt :
DID=did:plc:hqnyog7skad6m4aejb2yujxy
PDS=https://eurosky.social
curl -s "$PDS/xrpc/com.atproto.sync.getLatestCommit?did=$DID"
{
"cid": "bafyreicffaziov3xn3fjkmtwm5ye6dfsqrob4xubctcy473zdjtojuiyei",
"rev": "3msvbd7d4gf2v"
}
Ce cid, c’est l’empreinte du commit signé (comme un fichier). On verra plus tard comment, mais grâce à celui-ci : n’importe qui peut vérifier la validité de ce commit.
Récupérer l’export complet du dépôt
Pour ça, on télécharge le dépôt entier. ATProto sait exporter tout un PDS (l’arbre MST et tous les records) dans un seul fichier CAR (Content Addressable aRchives).
DID=did:plc:hqnyog7skad6m4aejb2yujxy
PDS=https://eurosky.social
curl -s "$PDS/xrpc/com.atproto.sync.getRepo?did=$DID" -o atproto.car
-rw-r--r-- 1 qjoly 2.2M atproto.car
2,2 Mio pour tout mon compte. Ouvrons ce fichier avec un petit lecteur CAR claudé en Python.
import cbor2, io, base64
def read_varint(f):
shift = result = 0
while True:
b = f.read(1)
if not b: return None
b = b[0]; result |= (b & 0x7f) << shift
if not (b & 0x80): return result
shift += 7
def read_cid(f):
assert f.read(1) == b"\x01" # CIDv1
read_varint(f); read_varint(f) # codec, code de hash
f.read(read_varint(f)) # longueur + digest
def cid_str(tag):
# CBORTag(42) = CID DAG-CBOR ; on retire le préfixe multibase 0x00 puis base32
return "b" + base64.b32encode(tag.value[1:]).decode().lower().rstrip("=")
data = open("atproto.car", "rb").read(); f = io.BytesIO(data)
header = cbor2.loads(f.read(read_varint(f)))
print(f"version CAR : {header['version']}")
print(f"racine : {cid_str(header['roots'][0])}")
commit = None
while True:
blen = read_varint(f)
if blen is None: break
start = f.tell(); read_cid(f)
block = f.read(blen - (f.tell() - start))
obj = cbor2.loads(block)
if isinstance(obj, dict) and "sig" in obj and "did" in obj:
commit = obj
print("\ncommit :")
for k, v in commit.items():
if k == "sig":
v = f"<{len(v)} octets>"
elif isinstance(v, cbor2.CBORTag):
v = cid_str(v)
print(f" {k:<7}: {v}")
Et ça donne :
version CAR : 1
racine : bafyreicffaziov3xn3fjkmtwm5ye6dfsqrob4xubctcy473zdjtojuiyei
commit :
did : did:plc:hqnyog7skad6m4aejb2yujxy
rev : 3msvbd7d4gf2v
sig : <64 octets>
data : bafyreih7z664my6jus6jubl72wkohuuafuux6m3qolalglgucgur7og3va
prev : None
version: 3
On retrouve exactement le commit décrit plus haut, avec sa signature de 64 octets. Le rev (3msvbd7d4gf2v) correspond bien à ce que getLatestCommit nous avait envoyé. Mais récupérer ce commit ne suffit pas : il faut aussi attester qu’il est bien valide.
Vérifier la signature soi-même
Vu qu’on veut volontairement pas faire confiance à la provenance de ce commit, faut bien qu’on vérifie son authenticité. La signature ATProto est une ECDSA, calculée sur le SHA-256 du commit (en retirant la signature à laquelle on n’accorde pas notre confiance) et ré-encodé en DAG-CBOR. La clé publique, on l’a déjà : c’est le zQ3shvi9... du document DID.
Le plan :
- Prendre le commit
- Retirer la signature existante et le ré-encoder en CBOR canonique.
- En calculer le SHA-256.
- Décoder la clé publique du document DID.
- Vérifier la signature ECDSA avec cette clé publique.
On refait appel à tonton Claude pour générer un script rapidos :
import cbor2, io, hashlib, base58, ecdsa
def read_varint(f):
shift = result = 0
while True:
b = f.read(1)
if not b: return None
b = b[0]; result |= (b & 0x7f) << shift
if not (b & 0x80): return result
shift += 7
def read_cid(f):
assert f.read(1) == b"\x01" # CIDv1
read_varint(f); read_varint(f) # codec, code de hash
f.read(read_varint(f)) # longueur + digest
# 0 : on relit le CAR pour retrouver le commit (bloc DAG-CBOR avec 'sig' + 'did')
f = io.BytesIO(open("atproto.car", "rb").read())
cbor2.loads(f.read(read_varint(f))) # header CAR (ignoré)
commit = None
while True:
blen = read_varint(f)
if blen is None: break
start = f.tell(); read_cid(f)
obj = cbor2.loads(f.read(blen - (f.tell() - start)))
if isinstance(obj, dict) and "sig" in obj and "did" in obj:
commit = obj
# 1-3 : le message signé = commit sans 'sig', en DAG-CBOR canonique, puis sha256
sig = commit.pop("sig")
sig = sig.value if hasattr(sig, "value") else sig
msg = cbor2.dumps(commit, canonical=True)
digest = hashlib.sha256(msg).digest()
# 4 : clé publique depuis le DID doc. 'z' = base58btc, préfixe 0xe7 0x01 = secp256k1-pub
mk = "zQ3shvi9RBYFYwfzbkju871YNjv9EDAZ5Cy52PY5nNkQiH215" # clé #atproto du DID doc (plc.directory)
raw = base58.b58decode(mk[1:])
assert raw[0] == 0xe7 and raw[1] == 0x01
vk = ecdsa.VerifyingKey.from_string(raw[2:], curve=ecdsa.SECP256k1) # point compressé
# 5 : vérification ECDSA (signature compacte r||s de 64 octets)
try:
ok = vk.verify_digest(sig, digest)
except ecdsa.BadSignatureError:
ok = False
print("message signé :", len(msg), "octets | sha256 :", digest.hex()[:24], "...")
print(">>> SIGNATURE VALIDE :", ok)
message signé : 118 octets | sha256 : 1ad6ce66af92b2624236353f ...
>>> SIGNATURE VALIDE : True
On vient de prendre un fichier téléchargé depuis un serveur du réseau, d’en extraire le commit, et de prouver, avec uniquement la seule clé publique du compte (publiée dans un annuaire indépendant), que ce commit a bel et bien été signé par la clé privée de une-tasse-de.cafe.
Astuce
C’est ce qui distingue radicalement ATProto d’une API classique. Avec une API REST normale, quand api.exemple.com vous renvoie un post, vous devez croire que le serveur ne ment pas. Ici, la confiance est déplacée du serveur (qui peut être n’importe qui) vers la clé (qui est vérifiable par tous). Un serveur intermédiaire, un relais, un cache, un miroir : peu importe qui vous sert la donnée, vous pouvez toujours la valider.
Les blobs : des fichiers adressés par leur contenu
Les blobs, ce sont les fichiers binaires (images, vidéos…) que les records ne font que référencer. Listons ceux de mon dépôt et récupérons-en un, sans aucune authentification :
DID=did:plc:hqnyog7skad6m4aejb2yujxy
PDS=https://eurosky.social
CID=$(curl -s "$PDS/xrpc/com.atproto.sync.listBlobs?did=$DID&limit=1" | jq -r '.cids[0]')
echo $CID # bafkreia2chbwa44vquidtt6tpycv4dqhlvm3htbszke22h6lkot7t6dxkm
curl -s "$PDS/xrpc/com.atproto.sync.getBlob?did=$DID&cid=$CID" -o blob.bin
file blob.bin
blob.bin: JPEG image data, JFIF standard 1.01, baseline, precision 8, 1500x2000, components 3
Les CIDs des fichiers JPEG commencent tous par bafkrei (alors que les records des commits commençaient par bafyrei).
Mais le plus intéressant, comme on a vu plus haut, ce CID n’est pas un identifiant arbitraire mais le hash du contenu du fichier… ce qui veut dire qu’on peut le recalculer et vérifier qu’on n’a pas reçu un fichier trafiqué (et cette validation tient en quelques lignes de code).
import hashlib, base64
data = open("blob.bin", "rb").read()
digest = hashlib.sha256(data).digest()
cidv1 = bytes([0x01, 0x55, 0x12, len(digest)]) + digest # CIDv1 + raw(0x55) + sha256(0x12)
print("b" + base64.b32encode(cidv1).decode().lower().rstrip("="))
bafkreia2chbwa44vquidtt6tpycv4dqhlvm3htbszke22h6lkot7t6dxkm
Identique, au caractère près. C’est la même mécanique de CID et de multihash que celle sur laquelle repose IPFS, dont je parlais dans un précédent article : un fichier est joingnable via son empreinte.
Déployer son propre PDS
Tout ce qu’on a fait jusqu’ici, c’était de la lecture sur le PDS de quelqu’un d’autre, mais il y a beaucoup à savoir coté “dev/admin”. Le PDS de référence est distribué par Bluesky (bluesky-social/pds) sous forme d’une image Docker unique. La distribution officielle vise un serveur dédié avec Caddy pour le TLS mais vous me connaissez bien : on va évidemment le mettre sur Kubernetes avec Talos !
Un PDS a besoin de trois secrets. Deux sont de simples chaînes aléatoires, le troisième est une clé privée secp256k1 : Une clé de rotation qui sert à signer les opérations sur le DID PLC.
kubectl create namespace pds
kubectl -n pds create secret generic pds-secrets \
--from-literal=PDS_JWT_SECRET=$(openssl rand -hex 16) \
--from-literal=PDS_ADMIN_PASSWORD=$(openssl rand -hex 16) \
--from-literal=PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX=$(openssl ecparam \
-name secp256k1 -genkey -noout --outform DER | tail -c +8 | head -c 32 | xxd -p -c 256)
Avertissement
Cette clé de rotation PLC, c’est le vrai trésor de guerre. C’est elle qui autorise à modifier le document DID d’un compte (changer de PDS, changer de clé). La perdre, c’est perdre définitivement le contrôle des identités hébergées. Donc on a intêret à backup ça !
Le compose officiel tourne en network_mode: host avec un fichier pds.env. Sur Kubernetes, ça donne un Deployment + Service + Ingress. Le PDS s’appuie sur du SQLite et un séquenceur d’événements sur un volume ReadWriteOnce.
apiVersion: apps/v1
kind: Deployment
metadata: { name: pds, namespace: pds }
spec:
replicas: 1
strategy: { type: Recreate }
selector: { matchLabels: { app: pds } }
template:
metadata: { labels: { app: pds } }
spec:
securityContext: { fsGroup: 1000 } # volume writable par le user 'node'
containers:
- name: pds
image: ghcr.io/bluesky-social/pds:0.4
ports: [{ containerPort: 3000 }]
env:
- { name: PDS_HOSTNAME, value: "pds.mocha.thoughtless.eu" }
- { name: PDS_DATA_DIRECTORY, value: "/pds" }
- { name: PDS_BLOBSTORE_DISK_LOCATION, value: "/pds/blocks" }
- { name: PDS_DID_PLC_URL, value: "https://plc.directory" }
- { name: PDS_BSKY_APP_VIEW_URL, value: "https://api.bsky.app" }
- { name: PDS_BSKY_APP_VIEW_DID, value: "did:web:api.bsky.app" }
- { name: PDS_CRAWLERS, value: "https://bsky.network" } # le relais à prévenir de notre arrivée
- { name: PDS_SERVICE_HANDLE_DOMAINS, value: ".pds.mocha.thoughtless.eu" }
- { name: PDS_INVITE_REQUIRED, value: "true" }
# les 3 secrets sont injectés depuis le Secret pds-secrets via secretKeyRef
volumeMounts: [{ name: data, mountPath: /pds }]
readinessProbe: { httpGet: { path: /xrpc/_health, port: 3000 } }
volumes:
- name: data
persistentVolumeClaim: { claimName: pds-data }
Pour l’ingress, il faudra également déclarer un wildcard qui servira à nos utilisateurs/applications.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: pds
namespace: pds
annotations: { cert-manager.io/cluster-issuer: cloudflare }
spec:
ingressClassName: traefik
rules:
- host: pds.mocha.thoughtless.eu # le PDS
http: { paths: [{ path: /, pathType: Prefix,
backend: { service: { name: pds, port: { number: 3000 } } } }] }
- host: "*.pds.mocha.thoughtless.eu" # les handles hébergés
http: { paths: [{ path: /, pathType: Prefix,
backend: { service: { name: pds, port: { number: 3000 } } } }] }
tls:
- hosts: ["pds.mocha.thoughtless.eu", "*.pds.mocha.thoughtless.eu"]
secretName: pds-tls
Après ça, on aura notre PDS disponible pour nous !
curl -s https://pds.mocha.thoughtless.eu/xrpc/_health
# {"version":"0.4.5027"}
curl -s https://pds.mocha.thoughtless.eu/xrpc/com.atproto.server.describeServer
{
"did": "did:web:pds.mocha.thoughtless.eu",
"availableUserDomains": [".pds.mocha.thoughtless.eu"],
"inviteCodeRequired": true,
"blobUploadLimit": 52428800
}
Notez que le PDS lui-même a une identité : did:web:pds.mocha.thoughtless.eu.
Le serveur exige un code d’invitation. On le génère avec le mot de passe admin (qu’on a défini dans le manifest du Deployment), puis on crée un premier compte.
# 1. un code d'invitation
curl -s -u "admin:$PDS_ADMIN_PASSWORD" -X POST \
https://pds.mocha.thoughtless.eu/xrpc/com.atproto.server.createInviteCode \
-H "Content-Type: application/json" -d '{"useCount":1}'
# {"code":"pds-mocha-thoughtless-eu-dozhd-pmsfi"}
# 2. le compte
curl -s -X POST https://pds.mocha.thoughtless.eu/xrpc/com.atproto.server.createAccount \
-H "Content-Type: application/json" -d '{
"email":"pds-demo@thoughtless.eu",
"handle":"quentin.pds.mocha.thoughtless.eu",
"password":"...",
"inviteCode":"pds-mocha-thoughtless-eu-dozhd-pmsfi"}'
{
"handle": "quentin.pds.mocha.thoughtless.eu",
"did": "did:plc:zzk6wzyfgltonuuex2suxai4",
"accessJwt": "eyJ0eXAiOiJhdCtqd3Qi...",
"refreshJwt": "..."
}
En créant ce compte, mon PDS a généré un did:plc et a publié l’opération de création sur plc.directory, l’annuaire public. Mon compte de démo existe désormais sur le réseau fédéré, indépendamment de mon serveur. On récupère au passage un accessJwt : c’est le jeton qui va authentifier les écritures.
Écrire dans son propre dépôt
Maintenant, on peut librement écrire sur notre repo, ce qui va avoir un impact sur les applications ATProto. Créons par exemple un post BlueSky :
AUTH="Authorization: Bearer $ACCESS_JWT"
DID=did:plc:zzk6wzyfgltonuuex2suxai4
# un post
curl -s -X POST https://pds.mocha.thoughtless.eu/xrpc/com.atproto.repo.createRecord -H "$AUTH" -d '{
"repo":"'$DID'", "collection":"app.bsky.feed.post",
"record":{"$type":"app.bsky.feed.post","text":"Premier post depuis mon PDS !",
"createdAt":"2026-08-12T...Z","langs":["fr"]}}'
{
"uri": "at://did:plc:zzk6wzyfgltonuuex2suxai4/app.bsky.feed.post/3msutcanuj22n",
"cid": "bafyreibrisducmkzrqxsmj3ajyr2qsqdy46iq263zxadawsjkt2l3xzvvm",
"commit": { "rev": "3msutcao6bk2n" },
"validationStatus": "valid"
}
Chaque écriture renvoie l’AT-URI du record (avec mon DID), son CID, et surtout un nouveau commit. Un uploadBlob d’une image me rend un CID de blob, que je colle ensuite dans un app.bsky.actor.profile comme avatar. C’est des opérations qu’on aurait pu réaliser via l’UI de Bsky, mais on le fait en direct dans notre registre.
curl -s ".../xrpc/com.atproto.repo.describeRepo?repo=$DID"
# collections: ['app.bsky.actor.profile', 'app.bsky.feed.post']

Revérifier la signature de notre compte
Le moment de vérité. Je reprends exactement le script de vérification écrit plus haut pour une-tasse-de.cafe, mais pointé sur mon dépôt. La clé publique, je la lis sur plc.directory (pas sur mon PDS : c’est tout l’intérêt) :
curl -s "https://plc.directory/$DID" | jq -r '.verificationMethod[0].publicKeyMultibase'
# zQ3shWLfbzWjtbCmqaHwBhs37hcv5atdvt9eWmEMjnsXzb34R
curl -s ".../xrpc/com.atproto.sync.getRepo?did=$DID" -o mypds.car # 1.1 Kio
python3 verify_sig.py mypds.car zQ3shWLfbzWjtbCmqaHwBhs37hcv5atdvt9eWmEMjnsXzb34R
>>> SIGNATURE VALIDE : True
Et la contre-épreuve, avec la clé de une-tasse-de.cafe cette fois :
>>> SIGNATURE VALIDE : False
Mon PDS sur mon cluster, produit exactement le même genre de dépôt auto-certifié que le serveur officiel de Bluesky.
Constater qu’on est bien fédéré
Le but de l’ATProto est de créer des plateformes communautaires, donc on doit bien vérifier que Bsky a connaissance de notre existence. Le mien a été configuré avec PDS_CRAWLERS=https://bsky.network, donc à la création du compte il a prévenu le relais du réseau. Résultat, en interrogeant l’AppView publique de Bluesky, par DID : je vois bien mon compte de test.
curl -s "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=$DID"
{
"did": "did:plc:zzk6wzyfgltonuuex2suxai4",
"handle": "quentin.pds.mocha.thoughtless.eu",
"displayName": "Quentin (PDS auto-hébergé)",
"associated": {
"lists": 0,
"feedgens": 0,
"starterPacks": 0,
"labeler": false,
"activitySubscription": {
"allowSubscriptions": "followers"
}
},
"description": "Compte de démo pour un article de blog sur ATProto",
"followersCount": 0,
"followsCount": 0,
"postsCount": 1
}
Mon post, écrit sur mon serveur, est indexé par l’infrastructure de Bluesky. La boucle est bouclée : stockage chez moi, indexation et lecture chez eux.
En comptant les records par type directement dans le fichier CAR ou via pdsls, on trouve pas mal d’information sur ce que je fais (ici sur mon vrai compte une-tasse-de.cafe).
à ce jour :
- 5044 enregistrements répartis sur 34 collections :
- 2513 app.bsky.feed.like
- 1926 app.bsky.feed.post
- 250 app.bsky.graph.follow
- 41 id.sifa.profile.skill <- un CV structuré (SIFA)
- 4 sh.tangled.repo <- une forge Git (Tangled)
- 1 blog.pckt.publication <- une plateforme de blog
- 1 app.offprint.actor.profile <- une autre appli de publication
Sur mon seul compte, on voit déjà qu’il y a plusieurs applications qui stockent toutes des informations (Tangled, pckt, BlueSky, Sifa), mon PDS n’a pas son mot à dire et une application tierce peut sans-problème créer sa propre entrée dans mon repo.
Comme chaque enregistrement a une adresse universelle (son AT-URI), un record d’une appli peut en référencer un d’une autre appli. On l’a vu dans le post plus haut : mon second post embarquait un app.bsky.embed.record. Rien n’empêche de lier un post Bluesky à un ticket tangled, ou à un article de blog hébergé.
Alors histoire de voir comment ça marche réellement, créons notre propre application basée sur l’ATProto.
Écrire une app sur l’ATProto
Reste le plus intéressant : écrire une app se basant sur l’ATProto. Juste au-dessus, je disais que n’importe qui peut créer son propre schéma., alors j’ai créé un petit outil, Notary (dont le code vit d’ailleurs sur Tangled, donc dans un dépôt ATProto). L’idée : horodater l’empreinte d’un fichier dans son dépôt pour pouvoir stocker sa signature sur l’ATProto et que le contenu d’un fichier soit tamponné par moi.
Je m’inspirer de ce que fait GPG dans ce domaine : créer une signature unique d’un fichier pour valider de son authenticité.
Création du schéma
Créer un nouveau type de donnée sur ATProto, c’est comme écrire un CRD sur Kubernetes, on valide un format et on laisse l’API le consommer. Dans le cadre de l’ATProto, c’est un fichier JSON qui décrit la forme d’un record qu’on nomme un lexicon, identifié par un NSID (un nom de domaine à l’envers, sous un domaine qu’on contrôle). Le mien, sous a-cup-of.coffee :
{
"lexicon": 1,
"id": "coffee.a-cup-of.notary.stamp",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["hash", "createdAt"],
"properties": {
"hash": { "type": "string" },
"subject": { "type": "string" },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
Et c’est tout. Pas de migration de base, pas de schéma à faire approuver. Le PDS ne connaît pas coffee.a-cup-of.notary.stamp et s’en fiche : il stockera mes records tels quels (avec juste un validationStatus: unknown, ce qui est parfaitement normal pour un lexique tiers).
Écrire notre record
Un stamp dans Notary, c’est une écriture com.atproto.repo.createRecord authentifiée dans le dépôt de l’utilisateur. Le cœur tient en un appel :
curl -s -X POST "$PDS/xrpc/com.atproto.repo.createRecord" -H "$AUTH" -d '{
"repo": "'$DID'",
"collection": "coffee.a-cup-of.notary.stamp",
"record": {
"$type": "coffee.a-cup-of.notary.stamp",
"hash": "sha256:bbf8e70148535496...",
"subject": "contrat.txt",
"createdAt": "2026-08-15T08:04:39.581Z"
}
}'
Notary emballe ça dans une petite CLI en Go (avec indigo, la lib de référence de Bluesky). Je la lance contre le PDS qu’on a déployé plus haut :
$ notary stamp --subject notary2.txt notary2.txt
notarized
digest : sha256:bbf8e70148535496cbe9558b916930bf9532a53225a16c28db4ccbb0c612c1d5
subject: notary2.txt
uri : at://did:plc:zzk6.../coffee.a-cup-of.notary.stamp/3mt47lsowxk2n
rev : 3mt47lspdns2n
À cet instant, mon record est dans mon PDS, un nouveau commit signé a été produit, et l’écriture est partie sur le réseau. Son empreinte sera disponible à qui le demande.
Vérifier sans faire confiance au serveur
Vérifier un stamp, c’est refaire exactement ce qu’on a fait à la main avec notre python qui valide la signature, mais empaqueté :
$ notary verify notary2.txt at://did:plc:zzk6.../coffee.a-cup-of.notary.stamp/3mt47lsowxk2n
notarized by : did:plc:zzk6wzyfgltonuuex2suxai4 (quentin.pds.mocha.thoughtless.eu)
committed rev : 3mt47lspdns2n
stamped at : 2026-08-15T08:04:39.581Z
stamped hash : sha256:bbf8e70148535496cbe9558b916930bf9532a53225a16c28db4ccbb0c612c1d5
your content : sha256:bbf8e70148535496cbe9558b916930bf9532a53225a16c28db4ccbb0c612c1d5
signature valid, and stamp included in the signed repository.
MATCH: this content was notarized by did:plc:zzk6wzyfgltonuuex2suxai4
Sous le capot, on vérifie trois fois, aucune ne faisant confiance au PDS qui sert la donnée :
- La clé publique est lue sur plc.directory (l’annuaire), pas sur le PDS.
com.atproto.sync.getRecordrenvoie une preuve : le commit signé, les nœuds du MST jusqu’au record, et le record lui-même. La signature du commit est vérifiée avec la clé de l’étape 1.- Le record est atteint depuis la racine signée du MST (donc il est bien inclus dans le dépôt signé), puis son champ
hashest comparé à l’empreinte de mon fichier.
Et si quelqu’un a bidouillé le fichier entre-temps ? Ça se voit tout de suite :
$ notary verify notary2-edited.txt at://did:plc:zzk6.../coffee.a-cup-of.notary.stamp/3mt47lsowxk2n
signature valid, and stamp included in the signed repository.
error: MISMATCH: the stamp is authentic, but its hash does not match your content
C’est un cas d’usage très simple que permet l’ATProto, on aurait pu imaginer une application bien plus complexe.
Conclusion
J’espère qu’avec cet article, vous aurez un peu mieux compris le fonctionnement de BlueSky et de son écosystème entier. Construire un service communautaire avec cette base est un réel gain (et très d’actualité avec la souveraineté des données qu’est très recherchée).
C’est là où je pense que Bluesky ira plus loin que les autres alternatives (comme Mastodon) car propose un écosystème complet très ouvert. Le terrain est prêt pour faire évoluer l’ATmosphère et contribuer à son expansion.
La seule limite a mes yeux est la gouvernance du registre centrale plc.directory qui fédère les PDS mais BlueSky travaille dans la création d’une association Suisse qui pourrait prendre le projet en main.
Ça fait qu’une petite semaine que je me suis mis dans l’ATProto, n’hésitez pas à m’envoyer un DM (Bluesky) ou à laisser un commentaire si vous voyez des boulettes.
Bon kawa ☕
