diff --git a/FEDERATION.md b/FEDERATION.md index 1b587924..34733d6c 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -40,20 +40,23 @@ tootik implements [draft-cavage-http-signatures](https://datatracker.ietf.org/do * All other outgoing requests have `headers="(request-target) host date"` In addition, tootik partially implements [RFC9421](https://datatracker.ietf.org/doc/rfc9421/): -* It supports `rsa-v1_5-sha256` and `ed25519` signatures +* It supports `rsa-v1_5-sha256`, `ed25519` and [`ml-dsa-44`](https://c2sp.org/httpsig-pq@v1.0.0) signatures * If `alg` is specified, tootik validates the signature only if the key type matches `alg` * It obeys `expires` if specified, but also validates `created` using `MaxRequestAge` * Incoming `POST` requests must have at least `("@method" "@target-uri" "content-type" "content-digest")` * All other incoming requests must have at least `("@method" "@target-uri")` * If query is not empty, `@query` must be signed -tootik's actors have a traditional RSA key under `publicKey` and an Ed25519 key under `assertionMethod`, as described in [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md). +tootik's actors have a traditional RSA key under `publicKey` and two keys under `assertionMethod` (see [FEP-521a](https://codeberg.org/fediverse/fep/src/branch/main/fep/521a/fep-521a.md)): Ed25519 and ML-DSA-44. -By default, tootik uses `draft-cavage-http-signatures` when it signs outgoing requests. It starts using RFC9421 (with Ed25519, if possible) when talking to a particular server once these capabilities are 'discovered' in one of several ways: +By default, tootik uses `draft-cavage-http-signatures` when it signs outgoing requests. It starts using RFC9421 (with Ed25519 or ML-DSA-44, if possible) when talking to a particular server once these capabilities are 'discovered' in one of several ways: * When at least one actor on the server advertises support for these capabilities using [FEP-844e](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md); tootik assumes this information is true although it's perfectly possible for a server to be behind a reverse proxy that drops the `Signature-Input` header -* It remembers which servers responded with `200 OK` or `202 Accepted` to a `POST` request signed with RFC9421, with or without Ed25519 -* When it accepts a RFC9421-signed (with or without Ed25519) request from another server, it assumes this server also supports incoming requests signed like this -* It does **not** implement ['double-knocking'](https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions) to detect RFC9421 support, because it's uncommon and this mechanism is very likely to double the number of outgoing requests; instead, tootik randomly (see `RFC9421Threshold` and `Ed25519Threshold`) tries RFC9421 and Ed25519 in `POST` requests to servers that still haven't advertised or demonstrated support, to prevent deadlock if these servers are waiting too +* It remembers which servers responded with `200 OK` or `202 Accepted` to a `POST` request signed with RFC9421, Ed25519 or ML-DSA-44 +* When it accepts a RFC9421-signed (with or without Ed25519 or ML-DSA-44) request from another server, it assumes this server also supports incoming requests signed like this + +tootik does **not** implement ['double-knocking'](https://swicg.github.io/activitypub-http-signature/#how-to-upgrade-supported-versions) to detect RFC9421 support, because it's uncommon and this mechanism is very likely to double the number of outgoing requests. Instead, it breaks the deadlock from both ends: +* It occasionally (see `RFC9421Threshold`, `Ed25519Threshold` and `MLDSA44Threshold`) signs outgoing `POST` requests with RFC9421, Ed25519 or ML-DSA-44, to prevent deadlock if another server is waiting instead of advertising or demonstrating support +* It occasionally (see `CavageDraftFailureThreshold`) rejects incoming, `draft-cavage-http-signatures`-signed `POST` requests with `401 Unauthorized`, to encourage other servers to retry with RFC9421 ## Collections @@ -171,9 +174,11 @@ Support for data portability comes into play in 5 main areas: Since v0.21.0, tootik no longer offers choice between 'traditional' and portable actors: all newly registered users are portable actors. -A portable actor is created by generating or supplying a pre-generated, base58-encoded Ed25519 private key during registration. The key, like the user's `preferredUsername`, must be unique per tootik instance. +All portable actors have both Ed25519 and ML-DSA-44 keys. By default, tootik generates both, but it allows the user to supply a base58-encoded Ed25519 or base64url-encoded ML-DSA-44 private key during registration. This key determines the DID, while the other key is generated. Like the user's `preferredUsername`, this key must be unique per tootik instance. + +Note that use of ML-DSA-44 DIDs may hinder interoperability, as it produces `did:key:ukC...` DIDs (forbidden by [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) at the time of writing), [`mldsa44-jcs-2024`](https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024) integrity proofs and large objects other servers may reject. -No matter if the key was generated by tootik or provided by the user, the user can recover it through the settings page. +No matter what key was used to derive the DID, the user can recover it through the settings page. tootik does not support the [FEP-ae97](https://codeberg.org/fediverse/fep/src/branch/main/fep/ae97/fep-ae97.md) registration flow. @@ -204,21 +209,27 @@ The response points to a `https://` gateway that returns the actor object: "https://w3id.org/security/data-integrity/v1", "https://w3id.org/security/v1" ], - "id": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor", + "id": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor", "type": "Person", "preferredUsername": "alice", - "inbox": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor/inbox", - "outbox": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor/outbox", - "followers": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor/followers", + "inbox": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor/inbox", + "outbox": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor/outbox", + "followers": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor/followers", "gateways": [ "https://a.localdomain" ], "assertionMethod": [ { - "controller": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor", - "id": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor#ed25519-key", - "publicKeyMultibase": "z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8", - "type": "Multikey" + "id": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor#ed25519-key", + "type": "Multikey", + "controller": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor", + "publicKeyMultibase": "z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL" + }, + { + "id": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor#ml-dsa-44-key", + "type": "Multikey", + "controller": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor", + "publicKeyMultibase": "ukCTMgzJ6Q7ojrIZbQfdXuomL9roZk1gS21Jv068UEOZyxLmi6-c7EKOrvbo4GUumwTmeKXuYVJkrFP5EHbWTsadlvrAP62Ba0EoUUAGSfyz9v22CuN5oZXdTXJwqGgC_k1A4Z0hM0Pd_DU01nHJVT0X0MeoaecVBRa2NXzn3JQ00YlJbcZ1H8U15Ofhs93VRFc-j9Bi7WWYz_Bnrij1kq7-AhLRt9uMfpp30U-4O-n33P9385gpsp1EKBxGfJr--3-wqjbJ4dfXfIfS47eOFJXKHTR6s63YcP61-Dgp38_BRbVNxfVzzfCKLCr2MzoQR6MP-rAgPYgRzAbqfplsWDZFpjYpsnc0slXRd8rq6vSr7IJ1VVsUwpfNc6Bw378rlk5a51ksXlomZu8dEXi_ehvLiIZ3q4L5Vg42LUwX5fQPhCzyQa-teXJIkVn_XFSlg7a93plgS5LTjnvKLZMF3ALjbGQjQdWpq1WI2OMVgmgtNgpU8wuveqD48D0LTBoqkPENW8dgUcO61-1h6CE-ducX9jRlgw8LZuko2sObNMlroc2R86B1jD5y7DB4NtQKD1JlSwM1_mA3Ly0mIYX1NNj5KRzQC7r9bTkTbczaDuwShO_L15YoSmN5sayQP_jTXnSxG0VokSoeypAJdsPapUJCPa72U2t6ldDlHJi8bFtdoshDJXyU-X5V-ajjqs199MClvdvuwMVFeF4G8j4YaR-P4RCoD00Q3BMFWLkQf1nKxRhUIW1l5RU7aVEr8oaD1VwvFf_H7s7yvO-l5KGDGjswsaagjCmtN9xfI7Jqq5-hPDR1rTPtDGThZmu8vlCdGGNXT3_mvtaqkYeFM2vgE9GE8-N72PSMDpwYl70MAfg_GhbkbaftM0AZy6e0gNi6hXp6kv3y-8lz1AuZvVWvhXsQ5Hqo6O4zUA_Clecf4ZPrF8H8a1gRvIDurlaql1ieR0kjc_NFR3JKbuxFHpyh21Bd9ZJ33UsYjxY7A8pqNTSq5TBNXqotq3V4260lT0MQIBkYxFVPgq1pJ3xTZ2aUPVUH8XaXBYrfNeAmb16ae2FYn38P1Fx_tjIT258F5wIlNzwq6J4ZEkUmKKfPj1r2PF5WmoW-dvHB6uF7-VjK3q2h3GqzFEvKRiG8JMRtrIvwM-ZJfdsPFoq4PES0FrdWFWGeA_9N0bATEdkJxRcpPpwQAjJBAx8q16M4dOEM0RVTnmV8f78Fm9zkTGyD7AtPCItRKHbr9zh4uFy3H9UkTqvLj0Kwdzk7d7TLyFkKuhuC06Sk-1iqHnGYoNF_JNJiF34RBc1hMl5VzKUj_qYeGvFoR-4ubX3iDWM_cUU0YYlE3PqNXQCVcs8gsbWyPqphEGMu0T7qeIZVTSQFGhMZ-k6iOG4-877zenaXiYYZ8ZHoWGhHyDY4gREcBXa35RldWmXMwJXEUmlQpRZ7phxmKn7rFhIxbr7_YCw2sJwKUFpMwHsaSAL-R54KMrggbB3KjXed-6iSUZJ7VHImG2eJTGjYcDKWmMK_03jbr6JRmuZm0gBgnHCg7XBeDDUapqt6xYWAqr0YU_qgOcBb_JeykUea4rCrywjoLY-DD4KUFNxL51G8yBOZNihnIO3nN8OZz0r4JEwZd36jeefLnwYXPuioL2lr1dcwgK9-zca7T247WlVJjEnJKyW71lo9V7CAW0kzV2ESYK-tI9KzMHFwIb9fIpyXZv6RyvkadkkPHDc7Wp0g1oeuYl70vD8UXrDDOuQX0" } ], "proof": { @@ -227,35 +238,103 @@ The response points to a `https://` gateway that returns the actor object: "https://w3id.org/security/data-integrity/v1", "https://w3id.org/security/v1" ], - "created": "2025-10-04T14:19:20Z", + "created": "2026-08-15T06:51:20Z", + "type": "DataIntegrityProof", "cryptosuite": "eddsa-jcs-2022", + "verificationMethod": "did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL#z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL", "proofPurpose": "assertionMethod", - "proofValue": "z3ijraF3GjA6Rb7aY3q75KAnwm5ZEdVGsn64dRaYtkNxPhne88EcrzgtP1C1pE6CfKCtCpy338hvcttBmiudGUsbi", + "proofValue": "z5mwa8x2YcS6nGcBzjLPFKt4WcrbeBTyS29dooo891ommoVu4kyMCBAqceAH3PVKhUff2bgxsLCAgnKxAZGpGLUAn" + }, + "publicKey": { + "id": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor#main-key", + "owner": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwgUdOpi0cPPCC7XYHQ+a\ngSEmMDdLArtp/tgEdNfk5gu742UWdI8+9uQqGEikjOlG+w7TSJl8ta8Foa6lQA9U\nf3O1gqHFev/jtZbArhdRSjWQuVv1nhY/WWiBP9mqIqWj4LLNoVAHXOeLZgpXDMCo\n87zbremTa+3CRCe/jGvNIzE+rC30ZerJG2o6Id2aogy/hIKP77AijZuor3/YVflR\nneXOtMloJbkY4rxKEOJZuB4iMuaExwFFReMsAif2lc8uG7j4S2kLuuqh9om7uHEW\nkhE0/6JXSXhfsqJtxVBQdaf94OdvkccHGiMUvSMPskIr0eIWhe3ioHydLlSZmV4Q\n0wIDAQAB\n-----END PUBLIC KEY-----\n" + }, + "icon": [ + { + "type": "Image", + "mediaType": "image/gif", + "url": "https://a.localdomain/.well-known/apgateway/did:key:z6Mkm8WZrNcWpbqjJWZC3zs18P4f8cWyqaEoBmhiv5wvMUFL/actor/icon.gif" + } + ], + "manuallyApprovesFollowers": false +} +``` + +Portable actors with DIDs derived from their ML-DSA-44 are similar but use bigger, base64url-encoded public keys: + +``` +{ + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/security/v1" + ], + "id": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor", + "type": "Person", + "preferredUsername": "alice", + "inbox": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor/inbox", + "outbox": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor/outbox", + "followers": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor/followers", + "gateways": [ + "https://a.localdomain" + ], + "assertionMethod": [ + { + "id": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor#ed25519-key", + "type": "Multikey", + "controller": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor", + "publicKeyMultibase": "z6MknwDu1csDsEsxM3cGyFtrpz9tiXdmJn7xxhvZqUQD81J5" + }, + { + "id": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor#ml-dsa-44-key", + "type": "Multikey", + "controller": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor", + "publicKeyMultibase": "ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH" + } + ], + "proof": { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/data-integrity/v1", + "https://w3id.org/security/v1" + ], "type": "DataIntegrityProof", - "verificationMethod": "did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8#z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8" + "cryptosuite": "mldsa44-jcs-2024", + "verificationMethod": "did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH#ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH", + "proofPurpose": "assertionMethod", + "proofValue": "uo_P_HJ2SgBoe9Al46O5_MGtnexWKUNHoTkk-sAVkAVqTSVjCPWQZCN6bvA-xwMG2f0e0yDrBqI-W7pdY98PW-G1fUymJ0fuHv-NVa7LHlkqlw6rk6U1yVFwrpno68kGc3230_nvcVpkJ85i_PjfBFm8XB2biUp9qzqcNPAY9MIohFaeluBd7MMpVocj61aL4QSMuA8TbYRdckVSAIaSpjYK5kq3yfWJvwFd-l6dT6Z3f8ChQPBW5E-MgNrzKbhz2eNpEmN8AcelikQINj5YZAQvrS2ZU1laOeWOkp7t4GsJooDGjGBqUnk9Yjt0zRU9VGkiaCi6LTJqko99jsA6WgIWnMK2CzD37nOkm5PWsC2LczyozYZAnhxWuG882VXLiJrkgefVLbWMtBlt-yOjen7QgdwTDVV2bTjYP0oFQYeN91PxkDxcKTKyrcM1jk-t1tPTfv69t7oQOgeFWqncJ9j3IH60MnQWUPPfszNArHcV_Nb8DNMEk-6-9w2w3iZAGR4zhjqyRLT4gzRyodBW7zqBOuucXbmGIdJsolrfn3JxAhaX5PWDd-TlB8nyGsyAbRGRXMWUpR7z3Wgfl-pv5lw7VC6GWDD9yWeZtyWF0-EIydz9nThsG7l_twpNwbS3PfVnKYRVxrDCeLfXCCv3yUx51ViCrDjryTxFCfJz-lVdW-te-_XlbZlWDrOgI2Zen0V9DnidD5wPtKxF3Pbx33NwrwIeFgn4hXwyojIgOn-rDE3J27rOc7Nm00Cj-fBOddLsh4n2qC_7NwTiHRwzHRBhpFQb3RFu9IN91ma2PsOqgYApfgm27tqRth3eqGauySsQkk7Z-gUpKT9g7-XXMgMFA1LQgoUUDV1ramWRUviUayAhWcwrd9TDH8Tl2q2cr94uVHM74CnzQ0aN4_PDqf5ipoR-6bZh85MLwgWxkyGTA3ZJ4dj4pD_MIRpFCSInPByj-kjDBpbxqh8YHoAoRUIi9HA4GTTQzRzrTzCyvjXYuIoWlE1gyAb7LqP5fH58Xik9tCLMx-KMeQQrhLTsafQV0YP5WQ69ER4kPD_Z3YXb6PFsQrRQ3RbebWM6v8L1nGsTJDq7FtG1QfKRLcbJSa7aAcTVbsw8D7JapaaRBnogwiny-3pGH4TT4eGI9oSJv3eLJjH7kDTKL66l4USm0NbauCRbJgv8LOeLOjI6FHhONFYHNrdFwaH_AiGv5AtCcG_hPUC2XlceIO4VvgslqNpOBwo4MCTqPhZker1YOr37jqZLsU4KzBpraK4rrgePi6QYi5q2iUhvyTVO1L0Zdh01j55k65DK6zYZzSIOfJ1YNWqx_WyjrE8HchuTYPP1rbCTqCyqlO5c4-umKC2gMlDh20F1_8BL-vOR7v1j4y6_ZpQR55ISVAyOP_90OFagEfXTkF1qJ32I6V2xuGNNHWmqxg39Dlg3UHUoV3BMxT9mG7KMEP6Ba66oRBzfwyoXLmwwenPkkjhgpmhrQLf0-csildhNnY0VxbJXt5kpQro6lcrsGDvTXZfdIolJFJbPU49UHK2aypAgc-wDDNz5yNJbvT4QStIVUguxc2XISQ7QAW3fr7BckEjqJWglcTnJahKcZ6R2mhAWxYfaMEjhBw0uhcfr7GtpaYizqyXCWGbemKeCkzPeMU3_q_jcrOSQfkU2TUIwsON_JU0xhkqZg55m1Ipqgx1KtiM3lmnOEY_XSX6UxB5-LYL2aGvr_MY-3F9GBG781mufjTQPha9TyBQQly_05PITS__ELeXFEsB9HexTpSVkzTPOuRDz_cYR6N56tadrVXQYMQ5212BQ5UIDXkpHOOX1XERp1Oj6ySIejN0MuqPd8JnIuFBST5AOzbbN28ZUQwV0QHHcHCC55gaXrzPay7QiAkG_3aHQa17TuBPU2twodJIoBW3wyhbRQk0wdQi_oD58P1iE-r3bouHC5PCRk14tGwBHCffEfCumJqsoaFUiB0BEY5cycYDVUHGGsuYfcIHl16XunAJBVYFWMqvuu6qe9q90xwACj7GJGBXfluKquLrlPgNDT_hm-FU8MaYaKJIFxX8Mu1zeySoVOC0E8dJqCxSuB3xCtyjuuxnegsF7FvxWkq_bi-eLc4n5F4DDz7SvEXd8jVCaBesOV8oXYsYjnuRnx6sHYdG5IrjyEPd1ylsAlkkN6ry9SL6W_K0tSlv8ytMvaLEJakTZJFdGhpzsR3_m2rdhWPUpLGsFus3UR3Dp9MMI3FdJj2rxrYSYagFLjFZIfQHA4Ts33-jVF55bKvHAvC-syMTT7WNAA0L8oLA56j57EvAB4utaZ-RlbjlM6MEA_ATEvyBVk6slyUNWzIA_4Ig26GPeV1j2cdXJ4WUoz-JArjmCwkNOoitm4ahhysF0MOEJM1JKu5gxo9WQlOIKnz38llikcje2XiLH0bJsmI-enfUxPPTatkaksK-V0wTSA4kdPBmKOnx_LDORQaqFQ26UIHA-fD56sWomSJyUHNSoWTow_uTL913qyL_87bbLhPq5Yo9iwkoSkf0NVUXiEeVteIdyLsIcuxAeQBA8ezcW9ZjPrGMYTNjqXQO0CXYJmyR0umbyTYl3IgL70B3jd8eJxSoGZaJsx4lCT6m1FJrLXQ5lVinkYD2trrS0op_RVdjTGe9fbuzdR_O9LNj68hG5O5Nfy05Jcf9btsUfonEbaniqGDMytC9SKsLSFzJazEeoEatfKpewTt7Hj9wHiRbp3dm2FZbjAwGfw8wSrkMf6uH1KpJJjbckUV48uhodIVqm71F9g6X4W5dQ3ymSdjNCe8-1d4EAFxGsj7YmJOoYob5ohLhDXljos0xz-a7oDANEE3AofdDvMAcik5ZAe_kzuUtVy6vc9NYNImQCI1sCM6UERxGiPiOmLnOhndlE704x3an7qjn-oSY0ihIq4UZKjaFDvX9E5ksQCpt1vnN2_1Jhlx7am5RBbieeFXCQ7nTDFCeT9srY36Lnf5lC_OY16hYgAC0Hg2lWmjCQ6dYNpskaVmH_MqYON7HtZJfwS7GBlu7PHLPsPSMQT5DyjAbNdS-nYLBCGp-I46WS4fXpmgAf0iE0ZOFCyY7r9TOJ_xwQX7NSux6zj4br_p4zXFTijefgTIDg5P0ZNUp-rusXH2wEMEiouQF5wdHuLlZ-jpKisu77Cw-gJFCUsOFRzeIaHi46YpKnY4QEOHiApOT5CRkhTW2WAgbi-xMXJ1t3j5vH9AA4kNU8", + "created": "2026-08-15T06:53:38Z" }, "publicKey": { - "id": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor#main-key", - "owner": "https://a.localdomain/.well-known/apgateway/did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor", - "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs28wI+POzhRamoFp5IaK\n2zWeFtOsQYDiSuE3t9uQeqntRTNHRY4KUUYiQlvuZTxhxjLN15GpcDiRgnaR0I54\nTaPbTUHGSQkjnjvKJaZn1PltRXBlsgvibUtQHlNvGa33UMjKdFa8+IJNYJBXeoxi\nf+HKcBAMGd5hfz7xjLIaRv5usm+MHDa9tF+YDojczy2JRrxlTHq8UCrSnwp+8GxX\nleox3//Qa5S3ZMdGxk16FxJYUjd3cdC/bIkXo8UwTUm4HKi5rzoszQjnt9monYcg\nx0ldHWoYkdF386MR07irm3wvEnf9FAcolA8oMtWOWkRhyTinZWRKBuXbfpqvitdl\ndQIDAQAB\n-----END PUBLIC KEY-----\n" + "id": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor#main-key", + "owner": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApYMmhHSCZFu4CiyNNoi0\nwC2GvSJjyPL4fwNlf28vbSo3yH/oM+bHRNJ44/purDzXzGb56dYljgrmbZ73UHJJ\nM+g8+2rczs2vRb/UJzzal4i1fryxAnHqHkx4cFxrVr4m/rCyq3yvebjWpMbQzBCX\n4fxu4MmPI/HqJdet1B7zR54TzCNVAJIlGT5fqFEWMcmIZNmSNa++ZQJqvr8100TC\nyh31pU/iX1Txjezh6QIHqz8NRkwFIc2PfpwOKXRloORDmZfrqQg8KgWsqstHxOAH\nbftdTZXQfHMoc9LoVp7Ll68mgvV/3xs2mjHWPPsb2HlygJRea9jp1EJINiY7oer9\newIDAQAB\n-----END PUBLIC KEY-----\n" }, + "icon": [ + { + "type": "Image", + "mediaType": "image/gif", + "url": "https://a.localdomain/.well-known/apgateway/did:key:ukCQOcMPmypzMNCErwCrTCBhrM-Tw1mBRiynxdMEL-M6aZc5_jLrRuQtEnoRNdMPkTiLUx9kfOKHRsXffLaDvtTcajS2lLvKQmUaHM7RidMg3hAD0joyE63xALsysqQ9IMQBeZ2bCjdwQDm1FQGqssEo0KDmXvvMvIY8AkfDd4POkYbymACsu-4nx44E11pNVQoq620mWocB2U7s4ZWw5__DZST8K9PhmJBTkaVS1hnwKGkArxLtdVQHN-iEdbIya8LkqjrSE_4ogoimlyay_DNPLP--i2wke52yisa3iH7D082wZc7JTzupo_coBJ5s0RpbxslUZAtj1cUu2-KqW4lDnwVFbXElyJVWFvR_nRdCI8VC2rxvh95xelh3yy03BPbnqukUUBLeJ13jr76YhTOHbQx00CXiLZhYOtQuDoZLE7sDtd3C6KM0ajbfp5OrITVbGardPNtnFL2ABmVPVOopkE59Fcb7b6cslBUKD70wnC0XKnO5RidQx0ZNcs6gTuyxfs20VHvUrFSkIMoSU3aSuUcaLFEk2INB-hTswoT6DfoXmcFDbg9scv-kjzsmHLw8yUbQfHS5y1_7IWA0LI7oblfUAyoZSuuPi9zgHczm-TU68QzKAUCv2kf52wIFTGhJyc2Bw8raMuMB2VjHP5FtdPz-wJHNcCYV2LyJr-UoEfUPPSTdgRdMFCUQukZf4KeAqKE4qnZLes5CADd_ziYoFa-oJJwZLsN2Vp5lldvuHMk-39481QsGV6dFbJ8KdFeYHoUJ8kbS_Wy4I4boHvr7RJAZpQC3ibxk48B7vobsyL6RrZEAhMu8hEW1XFOLjb9Ye_UO3fNQDGNTudvtRu7GUl1gG6CCff0jobPrPomJxIBw9P_VJj75S9AEzIKwHNRzrXOe-TkAPp7qMQ4mwCyAhrqFFtNUi24NtclxoLWZOkSXkRO-vsxYMBVQY1VTFR5sPf1-Zno8LUNZjVf4v9Y2Ow3FrEHu2l9WfNQhA1-eGjqWTiOJSLoH_jafGYdCx6mT8GC3PpwWGz1TNwjnMVt52tAWnHsKT8KcH0dVY7ggEBCXx1rI_k_JOSlIGV55HdYg29aAyyfzNLC0oXhRsSq2Uf3xJIZtZXTGT6cACZ9ailW1UoO2jazBwVW57-W2-SKwg9Mih12Td0d4nw1Jyzqn713atCtB-UeCxzOKoRZuS6hQPwoJpoXt014Fs43_vWbKxz8ZWP0z4z4WPWLhosSo-rnZtS7v_iYCgPUqlSQSzShDb0ca8qKQMmvw52xN3hyUgCuduyE_VZ-0gj7Her-NuIA8v8WRO5zu6mhPTPZaivazH3RThyv-M1UveieXPJz3Qa85Qk-lAQ775EyNi08pjGJH4IUdN9ON_LRmQgoeIYzdR9FuFy16-qSfVyIoTffBdnE2Fcs7nuMaNwr6Ep0qjePPlXfx-fui51JkibpRzqvg5ojOecXvsu0LgIRElcr4ueG4Ed7prcVF8Lbx7V-PVTEo4rLhZCm8rXh2TUPKGoH7hNjxXS-OBgqEpRIX0DyMtODJ9_snmlfwuNrlMEACypGtgrsjOweB__idc0l4XS_Z3fRhEPA90s_culmz8-Q29h8A0PHFZFctffactceFh99Tf_Eale8All3YyzjXw6zXXFPhGKwlqXp5VeLyAho1PW-uxOOInKyrHgsHYUDQA_qjSioCVj5G4bTdV5YzV3yPnjAI2STbJ4-wfknbE16sBGVgIU_vGmOAyiwsRmPVH/actor/icon.gif" + } + ], "manuallyApprovesFollowers": false } ``` -Portable actors have both Ed25519 and RSA keys, allowing them to interact with actors on ActivityPub servers that don't support Ed25519 signatures. +All portable actors have RSA, Ed25519 and ML-DSA-44 keys, allowing them to interact with actors on a wide range of ActivityPub servers. -In addition, portable actors carry an [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof, allowing other servers to securely determine which servers were "approved" by the owner of `ap://did:key:z6MksgCbQa3BZxBayRRkF1hcP7zt6TZGvZF2rR1k3AY7zFL8/actor`. +In addition, portable actors carry an [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof, allowing other servers to securely determine which servers were "approved" by the DID owner. Moreover, all objects and activities owned by a portable actor contain an integrity proof, allowing other servers to validate their authenticity and processes them without having to fetch them from their origin first. ## Delivery -When tootik receives a `POST` request to `inbox` from a portable actor, it requires a valid [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof generated using the actor's Ed25519 key and ability to fetch the actor, if not cached. +When tootik receives a `POST` request to `inbox` from a portable actor, it requires a valid [FEP-8b32](https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md) integrity proof generated using the private key that matches the DID, and ability to fetch the actor, if not cached. -tootik validates the integrity proof using the Ed25519 public key extracted from the key ID, and doesn't need to fetch the actor first. +tootik validates the integrity proof using the public key extracted from the key ID, and doesn't need to fetch the actor first. -tootik's `inbox` doesn't validate HTTP signatures and simply ignores them when the sender is a portable actor. Other servers might do the same, therefore automatic detection of RFC9421 and Ed25519 support on other servers ignores `200 OK` or `202 Accepted` responses from `/.well-known/apgateway`. +tootik's `inbox` doesn't validate HTTP signatures and simply ignores them when the sender is a portable actor. Other servers might do the same, therefore automatic detection of RFC9421 and Ed25519 or ML-DSA-44 support on other servers ignores `200 OK` or `202 Accepted` responses from `/.well-known/apgateway`. tootik forwards posts by actors that share the same DID with a local actor, and replies in threads started by such actors. @@ -276,4 +355,4 @@ When tootik forwards activities, it assumes that other servers use the same URL * tootik does not support `ap://` identifiers and location hints. * tootik assumes that activity and object IDs don't change: for example, it assumes that `Update` activities for portable posts preserve the `id` field of the original object. This matches the expectation of servers that don't support data portability and simplifies the implementation. * tootik provides limited support for fetching of objects (like posts) and activities from `/.well-known/apgateway`: replication of data across all actors with the same canonical ID is primarily achieved using forwarding. -* The RSA key under `publicKey` is generated during registration, so different actors owned by the same DID will use different RSA keys when they talk to servers that don't support Ed25519 signatures. Therefore, servers that cache only one RSA key for two actors with the same canonical ID (which shouldn't exist) might fail to validate some signatures. +* The RSA key under `publicKey` is generated during registration, so different actors owned by the same DID will use different RSA keys when they talk to servers that don't support Ed25519 and ML-DSA-44 signatures. Therefore, servers that cache only one RSA key for two actors with the same canonical ID (which shouldn't exist) might fail to validate some signatures. diff --git a/README.md b/README.md index 40f580f4..5fd22444 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ This makes tootik lightweight, private and accessible: * With support for manual approval of follow requests * With support for [Mastodon's follower synchronization mechanism](https://docs.joinmastodon.org/spec/activitypub/#follower-synchronization-mechanism), aka [FEP-8fcf](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) * [FEP-ef61](https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md) portable accounts - * Accounts on different servers use one Ed25519 keypair + * Accounts on different servers use one Ed25519 or ML-DSA-44 keypair * User activity is replicated across all servers * Multi-choice polls * [Lemmy](https://join-lemmy.org/)-style communities diff --git a/ap/capability.go b/ap/capability.go index 7f1bbb3a..a54f03c4 100644 --- a/ap/capability.go +++ b/ap/capability.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -28,4 +28,7 @@ const ( // RFC9421Ed25519Signatures is support for RFC9421 HTTP signatures, with Ed25119 keys. RFC9421Ed25519Signatures + + // RFC9421MLDSA44Signatures is support for RFC9421 HTTP signatures, with ML-DSA-44 keys. + RFC9421MLDSA44Signatures ) diff --git a/ap/id.go b/ap/id.go index ff4dd58b..246cf28e 100644 --- a/ap/id.go +++ b/ap/id.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -22,15 +22,28 @@ import ( "regexp" ) +const ( + ed25519PubBase58 = `z6Mk[a-km-zA-HJ-NP-Z1-9]{44}` + ed25519PubBase64 = `u7Q[A-Za-z0-9_-]{44}` + + mldsa44PubBase58 = `z4sd[a-km-zA-HJ-NP-Z1-9]{1000}[a-km-zA-HJ-NP-Z1-9]{792}` + + // MLDSA44PubBase64 matches a base64url-encoded ML-DSA-44 public key. + MLDSA44PubBase64 = `ukC[A-Za-z0-9_-]{1000}[A-Za-z0-9_-]{750}` + + // PortableActorPubPattern matches public keys in portable actor did:key DIDs. + PortableActorPubPattern = ed25519PubBase58 + `|` + MLDSA44PubBase64 +) + var ( - // KeyRegex matches a Multibase-encoded Ed25519 public key. - KeyRegex = regexp.MustCompile(`\b(z6Mk[a-km-zA-HJ-NP-Z1-9]+|u7Q[A-Za-z0-9_-]+)\b`) + // KeyRegex matches any Multibase-encoded public key. + KeyRegex = regexp.MustCompile(`\b(` + PortableActorPubPattern + `|` + ed25519PubBase64 + `|` + mldsa44PubBase58 + `)(?:[\/#?]|$)`) // apURLRegex matches an ap:// URL. - apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+)((?:[\/#?].*){0,1})`) + apURLRegex = regexp.MustCompile(`^ap:\/\/did:key:(` + PortableActorPubPattern + `)([\/#?].*|$)`) // GatewayURLRegex matches an https:// gateway URL. - GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(z6Mk[a-km-zA-HJ-NP-Z1-9]+)((?:[\/#?].*){0,1})`) + GatewayURLRegex = regexp.MustCompile(`^https:\/\/[a-z0-9-]+(?:\.[a-z0-9-]+)+\/\.well-known\/apgateway\/did:key:(` + PortableActorPubPattern + `)([\/#?].*|$)`) ) // IsPortable determines whether or not an ActivityPub ID is portable. diff --git a/ap/resolver.go b/ap/resolver.go index a1e07395..098753b0 100644 --- a/ap/resolver.go +++ b/ap/resolver.go @@ -1,5 +1,5 @@ /* -Copyright 2024 - 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -38,7 +38,7 @@ const ( // Resolver retrieves [Actor], [Object] and [Activity] objects. type Resolver interface { - ResolveID(ctx context.Context, keys [2]httpsig.Key, id string, flags ResolverFlag) (*Actor, error) - Resolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ResolverFlag) (*Actor, error) - Get(ctx context.Context, keys [2]httpsig.Key, url string) (*http.Response, error) + ResolveID(ctx context.Context, keys [3]httpsig.Key, id string, flags ResolverFlag) (*Actor, error) + Resolve(ctx context.Context, keys [3]httpsig.Key, host, name string, flags ResolverFlag) (*Actor, error) + Get(ctx context.Context, keys [3]httpsig.Key, url string) (*http.Response, error) } diff --git a/ap/slug.go b/ap/slug.go new file mode 100644 index 00000000..cd608472 --- /dev/null +++ b/ap/slug.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ap + +import ( + "crypto/sha256" + "encoding/base64" + "strings" + + "github.com/dimkr/tootik/danger" +) + +// Slug shortens an ActivityPub ID. +func Slug(id string) string { + sum := sha256.Sum256(danger.Bytes(strings.TrimPrefix(id, "https://"))) + return base64.RawURLEncoding.EncodeToString(sum[:12]) +} diff --git a/cfg/cfg.go b/cfg/cfg.go index 7c09a3ca..97ce0203 100644 --- a/cfg/cfg.go +++ b/cfg/cfg.go @@ -132,8 +132,11 @@ type Config struct { FillNodeInfoUsage bool + CavageDraftFailureThreshold float32 + RFC9421Threshold float32 Ed25519Threshold float32 + MLDSA44Threshold float32 DisableIntegrityProofs bool MaxGateways int @@ -446,6 +449,10 @@ func (c *Config) FillDefaults() { c.HistoryTTL = time.Hour * 24 * 30 } + if c.CavageDraftFailureThreshold <= 0 || c.CavageDraftFailureThreshold > 1 { + c.CavageDraftFailureThreshold = 0.995 + } + if c.RFC9421Threshold <= 0 || c.RFC9421Threshold > 1 { c.RFC9421Threshold = 0.95 } @@ -454,6 +461,10 @@ func (c *Config) FillDefaults() { c.Ed25519Threshold = 0.98 } + if c.MLDSA44Threshold <= 0 || c.MLDSA44Threshold > 1 { + c.MLDSA44Threshold = 0.998 + } + if c.MaxGateways <= 0 { c.MaxGateways = 10 } diff --git a/cluster/mention_test.go b/cluster/mention_test.go index f0c21ce1..5fe98542 100644 --- a/cluster/mention_test.go +++ b/cluster/mention_test.go @@ -270,7 +270,7 @@ func TestMention_AmbiguousGroupAndFollowed(t *testing.T) { alice. Follow("📣 New post"). FollowInput("📣 Anyone", "!bob post"). - Contains(gmi.Line{Type: gmi.Link, Text: "bob", URL: "/users/outbox/" + group.ID[8:]}) + Contains(gmi.Line{Type: gmi.Link, Text: "bob", URL: "/users/outbox/" + ap.Slug(group.ID)}) } func TestMention_AmbiguousGroupAndGroup(t *testing.T) { diff --git a/cluster/portability_test.go b/cluster/portability_test.go index b51e92a0..22abb186 100644 --- a/cluster/portability_test.go +++ b/cluster/portability_test.go @@ -26,6 +26,7 @@ import ( "testing" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/front/text/gmi" @@ -134,7 +135,7 @@ func TestCluster_Gateways(t *testing.T) { bob. Follow("⚡️ Follows"). - Contains(gmi.Line{Type: gmi.Link, Text: "🚴 alice (alice@a.localdomain)", URL: "/users/outbox/a.localdomain/.well-known/apgateway/" + did + "/actor"}) + Contains(gmi.Line{Type: gmi.Link, Text: "🚴 alice (alice@a.localdomain)", URL: "/users/outbox/" + ap.Slug("https://a.localdomain/.well-known/apgateway/"+did+"/actor")}) post := alice. Follow("📣 New post"). @@ -190,6 +191,7 @@ func TestCluster_ForwardedLegacyReply(t *testing.T) { cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain") defer cluster.Stop() + cluster["b.localdomain"].Config.MLDSA44Threshold = 1 cluster["b.localdomain"].Config.RFC9421Threshold = 1 cluster["b.localdomain"].Config.Ed25519Threshold = 1 cluster["b.localdomain"].Config.DisableIntegrityProofs = true @@ -311,6 +313,94 @@ func TestCluster_ClientSideSigningInboxHappyFlow(t *testing.T) { Contains(gmi.Line{Type: gmi.Quote, Text: "hi"}) } +func TestCluster_MLDSA44ClientSideSigningInboxHappyFlow(t *testing.T) { + cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain") + defer cluster.Stop() + + pub, priv, err := mldsa44.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + registerPortable := "/users/register?" + data.EncodeMLDSA44PrivateKey(priv) + + did := "did:key:" + data.EncodeMLDSA44Publickey(pub) + + alice := cluster["a.localdomain"].Handle(aliceKeypair, registerPortable).OK() + bob := cluster["b.localdomain"].Register(bobKeypair).OK() + carol := cluster["c.localdomain"].Handle(carolKeypair, registerPortable).OK() + + alice. + Follow("⚙️ Settings"). + Follow("🚲 Data portability"). + FollowInput("➕ Add", "c.localdomain"). + OK() + + carol. + Follow("⚙️ Settings"). + Follow("🚲 Data portability"). + FollowInput("➕ Add", "a.localdomain"). + OK() + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Follow("⚡ Follow alice"). + OK() + cluster.Settle(t) + + actorID := "https://a.localdomain/.well-known/apgateway/" + did + "/actor" + + to := ap.Audience{} + to.Add(ap.Public) + + create := &ap.Activity{ + Type: ap.Create, + ID: actorID + "/create/1", + Actor: actorID, + To: to, + CC: to, + Published: ap.Time{Time: time.Now()}, + Object: &ap.Object{ + Type: ap.Note, + ID: actorID + "/note/1", + Content: "hi", + AttributedTo: actorID, + To: to, + CC: to, + }, + } + + create.Proof, err = proof.Create(httpsig.Key{ID: actorID + "#ml-dsa-44-key", PrivateKey: priv}, create) + if err != nil { + t.Fatalf("Failed to generate proof: %v", err) + } + + j, err := json.Marshal(create) + if err != nil { + t.Fatalf("Failed to marshal activity: %v", err) + } + + r, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://c.localdomain/inbox", bytes.NewReader(j)) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + var w responseWriter + cluster["c.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusAccepted { + t.Fatalf("Failed to process activity: %d", w.StatusCode) + } + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + NotContains(gmi.Line{Type: gmi.Quote, Text: "hi"}) + + cluster.Settle(t) + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Contains(gmi.Line{Type: gmi.Quote, Text: "hi"}) +} + func TestCluster_ClientSideSigningOutboxHappyFlow(t *testing.T) { cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain") defer cluster.Stop() @@ -1081,6 +1171,109 @@ func TestCluster_InboxFetchHappyFlow(t *testing.T) { } } +func TestCluster_MLDSA44InboxFetchHappyFlow(t *testing.T) { + cluster := NewCluster(t, "a.localdomain", "b.localdomain") + defer cluster.Stop() + + pub, priv, err := mldsa44.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + registerPortable := "/users/register?" + data.EncodeMLDSA44PrivateKey(priv) + + did := "did:key:" + data.EncodeMLDSA44Publickey(pub) + + alice := cluster["a.localdomain"].Handle(aliceKeypair, registerPortable).OK() + bob := cluster["b.localdomain"].Register(bobKeypair).OK() + + alice. + FollowInput("🔭 View profile", "bob@b.localdomain"). + Follow("⚡ Follow bob"). + OK() + cluster.Settle(t) + + bob. + Follow("📣 New post"). + FollowInput("📣 Anyone", "hello"). + OK() + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Follow("⚡ Follow alice"). + OK() + cluster.Settle(t) + + r, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://a.localdomain/.well-known/apgateway/"+did+"/actor/inbox", nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + if err := httpsig.SignRFC9421( + r, + nil, + httpsig.Key{ + ID: "https://a.localdomain/.well-known/apgateway/" + did + "/actor#ml-dsa-44-key", + PrivateKey: priv, + }, + time.Now(), + time.Now().Add(time.Minute*5), + httpsig.RFC9421DigestSHA256, + "ml-dsa-44", + nil, + ); err != nil { + t.Fatalf("Failed to sign HTTP request: %v", err) + } + + w := responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to fetch inbox: %d", w.StatusCode) + } + + var inbox ap.Collection + if err := json.NewDecoder(&w.Body).Decode(&inbox); err != nil { + t.Fatalf("Failed to decode inbox: %v", err) + } + + r, err = http.NewRequestWithContext(t.Context(), http.MethodGet, inbox.First.(string), nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + if err := httpsig.SignRFC9421( + r, + nil, + httpsig.Key{ + ID: "https://a.localdomain/.well-known/apgateway/" + did + "/actor#ml-dsa-44-key", + PrivateKey: priv, + }, + time.Now(), + time.Now().Add(time.Minute*5), + httpsig.RFC9421DigestSHA256, + "ml-dsa-44", + nil, + ); err != nil { + t.Fatalf("Failed to sign HTTP request: %v", err) + } + + w = responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to fetch inbox page: %d", w.StatusCode) + } + + var page struct { + OrderedItems []ap.Activity `json:"orderedItems"` + } + if err := json.NewDecoder(&w.Body).Decode(&page); err != nil { + t.Fatalf("Failed to decode inbox page: %v", err) + } +} + func TestCluster_InboxFetchInvalidSignature(t *testing.T) { cluster := NewCluster(t, "a.localdomain", "b.localdomain") defer cluster.Stop() @@ -1412,6 +1605,136 @@ func TestCluster_OutboxImport(t *testing.T) { Contains(gmi.Line{Type: gmi.Quote, Text: "hello"}) } +func TestCluster_MLDSA44OutboxImport(t *testing.T) { + cluster := NewCluster(t, "a.localdomain", "b.localdomain") + defer cluster.Stop() + + pub, priv, err := mldsa44.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate key: %v", err) + } + registerPortable := "/users/register?" + data.EncodeMLDSA44PrivateKey(priv) + + did := "did:key:" + data.EncodeMLDSA44Publickey(pub) + + alice := cluster["a.localdomain"].Handle(aliceKeypair, registerPortable).OK() + bob := cluster["b.localdomain"].Handle(bobKeypair, registerPortable).OK() + carol := cluster["a.localdomain"].Register(carolKeypair).OK() + + alice. + FollowInput("🔭 View profile", "carol@a.localdomain"). + Follow("⚡ Follow carol"). + OK() + cluster.Settle(t) + + alice. + Follow("📣 New post"). + FollowInput("📣 Anyone", "hello"). + OK() + carol. + Follow("📣 New post"). + FollowInput("📣 Anyone", "hi"). + OK() + cluster.Settle(t) + + alice. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Contains(gmi.Line{Type: gmi.Quote, Text: "hello"}) + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + NotContains(gmi.Line{Type: gmi.Quote, Text: "hello"}) + + r, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://a.localdomain/.well-known/apgateway/"+did+"/actor/outbox", nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + if err := httpsig.SignRFC9421( + r, + nil, + httpsig.Key{ + ID: "https://a.localdomain/.well-known/apgateway/" + did + "/actor#ml-dsa-44-key", + PrivateKey: priv, + }, + time.Now(), + time.Now().Add(time.Minute*5), + httpsig.RFC9421DigestSHA256, + "ml-dsa-44", + nil, + ); err != nil { + t.Fatalf("Failed to sign HTTP request: %v", err) + } + + w := responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to fetch inbox: %d", w.StatusCode) + } + + var inbox ap.Collection + if err := json.NewDecoder(&w.Body).Decode(&inbox); err != nil { + t.Fatalf("Failed to decode inbox: %v", err) + } + + r, err = http.NewRequestWithContext(t.Context(), http.MethodGet, inbox.First.(string), nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + if err := httpsig.SignRFC9421( + r, + nil, + httpsig.Key{ + ID: "https://a.localdomain/.well-known/apgateway/" + did + "/actor#ml-dsa-44-key", + PrivateKey: priv, + }, + time.Now(), + time.Now().Add(time.Minute*5), + httpsig.RFC9421DigestSHA256, + "ml-dsa-44", + nil, + ); err != nil { + t.Fatalf("Failed to sign HTTP request: %v", err) + } + + w = responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to fetch inbox page: %d", w.StatusCode) + } + + var page struct { + OrderedItems []json.RawMessage `json:"orderedItems"` + } + if err := json.NewDecoder(&w.Body).Decode(&page); err != nil { + t.Fatalf("Failed to decode inbox page: %v", err) + } + + for _, item := range page.OrderedItems { + r, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://b.localdomain/.well-known/apgateway/"+did+"/actor/outbox", bytes.NewReader([]byte(item))) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + var w responseWriter + cluster["b.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusAccepted { + t.Fatalf("Failed to import activity: %d", w.StatusCode) + } + } + + cluster.Settle(t) + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Contains(gmi.Line{Type: gmi.Quote, Text: "hello"}) +} + func TestCluster_ClientSideSigningFollowersHappyFlow(t *testing.T) { cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain") defer cluster.Stop() @@ -1566,6 +1889,160 @@ func TestCluster_ClientSideSigningFollowersHappyFlow(t *testing.T) { } } +func TestCluster_MLDSA44ClientSideSigningFollowersHappyFlow(t *testing.T) { + cluster := NewCluster(t, "a.localdomain", "b.localdomain", "c.localdomain") + defer cluster.Stop() + + alicePub, alicePriv, err := mldsa44.GenerateKey(nil) + aliceDID := "did:key:" + data.EncodeMLDSA44Publickey(alicePub) + alice := cluster["a.localdomain"].Handle(aliceKeypair, "/users/register?"+data.EncodeMLDSA44PrivateKey(alicePriv)).OK() + + bobPub, bobPriv, err := ed25519.GenerateKey(nil) + bobDID := "did:key:" + data.EncodeEd25519PublicKey(bobPub) + bob := cluster["b.localdomain"].Handle(bobKeypair, "/users/register?"+data.EncodeEd25519PrivateKey(bobPriv)).OK() + + carolPub, carolPriv, err := ed25519.GenerateKey(nil) + carolDID := "did:key:" + data.EncodeEd25519PublicKey(carolPub) + carol := cluster["c.localdomain"].Handle(carolKeypair, "/users/register?"+data.EncodeEd25519PrivateKey(carolPriv)).OK() + + r, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://a.localdomain/.well-known/apgateway/"+aliceDID+"/actor/followers", nil) + if err != nil { + t.Fatalf("Failed to create HTTP request: %v", err) + } + + if err := httpsig.SignRFC9421( + r, + nil, + httpsig.Key{ + ID: "https://a.localdomain/.well-known/apgateway/" + aliceDID + "/actor#ml-dsa-44-key", + PrivateKey: alicePriv, + }, + time.Now(), + time.Now().Add(time.Minute*5), + httpsig.RFC9421DigestSHA256, + "ml-dsa-44", + nil, + ); err != nil { + t.Fatalf("Failed to sign HTTP request: %v", err) + } + + w := responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to process activity: %d", w.StatusCode) + } + + var followers struct { + OrderedItems []string `json:"orderedItems"` + } + if err := json.NewDecoder(&w.Body).Decode(&followers); err != nil { + t.Fatalf("Failed to decode followers: %v", err) + } + + if followers.OrderedItems == nil || len(followers.OrderedItems) > 0 { + t.Fatalf("Unexpected list of followers: %v", followers.OrderedItems) + } + + alice. + Follow("🐕 Followers"). + Follow("🔒 Approve new follow requests manually"). + OK() + + carol. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Follow("⚡ Follow alice (requires approval)"). + OK() + cluster.Settle(t) + + alice. + Follow("🐕 Followers"). + Follow("🟢 Accept") + cluster.Settle(t) + + bob. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Follow("⚡ Follow alice (requires approval)"). + OK() + cluster.Settle(t) + + w = responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to process activity: %d", w.StatusCode) + } + + if err := json.NewDecoder(&w.Body).Decode(&followers); err != nil { + t.Fatalf("Failed to decode followers: %v", err) + } + + if !slices.Equal( + followers.OrderedItems, + []string{ + "https://c.localdomain/.well-known/apgateway/" + carolDID + "/actor", + }, + ) { + t.Fatalf("Unexpected list of followers: %v", followers.OrderedItems) + } + + alice. + Follow("🐕 Followers"). + Follow("🟢 Accept") + cluster.Settle(t) + + w = responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to process activity: %d", w.StatusCode) + } + + if err := json.NewDecoder(&w.Body).Decode(&followers); err != nil { + t.Fatalf("Failed to decode followers: %v", err) + } + + if !slices.Equal( + followers.OrderedItems, + []string{ + "https://b.localdomain/.well-known/apgateway/" + bobDID + "/actor", + "https://c.localdomain/.well-known/apgateway/" + carolDID + "/actor", + }, + ) { + t.Fatalf("Unexpected list of followers: %v", followers.OrderedItems) + } + + carol. + FollowInput("🔭 View profile", "alice@a.localdomain"). + Follow("🔌 Unfollow alice"). + OK() + cluster.Settle(t) + + w = responseWriter{ + Headers: http.Header{}, + } + cluster["a.localdomain"].Backend.ServeHTTP(&w, r) + if w.StatusCode != http.StatusOK { + t.Fatalf("Failed to process activity: %d", w.StatusCode) + } + + if err := json.NewDecoder(&w.Body).Decode(&followers); err != nil { + t.Fatalf("Failed to decode followers: %v", err) + } + + if !slices.Equal( + followers.OrderedItems, + []string{ + "https://b.localdomain/.well-known/apgateway/" + bobDID + "/actor", + }, + ) { + t.Fatalf("Unexpected list of followers: %v", followers.OrderedItems) + } +} + func TestCluster_ClientSideSigningFollowersMissingCollection(t *testing.T) { cluster := NewCluster(t, "a.localdomain", "b.localdomain") defer cluster.Stop() diff --git a/cluster/server.go b/cluster/server.go index d6d65ccd..e3f834ea 100644 --- a/cluster/server.go +++ b/cluster/server.go @@ -48,7 +48,7 @@ type Server struct { Config *cfg.Config DB *sql.DB Resolver *fed.Resolver - AppActorKeys [2]httpsig.Key + AppActorKeys [3]httpsig.Key Frontend gemini.Listener Cache *sync.Map Backend http.Handler @@ -149,6 +149,8 @@ func NewServer(t T, domain string, client fed.Client) *Server { cfg.ResolverCacheTTL = 0 cfg.ResolverRetryInterval = 0 cfg.FollowersSyncInterval = 0 + cfg.CavageDraftFailureThreshold = 1 + cfg.MLDSA44Threshold = 0.25 cfg.Ed25519Threshold = 0.25 cfg.RFC9421Threshold = 0.5 cfg.EnableNonPortableActorRegistration = true diff --git a/cmd/tootik/main.go b/cmd/tootik/main.go index 0fbd2186..c6ae6fb6 100644 --- a/cmd/tootik/main.go +++ b/cmd/tootik/main.go @@ -18,7 +18,6 @@ package main import ( "context" - "crypto/ed25519" "crypto/tls" "database/sql" "encoding/json" @@ -46,11 +45,11 @@ import ( "github.com/dimkr/tootik/front/gemini" tplain "github.com/dimkr/tootik/front/text/plain" "github.com/dimkr/tootik/front/user" - "github.com/dimkr/tootik/httpsig" "github.com/dimkr/tootik/icon" "github.com/dimkr/tootik/inbox" "github.com/dimkr/tootik/migrations" "github.com/dimkr/tootik/outbox" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/sqlite" ) @@ -246,19 +245,19 @@ func main() { defer tx.Rollback() var actor ap.Actor - var ed25519PrivKey []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select json(actor), ed25519privkey from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, + `select json(actor), ed25519seed, mldsa44seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, flag.Arg(1), - ).Scan(&actor, &ed25519PrivKey); err != nil { + ).Scan(&actor, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) } actor.Summary = tplain.ToHTML(string(summary), nil) actor.Updated.Time = time.Now() - if err := localInbox.UpdateActorTx(ctx, tx, &actor, httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}); err != nil { + if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed)); err != nil { panic(err) } @@ -288,12 +287,12 @@ func main() { userName := flag.Arg(1) var actor ap.Actor - var ed25519PrivKey []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, - `select select json(actor), ed25519privkey from persons where ed25519privkey is not null and actor->>'$.preferredUsername' = ?`, + `select json(actor), ed25519seed, mldsa44seed from persons where ed25519seed is not null and actor->>'$.preferredUsername' = ?`, userName, - ).Scan(&actor, &ed25519PrivKey); err != nil { + ).Scan(&actor, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) } @@ -312,7 +311,7 @@ func main() { }) actor.Updated.Time = now - if err := localInbox.UpdateActorTx(ctx, tx, &actor, httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}); err != nil { + if err := localInbox.UpdateActorTx(ctx, tx, &actor, proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed)); err != nil { panic(err) } diff --git a/data/garbage.go b/data/garbage.go index a883c3e1..d5ba5f4b 100644 --- a/data/garbage.go +++ b/data/garbage.go @@ -35,7 +35,7 @@ type GarbageCollector struct { func (gc *GarbageCollector) Run(ctx context.Context) error { now := time.Now() - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select notes.rowid from notes left join follows on follows.followed in (notes.author, notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = follows.followed)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = follows.followed)) where follows.accepted = 1 and notes.inserted < $1 and notes.host != $2 and follows.id is null and not exists (select 1 from bookmarks where bookmarks.note = notes.id) and not exists (select 1 from shares where shares.note = notes.id and exists (select 1 from persons where persons.id = shares.by and persons.host = $2)))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select notes.pk from notes left join follows on follows.followed in (notes.author, notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or (notes.to2 is not null and exists (select 1 from json_each(notes.object->'$.to') where value = follows.followed)) or (notes.cc2 is not null and exists (select 1 from json_each(notes.object->'$.cc') where value = follows.followed)) where follows.accepted = 1 and notes.inserted < $1 and notes.host != $2 and follows.id is null and not exists (select 1 from bookmarks where bookmarks.note = notes.id) and not exists (select 1 from shares where shares.note = notes.id and exists (select 1 from persons where persons.id = shares.by and persons.host = $2)))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove invisible posts: %w", err) } @@ -43,7 +43,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove invisible posts: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select rowid from notes where inserted < $1 and author not in (select followed from follows where accepted = 1) and host != $2 and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select pk from notes where inserted < $1 and author not in (select followed from follows where accepted = 1) and host != $2 and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.InvisiblePostsTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove posts by authors without followers: %w", err) } @@ -51,7 +51,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove posts by authors without followers: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select rowid from notes where inserted < ? and host != ? and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.NotesTTL).Unix(), gc.Domain); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from notesfts where rowid in (select pk from notes where inserted < ? and host != ? and not exists (select 1 from bookmarks where bookmarks.note = notes.id))`, now.Add(-gc.Config.NotesTTL).Unix(), gc.Domain); err != nil { return fmt.Errorf("failed to remove old posts: %w", err) } @@ -67,7 +67,7 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove old posts: %w", err) } - if _, err := gc.DB.ExecContext(ctx, `delete from persons where updated < ? and ed25519privkey is null and not exists (select 1 from follows where followed = persons.id) and not exists (select 1 from follows where follower = persons.id) and not exists (select 1 from notes where notes.author = persons.id) and not exists (select 1 from shares where shares.by = persons.id)`, now.Add(-gc.Config.ActorTTL).Unix()); err != nil { + if _, err := gc.DB.ExecContext(ctx, `delete from persons where updated < ? and ed25519seed is null and not exists (select 1 from follows where followed = persons.id) and not exists (select 1 from follows where follower = persons.id) and not exists (select 1 from notes where notes.author = persons.id) and not exists (select 1 from shares where shares.by = persons.id)`, now.Add(-gc.Config.ActorTTL).Unix()); err != nil { return fmt.Errorf("failed to remove idle actors: %w", err) } @@ -110,6 +110,10 @@ func (gc *GarbageCollector) Run(ctx context.Context) error { return fmt.Errorf("failed to remove expired certificates: %w", err) } + if _, err := gc.DB.ExecContext(ctx, `insert into notesfts(notesfts, rank) values('merge', -16)`); err != nil { + return fmt.Errorf("failed to merge FTS: %w", err) + } + if _, err := gc.DB.ExecContext(ctx, `pragma optimize`); err != nil { return fmt.Errorf("failed to optimize: %w", err) } diff --git a/data/key.go b/data/key.go index 97c83242..49dfa115 100644 --- a/data/key.go +++ b/data/key.go @@ -17,49 +17,78 @@ limitations under the License. package data import ( + "crypto" "crypto/ed25519" "encoding/base64" "errors" "fmt" "github.com/btcsuite/btcutil/base58" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" ) +type PrivateKey interface { + crypto.PrivateKey + + Public() crypto.PublicKey +} + // EncodeEd25519PrivateKey encodes an Ed25519 private key. func EncodeEd25519PrivateKey(key ed25519.PrivateKey) string { return "z" + base58.Encode(append([]byte{0x80, 0x26}, key.Seed()...)) } -// DecodeEd25519PrivateKey decodes an Ed25519 private key encoded by [EncodeEd25519PrivateKey]. -func DecodeEd25519PrivateKey(key string) (ed25519.PrivateKey, error) { +// EncodeEd25519PublicKey encodes an Ed25519 public key. +func EncodeEd25519PublicKey(key ed25519.PublicKey) string { + return "z" + base58.Encode(append([]byte{0xed, 0x01}, key...)) +} + +// EncodeMLDSA44PrivateKey encodes a ML-DSA-44 private key. +func EncodeMLDSA44PrivateKey(key *mldsa44.PrivateKey) string { + return "u" + base64.RawURLEncoding.EncodeToString(append([]byte{0x9a, 0x26}, key.Seed()...)) +} + +// EncodeMLDSA44Publickey encodes a ML-DSA-44 public key. +func EncodeMLDSA44Publickey(key *mldsa44.PublicKey) string { + return "u" + base64.RawURLEncoding.EncodeToString(append([]byte{0x90, 0x24}, key.Bytes()...)) +} + +// DecodePrivateKey decodes a public key encoded by [EncodeEd25519PrivateKey] or [EncodeMLDSA44PrivateKey]. +func DecodePrivateKey(key string) (PrivateKey, error) { if len(key) == 0 { return nil, errors.New("empty key") } - if key[0] != 'z' { - return nil, fmt.Errorf("invalid key prefix: %c", key[0]) - } + var rawKey []byte + switch key[0] { + case 'z': + rawKey = base58.Decode(key[1:]) - rawKey := base58.Decode(key[1:]) + case 'u': + var err error + rawKey, err = base64.RawURLEncoding.DecodeString(key[1:]) + if err != nil { + return nil, fmt.Errorf("failed to decode key: %w", err) + } - if len(rawKey) != ed25519.SeedSize+2 { - return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) + default: + return nil, fmt.Errorf("invalid prefix: %c", key[0]) } - if rawKey[0] != 0x80 || rawKey[1] != 0x26 { + if len(rawKey) == 2+ed25519.SeedSize && rawKey[0] == 0x80 && rawKey[1] == 0x26 { + return ed25519.NewKeyFromSeed(rawKey[2:]), nil + } else if len(rawKey) == 2+mldsa44.SeedSize && rawKey[0] == 0x9a && rawKey[1] == 0x26 { + _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(rawKey[2:])) + return priv, nil + } else if len(rawKey) >= 2 { return nil, fmt.Errorf("invalid key prefix: %02x%02x", rawKey[0], rawKey[1]) + } else { + return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) } - - return ed25519.NewKeyFromSeed(rawKey[2:]), nil } -// EncodeEd25519PublicKey encodes an Ed25519 public key. -func EncodeEd25519PublicKey(key ed25519.PublicKey) string { - return "z" + base58.Encode(append([]byte{0xed, 0x01}, key...)) -} - -// DecodeEd25519PublicKey decodes an Ed25519 public key encoded by [EncodeEd25519PublicKey]. -func DecodeEd25519PublicKey(key string) (ed25519.PublicKey, error) { +// DecodePublicKey decodes a public key encoded by [EncodeEd25519PublicKey] or [EncodeMLDSA44PublicKey]. +func DecodePublicKey(key string) (crypto.PublicKey, error) { if len(key) == 0 { return nil, errors.New("key is empty") } @@ -80,13 +109,14 @@ func DecodeEd25519PublicKey(key string) (ed25519.PublicKey, error) { return nil, fmt.Errorf("invalid prefix: %c", key[0]) } - if len(rawKey) != ed25519.PublicKeySize+2 { + if len(rawKey) == 2+ed25519.PublicKeySize && rawKey[0] == 0xed && rawKey[1] == 0x01 { + return ed25519.PublicKey(rawKey[2:]), nil + } else if len(rawKey) == 2+mldsa44.PublicKeySize && rawKey[0] == 0x90 && rawKey[1] == 0x24 { + pub := &mldsa44.PublicKey{} + return pub, pub.UnmarshalBinary(rawKey[2:]) + } else if len(rawKey) >= 2 { + return nil, fmt.Errorf("invalid prefix: %02x%02x", rawKey[0], rawKey[1]) + } else { return nil, fmt.Errorf("invalid key length: %d", len(rawKey)) } - - if rawKey[0] != 0xed || rawKey[1] != 0x01 { - return nil, fmt.Errorf("invalid prefix: %x%x", rawKey[0], rawKey[1]) - } - - return ed25519.PublicKey(rawKey[2:]), nil } diff --git a/data/key_test.go b/data/key_test.go index 92c6cd3a..986b161a 100644 --- a/data/key_test.go +++ b/data/key_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,22 +18,48 @@ package data import ( "bytes" + "crypto/ed25519" "testing" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" ) // https://codeberg.org/fediverse/fep/src/commit/480415584237eb19cb7373b6a25faa6fa6e3a200/fep/521b/fep-521b.md func Test_FEP521b(t *testing.T) { - a, err := DecodeEd25519PublicKey("u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw") + a, err := DecodePublicKey("u7QGwDY2Tjn93PVFWWq02piP1NE9_XRlg-c8-jhJiDqKBDw") if err != nil { t.Fatalf("Failed to decode base64-encoded key: %v", err) } - b, err := DecodeEd25519PublicKey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2") + b, err := DecodePublicKey("z6MkrJVnaZkeFzdQyMZu1cgjg7k1pZZ6pvBQ7XJPt4swbTQ2") if err != nil { t.Fatalf("Failed to decode base58-encoded key: %v", err) } - if !bytes.Equal(a, b) { + if !bytes.Equal(a.(ed25519.PublicKey), b.(ed25519.PublicKey)) { + t.Fatal("Keys are different") + } +} + +func Test_MLDSA44(t *testing.T) { + pub, priv, err := mldsa44.GenerateKey(nil) + if err != nil { + t.Fatalf("Failed to generate: %v", err) + } + + decodedPriv, err := DecodePrivateKey(EncodeMLDSA44PrivateKey(priv)) + if err != nil { + t.Fatalf("Failed to decode private key: %v", err) + } + if !decodedPriv.(*mldsa44.PrivateKey).Equal(priv) { + t.Fatal("Private keys are different") + } + + decodedPub, err := DecodePublicKey(EncodeMLDSA44Publickey(pub)) + if err != nil { + t.Fatalf("Failed to decode public key: %v", err) + } + if !decodedPub.(*mldsa44.PublicKey).Equal(pub) { t.Fatal("Keys are different") } } diff --git a/fed/apgateway.go b/fed/apgateway.go index ce4213d4..7fe2ddec 100644 --- a/fed/apgateway.go +++ b/fed/apgateway.go @@ -30,6 +30,7 @@ import ( "regexp" "strconv" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" @@ -39,12 +40,12 @@ import ( "github.com/dimkr/tootik/proof" ) -var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:z6Mk[a-km-zA-HJ-NP-Z1-9]+)(\/actor(?:\/[^\/]+)?)(\/.+)?`) +var apGatewayPathRegex = regexp.MustCompile(`\/.well-known\/apgateway\/(did:key:(?:` + ap.PortableActorPubPattern + `))(\/actor(?:\/[^\/]+)?)(\/.+)?`) func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Request, did string) { var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte - if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, did).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte + if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where cid = 'ap://' || ? || '/actor' and ed25519seed is not null`, did).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Debug("Receiving user does not exist", "did", did) w.WriteHeader(http.StatusNotFound) return @@ -61,9 +62,12 @@ func (l *Listener) handleApGatewayInboxPost(w http.ResponseWriter, r *http.Reque return } - l.doHandleInbox(w, r, [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + + l.doHandleInbox(w, r, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }) } @@ -115,7 +119,7 @@ func (l *Listener) handleApGatewayOutboxPost(w http.ResponseWriter, r *http.Requ return } - publicKey, err := data.DecodeEd25519PublicKey(expectedPublicKey) + publicKey, err := data.DecodePublicKey(expectedPublicKey) if err != nil { slog.Warn("Failed to decode key to verify proof", "activity", activity.ID, "error", err) w.WriteHeader(http.StatusForbidden) @@ -159,7 +163,7 @@ func (l *Listener) handleApGatewayInboxGet(w http.ResponseWriter, r *http.Reques var inbox string if err := l.DB.QueryRowContext( r.Context(), - `select actor->>'$.inbox' from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, + `select actor->>'$.inbox' from persons where cid = 'ap://' || ? || '/actor' and ed25519seed is not null`, did, ).Scan(&inbox); errors.Is(err, sql.ErrNoRows) { slog.Warn("Inbox does not exist", "did", did) @@ -312,12 +316,13 @@ func (l *Listener) handleApGatewayContext(w http.ResponseWriter, r *http.Request } var postID string - var ed25519PrivKey []byte + var author ap.Actor + var ed25519Seed, mldsa44Seed []byte if err := l.DB.QueryRowContext( r.Context(), - `select notes.id, notes.author, persons.ed25519privkey from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null and persons.ed25519privkey is not null`, + `select notes.id, notes.author, json(persons.actor), persons.ed25519seed, persons.mldsa44seed from notes join persons on persons.id = notes.author where notes.object->>'$.context' = ? and notes.object->>'$.inReplyTo' is null and persons.ed25519seed is not null`, contextID, - ).Scan(&postID, &collection.AttributedTo, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + ).Scan(&postID, &collection.AttributedTo, &author, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Warn("Context does not exist", "id", contextID) w.WriteHeader(http.StatusNotFound) return @@ -364,10 +369,7 @@ func (l *Listener) handleApGatewayContext(w http.ResponseWriter, r *http.Request var err error collection.Proof, err = proof.Create( - httpsig.Key{ - ID: collection.AttributedTo + "#ed25519-key", - PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey), - }, + proof.SigningSeed(&author, ed25519Seed, mldsa44Seed), collection, ) if err != nil { @@ -405,7 +407,7 @@ func (l *Listener) handleApGatewayOutboxGet(w http.ResponseWriter, r *http.Reque var outbox string if err := l.DB.QueryRowContext( r.Context(), - `select actor->>'$.outbox' from persons where cid = ? and ed25519privkey is not null`, + `select actor->>'$.outbox' from persons where cid = ? and ed25519seed is not null`, actorCID, ).Scan(&outbox); errors.Is(err, sql.ErrNoRows) { slog.Warn("Outbox does not exist", "did", did) @@ -606,7 +608,7 @@ func (l *Listener) fetchSenderFollowers( var actor ap.Actor if err := l.DB.QueryRowContext( r.Context(), - `select json(actor) from persons where cid = 'ap://' || ? || '/actor' and ed25519privkey is not null`, + `select json(actor) from persons where cid = 'ap://' || ? || '/actor' and ed25519seed is not null`, did, ).Scan(&actor); errors.Is(err, sql.ErrNoRows) { slog.Warn("Denying followers request for non-existing user", "did", did) @@ -698,15 +700,15 @@ func (l *Listener) handleAPGatewayGetObject(w http.ResponseWriter, r *http.Reque select raw from ( select json(actor) as raw from persons - where cid = $1 and ed25519privkey is not null + where cid = $1 and ed25519seed is not null union all select json(notes.object) as raw from notes join persons on notes.author = persons.id - where notes.cid = $1 and notes.deleted = 0 and notes.public = 1 and persons.ed25519privkey is not null + where notes.cid = $1 and notes.deleted = 0 and notes.public = 1 and persons.ed25519seed is not null union all select json(outbox.activity) as raw from outbox join persons on outbox.activity->>'$.actor' = persons.id - where outbox.cid = $1 and (exists (select 1 from json_each(outbox.activity->'$.cc') where value = $2) or exists (select 1 from json_each(outbox.activity->'$.to') where value = $2)) and persons.ed25519privkey is not null + where outbox.cid = $1 and (exists (select 1 from json_each(outbox.activity->'$.cc') where value = $2) or exists (select 1 from json_each(outbox.activity->'$.to') where value = $2)) and persons.ed25519seed is not null ) limit 1 `, diff --git a/fed/deliver.go b/fed/deliver.go index 31747682..729f9dfd 100644 --- a/fed/deliver.go +++ b/fed/deliver.go @@ -32,6 +32,7 @@ import ( "sync" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/danger" @@ -54,7 +55,7 @@ type deliveryJob struct { type deliveryTask struct { Job deliveryJob - Keys [2]httpsig.Key + Keys [3]httpsig.Key Request *http.Request Inbox string } @@ -91,11 +92,11 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { slog.Debug("Polling delivery queue") rows, err := dbx.QueryCollectCountIgnore[struct { - DeliveryAttempts int - Activity ap.Activity - RawActivity string - Actor ap.Actor - RsaPrivKeyDer, Ed25519PrivKey []byte + DeliveryAttempts int + Activity ap.Activity + RawActivity string + Actor ap.Actor + RsaPrivKeyDer, Ed25519Seed, MLDSA44Seed []byte }]( ctx, q.DB, @@ -104,7 +105,7 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { slog.Error("Failed to fetch post to deliver", "error", err) return true }, - `select outbox.attempts, json(outbox.activity) as x, json(outbox.activity) as y, json(persons.actor), persons.rsaprivkey, persons.ed25519privkey from + `select outbox.attempts, json(outbox.activity) as x, json(outbox.activity) as y, json(persons.actor), persons.rsaprivkey, persons.ed25519seed, persons.mldsa44seed from outbox join persons on @@ -175,9 +176,12 @@ func (q *Queue) ProcessBatch(ctx context.Context) (int, error) { continue } - keys := [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(row.MLDSA44Seed)) + + keys := [3]httpsig.Key{ {ID: row.Actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey)}, + {ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519Seed)}, + {ID: row.Actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } if _, err := q.DB.ExecContext( @@ -318,7 +322,7 @@ func (q *Queue) consume(ctx context.Context, requests <-chan *deliveryTask, even func (q *Queue) queueTask( ctx context.Context, job deliveryJob, - keys [2]httpsig.Key, + keys [3]httpsig.Key, inbox, contentLength string, followers *partialFollowers, tasks []chan *deliveryTask, @@ -361,7 +365,7 @@ func (q *Queue) queueTask( func (q *Queue) queueTasks( ctx context.Context, job deliveryJob, - keys [2]httpsig.Key, + keys [3]httpsig.Key, followers *partialFollowers, tasks []chan *deliveryTask, events chan<- deliveryEvent, @@ -404,7 +408,7 @@ func (q *Queue) queueTasks( slog.Warn("Skipped an inbox", "activity", job.Activity.ID, "error", err) return true }, - `select distinct coalesce(persons.actor->>'$.endpoints.sharedInbox', persons.actor->>'$.inbox') as inbox from persons join follows on follows.follower = persons.id where follows.followed = ? and follows.accepted = 1 and follows.follower not like ? and persons.ed25519privkey is null order by persons.actor->>'$.endpoints.sharedInbox' is not null desc, inbox`, + `select distinct coalesce(persons.actor->>'$.endpoints.sharedInbox', persons.actor->>'$.inbox') as inbox from persons join follows on follows.follower = persons.id where follows.followed = ? and follows.accepted = 1 and follows.follower not like ? and persons.ed25519seed is null order by persons.actor->>'$.endpoints.sharedInbox' is not null desc, inbox`, job.Sender.ID, fmt.Sprintf("https://%s/%%", activityID.Host), ) diff --git a/fed/deliver_test.go b/fed/deliver_test.go index 4b22bd48..d19ff8b3 100644 --- a/fed/deliver_test.go +++ b/fed/deliver_test.go @@ -20,6 +20,7 @@ import ( "bytes" "context" "database/sql" + "github.com/dimkr/tootik/ap" "io" "net/http" "os" @@ -70,14 +71,16 @@ func TestDeliver_TwoUsersTwoPosts(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -176,14 +179,16 @@ func TestDeliver_ForwardedPost(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -277,14 +282,16 @@ func TestDeliver_OneFailed(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -389,14 +396,16 @@ func TestDeliver_OneFailedRetry(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -483,14 +492,16 @@ func TestDeliver_OneInvalidURLRetry(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes:inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -577,14 +588,16 @@ func TestDeliver_MaxAttempts(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -672,21 +685,24 @@ func TestDeliver_SharedInbox(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -759,21 +775,24 @@ func TestDeliver_SharedInboxRetry(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -869,14 +888,16 @@ func TestDeliver_SharedInboxUnknownActor(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -958,21 +979,24 @@ func TestDeliver_SharedInboxSingleWorker(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin","endpoints":{"sharedInbox":"https://ip6-allnodes/inbox/nobody"}}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -1045,21 +1069,24 @@ func TestDeliver_SameInbox(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/frank"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/frank"), "https://ip6-allnodes/user/frank", `{"type":"Person","id":"https://ip6-allnodes/user/frank","preferredUsername":"frank","inbox":"https://ip6-allnodes/inbox/frank"}`, ) @@ -1135,14 +1162,16 @@ func TestDeliver_ToAndCCDuplicates(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -1241,14 +1270,16 @@ func TestDeliver_PublicInTo(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) @@ -1347,14 +1378,16 @@ func TestDeliver_AuthorInTo(t *testing.T) { assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/dan"), "https://ip6-allnodes/user/dan", `{"type":"Person","id":"https://ip6-allnodes/user/dan","preferredUsername":"dan","inbox":"https://ip6-allnodes/inbox/dan"}`, ) assert.NoError(err) _, err = db.Exec( - `insert into persons (id, actor) values(?,?)`, + `insert into persons (slug, id, actor) values(?,?,?)`, + ap.Slug("https://ip6-allnodes/user/erin"), "https://ip6-allnodes/user/erin", `{"type":"Person","id":"https://ip6-allnodes/user/erin","preferredUsername":"erin","inbox":"https://ip6-allnodes/inbox/erin"}`, ) diff --git a/fed/followers.go b/fed/followers.go index 1a8e103f..77c50254 100644 --- a/fed/followers.go +++ b/fed/followers.go @@ -18,7 +18,6 @@ package fed import ( "context" - "crypto/ed25519" "crypto/sha256" "database/sql" "encoding/json" @@ -37,6 +36,7 @@ import ( "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/dbx" "github.com/dimkr/tootik/httpsig" + "github.com/dimkr/tootik/proof" ) type partialFollowers map[string]map[string]string @@ -46,7 +46,7 @@ type Syncer struct { Config *cfg.Config DB *sql.DB Resolver *Resolver - Keys [2]httpsig.Key + Keys [3]httpsig.Key Inbox ap.Inbox } @@ -241,7 +241,7 @@ func (l *Listener) saveFollowersDigest(ctx context.Context, sender *ap.Actor, he return nil } -func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Config, db *sql.DB, resolver *Resolver, keys [2]httpsig.Key) error { +func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Config, db *sql.DB, resolver *Resolver, keys [3]httpsig.Key) error { if digest, err := digestFollowers(ctx, db, d.Followed, domain); err != nil { return err } else if digest == d.Digest { @@ -315,8 +315,8 @@ func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Conf slog.Info("Found unknown remote follow", "followed", d.Followed, "follower", follower) var actor ap.Actor - var ed25519PrivKey []byte - if err := db.QueryRowContext(ctx, `SELECT JSON(persons.actor), persons.ed25519privkey FROM persons WHERE id = ? AND persons.ed25519privkey IS NOT NULL`, follower).Scan(&actor, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + var ed25519Seed, mldsa44Seed []byte + if err := db.QueryRowContext(ctx, `SELECT JSON(persons.actor), persons.ed25519seed, persons.mldsa44seed FROM persons WHERE id = ? AND persons.ed25519seed IS NOT NULL`, follower).Scan(&actor, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Info("Follower does not exist", "followed", d.Followed, "follower", follower) continue } else if err != nil { @@ -337,7 +337,7 @@ func (d *followersDigest) Sync(ctx context.Context, domain string, cfg *cfg.Conf continue } - if err := d.Inbox.Unfollow(ctx, &actor, httpsig.Key{ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, d.Followed, followID); err != nil { + if err := d.Inbox.Unfollow(ctx, &actor, proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed), d.Followed, followID); err != nil { slog.Warn("Failed to remove remote follow", "followed", d.Followed, "follower", follower, "error", err) } } diff --git a/fed/inbox.go b/fed/inbox.go index ddf7e15f..b0b8f1b4 100644 --- a/fed/inbox.go +++ b/fed/inbox.go @@ -30,6 +30,7 @@ import ( "net/http" "strings" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" @@ -68,7 +69,7 @@ func (l *Listener) getActivityOrigin(activity *ap.Activity, sender *ap.Actor) (s return activityOrigin, senderOrigin, senderHost, nil } -func (l *Listener) fetchObject(ctx context.Context, id string, keys [2]httpsig.Key) (bool, []byte, error) { +func (l *Listener) fetchObject(ctx context.Context, id string, keys [3]httpsig.Key) (bool, []byte, error) { resp, err := l.Resolver.Get(ctx, keys, id) if err != nil { if resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone) { @@ -119,7 +120,7 @@ func (l *Listener) fetchObject(ctx context.Context, id string, keys [2]httpsig.K return true, nil, fmt.Errorf("key %s does not belong to %s", m[1], origin) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return true, nil, fmt.Errorf("failed to verify proof using %s: %w", withProof.Proof.VerificationMethod, err) } @@ -139,8 +140,8 @@ func (l *Listener) handleInbox(w http.ResponseWriter, r *http.Request) { receiver := r.PathValue("username") var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte - if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, receiver).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); errors.Is(err, sql.ErrNoRows) { + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte + if err := l.DB.QueryRowContext(r.Context(), `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519seed is not null`, receiver).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed); errors.Is(err, sql.ErrNoRows) { slog.Debug("Receiving user does not exist", "receiver", receiver) w.WriteHeader(http.StatusNotFound) return @@ -157,13 +158,16 @@ func (l *Listener) handleInbox(w http.ResponseWriter, r *http.Request) { return } - l.doHandleInbox(w, r, [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + + l.doHandleInbox(w, r, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }) } -func (l *Listener) doHandleInbox(w http.ResponseWriter, r *http.Request, keys [2]httpsig.Key) { +func (l *Listener) doHandleInbox(w http.ResponseWriter, r *http.Request, keys [3]httpsig.Key) { if r.ContentLength > l.Config.MaxRequestBodySize { slog.Warn("Ignoring big request", "size", r.ContentLength) w.WriteHeader(http.StatusRequestEntityTooLarge) @@ -443,6 +447,8 @@ func (l *Listener) doHandleInbox(w http.ResponseWriter, r *http.Request, keys [2 capabilities = ap.RFC9421RSASignatures case "ed25519": capabilities = ap.RFC9421Ed25519Signatures + case "ml-dsa-44": + capabilities = ap.RFC9421MLDSA44Signatures } } diff --git a/fed/listener.go b/fed/listener.go index 94e29f69..23123333 100644 --- a/fed/listener.go +++ b/fed/listener.go @@ -45,7 +45,7 @@ type Listener struct { DB *sql.DB Resolver *Resolver AppActor *ap.Actor - AppActorKeys [2]httpsig.Key + AppActorKeys [3]httpsig.Key Addr string Cert string Key string diff --git a/fed/resolve.go b/fed/resolve.go index ddabbd4b..bca2b3b3 100644 --- a/fed/resolve.go +++ b/fed/resolve.go @@ -83,7 +83,7 @@ func NewResolver(blockedDomains *BlockList, domain string, cfg *cfg.Config, clie } // ResolveID retrieves an actor object by its ID. -func (r *Resolver) ResolveID(ctx context.Context, keys [2]httpsig.Key, id string, flags ap.ResolverFlag) (*ap.Actor, error) { +func (r *Resolver) ResolveID(ctx context.Context, keys [3]httpsig.Key, id string, flags ap.ResolverFlag) (*ap.Actor, error) { if id == "" { return nil, errors.New("empty ID") } @@ -109,7 +109,7 @@ func (r *Resolver) ResolveID(ctx context.Context, keys [2]httpsig.Key, id string } // Resolve retrieves an actor object by host and name. -func (r *Resolver) Resolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, error) { +func (r *Resolver) Resolve(ctx context.Context, keys [3]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, error) { if actor, err := r.validate(func() (*ap.Actor, *ap.Actor, error) { return r.tryResolve(ctx, keys, host, name, flags) }); err != nil { return nil, err } else if actor.Suspended { @@ -138,7 +138,7 @@ func (r *Resolver) validate(try func() (*ap.Actor, *ap.Actor, error)) (*ap.Actor } func deleteActor(ctx context.Context, db *sql.DB, id string) { - if _, err := db.ExecContext(ctx, `delete from notesfts where exists (select 1 from notes where notes.author = ? and notes.rowid = notesfts.rowid)`, id); err != nil { + if _, err := db.ExecContext(ctx, `delete from notesfts where exists (select 1 from notes where notes.author = ? and notes.pk = notesfts.rowid)`, id); err != nil { slog.Warn("Failed to delete notes by actor", "id", id, "error", err) } @@ -202,7 +202,7 @@ func (r *Resolver) handleFetchFailure(ctx context.Context, fetched string, cache return nil, cachedActor, fmt.Errorf("failed to fetch %s: %w", fetched, err) } -func (r *Resolver) tryResolve(ctx context.Context, keys [2]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { +func (r *Resolver) tryResolve(ctx context.Context, keys [3]httpsig.Key, host, name string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { slog.Debug("Resolving actor", "host", host, "name", name) if r.BlockedDomains != nil && r.BlockedDomains.Contains(host) { @@ -351,7 +351,7 @@ func (r *Resolver) tryResolve(ctx context.Context, keys [2]httpsig.Key, host, na return nil, cachedActor, fmt.Errorf("no profile link in %s response", finger) } -func (r *Resolver) tryResolveID(ctx context.Context, keys [2]httpsig.Key, u *url.URL, id string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { +func (r *Resolver) tryResolveID(ctx context.Context, keys [3]httpsig.Key, u *url.URL, id string, flags ap.ResolverFlag) (*ap.Actor, *ap.Actor, error) { slog.Debug("Resolving actor", "id", id) if r.BlockedDomains != nil && r.BlockedDomains.Contains(u.Host) { @@ -444,7 +444,7 @@ func discoverCapabilities(implements []ap.Implement) ap.Capability { return capabilities } -func (r *Resolver) fetchActor(ctx context.Context, keys [2]httpsig.Key, host, profile string, cachedActor *ap.Actor, sinceLastUpdate time.Duration) (*ap.Actor, *ap.Actor, error) { +func (r *Resolver) fetchActor(ctx context.Context, keys [3]httpsig.Key, host, profile string, cachedActor *ap.Actor, sinceLastUpdate time.Duration) (*ap.Actor, *ap.Actor, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, profile, nil) if err != nil { return nil, cachedActor, fmt.Errorf("failed to send request to %s: %w", profile, err) @@ -545,7 +545,7 @@ func (r *Resolver) fetchActor(ctx context.Context, keys [2]httpsig.Key, host, pr } if m := ap.GatewayURLRegex.FindStringSubmatch(actor.ID); m != nil { - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return nil, cachedActor, fmt.Errorf("failed to parse key %s for %s to verify proof: %w", m[1], actor.ID, err) } @@ -567,7 +567,8 @@ func (r *Resolver) fetchActor(ctx context.Context, keys [2]httpsig.Key, host, pr if _, err := tx.ExecContext( ctx, - `INSERT INTO persons(id, actor, fetched) VALUES ($1, JSONB($2), UNIXEPOCH()) ON CONFLICT(id) DO UPDATE SET actor = JSONB($2), updated = UNIXEPOCH()`, + `INSERT INTO persons(slug, id, actor, fetched) VALUES ($1, $2, JSONB($3), UNIXEPOCH()) ON CONFLICT(id) DO UPDATE SET actor = JSONB($3), updated = UNIXEPOCH()`, + ap.Slug(actor.ID), actor.ID, bodyString, ); err != nil { diff --git a/fed/send.go b/fed/send.go index 23a41c8b..a133afdb 100644 --- a/fed/send.go +++ b/fed/send.go @@ -43,7 +43,7 @@ type sender struct { var userAgent = "tootik/" + buildinfo.Version -func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*http.Response, error) { +func (s *sender) send(keys [3]httpsig.Key, req *http.Request, body []byte) (*http.Response, error) { urlString := req.URL.String() if req.URL.Scheme != "https" { @@ -67,7 +67,10 @@ func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*htt return nil, fmt.Errorf("failed to query server capabilities for %s: %w", req.URL.Host, err) } - if capabilities&ap.RFC9421Ed25519Signatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.Ed25519Threshold { + if capabilities&ap.RFC9421MLDSA44Signatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.MLDSA44Threshold { + slog.Debug("Randomly enabling RFC9421 with ML-DSA-44", "server", req.URL.Host) + capabilities = ap.RFC9421MLDSA44Signatures + } else if capabilities&ap.RFC9421Ed25519Signatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.Ed25519Threshold { slog.Debug("Randomly enabling RFC9421 with Ed25519", "server", req.URL.Host) capabilities = ap.RFC9421Ed25519Signatures } else if capabilities&ap.RFC9421RSASignatures == 0 && req.Method == http.MethodPost && rand.Float32() > s.Config.RFC9421Threshold { @@ -75,7 +78,13 @@ func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*htt capabilities = ap.RFC9421RSASignatures } - if capabilities&ap.RFC9421Ed25519Signatures > 0 { + if capabilities&ap.RFC9421MLDSA44Signatures > 0 { + slog.Debug("Signing request using RFC9421 with ML-DSA-44", "method", req.Method, "url", urlString, "key", keys[2].ID) + + if err := httpsig.SignRFC9421(req, body, keys[2], time.Now(), time.Time{}, httpsig.RFC9421DigestSHA256, "ml-dsa-44", nil); err != nil { + return nil, fmt.Errorf("failed to sign request for %s: %w", urlString, err) + } + } else if capabilities&ap.RFC9421Ed25519Signatures > 0 { slog.Debug("Signing request using RFC9421 with Ed25519", "method", req.Method, "url", urlString, "key", keys[1].ID) if err := httpsig.SignRFC9421(req, body, keys[1], time.Now(), time.Time{}, httpsig.RFC9421DigestSHA256, "ed25519", nil); err != nil { @@ -134,7 +143,7 @@ func (s *sender) send(keys [2]httpsig.Key, req *http.Request, body []byte) (*htt return resp, nil } -func (s *sender) Get(ctx context.Context, keys [2]httpsig.Key, url string) (*http.Response, error) { +func (s *sender) Get(ctx context.Context, keys [3]httpsig.Key, url string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, fmt.Errorf("failed to send request to %s: %w", url, err) diff --git a/fed/verify.go b/fed/verify.go index 3b4878fb..47afe85e 100644 --- a/fed/verify.go +++ b/fed/verify.go @@ -24,10 +24,12 @@ import ( "encoding/pem" "errors" "fmt" + "math/rand/v2" "net/http" "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" @@ -37,7 +39,7 @@ import ( var errNoKeyInKeyID = errors.New("key origin does not contain a key") -func getKeyByID(actor *ap.Actor, keyID string) (ed25519.PublicKey, error) { +func getKeyByID(actor *ap.Actor, keyID string) (crypto.PublicKey, error) { for _, key := range actor.AssertionMethod { if key.ID != keyID { continue @@ -51,7 +53,7 @@ func getKeyByID(actor *ap.Actor, keyID string) (ed25519.PublicKey, error) { continue } - raw, err := data.DecodeEd25519PublicKey(key.PublicKeyMultibase) + raw, err := data.DecodePublicKey(key.PublicKeyMultibase) if err != nil { return nil, fmt.Errorf("failed to parse %s: %w", key.ID, err) } @@ -68,11 +70,21 @@ func (l *Listener) extractRequestSignature(r *http.Request, body []byte) (*https return nil, fmt.Errorf("failed to extract signature: %w", err) } + if r.Method == http.MethodPost && (sig.Alg == "rsa-sha256" || sig.Alg == "hs2019") && rand.Float32() > l.Config.CavageDraftFailureThreshold { + return nil, errors.New("randomly refusing draft-cavage-http-signatures to encourage use of RFC9421") + } + return sig, err } -func (l *Listener) verifyEd25519RequestSignatureUsingKeyID(sig *httpsig.Signature) (string, error) { - if sig.Alg != "ed25519" { +func (l *Listener) verifyRequestSignatureUsingKeyID(sig *httpsig.Signature) (string, error) { + keyOrigin, err := ap.Origin(sig.KeyID) + if err != nil { + return "", fmt.Errorf("failed to get origin of %s: %w", sig.KeyID, err) + } + + suffix, ok := strings.CutPrefix(keyOrigin, "did:key:") + if !ok { return "", errNoKeyInKeyID } @@ -81,21 +93,30 @@ func (l *Listener) verifyEd25519RequestSignatureUsingKeyID(sig *httpsig.Signatur return "", errNoKeyInKeyID } - keyOrigin, err := ap.Origin(sig.KeyID) - if err != nil { - return "", fmt.Errorf("failed to get origin of %s: %w", sig.KeyID, err) - } - - suffix, ok := strings.CutPrefix(keyOrigin, "did:key:") - if !ok || suffix != m[1] { - return "", errors.New("key origin is not portable") + if suffix != m[1] { + return "", errNoKeyInKeyID } - raw, err := data.DecodeEd25519PublicKey(m[1]) + raw, err := data.DecodePublicKey(m[1]) if err != nil { return "", fmt.Errorf("failed to parse %s: %w", sig.KeyID, err) } + switch raw.(type) { + case ed25519.PublicKey: + if sig.Alg != "ed25519" { + return "", errNoKeyInKeyID + } + + case *mldsa44.PublicKey: + if sig.Alg != "ml-dsa-44" { + return "", errNoKeyInKeyID + } + + default: + return "", errNoKeyInKeyID + } + if err := sig.Verify(raw); err != nil { return "", fmt.Errorf("failed to verify message using %s: %w", sig.KeyID, err) } @@ -109,17 +130,17 @@ func (l *Listener) verifyRequestUsingKeyID(r *http.Request, body []byte) (*https return nil, "", err } - key, err := l.verifyEd25519RequestSignatureUsingKeyID(sig) + key, err := l.verifyRequestSignatureUsingKeyID(sig) return sig, key, err } -func (l *Listener) verifyRequest(r *http.Request, body []byte, flags ap.ResolverFlag, keys [2]httpsig.Key) (*httpsig.Signature, *ap.Actor, error) { +func (l *Listener) verifyRequest(r *http.Request, body []byte, flags ap.ResolverFlag, keys [3]httpsig.Key) (*httpsig.Signature, *ap.Actor, error) { sig, err := l.extractRequestSignature(r, body) if err != nil { return nil, nil, err } - if _, err := l.verifyEd25519RequestSignatureUsingKeyID(sig); err != nil && !errors.Is(err, errNoKeyInKeyID) { + if _, err := l.verifyRequestSignatureUsingKeyID(sig); err != nil && !errors.Is(err, errNoKeyInKeyID) { return nil, nil, err } else if err == nil { actor, err := l.Resolver.ResolveID(r.Context(), keys, sig.KeyID, flags) @@ -168,14 +189,14 @@ func (l *Listener) verifyRequest(r *http.Request, body []byte, flags ap.Resolver return sig, actor, nil } -func (l *Listener) verifyProof(ctx context.Context, activity *ap.Activity, raw []byte, flags ap.ResolverFlag, keys [2]httpsig.Key) (*ap.Actor, error) { +func (l *Listener) verifyProof(ctx context.Context, activity *ap.Activity, raw []byte, flags ap.ResolverFlag, keys [3]httpsig.Key) (*ap.Actor, error) { if m := ap.KeyRegex.FindStringSubmatch(activity.Proof.VerificationMethod); m != nil { if m2 := ap.GatewayURLRegex.FindStringSubmatch(activity.Actor); m2 != nil { if m2[1] != m[1] { return nil, fmt.Errorf("key %s does not belong to %s", m[1], activity.Actor) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return nil, fmt.Errorf("failed to decode key %s to verify proof: %w", activity.Proof.VerificationMethod, err) } diff --git a/front/accept.go b/front/accept.go index 35c3d303..8549bda3 100644 --- a/front/accept.go +++ b/front/accept.go @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/front/text" ) @@ -29,24 +30,24 @@ func (h *Handler) accept(w text.Writer, r *Request, args ...string) { return } - follower := "https://" + args[1] + arg := args[1] tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { - r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) + r.Log.Warn("Failed to accept follow request", "follower", arg, "error", err) w.Error() return } defer tx.Rollback() - var followID string + var follower, followID string if err := tx.QueryRowContext( r.Context, - `SELECT id FROM follows WHERE followed = ? AND follower = ? AND accepted IS NULL`, + `SELECT follows.follower, follows.id FROM follows JOIN persons ON persons.id = follows.follower WHERE follows.followed = $1 AND (persons.id = 'https://' || $2 OR persons.slug = $2) AND follows.accepted IS NULL`, r.User.ID, - follower, - ).Scan(&followID); errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Failed to fetch follow request to approve", "follower", follower) + arg, + ).Scan(&follower, &followID); errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to fetch follow request to approve", "follower", arg) w.Status(40, "No such follow request") return } else if err != nil { @@ -55,7 +56,7 @@ func (h *Handler) accept(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.AcceptFollow(r.Context, r.User, r.Keys[1], follower, followID, tx); err != nil { + if err := h.Inbox.AcceptFollow(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), follower, followID, tx); err != nil { r.Log.Warn("Failed to accept follow request", "follower", follower, "error", err) w.Error() return diff --git a/front/alias.go b/front/alias.go index 71689575..9633cf75 100644 --- a/front/alias.go +++ b/front/alias.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -72,11 +73,11 @@ func (h *Handler) alias(w text.Writer, r *Request, args ...string) { r.User.AlsoKnownAs.Add(actor.ID) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to update alias", "error", err) w.Error() return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(actor.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(actor.ID)) } diff --git a/front/avatar.go b/front/avatar.go index 71ae3125..4cd298d0 100644 --- a/front/avatar.go +++ b/front/avatar.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package front import ( "fmt" + "github.com/dimkr/tootik/proof" "io" "strconv" "time" @@ -144,7 +145,7 @@ func (h *Handler) uploadAvatar(w text.Writer, r *Request, args ...string) { } r.User.Updated.Time = now - if err := h.Inbox.UpdateActorTx(r.Context, tx, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActorTx(r.Context, tx, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to set avatar", "error", err) w.Error() return diff --git a/front/bio.go b/front/bio.go index 21c68141..ab3723f4 100644 --- a/front/bio.go +++ b/front/bio.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,6 +18,7 @@ package front import ( "fmt" + "github.com/dimkr/tootik/proof" "time" "unicode/utf8" @@ -94,7 +95,7 @@ func (h *Handler) doSetBio(w text.Writer, r *Request, readInput func(text.Writer r.User.Summary = plain.ToHTML(bio, nil) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to update bio", "error", err) w.Error() return diff --git a/front/bookmark.go b/front/bookmark.go index 95d18630..c285f758 100644 --- a/front/bookmark.go +++ b/front/bookmark.go @@ -29,7 +29,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { @@ -39,13 +39,13 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { } defer tx.Rollback() - var exists int + var postID sql.NullString if err := tx.QueryRowContext( r.Context, - `select exists ( - select 1 from notes + `select ( + select notes.id from notes where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and notes.deleted = 0 and ( notes.author = $2 or @@ -57,14 +57,14 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { ) )`, - postID, + arg, r.User.ID, - ).Scan(&exists); err != nil { - r.Log.Warn("Failed to check if bookmarked post exists", "post", postID, "error", err) + ).Scan(&postID); err != nil { + r.Log.Warn("Failed to check if bookmarked post exists", "post", arg, "error", err) w.Error() return - } else if exists == 0 { - r.Log.Info("Post was not found", "post", postID) + } else if !postID.Valid { + r.Log.Info("Post was not found", "post", arg) w.Status(40, "Post not found") return } @@ -80,7 +80,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { } if count >= h.Config.MaxBookmarksPerUser { - r.Log.Warn("User has reached bookmarks limit", "post", postID) + r.Log.Warn("User has reached bookmarks limit", "post", postID.String) w.Status(40, "Reached bookmarks limit") return } @@ -94,7 +94,7 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { } } - if _, err := tx.ExecContext(r.Context, `insert into bookmarks(note, by) values(?, ?)`, postID, r.User.ID); err != nil { + if _, err := tx.ExecContext(r.Context, `insert into bookmarks(note, by) values(?, ?)`, postID.String, r.User.ID); err != nil { r.Log.Warn("Failed to insert bookmark", "error", err) w.Error() return @@ -106,5 +106,5 @@ func (h *Handler) bookmark(w text.Writer, r *Request, args ...string) { return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/communities.go b/front/communities.go index 23ea80a0..32543d04 100644 --- a/front/communities.go +++ b/front/communities.go @@ -17,7 +17,6 @@ limitations under the License. package front import ( - "strings" "time" "github.com/dimkr/tootik/dbx" @@ -76,9 +75,9 @@ func (h *Handler) communities(w text.Writer, r *Request, args ...string) { for _, row := range rows { if r.User == nil { - w.Linkf("/outbox/"+strings.TrimPrefix(row.ID, "https://"), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) + w.Linkf("/outbox/"+idLink(row.ID), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) } else { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.ID, "https://"), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) + w.Linkf("/users/outbox/"+idLink(row.ID), "%s %s", time.Unix(row.Last, 0).Format(time.DateOnly), row.Username) } } } diff --git a/front/delete.go b/front/delete.go index 5ea3af07..0e57f841 100644 --- a/front/delete.go +++ b/front/delete.go @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" @@ -30,28 +31,28 @@ func (h *Handler) delete(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to delete a non-existing post", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where (id = 'https://' || $1 or slug = $1) and deleted = 0 and author in (select id from persons where cid = $2)`, arg, ap.Canonical(r.User.ID)).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to delete a non-existing post", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch post to delete", "post", postID, "error", err) + r.Log.Warn("Failed to fetch post to delete", "post", arg, "error", err) w.Error() return } - if err := h.Inbox.Delete(r.Context, r.User, r.Keys[1], ¬e); err != nil { + if err := h.Inbox.Delete(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), ¬e); err != nil { r.Log.Error("Failed to delete post", "note", note.ID, "error", err) w.Error() return } if r.User == nil { - w.Redirect("/view/" + args[1]) + w.Redirect("/view/" + arg) } else { - w.Redirect("/users/view/" + args[1]) + w.Redirect("/users/view/" + arg) } } diff --git a/front/edit.go b/front/edit.go index 75128665..fed204cb 100644 --- a/front/edit.go +++ b/front/edit.go @@ -32,15 +32,15 @@ func (h *Handler) doEdit(w text.Writer, r *Request, args []string, readInput inp return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = ? and deleted = 0 and author in (select id from persons where cid = ?)`, postID, ap.Canonical(r.User.ID)).Scan(¬e); errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to edit non-existing post", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where (id = 'https://' || $1 or slug = $1) and deleted = 0 and author in (select id from persons where cid = $2)`, arg, ap.Canonical(r.User.ID)).Scan(¬e); errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to edit non-existing post", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch post to edit", "post", postID, "error", err) + r.Log.Warn("Failed to fetch post to edit", "post", arg, "error", err) w.Error() return } @@ -53,7 +53,7 @@ func (h *Handler) doEdit(w text.Writer, r *Request, args []string, readInput inp var edits int if err := h.DB.QueryRowContext(r.Context, `select count(*) from outbox where activity->>'$.object.id' = ? and sender = ? and (activity->>'$.type' = 'Update' or activity->>'$.type' = 'Create')`, note.ID, r.User.ID).Scan(&edits); err != nil { - r.Log.Warn("Failed to count post edits", "post", postID, "error", err) + r.Log.Warn("Failed to count post edits", "post", note.ID, "error", err) w.Error() return } diff --git a/front/follow.go b/front/follow.go index 426a94fd..bdc3229d 100644 --- a/front/follow.go +++ b/front/follow.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/front/text" ) @@ -29,17 +30,17 @@ func (h *Handler) follow(w text.Writer, r *Request, args ...string) { return } - followed := "https://" + args[1] + arg := args[1] - var exists int - if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from persons where id = ?)`, followed).Scan(&exists); err != nil { - r.Log.Warn("Failed to check if user exists", "followed", followed, "error", err) + var followed string + if err := h.DB.QueryRowContext(r.Context, `select id from persons where id = 'https://' || $1 or slug = $1`, arg).Scan(&followed); err != nil && !errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to check if user exists", "followed", arg, "error", err) w.Error() return } - if exists == 0 { - r.Log.Warn("Cannot follow a non-existing user", "followed", followed) + if followed == "" { + r.Log.Warn("Cannot follow a non-existing user", "followed", arg) w.Status(40, "No such user") return } @@ -69,11 +70,11 @@ func (h *Handler) follow(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.Follow(r.Context, r.User, r.Keys[1], followed); err != nil { + if err := h.Inbox.Follow(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), followed); err != nil { r.Log.Warn("Failed to follow user", "followed", followed, "error", err) w.Error() return } - w.Redirectf("/users/outbox/" + args[1]) + w.Redirectf("/users/outbox/" + arg) } diff --git a/front/followers.go b/front/followers.go index 5117d3d1..0d56d735 100644 --- a/front/followers.go +++ b/front/followers.go @@ -18,6 +18,7 @@ package front import ( "database/sql" + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -52,7 +53,7 @@ func (h *Handler) followers(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Warn("Failed to toggle manual approval", "error", err) w.Error() return diff --git a/front/follows.go b/front/follows.go index 3aad0641..7ec94077 100644 --- a/front/follows.go +++ b/front/follows.go @@ -18,7 +18,6 @@ package front import ( "database/sql" - "strings" "time" "github.com/dimkr/tootik/ap" @@ -96,17 +95,17 @@ func (h *Handler) follows(w text.Writer, r *Request, args ...string) { displayName := h.getActorDisplayName(&row.Actor) if !row.Accepted.Valid && row.Last.Valid { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s %s - pending approval", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s %s - pending approval", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else if !row.Accepted.Valid { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s - pending approval", displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s - pending approval", displayName) } else if row.Last.Valid && row.Accepted.Int32 == 1 { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s %s", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s %s", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else if row.Accepted.Int32 == 1 { - w.Link("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), displayName) + w.Link("/users/outbox/"+idLink(row.Actor.ID), displayName) } else if row.Last.Valid { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s %s - rejected", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s %s - rejected", time.Unix(row.Last.Int64*(60*60*24), 0).Format(time.DateOnly), displayName) } else { - w.Linkf("/users/outbox/"+strings.TrimPrefix(row.Actor.ID, "https://"), "%s - rejected", displayName) + w.Linkf("/users/outbox/"+idLink(row.Actor.ID), "%s - rejected", displayName) } } diff --git a/front/fts.go b/front/fts.go index a466c211..4117139a 100644 --- a/front/fts.go +++ b/front/fts.go @@ -62,7 +62,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { select json(notes.object), json(authors.actor), json(groups.actor), notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, json(parent_authors.actor) from (select rowid, rank from notesfts where content match $1 order by rank limit $2) top join notes on - notes.rowid = top.rowid + notes.pk = top.rowid join persons authors on authors.id = notes.author and coalesce(authors.actor->>'$.discoverable', 1) left join notes parent_notes on @@ -94,7 +94,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 2 as aud from top join notes on - notes.rowid = top.rowid + notes.pk = top.rowid where notes.public = 1 union all @@ -114,7 +114,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { ) join top on - top.rowid = notes.rowid + top.rowid = notes.pk where follows.follower = $3 and follows.accepted = 1 @@ -122,7 +122,7 @@ func (h *Handler) fts(w text.Writer, r *Request, args ...string) { select notes.id, notes.object, notes.author, notes.inserted, notes.nreplies, notes.nquotes, notes.nshares, top.rank, 0 as aud from top join notes on - notes.rowid = top.rowid + notes.pk = top.rowid where ( $3 in (notes.cc0, notes.to0, notes.cc1, notes.to1, notes.cc2, notes.to2) or diff --git a/front/gemini/gemini.go b/front/gemini/gemini.go index 53bdacc7..61a8c02d 100644 --- a/front/gemini/gemini.go +++ b/front/gemini/gemini.go @@ -40,6 +40,7 @@ import ( "sync" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/danger" @@ -60,52 +61,55 @@ type Listener struct { KeyPath string } -func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Config) (*ap.Actor, [2]httpsig.Key, error) { +func (gl *Listener) getUser(ctx context.Context, tlsConn *tls.Conn, cfg *cfg.Config) (*ap.Actor, [3]httpsig.Key, error) { state := tlsConn.ConnectionState() if len(state.PeerCertificates) == 0 { - return nil, [2]httpsig.Key{}, nil + return nil, [3]httpsig.Key{}, nil } clientCert := state.PeerCertificates[0] if time.Now().After(clientCert.NotAfter) { - return nil, [2]httpsig.Key{}, nil + return nil, [3]httpsig.Key{}, nil } certHash := fmt.Sprintf("%X", sha256.Sum256(clientCert.Raw)) - var rsaPrivKeyDer, ed25519PrivKey []byte + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte var actor ap.Actor var approved int - if err := gl.DB.QueryRowContext(ctx, `select json(persons.actor), persons.rsaprivkey, persons.ed25519privkey, certificates.approved from certificates join persons on persons.actor->>'$.preferredUsername' = certificates.user where persons.host = ? and certificates.hash = ? and certificates.expires > unixepoch()`, gl.Domain, certHash).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { + if err := gl.DB.QueryRowContext(ctx, `select json(persons.actor), persons.rsaprivkey, persons.ed25519seed, persons.mldsa44seed, certificates.approved from certificates join persons on persons.actor->>'$.preferredUsername' = certificates.user where persons.host = ? and certificates.hash = ? and certificates.expires > unixepoch()`, gl.Domain, certHash).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed, &approved); err != nil && errors.Is(err, sql.ErrNoRows) { if cfg.RequireInvitation { var accepted int if err := gl.DB.QueryRowContext(ctx, `select exists (select 1 from invites where certhash = ?)`, certHash).Scan(&accepted); err != nil { - return nil, [2]httpsig.Key{}, err + return nil, [3]httpsig.Key{}, err } else if accepted == 0 { - return nil, [2]httpsig.Key{}, front.ErrNotInvited + return nil, [3]httpsig.Key{}, front.ErrNotInvited } } - return nil, [2]httpsig.Key{}, front.ErrNotRegistered + return nil, [3]httpsig.Key{}, front.ErrNotRegistered } else if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, err) } if approved == 0 { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, front.ErrNotApproved) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to fetch user for %s: %w", certHash, front.ErrNotApproved) } rsaPrivKey, err := x509.ParsePKCS1PrivateKey(rsaPrivKeyDer) if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to parse RSA private key for %s: %w", certHash, err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to parse RSA private key for %s: %w", certHash, err) } + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + slog.Debug("Found existing user", "hash", certHash, "user", actor.ID) - return &actor, [2]httpsig.Key{ + return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, nil } diff --git a/front/id.go b/front/id.go new file mode 100644 index 00000000..033ef362 --- /dev/null +++ b/front/id.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package front + +import ( + "strings" + + "github.com/dimkr/tootik/ap" +) + +func idLink(id string) string { + if len(id) < 80 { + return strings.TrimPrefix(id, "https://") + } + + return ap.Slug(id) +} diff --git a/front/invitations.go b/front/invitations.go index 5b49c0d2..fb59e51b 100644 --- a/front/invitations.go +++ b/front/invitations.go @@ -21,7 +21,6 @@ import ( "crypto/tls" "database/sql" "fmt" - "strings" "time" "github.com/dimkr/tootik/ap" @@ -82,7 +81,7 @@ func (h *Handler) invitations(w text.Writer, r *Request, args ...string) { if row.Actor.Valid { w.Text("Used: " + time.Unix(row.ActorInserted.Int64, 0).Format(time.DateOnly)) - w.Link("/users/outbox/"+strings.TrimPrefix(row.Actor.V.ID, "https://"), "Used by: "+row.Actor.V.PreferredUsername) + w.Link("/users/outbox/"+idLink(row.Actor.V.ID), "Used by: "+row.Actor.V.PreferredUsername) } else { if expires := inserted.Add(h.Config.InvitationTimeout); now.After(expires) { w.Text("Expired: " + expires.Format(time.DateOnly)) diff --git a/front/me.go b/front/me.go index ea37833e..2f732f93 100644 --- a/front/me.go +++ b/front/me.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,8 +17,6 @@ limitations under the License. package front import ( - "strings" - "github.com/dimkr/tootik/front/text" ) @@ -28,5 +26,5 @@ func me(w text.Writer, r *Request, args ...string) { return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(r.User.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(r.User.ID)) } diff --git a/front/metadata.go b/front/metadata.go index 0d593750..3f204b3f 100644 --- a/front/metadata.go +++ b/front/metadata.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "html" "net/url" "regexp" @@ -126,7 +127,7 @@ func (h *Handler) metadataAdd(w text.Writer, r *Request, args ...string) { r.User.Attachment = append(r.User.Attachment, attachment) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to add metadata field", "name", attachment.Name, "error", err) w.Error() return @@ -171,7 +172,7 @@ found: r.User.Attachment = slices.Delete(r.User.Attachment, id, id+1) r.User.Updated.Time = time.Now() - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to remove metadata field", "key", key, "id", id, "error", err) w.Error() return diff --git a/front/move.go b/front/move.go index 2572a707..6fa1fd3e 100644 --- a/front/move.go +++ b/front/move.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -87,11 +88,11 @@ func (h *Handler) move(w text.Writer, r *Request, args ...string) { return } - if err := h.Inbox.Move(r.Context, r.User, r.Keys[1], actor.ID); err != nil { + if err := h.Inbox.Move(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), actor.ID); err != nil { r.Log.Error("Failed to move user", "error", err) w.Error() return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(actor.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(actor.ID)) } diff --git a/front/name.go b/front/name.go index 8df8818d..ecb4bfe6 100644 --- a/front/name.go +++ b/front/name.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,6 +17,7 @@ limitations under the License. package front import ( + "github.com/dimkr/tootik/proof" "net/url" "strings" "time" @@ -92,7 +93,7 @@ func (h *Handler) setName(w text.Writer, r *Request, args ...string) { r.User.Name = plainDisplayName r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to update name", "error", err) w.Error() return diff --git a/front/outbox.go b/front/outbox.go index 8245763b..2bd72e75 100644 --- a/front/outbox.go +++ b/front/outbox.go @@ -20,7 +20,6 @@ import ( "database/sql" "errors" "fmt" - "strings" "time" "github.com/dimkr/tootik/ap" @@ -52,15 +51,15 @@ func writeMetadataField(field ap.Attachment, w text.Writer) { } func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { - actorID := "https://" + args[1] + arg := args[1] var actor ap.Actor - if err := h.DB.QueryRowContext(r.Context, `select json(actor) from persons where id = ?`, actorID).Scan(&actor); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Info("Person was not found", "actor", actorID) + if err := h.DB.QueryRowContext(r.Context, `select json(actor) from persons where id = 'https://' || $1 or slug = $1`, arg).Scan(&actor); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Info("Person was not found", "actor", arg) w.Status(40, "User not found") return } else if err != nil { - r.Log.Warn("Failed to find person by ID", "actor", actorID, "error", err) + r.Log.Warn("Failed to find person by ID", "actor", arg, "error", err) w.Error() return } @@ -72,7 +71,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { return } - r.Log.Info("Viewing outbox", "actor", actorID, "offset", offset) + r.Log.Info("Viewing outbox", "actor", actor.ID, "offset", offset) var rows *sql.Rows if actor.Type == ap.Group && r.User == nil { @@ -94,7 +93,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) page join persons authors on authors.id = page.author order by page.pulse / 86400 desc, page.nreplies desc, page.pulse desc`, - actorID, + actor.ID, h.Config.PostsPerPage, offset, ) @@ -129,7 +128,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { ) page join persons authors on authors.id = page.author order by page.pulse / 86400 desc, page.nreplies desc, page.pulse desc`, - actorID, + actor.ID, r.User.ID, h.Config.PostsPerPage, offset, @@ -154,11 +153,11 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { left join persons parent_authors on parent_authors.id = parent_notes.author group by u.id order by max(u.inserted) desc limit $2 offset $3`, - actorID, + actor.ID, h.Config.PostsPerPage, offset, ) - } else if r.User.ID == actorID { + } else if r.User.ID == actor.ID { // users can see all their posts rows, err = h.DB.QueryContext( r.Context, @@ -177,7 +176,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { left join persons parent_authors on parent_authors.id = parent_notes.author group by u.id order by max(u.inserted) desc limit $2 offset $3`, - actorID, + actor.ID, h.Config.PostsPerPage, offset, ) @@ -222,14 +221,14 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { left join notes parent_notes on parent_notes.id = page.object->>'$.inReplyTo' left join persons parent_authors on parent_authors.id = parent_notes.author order by page.inserted desc`, - actorID, + actor.ID, r.User.ID, h.Config.PostsPerPage, offset, ) } if err != nil { - r.Log.Warn("Failed to fetch posts", "actor", actorID, "error", err) + r.Log.Warn("Failed to fetch posts", "actor", actor.ID, "error", err) w.Error() return } @@ -266,7 +265,7 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { } if offset == 0 && actor.MovedTo != "" { - w.Linkf("/users/outbox/"+strings.TrimPrefix(actor.MovedTo, "https://"), "Moved to %s", actor.MovedTo) + w.Linkf("/users/outbox/"+idLink(actor.MovedTo), "Moved to %s", actor.MovedTo) } if offset == 0 { @@ -324,21 +323,21 @@ func (h *Handler) userOutbox(w text.Writer, r *Request, args ...string) { w.Linkf(fmt.Sprintf("%s?%d", r.URL.Path, offset+h.Config.PostsPerPage), "Next page (%d-%d)", offset+h.Config.PostsPerPage, offset+2*h.Config.PostsPerPage) } - if r.User != nil && actorID != r.User.ID { + if r.User != nil && actor.ID != r.User.ID { w.Empty() w.Subtitle("Actions") var accepted sql.NullInt32 - if err := h.DB.QueryRowContext(r.Context, `select accepted from follows where follower = ? and followed = ?`, r.User.ID, actorID).Scan(&accepted); actor.ManuallyApprovesFollowers && errors.Is(err, sql.ErrNoRows) { - w.Linkf("/users/follow/"+strings.TrimPrefix(actorID, "https://"), "⚡ Follow %s (requires approval)", actor.PreferredUsername) + if err := h.DB.QueryRowContext(r.Context, `select accepted from follows where follower = ? and followed = ?`, r.User.ID, actor.ID).Scan(&accepted); actor.ManuallyApprovesFollowers && errors.Is(err, sql.ErrNoRows) { + w.Linkf("/users/follow/"+idLink(actor.ID), "⚡ Follow %s (requires approval)", actor.PreferredUsername) } else if errors.Is(err, sql.ErrNoRows) { - w.Linkf("/users/follow/"+strings.TrimPrefix(actorID, "https://"), "⚡ Follow %s", actor.PreferredUsername) + w.Linkf("/users/follow/"+idLink(actor.ID), "⚡ Follow %s", actor.PreferredUsername) } else if err != nil { - r.Log.Warn("Failed to check if user is followed", "actor", actorID, "error", err) + r.Log.Warn("Failed to check if user is followed", "actor", actor.ID, "error", err) } else if accepted.Valid && accepted.Int32 == 0 { - w.Linkf("/users/unfollow/"+strings.TrimPrefix(actorID, "https://"), "🔌 Unfollow %s (rejected)", actor.PreferredUsername) + w.Linkf("/users/unfollow/"+idLink(actor.ID), "🔌 Unfollow %s (rejected)", actor.PreferredUsername) } else { - w.Linkf("/users/unfollow/"+strings.TrimPrefix(actorID, "https://"), "🔌 Unfollow %s", actor.PreferredUsername) + w.Linkf("/users/unfollow/"+idLink(actor.ID), "🔌 Unfollow %s", actor.PreferredUsername) } } } diff --git a/front/portability.go b/front/portability.go index f8b74353..ce0f3d1c 100644 --- a/front/portability.go +++ b/front/portability.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,9 +24,11 @@ import ( "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/front/text" + "github.com/dimkr/tootik/proof" ) var gatewayRegex = regexp.MustCompile(`[a-z0-9-]+(?:\.[a-z0-9-]+)+`) @@ -42,14 +44,26 @@ func (h *Handler) portability(w text.Writer, r *Request, args ...string) { return } + var algo, priv string + switch v := proof.SigningKey(r.User.ID, r.Keys).PrivateKey.(type) { + case ed25519.PrivateKey: + algo, priv = "Ed25519", data.EncodeEd25519PrivateKey(v) + case *mldsa44.PrivateKey: + algo, priv = "ML-DSA-44", data.EncodeMLDSA44PrivateKey(v) + default: + r.Log.Warn("Account has no exportable private key", "user", r.User.ID) + w.Error() + return + } + w.OK() w.Title("🚲 Data Portability") w.Subtitle("Private Key") - w.Text("To register this account on another server, use this Ed25519 private key:") + w.Textf("To register this account on another server, use this %s private key:", algo) w.Empty() if r.URL.RawQuery == "show" { - w.Text(data.EncodeEd25519PrivateKey(r.Keys[1].PrivateKey.(ed25519.PrivateKey))) + w.Text(priv) } else { w.Text("********") w.Link("/users/portability?show", "Show") @@ -138,7 +152,7 @@ func (h *Handler) gatewayAdd(w text.Writer, r *Request, args ...string) { r.User.Gateways = append(r.User.Gateways, "https://"+gw) r.User.Updated.Time = now - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to add gateway", "gateway", gw, "error", err) w.Error() return @@ -195,7 +209,7 @@ found: r.User.Gateways = slices.Delete(r.User.Gateways, id, id+1) r.User.Updated.Time = time.Now() - if err := h.Inbox.UpdateActor(r.Context, r.User, r.Keys[1]); err != nil { + if err := h.Inbox.UpdateActor(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys)); err != nil { r.Log.Error("Failed to remove gateway", "gateway", gw, "id", id, "error", err) w.Error() return diff --git a/front/post.go b/front/post.go index 0ecadcd6..44806b41 100644 --- a/front/post.go +++ b/front/post.go @@ -20,6 +20,7 @@ import ( "database/sql" "errors" "fmt" + "github.com/dimkr/tootik/proof" "regexp" "strings" "time" @@ -136,7 +137,7 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo parents.object->>'$.attributedTo' = persons.id or exists (select 1 from json_each(parents.object->'$.to') where value = persons.id) or exists (select 1 from json_each(parents.object->'$.cc') where value = persons.id) - ) or ed25519privkey is not null + ) or ed25519seed is not null or id in (select followed from follows where follower = $4 and accepted = 1) ) limit 2 @@ -176,7 +177,7 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo parents.object->>'$.attributedTo' = persons.id or exists (select 1 from json_each(parents.object->'$.to') where value = persons.id) or exists (select 1 from json_each(parents.object->'$.cc') where value = persons.id) - ) or ed25519privkey is not null + ) or ed25519seed is not null or id in (select followed from follows where follower = $5 and accepted = 1) ) limit 2 @@ -197,7 +198,7 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo actor->>'$.preferredUsername' = $1 and ((actor->>'$.type' = 'Group') is $2) and ( - ed25519privkey is not null + ed25519seed is not null or id in (select followed from follows where follower = $3 and accepted = 1) ) limit 2 @@ -383,9 +384,9 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo note.Updated = now - err = h.Inbox.UpdateNote(r.Context, r.User, r.Keys[1], ¬e) + err = h.Inbox.UpdateNote(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), ¬e) } else { - err = h.Inbox.Create(r.Context, h.Config, ¬e, r.User, r.Keys[1]) + err = h.Inbox.Create(r.Context, h.Config, ¬e, r.User, proof.SigningKey(r.User.ID, r.Keys)) } if err != nil { r.Log.Error("Failed to insert post", "error", err) @@ -398,8 +399,8 @@ func (h *Handler) post(w text.Writer, r *Request, oldNote *ap.Object, inReplyTo } if r.URL.Scheme == "titan" { - w.Redirectf("gemini://%s/users/view/%s", h.Domain, strings.TrimPrefix(postID, "https://")) + w.Redirectf("gemini://%s/users/view/%s", h.Domain, idLink(postID)) } else { - w.Redirectf("/users/view/%s", strings.TrimPrefix(postID, "https://")) + w.Redirectf("/users/view/%s", idLink(postID)) } } diff --git a/front/print.go b/front/print.go index 6a714eec..3faffaaf 100644 --- a/front/print.go +++ b/front/print.go @@ -310,9 +310,9 @@ func (h *Handler) printCompactNote( } if r.User == nil { - w.Link("/view/"+strings.TrimPrefix(note.ID, "https://"), title.String()) + w.Link("/view/"+idLink(note.ID), title.String()) } else { - w.Link("/users/view/"+strings.TrimPrefix(note.ID, "https://"), title.String()) + w.Link("/users/view/"+idLink(note.ID), title.String()) } for _, line := range contentLines { diff --git a/front/register.go b/front/register.go index 93b40c4f..bb8d625d 100644 --- a/front/register.go +++ b/front/register.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,8 +20,10 @@ import ( "crypto/ed25519" "crypto/tls" "database/sql" + "reflect" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/data" "github.com/dimkr/tootik/front/text" @@ -116,16 +118,24 @@ func (h *Handler) register(w text.Writer, r *Request, args ...string) { } default: - key, err := data.DecodeEd25519PrivateKey(r.URL.RawQuery) + key, err := data.DecodePrivateKey(r.URL.RawQuery) if err != nil { - r.Log.Warn("Failed to decode Ed25519 private key", "name", userName, "error", err) + r.Log.Warn("Failed to decode private key", "name", userName, "error", err) w.Statusf(40, "Invalid key: %s", err.Error()) return } - if _, _, err := user.CreatePortableWithKey(r.Context, h.Domain, h.DB, h.Config, userName, ap.Person, clientCert, key, key.Public().(ed25519.PublicKey)); err != nil { - r.Log.Warn("Failed to create new portable user", "name", userName, "error", err) - w.Status(40, "Failed to create new user") + switch key.(type) { + case ed25519.PrivateKey, *mldsa44.PrivateKey: + if _, _, err := user.CreatePortableWithKey(r.Context, h.Domain, h.DB, h.Config, userName, ap.Person, clientCert, key); err != nil { + r.Log.Warn("Failed to create new portable user", "name", userName, "error", err) + w.Status(40, "Failed to create new user") + return + } + + default: + r.Log.Warn("Key type is unsupported", "name", userName, "type", reflect.TypeOf(key).String()) + w.Status(40, "Invalid key type") return } } diff --git a/front/reject.go b/front/reject.go index 0bc5ccf1..7dced80c 100644 --- a/front/reject.go +++ b/front/reject.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -20,6 +20,8 @@ import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" + "github.com/dimkr/tootik/front/text" ) @@ -29,33 +31,33 @@ func (h *Handler) reject(w text.Writer, r *Request, args ...string) { return } - follower := "https://" + args[1] + arg := args[1] tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { - r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) + r.Log.Warn("Failed to reject follow request", "follower", arg, "error", err) w.Error() return } defer tx.Rollback() - var followID string + var follower, followID string if err := tx.QueryRowContext( r.Context, - `SELECT id FROM follows WHERE follower = ? AND followed = ?`, - follower, + `SELECT follows.follower, follows.id FROM follows JOIN persons ON persons.id = follows.follower WHERE (persons.id = 'https://' || $1 OR persons.slug = $1) AND follows.followed = $2`, + arg, r.User.ID, - ).Scan(&followID); errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Failed to fetch follow request to reject", "follower", follower) + ).Scan(&follower, &followID); errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Failed to fetch follow request to reject", "follower", arg) w.Status(40, "No such follow request") return } else if err != nil { - r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) + r.Log.Warn("Failed to reject follow request", "follower", arg, "error", err) w.Error() return } - if err := h.Inbox.Reject(r.Context, r.User, r.Keys[1], follower, followID, tx); err != nil { + if err := h.Inbox.Reject(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), follower, followID, tx); err != nil { r.Log.Warn("Failed to reject follow request", "follower", follower, "error", err) w.Error() return diff --git a/front/reply.go b/front/reply.go index 819d83a7..6c818d55 100644 --- a/front/reply.go +++ b/front/reply.go @@ -30,7 +30,7 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object if err := h.DB.QueryRowContext( @@ -39,7 +39,7 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b select json(notes.object) from notes join persons on persons.id = notes.author where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and notes.deleted = 0 and ( notes.public = 1 or @@ -63,14 +63,14 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b ) ) `, - postID, + arg, r.User.ID, ).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Post does not exist", "post", postID) + r.Log.Warn("Post does not exist", "post", arg) w.Status(40, "Post not found") return } else if err != nil { - r.Log.Warn("Failed to find post by ID", "post", postID, "error", err) + r.Log.Warn("Failed to find post by ID", "post", arg, "error", err) w.Error() return } @@ -82,12 +82,12 @@ func (h *Handler) replyOrQuote(w text.Writer, r *Request, args []string, quote b r.Log.Info("Quoting post", "post", note.ID) if !note.CanQuote() { - r.Log.Warn("Post cannot be quoted", "post", postID) + r.Log.Warn("Post cannot be quoted", "post", note.ID) w.Status(40, "Post cannot be quoted") return } - note.Quote = postID + note.Quote = note.ID to.Add(note.AttributedTo) cc.Add(r.User.Followers) diff --git a/front/request.go b/front/request.go index c2fed2ae..5d7f1f5a 100644 --- a/front/request.go +++ b/front/request.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -44,5 +44,5 @@ type Request struct { User *ap.Actor // Keys optionally specifies the signing keys associated with User. - Keys [2]httpsig.Key + Keys [3]httpsig.Key } diff --git a/front/resolve.go b/front/resolve.go index 0b95f1a1..cd72baca 100644 --- a/front/resolve.go +++ b/front/resolve.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +19,6 @@ package front import ( "net/url" "regexp" - "strings" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" @@ -72,5 +71,5 @@ func (h *Handler) resolve(w text.Writer, r *Request, args ...string) { return } - w.Redirect("/users/outbox/" + strings.TrimPrefix(person.ID, "https://")) + w.Redirect("/users/outbox/" + idLink(person.ID)) } diff --git a/front/share.go b/front/share.go index bd3671a1..8782aba8 100644 --- a/front/share.go +++ b/front/share.go @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "time" "github.com/dimkr/tootik/ap" @@ -48,15 +49,15 @@ func (h *Handler) share(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] var note ap.Object - if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where id = $1 and deleted = 0 and public = 1 and author != $2 and not exists (select 1 from shares where note = notes.id and by = $2)`, postID, r.User.ID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to share non-existing post", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(object) from notes where (id = 'https://' || $1 or slug = $1) and deleted = 0 and public = 1 and author != $2 and not exists (select 1 from shares where note = notes.id and by = $2)`, arg, r.User.ID).Scan(¬e); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to share non-existing post", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch post to share", "post", postID, "error", err) + r.Log.Warn("Failed to fetch post to share", "post", arg, "error", err) w.Error() return } @@ -73,23 +74,23 @@ func (h *Handler) share(w text.Writer, r *Request, args ...string) { tx, err := h.DB.BeginTx(r.Context, nil) if err != nil { - r.Log.Warn("Failed to share post", "post", postID, "error", err) + r.Log.Warn("Failed to share post", "post", note.ID, "error", err) w.Error() return } defer tx.Rollback() - if err := h.Inbox.Announce(r.Context, tx, r.User, r.Keys[1], ¬e); err != nil { - r.Log.Warn("Failed to share post", "post", postID, "error", err) + if err := h.Inbox.Announce(r.Context, tx, r.User, proof.SigningKey(r.User.ID, r.Keys), ¬e); err != nil { + r.Log.Warn("Failed to share post", "post", note.ID, "error", err) w.Error() return } if err := tx.Commit(); err != nil { - r.Log.Warn("Failed to share post", "post", postID, "error", err) + r.Log.Warn("Failed to share post", "post", note.ID, "error", err) w.Error() return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/shell.go b/front/shell.go index f8c4cd3e..de110aae 100644 --- a/front/shell.go +++ b/front/shell.go @@ -25,6 +25,7 @@ import ( "log/slog" "net/url" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text/gmi" "github.com/dimkr/tootik/httpsig" @@ -39,12 +40,12 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { } var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte if err := h.DB.QueryRowContext( ctx, - `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = ? and ed25519privkey is not null`, + `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where actor->>'$.preferredUsername' = ? and ed25519seed is not null`, user, - ).Scan(&actor, &rsaPrivKeyDer, &ed25519PrivKey); err != nil { + ).Scan(&actor, &rsaPrivKeyDer, &ed25519Seed, &mldsa44Seed); err != nil { panic(err) } @@ -53,6 +54,8 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { panic(err) } + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + var buf bytes.Buffer return shell.Run(ctx, domain, u, func(ctx context.Context, u *url.URL) (*url.URL, string, error) { @@ -65,9 +68,10 @@ func (h *Handler) Shell(ctx context.Context, user, domain string) error { URL: u, Log: slog.Default(), User: &actor, - Keys: [2]httpsig.Key{ + Keys: [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, }, w, diff --git a/front/status.go b/front/status.go index 8498aa3c..cfc2ec58 100644 --- a/front/status.go +++ b/front/status.go @@ -95,25 +95,28 @@ func (h *Handler) getActiveUsersGraph(r *Request) string { } func (h *Handler) getInstanceCapabilitiesGraph(r *Request) string { - keys := make([]string, 6) - values := make([]int64, 6) + keys := make([]string, 7) + values := make([]int64, 7) return h.getGraph( r, keys, values, ` - select 'RFC9421 with Ed25519', (select count(*) from servers where capabilities & $1 > 0) + select 'RFC9421 with ML-DSA-44', (select count(*) from servers where capabilities & $1 > 0) + union all + select 'RFC9421 with Ed25519', (select count(*) from servers where capabilities & $2 > 0) union all - select 'RFC9421 with RSA but without Ed25519', (select count(*) from servers where capabilities & ($1 | $2) = $2) + select 'RFC9421 with RSA but without Ed25519 or ML-DSA-44', (select count(*) from servers where capabilities & ($1 | $2 | $3) = $3) union all - select 'RFC9421 without draft-cavage-http-signatures', (select count(*) from servers where capabilities & $3 = 0 and capabilities & ($1 | $2) > 0) + select 'RFC9421 without draft-cavage-http-signatures', (select count(*) from servers where capabilities & $4 = 0 and capabilities & ($1 | $2 | $3) > 0) union all - select 'draft-cavage-http-signatures without RFC9421', (select count(*) from servers where capabilities & $3 > 0 and capabilities & ($1 | $2) = 0) + select 'draft-cavage-http-signatures without RFC9421', (select count(*) from servers where capabilities & $4 > 0 and capabilities & ($1 | $2 | $3) = 0) union all - select 'draft-cavage-http-signatures', (select count(*) from servers where capabilities & $3 > 0) + select 'draft-cavage-http-signatures', (select count(*) from servers where capabilities & $4 > 0) union all select 'Total', (select count(*) from servers) `, + ap.RFC9421MLDSA44Signatures, ap.RFC9421Ed25519Signatures, ap.RFC9421RSASignatures, ap.CavageDraftSignatures, diff --git a/front/unbookmark.go b/front/unbookmark.go index ae14df01..adc68694 100644 --- a/front/unbookmark.go +++ b/front/unbookmark.go @@ -1,5 +1,5 @@ /* -Copyright 2024 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,13 +24,13 @@ func (h *Handler) unbookmark(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] - if _, err := h.DB.ExecContext(r.Context, `delete from bookmarks where note = ? and by = ?`, postID, r.User.ID); err != nil { - r.Log.Warn("Failed to delete bookmark", "post", postID, "error", err) + if _, err := h.DB.ExecContext(r.Context, `delete from bookmarks where note in (select id from notes where id = 'https://' || $1 or slug = $1) and by = $2`, arg, r.User.ID); err != nil { + r.Log.Warn("Failed to delete bookmark", "post", arg, "error", err) w.Error() return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/unfollow.go b/front/unfollow.go index 40966430..33bae350 100644 --- a/front/unfollow.go +++ b/front/unfollow.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/front/text" ) @@ -29,24 +30,24 @@ func (h *Handler) unfollow(w text.Writer, r *Request, args ...string) { return } - followed := "https://" + args[1] + arg := args[1] - var followID string - if err := h.DB.QueryRowContext(r.Context, `select follows.id from persons join follows on persons.id = follows.followed where persons.id = ? and follows.follower = ?`, followed, r.User.ID).Scan(&followID); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Cannot undo a non-existing follow", "followed", followed, "error", err) + var followed, followID string + if err := h.DB.QueryRowContext(r.Context, `select persons.id, follows.id from persons join follows on persons.id = follows.followed where (persons.id = 'https://' || $1 or persons.slug = $1) and follows.follower = $2`, arg, r.User.ID).Scan(&followed, &followID); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Cannot undo a non-existing follow", "followed", arg, "error", err) w.Status(40, "No such follow") return } else if err != nil { - r.Log.Warn("Failed to find followed user", "followed", followed, "error", err) + r.Log.Warn("Failed to find followed user", "followed", arg, "error", err) w.Error() return } - if err := h.Inbox.Unfollow(r.Context, r.User, r.Keys[1], followed, followID); err != nil { + if err := h.Inbox.Unfollow(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), followed, followID); err != nil { r.Log.Warn("Failed undo follow", "followed", followed, "error", err) w.Error() return } - w.Redirect("/users/outbox/" + args[1]) + w.Redirect("/users/outbox/" + arg) } diff --git a/front/unshare.go b/front/unshare.go index bf7fe33e..572b99b4 100644 --- a/front/unshare.go +++ b/front/unshare.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package front import ( "database/sql" "errors" + "github.com/dimkr/tootik/proof" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/front/text" @@ -30,24 +31,24 @@ func (h *Handler) unshare(w text.Writer, r *Request, args ...string) { return } - postID := "https://" + args[1] + arg := args[1] var share ap.Activity - if err := h.DB.QueryRowContext(r.Context, `select json(activity) from outbox where activity->>'$.actor' = $1 and sender = $1 and activity->>'$.type' = 'Announce' and activity->>'$.object' = $2`, r.User.ID, postID).Scan(&share); err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Warn("Attempted to unshare non-existing share", "post", postID, "error", err) + if err := h.DB.QueryRowContext(r.Context, `select json(activity) from outbox where activity->>'$.actor' = $1 and sender = $1 and activity->>'$.type' = 'Announce' and activity->>'$.object' in (select id from notes where id = 'https://' || $2 or slug = $2)`, r.User.ID, arg).Scan(&share); err != nil && errors.Is(err, sql.ErrNoRows) { + r.Log.Warn("Attempted to unshare non-existing share", "post", arg, "error", err) w.Error() return } else if err != nil { - r.Log.Warn("Failed to fetch share to unshare", "post", postID, "error", err) + r.Log.Warn("Failed to fetch share to unshare", "post", arg, "error", err) w.Error() return } - if err := h.Inbox.Undo(r.Context, r.User, r.Keys[1], &share); err != nil { - r.Log.Warn("Failed to unshare post", "post", postID, "error", err) + if err := h.Inbox.Undo(r.Context, r.User, proof.SigningKey(r.User.ID, r.Keys), &share); err != nil { + r.Log.Warn("Failed to unshare post", "post", arg, "error", err) w.Error() return } - w.Redirectf("/users/view/" + args[1]) + w.Redirectf("/users/view/" + arg) } diff --git a/front/user/app.go b/front/user/app.go index 2d26ce60..2cbbcb36 100644 --- a/front/user/app.go +++ b/front/user/app.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import ( "errors" "fmt" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/httpsig" @@ -31,30 +32,34 @@ import ( // CreateApplicationActor creates the special "actor" user. // This user is used to sign outgoing requests not initiated by a particular user. -func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config) (*ap.Actor, [2]httpsig.Key, error) { +func CreateApplicationActor(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config) (*ap.Actor, [3]httpsig.Key, error) { var actor ap.Actor - var rsaPrivKeyDer, ed25519PrivKey []byte + var rsaPrivKeyDer, ed25519Seed, mldsa44Seed []byte if err := db.QueryRowContext( ctx, - `select json(actor), rsaprivkey, ed25519privkey from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, + `select json(actor), rsaprivkey, ed25519seed, mldsa44seed from persons where actor->>'$.preferredUsername' = 'actor' and host = ?`, domain, ).Scan( &actor, &rsaPrivKeyDer, - &ed25519PrivKey, + &ed25519Seed, + &mldsa44Seed, ); errors.Is(err, sql.ErrNoRows) { return CreatePortable(ctx, domain, db, cfg, "actor", ap.Application, nil) } else if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to fetch application actor: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to fetch application actor: %w", err) } rsaPrivKey, err := x509.ParsePKCS1PrivateKey(rsaPrivKeyDer) if err != nil { - return nil, [2]httpsig.Key{}, err + return nil, [3]httpsig.Key{}, err } - return &actor, [2]httpsig.Key{ + _, mldsa44Priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + + return &actor, [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPrivKey}, - {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, + {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed)}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, }, err } diff --git a/front/user/create.go b/front/user/create.go index 36c0cd32..0227192b 100644 --- a/front/user/create.go +++ b/front/user/create.go @@ -28,6 +28,7 @@ import ( "fmt" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" @@ -66,14 +67,15 @@ func insertActor( actor *ap.Actor, rsaPriv *rsa.PrivateKey, ed25519Priv ed25519.PrivateKey, - keys [2]httpsig.Key, + mldsa44Priv *mldsa44.PrivateKey, + keys [3]httpsig.Key, cert *x509.Certificate, db *sql.DB, cfg *cfg.Config, ) error { if !cfg.DisableIntegrityProofs { var err error - if actor.Proof, err = proof.Create(keys[1], actor); err != nil { + if actor.Proof, err = proof.Create(proof.SigningKey(actor.ID, keys), actor); err != nil { return err } } @@ -86,11 +88,13 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT OR IGNORE INTO persons (id, actor, rsaprivkey, ed25519privkey) VALUES (?, JSONB(?), ?, ?)`, + `INSERT OR IGNORE INTO persons (slug, id, actor, rsaprivkey, ed25519seed, mldsa44seed) VALUES (?, ?, JSONB(?), ?, ?, ?)`, + ap.Slug(actor.ID), actor.ID, actor, x509.MarshalPKCS1PrivateKey(rsaPriv), ed25519Priv.Seed(), + mldsa44Priv.Seed(), ); err != nil { return err } @@ -120,18 +124,11 @@ func insertActor( if _, err := tx.ExecContext( ctx, - `INSERT OR IGNORE INTO keys (id, actor) VALUES (?, ?)`, - actor.PublicKey.ID, + `INSERT OR IGNORE INTO keys (actor, id) VALUES ($1, $2), ($1, $3), ($1, $4)`, actor.ID, - ); err != nil { - return err - } - - if _, err := tx.ExecContext( - ctx, - `INSERT OR IGNORE INTO keys (id, actor) VALUES (?, ?)`, + actor.PublicKey.ID, actor.AssertionMethod[0].ID, - actor.ID, + actor.AssertionMethod[1].ID, ); err != nil { return err } @@ -148,10 +145,10 @@ func CreatePortable( name string, actorType ap.ActorType, cert *x509.Certificate, -) (*ap.Actor, [2]httpsig.Key, error) { - pub, priv, err := ed25519.GenerateKey(nil) +) (*ap.Actor, [3]httpsig.Key, error) { + _, priv, err := ed25519.GenerateKey(nil) if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key for %s: %w", name, err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key for %s: %w", name, err) } return CreatePortableWithKey( @@ -163,7 +160,6 @@ func CreatePortable( actorType, cert, priv, - pub, ) } @@ -176,17 +172,55 @@ func CreatePortableWithKey( name string, actorType ap.ActorType, cert *x509.Certificate, - ed25519Priv ed25519.PrivateKey, - ed25519Pub ed25519.PublicKey, -) (*ap.Actor, [2]httpsig.Key, error) { + priv data.PrivateKey, +) (*ap.Actor, [3]httpsig.Key, error) { rsaPriv, rsaPubPem, err := generateRSAKey() if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) } - ed25519PubMultibase := data.EncodeEd25519PublicKey(ed25519Pub) + var ( + ed25519Priv ed25519.PrivateKey + ed25519Pub ed25519.PublicKey + + mldsa44Priv *mldsa44.PrivateKey + mldsa44Pub *mldsa44.PublicKey + + ed25519PubMultibase, mldsa44PubMultibase, didKeyMultibase string + ) + + switch v := priv.(type) { + case ed25519.PrivateKey: + mldsa44Pub, mldsa44Priv, err = mldsa44.GenerateKey(nil) + if err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate ML-DSA-44 key pair: %w", err) + } + + ed25519Priv = v + ed25519Pub = v.Public().(ed25519.PublicKey) + + ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) + mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) + didKeyMultibase = ed25519PubMultibase - id := fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, ed25519PubMultibase) + case *mldsa44.PrivateKey: + ed25519Pub, ed25519Priv, err = ed25519.GenerateKey(nil) + if err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) + } + + mldsa44Priv = v + mldsa44Pub = v.Public().(*mldsa44.PublicKey) + + mldsa44PubMultibase = data.EncodeMLDSA44Publickey(mldsa44Pub) + ed25519PubMultibase = data.EncodeEd25519PublicKey(ed25519Pub) + didKeyMultibase = mldsa44PubMultibase + + default: + return nil, [3]httpsig.Key{}, fmt.Errorf("unsupported key type: %T", priv) + } + + id := fmt.Sprintf("https://%s/.well-known/apgateway/did:key:%s/actor", domain, didKeyMultibase) actor := ap.Actor{ Context: []string{ "https://www.w3.org/ns/activitystreams", @@ -219,6 +253,12 @@ func CreatePortableWithKey( Controller: id, PublicKeyMultibase: ed25519PubMultibase, }, + { + ID: id + "#ml-dsa-44-key", + Type: "Multikey", + Controller: id, + PublicKeyMultibase: mldsa44PubMultibase, + }, }, } @@ -235,13 +275,14 @@ func CreatePortableWithKey( } } - keys := [2]httpsig.Key{ + keys := [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPriv}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519Priv}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } - if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, keys, cert, db, cfg); err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, mldsa44Priv, keys, cert, db, cfg); err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) } return &actor, keys, nil @@ -252,15 +293,20 @@ func CreatePortableWithKey( // Before v0.21.0, tootik offered users choice between 'traditional' and 'portable' accounts, and this function exists // only because it's used by tests, to test backward compatibility with older tootik versions and interoperability with // ActivityPub servers that don't support https://codeberg.org/fediverse/fep/src/branch/main/fep/ef61/fep-ef61.md. -func Create(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config, name string, cert *x509.Certificate) (*ap.Actor, [2]httpsig.Key, error) { +func Create(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config, name string, cert *x509.Certificate) (*ap.Actor, [3]httpsig.Key, error) { rsaPriv, rsaPubPem, err := generateRSAKey() if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate RSA key pair: %w", err) } ed25519Pub, ed25519Priv, err := ed25519.GenerateKey(nil) if err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate Ed25519 key pair: %w", err) + } + + mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) + if err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to generate ML-DSA-44 key pair: %w", err) } id := fmt.Sprintf("https://%s/user/%s", domain, name) @@ -298,18 +344,25 @@ func Create(ctx context.Context, domain string, db *sql.DB, cfg *cfg.Config, nam Controller: id, PublicKeyMultibase: data.EncodeEd25519PublicKey(ed25519Pub), }, + { + ID: fmt.Sprintf("https://%s/user/%s#ml-dsa-44-key", domain, name), + Type: "Multikey", + Controller: id, + PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), + }, }, ManuallyApprovesFollowers: false, Published: ap.Time{Time: time.Now()}, } - keys := [2]httpsig.Key{ + keys := [3]httpsig.Key{ {ID: actor.PublicKey.ID, PrivateKey: rsaPriv}, {ID: actor.AssertionMethod[0].ID, PrivateKey: ed25519Priv}, + {ID: actor.AssertionMethod[1].ID, PrivateKey: mldsa44Priv}, } - if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, keys, cert, db, cfg); err != nil { - return nil, [2]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) + if err := insertActor(ctx, &actor, rsaPriv, ed25519Priv, mldsa44Priv, keys, cert, db, cfg); err != nil { + return nil, [3]httpsig.Key{}, fmt.Errorf("failed to insert %s: %w", id, err) } return &actor, keys, nil diff --git a/front/view.go b/front/view.go index 934dd601..40f186b0 100644 --- a/front/view.go +++ b/front/view.go @@ -31,8 +31,6 @@ import ( ) func (h *Handler) view(w text.Writer, r *Request, args ...string) { - postID := "https://" + args[1] - offset, err := getOffset(r.URL) if err != nil { r.Log.Info("Failed to parse query", "error", err) @@ -40,7 +38,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { return } - r.Log.Info("Viewing post", "post", postID) + arg := args[1] + + r.Log.Info("Viewing post", "post", arg) var note ap.Object var author ap.Actor @@ -52,12 +52,12 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { ` select json(notes.object), json(persons.actor), json(groups.actor) from notes join persons on persons.id = notes.author - left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = $1) + left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and notes.public = 1 `, - postID, + arg, ).Scan(¬e, &author, &group) } else { err = h.DB.QueryRowContext( @@ -65,9 +65,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { ` select json(notes.object), json(persons.actor), json(groups.actor) from notes join persons on persons.id = notes.author - left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = $1) + left join (select id, actor from persons where actor->>'$.type' = 'Group') groups on exists (select 1 from shares where shares.by = groups.id and shares.note = notes.id) where - notes.id = $1 and + (notes.id = 'https://' || $1 or notes.slug = $1) and ( notes.public = 1 or notes.author = $2 or @@ -90,20 +90,22 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { ) ) `, - postID, + arg, r.User.ID, ).Scan(¬e, &author, &group) } if err != nil && errors.Is(err, sql.ErrNoRows) { - r.Log.Info("Post was not found", "post", postID) + r.Log.Info("Post was not found", "post", arg) w.Status(40, "Post not found") return } else if err != nil { - r.Log.Info("Failed to find post", "post", postID, "error", err) + r.Log.Info("Failed to find post", "post", arg, "error", err) w.Error() return } + r.Log.Info("Viewing post", "post", note.ID) + w.OK() if offset > 0 { @@ -162,9 +164,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Empty() if r.User == nil { - w.Link("/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[1 reply]") + w.Link("/view/"+idLink(rows[i].Note.InReplyTo), "[1 reply]") } else { - w.Link("/users/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[1 reply]") + w.Link("/users/view/"+idLink(rows[i].Note.InReplyTo), "[1 reply]") } w.Empty() @@ -172,9 +174,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Empty() if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-rows[i].Depth-1) + w.Linkf("/view/"+idLink(rows[i].Note.InReplyTo), "[%d replies]", rows[0].Depth-rows[i].Depth-1) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(rows[i].Note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-rows[i].Depth-1) + w.Linkf("/users/view/"+idLink(rows[i].Note.InReplyTo), "[%d replies]", rows[0].Depth-rows[i].Depth-1) } w.Empty() @@ -184,9 +186,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(rows[i].Note.ID, "https://"), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) + w.Linkf("/view/"+idLink(rows[i].Note.ID), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(rows[i].Note.ID, "https://"), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) + w.Linkf("/users/view/"+idLink(rows[i].Note.ID), "%s %s", rows[i].Note.Published.Time.Format(time.DateOnly), rows[i].Author.PreferredUsername) } contentLines, _ := h.getCompactNoteContent(&rows[i].Note) @@ -198,17 +200,17 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Empty() if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-1) + w.Linkf("/view/"+idLink(note.InReplyTo), "[%d replies]", rows[0].Depth-1) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[%d replies]", rows[0].Depth-1) + w.Linkf("/users/view/"+idLink(note.InReplyTo), "[%d replies]", rows[0].Depth-1) } } else if len(rows) == 1 && rows[0].Note.InReplyTo == "" && rows[0].Depth == 2 { w.Empty() if r.User == nil { - w.Link("/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[1 reply]") + w.Link("/view/"+idLink(note.InReplyTo), "[1 reply]") } else { - w.Link("/users/view/"+strings.TrimPrefix(note.InReplyTo, "https://"), "[1 reply]") + w.Link("/users/view/"+idLink(note.InReplyTo), "[1 reply]") } } else if rows[i].Depth == 0 { w.Empty() @@ -238,16 +240,16 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - links.Store("/outbox/"+strings.TrimPrefix(mentionID, "https://"), mentionUserName) + links.Store("/outbox/"+idLink(mentionID), mentionUserName) } else { - links.Store("/users/outbox/"+strings.TrimPrefix(mentionID, "https://"), mentionUserName) + links.Store("/users/outbox/"+idLink(mentionID), mentionUserName) } } if r.User == nil && group.Valid { - links.Store("/outbox/"+strings.TrimPrefix(group.V.ID, "https://"), "🔄 "+group.V.PreferredUsername) + links.Store("/outbox/"+idLink(group.V.ID), "🔄 "+group.V.PreferredUsername) } else if group.Valid { - links.Store("/users/outbox/"+strings.TrimPrefix(group.V.ID, "https://"), "🔄️ "+group.V.PreferredUsername) + links.Store("/users/outbox/"+idLink(group.V.ID), "🔄️ "+group.V.PreferredUsername) } else if note.IsPublic() { var rows *sql.Rows var err error @@ -332,7 +334,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { r.Log.Warn("Failed to query sharers", "error", err) } else { for _, row := range rows { - links.Store("/users/outbox/"+strings.TrimPrefix(row.SharerID, "https://"), "🔄 "+row.SharerName) + links.Store("/users/outbox/"+idLink(row.SharerID), "🔄 "+row.SharerName) } } rows.Close() @@ -362,9 +364,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } else { for _, row := range quotes { if r.User == nil { - links.Store("/view/"+strings.TrimPrefix(row.QuoteID, "https://"), "♻️ "+row.Quoter) + links.Store("/view/"+idLink(row.QuoteID), "♻️ "+row.Quoter) } else { - links.Store("/users/view/"+strings.TrimPrefix(row.QuoteID, "https://"), "♻️ "+row.Quoter) + links.Store("/users/view/"+idLink(row.QuoteID), "♻️ "+row.Quoter) } } } @@ -386,9 +388,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User == nil { - w.Link("/outbox/"+strings.TrimPrefix(author.ID, "https://"), author.PreferredUsername) + w.Link("/outbox/"+idLink(author.ID), author.PreferredUsername) } else { - w.Link("/users/outbox/"+strings.TrimPrefix(author.ID, "https://"), author.PreferredUsername) + w.Link("/users/outbox/"+idLink(author.ID), author.PreferredUsername) } for link, alt := range links.All() { @@ -418,11 +420,11 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { } if r.User != nil && ap.Canonical(note.AttributedTo) == ap.Canonical(r.User.ID) && note.Type != ap.Question && note.Name == "" { // polls and votes cannot be edited - w.Link("/users/edit/"+strings.TrimPrefix(note.ID, "https://"), "🩹 Edit") - w.Link(fmt.Sprintf("titan://%s/users/upload/edit/%s", h.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload edited post") + w.Link("/users/edit/"+arg, "🩹 Edit") + w.Link(fmt.Sprintf("titan://%s/users/upload/edit/%s", h.Domain, arg), "Upload edited post") } if r.User != nil && ap.Canonical(note.AttributedTo) == ap.Canonical(r.User.ID) { - w.Link("/users/delete/"+strings.TrimPrefix(note.ID, "https://"), "💣 Delete") + w.Link("/users/delete/"+arg, "💣 Delete") } if r.User != nil && note.Type == ap.Question && note.Closed == (ap.Time{}) && (note.EndTime == (ap.Time{}) || time.Now().Before(note.EndTime.Time)) { options := note.OneOf @@ -430,7 +432,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { options = note.AnyOf } for _, option := range options { - w.Linkf(fmt.Sprintf("/users/reply/%s?%s", strings.TrimPrefix(note.ID, "https://"), url.PathEscape(option.Name)), "📮 Vote %s", option.Name) + w.Linkf(fmt.Sprintf("/users/reply/%s?%s", arg, url.PathEscape(option.Name)), "📮 Vote %s", option.Name) } } @@ -439,9 +441,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from shares where note = ? and by = ?)`, note.ID, r.User.ID).Scan(&shared); err != nil { r.Log.Warn("Failed to check if post is shared", "id", note.ID, "error", err) } else if shared == 0 { - w.Link("/users/share/"+strings.TrimPrefix(note.ID, "https://"), "🔁 Share") + w.Link("/users/share/"+arg, "🔁 Share") } else { - w.Link("/users/unshare/"+strings.TrimPrefix(note.ID, "https://"), "🔄️ Unshare") + w.Link("/users/unshare/"+arg, "🔄️ Unshare") } } @@ -450,20 +452,20 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { if err := h.DB.QueryRowContext(r.Context, `select exists (select 1 from bookmarks where note = ? and by = ?)`, note.ID, r.User.ID).Scan(&bookmarked); err != nil { r.Log.Warn("Failed to check if post is bookmarked", "id", note.ID, "error", err) } else if bookmarked == 0 { - w.Link("/users/bookmark/"+strings.TrimPrefix(note.ID, "https://"), "🔖 Bookmark") + w.Link("/users/bookmark/"+arg, "🔖 Bookmark") } else { - w.Link("/users/unbookmark/"+strings.TrimPrefix(note.ID, "https://"), "🔖 Unbookmark") + w.Link("/users/unbookmark/"+arg, "🔖 Unbookmark") } } if r.User != nil { if note.CanQuote() { - w.Link("/users/quote/"+strings.TrimPrefix(note.ID, "https://"), "♻️ Quote") - w.Link(fmt.Sprintf("titan://%s/users/upload/quote/%s", h.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload quote") + w.Link("/users/quote/"+arg, "♻️ Quote") + w.Link(fmt.Sprintf("titan://%s/users/upload/quote/%s", h.Domain, arg), "Upload quote") } - w.Link("/users/reply/"+strings.TrimPrefix(note.ID, "https://"), "💬 Reply") - w.Link(fmt.Sprintf("titan://%s/users/upload/reply/%s", h.Domain, strings.TrimPrefix(note.ID, "https://")), "Upload reply") + w.Link("/users/reply/"+arg, "💬 Reply") + w.Link(fmt.Sprintf("titan://%s/users/upload/reply/%s", h.Domain, arg), "Upload reply") } if note.Type == ap.Question && offset == 0 { @@ -514,9 +516,9 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { w.Text("[Error]") } else { if r.User == nil { - w.Linkf("/view/"+strings.TrimPrefix(quote.ID, "https://"), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) + w.Linkf("/view/"+idLink(quote.ID), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) } else { - w.Linkf("/users/view/"+strings.TrimPrefix(quote.ID, "https://"), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) + w.Linkf("/users/view/"+idLink(quote.ID), "%s %s", quote.Published.Time.Format(time.DateOnly), quoteAuthor) } quoteLines, _ := h.getCompactNoteContent("e) @@ -548,7 +550,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { replies.public = 1 order by replies.inserted desc limit $2 offset $3 `, - postID, + note.ID, h.Config.RepliesPerPage, offset, ) @@ -583,7 +585,7 @@ func (h *Handler) view(w text.Writer, r *Request, args ...string) { ) order by replies.inserted desc limit $3 offset $4 `, - postID, + note.ID, r.User.ID, h.Config.RepliesPerPage, offset, diff --git a/go.mod b/go.mod index 2f0297ad..15da42df 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.6 require ( github.com/btcsuite/btcutil v1.0.2 + github.com/cloudflare/circl v1.6.5 github.com/creack/pty v1.1.24 github.com/dimkr/slopline v0.0.0-20260327144222-f21b275f569f github.com/fsnotify/fsnotify v1.10.1 diff --git a/go.sum b/go.sum index 0e3c9a0d..6e07d760 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtE github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= +github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= diff --git a/httpsig/rfc9421.go b/httpsig/rfc9421.go index 64955800..ef35618c 100644 --- a/httpsig/rfc9421.go +++ b/httpsig/rfc9421.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -33,6 +33,7 @@ import ( "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/danger" "github.com/dimkr/tootik/data" ) @@ -211,6 +212,10 @@ func SignRFC9421( case ed25519.PrivateKey: sig = ed25519.Sign(v, danger.Bytes(s)) + case *mldsa44.PrivateKey: + sig = make([]byte, mldsa44.SignatureSize) + err = mldsa44.SignTo(v, danger.Bytes(s), nil, true, sig) + default: return errors.New("invalid private key") } @@ -345,7 +350,7 @@ func rfc9421Extract( return nil, errors.New("invalid signature input: " + input) } - if alg != "" && alg != "rsa-v1_5-sha256" && alg != "ed25519" { + if alg != "" && alg != "rsa-v1_5-sha256" && alg != "ed25519" && alg != "ml-dsa-44" { return nil, errors.New("unsupported alg: " + alg) } diff --git a/httpsig/rfc9421_test.go b/httpsig/rfc9421_test.go index d859cf48..0c67156a 100644 --- a/httpsig/rfc9421_test.go +++ b/httpsig/rfc9421_test.go @@ -1,5 +1,5 @@ /* -Copyright 2025 Dima Krasner +Copyright 2025, 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,12 +19,15 @@ package httpsig import ( "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/pem" "math/big" "net/http" "strings" "testing" "time" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" ) // B.1.4. Example Ed25519 Test Key @@ -710,3 +713,36 @@ func TestRFC9421_VerifySignatureAge(t *testing.T) { }) } } + +// https://c2sp.org/httpsig-pq@v1.0.0#machine-readable-test-vectors +func TestRFC9421_MLDSA44(t *testing.T) { + t.Parallel() + + rawPub, err := base64.StdEncoding.DecodeString("guWbbXQe/Y1a83/qIeU8MjIrRseICPuOyWzoGXk27Fia2lpk2uPxbEfh/iEmvY1z/LTtxSBFZS8zbgV04qa038ZokwE+QoBcISX0H4F9hEY3Oxg8SRtzrek0E+mBOc6R4ilBje5vodImgSMTMSXKuDz2PWqvrLY20AfLmMkLXZqVERYcW+S7iKyTP53cvtenYwealt3MGcZxLKRAMnCGBY3pRfj1xn6czY6OuMYNuJaW1rAeL2BeVpNA+XBA+HUHS1yTYOGw93ddFe9lw0RDex7Pw+tht/n31gggrR4D0kmE8L88Q5c2qWuQtxk9+cmHTm3u1WuG/vCRiubTA3DmY0pK1fO1etarscNRnZ06q5UfTgSQOMqR/PLXtqT9cZiN8GWQvum15uVuxXCubB4r7T3nz2ijyBPQfo+Ywd10QxI8dpGpsBd9SEslLo+esyFDt7+9t4c702PNC0121oI18PriNlKxjxshQljNnrXSZ4eCvhHrY5Le8B/mTwsb/LYPbBvktCecvkRxboTjhyq2anblDt5Hp55gn+0YRWUz7myuDjBD/+a2riHzOXCoLOtGvsfVTLAwLPYj/AIBbdVFrGq9MKldtcgiqTWhG6JWD/dxFrfUT4YIf2yWO9AbhkgkgPynKmV82sLda4L3JaABwSXVAB1vzDmxfQ17orBFxX2pK23G5EmrG8Ix/1O9NJ35LSxkZhUmNs+4zm9GRaChPbw04q//Gc9CPiE3xvv/BrRms5mIiRl7zT8o2mgNs/APM45mJrKClmYRQllKVwnKBj+njZkDv1CG2WeBv01COmic4P4EEraSmKO8I5sJ98gpTiA9jeBuvloLYdAInPyZui01GyQO4P3OETyAVqggBGIvajxWuio7eL+4XZmkX3AMb5XrfWtlfYCKUzhodcv7NM6M4LizEh0KZ8qSHsnVZxqwb5J9hb4oiR5/N7sgxjjtiBsbg/vJ1UmBZZR6hPjnLbLDTZ6PUMjeQNdoYGL0Cv4ESloaEtc4vhW/Aa2roIyoAM/kimrHYztNknxZK3nLQ9PXVp9Np1okp4VPUkOfHvy6Bk2WPQd8vdwrfE3xOPGrkhzdmlcsVEp3rIAW4s4Hem2A99CFSkbPePEEPG4uB3sVl73X+sr8+X7jFkiiy686eV73zQTrqL4OnrYJIkaZr6zLXHccvm1txmftmtS3WH3Ny0hGVjwxnu1femv2++2pZpZr67//WBksxPOG4xk6AA7xrHEAjRFA0b0g6AC3+WuZmaNCNMd8YdibEt/WHKg7uGWYetMf45cWxPxZmNl+jddlf1btTAfYZ+eXla3Tx/pg5fU2EWTXyTOVIRdRwAOpZHG7jFYCxRE2SWFFM2lwjLxzso69uABhM1zraUxo6TfOalw1x7S1NkTowesaJPlTMmr43N5hdbRyDD6qmcCSNNjDICvK/fv3ijSY0lueglt1Jf7O1fi9/s0CNGf8E/pQohdb/pzGhSlJRo73hC/wTmLEjFukAYNdNeJO8Fxge7j6rLHIOu22q8lK0DSsWqXXeFYt5mKmdTOFHN6soJQ7Uk43HgwoMreCn6xJQhl87CZu4SUzr5XztrsOV/YF5u5IYY8cDneoZW+0ldE+/8bvI8gi352YmfL9ZY5TacDexbl0S+hGbR7IPHh6av0N+odj2uBNt4Cjb3Mt3aYwQi2Yh9vD+CaGcz5AO67GAf7pIpS8naBrzQeNjdOYLxu6VcXT4zLO9Gu1Uv+RvkLPaalcAQ==") + if err != nil { + t.Fatalf("Failed to decode public key: %v", err) + } + + var pub mldsa44.PublicKey + if err := pub.UnmarshalBinary(rawPub[:]); err != nil { + t.Fatalf("Failed to parse public key: %v", err) + } + + r, err := http.NewRequest(http.MethodGet, "https://example.com/foo?param=Value&Pet=dog", nil) + if err != nil { + t.Fatalf("Failed to create request: %v", err) + } + + r.Header.Set("Host", "example.com") + r.Header.Set("Date", "Mon, 06 Jul 2026 20:00:00 GMT") + r.Header.Set("Signature", "sig1=:apZ5/ADQOgYFPWs2iqmiwKjWK7MyWOQj0ItgYx+14iDa5XNdcB/nHICBEONDRISfvvFIDf7u4UjEKVZRUxLxF7BK1932ydZzQZlU4lv0UwB2zPmDCSHV+dqF/vqP5AdlGN3VX8if4P3Z34S0kYMA3ECKEKCT4kcdL4zA4TSzomhHF0S/qcfam/Mz1Ss6W0CyzLPMvJdPJ4rLJkIWlBKB7aWzFfKrI6zZx1asvgPht0RjCc/IIMNuCXmPPusyAmi3NBFfrn/eQIkjOxBujePKXFx6k2FEdfYJRugcHvLOEhgu8kBLZzULW4t9qytaTw1ItKoXOwksdt8yQbTKzsTWjBrY43scbWp6hYpo6Mom6QcQxy8Qc4sMe/D/V9OZh/yvQ0Z7F+d9om3XgTmrTO23Gs715KC5AFH5EX15orv2xeGUg6HI17pp5seCpALemy5yev2CNBDBQISxeeA3WQH6xvt2p00CN7ZSjrSk3fgu4Siu/gbb2UYqhNICXqa2JqJL9/hQQOA2olg1fGyIkJC9MK0SxgfPGD05WkGTN3/cNnapT+sa1tRoDz1jEj3joalN3HH3GOtltOXtzfxvbraVBIRaGWYaDiOQxJHvzuMQy4ioYTGXhlMO5/mgkX2GUdAoBQ+agRlJyn1M3SMMQ1x70/Gpz3ykO0mhyjnwfUhL8hzpt0fFFuSctT0QpSvbqdS4rTQR0Nls2enkvam09txQugXi1y/F4v73OenS/X3GqfWgtVOn5Ww+AL4dCrs+8VFhKzLqiebkez64HxAPMD3STWuezcdq71vv08XMc7CX4d/Hi/TwU25OGrubccM8Ueefh1Glr7cPA9qly7Ev1MsmtlidNxbv/0ryD9WiYIssJsqpfoFP8ag5W3HCjKeHzGX/BhCrOPF9+Q3uSRefxp13dR/Hmkk4kbFePwYZp6eG+q2OJA0B/NAec2JI/Ikp3c2+R+6K2+caWSIRMYwUdq2HdTwgnw6e0IeMj0tAT940rVW+uIp2j2p+taNFZQ8QvFaYevAfR5Do9wmIDWUSThpJaRRTfg3obvZkq9MxTWvhhiuIueOnM4lFUxCBjPXcvVgBKoEXuECmnfDWHXfspiTZfEUaBPn3gRsaVOQTO2zH12+mtXK5K3sgbZGUwUnF1h33g1uAe7YlZrlK867IccnjgoFRpKliwV0UFvlbIVXg9mT06yv7NvP0sqk8fJyj7wI9QdMaX7N0LMcG8ArFqwR5RlK8fqrD0hXFpYwev3ubpIFNrQbkM2otS19BeHy4LSW9R+TVQgrmWlxQFFuOqUdUFs3C0UoZJPnlgVy3mp+1ule6JWho/LiXW7oBLY4tRmpVLO1zfBPraBlAS7dUqk3jvFMrI955Tc7gJ5AK07eJiKeyqS5QtO3BMXZ+V1YVRSeWtG4zNzZtVWxIqxRndh5eO2wgdWxRb8E89k0WvOtuSvKwCP+V9MKOIYPgfY+VCVn4B+6Qi4QXjQpbYBPwpQMXPsB74ju6Wl/wmuHDp2jhG4OeOXkENSd4S312v0Bn//jc4Ia2cEg+T97sJPF0fA8iASFAueVowIw00tYPb9DWGWte8wDhfwm+y3C8yWlOhM4Ko7AxJpq5hsN+GvrmC+UgA+Zj4GnngnsQmglVm7EABe5hKay9bxtqAjFZAkrhi9d96xPRVDUVtZtHoyu8WCZKrPUQ9K/cCyC7lllCETOVycxNF8Vx32/5OG4D1K1bDSmU1E70M8J9dxRoo7+f55AxjdAgi/GG4QcEuJ/Qn2PqfF9zv0Z3pLwKpDchsq9VNWs1//DlrgK3Bh2p+H5Wh7rcKHWrtq7/i/ea+8cThRH9zcm5I6uphYo4UBiulsP3efGG0gVznoNqfFG9bW1+kBYymULSaSwIDH2FfSdq5kkLGq87N7E0QQ+1cwcG78lujNjda2qq3tM6DRelh5wjfe64IMsU8pkqLPSV3nfhQMRrL2opv8A0YO3ZC7B8kOfNfsJZINZgQHp14dWA73QDNMAOf/JwsyxTGoi4hEzSnGTlhmg4Yeg/YgEZd1q9T83UmMEU43pOBQnzaOieOfpLCAyzP/RE46wVwUOOzRzJeSIX5qnFN/6yKCUItFW0l6cKr2MiklLPF5h4RyWEXFR9B8AOozq2nLIfZ4c9vRVIkN87yU/bWE6ZGVfHjyiunSMW4VrppKXHLHCKEpmYtb5RNgSt9Mjh8kFpWzlzPnb+DngdOv3tV2Gm3TaCAK4PRkZmSCg7xtSG7CPF1unGknvqStYJLkOjlijiYnenEv+6+A5HPwUMnbpzZ7EHI8hbGSW0xTJtDJt7oREuXByNZmzVOmmnp/kzVn4OFpMRJY45l1L3pIk53XSpxQkgICQEuFHCjEyZIav76seENULFeLWS6TVjQCpoDIiQCh8BFC6ULFVaC06HPXBuuMHWW6vIXPls6wQNe3NkZqu9nQNEnQlSqdl71L8KA66m7gUHrB6tidalXBRT9cISB/ntnRS/p/3IBm8+Nf9KHTVQGmojEARreVrkf6PLIRMI1cvvJRWHvhDt1qnXibwqKQCvWTp2G9XbC95wl/fwSNxNfayguheTgTDwtn6liHq2Q9T/wMotE/uSMgxEz493qAtUi5QVZwYN3Dgdz109plrZ+44WYAn+CYZi0ucbE8Oyw3tPJ2eAvL87qtOx9OnGMFGlWrn2hqkf2ezL5jW04b8TUjO8xWNBjspyoOz7cRczdCAdOH4deseJfZn5OjWUHFDfGsCuufO6lPpTucKFwrK+g0CNSO8hx47SguuTz63zvtpZoaefxRehQ+2fm9Vu/ti/ROopsL14yJ310OlQEcJU35IC++sKPaSOaG3d/SyZYVeprVcLaSHLvSRIU/5jD4NLHFpmXT05QGkRwQbClhENHF7M83wuTLaHrdGL65rPlUAmTjNKLzmO2uLKhFwUENr9b4m+D4ZzLCuxc1JCCjAHWuY9rgk2/IsYj2lfXw6EBt+OpnaMpjRTemwHA2Q5Di54hrhKULzvz3k6NT/MeiyWD7qdrhsJCVTpowtN7IA/HGsj0vA15d87ExoAyYDIW72wJVIiCf9piIRdM3RFrdjaPUze6cVa26RDcFG21cKHVfLrhezq85TKkvQNGioxP5aarrq829/g8PsGJE1hfIaPkJalqcbW2gkKNkFlZm11gYOKjs7Y9BEaOk9fdX6CpK/D1eX3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8dLDo=:") + + sig, err := rfc9421Extract(r, `sig1=("@method" "@target-uri" "host" "date");created=1783368000;keyid="test-key-mldsa44";alg="ml-dsa-44"`, nil, r.URL.Host, time.Unix(1783368000, 0), time.Minute, nil) + if err != nil { + t.Fatalf("Failed to extract: %v", err) + } + + if err := sig.Verify(&pub); err != nil { + t.Fatalf("Failed to verify: %v", err) + } +} diff --git a/httpsig/verify.go b/httpsig/verify.go index e6610ec9..7d475081 100644 --- a/httpsig/verify.go +++ b/httpsig/verify.go @@ -30,6 +30,8 @@ import ( "strings" "time" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/danger" ) @@ -268,6 +270,15 @@ func (s *Signature) Verify(key crypto.PublicKey) error { return errors.New("invalid ed25519 signature") } + case *mldsa44.PublicKey: + if s.Alg != "" && s.Alg != "ml-dsa-44" { + return errors.New("alg is not ML-DSA-44: " + s.Alg) + } + + if !mldsa44.Verify(v, danger.Bytes(s.s), nil, s.signature) { + return errors.New("invalid ML-DSA-44 signature") + } + default: return fmt.Errorf(`cannot verify alg="%s" with %T`, s.Alg, key) } diff --git a/inbox/backfill.go b/inbox/backfill.go index a039bd9c..4363e5ef 100644 --- a/inbox/backfill.go +++ b/inbox/backfill.go @@ -53,13 +53,13 @@ func (q *Queue) fetchCachedPost(ctx context.Context, id string) (*ap.Object, err or exists ( select 1 from persons where persons.id = notes.author - and persons.ed25519privkey is not null + and persons.ed25519seed is not null ) or ( not exists ( select 1 from persons where persons.id = notes.author - and persons.ed25519privkey is not null + and persons.ed25519seed is not null ) and ( max(inserted, updated) > $2 or exists ( @@ -215,7 +215,7 @@ func (q *Queue) fetchPost(ctx context.Context, id string) (*ap.Object, error) { return nil, fmt.Errorf("key %s does not belong to %s", m[1], origin) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return nil, fmt.Errorf("failed to verify proof using %s: %w", post.Proof.VerificationMethod, err) } @@ -394,7 +394,7 @@ func (q *Queue) fetchContext(ctx context.Context, post *ap.Object) error { return fmt.Errorf("key %s does not belong to %s", m[1], contextOrigin) } - publicKey, err := data.DecodeEd25519PublicKey(m[1]) + publicKey, err := data.DecodePublicKey(m[1]) if err != nil { return fmt.Errorf("failed to verify proof using %s: %w", collection.Proof.VerificationMethod, err) } diff --git a/inbox/forward.go b/inbox/forward.go index 739b07c2..30cb0588 100644 --- a/inbox/forward.go +++ b/inbox/forward.go @@ -18,26 +18,25 @@ package inbox import ( "context" - "crypto/ed25519" "database/sql" "errors" "fmt" + "github.com/dimkr/tootik/proof" "log/slog" "time" "github.com/dimkr/tootik/ap" - "github.com/dimkr/tootik/httpsig" ) func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Object, activity *ap.Activity, rawActivity, firstPostID string) (bool, error) { var group ap.Actor - var ed25519PrivKey []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, ` - select json(actor), ed25519privkey from + select json(actor), ed25519seed, mldsa44seed from ( - select persons.actor, ed25519privkey, 1 as rank + select persons.actor, ed25519seed, mldsa44seed, 1 as rank from persons join notes on @@ -47,7 +46,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj persons.host = $2 and persons.actor->>'$.type' = 'Group' union all - select persons.actor, ed25519privkey, 2 as rank + select persons.actor, ed25519seed, mldsa44seed, 2 as rank from persons join notes on @@ -58,7 +57,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj persons.host = $2 and persons.actor->>'$.type' = 'Group' union all - select persons.actor, ed25519privkey, 3 as rank + select persons.actor, ed25519seed, mldsa44seed, 3 as rank from persons join notes on @@ -74,7 +73,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj `, firstPostID, inbox.Domain, - ).Scan(&group, &ed25519PrivKey); err != nil && errors.Is(err, sql.ErrNoRows) { + ).Scan(&group, &ed25519Seed, &mldsa44Seed); err != nil && errors.Is(err, sql.ErrNoRows) { return false, nil } else if err != nil { return false, err @@ -106,7 +105,7 @@ func (inbox *Inbox) forwardToGroup(ctx context.Context, tx *sql.Tx, note *ap.Obj } // if this is a new post and we're passing the Create activity to followers, also share the post - if err := inbox.Announce(ctx, tx, &group, httpsig.Key{ID: group.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, note); err != nil { + if err := inbox.Announce(ctx, tx, &group, proof.SigningSeed(&group, ed25519Seed, mldsa44Seed), note); err != nil { return true, err } @@ -163,7 +162,7 @@ func (inbox *Inbox) forwardActivity(ctx context.Context, tx *sql.Tx, note *ap.Ob return nil } - if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519privkey is not null`, ap.Canonical(threadStarterID)).Scan(&threadStarterID); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519seed is not null`, ap.Canonical(threadStarterID)).Scan(&threadStarterID); errors.Is(err, sql.ErrNoRows) { slog.Debug("Thread starter is federated", "activity", activity.ID, "note", note.ID) return nil } else if err != nil { diff --git a/inbox/inbox.go b/inbox/inbox.go index 609ea3e8..322a4aca 100644 --- a/inbox/inbox.go +++ b/inbox/inbox.go @@ -21,7 +21,6 @@ package inbox import ( "context" - "crypto/ed25519" "database/sql" "errors" "fmt" @@ -32,8 +31,8 @@ import ( "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/cfg" "github.com/dimkr/tootik/data" - "github.com/dimkr/tootik/httpsig" "github.com/dimkr/tootik/inbox/note" + "github.com/dimkr/tootik/proof" ) type Inbox struct { @@ -163,7 +162,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to delete %s: %w", deleted, err) } - if _, err := tx.ExecContext(ctx, `delete from notesfts where rowid = (select rowid from notes where id = ?)`, deleted); err != nil { + if _, err := tx.ExecContext(ctx, `delete from notesfts where rowid = (select pk from notes where id = ?)`, deleted); err != nil { return fmt.Errorf("cannot delete %s: %w", deleted, err) } if _, err := tx.ExecContext(ctx, `update notes set object = jsonb_set(jsonb_remove(object, '$.name', '$.summary', '$.tag', '$.attachment', '$.votersCount', '$.oneOf', '$.anyOf'), '$.content', '[deleted]'), deleted = 1 where id = ?`, deleted); err != nil { @@ -180,11 +179,11 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return errors.New("received an invalid follow request") } - var ed25519PrivKey []byte + var ed25519Seed, mldsa44Seed []byte var followed ap.Actor - if err := tx.QueryRowContext(ctx, `select ed25519privkey, json(actor) from persons where cid = ? order by ed25519privkey is not null desc limit 1`, ap.Canonical(followedID)).Scan(&ed25519PrivKey, &followed); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select ed25519seed, mldsa44seed, json(actor) from persons where cid = ? order by ed25519seed is not null desc limit 1`, ap.Canonical(followedID)).Scan(&ed25519Seed, &mldsa44Seed, &followed); errors.Is(err, sql.ErrNoRows) { var localFollowerID string - if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519privkey is not null`, ap.Canonical(activity.Actor)).Scan(&localFollowerID); errors.Is(err, sql.ErrNoRows) { + if err := tx.QueryRowContext(ctx, `select id from persons where cid = ? and ed25519seed is not null`, ap.Canonical(activity.Actor)).Scan(&localFollowerID); errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("received an invalid follow request for %s by %s", followedID, activity.Actor) } else if err != nil { return fmt.Errorf("failed to validate follow request for %s by %s: %w", followedID, activity.Actor, err) @@ -204,7 +203,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to fetch %s: %w", followed.ID, err) } - if ed25519PrivKey == nil || followed.ManuallyApprovesFollowers { + if ed25519Seed == nil || followed.ManuallyApprovesFollowers { slog.Info("Not approving follow request", "activity", activity, "follower", activity.Actor, "followed", followed.ID) if _, err := tx.ExecContext( @@ -217,7 +216,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu ); err != nil { return fmt.Errorf("failed to insert follow %s: %w", activity.ID, err) } - } else if ed25519PrivKey != nil && !followed.ManuallyApprovesFollowers { + } else if ed25519Seed != nil && !followed.ManuallyApprovesFollowers { slog.Info("Approving follow request", "activity", activity, "follower", activity.Actor, "followed", followed.ID) if _, err := tx.ExecContext( @@ -231,7 +230,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu return fmt.Errorf("failed to insert follow %s: %w", activity.ID, err) } - if err := inbox.AcceptFollow(ctx, &followed, httpsig.Key{ID: followed.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey)}, activity.Actor, activity.ID, tx); err != nil { + if err := inbox.AcceptFollow(ctx, &followed, proof.SigningSeed(&followed, ed25519Seed, mldsa44Seed), activity.Actor, activity.ID, tx); err != nil { return fmt.Errorf("failed to accept %s: %w", activity.ID, err) } } else { @@ -422,7 +421,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu if post.Content != oldPost.Content { if _, err := tx.ExecContext( ctx, - `update notesfts set content = ? where rowid = (select rowid from notes where id = ?)`, + `update notesfts set content = ? where rowid = (select pk from notes where id = ?)`, note.Flatten(post), post.ID, ); err != nil { @@ -444,16 +443,16 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu } var actor ap.Actor - var ed25519PrivKey []byte + var ed25519Seed, mldsa44Seed []byte if err := tx.QueryRowContext( ctx, ` - select ed25519privkey, json(actor) from notes + select ed25519seed, mldsa44seed, json(actor) from notes join persons on persons.id = notes.author - where notes.id = ? and notes.public = 1 and notes.deleted = 0 and persons.ed25519privkey is not null + where notes.id = ? and notes.public = 1 and notes.deleted = 0 and persons.ed25519seed is not null `, postID, - ).Scan(&ed25519PrivKey, &actor); errors.Is(err, sql.ErrNoRows) { + ).Scan(&ed25519Seed, &mldsa44Seed, &actor); errors.Is(err, sql.ErrNoRows) { slog.Debug("Received invalid quote request", "activity", activity) return nil } else if err != nil { @@ -463,10 +462,7 @@ func (inbox *Inbox) processActivity(ctx context.Context, tx *sql.Tx, path sql.Nu if err := inbox.acceptRequest( ctx, &actor, - httpsig.Key{ - ID: actor.AssertionMethod[0].ID, - PrivateKey: ed25519.NewKeyFromSeed(ed25519PrivKey), - }, + proof.SigningSeed(&actor, ed25519Seed, mldsa44Seed), activity, tx, ); err != nil { diff --git a/inbox/note/insert.go b/inbox/note/insert.go index bafcdc76..bc7a51a4 100644 --- a/inbox/note/insert.go +++ b/inbox/note/insert.go @@ -63,22 +63,23 @@ func Insert(ctx context.Context, tx *sql.Tx, note *ap.Object) error { public = 1 } - var rowID int64 + var pk int64 if err := tx.QueryRowContext( ctx, - `INSERT INTO notes (id, author, object, public) VALUES (?, ?, JSONB(?), ?) RETURNING rowid`, + `INSERT INTO notes (slug, id, author, object, public) VALUES (?, ?, ?, JSONB(?), ?) RETURNING pk`, + ap.Slug(note.ID), note.ID, note.AttributedTo, ¬e, public, - ).Scan(&rowID); err != nil { + ).Scan(&pk); err != nil { return fmt.Errorf("failed to insert note %s: %w", note.ID, err) } if _, err := tx.ExecContext( ctx, `INSERT INTO notesfts (rowid, content) VALUES(?,?)`, - rowID, + pk, Flatten(note), ); err != nil { return fmt.Errorf("failed to insert note %s: %w", note.ID, err) diff --git a/inbox/queue.go b/inbox/queue.go index ca6e4b86..93e3c942 100644 --- a/inbox/queue.go +++ b/inbox/queue.go @@ -35,7 +35,7 @@ type Queue struct { DB *sql.DB Inbox ap.Inbox Resolver ap.Resolver - Keys [2]httpsig.Key + Keys [3]httpsig.Key } type batchItem struct { diff --git a/migrations/046_proofs.go b/migrations/046_proofs.go index 47a5bf61..f606abaf 100644 --- a/migrations/046_proofs.go +++ b/migrations/046_proofs.go @@ -23,7 +23,7 @@ func proofs(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -56,7 +56,7 @@ func proofs(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -89,7 +89,7 @@ func proofs(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/047_contexts.go b/migrations/047_contexts.go index f9b4c9c1..f4b58fe4 100644 --- a/migrations/047_contexts.go +++ b/migrations/047_contexts.go @@ -23,7 +23,7 @@ func contexts(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -62,7 +62,7 @@ func contexts(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } @@ -101,7 +101,7 @@ func contexts(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/049_pembegin.go b/migrations/049_pembegin.go index f8a34314..3e152352 100644 --- a/migrations/049_pembegin.go +++ b/migrations/049_pembegin.go @@ -26,7 +26,7 @@ func pembegin(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } diff --git a/migrations/053_ed25519blob.go b/migrations/053_ed25519blob.go index 8fa1d4ea..ea94cc1e 100644 --- a/migrations/053_ed25519blob.go +++ b/migrations/053_ed25519blob.go @@ -2,6 +2,7 @@ package migrations import ( "context" + "crypto/ed25519" "database/sql" "github.com/dimkr/tootik/data" @@ -23,12 +24,12 @@ func ed25519blob(ctx context.Context, domain string, tx *sql.Tx) error { return err } - ed25519PrivKey, err := data.DecodeEd25519PrivateKey(ed25519PrivKeyMultibase) + ed25519PrivKey, err := data.DecodePrivateKey(ed25519PrivKeyMultibase) if err != nil { return err } - if _, err := tx.ExecContext(ctx, `UPDATE persons SET ed25519privkeyblob = ? WHERE id = ?`, ed25519PrivKey.Seed(), id); err != nil { + if _, err := tx.ExecContext(ctx, `UPDATE persons SET ed25519privkeyblob = ? WHERE id = ?`, ed25519PrivKey.(ed25519.PrivateKey).Seed(), id); err != nil { return err } } diff --git a/migrations/076_mldsa44slug.go b/migrations/076_mldsa44slug.go new file mode 100644 index 00000000..ca6a3119 --- /dev/null +++ b/migrations/076_mldsa44slug.go @@ -0,0 +1,360 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/data" + "github.com/dimkr/tootik/inbox/note" +) + +func insertSlugs(ctx context.Context, tx *sql.Tx, table string) error { + if _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS slugs`); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `CREATE TEMP TABLE slugs(src INTEGER PRIMARY KEY, slug TEXT NOT NULL)`); err != nil { + return err + } + + type row struct { + src int64 + id string + } + batch := make([]row, 0, 10000) + query := fmt.Sprintf(`SELECT rowid, id FROM %s WHERE rowid > ? ORDER BY rowid LIMIT %d`, table, cap(batch)) + + last := int64(0) + for { + rows, err := tx.QueryContext(ctx, query, last) + if err != nil { + return err + } + + batch = batch[:0] + for rows.Next() { + var r row + if err := rows.Scan(&r.src, &r.id); err != nil { + rows.Close() + return err + } + + batch = append(batch, r) + } + + rows.Close() + + if err := rows.Err(); err != nil { + return err + } + + if len(batch) == 0 { + return nil + } + + for _, r := range batch { + if _, err := tx.ExecContext(ctx, `INSERT INTO slugs(src, slug) VALUES(?,?)`, r.src, ap.Slug(r.id)); err != nil { + return err + } + } + + last = batch[len(batch)-1].src + } +} + +func rebuildNotesFts(ctx context.Context, tx *sql.Tx) error { + type row struct { + pk int64 + content string + } + batch := make([]row, 0, 1000) + query := fmt.Sprintf(`SELECT pk, JSON(object) FROM notes WHERE deleted = 0 AND pk > ? ORDER BY pk LIMIT %d`, cap(batch)) + + last := int64(0) + for { + rows, err := tx.QueryContext(ctx, query, last) + if err != nil { + return err + } + + batch = batch[:0] + for rows.Next() { + var pk int64 + var post ap.Object + if err := rows.Scan(&pk, &post); err != nil { + rows.Close() + return err + } + + batch = append(batch, row{pk, note.Flatten(&post)}) + } + + rows.Close() + + if err := rows.Err(); err != nil { + return err + } + + if len(batch) == 0 { + break + } + + for _, r := range batch { + if _, err := tx.ExecContext(ctx, `INSERT INTO notesfts(rowid, content) VALUES(?,?)`, r.pk, r.content); err != nil { + return err + } + } + + last = batch[len(batch)-1].pk + } + + _, err := tx.ExecContext(ctx, `INSERT INTO notesfts(notesfts) VALUES('optimize')`) + return err +} + +func addMLDSA44Keys(ctx context.Context, tx *sql.Tx) error { + type local struct { + pk int64 + actor ap.Actor + } + batch := make([]local, 0, 1000) + query := fmt.Sprintf(`SELECT pk, JSON(actor) FROM persons WHERE ed25519seed IS NOT NULL AND mldsa44seed IS NULL LIMIT %d`, cap(batch)) + + for { + rows, err := tx.QueryContext(ctx, query) + if err != nil { + return err + } + + batch = batch[:0] + for rows.Next() { + var l local + if err := rows.Scan(&l.pk, &l.actor); err != nil { + rows.Close() + return err + } + + batch = append(batch, l) + } + + rows.Close() + + if err := rows.Err(); err != nil { + return err + } + + if len(batch) == 0 { + return nil + } + + for _, l := range batch { + if len(l.actor.AssertionMethod) == 0 { + return fmt.Errorf("local actor %s has no assertion method", l.actor.ID) + } + + mldsa44Pub, mldsa44Priv, err := mldsa44.GenerateKey(nil) + if err != nil { + return err + } + + keyID := l.actor.ID + "#ml-dsa-44-key" + + l.actor.AssertionMethod = append(l.actor.AssertionMethod, ap.AssertionMethod{ + ID: keyID, + Type: "Multikey", + Controller: l.actor.ID, + PublicKeyMultibase: data.EncodeMLDSA44Publickey(mldsa44Pub), + }) + + if _, err := tx.ExecContext(ctx, `UPDATE persons SET actor = JSONB(?), mldsa44seed = ? WHERE pk = ?`, &l.actor, mldsa44Priv.Seed(), l.pk); err != nil { + return err + } + + if _, err := tx.ExecContext(ctx, `INSERT INTO keys(id, actor) VALUES(?,?)`, keyID, l.actor.ID); err != nil { + return err + } + } + } +} + +func mldsa44slug(ctx context.Context, domain string, tx *sql.Tx) error { + if err := insertSlugs(ctx, tx, `notes`); err != nil { + return err + } + + for _, stmt := range []string{ + `DROP TRIGGER nshares_insert`, + `DROP TRIGGER nshares_delete`, + + `CREATE TABLE nnotes(pk INTEGER PRIMARY KEY, slug TEXT NOT NULL, id TEXT NOT NULL, author TEXT NOT NULL, object JSONB NOT NULL, public INTEGER NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT 0, host TEXT AS (substr(substr(author, 9), 0, instr(substr(author, 9), '/'))), to0 TEXT AS (object->>'$.to[0]'), to1 TEXT AS (object->>'$.to[1]'), to2 TEXT AS (object->>'$.to[2]'), cc0 TEXT AS (object->>'$.cc[0]'), cc1 TEXT AS (object->>'$.cc[1]'), cc2 TEXT AS (object->>'$.cc[2]'), deleted INTEGER NOT NULL DEFAULT 0, nreplies INTEGER DEFAULT 0, nquotes INTEGER DEFAULT 0, nshares INTEGER DEFAULT 0, pulse INTEGER DEFAULT 0, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`, + `INSERT INTO nnotes(slug, id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse) SELECT slugs.slug, notes.id, author, object, public, inserted, updated, deleted, nreplies, nquotes, nshares, pulse FROM notes JOIN slugs ON slugs.src = notes.rowid`, + `DROP TABLE notes`, + `ALTER TABLE nnotes RENAME TO notes`, + + `CREATE UNIQUE INDEX notesid ON notes(id)`, + `CREATE UNIQUE INDEX notescid ON notes(cid)`, + `CREATE UNIQUE INDEX notesslug ON notes(slug)`, + + `CREATE INDEX notesinserted ON notes(inserted)`, + `CREATE INDEX notespublicauthor ON notes(public, author)`, + `CREATE INDEX noteshostinserted on notes(host, inserted)`, + `CREATE INDEX notesaudience ON notes(object->>'$.audience')`, + `CREATE INDEX notesquote ON notes(object->>'$.quote') WHERE object->>'$.quote' IS NOT NULL`, + `CREATE INDEX localnotescontext ON notes(object->>'$.context') WHERE object->>'$.context' IS NOT NULL`, + `CREATE INDEX notesopenpolls ON notes(id) WHERE object->>'$.type' = 'Question' AND deleted = 0 AND object->>'$.closed' IS NULL`, + `CREATE TRIGGER nreplies_insert AFTER INSERT ON notes + WHEN NEW.object->>'$.inReplyTo' IS NOT NULL + BEGIN + UPDATE notes + SET nreplies = nreplies + 1 + WHERE id = NEW.object->>'$.inReplyTo'; + + UPDATE notes + SET pulse = MAX(pulse, NEW.inserted) + WHERE id IN ( + WITH RECURSIVE thread(id, depth) AS ( + SELECT NEW.object->>'$.inReplyTo', 1 + UNION ALL + SELECT n.object->>'$.inReplyTo', t.depth + 1 + FROM notes n + JOIN thread t ON n.id = t.id + WHERE n.object->>'$.inReplyTo' IS NOT NULL AND t.depth <= 5 + ) + SELECT id FROM thread WHERE id IS NOT NULL + ); + END`, + `CREATE TRIGGER nreplies_delete AFTER DELETE ON notes + WHEN OLD.object->>'$.inReplyTo' IS NOT NULL + BEGIN + UPDATE notes + SET nreplies = MAX(0, nreplies - 1) + WHERE id = OLD.object->>'$.inReplyTo'; + END`, + `CREATE TRIGGER nquotes_insert AFTER INSERT ON notes + WHEN NEW.object->>'$.quote' IS NOT NULL + BEGIN + UPDATE notes + SET nquotes = nquotes + 1, pulse = MAX(pulse, NEW.inserted) + WHERE id = NEW.object->>'$.quote'; + END`, + `CREATE TRIGGER nquotes_delete AFTER DELETE ON notes + WHEN OLD.object->>'$.quote' IS NOT NULL + BEGIN + UPDATE notes + SET nquotes = MAX(0, nquotes - 1) + WHERE id = OLD.object->>'$.quote'; + END`, + `CREATE TRIGGER notes_insert AFTER INSERT ON notes + BEGIN + UPDATE notes SET + nreplies = (SELECT COUNT(*) FROM notes WHERE object->>'$.inReplyTo' = NEW.id), + nquotes = (SELECT COUNT(*) FROM notes WHERE object->>'$.quote' = NEW.id), + nshares = (SELECT COUNT(*) FROM shares WHERE note = NEW.id AND shares.by IS NOT NEW.object->>'$.audience'), + pulse = COALESCE( + (SELECT MAX(v) FROM ( + SELECT MAX(replies.inserted) as v FROM notes replies WHERE replies.object->>'$.inReplyTo' = NEW.id + UNION ALL + SELECT MAX(quotes.inserted) as v FROM notes quotes WHERE quotes.object->>'$.quote' = NEW.id + )), + NEW.inserted + ) + WHERE id = NEW.id; + END`, + `CREATE TRIGGER nshares_insert AFTER INSERT ON shares + BEGIN + UPDATE notes + SET nshares = nshares + 1 + WHERE id = NEW.note AND NEW.by IS NOT object->>'$.audience'; + END`, + `CREATE TRIGGER nshares_delete AFTER DELETE ON shares + BEGIN + UPDATE notes + SET nshares = MAX(0, nshares - 1) + WHERE id = OLD.note AND OLD.by IS NOT object->>'$.audience'; + END`, + `CREATE INDEX notesinreplytoinserted ON notes(object->>'$.inReplyTo', inserted) WHERE object->>'$.inReplyTo' IS NOT NULL`, + `CREATE INDEX notesauthorinserted ON notes(author, inserted)`, + `CREATE TRIGGER noteshashtagsinserted AFTER INSERT ON notes + BEGIN + INSERT INTO hashtags (note, hashtag) + SELECT DISTINCT new.id, CASE WHEN SUBSTR(value->>'$.name', 1, 1) = '#' THEN SUBSTR(value->>'$.name', 2) ELSE value->>'$.name' END COLLATE NOCASE + FROM JSON_EACH(new.object->'$.tag') + WHERE new.deleted = 0 AND value->>'$.type' = 'Hashtag' AND value->>'$.name' IS NOT NULL AND value->>'$.name' != ''; + END`, + `CREATE TRIGGER noteshashtagsupdated AFTER UPDATE ON notes + BEGIN + DELETE FROM hashtags WHERE note = new.id AND hashtag NOT IN ( + SELECT CASE WHEN SUBSTR(value->>'$.name', 1, 1) = '#' THEN SUBSTR(value->>'$.name', 2) ELSE value->>'$.name' END COLLATE NOCASE + FROM JSON_EACH(new.object->'$.tag') + WHERE new.deleted = 0 AND value->>'$.type' = 'Hashtag' AND value->>'$.name' IS NOT NULL AND value->>'$.name' != '' + ); + + INSERT INTO hashtags (note, hashtag) + SELECT candidates.note, candidates.hashtag FROM ( + SELECT DISTINCT new.id AS note, CASE WHEN SUBSTR(value->>'$.name', 1, 1) = '#' THEN SUBSTR(value->>'$.name', 2) ELSE value->>'$.name' END COLLATE NOCASE AS hashtag + FROM JSON_EACH(new.object->'$.tag') + WHERE new.deleted = 0 AND value->>'$.type' = 'Hashtag' AND value->>'$.name' IS NOT NULL AND value->>'$.name' != '' + ) candidates + WHERE candidates.hashtag NOT IN (SELECT hashtag COLLATE NOCASE FROM hashtags WHERE hashtags.note = candidates.note); + END`, + `CREATE TRIGGER noteshashtagsdeleted AFTER DELETE ON notes + BEGIN + DELETE FROM hashtags WHERE note = old.id; + END`, + + `DROP TABLE notesfts`, + `CREATE VIRTUAL TABLE notesfts USING fts5(content, tokenize = "unicode61 tokenchars '#@'", content='', contentless_delete=1)`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return err + } + } + + if err := rebuildNotesFts(ctx, tx); err != nil { + return err + } + + if err := insertSlugs(ctx, tx, `persons`); err != nil { + return err + } + + for _, stmt := range []string{ + `CREATE TABLE npersons(pk INTEGER PRIMARY KEY, slug TEXT NOT NULL, id TEXT NOT NULL, actor JSONB NOT NULL, inserted INTEGER DEFAULT (UNIXEPOCH()), updated INTEGER DEFAULT (UNIXEPOCH()), host TEXT AS (substr(substr(id, 9), 0, instr(substr(id, 9), '/'))), fetched INTEGER, ttl INTEGER, rsaprivkey BLOB, ed25519seed BLOB, mldsa44seed BLOB, cid TEXT NOT NULL AS (CASE WHEN id LIKE 'https://%' AND (id LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR id LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22, CASE WHEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(id, 9 + INSTR(SUBSTR(id, 9), '/') + 22), '?') - 1 ELSE LENGTH(id) END) WHEN id LIKE 'https://%' THEN id ELSE NULL END))`, + `INSERT INTO npersons(slug, id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519seed) SELECT slugs.slug, persons.id, actor, inserted, updated, fetched, ttl, rsaprivkey, ed25519privkey FROM persons JOIN slugs ON slugs.src = persons.rowid`, + `DROP TABLE persons`, + `ALTER TABLE npersons RENAME TO persons`, + + `CREATE UNIQUE INDEX personsslug ON persons(slug)`, + `CREATE UNIQUE INDEX personsid ON persons(id)`, + `CREATE INDEX personstypeid ON persons(actor->>'$.type', id)`, + `CREATE INDEX personsmovedto ON persons(actor->>'$.movedTo') WHERE actor->>'$.movedTo' IS NOT NULL`, + `CREATE UNIQUE INDEX personspreferredusernamehosttype ON persons(actor->>'$.preferredUsername', host, actor->>'$.type')`, + `CREATE INDEX personscid ON persons(cid)`, + `CREATE UNIQUE INDEX personscidlocal ON persons(cid) WHERE ed25519seed IS NOT NULL`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return err + } + } + + if err := addMLDSA44Keys(ctx, tx); err != nil { + return err + } + + for _, stmt := range []string{ + `DROP INDEX outboxcidsender`, + `ALTER TABLE outbox DROP COLUMN cid`, + `ALTER TABLE outbox ADD COLUMN cid TEXT NOT NULL AS (CASE WHEN activity->>'$.id' LIKE 'https://%' AND (activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:z6Mk%' OR activity->>'$.id' LIKE '%/.well-known/apgateway/did:key:ukC%') THEN 'ap://' || SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22, CASE WHEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') > 0 THEN INSTR(SUBSTR(activity->>'$.id', 9 + INSTR(SUBSTR(activity->>'$.id', 9), '/') + 22), '?') - 1 ELSE LENGTH(activity->>'$.id') END) WHEN activity->>'$.id' LIKE 'https://%' THEN activity->>'$.id' ELSE NULL END)`, + `CREATE INDEX outboxcidsender ON outbox(cid, sender)`, + } { + if _, err := tx.ExecContext(ctx, stmt); err != nil { + return err + } + } + + _, err := tx.ExecContext(ctx, `DROP TABLE slugs`) + return err +} diff --git a/outbox/deleter.go b/outbox/deleter.go index 416a36f6..696429a8 100644 --- a/outbox/deleter.go +++ b/outbox/deleter.go @@ -18,13 +18,12 @@ package outbox import ( "context" - "crypto/ed25519" "database/sql" + "github.com/dimkr/tootik/proof" "log/slog" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/dbx" - "github.com/dimkr/tootik/httpsig" ) const batchSize = 512 @@ -36,14 +35,15 @@ type Deleter struct { func (d *Deleter) undoShares(ctx context.Context) (bool, error) { rows, err := dbx.QueryCollect[struct { - Sharer ap.Actor - Ed25519PrivKey []byte - Share ap.Activity + Sharer ap.Actor + Ed25519Seed []byte + MLDSA44Seed []byte + Share ap.Activity }]( ctx, d.DB, ` - select json(persons.actor), persons.ed25519privkey, json(outbox.activity) from persons + select json(persons.actor), persons.ed25519seed, persons.mldsa44seed, json(outbox.activity) from persons join shares on shares.by = persons.id join outbox on outbox.activity->>'$.actor' = shares.by and outbox.activity->>'$.object' = shares.note where @@ -64,10 +64,7 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { if err := d.Inbox.Undo( ctx, &row.Sharer, - httpsig.Key{ - ID: row.Sharer.AssertionMethod[0].ID, - PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey), - }, + proof.SigningSeed(&row.Sharer, row.Ed25519Seed, row.MLDSA44Seed), &row.Share, ); err != nil { return false, err @@ -86,14 +83,15 @@ func (d *Deleter) undoShares(ctx context.Context) (bool, error) { func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { rows, err := dbx.QueryCollect[struct { - Author ap.Actor - Ed25519PrivKey []byte - Note ap.Object + Author ap.Actor + Ed25519Seed []byte + MLDSA44Seed []byte + Note ap.Object }]( ctx, d.DB, ` - select json(persons.actor), persons.ed25519privkey, json(notes.object) from persons + select json(persons.actor), persons.ed25519seed, persons.mldsa44seed, json(notes.object) from persons join notes on notes.author = persons.id where persons.ttl is not null and @@ -114,10 +112,7 @@ func (d *Deleter) deletePosts(ctx context.Context) (bool, error) { if err := d.Inbox.Delete( ctx, &row.Author, - httpsig.Key{ - ID: row.Author.AssertionMethod[0].ID, - PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey), - }, + proof.SigningSeed(&row.Author, row.Ed25519Seed, row.MLDSA44Seed), &row.Note, ); err != nil { return false, err diff --git a/outbox/mover.go b/outbox/mover.go index ed4e492a..ce4f405f 100644 --- a/outbox/mover.go +++ b/outbox/mover.go @@ -18,9 +18,9 @@ package outbox import ( "context" - "crypto/ed25519" "database/sql" "fmt" + "github.com/dimkr/tootik/proof" "log/slog" "github.com/dimkr/tootik/ap" @@ -32,7 +32,7 @@ type Mover struct { Domain string DB *sql.DB Resolver ap.Resolver - Keys [2]httpsig.Key + Keys [3]httpsig.Key Inbox ap.Inbox } @@ -80,7 +80,8 @@ func (m *Mover) Run(ctx context.Context) error { rows, err := dbx.QueryCollectIgnore[struct { Actor ap.Actor - Ed25519PrivKey []byte + Ed25519Seed []byte + MLDSA44Seed []byte OldID, NewID, OldFollowID string OnlyRemove bool }]( @@ -91,7 +92,7 @@ func (m *Mover) Run(ctx context.Context) error { return true }, ` - select json(persons.actor), persons.ed25519privkey, old.id, new.id, follows.id, new.id = follows.follower or exists (select 1 from follows where follower = persons.id and followed = new.id) from + select json(persons.actor), persons.ed25519seed, persons.mldsa44seed, old.id, new.id, follows.id, new.id = follows.follower or exists (select 1 from follows where follower = persons.id and followed = new.id) from persons old join persons new @@ -116,18 +117,16 @@ func (m *Mover) Run(ctx context.Context) error { } for _, row := range rows { - key := httpsig.Key{ID: row.Actor.AssertionMethod[0].ID, PrivateKey: ed25519.NewKeyFromSeed(row.Ed25519PrivKey)} - if row.OnlyRemove { slog.Info("Removing follow of moved actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID) } else { slog.Info("Moving follow", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID) - if err := m.Inbox.Follow(ctx, &row.Actor, key, row.NewID); err != nil { + if err := m.Inbox.Follow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519Seed, row.MLDSA44Seed), row.NewID); err != nil { slog.Warn("Failed to follow new actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID, "error", err) continue } } - if err := m.Inbox.Unfollow(ctx, &row.Actor, key, row.OldID, row.OldFollowID); err != nil { + if err := m.Inbox.Unfollow(ctx, &row.Actor, proof.SigningSeed(&row.Actor, row.Ed25519Seed, row.MLDSA44Seed), row.OldID, row.OldFollowID); err != nil { slog.Warn("Failed to unfollow old actor", "follow", row.OldFollowID, "old", row.OldID, "new", row.NewID, "error", err) } } diff --git a/outbox/poller.go b/outbox/poller.go index c9a4359b..d3c8379e 100644 --- a/outbox/poller.go +++ b/outbox/poller.go @@ -18,11 +18,12 @@ package outbox import ( "context" - "crypto/ed25519" "database/sql" "log/slog" "time" + "github.com/dimkr/tootik/proof" + "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/dbx" "github.com/dimkr/tootik/httpsig" @@ -36,13 +37,14 @@ type Poller struct { func (p *Poller) Run(ctx context.Context) error { rows, err := dbx.QueryCollectIgnore[struct { - PollID string - Option sql.NullString - OptionCount int64 - VotersCount int64 - Object ap.Object - Actor ap.Actor - ED25519PrivKey []byte + PollID string + Option sql.NullString + OptionCount int64 + VotersCount int64 + Object ap.Object + Actor ap.Actor + ED25519Seed []byte + MLDSA44Seed []byte }]( ctx, p.DB, @@ -52,7 +54,7 @@ func (p *Poller) Run(ctx context.Context) error { }, ` with polls as ( - select notes.id, notes.object, persons.actor as author, persons.ed25519privkey + select notes.id, notes.object, persons.actor as author, persons.ed25519seed, persons.mldsa44seed from notes join persons on persons.id = notes.author where @@ -69,7 +71,8 @@ func (p *Poller) Run(ctx context.Context) error { coalesce(voter_counts.count, 0), json(polls.object), json(polls.author), - polls.ed25519privkey + polls.ed25519seed, + polls.mldsa44seed from polls join json_each(polls.object->'$.anyOf') as anyof left join ( @@ -99,7 +102,7 @@ func (p *Poller) Run(ctx context.Context) error { ap.Object Author ap.Actor - Key ed25519.PrivateKey + Key httpsig.Key CurrentVotersCount int64 CurrentVotes map[string]int64 } @@ -111,7 +114,7 @@ func (p *Poller) Run(ctx context.Context) error { info = &poll{ Object: row.Object, Author: row.Actor, - Key: ed25519.NewKeyFromSeed(row.ED25519PrivKey), + Key: proof.SigningSeed(&row.Actor, row.ED25519Seed, row.MLDSA44Seed), CurrentVotersCount: row.VotersCount, CurrentVotes: make(map[string]int64, len(row.Object.AnyOf)), } @@ -156,10 +159,7 @@ func (p *Poller) Run(ctx context.Context) error { if err := p.Inbox.UpdateNote( ctx, &poll.Author, - httpsig.Key{ - ID: poll.Author.AssertionMethod[0].ID, - PrivateKey: poll.Key, - }, + poll.Key, &poll.Object, ); err != nil { slog.Warn("Failed to update poll results", "poll", poll.ID, "error", err) diff --git a/proof/key.go b/proof/key.go new file mode 100644 index 00000000..f778e16b --- /dev/null +++ b/proof/key.go @@ -0,0 +1,53 @@ +/* +Copyright 2026 Dima Krasner + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package proof + +import ( + "crypto/ed25519" + "regexp" + + "github.com/cloudflare/circl/sign/mldsa/mldsa44" + "github.com/dimkr/tootik/ap" + "github.com/dimkr/tootik/httpsig" +) + +var mldsa44DIDRegex = regexp.MustCompile(`\bdid:key:` + ap.MLDSA44PubBase64 + `(?:[\/#?]|$)`) + +// SigningKey the key that should be used to create proofs on behalf of actor. +func SigningKey(id string, keys [3]httpsig.Key) httpsig.Key { + if mldsa44DIDRegex.MatchString(id) { + return keys[2] + } + + return keys[1] +} + +// SigningSeed the key that should be used to create proofs on behalf of actor. +func SigningSeed(actor *ap.Actor, ed25519Seed, mldsa44Seed []byte) httpsig.Key { + if mldsa44DIDRegex.MatchString(actor.ID) { + _, priv := mldsa44.NewKeyFromSeed((*[mldsa44.SeedSize]byte)(mldsa44Seed)) + return httpsig.Key{ + ID: actor.AssertionMethod[1].ID, + PrivateKey: priv, + } + } + + return httpsig.Key{ + ID: actor.AssertionMethod[0].ID, + PrivateKey: ed25519.NewKeyFromSeed(ed25519Seed), + } +} diff --git a/proof/proof.go b/proof/proof.go index 7f37f8b7..546b2129 100644 --- a/proof/proof.go +++ b/proof/proof.go @@ -17,18 +17,22 @@ limitations under the License. // Package proof creates and verifies integrity proofs. // // See https://codeberg.org/fediverse/fep/src/branch/main/fep/8b32/fep-8b32.md for more details. +// +// In addition to eddsa-jcs-2022, this package supports mldsa44-jcs-2024; see https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024. package proof import ( "crypto" "crypto/ed25519" "crypto/sha256" + "encoding/base64" "encoding/json" "errors" "fmt" "time" "github.com/btcsuite/btcutil/base58" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/httpsig" "github.com/gowebpki/jcs" @@ -43,7 +47,7 @@ func normalizeJSON(v any) ([]byte, error) { return jcs.Transform(j) } -// Create creates an eddsa-jcs-2022 integrity proof for a JSON object. +// Create creates an integrity proof for a JSON object. func Create(key httpsig.Key, doc any) (ap.Proof, error) { switch v := doc.(type) { case *ap.Activity: @@ -77,11 +81,6 @@ func Create(key httpsig.Key, doc any) (ap.Proof, error) { } func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) { - edKey, ok := key.PrivateKey.(ed25519.PrivateKey) - if !ok { - return ap.Proof{}, fmt.Errorf("wrong key type: %T", key.PrivateKey) - } - created := now.UTC().Format(time.RFC3339) keyID := key.ID @@ -92,12 +91,22 @@ func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) proof := ap.Proof{ Context: context, Type: "DataIntegrityProof", - CryptoSuite: "eddsa-jcs-2022", Created: created, Purpose: "assertionMethod", VerificationMethod: keyID, } + switch key.PrivateKey.(type) { + case ed25519.PrivateKey: + proof.CryptoSuite = "eddsa-jcs-2022" + + case *mldsa44.PrivateKey: + proof.CryptoSuite = "mldsa44-jcs-2024" + + default: + return ap.Proof{}, fmt.Errorf("wrong key type: %T", key.PrivateKey) + } + cfg, err := normalizeJSON(proof) if err != nil { return ap.Proof{}, err @@ -111,11 +120,23 @@ func create(key httpsig.Key, now time.Time, doc, context any) (ap.Proof, error) cfgHash := sha256.Sum256(cfg) docHash := sha256.Sum256(data) - proof.Value = "z" + base58.Encode(ed25519.Sign(edKey, append(cfgHash[:], docHash[:]...))) + switch v := key.PrivateKey.(type) { + case ed25519.PrivateKey: + proof.Value = "z" + base58.Encode(ed25519.Sign(v, append(cfgHash[:], docHash[:]...))) + + case *mldsa44.PrivateKey: + sig := make([]byte, mldsa44.SignatureSize) + if err := mldsa44.SignTo(v, append(cfgHash[:], docHash[:]...), nil, true, sig); err != nil { + return ap.Proof{}, err + } + + proof.Value = "u" + base64.RawURLEncoding.EncodeToString(sig) + } + return proof, nil } -// Add adds an eddsa-jcs-2022 integrity proof to a JSON object. +// Add adds an integrity proof to a JSON object. func Add(key httpsig.Key, now time.Time, raw []byte) ([]byte, error) { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { @@ -133,24 +154,15 @@ func Add(key httpsig.Key, now time.Time, raw []byte) ([]byte, error) { // Verify verifies an integrity proof. func Verify(key crypto.PublicKey, proof ap.Proof, context any, raw []byte) error { - edKey, ok := key.(ed25519.PublicKey) - if !ok { - return fmt.Errorf("wrong key type: %T", key) - } - if proof.Type != "DataIntegrityProof" { return errors.New("invalid type: " + proof.Type) } - if proof.CryptoSuite != "eddsa-jcs-2022" { - return errors.New("invalid cryptosuite: " + proof.CryptoSuite) - } - if proof.Purpose != "assertionMethod" { return errors.New("invalid purpose: " + proof.Purpose) } - if len(proof.Value) <= 1 || proof.Value[0] != 'z' { + if len(proof.Value) <= 1 { return errors.New("invalid value: " + proof.Value) } @@ -176,8 +188,17 @@ func Verify(key crypto.PublicKey, proof ap.Proof, context any, raw []byte) error options := proof options.Value = "" - if options.Context == nil { + switch proof.CryptoSuite { + case "eddsa-jcs-2022": + if options.Context == nil { + options.Context = context + } + + case "mldsa44-jcs-2024": options.Context = context + + default: + return fmt.Errorf("invalid cryptosuite: %s/%T", proof.CryptoSuite, key) } cfg, err := normalizeJSON(options) @@ -187,8 +208,39 @@ func Verify(key crypto.PublicKey, proof ap.Proof, context any, raw []byte) error cfgHash := sha256.Sum256(cfg) - if !ed25519.Verify(edKey, append(cfgHash[:], docHash[:]...), base58.Decode(proof.Value[1:])) { - return errors.New("proof verification failed") + switch proof.CryptoSuite { + case "eddsa-jcs-2022": + if proof.Value[0] != 'z' { + return errors.New("invalid value: " + proof.Value) + } + + edKey, ok := key.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("wrong key type: %T", key) + } + + if !ed25519.Verify(edKey, append(cfgHash[:], docHash[:]...), base58.Decode(proof.Value[1:])) { + return errors.New("proof verification failed") + } + + case "mldsa44-jcs-2024": + if proof.Value[0] != 'u' { + return errors.New("invalid value: " + proof.Value) + } + + mlKey, ok := key.(*mldsa44.PublicKey) + if !ok { + return fmt.Errorf("wrong key type: %T", key) + } + + sig, err := base64.RawURLEncoding.DecodeString(proof.Value[1:]) + if err != nil { + return fmt.Errorf("failed to decode proof: %w", err) + } + + if !mldsa44.Verify(mlKey, append(cfgHash[:], docHash[:]...), nil, sig) { + return errors.New("proof verification failed") + } } return nil diff --git a/proof/proof_test.go b/proof/proof_test.go index 9fe89169..f12af0cb 100644 --- a/proof/proof_test.go +++ b/proof/proof_test.go @@ -18,17 +18,19 @@ package proof import ( "crypto/ed25519" + "encoding/base64" "encoding/json" "testing" "time" "github.com/btcsuite/btcutil/base58" + "github.com/cloudflare/circl/sign/mldsa/mldsa44" "github.com/dimkr/tootik/ap" "github.com/dimkr/tootik/httpsig" ) // https://codeberg.org/fediverse/fep/src/commit/3a5942066f989d8317befe6457b48237bc61efe0/fep/8b32/fep-8b32.feature#L3 -func TestProof_Sign(t *testing.T) { +func TestProof_SignEd25519(t *testing.T) { t.Parallel() raw := []byte(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/data-integrity/v1"],"id":"https://server.example/activities/1","type":"Create","actor":"https://server.example/users/alice","object":{"id":"https://server.example/objects/1","type":"Note","attributedTo":"https://server.example/users/alice","content":"Hello world","location":{"type":"Place","longitude":-71.184902,"latitude":25.273962}}}`) @@ -57,7 +59,7 @@ func TestProof_Sign(t *testing.T) { } // https://codeberg.org/fediverse/fep/src/commit/3a5942066f989d8317befe6457b48237bc61efe0/fep/8b32/fep-8b32.feature#L67 -func TestProof_Verify(t *testing.T) { +func TestProof_VerifyEd25519(t *testing.T) { t.Parallel() raw := []byte(`{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/data-integrity/v1"],"id":"https://server.example/activities/1","type":"Create","actor":"https://server.example/users/alice","object":{"id":"https://server.example/objects/1","type":"Note","attributedTo":"https://server.example/users/alice","content":"Hello world","location":{"type":"Place","longitude":-71.184902,"latitude":25.273962}},"proof":{"@context":["https://www.w3.org/ns/activitystreams","https://w3id.org/security/data-integrity/v1"],"type":"DataIntegrityProof","cryptosuite":"eddsa-jcs-2022","verificationMethod":"https://server.example/users/alice#ed25519-key","proofPurpose":"assertionMethod","proofValue":"zLaewdp4H9kqtwyrLatK4cjY5oRHwVcw4gibPSUDYDMhi4M49v8pcYk3ZB6D69dNpAPbUmY8ocuJ3m9KhKJEEg7z","created":"2023-02-24T23:36:38Z"}}`) @@ -71,3 +73,32 @@ func TestProof_Verify(t *testing.T) { t.Fatalf("Failed to verify proof: %v", err) } } + +// https://www.w3.org/TR/vc-di-quantum-resistant-1.0/#cryptosuite-mldsa44-jcs-2024 +func TestProof_VerifyMLDSA(t *testing.T) { + t.Parallel() + + raw := []byte(`{"@context":["https://www.w3.org/ns/credentials/v2","https://w3id.org/citizenship/v4rc1"],"type":["VerifiableCredential","EmploymentAuthorizationDocumentCredential"],"issuer":{"id":"did:key:zDnaegE6RR3atJtHKwTRTWHsJ3kNHqFwv7n9YjTgmU7TyfU76","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgUPr/HwADaAIhG61j/AAAAABJRU5ErkJggg=="},"credentialSubject":{"type":["Person","EmployablePerson"],"givenName":"JOHN","additionalName":"JACOB","familyName":"SMITH","image":"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg==","gender":"Male","residentSince":"2015-01-01","birthCountry":"Bahamas","birthDate":"1999-07-17","employmentAuthorizationDocument":{"type":"EmploymentAuthorizationDocument","identifier":"83627465","lprCategory":"C09","lprNumber":"999-999-999"}},"name":"Employment Authorization Document","description":"Example Employment Authorization Document.","validFrom":"2019-12-03T00:00:00Z","validUntil":"2029-12-03T00:00:00Z","proof":{"type":"DataIntegrityProof","cryptosuite":"mldsa44-jcs-2024","created":"2023-02-24T23:36:38Z","verificationMethod":"did:key:ukCRKDtY8Do_dXzYGyuX7BY-1dDYM4FuSiw0gFdO-eJXFH0eqlt4_CP4sEISGAzNlKDzLpUJWoInRywXOpd7FCp_QAJAlL7iRo4cepKhzhlq8xt6qd5jkhYF9tNH8z3RGDl9aunNy_06fWLYNScWd5RmGg46Po8T-kIjMMkJftaqqZcGDxktpu9Et2bnaZMx4K98YyG1urUpM9lgvldgg2qv-6XCrm2uXlJ9U-HN4xtQKn4Ug-5xPwbhPGR2pbcBScTFotkhBqLc2eQLL6zPutWF83sSZbOhD_11BjMkeiLyJbMeHCIhz5GDIbPksEFIaSho3MdFo5fpQ8QZoqCit3Jn4ddfuShfIoLU1Hw5EZ0xBiqOU7e-TINd7-7HsgHLmYMGnpqljm1ot3c3cfalYsg87WQuSscO7XNH3Ewa-cgU6Bnj1SGn0plTy6Yq-GxU8XUBPwsK_IoIJbXWC0UD97c9UYpNiZi0-ECnbB-Y5_SM8auMfoIeap_buAOdXlJZmcp8xNCXI59AN9-96Sdhks5L-JmsELzyjAgjqNx8Zt3KPFc2jSwNjDVC1fEa8FdDdDT3WNkF6KTt65lb5_aIkFh20nOvT7kIJcKTgmhRGNJZgGSPYVbMypaQoaac8dtEoQjvYgnO-rM_RcsiWMHNc29br3o5wdiLXdr63MoX1lEWu_THBfeP1JuxrSbUmHOByepWbubbSM4iVQITCxBHZT0Mj2bWwIxd3nZUajzebyEnsfitV01kpzlO7bzY2uxSzyplTkRfppc_7YH0y0PHaggw0cIXNSh73wVqNZmzmJx5W0_akrvy5oSz9ZB1Io2p_fTxzibefwO700bUqbElV_yuCjD7EJ_Hfqbog80y_g9TK6koX7wYwqFNQxBVavKC-HbcT7yPdvzs9hlC2MNWCT3W7gVgeYr4AFgbV9EgMcH0GtJDKYw8vkpB_vTsaSTGZAj3TNKalAwiGO50VAmF5tknF96kOrWmNL0MdkXhnm1vXgDpP68bMt4r2Qr-hNdJ4s3_nqmSDYTnZRA4qjXjrgKQfO19txt0tX7LifE1GZ1bQyS7NqHWXyMEhw6_F8pc_tS16VhvJO_FM7CX51mLLkLCGl7DsmbnEIsVUW9qlCxb6bj53UyijTYdu6uLZW9JISE2B4EevxzwDu9UGcJPHmJYi1rRQAP__jH97GiQC8FvkdAEfKqcwV9jAbBPQPG6lUkBLcoijgR3Bcwd-ta92oeZmcpoJ97PzzBbCL-NrppJ2HHQ1SMsYWoPveZTmZc66YBA0P9YfT4hZ0RQiP2gxB4snTvMFI0Ot6Q2nQ0p5DMxmWqIaCKW53rqn16AVXQeqC2TJjlbjA9sC6pr8GEGY2OQUgEmWu5GmnOSz1lNY7fNHJypChnieI_hyYiy06qouUpoHA5z_IUtfzZoMIG0yJiGUUpF9BJvYChDECCqaUM1kWnO5tKcohSKq5Hqwu_EWDRYF2tj7igSimZkS4Pts41tu8nIaVk5EkzAX9gCR2EX3Lk869mIxSyBS3MyG_NotPcbm6uXDn_YkV5Z0HkxUxYRA9hIG-UhKhK3VOaHZP8GcQN8noOMa2CnPd208X6HOzlIlxs7SRbzppUs_fHN1eROglNy-2oJWGmo-xOy0Qd44TtY0S_bYhu6iH6inrx3-yncSrWFxEiYosvYJD4ZBSyrV4d6UsfeNSHYS0ODTsdPqz4SYTeloZbIx8XWz7fxLXlNyLr3s9tp-Q25f1vTIrmQL","proofPurpose":"assertionMethod","proofValue":"uTSucVLvXmOpmjGGNB-B9rM-u4HzBxN8ZIuZbpTHrjOTNBnahoE4PSdkeD-IzLLXykJn0aYq_APExy-Ka0BcJNMvKgkdjbbP33WmUwkzljno3szRUDrN9KX2DMH7j0iOBakU4ByjD-hTSO1iR6rlxsZPHJM1H-WLMhzVSggBILAuglItzstl663Gz5bFjEfbKAgfe50L4v4PjLFSDbJYcg65GtCKRXISkWrnJRuToWwvTVdcnIBOQwPBFKsvApPJMKrUTkIuZf4-V1uJ81zzct4o20O-DqLQ5bHfOR2n5Y4DSy6e5zg0-S3ADKtMtuPaQ8cAPUTEKRXGRQnSndnrtgMh2dimvpSaaw0TDy7zY6vrDxJa1tkrS0ulKf3Xz8xsNrNIkx4SaKYWPTjhRvdKqjdrpbGRt3mRSFFc0VE8vK44F_EVFIhwouL-4Rm4mXU2QkiO0YkwuAJM-QdWUACqzJ7TSf2QrrU8zAwOLbrGS5uZ1qLGD1PcgWfg2d0zTAYmcWP4LP63fTnFxwr-L0N_3MLFXixHNEp5osMlo2lhl5noDCmQpqCgluxkd5gXs1NSpOBbWVQyYWcj0WMBtMam8AeqXpA39L7oqvYxqbEpiwvKrmHsXIEZrnsHKCk2P0yc10AFCCtsIapvTHwIAjbDhX11HFU5cci4X5vCdG2BUzRsgmGeiYiUClCHmqsBW4z2GA9r0d9jtHZ03nMie_qS95XPsXuAFqypsP1HOfcIUAHHS9Wn4XGFz3hXoqMsmoUGRg9vEpC2j_nkcYZQphYLs54veWq5BBzoMqPuvYhhRdawdCnn-LTf7AxQgVGoRTpTy4IkXxr_pC1LUJZJkdKeG-2TuQzyHSkbMPu3YbWsGy2KxdGeFN2yUI8TTQ-MFHl-_jDCRBrAYyCVOgML4NAtbGqvs7h2tGZYI-m9MfjG0vjp7CUyIPD8BV-Yhku_bHd0hcrseKtYYyUjxISf2wveo4dfQ2AnCVdDAbBmznjPIDlkqx0316sRc-vXJGRQmfXOW1dNk-7WNBrJVQbnT9m6cf0UEl3mEgagk1_lLOxTgjzZRpWcOB827VB3hPi7RdI6U6knXuOflHPt9BZN7i6OAl76k69uMFH2KNH3Abm0GDhOv_nu1lEaOH8aXdOqL3U8Yo0cp5roOoTw5fJP2gxwI3DY0TOWeNOCfLXmnodgoGaKG2Vns4_-gN_Mg7g0ZinguwJMwKACx07H__ffh8jYQdc87EjCNyH4m8hJICvcC81J7CKb89YTZm7IM0D1_qTR5t-DkuU4ypxNuFOCxWpN9y2QiLAAobDdc1Y_S3nXFFkLmsn7hUNhcgXxPC3jLifiM0IV7DAqmQpk2ZGE59l0VTKb3F2Ualj9JcqKtLg_b2KqprUol9WtjFlkbxqJPYyCKnSEitzDnDsfxTRFEIViTx5-1SFb0NjPE_hv5MewCkizNpfo0b-m-FxvyWJnDYt4Igv8JtgF0K_xMRC9Tf3NaQgFHb1OgkBz04C3wsxoPLqTgMWoxcZ2-2x7TRRvX2Nh1Ye-ZrpmF5hVeRMK0ECj_t5HLPaq2md18rqnhwsZv84-V0eReDyXVIhkE2eAKedCM9t13UjTfF1qFoUQ3D8xQ6MfR7zFwf6X78Tb0EFs0cBQ1TatzWysbE2b_0k-YbT78G4Ko8FlmTljBN1b0StKxzOE1Kp1h4nDBY9jZYYPNnVrtAGn2AKN3HWr0bhhF8fW_G4SA_MGu2r6LnobB3MLCSj1lbVZSk5YtCtMnkldAsDagoI8lRBlKW1R7PFvXatSHcwbe352nVuvZYxs9QJVSylf2QS1xeUQUMS4AHd4h8Y9HqmnGPr5JX65uch8sr1bWcpGiNlwnMlFy89pnEZU2v7IiC_foLi8JbxMud1k2XRDwThhepEf5bxqlBcgjF8vAbGEKAJg5Oahl0GCBffuHhuUXmuF6XwOxLovlJUM8oFCTayQL42NGz_Z2cuFltmiy-cbI8NmEpvlOPnGZBj00ZIrp9tAgwepYvlt5ticFn9ufU-f8xZpBpZb-rf5sVTNqYmoOYvhKVhE5cCPpCbzZ5GvW5ukB8yLLJC5sc6df9oSoujovfA_VAXqsvmBuKU4cHcNTNEqzsGEg_l0ln5FIHKH3CRUTDemKN2vbtmTz1snn4VfdBFAlhJhBItpBmd3HibH-1q2WD313j-cE5nW_QgQeDn_JFJ7zwlORQVRUkeB942HJjkHWXCJMXz9LKxdncKalaVNm-yVjDkQdgA1tKl9bc_QnAL7HWhtHFc_XhzJvQxqLJ0aLUkkrnphrqscG6D_Kdh7aTTkDjDSA-dmgRQBh51YVBnfnp5V28AwmGXXglBAWCWChGmAtac6xeLbxW143426J4HMAUIpLgNhjetQoQqKzVTIpzcyo-GK8L3C0calt57orTswSRqDjxg_6zAQ6RPNoThToRqb2QgHr-gOop6NEJkEy0K8GwPg3nYAFgVVjcCyDtMEbIrHXJ9WKg3oTd14eC7GNZgQ75aP3HpUAqT5gqWdxer35Ohs3n1FylwreS1kOZ5Z4OVW5PVJkNHhKPfaNq4mQT5vBAWlWphOotPHwTbN7oMiOGYu-AMTnsLPCn0A3VEXx16EROpu0zlVisEZo1sya8nQaJYiI_i3MEWqvV2ypvcMDYt_ArFjxMU3tjV5tNcJgIoE5E5UCGyo2HQMvN03T0GdHZr7txswg9HpRJqntiJzm0iAr9BhRPfErg4HLQyc92gOH6UdczP4hvbweP3mcW67yUT3lH31vznZ5lIJ0pth3H_7khwUff5daIROar2usWxoMWTItY5HC7v5HBjnfqj4EoVi1A4Uw7RvHhaCMkfDrqqntNM0TDDSfDP1uCB1RwZtcuWpNfLWYyP1B9XqwKg3EworHhIq1vI74gZROvebyYqx8UCFeiLubTfXHJrC2evT99ha6jf7vg8zKgew6Cj3Jz_RSRE5rF5uQQno6PsevbKKtZsQmn0PQphfNzWqacMpred2yrmetGOEG_JvYxx5Scmu8w8Yg-3V7yhMeDsOh0DZ8sjTw5d-w3zKUNeU2IZzz5wvaJ7-8RRxyJqxFsUFaBv7WxUZ0bmWg4pRXdBUKNW53mEUGDigpRF6Fla-6wc4BCDhwfJWdpaixu8Lf4fkRKCs0Y4-lrsTH1-zwNkKBudbd6v4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwbKDA"}}`) + + var a struct { + Context any `json:"@context"` + Proof ap.Proof `json:"proof"` + } + if err := json.Unmarshal(raw, &a); err != nil { + t.Fatalf("Failed to unmarshal activity: %v", err) + } + + pubBytes, err := base64.RawURLEncoding.DecodeString("kCRKDtY8Do_dXzYGyuX7BY-1dDYM4FuSiw0gFdO-eJXFH0eqlt4_CP4sEISGAzNlKDzLpUJWoInRywXOpd7FCp_QAJAlL7iRo4cepKhzhlq8xt6qd5jkhYF9tNH8z3RGDl9aunNy_06fWLYNScWd5RmGg46Po8T-kIjMMkJftaqqZcGDxktpu9Et2bnaZMx4K98YyG1urUpM9lgvldgg2qv-6XCrm2uXlJ9U-HN4xtQKn4Ug-5xPwbhPGR2pbcBScTFotkhBqLc2eQLL6zPutWF83sSZbOhD_11BjMkeiLyJbMeHCIhz5GDIbPksEFIaSho3MdFo5fpQ8QZoqCit3Jn4ddfuShfIoLU1Hw5EZ0xBiqOU7e-TINd7-7HsgHLmYMGnpqljm1ot3c3cfalYsg87WQuSscO7XNH3Ewa-cgU6Bnj1SGn0plTy6Yq-GxU8XUBPwsK_IoIJbXWC0UD97c9UYpNiZi0-ECnbB-Y5_SM8auMfoIeap_buAOdXlJZmcp8xNCXI59AN9-96Sdhks5L-JmsELzyjAgjqNx8Zt3KPFc2jSwNjDVC1fEa8FdDdDT3WNkF6KTt65lb5_aIkFh20nOvT7kIJcKTgmhRGNJZgGSPYVbMypaQoaac8dtEoQjvYgnO-rM_RcsiWMHNc29br3o5wdiLXdr63MoX1lEWu_THBfeP1JuxrSbUmHOByepWbubbSM4iVQITCxBHZT0Mj2bWwIxd3nZUajzebyEnsfitV01kpzlO7bzY2uxSzyplTkRfppc_7YH0y0PHaggw0cIXNSh73wVqNZmzmJx5W0_akrvy5oSz9ZB1Io2p_fTxzibefwO700bUqbElV_yuCjD7EJ_Hfqbog80y_g9TK6koX7wYwqFNQxBVavKC-HbcT7yPdvzs9hlC2MNWCT3W7gVgeYr4AFgbV9EgMcH0GtJDKYw8vkpB_vTsaSTGZAj3TNKalAwiGO50VAmF5tknF96kOrWmNL0MdkXhnm1vXgDpP68bMt4r2Qr-hNdJ4s3_nqmSDYTnZRA4qjXjrgKQfO19txt0tX7LifE1GZ1bQyS7NqHWXyMEhw6_F8pc_tS16VhvJO_FM7CX51mLLkLCGl7DsmbnEIsVUW9qlCxb6bj53UyijTYdu6uLZW9JISE2B4EevxzwDu9UGcJPHmJYi1rRQAP__jH97GiQC8FvkdAEfKqcwV9jAbBPQPG6lUkBLcoijgR3Bcwd-ta92oeZmcpoJ97PzzBbCL-NrppJ2HHQ1SMsYWoPveZTmZc66YBA0P9YfT4hZ0RQiP2gxB4snTvMFI0Ot6Q2nQ0p5DMxmWqIaCKW53rqn16AVXQeqC2TJjlbjA9sC6pr8GEGY2OQUgEmWu5GmnOSz1lNY7fNHJypChnieI_hyYiy06qouUpoHA5z_IUtfzZoMIG0yJiGUUpF9BJvYChDECCqaUM1kWnO5tKcohSKq5Hqwu_EWDRYF2tj7igSimZkS4Pts41tu8nIaVk5EkzAX9gCR2EX3Lk869mIxSyBS3MyG_NotPcbm6uXDn_YkV5Z0HkxUxYRA9hIG-UhKhK3VOaHZP8GcQN8noOMa2CnPd208X6HOzlIlxs7SRbzppUs_fHN1eROglNy-2oJWGmo-xOy0Qd44TtY0S_bYhu6iH6inrx3-yncSrWFxEiYosvYJD4ZBSyrV4d6UsfeNSHYS0ODTsdPqz4SYTeloZbIx8XWz7fxLXlNyLr3s9tp-Q25f1vTIrmQL") + if err != nil { + t.Fatalf("Failed to decode public key: %v", err) + } + + var pub mldsa44.PublicKey + if err := pub.UnmarshalBinary(pubBytes[2:]); err != nil { + t.Fatalf("Failed to decode key: %v", err) + } + + if err := Verify(&pub, a.Proof, a.Context, raw); err != nil { + t.Fatalf("Failed to verify proof: %v", err) + } +} diff --git a/test/community_test.go b/test/community_test.go index dc916f63..f7147ee2 100644 --- a/test/community_test.go +++ b/test/community_test.go @@ -41,7 +41,8 @@ func TestCommunity_NewThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -103,7 +104,8 @@ func TestCommunity_NewThreadNotFollowing(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -162,7 +164,8 @@ func TestCommunity_NewThreadNotPublic(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -224,7 +227,8 @@ func TestCommunity_ReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -316,7 +320,8 @@ func TestCommunity_ReplyInThreadAuthorNotFollowing(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -379,7 +384,8 @@ func TestCommunity_ReplyInThreadSenderNotFollowing(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -412,7 +418,8 @@ func TestCommunity_ReplyInThreadSenderNotFollowing(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin"}`, ) @@ -475,7 +482,8 @@ func TestCommunity_DuplicateReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -578,7 +586,8 @@ func TestCommunity_EditedReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -702,7 +711,8 @@ func TestCommunity_UnknownEditedReplyInThread(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) diff --git a/test/forward_test.go b/test/forward_test.go index 27d24249..3898c234 100644 --- a/test/forward_test.go +++ b/test/forward_test.go @@ -70,7 +70,8 @@ func TestForward_ReplyToPostByFollower(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -139,7 +140,8 @@ func TestForward_ReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -187,7 +189,8 @@ func TestForward_LocalReplyToLocalPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -260,7 +263,8 @@ func TestForward_ReplyToReplyToPostByFollower(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -325,7 +329,8 @@ func TestForward_ReplyToUnknownPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -390,7 +395,8 @@ func TestForward_ReplyToDM(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -444,7 +450,8 @@ func TestForward_NotFollowingAuthor(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -509,7 +516,8 @@ func TestForward_NotReplyToLocalPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -563,7 +571,8 @@ func TestForward_ReplyToFederatedPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -673,7 +682,8 @@ func TestForward_MaxDepth(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -798,7 +808,8 @@ func TestForward_MaxDepthPlusOne(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -849,7 +860,8 @@ func TestForward_ReplyToLocalPostByLocalFollower(t *testing.T) { assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -890,7 +902,8 @@ func TestForward_EditedReplyToLocalPostByLocalFollower(t *testing.T) { assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -938,7 +951,8 @@ func TestForward_DeletedReplyToLocalPostByLocalFollower(t *testing.T) { assert.Regexp(`^30 /users/view/\S+\r\n$`, whisper) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -1004,7 +1018,8 @@ func TestForward_EditedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) @@ -1104,7 +1119,8 @@ func TestForward_ResentEditedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) @@ -1215,7 +1231,8 @@ func TestForward_DeletedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) @@ -1293,7 +1310,8 @@ func TestForward_DeletedDeletedReplyToPublicPost(t *testing.T) { assert.NoError(tx.Commit()) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","id":"https://127.0.0.1/user/dan","preferredUsername":"dan"}`, ) diff --git a/test/move_test.go b/test/move_test.go index 15c657b1..0e6809bd 100644 --- a/test/move_test.go +++ b/test/move_test.go @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "net/http" "strings" "testing" @@ -36,7 +37,8 @@ func TestMove_FederatedToFederated(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) @@ -55,7 +57,8 @@ func TestMove_FederatedToFederated(t *testing.T) { assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan","alsoKnownAs":"https://127.0.0.1/user/dan"}`, ) @@ -84,14 +87,16 @@ func TestMove_FederatedToFederatedTwoAccounts(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan","alsoKnownAs":["https://::1/user/dan","https://127.0.0.1/user/dan"]}`, ) @@ -132,14 +137,16 @@ func TestMove_FederatedToFederatedNotLinked(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan"}`, ) @@ -180,7 +187,8 @@ func TestMove_FederatedToLocal(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://localhost.localdomain:8443/user/bob"}`, ) @@ -221,7 +229,8 @@ func TestMove_FederatedToLocalLinked(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://localhost.localdomain:8443/user/bob"}`, ) @@ -265,14 +274,16 @@ func TestMove_FollowingBoth(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","movedTo":"https://::1/user/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://::1/user/dan"), "https://::1/user/dan", `{"id":"https://::1/user/dan","type":"Person","preferredUsername":"dan","alsoKnownAs":"https://127.0.0.1/user/dan"}`, ) @@ -411,7 +422,8 @@ func TestMove_LocalToFederated(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":["https://localhost.localdomain:8443/user/alice"]}`, ) @@ -459,7 +471,8 @@ func TestMove_LocalToFederatedNoSourceToTargetAlias(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":["https://localhost.localdomain:8443/user/alice"]}`, ) @@ -487,7 +500,8 @@ func TestMove_LocalToFederatedNoTargetToSourceAlias(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":[]}`, ) @@ -520,7 +534,8 @@ func TestMove_LocalToFederatedAlreadyMoved(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/alice"), "https://127.0.0.1/user/alice", `{"id":"https://127.0.0.1/user/alice","type":"Person","preferredUsername":"alice","alsoKnownAs":["https://localhost.localdomain:8443/user/alice"]}`, ) diff --git a/test/outbox_test.go b/test/outbox_test.go index 554592d7..7a03dc29 100644 --- a/test/outbox_test.go +++ b/test/outbox_test.go @@ -1,5 +1,5 @@ /* -Copyright 2023 - 2025 Dima Krasner +Copyright 2023 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "testing" @@ -217,14 +218,16 @@ func TestOutbox_PublicPostInGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -253,14 +256,16 @@ func TestOutbox_PublicPostInGroupUnauthenticatedUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -288,14 +293,16 @@ func TestOutbox_PublicPostInGroupAudienceSetByUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -337,14 +344,16 @@ func TestOutbox_PublicPostInGroupAudienceSetByGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -386,14 +395,16 @@ func TestOutbox_PublicPostInGroupDeletedByUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -435,21 +446,24 @@ func TestOutbox_PublicPostInGroupDeletedByAnotherUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"id":"https://127.0.0.1/user/erin","type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -491,14 +505,16 @@ func TestOutbox_PublicPostInGroupDeletedByGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -540,21 +556,24 @@ func TestOutbox_PublicPostInGroupForwardedDelete(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -596,14 +615,16 @@ func TestOutbox_PublicPostInGroupEditedByUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -645,14 +666,16 @@ func TestOutbox_PostToFollowersInGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -686,14 +709,16 @@ func TestOutbox_PostToFollowersInGroupNotFollowingGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -727,14 +752,16 @@ func TestOutbox_PostToFollowersInGroupNotAccepted(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -765,14 +792,16 @@ func TestOutbox_PostToFollowersInGroupFollowingAuthor(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -806,14 +835,16 @@ func TestOutbox_PostToFollowersInGroupUnauthenticatedUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -847,14 +878,16 @@ func TestOutbox_DMInGroupNotFollowingGroup(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) @@ -888,14 +921,16 @@ func TestOutbox_DMInGroupAnotherUser(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://other.localdomain/group/people"), "https://other.localdomain/group/people", `{"id":"https://other.localdomain/group/people","type":"Group","preferredUsername":"people"}`, ) diff --git a/test/poll_test.go b/test/poll_test.go index f3cd3314..ffd68e8c 100644 --- a/test/poll_test.go +++ b/test/poll_test.go @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "testing" @@ -33,7 +34,8 @@ func TestPoll_TwoOptions(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -66,7 +68,8 @@ func TestPoll_TwoOptionsZeroVotes(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -99,7 +102,8 @@ func TestPoll_TwoOptionsOnlyZeroVotes(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -132,7 +136,8 @@ func TestPoll_OneOption(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -164,7 +169,8 @@ func TestPoll_Vote(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -204,7 +210,8 @@ func TestPoll_VoteClosedPoll(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -238,7 +245,8 @@ func TestPoll_VoteEndedPoll(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -272,7 +280,8 @@ func TestPoll_Reply(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -312,7 +321,8 @@ func TestPoll_ReplyClosedPoll(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -352,7 +362,8 @@ func TestPoll_EditVote(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -395,7 +406,8 @@ func TestPoll_DeleteReply(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -438,7 +450,8 @@ func TestPoll_Update(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) @@ -490,7 +503,8 @@ func TestPoll_OldUpdate(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"type":"Person","preferredUsername":"dan"}`, ) diff --git a/test/server.go b/test/server.go index d5494232..7edddea8 100644 --- a/test/server.go +++ b/test/server.go @@ -49,7 +49,7 @@ type server struct { Alice *ap.Actor Bob *ap.Actor Carol *ap.Actor - AppActorKeys [2]httpsig.Key + AppActorKeys [3]httpsig.Key } func (s *server) Shutdown() { diff --git a/test/users_test.go b/test/users_test.go index aa5ac0e6..76cbaf49 100644 --- a/test/users_test.go +++ b/test/users_test.go @@ -1,5 +1,5 @@ /* -Copyright 2024, 2025 Dima Krasner +Copyright 2024 - 2026 Dima Krasner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "github.com/dimkr/tootik/inbox" @@ -163,14 +164,16 @@ func TestUsers_PublicPostShared(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"id":"https://127.0.0.1/user/erin","type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) @@ -206,14 +209,16 @@ func TestUsers_PublicPostSharedNotFollowing(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"id":"https://127.0.0.1/user/erin","type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) diff --git a/test/view_test.go b/test/view_test.go index a4107101..303c7837 100644 --- a/test/view_test.go +++ b/test/view_test.go @@ -19,6 +19,7 @@ package test import ( "context" "fmt" + "github.com/dimkr/tootik/ap" "strings" "testing" @@ -307,7 +308,8 @@ func TestView_Update(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -357,7 +359,8 @@ func TestView_OldUpdate(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -507,14 +510,16 @@ func TestView_PostInGroupPublicAndGroupFollowed(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -550,14 +555,16 @@ func TestView_PostInGroupNotPublicAndGroupFollowed(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -593,14 +600,16 @@ func TestView_PostInGroupNotPublicAndGroupFollowedButNotAccepted(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -633,14 +642,16 @@ func TestView_PostInGroupNotPublicAndAuthorFollowed(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -676,14 +687,16 @@ func TestView_PostInGroupNotPublicAndAuthorFollowedButNotAccepted(t *testing.T) assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) @@ -716,21 +729,24 @@ func TestView_PostInGroupNotPublicAndGroupFollowedWithReply(t *testing.T) { assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, ) @@ -776,21 +792,24 @@ func TestView_PostInGroupNotPublicAndGroupFollowedWithPrivateReply(t *testing.T) assert := assert.New(t) _, err := server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/group/people"), "https://127.0.0.1/group/people", `{"id":"https://127.0.0.1/group/people","type":"Group","preferredUsername":"people"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/dan"), "https://127.0.0.1/user/dan", `{"id":"https://127.0.0.1/user/dan","type":"Person","preferredUsername":"dan","followers":"https://127.0.0.1/followers/dan"}`, ) assert.NoError(err) _, err = server.db.Exec( - `insert into persons (id, actor) values (?, jsonb(?))`, + `insert into persons (slug, id, actor) values (?, ?, jsonb(?))`, + ap.Slug("https://127.0.0.1/user/erin"), "https://127.0.0.1/user/erin", `{"type":"Person","preferredUsername":"erin","followers":"https://127.0.0.1/followers/erin"}`, )