char/plc-mirror

git clone https://git.t4t.associates/char/plc-mirror

Charlotte Somuse new plox opcodec formatc2f2166

main
20.5 KiB619 linesraw
1use std::net::SocketAddr;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Context, Result};
6use axum::{
7    Router,
8    extract::{Path as AxumPath, State},
9    http::{HeaderValue, StatusCode, header::CONTENT_TYPE},
10    response::{IntoResponse, Response},
11    routing::get,
12};
13use clap::Parser;
14use serde::{Deserialize, Serialize};
15
16mod db;
17
18// Vendored from plox/src/opcodec.rs; keep the storage format implementation in sync.
19#[allow(dead_code)]
20mod opcodec;
21
22#[derive(Parser)]
23struct Args {
24    #[arg(long, env = "PLC_MIRROR_DB", default_value = "../plox/data/plox.db")]
25    db: PathBuf,
26
27    #[arg(long, env = "PLC_MIRROR_LISTEN", default_value = "127.0.0.1:2486")]
28    listen: SocketAddr,
29
30    #[arg(
31        long,
32        env = "PLC_MIRROR_DB_POOL_SIZE",
33        default_value_t = 16,
34        value_parser = clap::value_parser!(u32).range(1..)
35    )]
36    db_pool_size: u32,
37}
38
39#[derive(Deserialize)]
40#[serde(tag = "type")]
41enum Operation {
42    #[serde(rename = "plc_operation")]
43    Regular(RegularOperation),
44    #[serde(rename = "create")]
45    Legacy(LegacyOperation),
46    #[serde(rename = "plc_tombstone")]
47    Tombstone,
48}
49
50#[derive(Deserialize)]
51#[serde(rename_all = "camelCase")]
52struct RegularOperation {
53    verification_methods: indexmap::IndexMap<String, String>,
54    also_known_as: Vec<String>,
55    services: indexmap::IndexMap<String, OperationService>,
56}
57
58#[derive(Deserialize)]
59struct OperationService {
60    #[serde(rename = "type")]
61    kind: String,
62    endpoint: String,
63}
64
65#[derive(Deserialize)]
66#[serde(rename_all = "camelCase")]
67struct LegacyOperation {
68    signing_key: String,
69    handle: String,
70    service: String,
71}
72
73#[derive(Serialize)]
74#[serde(rename_all = "camelCase")]
75struct DidDocument {
76    #[serde(rename = "@context")]
77    context: Vec<String>,
78    id: String,
79    also_known_as: Vec<String>,
80    verification_method: Vec<VerificationMethod>,
81    service: Vec<Service>,
82}
83
84#[derive(Serialize)]
85#[serde(rename_all = "camelCase")]
86struct VerificationMethod {
87    id: String,
88    #[serde(rename = "type")]
89    kind: &'static str,
90    controller: String,
91    public_key_multibase: String,
92}
93
94#[derive(Serialize)]
95#[serde(rename_all = "camelCase")]
96struct Service {
97    id: String,
98    #[serde(rename = "type")]
99    kind: String,
100    service_endpoint: String,
101}
102
103enum Resolution {
104    Document(DidDocument),
105    Tombstone,
106}
107
108fn resolution_from_operation(did: &str, operation: serde_json::Value) -> Result<Resolution> {
109    let operation: Operation =
110        serde_json::from_value(operation).context("parsing PLC operation")?;
111    // legacy create ops resolve through the same path as regular ops once
112    // normalized, mirroring plc.directory's normalizeOp
113    let (verification_methods, also_known_as, services) = match operation {
114        Operation::Regular(op) => (op.verification_methods, op.also_known_as, op.services),
115        Operation::Legacy(op) => (
116            [("atproto".to_string(), op.signing_key)]
117                .into_iter()
118                .collect(),
119            vec![ensure_atproto_prefix(&op.handle)],
120            [(
121                "atproto_pds".to_string(),
122                OperationService {
123                    kind: "AtprotoPersonalDataServer".into(),
124                    endpoint: ensure_http_prefix(&op.service),
125                },
126            )]
127            .into_iter()
128            .collect(),
129        ),
130        Operation::Tombstone => return Ok(Resolution::Tombstone),
131    };
132
133    let mut context = vec![
134        "https://www.w3.org/ns/did/v1".to_string(),
135        "https://w3id.org/security/multikey/v1".to_string(),
136    ];
137    let verification_method = verification_methods
138        .into_iter()
139        .map(|(name, key)| {
140            let multibase = key.strip_prefix("did:key:").unwrap_or(&key);
141            if let Some(suite) = suite_context(multibase)
142                && !context.iter().any(|existing| existing == suite)
143            {
144                context.push(suite.into());
145            }
146            VerificationMethod {
147                id: format!("{did}#{name}"),
148                kind: "Multikey",
149                controller: did.into(),
150                public_key_multibase: multibase.into(),
151            }
152        })
153        .collect();
154    let service = services
155        .into_iter()
156        .map(|(name, service)| Service {
157            id: format!("#{name}"),
158            kind: service.kind,
159            service_endpoint: service.endpoint,
160        })
161        .collect();
162
163    Ok(Resolution::Document(DidDocument {
164        context,
165        id: did.into(),
166        also_known_as,
167        verification_method,
168        service,
169    }))
170}
171
172/// Suite context for a verification key, mirroring plc.directory's
173/// formatKeyAndContext: the key type comes from the did:key multibase
174/// prefix (zDnae = P-256, zQ3sh = secp256k1); other types get none.
175fn suite_context(public_key_multibase: &str) -> Option<&'static str> {
176    if public_key_multibase.starts_with("zDnae") {
177        Some("https://w3id.org/security/suites/ecdsa-2019/v1")
178    } else if public_key_multibase.starts_with("zQ3sh") {
179        Some("https://w3id.org/security/suites/secp256k1-2019/v1")
180    } else {
181        None
182    }
183}
184
185fn ensure_http_prefix(s: &str) -> String {
186    if s.starts_with("http://") || s.starts_with("https://") {
187        s.to_string()
188    } else {
189        format!("https://{s}")
190    }
191}
192
193fn ensure_atproto_prefix(s: &str) -> String {
194    if s.starts_with("at://") {
195        return s.to_string();
196    }
197    let stripped = s
198        .strip_prefix("https://")
199        .or_else(|| s.strip_prefix("http://"))
200        .unwrap_or(s);
201    format!("at://{stripped}")
202}
203
204async fn get_did(State(db_pool): State<Arc<db::Db>>, AxumPath(did): AxumPath<String>) -> Response {
205    if !did.starts_with("did:plc:") {
206        return StatusCode::NOT_FOUND.into_response();
207    }
208
209    match db::resolve(&db_pool, &did).await {
210        Ok(Some(Resolution::Document(document))) => {
211            // plc.directory serves did documents as did+ld+json
212            let mut response = axum::Json(document).into_response();
213            response.headers_mut().insert(
214                CONTENT_TYPE,
215                HeaderValue::from_static("application/did+ld+json"),
216            );
217            response
218        }
219        // plc.directory answers tombstoned dids with 404 ("DID not available")
220        Ok(Some(Resolution::Tombstone) | None) => StatusCode::NOT_FOUND.into_response(),
221        Err(error) => {
222            tracing::error!(%error, "failed to resolve DID");
223            StatusCode::INTERNAL_SERVER_ERROR.into_response()
224        }
225    }
226}
227
228#[tokio::main]
229async fn main() -> Result<()> {
230    tracing_subscriber::fmt()
231        .with_env_filter(
232            tracing_subscriber::EnvFilter::try_from_default_env()
233                .unwrap_or_else(|_| "plc_mirror=info".into()),
234        )
235        .init();
236
237    let args = Args::parse();
238    tracing::info!(db = %args.db.display(), "opening plox database read-only");
239    let db_pool = db::open_database(&args.db, args.db_pool_size as usize)?;
240
241    let app = Router::new()
242        .route(
243            "/",
244            get(|| async {
245                "plc-mirror: a read-only mirror of plc.directory.\n\
246                 \n\
247                 GET /{did}  resolve a did:plc DID to its DID document\n"
248            }),
249        )
250        .route("/{did}", get(get_did))
251        .with_state(db_pool);
252    let listener = tokio::net::TcpListener::bind(args.listen).await?;
253    tracing::info!(listen = %args.listen, "serving PLC DID documents");
254    axum::serve(listener, app)
255        .with_graceful_shutdown(async {
256            let _ = tokio::signal::ctrl_c().await;
257        })
258        .await?;
259    Ok(())
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::db::open_database;
266    use rusqlite::{Connection, params};
267
268    const REGULAR_OPERATION: &str = r#"{
269        "type": "plc_operation",
270        "rotationKeys": ["did:key:zRotation"],
271        "verificationMethods": {
272            "atproto": "did:key:zQ3shSigning",
273            "backup": "did:key:zQ3shBackup"
274        },
275        "alsoKnownAs": ["at://alice.example"],
276        "services": {
277            "atproto_pds": {
278                "type": "AtprotoPersonalDataServer",
279                "endpoint": "https://pds.example"
280            }
281        },
282        "prev": null
283    }"#;
284
285    #[test]
286    fn converts_legacy_genesis() {
287        let Resolution::Document(document) = resolution_from_operation(
288            "did:plc:example",
289            serde_json::from_str(
290                r#"{
291                "type": "create",
292                "signingKey": "did:key:zDnaeSigning",
293                "recoveryKey": "did:key:zRecovery",
294                "handle": "alice.example",
295                "service": "pds.example"
296            }"#,
297            )
298            .unwrap(),
299        )
300        .unwrap() else {
301            panic!("legacy genesis should produce a document")
302        };
303
304        assert_eq!(document.also_known_as, ["at://alice.example"]);
305        assert_eq!(
306            document.context,
307            [
308                "https://www.w3.org/ns/did/v1",
309                "https://w3id.org/security/multikey/v1",
310                "https://w3id.org/security/suites/ecdsa-2019/v1"
311            ]
312        );
313        assert_eq!(
314            document.verification_method[0].id,
315            "did:plc:example#atproto"
316        );
317        assert_eq!(
318            document.verification_method[0].public_key_multibase,
319            "zDnaeSigning"
320        );
321        assert_eq!(document.service[0].id, "#atproto_pds");
322        assert_eq!(document.service[0].service_endpoint, "https://pds.example");
323    }
324
325    #[test]
326    fn serializes_regular_operation_as_did_document() {
327        let Resolution::Document(document) = resolution_from_operation(
328            "did:plc:example",
329            serde_json::from_str(REGULAR_OPERATION).unwrap(),
330        )
331        .unwrap() else {
332            panic!("regular operation should produce a document")
333        };
334
335        assert_eq!(
336            serde_json::to_value(document).unwrap(),
337            serde_json::json!({
338                "@context": [
339                    "https://www.w3.org/ns/did/v1",
340                    "https://w3id.org/security/multikey/v1",
341                    "https://w3id.org/security/suites/secp256k1-2019/v1"
342                ],
343                "id": "did:plc:example",
344                "alsoKnownAs": ["at://alice.example"],
345                "verificationMethod": [
346                    {
347                        "id": "did:plc:example#atproto",
348                        "type": "Multikey",
349                        "controller": "did:plc:example",
350                        "publicKeyMultibase": "zQ3shSigning"
351                    },
352                    {
353                        "id": "did:plc:example#backup",
354                        "type": "Multikey",
355                        "controller": "did:plc:example",
356                        "publicKeyMultibase": "zQ3shBackup"
357                    }
358                ],
359                "service": [{
360                    "id": "#atproto_pds",
361                    "type": "AtprotoPersonalDataServer",
362                    "serviceEndpoint": "https://pds.example"
363                }]
364            })
365        );
366    }
367
368    #[test]
369    fn recognizes_tombstones() {
370        assert!(matches!(
371            resolution_from_operation(
372                "did:plc:example",
373                serde_json::json!({"type":"plc_tombstone","prev":"previous","sig":"signature"})
374            ),
375            Ok(Resolution::Tombstone)
376        ));
377    }
378
379    fn database() -> (tempfile::NamedTempFile, Connection) {
380        let database = tempfile::NamedTempFile::new().unwrap();
381        let connection = Connection::open(database.path()).unwrap();
382        connection
383            .execute_batch(
384                "CREATE TABLE ops (
385                    seq INTEGER PRIMARY KEY CHECK (seq > 0),
386                    did BLOB NOT NULL,
387                    operation BLOB NOT NULL,
388                    created_at BLOB NOT NULL DEFAULT X'00'
389                ) STRICT;
390                CREATE INDEX ops_did ON ops(did);
391                CREATE TABLE strings (id INTEGER PRIMARY KEY, value TEXT NOT NULL UNIQUE) STRICT;
392                INSERT INTO strings VALUES (0, 'https://pds.example');
393                PRAGMA user_version = 1;",
394            )
395            .unwrap();
396        (database, connection)
397    }
398
399    #[tokio::test]
400    async fn serves_the_latest_database_operation() {
401        let (database, connection) = database();
402        connection
403            .execute(
404                "INSERT INTO ops (seq, did, operation) VALUES (?1, ?2, ?3)",
405                params![
406                    1,
407                    opcodec::encode_did("did:plc:ragtjsm2j2vknwkz3zp4oxrd"),
408                    opcodec::encode(&serde_json::json!({"type":"plc_tombstone"}), |_| None)
409                        .unwrap()
410                ],
411            )
412            .unwrap();
413        connection
414            .execute(
415                "INSERT INTO ops (seq, did, operation) VALUES (?1, ?2, ?3)",
416                params![
417                    2,
418                    opcodec::encode_did("did:plc:ragtjsm2j2vknwkz3zp4oxrd"),
419                    opcodec::encode(&serde_json::from_str(REGULAR_OPERATION).unwrap(), |s| (s
420                        == "https://pds.example")
421                        .then_some(0))
422                    .unwrap()
423                ],
424            )
425            .unwrap();
426
427        let pool = open_database(database.path(), 1).unwrap();
428        let response = get_did(
429            State(pool.clone()),
430            AxumPath("did:plc:ragtjsm2j2vknwkz3zp4oxrd".into()),
431        )
432        .await;
433        assert_eq!(response.status(), StatusCode::OK);
434        assert_eq!(
435            response.headers().get(CONTENT_TYPE).unwrap(),
436            "application/did+ld+json"
437        );
438        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
439            .await
440            .unwrap();
441        let document: serde_json::Value = serde_json::from_slice(&body).unwrap();
442        assert_eq!(
443            document["alsoKnownAs"],
444            serde_json::json!(["at://alice.example"])
445        );
446
447        assert_eq!(
448            get_did(State(pool.clone()), AxumPath("did:plc:missing".into()))
449                .await
450                .status(),
451            StatusCode::NOT_FOUND
452        );
453        assert_eq!(
454            get_did(State(pool), AxumPath("did:web:example.com".into()))
455                .await
456                .status(),
457            StatusCode::NOT_FOUND
458        );
459    }
460
461    #[tokio::test]
462    async fn reads_dictionary_additions_while_serving() {
463        let (database, mut connection) = database();
464        let pool = open_database(database.path(), 2).unwrap();
465        let did = "did:plc:ragtjsm2j2vknwkz3zp4oxrd";
466        for (id, endpoint) in [(0, "https://pds.example"), (1, "https://new-pds.example")] {
467            let tx = connection.transaction().unwrap();
468            tx.execute(
469                "INSERT OR IGNORE INTO strings VALUES (?1, ?2)",
470                params![id, endpoint],
471            )
472            .unwrap();
473            // Version 1 fixture: interned endpoint, handle, literal key, signature, Bluesky genesis.
474            let mut program = b"\x01\x00\x0a\x05alice\x00\x14did:key:zQ3shSigning\x03".to_vec();
475            program[1] = id as u8;
476            program.extend([0; 64]);
477            program.push(7);
478            tx.execute(
479                "INSERT INTO ops (seq, did, operation) VALUES (?1, ?2, ?3)",
480                params![id + 1, opcodec::encode_did(did), program],
481            )
482            .unwrap();
483            tx.commit().unwrap();
484
485            let response = get_did(State(pool.clone()), AxumPath(did.into())).await;
486            assert_eq!(response.status(), StatusCode::OK);
487            let body = axum::body::to_bytes(response.into_body(), usize::MAX)
488                .await
489                .unwrap();
490            let document: serde_json::Value = serde_json::from_slice(&body).unwrap();
491            assert_eq!(document["id"], did);
492            assert_eq!(
493                document["alsoKnownAs"],
494                serde_json::json!(["at://alice.bsky.social"])
495            );
496            assert_eq!(
497                document["verificationMethod"][0]["publicKeyMultibase"],
498                "zQ3shSigning"
499            );
500            assert_eq!(document["service"][0]["serviceEndpoint"], endpoint);
501        }
502    }
503
504    #[tokio::test]
505    async fn serves_compact_bluesky_create() {
506        let (database, connection) = database();
507        let did = "did:plc:ragtjsm2j2vknwkz3zp4oxrd";
508        // Fixed BSKY_CREATE fixture, independent of the encoder.
509        let mut program =
510            b"\x01\x00\x00\x11alice.bsky.social\x00\x14did:key:zQ3shSigning\x03".to_vec();
511        program.extend([0; 64]);
512        program.push(11);
513        connection
514            .execute(
515                "INSERT INTO ops (seq, did, operation) VALUES (1, ?1, ?2)",
516                params![opcodec::encode_did(did), program],
517            )
518            .unwrap();
519
520        let pool = open_database(database.path(), 1).unwrap();
521        let response = get_did(State(pool), AxumPath(did.into())).await;
522        assert_eq!(response.status(), StatusCode::OK);
523        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
524            .await
525            .unwrap();
526        let document: serde_json::Value = serde_json::from_slice(&body).unwrap();
527        assert_eq!(
528            document,
529            serde_json::json!({
530                "@context": [
531                    "https://www.w3.org/ns/did/v1",
532                    "https://w3id.org/security/multikey/v1",
533                    "https://w3id.org/security/suites/secp256k1-2019/v1"
534                ],
535                "id": did,
536                "alsoKnownAs": ["at://alice.bsky.social"],
537                "verificationMethod": [{
538                    "id": format!("{did}#atproto"),
539                    "type": "Multikey",
540                    "controller": did,
541                    "publicKeyMultibase": "zQ3shSigning"
542                }],
543                "service": [{
544                    "id": "#atproto_pds",
545                    "type": "AtprotoPersonalDataServer",
546                    "serviceEndpoint": "https://pds.example"
547                }]
548            })
549        );
550    }
551
552    #[tokio::test]
553    async fn serves_legacy_operations_tombstones_and_decode_errors() {
554        let (database, connection) = database();
555        let pool = open_database(database.path(), 1).unwrap();
556        let legacy = serde_json::json!({
557            "type": "create", "signingKey": "did:key:zDnaeSigning",
558            "handle": "alice.example", "service": "https://pds.example"
559        });
560        for (program, status) in [
561            (
562                opcodec::encode(&legacy, |s| (s == "https://pds.example").then_some(0)).unwrap(),
563                StatusCode::OK,
564            ),
565            (
566                opcodec::encode(&serde_json::json!({"type": "plc_tombstone"}), |_| None).unwrap(),
567                StatusCode::NOT_FOUND,
568            ),
569            (vec![255], StatusCode::INTERNAL_SERVER_ERROR),
570            (vec![1, 99], StatusCode::INTERNAL_SERVER_ERROR),
571        ] {
572            connection
573                .execute(
574                    "INSERT OR REPLACE INTO ops (seq, did, operation) VALUES (1, ?1, ?2)",
575                    params![b"\x00did:plc:example".as_slice(), program],
576                )
577                .unwrap();
578            assert_eq!(
579                get_did(State(pool.clone()), AxumPath("did:plc:example".into()))
580                    .await
581                    .status(),
582                status,
583            );
584        }
585    }
586
587    #[test]
588    fn rejects_unsupported_or_unfinished_databases() {
589        let (database, connection) = database();
590        for version in [0, 2] {
591            connection
592                .pragma_update(None, "user_version", version)
593                .unwrap();
594            let error = open_database(database.path(), 1).err().unwrap();
595            assert!(
596                error
597                    .to_string()
598                    .contains("unsupported plox database version")
599            );
600        }
601        connection
602            .execute_batch(
603                "PRAGMA user_version = 1;
604             CREATE TABLE compaction (singleton INTEGER PRIMARY KEY, complete INTEGER NOT NULL);
605             INSERT INTO compaction VALUES (1, 0);",
606            )
607            .unwrap();
608        let error = open_database(database.path(), 1).err().unwrap();
609        assert!(error.to_string().contains("compaction is unfinished"));
610        connection
611            .execute("UPDATE compaction SET complete = 1", [])
612            .unwrap();
613        assert!(open_database(database.path(), 1).is_ok());
614
615        let missing = database.path().with_extension("missing");
616        assert!(open_database(&missing, 1).is_err());
617        assert!(!missing.exists());
618    }
619}