Compare commits

...
Author SHA1 Message Date
Lewis 7b20f4cfb4 totp: let legacy sessions disable totp
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-28 10:50:30 +03:00
Lewis f3af04e4ae dns: fall back to defaults if smth bad
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-28 10:12:00 +03:00
Lewis aab1a945c2 session: deletes scope to did, route muts by did
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-28 09:55:11 +03:00
aveandTangled 9dc184ee33 bsky(auth): add grace period to legacy session refresh
Concurrent or retried com.atproto.server.refreshSession calls presenting the
same refresh token hit the reuse-detection path, which deleted the session and
returned "Refresh token has been revoked due to suspected compromise" —
logging users out at random. The legacy flow had no grace period, unlike OAuth.

Mirror the reference atproto PDS: every rotated refresh token gets a 2h grace
window measured from its own rotation time (used_refresh_tokens.used_at in
postgres; a rotated_at_ms field appended to the metastore used-marker, with
old-format markers decoding as outside the window). A refresh presenting a
recently-rotated token is served the session's current tokens, re-minted on
the fly with the same jti/expiry — signed JWTs are never persisted. Reuse
outside the window still revokes the session.

The grace lookup returns the session's encrypted signing key so the handler
verifies the presented token's signature before minting replacement tokens or
revoking a session; a forged token bearing a known jti gets a generic
rejection with no side effects.

Integration tests asserting the old replay-gets-401 behavior are reworked to
the new contract and now also cover forged-signature replays and
out-of-window revocation.
2026-06-27 23:54:22 +03:00
Lewis ab4eba6dc4 delegation: preset scopes grant identity & account
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-27 23:11:24 +03:00
Lewis a171518290 tranquil-store/repo: repair tolerates corrupt/missing blocks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-26 20:50:30 +03:00
LewisandTangled 28f2e04019 plc: always keep signing key in rotationKeys
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-26 18:52:12 +03:00
LewisandTangled 39a2e40b35 invite codes: dedup consumption, iron out kinks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-26 13:28:49 +03:00
isabelandTangled 05ab0b7423 nix: use systemd-nspawn tests
this speeds tests up like 10 fold; had to swap from sudo to runuser
since sudo wanted passwords
2026-06-22 20:31:06 +03:00
LewisandTangled 221b32d66f build: smaller faster prod container
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-19 09:16:30 +03:00
Lewis 1b489776c5 server healthcheck in-bin
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-18 09:34:36 +03:00
nelind 2ca15fe7a0 fix(nix): disable the nixpkgs tranquil module when the repo module is used 2026-06-17 11:53:58 +02:00
aveandTangled 48ae1e8b7b Move Dockerfile(s) from musl to glibc
Musl is tier 2 on rust support, and glibc is tier 1.
https://doc.rust-lang.org/nightly/rustc/platform-support.html
Generally, glibc rust is reported to be more performant. Could vary due
to any other reason, but in my testing, builds were up to 50% faster
(for docker cross-compilation amd64->arm64 at least).
2026-06-16 14:31:32 +03:00
aveandTangled 6838976969 fix(cors): Allow User-Agent header in CORS 2026-06-15 13:27:31 +03:00
Jim SeverinoandTangled 3045ee25c0 Fix(docs): Add correctly TOML-formatted values for first string and array examples 2026-06-15 11:04:59 +03:00
Jim SeverinoandTangled 04a90b1563 Fix(docs): Change symlinks to SSL cert files from absolute to relative 2026-06-15 11:04:59 +03:00
LewisandTangled e13ba7f4c7 ripple: anti-entropy gossip sync
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-14 18:46:41 +03:00
LewisandTangled a3f729c3cd ripple: fail-closed startup, bind policy
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-14 18:46:41 +03:00
LewisandTangled 637b817a33 ripple: cluster-key oomf authentication
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-14 18:46:41 +03:00
LewisandTangled 562f970bc3 ripple: transport backpressure & connect coalesing
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-14 18:46:41 +03:00
LewisandTangled 06fd6a1ce9 ripple: transport from tcp to quic
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-14 18:46:41 +03:00
nelindandTangled 3b07cdfb24 fix(api): dont verify signature or DID during importRepo 2026-06-13 23:33:57 +03:00
nelind 4a8826b7a1 fix(docs): i forgor auto links need absolute URIs ... lets use normal links instead 2026-06-12 01:00:24 +02:00
nelind 80afd764d3 fix(nix): provide a jemalloc build in the dev shell 2026-06-10 19:56:40 +02:00
LewisandTangled 5bbe2146ff server: serve xrpc over http/3
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-10 13:18:49 +03:00
LewisandTangled b009ccdaf2 repo: the pg side of MST structural repair
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-08 16:58:51 +03:00
nelind 39f74b5adf feat(nix): pratice what i preach. expose lib.mkPackages 2026-06-07 22:14:27 +02:00
aveandTangled a231d7da29 feat(auth): accept totp/backup codes in legacy login createSession 2026-06-06 10:42:24 +03:00
aveandTangled 63d84d38fb refactor(auth): unify short-code generation onto util::generate_token_code
Collapse the three ad-hoc short-code generators into one canonical generator
plus a shared normalizer:

- util::generate_token_code now emits the uppercase base32 XXXXX-XXXXX display
  form; new util::normalize_token_code canonicalizes user input (uppercase,
  strip hyphen/whitespace).
- email_token and legacy_2fa now generate via util, store the normalized form,
  and compare normalized input. Their private generate_short_token/generate_code
  (and BASE32_CHARS/CODE_LENGTH) are removed.
- PLC (request/sign) and password reset inline util::generate_token_code,
  persist the normalized form, email the display form, and normalize input
  before lookup. The generate_plc_token/generate_reset_code wrappers are removed.

Behavior changes: legacy login-2FA codes go from 8-digit numeric to XXXXX-XXXXX;
PLC and password-reset codes go from lowercase to uppercase. All four code types
are now accepted case-/hyphen-insensitively. OAuth web-login 2FA, account
deletion, and the long verification_token blobs are intentionally untouched.

Tests: add util normalize tests + email_token/legacy_2fa case/hyphen tests;
update integration tests to expect the canonical stored form and the new
emailed format.
2026-06-06 09:36:08 +03:00
aveandTangled fe9b88141c chore(auth): align at+jwt/refresh+jwt expiry with reference PDS 2026-06-06 01:43:46 +03:00
aveandTangled 72f5dce32b add ave.zone to contributors list in README 2026-06-06 01:42:54 +03:00
nelind cd7e01100e chore(auth): also mention that atproto spec requiers typ be "JWT" for inter-service tokens 2026-06-05 12:49:25 +02:00
aveandTangled 7c248be153 fix(auth): emit uppercase "JWT" typ in service-auth header
RFC 7519 §5.1 recommends the uppercase "JWT" typ for compatibility with
legacy implementations, and it matches the reference @atproto/pds. Parsing
already lowercases, so existing lowercase "jwt" tokens still verify.
2026-06-05 13:34:43 +03:00
aveandTangled 91999819c6 fix(proxy): limit audience of getFeed service-auth to the feed generator 2026-06-04 22:39:14 +03:00
nelindandTangled ffce1d5d05 feat(docs): add some general PDS debugging documentation 2026-06-04 13:45:08 +03:00
TylerandTangled 8e6ace2fe2 fix: derive lexicon DNS authority from all-but-last NSID segment
Permission-set expansion resolved the lexicon's DNS authority using a
fixed `parts[..2]`, which only works for three-segment NSIDs. For a
four-segment NSID such as community.lexicon.bookmarks.authManageBookmarks
this dropped a segment and queried _lexicon.lexicon.community instead of
_lexicon.bookmarks.lexicon.community, failing with "DNS resolution
failed: ... no record found".

The authority is every NSID segment except the last (the name),
reversed. Use parts[..parts.len() - 1] to match the spec and the
existing extract_namespace_authority helper, and update the DNS
authority test with three/four-segment and bookmarks regression cases.
2026-06-04 01:28:13 +03:00
LewisandTangled 3018a20843 fix(plc): allow arbitrary services to sign
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-03 11:23:51 +03:00
LewisandTangled 37fc06fb39 fix(store): unblock eventlog sync&freeze when writer dies
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
LewisandTangled 728a8c4d3b test(store): cross-store, firehose, read-validation coverage w/ faults
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
LewisandTangled 3d49e99cc3 test(store): generic consistency checker, gauntlet fault/read
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
LewisandTangled 7e823673ca test(store): untested metastore, eventlog, & archival stuff
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-02 17:29:21 +03:00
Lewis 320933598c fix(auth): error num 401 for oauth
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:38:45 +03:00
Lewis 500dc2e0e6 test(store): gauntlet sweep configs for time-travel & fsync repro
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
Lewis a220611a8b test(store): D gauntlet faults, crash-loss oracley, recoverable scenarios
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
Lewis ca7a4b4b73 fix(store): recover eventlog lastseq from tail not sidecar
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
Lewis 8ff02610e4 feat(store): inline-commit mode, committed-extent recovery, failsafe verify&rollback
Lewis: May this revision serve well! <lu5a@proton.me>
2026-06-01 19:01:04 +03:00
LewisandTangled 22f82489d5 test(pds): e2e & durability coverage for MST self-heal
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled cee483e358 feat(pds): selfhealing repo writing by detecting corruption & retrying
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled 7f8e858137 test(store): gauntlet MST-repairable & misdirected-write scenario
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled 44d73dac58 feat(store): rebuild & rewrite missing/corrupt MST blocks
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled b8cae15c12 feat(store): detect foreign&corrupt blocks on read & preserve blocks thru recovery
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 21:11:36 +03:00
LewisandTangled 9b58961bba feat(repo): missing $type? invent one
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 13:02:11 +03:00
Lewis 31ee12ecd3 fix(store): torn hint-file tail should be recoverable on reopen
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-31 11:37:21 +03:00
nelind ea106d5246 chore(nix): update frontend pnpm hash 2026-05-31 00:51:27 +02:00
Lewis 4015217a2e feat(store): Clock trait for DST
Lewis: May this revision serve well! <lu5a@proton.me>
2026-05-30 23:46:22 +03:00
188 changed files with 12641 additions and 2505 deletions
+20
View File
@@ -106,6 +106,16 @@ slow-timeout = { period = "300s", terminate-after = 8 }
filter = "binary(compaction_restart) | binary(mst_refcount_integrity) | binary(gc_compaction_restart)"
slow-timeout = { period = "120s", terminate-after = 4 }
[[profile.default.overrides]]
filter = "test(/retention_time_travel_survives_many_seeds/)"
slow-timeout = { period = "300s", terminate-after = 24 }
test-group = "io-heavy-sim"
[[profile.default.overrides]]
filter = "binary(fd_lifecycle)"
slow-timeout = { period = "300s", terminate-after = 4 }
test-group = "io-heavy-sim"
[[profile.ci.overrides]]
filter = "test(/import_with_verification/) | test(/plc_migration/)"
test-group = "serial-env-tests"
@@ -137,3 +147,13 @@ test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "binary(repo_lifecycle)"
test-group = "heavy-load-tests"
[[profile.ci.overrides]]
filter = "test(/retention_time_travel_survives_many_seeds/)"
slow-timeout = { period = "300s", terminate-after = 24 }
test-group = "io-heavy-sim"
[[profile.ci.overrides]]
filter = "binary(fd_lifecycle)"
slow-timeout = { period = "300s", terminate-after = 4 }
test-group = "io-heavy-sim"
+12
View File
@@ -0,0 +1,12 @@
target/
.git/
.jj/
**/node_modules/
frontend/dist/
frontend/coverage/
frontend/.pnpm-store/
.direnv/
result
.env
*.output
reference-pds-bsky/
+1
View File
@@ -4,5 +4,6 @@ target/
result
frontend/node_modules/
frontend/dist/
frontend/coverage/
frontend/.pnpm-store
frontend/.npmrc
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
"query": "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1 AND available_uses > 0 AND COALESCE(disabled, false) = false",
"describe": {
"columns": [],
"parameters": {
@@ -10,5 +10,5 @@
},
"nullable": []
},
"hash": "1ee6eda3e44660e7f14fcfe56adc2d41c72901b9c701fc7b992314e5370b32dc"
"hash": "0bb2cb6af37bff735b6b380697fc8e1fa2034ca0600e8c16e1e362b722192327"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Timestamptz",
"Int4"
]
},
"nullable": []
},
"hash": "4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE id = $1 AND did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "8003624cedbac8b094c83933578517abfb2eaf8e59d1d52c7ea59bf5d11cfcfe"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE access_jti = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "847ce3c34985d0957526c87e0a20c6b4e5daae08a338f7635def682ac0689cf6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE access_jti = $1 AND did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "a27e93bc594babbada10afe5c3e33a65909ec69c579329916833e4b0fe2332d3"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "session_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7"
}
@@ -0,0 +1,82 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did,\n st.access_jti, st.refresh_jti, st.access_expires_at, st.refresh_expires_at,\n k.key_bytes, k.encryption_version\n FROM used_refresh_tokens u\n JOIN session_tokens st ON st.id = u.session_id\n JOIN users us ON st.did = us.did\n JOIN user_keys k ON us.id = k.user_id\n WHERE u.refresh_jti = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "session_id",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "scope",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "controller_did",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "access_jti",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "refresh_jti",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "access_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "refresh_expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 9,
"name": "key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 10,
"name": "encryption_version",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
true,
true,
false,
false,
false,
false,
false,
true
]
},
"hash": "c08e3760be75071ac5e281cb3b05b3a02dccbf35b96c3d50c652878a38c9f9e6"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO used_refresh_tokens (refresh_jti, session_id)\n VALUES ($1, $2)\n ON CONFLICT (refresh_jti) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "c0a7b73070e6c5b9baa9caddca3c5d24427b48ed02b5cac913a0c0af1e5c9379"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM session_tokens WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4"
]
},
"nullable": []
},
"hash": "cf874abcb72017e775fe699a0b77ae9341355f30e4af84968ffeb9135dba745f"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "session_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3"
}
Generated
+178 -34
View File
@@ -210,7 +210,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [
"asn1-rs-derive",
"asn1-rs-derive 0.5.1",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
@@ -220,6 +220,22 @@ dependencies = [
"time",
]
[[package]]
name = "asn1-rs"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
dependencies = [
"asn1-rs-derive 0.6.0",
"asn1-rs-impl",
"displaydoc",
"nom 7.1.3",
"num-traits",
"rusticata-macros",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "asn1-rs-derive"
version = "0.5.1"
@@ -232,6 +248,18 @@ dependencies = [
"synstructure",
]
[[package]]
name = "asn1-rs-derive"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
"synstructure",
]
[[package]]
name = "asn1-rs-impl"
version = "0.2.0"
@@ -1046,7 +1074,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec",
"bit-vec 0.8.0",
]
[[package]]
@@ -1055,6 +1083,15 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
dependencies = [
"serde",
]
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -1970,7 +2007,21 @@ version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [
"asn1-rs",
"asn1-rs 0.6.2",
"displaydoc",
"nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
]
[[package]]
name = "der-parser"
version = "10.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs 0.7.2",
"displaydoc",
"nom 7.1.3",
"num-bigint",
@@ -2834,6 +2885,34 @@ dependencies = [
"tracing",
]
[[package]]
name = "h3"
version = "0.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10872b55cfb02a821b69dc7cf8dc6a71d6af25eb9a79662bec4a9d016056b3be"
dependencies = [
"bytes",
"fastrand",
"futures-util",
"http 1.4.0",
"pin-project-lite",
"tokio",
]
[[package]]
name = "h3-quinn"
version = "0.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2e732c8d91a74731663ac8479ab505042fbf547b9a207213ab7fbcbfc4f8b4"
dependencies = [
"bytes",
"futures",
"h3",
"quinn",
"tokio",
"tokio-util",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -4614,7 +4693,16 @@ version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [
"asn1-rs",
"asn1-rs 0.6.2",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
dependencies = [
"asn1-rs 0.7.2",
]
[[package]]
@@ -5184,7 +5272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
dependencies = [
"bit-set",
"bit-vec",
"bit-vec 0.8.0",
"bitflags",
"num-traits",
"rand 0.9.2",
@@ -5427,6 +5515,7 @@ checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"futures-io",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -5630,6 +5719,20 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "rcgen"
version = "0.14.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"x509-parser 0.18.1",
"yasna",
]
[[package]]
name = "redis"
version = "1.1.0"
@@ -7527,7 +7630,7 @@ dependencies = [
[[package]]
name = "tranquil-api"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -7578,7 +7681,7 @@ dependencies = [
[[package]]
name = "tranquil-auth"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"anyhow",
"base32",
@@ -7601,11 +7704,12 @@ dependencies = [
[[package]]
name = "tranquil-cache"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
"redis",
"thiserror 2.0.18",
"tokio-util",
"tracing",
"tranquil-config",
@@ -7615,7 +7719,7 @@ dependencies = [
[[package]]
name = "tranquil-comms"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7641,7 +7745,7 @@ dependencies = [
[[package]]
name = "tranquil-config"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"confique",
"serde",
@@ -7649,7 +7753,7 @@ dependencies = [
[[package]]
name = "tranquil-crypto"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -7665,7 +7769,7 @@ dependencies = [
[[package]]
name = "tranquil-db"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"chrono",
@@ -7682,7 +7786,7 @@ dependencies = [
[[package]]
name = "tranquil-db-traits"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"base64 0.22.1",
@@ -7698,7 +7802,7 @@ dependencies = [
[[package]]
name = "tranquil-infra"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"bytes",
@@ -7709,7 +7813,7 @@ dependencies = [
[[package]]
name = "tranquil-lexicon"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"chrono",
"futures",
@@ -7728,7 +7832,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -7751,7 +7855,7 @@ dependencies = [
[[package]]
name = "tranquil-oauth-server"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"axum",
"base64 0.22.1",
@@ -7784,7 +7888,7 @@ dependencies = [
[[package]]
name = "tranquil-pds"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"aes-gcm",
"anyhow",
@@ -7837,6 +7941,7 @@ dependencies = [
"sha2",
"sqlx",
"subtle",
"tempfile",
"testcontainers",
"testcontainers-modules",
"thiserror 2.0.18",
@@ -7877,7 +7982,7 @@ dependencies = [
[[package]]
name = "tranquil-repo"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"bytes",
"cid",
@@ -7889,19 +7994,22 @@ dependencies = [
[[package]]
name = "tranquil-ripple"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"backon",
"bincode 2.0.1",
"bytes",
"foca",
"futures",
"hkdf",
"metrics",
"parking_lot",
"quinn",
"rand 0.9.2",
"rcgen",
"rustls 0.23.37",
"serde",
"socket2 0.6.3",
"sha2",
"thiserror 2.0.18",
"tokio",
"tokio-util",
@@ -7910,11 +8018,12 @@ dependencies = [
"tranquil-config",
"tranquil-infra",
"uuid",
"x509-parser 0.18.1",
]
[[package]]
name = "tranquil-scopes"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"axum",
"futures",
@@ -7930,17 +8039,24 @@ dependencies = [
[[package]]
name = "tranquil-server"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"arc-swap",
"axum",
"bytes",
"clap",
"dotenvy",
"ed25519-dalek",
"futures-util",
"h3",
"h3-quinn",
"hex",
"http 1.4.0",
"hyper 1.8.1",
"hyper-util",
"quinn",
"rcgen",
"reqwest",
"rustls 0.23.37",
"rustls-pemfile",
"thiserror 2.0.18",
@@ -7960,7 +8076,7 @@ dependencies = [
[[package]]
name = "tranquil-signal"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"chrono",
@@ -7983,7 +8099,7 @@ dependencies = [
[[package]]
name = "tranquil-storage"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"aws-config",
@@ -8000,7 +8116,7 @@ dependencies = [
[[package]]
name = "tranquil-store"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"async-trait",
"bytes",
@@ -8049,7 +8165,7 @@ dependencies = [
[[package]]
name = "tranquil-sync"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"anyhow",
"axum",
@@ -8071,7 +8187,7 @@ dependencies = [
[[package]]
name = "tranquil-types"
version = "0.6.4"
version = "0.6.5"
dependencies = [
"chrono",
"cid",
@@ -8584,7 +8700,7 @@ checksum = "15784340a24c170ce60567282fb956a0938742dbfbf9eff5df793a686a009b8b"
dependencies = [
"base64 0.21.7",
"base64urlsafedata",
"der-parser",
"der-parser 9.0.0",
"hex",
"nom 7.1.3",
"openssl",
@@ -8600,7 +8716,7 @@ dependencies = [
"uuid",
"webauthn-attestation-ca",
"webauthn-rs-proto",
"x509-parser",
"x509-parser 0.16.0",
]
[[package]]
@@ -9154,17 +9270,35 @@ version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [
"asn1-rs",
"asn1-rs 0.6.2",
"data-encoding",
"der-parser",
"der-parser 9.0.0",
"lazy_static",
"nom 7.1.3",
"oid-registry",
"oid-registry 0.7.1",
"rusticata-macros",
"thiserror 1.0.69",
"time",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
dependencies = [
"asn1-rs 0.7.2",
"data-encoding",
"der-parser 10.0.0",
"lazy_static",
"nom 7.1.3",
"oid-registry 0.8.1",
"ring",
"rusticata-macros",
"thiserror 2.0.18",
"time",
]
[[package]]
name = "xattr"
version = "1.6.1"
@@ -9193,6 +9327,16 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yasna"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
dependencies = [
"bit-vec 0.9.1",
"time",
]
[[package]]
name = "yoke"
version = "0.8.1"
+17 -3
View File
@@ -26,7 +26,7 @@ members = [
]
[workspace.package]
version = "0.6.4"
version = "0.6.5"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -82,6 +82,8 @@ foca = { version = "1", features = ["bincode-codec", "tracing"] }
futures = "0.3"
futures-util = "0.3"
governor = "0.10"
h3 = "0.0.8"
h3-quinn = "0.0.10"
hex = "0.4"
hickory-resolver = { version = "0.24", features = ["tokio-runtime"] }
hkdf = "0.12"
@@ -106,7 +108,9 @@ parking_lot = "0.12"
multihash = "0.19"
p256 = { version = "0.13", features = ["ecdsa"] }
p384 = { version = "0.13", features = ["ecdsa"] }
quinn = { version = "0.11", default-features = false, features = ["runtime-tokio", "rustls-ring", "log"] }
rand = "0.8"
rcgen = { version = "0.14", default-features = false, features = ["ring", "pem"] }
redis = { version = "1.0", features = ["tokio-comp", "connection-manager"] }
regex = "1"
rsa = "0.9"
@@ -137,6 +141,7 @@ urlencoding = "2.1"
uuid = { version = "1.19", features = ["v4", "v5", "v7", "fast-rng", "serde"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys", "conditional-ui"] }
webauthn-rs-proto = "0.5"
x509-parser = "0.18"
zip = { version = "7.0", default-features = false, features = ["deflate"] }
ciborium = "0.2"
@@ -149,11 +154,20 @@ wiremock = "0.6"
curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag = "signal-curve25519-4.1.3" }
[profile.release]
lto = "fat"
opt-level = "s"
strip = true
codegen-units = 1
panic = "abort"
codegen-units = 256
[profile.lewis]
inherits = "release"
opt-level = 3
lto = "fat"
codegen-units = 1
[profile.bench]
opt-level = 3
lto = "fat"
codegen-units = 1
debug = 1
strip = false
+35 -11
View File
@@ -1,4 +1,6 @@
FROM node:24-alpine AS frontend
ARG DISTROLESS_IMAGE=gcr.io/distroless/cc-debian13:latest@sha256:1e3c6d9c255be500eb680cdea0ad07554f52ae92dfcbdf07043a2a435b4c1fe3
FROM node:24-trixie-slim AS frontend
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
@@ -6,8 +8,28 @@ RUN pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM rust:1.96-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
FROM rust:1.96-slim-trixie AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates pkg-config libssl-dev mold clang protobuf-compiler curl xz-utils \
&& rm -rf /var/lib/apt/lists/*
ARG COMPRESS="true"
RUN set -eux; \
if [ "$COMPRESS" = "true" ]; then \
arch="$(uname -m)"; \
case "$arch" in \
x86_64) upx_arch=amd64; upx_sha=ddc2654063fe4dc80d95b420788494e4db078ebb01a650692d623b5a9906e61e ;; \
aarch64) upx_arch=arm64; upx_sha=100310f74eb6f67694d1d0377f1c729b6a49238ce8c4de21ea2e7d3406186f8b ;; \
*) echo "upx: no prebuilt binary for $arch, skipping compression"; upx_arch="" ;; \
esac; \
if [ -n "$upx_arch" ]; then \
curl -fsSL -o /tmp/upx.tar.xz "https://github.com/upx/upx/releases/download/v5.0.2/upx-5.0.2-${upx_arch}_linux.tar.xz"; \
echo "${upx_sha} /tmp/upx.tar.xz" | sha256sum -c -; \
tar -xJf /tmp/upx.tar.xz -C /tmp; \
install -m0755 "/tmp/upx-5.0.2-${upx_arch}_linux/upx" /usr/local/bin/upx; \
rm -rf /tmp/upx.tar.xz "/tmp/upx-5.0.2-${upx_arch}_linux"; \
fi; \
fi
RUN mkdir -p /stage/var/lib/tranquil-pds/blobs /stage/var/lib/tranquil-pds/store
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
WORKDIR /app
ARG SLIM="false"
@@ -36,21 +58,23 @@ COPY crates/tranquil-store ./crates/tranquil-store
COPY crates/tranquil-signal ./crates/tranquil-signal
COPY crates/tranquil-server ./crates/tranquil-server
COPY migrations ./migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
RUN --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \
--mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \
--mount=type=cache,id=tranquil-target,target=/app/target,sharing=locked \
if [ "$SLIM" = "true" ]; then \
SQLX_OFFLINE=true cargo build --release -p tranquil-server --no-default-features; \
else \
SQLX_OFFLINE=true cargo build --release -p tranquil-server; \
fi && \
cp target/release/tranquil-server /tmp/tranquil-pds
cp target/release/tranquil-server /tmp/tranquil-pds && \
if [ "$COMPRESS" = "true" ] && command -v upx >/dev/null 2>&1; then upx --best --lzma /tmp/tranquil-pds; fi
FROM alpine:3.23
RUN apk add --no-cache ca-certificates
FROM ${DISTROLESS_IMAGE}
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
WORKDIR /app
COPY --from=builder --chown=65532:65532 /stage/var/lib/tranquil-pds /var/lib/tranquil-pds
COPY --from=frontend --chown=65532:65532 /app/dist /var/lib/tranquil-pds/frontend
WORKDIR /var/lib/tranquil-pds
ENV SERVER_HOST=[::]
ENV SERVER_PORT=3000
EXPOSE 3000
CMD ["tranquil-pds"]
ENTRYPOINT ["/usr/local/bin/tranquil-pds"]
+6 -7
View File
@@ -1,14 +1,13 @@
FROM rust:1.96-alpine
FROM rust:1.96-slim-trixie
RUN apk add --no-cache \
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
musl-dev \
pkgconfig \
openssl-dev \
openssl-libs-static \
pkg-config \
libssl-dev \
mold \
clang \
protoc
protobuf-compiler \
&& rm -rf /var/lib/apt/lists/*
RUN cargo install cargo-watch
+1
View File
@@ -92,6 +92,7 @@ We currently don't have a shared space to chat and organize Tranquil things, but
- [@sans-self.org](https://tangled.org/did:plc:wydyrngmxbcsqdvhmd7whmye)
- [@tachyonism.tngl.sh](https://tangled.org/did:plc:w6qiwij62bmdugsd3gemhpy2)
- [@trezy.codes](https://tangled.org/did:plc:4jrld6fwpnwqehtce56qshzv)
- [@ave.zone](https://tangled.org/did:plc:mchrltkrhuzpxleiwpmmvpar)
- Could be your name here too!
### Tranquil PDS instances in the wild!
+20 -31
View File
@@ -174,9 +174,7 @@ pub async fn remove_controller(
.session
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0)
.try_into()
.unwrap_or(0usize);
.unwrap_or(0);
let revoked_oauth_tokens = state
.repos
@@ -232,6 +230,20 @@ pub async fn update_controller_scopes(
.await
{
Ok(true) => {
let revoked_app_passwords = state
.repos
.session
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0);
let revoked_oauth_tokens = state
.repos
.oauth
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0);
let _ = state
.repos
.delegation
@@ -241,7 +253,9 @@ pub async fn update_controller_scopes(
Some(&input.controller_did),
DelegationActionType::ScopesModified,
Some(json!({
"new_scopes": input.granted_scopes.as_str()
"new_scopes": input.granted_scopes.as_str(),
"revoked_app_passwords": revoked_app_passwords,
"revoked_oauth_tokens": revoked_oauth_tokens
})),
None,
None,
@@ -330,7 +344,6 @@ pub struct CreateDelegatedAccountInput {
pub handle: String,
pub email: Option<String>,
pub controller_scopes: ValidatedDelegationScope,
pub invite_code: Option<String>,
}
#[derive(Debug, Serialize)]
@@ -362,19 +375,6 @@ pub async fn create_delegated_account(
return Err(ApiError::InvalidEmail);
}
let validated_invite_code = if let Some(ref code) = input.invite_code {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Err(ApiError::InviteCodeRequired);
}
None
};
let plc = create_plc_did(&state, &handle).await.map_err(|e| {
tracing::error!("PLC DID creation failed: {:?}", e);
e
@@ -397,16 +397,15 @@ pub async fn create_delegated_account(
commit_cid: repo.commit_cid.to_string(),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: input.invite_code.clone(),
};
let user_id = match state
match state
.repos
.user
.create_delegated_account(&create_input)
.await
{
Ok(id) => id,
Ok(_) => {}
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Err(ApiError::HandleNotAvailable(None));
}
@@ -417,16 +416,6 @@ pub async fn create_delegated_account(
error!("Error creating delegated account: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if let Some(validated) = validated_invite_code
&& let Err(e) = state
.repos
.infra
.record_invite_code_use(&validated, user_id)
.await
{
warn!("Failed to record invite code use for {}: {:?}", did, e);
}
crate::identity::provision::sequence_new_account(
+10 -39
View File
@@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::invite::check_registration_invite;
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
@@ -415,40 +416,11 @@ pub async fn create_account(
return ApiError::HandleTaken.into_response();
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
if is_bootstrap {
match input.invite_code.as_deref() {
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => {}
_ => return ApiError::InvalidInviteCode.into_response(),
}
} else {
let invite_code_required = tranquil_config::get().server.invite_code_required;
if invite_code_required
&& input
.invite_code
.as_ref()
.map(|c| c.trim().is_empty())
.unwrap_or(true)
{
return ApiError::InviteCodeRequired.into_response();
}
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let valid = match state.repos.user.check_and_consume_invite_code(code).await {
Ok(v) => v,
Err(e) => {
error!("Error checking invite code: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
if !valid {
return ApiError::InvalidInviteCode.into_response();
}
}
}
let invite_registration =
match check_registration_invite(&state, input.invite_code.as_deref()).await {
Ok(outcome) => outcome,
Err(e) => return e.into_response(),
};
if let Err(e) = validate_password(&input.password) {
return ApiError::InvalidRequest(e.to_string()).into_response();
@@ -517,11 +489,7 @@ pub async fn create_account(
commit_cid: commit_cid_str.clone(),
repo_rev: rev_str.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: if is_bootstrap {
None
} else {
input.invite_code.clone()
},
invite_code: invite_registration.into_invite_code(),
birthdate_pref,
};
@@ -541,6 +509,9 @@ pub async fn create_account(
Err(tranquil_db_traits::CreateAccountError::DidExists) => {
return ApiError::AccountAlreadyExists.into_response();
}
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
return ApiError::InvalidInviteCode.into_response();
}
Err(e) => {
error!("Error creating password account: {:?}", e);
return ApiError::InternalError(None).into_response();
+4 -10
View File
@@ -491,16 +491,10 @@ pub async fn get_recommended_did_credentials(
let rotation_keys = if auth.did.starts_with("did:web:") {
vec![]
} else {
let server_rotation_key = match &tranquil_config::get().secrets.plc_rotation_key {
Some(key) => key.clone(),
None => {
warn!(
"PLC_ROTATION_KEY not set, falling back to user's signing key for rotation key recommendation"
);
did_key.clone()
}
};
vec![server_rotation_key]
tranquil_pds::plc::rotation_keys_for(
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
&signing_key,
)
};
Ok(Json(GetRecommendedDidCredentialsOutput {
rotation_keys,
+1 -1
View File
@@ -3,5 +3,5 @@ mod sign;
mod submit;
pub use request::request_plc_operation_signature;
pub use sign::{ServiceInput, SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
pub use sign::{SignPlcOperationInput, SignPlcOperationOutput, sign_plc_operation};
pub use submit::{SubmitPlcOperationInput, submit_plc_operation};
@@ -6,10 +6,6 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::state::AppState;
fn generate_plc_token() -> String {
tranquil_pds::util::generate_token_code()
}
pub async fn request_plc_operation_signature(
State(state): State<AppState>,
auth: Auth<Permissive>,
@@ -28,12 +24,13 @@ pub async fn request_plc_operation_signature(
.ok_or(ApiError::AccountNotFound)?;
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
let plc_token = generate_plc_token();
let display_token = tranquil_pds::util::generate_token_code();
let stored_token = tranquil_pds::util::normalize_token_code(&display_token);
let expires_at = Utc::now() + Duration::minutes(10);
state
.repos
.infra
.insert_plc_token(user_id, &plc_token, expires_at)
.insert_plc_token(user_id, &stored_token, expires_at)
.await
.log_db_err("creating PLC token")?;
@@ -42,7 +39,7 @@ pub async fn request_plc_operation_signature(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&plc_token,
&display_token,
hostname,
)
.await
+20 -24
View File
@@ -9,7 +9,10 @@ use tranquil_pds::api::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::auth::{Auth, Permissive};
use tranquil_pds::circuit_breaker::with_circuit_breaker;
use tranquil_pds::plc::{PlcError, PlcService, ServiceType, create_update_op, sign_operation};
use tranquil_pds::plc::{
PlcError, PlcService, create_update_op, missing_required_rotation_key, sign_operation,
signing_key_to_did_key,
};
use tranquil_pds::state::AppState;
#[derive(Debug, Deserialize)]
@@ -19,14 +22,7 @@ pub struct SignPlcOperationInput {
pub rotation_keys: Option<Vec<String>>,
pub also_known_as: Option<Vec<String>>,
pub verification_methods: Option<HashMap<String, String>>,
pub services: Option<HashMap<String, ServiceInput>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ServiceInput {
#[serde(rename = "type")]
pub service_type: ServiceType,
pub endpoint: String,
pub services: Option<HashMap<String, PlcService>>,
}
#[derive(Debug, Serialize)]
@@ -50,9 +46,10 @@ pub async fn sign_plc_operation(
"PLC operations are only valid for did:plc identities".into(),
));
}
let token = input.token.as_ref().ok_or_else(|| {
let raw_token = input.token.as_ref().ok_or_else(|| {
ApiError::InvalidRequest("Email confirmation token required to sign PLC operations".into())
})?;
let token = &tranquil_pds::util::normalize_token_code(raw_token);
let user_id = state
.repos
@@ -106,25 +103,12 @@ pub async fn sign_plc_operation(
if last_op.is_tombstone() {
return Err(ApiError::from(PlcError::Tombstoned));
}
let services = input.services.map(|s| {
s.into_iter()
.map(|(k, v)| {
(
k,
PlcService {
service_type: v.service_type,
endpoint: v.endpoint,
},
)
})
.collect()
});
let unsigned_op = create_update_op(
&last_op,
input.rotation_keys,
input.verification_methods,
input.also_known_as,
services,
input.services,
)
.map_err(|e| match e {
PlcError::Tombstoned => ApiError::InvalidRequest("Cannot update tombstoned DID".into()),
@@ -134,6 +118,18 @@ pub async fn sign_plc_operation(
}
})?;
let signing_did_key = signing_key_to_did_key(&signing_key);
if let Some(rotation_keys) = unsigned_op.get("rotationKeys").and_then(Value::as_array) {
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
if let Some(missing) = missing_required_rotation_key(
&rotation_key_strs,
&signing_did_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
) {
return Err(ApiError::InvalidRequest(missing.message().into()));
}
}
let signed_op = sign_operation(&unsigned_op, &signing_key).map_err(|e| {
error!("Failed to sign PLC operation: {:?}", e);
ApiError::InternalError(None)
+7 -12
View File
@@ -67,19 +67,14 @@ pub async fn submit_plc_operation(
})?;
let user_did_key = signing_key_to_did_key(&signing_key);
let server_rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| user_did_key.clone());
if let Some(rotation_keys) = op.get("rotationKeys").and_then(Value::as_array) {
let has_server_key = rotation_keys
.iter()
.any(|k| k.as_str() == Some(&server_rotation_key));
if !has_server_key {
return Err(ApiError::InvalidRequest(
"Rotation keys do not include server's rotation key".into(),
));
let rotation_key_strs: Vec<&str> = rotation_keys.iter().filter_map(Value::as_str).collect();
if let Some(missing) = tranquil_pds::plc::missing_required_rotation_key(
&rotation_key_strs,
&user_did_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
) {
return Err(ApiError::InvalidRequest(missing.message().into()));
}
}
if let Some(services) = op.get("services").and_then(Value::as_object)
@@ -45,15 +45,9 @@ pub async fn submit_plc_genesis(
let hostname = &tranquil_config::get().server.hostname;
let pds_endpoint = format!("https://{}", hostname);
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(signing_key));
let genesis_result = tranquil_pds::plc::create_genesis_operation(
signing_key,
&rotation_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
handle,
&pds_endpoint,
)
+12 -2
View File
@@ -15,8 +15,12 @@ pub mod temp;
use tranquil_pds::state::AppState;
pub fn api_routes() -> axum::Router<AppState> {
use axum::extract::DefaultBodyLimit;
use axum::routing::{get, post};
let blob_body_limit =
DefaultBodyLimit::max(tranquil_config::get().server.max_blob_size as usize);
axum::Router::new()
.route("/_health", get(server::health))
.route(
@@ -68,7 +72,10 @@ pub fn api_routes() -> axum::Router<AppState> {
.route("/com.atproto.repo.deleteRecord", post(repo::delete_record))
.route("/com.atproto.repo.listRecords", get(repo::list_records))
.route("/com.atproto.repo.describeRepo", get(repo::describe_repo))
.route("/com.atproto.repo.uploadBlob", post(repo::upload_blob))
.route(
"/com.atproto.repo.uploadBlob",
post(repo::upload_blob).layer(blob_body_limit),
)
.route("/com.atproto.repo.applyWrites", post(repo::apply_writes))
.route(
"/com.atproto.server.checkAccountStatus",
@@ -247,7 +254,10 @@ pub fn api_routes() -> axum::Router<AppState> {
"/_identity.verifyHandleOwnership",
post(identity::verify_handle_ownership),
)
.route("/com.atproto.repo.importRepo", post(repo::import_repo))
.route(
"/com.atproto.repo.importRepo",
post(repo::import_repo).layer(blob_body_limit),
)
.route(
"/com.atproto.admin.deleteAccount",
post(admin::delete_account),
+45 -103
View File
@@ -14,6 +14,35 @@ use tranquil_pds::sync::verify::CarVerifier;
use tranquil_pds::types::Did;
use tranquil_types::{AtUri, CidLink};
fn map_car_verify_error(e: tranquil_pds::sync::verify::VerifyError) -> ApiError {
use tranquil_pds::sync::verify::VerifyError;
match e {
VerifyError::DidMismatch {
commit_did,
expected_did,
} => ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
)),
VerifyError::InvalidSignature => ApiError::InvalidRequest(
"Repo commit signature does not match the DID document signing key".into(),
),
VerifyError::NoSigningKey => {
ApiError::InvalidRequest("DID document has no atproto signing key".into())
}
VerifyError::DidResolutionFailed(msg) => {
ApiError::InvalidRequest(format!("Could not resolve DID document: {}", msg))
}
VerifyError::MstValidationFailed(msg) => {
ApiError::InvalidRequest(format!("MST validation failed: {}", msg))
}
other => {
error!("CAR verification failed: {:?}", other);
ApiError::InvalidRequest(format!("CAR verification failed: {}", other))
}
}
}
pub async fn import_repo(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
@@ -77,27 +106,6 @@ pub async fn import_repo(
blocks.len(),
root
);
let Some(root_block) = blocks.get(&root) else {
return Err(ApiError::InvalidRequest(
"Root block not found in CAR file".into(),
));
};
let commit_did: Did = match jacquard_repo::commit::Commit::from_cbor(root_block) {
Ok(commit) => commit
.did()
.as_str()
.parse()
.map_err(|_| ApiError::InvalidRequest("Commit contains invalid DID".into()))?,
Err(e) => {
return Err(ApiError::InvalidRequest(format!("Invalid commit: {}", e)));
}
};
if commit_did != *did {
return Err(ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, did
)));
}
let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION")
.ok()
.map(|v| v == "true" || v == "1")
@@ -108,90 +116,24 @@ pub async fn import_repo(
});
let is_migration = user.inbound_migration && user.deactivated_at.is_some();
if skip_verification {
warn!("Skipping all CAR verification for import (SKIP_IMPORT_VERIFICATION=true)");
warn!("Skipping all CAR verification for repo import (SKIP_IMPORT_VERIFICATION=true)");
} else if is_migration {
debug!("Verifying CAR file structure for migration (skipping signature verification)");
let verifier = CarVerifier::new();
match verifier.verify_car_structure_only(did, &root, &blocks) {
Ok(verified) => {
debug!(
"CAR structure verification successful: rev={}, data_cid={}",
verified.rev, verified.data_cid
);
}
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
commit_did,
expected_did,
}) => {
return Err(ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
)));
}
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
return Err(ApiError::InvalidRequest(format!(
"MST validation failed: {}",
msg
)));
}
Err(e) => {
error!("CAR structure verification error: {:?}", e);
return Err(ApiError::InvalidRequest(format!(
"CAR verification failed: {}",
e
)));
}
}
let verified = CarVerifier::new()
.verify_car_structure_only(&root, &blocks)
.map_err(map_car_verify_error)?;
debug!(
"CAR structure verified for migration import: rev={}, data_cid={}",
verified.rev, verified.data_cid
);
} else {
debug!("Verifying CAR file signature and structure for DID {}", did);
let verifier = CarVerifier::new();
match verifier.verify_car(did, &root, &blocks).await {
Ok(verified) => {
debug!(
"CAR verification successful: rev={}, data_cid={}",
verified.rev, verified.data_cid
);
}
Err(tranquil_pds::sync::verify::VerifyError::DidMismatch {
commit_did,
expected_did,
}) => {
return Err(ApiError::InvalidRepo(format!(
"CAR file is for DID {} but you are authenticated as {}",
commit_did, expected_did
)));
}
Err(tranquil_pds::sync::verify::VerifyError::InvalidSignature) => {
return Err(ApiError::InvalidRequest(
"CAR file commit signature verification failed".into(),
));
}
Err(tranquil_pds::sync::verify::VerifyError::DidResolutionFailed(msg)) => {
warn!("DID resolution failed during import verification: {}", msg);
return Err(ApiError::InvalidRequest(format!(
"Failed to verify DID: {}",
msg
)));
}
Err(tranquil_pds::sync::verify::VerifyError::NoSigningKey) => {
return Err(ApiError::InvalidRequest(
"DID document does not contain a signing key".into(),
));
}
Err(tranquil_pds::sync::verify::VerifyError::MstValidationFailed(msg)) => {
return Err(ApiError::InvalidRequest(format!(
"MST validation failed: {}",
msg
)));
}
Err(e) => {
error!("CAR verification error: {:?}", e);
return Err(ApiError::InvalidRequest(format!(
"CAR verification failed: {}",
e
)));
}
}
let verified = CarVerifier::new()
.verify_car(did, &root, &blocks)
.await
.map_err(map_car_verify_error)?;
debug!(
"CAR signature and structure verified: rev={}, data_cid={}",
verified.rev, verified.data_cid
);
}
let max_blocks = tranquil_config::get().import.max_blocks as usize;
let _write_lock = state.repo_write_locks.lock(user_id).await;
+61 -45
View File
@@ -1,6 +1,6 @@
use super::validation::validate_record_with_status;
use super::validation_mode::{ValidationMode, deserialize_validation_mode};
use crate::repo::record::write::CommitInfo;
use crate::repo::record::write::{CommitInfo, ensure_record_type};
use axum::{Json, extract::State};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use serde::{Deserialize, Serialize};
@@ -14,8 +14,8 @@ use tranquil_pds::auth::{
};
use tranquil_pds::repo::TrackingBlockStore;
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write,
CommitResult, FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
@@ -54,6 +54,8 @@ async fn process_single_write(
rkey,
value,
} => {
let value = ensure_record_type(value, collection);
let value = &*value;
let validation_status = if validate.should_skip() {
None
} else {
@@ -72,7 +74,7 @@ async fn process_single_write(
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.map_err(|e| ApiError::from_mst_error("read MST for applyWrites create", &e))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
@@ -90,7 +92,7 @@ async fn process_single_write(
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
let uri = AtUri::from_parts(did, collection, &rkey);
backlinks_to_add.extend(extract_backlinks(&uri, value));
results.push(WriteResult::CreateResult {
@@ -117,6 +119,8 @@ async fn process_single_write(
rkey,
value,
} => {
let value = ensure_record_type(value, collection);
let value = &*value;
let validation_status = if validate.should_skip() {
None
} else {
@@ -134,9 +138,7 @@ async fn process_single_write(
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.map_err(|e| ApiError::from_mst_error("read update target from MST", &e))?
.ok_or_else(|| {
ApiError::InvalidRequest("Update target record does not exist".into())
})?;
@@ -151,7 +153,7 @@ async fn process_single_write(
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
let uri = AtUri::from_parts(did, collection, rkey);
backlinks_to_remove.push(uri.clone());
backlinks_to_add.extend(extract_backlinks(&uri, value));
@@ -180,16 +182,14 @@ async fn process_single_write(
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| {
ApiError::InternalError(Some(format!("Failed to read prev record: {}", e)))
})?
.map_err(|e| ApiError::from_mst_error("read delete target from MST", &e))?
.ok_or_else(|| {
ApiError::InvalidRequest("Delete target record does not exist".into())
})?;
let new_mst = mst
.delete(&key)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to delete from MST".into())))?;
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
backlinks_to_remove.push(AtUri::from_parts(did, collection, rkey));
results.push(WriteResult::DeleteResult {});
ops.push(RecordOp::Delete {
@@ -232,6 +232,45 @@ async fn process_writes(
.await
}
async fn execute_apply_writes(
state: &AppState,
user_id: uuid::Uuid,
did: &Did,
input: &ApplyWritesInput,
controller_did: Option<&Did>,
write_summary: Option<serde_json::Value>,
) -> Result<(CommitResult, Vec<WriteResult>), ApiError> {
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let WriteAccumulator {
mst: final_mst,
results,
ops,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
} = process_writes(&input.writes, mst, did, input.validate, &ctx.tracking_store).await?;
let commit_result = finalize_repo_write(
state,
ctx,
final_mst,
FinalizeParams {
did,
user_id,
controller_did,
delegation_detail: write_summary,
ops,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
.await?;
Ok((commit_result, results))
}
#[derive(Deserialize)]
#[serde(tag = "$type")]
pub enum WriteOp {
@@ -346,24 +385,6 @@ pub async fn apply_writes(
.log_db_err("fetching user for batch write")?
.ok_or(ApiError::InternalError(Some("User not found".into())))?;
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let WriteAccumulator {
mst: final_mst,
results,
ops,
all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
} = process_writes(
&input.writes,
mst,
&did,
input.validate,
&ctx.tracking_store,
)
.await?;
let write_summary: Option<serde_json::Value> = controller_did.as_ref().map(|_| {
let writes: Vec<serde_json::Value> = input
.writes
@@ -397,21 +418,16 @@ pub async fn apply_writes(
})
});
let commit_result = finalize_repo_write(
&state,
ctx,
final_mst,
FinalizeParams {
did: &did,
let (commit_result, results) = with_repair_retry(&state, user_id, || {
execute_apply_writes(
&state,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: write_summary,
ops,
blob_cids: &all_blob_cids,
backlinks_to_add,
backlinks_to_remove,
},
)
&did,
&input,
controller_did.as_ref(),
write_summary.clone(),
)
})
.await?;
Ok(Json(ApplyWritesOutput {
+40 -21
View File
@@ -4,13 +4,15 @@ use cid::Cid;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::str::FromStr;
use tracing::error;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Active, Auth, VerifyScope};
use tranquil_pds::cid_types::RecordCid;
use tranquil_pds::repo_ops::{FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write};
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Nsid, Rkey};
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
use uuid::Uuid;
#[derive(Deserialize)]
pub struct DeleteRecordInput {
@@ -41,13 +43,30 @@ pub async fn delete_record(
let user_id = repo_auth.user_id;
let controller_did = repo_auth.controller_did;
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let out = with_repair_retry(&state, user_id, || {
delete_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
})
.await?;
Ok(Json(out))
}
async fn delete_record_inner(
state: &AppState,
did: &Did,
user_id: Uuid,
controller_did: Option<&Did>,
input: &DeleteRecordInput,
) -> Result<DeleteRecordOutput, ApiError> {
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let key = format!("{}/{}", input.collection, input.rkey);
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
let actual_cid = mst
.get(&key)
.await
.map_err(|e| ApiError::from_mst_error("read swap target from MST", &e))?;
if expected_cid != actual_cid {
return Err(ApiError::InvalidSwap(Some(
"Record has been modified or does not exist".into(),
@@ -55,18 +74,18 @@ pub async fn delete_record(
}
}
let prev_record_cid = mst.get(&key).await.map_err(|e| {
error!("Failed to read prev record from MST: {}", e);
ApiError::InternalError(Some("Failed to read MST".into()))
})?;
let prev_record_cid = mst
.get(&key)
.await
.map_err(|e| ApiError::from_mst_error("read prev record from MST", &e))?;
let Some(prev_record_cid) = prev_record_cid else {
return Ok(Json(DeleteRecordOutput { commit: None }));
return Ok(DeleteRecordOutput { commit: None });
};
let new_mst = mst.delete(&key).await.map_err(|e| {
error!("Failed to delete from MST: {}", e);
ApiError::InternalError(Some("Failed to delete from MST".into()))
})?;
let new_mst = mst
.delete(&key)
.await
.map_err(|e| ApiError::from_mst_error("delete record from MST", &e))?;
let op = RecordOp::Delete {
collection: input.collection.clone(),
@@ -74,17 +93,17 @@ pub async fn delete_record(
prev: RecordCid::from(prev_record_cid),
};
let deleted_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let deleted_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
let commit_result = finalize_repo_write(
&state,
state,
ctx,
new_mst,
FinalizeParams {
did: &did,
did,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: controller_did.as_ref().map(|_| {
controller_did,
delegation_detail: controller_did.map(|_| {
json!({
"action": "delete",
"collection": input.collection,
@@ -99,10 +118,10 @@ pub async fn delete_record(
)
.await?;
Ok(Json(DeleteRecordOutput {
Ok(DeleteRecordOutput {
commit: Some(CommitInfo {
cid: commit_result.commit_cid.to_string(),
rev: commit_result.rev,
}),
}))
})
}
+100 -45
View File
@@ -5,8 +5,8 @@ use cid::Cid;
use jacquard_repo::storage::BlockStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::borrow::Cow;
use std::str::FromStr;
use tracing::error;
use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, AuthSource, RepoScopeAction, ScopeVerified, VerifyScope, require_not_migrated,
@@ -14,7 +14,7 @@ use tranquil_pds::auth::{
};
use tranquil_pds::repo_ops::{
FinalizeParams, RecordOp, begin_repo_write, extract_backlinks, extract_blob_cids,
finalize_repo_write,
finalize_repo_write, with_repair_retry,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AtIdentifier, AtUri, Did, Nsid, Rkey};
@@ -62,6 +62,29 @@ pub async fn prepare_repo_write<A: RepoScopeAction>(
})
}
pub(crate) fn ensure_record_type<'a>(
record: &'a serde_json::Value,
collection: &Nsid,
) -> Cow<'a, serde_json::Value> {
let serde_json::Value::Object(map) = record else {
return Cow::Borrowed(record);
};
let needs_fill = match map.get("$type") {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::String(existing)) => existing.is_empty(),
Some(_) => false,
};
if !needs_fill {
return Cow::Borrowed(record);
}
let mut map = map.clone();
map.insert(
"$type".to_string(),
serde_json::Value::String(collection.to_string()),
);
Cow::Owned(serde_json::Value::Object(map))
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct CreateRecordInput {
@@ -95,15 +118,32 @@ pub struct CreateRecordOutput {
pub async fn create_record(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<CreateRecordInput>,
Json(mut input): Json<CreateRecordInput>,
) -> Result<Json<CreateRecordOutput>, ApiError> {
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
input.record = record;
}
let scope_proof = auth.verify_repo_create(&input.collection)?;
let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?;
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let controller_did = repo_auth.controller_did;
let (ctx, mut mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let out = with_repair_retry(&state, user_id, || {
create_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
})
.await?;
Ok(Json(out))
}
async fn create_record_inner(
state: &AppState,
did: &Did,
user_id: Uuid,
controller_did: Option<&Did>,
input: &CreateRecordInput,
) -> Result<CreateRecordOutput, ApiError> {
let (ctx, mut mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let validation_status = if input.validate.should_skip() {
None
@@ -119,12 +159,12 @@ pub async fn create_record(
)
};
let rkey = input.rkey.unwrap_or_else(Rkey::generate);
let rkey = input.rkey.clone().unwrap_or_else(Rkey::generate);
let mut ops: Vec<RecordOp> = Vec::new();
let mut conflict_uris_to_cleanup: Vec<AtUri> = Vec::new();
if !input.validate.should_skip() {
let record_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let record_uri = AtUri::from_parts(did, &input.collection, &rkey);
let backlinks = extract_backlinks(&record_uri, &input.record);
if !backlinks.is_empty() {
@@ -149,24 +189,18 @@ pub async fn create_record(
Ok(Some(cid)) => cid,
Ok(None) => continue,
Err(e) => {
error!(
"Failed to read conflict record from MST {}: {:?}",
conflict_uri, e
);
return Err(ApiError::InternalError(Some(
"Failed to read conflicting record from MST".into(),
)));
return Err(ApiError::from_mst_error(
&format!("read conflict record from MST {conflict_uri}"),
&e,
));
}
};
mst = mst.delete(&conflict_key).await.map_err(|e| {
error!(
"Failed to delete conflict from MST {}: {:?}",
conflict_uri, e
);
ApiError::InternalError(Some(
"Failed to delete conflicting record from MST".into(),
))
ApiError::from_mst_error(
&format!("delete conflict from MST {conflict_uri}"),
&e,
)
})?;
ops.push(RecordOp::Delete {
@@ -183,7 +217,7 @@ pub async fn create_record(
if mst
.get(&key)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to read MST: {e}"))))?
.map_err(|e| ApiError::from_mst_error("read MST for create existence check", &e))?
.is_some()
{
return Err(ApiError::InvalidRequest(format!(
@@ -202,7 +236,7 @@ pub async fn create_record(
mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
ops.push(RecordOp::Create {
collection: input.collection.clone(),
@@ -212,18 +246,18 @@ pub async fn create_record(
let blob_cids = extract_blob_cids(&input.record);
let created_uri = AtUri::from_parts(&did, &input.collection, &rkey);
let created_uri = AtUri::from_parts(did, &input.collection, &rkey);
let backlinks_to_add = extract_backlinks(&created_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
state,
ctx,
mst,
FinalizeParams {
did: &did,
did,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: controller_did.as_ref().map(|_| {
controller_did,
delegation_detail: controller_did.map(|_| {
json!({
"action": "create",
"collection": input.collection,
@@ -238,7 +272,7 @@ pub async fn create_record(
)
.await?;
Ok(Json(CreateRecordOutput {
Ok(CreateRecordOutput {
uri: created_uri,
cid: record_cid.to_string(),
commit: CommitInfo {
@@ -246,7 +280,7 @@ pub async fn create_record(
rev: commit_result.rev,
},
validation_status,
}))
})
}
#[derive(Deserialize)]
@@ -278,15 +312,32 @@ pub struct PutRecordOutput {
pub async fn put_record(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<PutRecordInput>,
Json(mut input): Json<PutRecordInput>,
) -> Result<Json<PutRecordOutput>, ApiError> {
if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) {
input.record = record;
}
let upsert_proof = auth.verify_repo_upsert(&input.collection)?;
let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?;
let did = repo_auth.did;
let user_id = repo_auth.user_id;
let controller_did = repo_auth.controller_did;
let (ctx, mst) = begin_repo_write(&state, user_id, input.swap_commit.as_deref()).await?;
let out = with_repair_retry(&state, user_id, || {
put_record_inner(&state, &did, user_id, controller_did.as_ref(), &input)
})
.await?;
Ok(Json(out))
}
async fn put_record_inner(
state: &AppState,
did: &Did,
user_id: Uuid,
controller_did: Option<&Did>,
input: &PutRecordInput,
) -> Result<PutRecordOutput, ApiError> {
let (ctx, mst) = begin_repo_write(state, user_id, input.swap_commit.as_deref()).await?;
let validation_status = if input.validate.should_skip() {
None
@@ -304,9 +355,13 @@ pub async fn put_record(
let key = format!("{}/{}", input.collection, input.rkey);
let read_cid = |r: Result<Option<Cid>, jacquard_repo::error::RepoError>| {
r.map_err(|e| ApiError::from_mst_error("read MST for put", &e))
};
if let Some(swap_record_str) = &input.swap_record {
let expected_cid = Cid::from_str(swap_record_str).ok();
let actual_cid = mst.get(&key).await.ok().flatten();
let actual_cid = read_cid(mst.get(&key).await)?;
if expected_cid != actual_cid {
return Err(ApiError::InvalidSwap(Some(
"Record has been modified or does not exist".into(),
@@ -314,7 +369,7 @@ pub async fn put_record(
}
}
let existing_cid = mst.get(&key).await.ok().flatten();
let existing_cid = read_cid(mst.get(&key).await)?;
let record_ipld = tranquil_pds::util::json_to_ipld(&input.record);
let record_bytes = serde_ipld_dagcbor::to_vec(&record_ipld)
.map_err(|_| ApiError::InvalidRecord("Failed to serialize record".into()))?;
@@ -325,21 +380,21 @@ pub async fn put_record(
.map_err(|_| ApiError::InternalError(Some("Failed to save record block".into())))?;
if existing_cid == Some(record_cid) {
return Ok(Json(PutRecordOutput {
uri: AtUri::from_parts(&did, &input.collection, &input.rkey),
return Ok(PutRecordOutput {
uri: AtUri::from_parts(did, &input.collection, &input.rkey),
cid: record_cid.to_string(),
commit: None,
validation_status,
}));
});
}
let record_uri = AtUri::from_parts(&did, &input.collection, &input.rkey);
let record_uri = AtUri::from_parts(did, &input.collection, &input.rkey);
let (new_mst, op, is_update, backlinks_to_remove) = match existing_cid {
Some(prev_cid) => {
let new_mst = mst
.update(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to update MST".into())))?;
.map_err(|e| ApiError::from_mst_error("update record in MST", &e))?;
let op = RecordOp::Update {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
@@ -352,7 +407,7 @@ pub async fn put_record(
let new_mst = mst
.add(&key, record_cid)
.await
.map_err(|_| ApiError::InternalError(Some("Failed to add to MST".into())))?;
.map_err(|e| ApiError::from_mst_error("add record to MST", &e))?;
let op = RecordOp::Create {
collection: input.collection.clone(),
rkey: input.rkey.clone(),
@@ -366,14 +421,14 @@ pub async fn put_record(
let backlinks_to_add = extract_backlinks(&record_uri, &input.record);
let commit_result = finalize_repo_write(
&state,
state,
ctx,
new_mst,
FinalizeParams {
did: &did,
did,
user_id,
controller_did: controller_did.as_ref(),
delegation_detail: controller_did.as_ref().map(|_| {
controller_did,
delegation_detail: controller_did.map(|_| {
json!({
"action": if is_update { "update" } else { "create" },
"collection": input.collection,
@@ -388,7 +443,7 @@ pub async fn put_record(
)
.await?;
Ok(Json(PutRecordOutput {
Ok(PutRecordOutput {
uri: record_uri,
cid: record_cid.to_string(),
commit: Some(CommitInfo {
@@ -396,5 +451,5 @@ pub async fn put_record(
rev: commit_result.rev,
}),
validation_status,
}))
})
}
@@ -197,18 +197,19 @@ async fn assert_valid_did_document_for_service(
.await
.map_err(ApiError::InvalidRequest)?;
let doc_rotation_keys = doc_data
.get("rotationKeys")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.unwrap_or_default();
let server_rotation_key = tranquil_config::get().secrets.plc_rotation_key.clone();
if let Some(ref expected_rotation_key) = server_rotation_key {
let rotation_keys = doc_data
.get("rotationKeys")
.and_then(Value::as_array)
.map(|arr| arr.iter().filter_map(Value::as_str).collect::<Vec<_>>())
.unwrap_or_default();
if !rotation_keys.contains(&expected_rotation_key.as_str()) {
return Err(ApiError::InvalidRequest(
"Server rotation key not included in PLC DID data".into(),
));
}
if let Some(ref expected_rotation_key) = server_rotation_key
&& !doc_rotation_keys.contains(&expected_rotation_key.as_str())
{
return Err(ApiError::InvalidRequest(
"Server rotation key not included in PLC DID data".into(),
));
}
let doc_signing_key = doc_data
@@ -243,6 +244,16 @@ async fn assert_valid_did_document_for_service(
"DID document verification method does not match expected signing key".into(),
));
}
if !doc_rotation_keys.contains(&expected_did_key.as_str()) {
warn!(
"DID {} rotation keys {:?} omit the PDS-managed signing key {}",
did, doc_rotation_keys, expected_did_key
);
return Err(ApiError::InvalidRequest(
"PLC rotation keys omit the PDS-managed signing key required to sign operations for this identity".into(),
));
}
}
} else if let Some(host_and_path) = did.as_str().strip_prefix("did:web:") {
let client = tranquil_pds::api::proxy_client::did_resolution_client();
@@ -116,7 +116,10 @@ pub async fn create_app_password(
.await
.ok()
.flatten();
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
let granted_scopes = match grant {
Some(g) => g.granted_scopes,
None => return Err(ApiError::InsufficientScope(None)),
};
let requested = input.scopes.as_deref().unwrap_or("atproto");
let intersected = intersect_scopes(requested, granted_scopes.as_str());
@@ -7,6 +7,7 @@ use serde_json::json;
use tracing::{debug, error, info, warn};
use tranquil_db_traits::WebauthnChallengeType;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::invite::check_registration_invite;
use tranquil_pds::api::{OptionsResponse, SuccessResponse};
use tranquil_pds::auth::NormalizedLoginIdentifier;
@@ -119,26 +120,8 @@ pub async fn create_passkey_account(
return Err(ApiError::InvalidEmail);
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
let _validated_invite_code = if is_bootstrap {
match input.invite_code.as_deref() {
Some(code) if Some(code) == state.bootstrap_invite_code.as_deref() => None,
_ => return Err(ApiError::InvalidInviteCode),
}
} else if let Some(ref code) = input.invite_code {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Err(ApiError::InviteCodeRequired);
}
None
};
let invite_registration =
check_registration_invite(&state, input.invite_code.as_deref()).await?;
let verification_channel = input
.verification_channel
@@ -241,15 +224,9 @@ pub async fn create_passkey_account(
));
}
} else {
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&secret_key));
let genesis_result = match tranquil_pds::plc::create_genesis_operation(
&secret_key,
&rotation_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
&handle,
&pds_endpoint,
) {
@@ -343,11 +320,7 @@ pub async fn create_passkey_account(
commit_cid: repo.commit_cid.to_string(),
repo_rev: repo.repo_rev.clone(),
genesis_block_cids: repo.genesis_block_cids,
invite_code: if is_bootstrap {
None
} else {
input.invite_code.clone()
},
invite_code: invite_registration.into_invite_code(),
birthdate_pref,
};
@@ -359,6 +332,9 @@ pub async fn create_passkey_account(
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
return Err(ApiError::EmailTaken);
}
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
return Err(ApiError::InvalidInviteCode);
}
Err(e) => {
error!("Error creating passkey account: {:?}", e);
return Err(ApiError::InternalError(None));
+11 -8
View File
@@ -13,10 +13,6 @@ use tranquil_pds::state::AppState;
use tranquil_pds::types::PlainPassword;
use tranquil_pds::validation::validate_password;
fn generate_reset_code() -> String {
tranquil_pds::util::generate_token_code()
}
#[derive(Deserialize)]
pub struct RequestPasswordResetInput {
#[serde(alias = "identifier")]
@@ -70,12 +66,13 @@ pub async fn request_password_reset(
return Err(ApiError::InternalError(None));
}
};
let code = generate_reset_code();
let display_code = tranquil_pds::util::generate_token_code();
let stored_code = tranquil_pds::util::normalize_token_code(&display_code);
let expires_at = Utc::now() + Duration::minutes(10);
if let Err(e) = state
.repos
.user
.set_password_reset_code(user_id, &code, expires_at)
.set_password_reset_code(user_id, &stored_code, expires_at)
.await
{
error!("DB error setting reset code: {:?}", e);
@@ -86,7 +83,7 @@ pub async fn request_password_reset(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&code,
&display_code,
hostname,
)
.await
@@ -133,7 +130,13 @@ pub async fn reset_password(
if let Err(e) = validate_password(password) {
return Err(ApiError::InvalidRequest(e.to_string()));
}
let user = match state.repos.user.get_user_by_reset_code(token).await {
let normalized_token = tranquil_pds::util::normalize_token_code(token);
let user = match state
.repos
.user
.get_user_by_reset_code(&normalized_token)
.await
{
Ok(Some(u)) => u,
Ok(None) => {
return Err(ApiError::InvalidToken(None));
+211 -32
View File
@@ -15,7 +15,10 @@ use tranquil_pds::auth::{
Active, Auth, NormalizedLoginIdentifier, Permissive, require_legacy_session_mfa,
require_reauth_window,
};
use tranquil_pds::rate_limit::{LoginLimit, RateLimited, RefreshSessionLimit};
use tranquil_pds::rate_limit::{
LoginLimit, RateLimited, RefreshSessionLimit, TotpVerifyLimit,
check_user_rate_limit_with_message,
};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{AccountState, Did, Handle, PlainPassword};
use tranquil_types::TokenId;
@@ -168,17 +171,35 @@ pub async fn create_session(
let has_totp = row.totp_enabled;
let email_2fa_enabled = row.email_2fa_enabled;
let is_legacy_login = has_totp || email_2fa_enabled;
let used_totp_factor = tranquil_pds::auth::legacy_2fa::used_totp_factor(
has_totp,
input.auth_factor_token.as_deref(),
);
let twofa_ctx = tranquil_pds::auth::legacy_2fa::Legacy2faContext {
is_app_password: app_password_name.is_some(),
email_2fa_enabled,
has_totp,
allow_legacy_login: row.allow_legacy_login,
};
if let Some(token) = input.auth_factor_token.as_deref()
&& has_totp
&& tranquil_pds::auth::legacy_2fa::looks_like_totp_token(token)
{
check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
row.did.as_str(),
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
}
match tranquil_pds::auth::legacy_2fa::process_legacy_2fa(
state.cache.as_ref(),
&row.did,
&twofa_ctx,
input.auth_factor_token.as_deref(),
async |t: &str| {
crate::server::totp::verify_totp_or_backup_for_user(&state, &row.did, t).await
},
)
.await
{
@@ -285,7 +306,7 @@ pub async fn create_session(
error!("Failed to insert session: {:?}", e);
return Err(ApiError::InternalError(None));
}
if is_legacy_login {
if is_legacy_login && !used_totp_factor {
warn!(
did = %row.did,
ip = %client_ip,
@@ -431,7 +452,12 @@ pub async fn delete_session(
) -> Result<Json<EmptyResponse>, ApiError> {
let jti = tranquil_pds::auth::extract_jti_from_headers(&headers)
.ok_or(ApiError::AuthenticationRequired)?;
match state.repos.session.delete_session_by_access_jti(&jti).await {
match state
.repos
.session
.delete_session_by_access_jti(&jti, &auth.did)
.await
{
Ok(rows) if rows > 0 => {
let session_cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &jti);
let _ = state.cache.delete(&session_cache_key).await;
@@ -484,16 +510,15 @@ pub async fn refresh_session(
)));
}
};
if let Ok(Some(_)) = state
.repos
.session
.check_refresh_token_used(&refresh_jti)
.await
if let Some(result) = dispatch_refresh_grace(
&state,
&refresh_token,
&refresh_jti,
state.repos.session.lookup_refresh_grace(&refresh_jti).await,
)
.await
{
warn!("Refresh token reuse detected for jti: {}", refresh_jti);
return Err(ApiError::AuthenticationFailed(Some(
"Refresh token has been revoked due to suspected compromise".into(),
)));
return result;
}
let session_row = match state
.repos
@@ -503,9 +528,18 @@ pub async fn refresh_session(
{
Ok(Some(row)) => row,
Ok(None) => {
return Err(ApiError::AuthenticationFailed(Some(
"Invalid refresh token".into(),
)));
return dispatch_refresh_grace(
&state,
&refresh_token,
&refresh_jti,
state.repos.session.lookup_refresh_grace(&refresh_jti).await,
)
.await
.unwrap_or_else(|| {
Err(ApiError::AuthenticationFailed(Some(
"Invalid refresh token".into(),
)))
});
}
Err(e) => {
error!("Database error fetching session: {:?}", e);
@@ -551,6 +585,7 @@ pub async fn refresh_session(
}
};
let refresh_data = tranquil_db_traits::SessionRefreshData {
did: session_row.did.clone(),
old_refresh_jti: refresh_jti.clone(),
session_id: session_row.id,
new_access_jti: new_access_meta.jti.clone(),
@@ -558,22 +593,27 @@ pub async fn refresh_session(
new_access_expires_at: new_access_meta.expires_at,
new_refresh_expires_at: new_refresh_meta.expires_at,
};
match state
let (access_jwt, refresh_jwt) = match state
.repos
.session
.refresh_session_atomic(&refresh_data)
.await
{
Ok(tranquil_db_traits::RefreshSessionResult::Success) => {}
Ok(tranquil_db_traits::RefreshSessionResult::TokenAlreadyUsed) => {
warn!("Refresh token reuse detected during atomic operation");
return Err(ApiError::AuthenticationFailed(Some(
"Refresh token has been revoked due to suspected compromise".into(),
)));
Ok(tranquil_db_traits::RefreshSessionResult::Success) => {
(new_access_meta.token, new_refresh_meta.token)
}
Ok(tranquil_db_traits::RefreshSessionResult::ConcurrentRefresh) => {
Ok(tranquil_db_traits::RefreshSessionResult::GraceReplay(replay)) => {
// Lost a benign concurrent rotation; re-mint the winner's tokens
// using this session's signing key (same user, unchanged by rotation).
info!(
"Concurrent refresh within grace window for session_id: {}; replaying tokens",
session_row.id
);
remint_grace_tokens(&replay, &key_bytes)?
}
Ok(tranquil_db_traits::RefreshSessionResult::Compromise) => {
warn!(
"Concurrent refresh detected for session_id: {}",
"Refresh token reuse outside grace window or unreplayable rotation conflict for session_id: {}",
session_row.id
);
return Err(ApiError::AuthenticationFailed(Some(
@@ -584,12 +624,151 @@ pub async fn refresh_session(
error!("Database error during session refresh: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
build_refresh_session_output(&state, session_row.did, access_jwt, refresh_jwt).await
}
async fn dispatch_refresh_grace(
state: &AppState,
refresh_token: &str,
presented_jti: &str,
lookup: Result<tranquil_db_traits::RefreshGraceLookup, tranquil_db_traits::DbError>,
) -> Option<Result<Json<RefreshSessionOutput>, ApiError>> {
match lookup {
Ok(tranquil_db_traits::RefreshGraceLookup::NotUsed) => None,
Ok(tranquil_db_traits::RefreshGraceLookup::Replay(replay)) => {
Some(serve_refresh_grace_replay(state, refresh_token, presented_jti, replay).await)
}
Ok(tranquil_db_traits::RefreshGraceLookup::Compromised {
did,
session_id,
key_bytes,
encryption_version,
}) => Some(Err(revoke_compromised_session(
state,
refresh_token,
presented_jti,
did,
session_id,
key_bytes,
encryption_version,
)
.await)),
Err(e) => {
error!("Database error checking refresh token grace: {:?}", e);
Some(Err(ApiError::InternalError(None)))
}
}
let did_for_doc = session_row.did.clone();
}
async fn serve_refresh_grace_replay(
state: &AppState,
refresh_token: &str,
presented_jti: &str,
replay: tranquil_db_traits::RefreshGraceReplay,
) -> Result<Json<RefreshSessionOutput>, ApiError> {
let key =
match tranquil_pds::config::decrypt_key(&replay.key_bytes, Some(replay.encryption_version))
{
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key for grace replay: {:?}", e);
return Err(ApiError::InternalError(None));
}
};
if tranquil_pds::auth::verify_refresh_token(refresh_token, &key).is_err() {
return Err(ApiError::AuthenticationFailed(Some(
"Invalid refresh token".into(),
)));
}
info!("Refresh token reuse within grace window for jti: {presented_jti}; replaying tokens");
let (access_jwt, refresh_jwt) = remint_grace_tokens(&replay, &key)?;
build_refresh_session_output(state, replay.did, access_jwt, refresh_jwt).await
}
async fn revoke_compromised_session(
state: &AppState,
refresh_token: &str,
presented_jti: &str,
did: Did,
session_id: SessionId,
key_bytes: Vec<u8>,
encryption_version: i32,
) -> ApiError {
let key = match tranquil_pds::config::decrypt_key(&key_bytes, Some(encryption_version)) {
Ok(k) => k,
Err(e) => {
error!("Failed to decrypt user key for grace check: {:?}", e);
return ApiError::InternalError(None);
}
};
if tranquil_pds::auth::verify_refresh_token(refresh_token, &key).is_err() {
return ApiError::AuthenticationFailed(Some("Invalid refresh token".into()));
}
warn!("Refresh token reuse outside grace window for jti: {presented_jti}; revoking session");
if let Err(e) = state
.repos
.session
.delete_session_by_id(session_id, &did)
.await
{
error!(
"Failed to revoke session {} for refresh token reuse: {:?}",
session_id.as_i32(),
e
);
return ApiError::InternalError(None);
}
ApiError::AuthenticationFailed(Some(
"Refresh token has been revoked due to suspected compromise".into(),
))
}
/// Re-mint the access/refresh JWTs for a grace-window replay from the session's
/// current jtis and signing key. We never persist the signed JWTs; they are
/// reconstructed on demand so a benignly-racing client converges on the same
/// credentials the winning rotation produced. `key_bytes` is the owning user's
/// already-decrypted signing key.
fn remint_grace_tokens(
replay: &tranquil_db_traits::RefreshGraceReplay,
key_bytes: &[u8],
) -> Result<(String, String), ApiError> {
let access_jwt = tranquil_pds::auth::create_access_token_with_jti(
&replay.did,
key_bytes,
replay.scope.as_deref(),
replay.controller_did.as_deref(),
None,
&replay.access_jti,
replay.access_expires_at,
)
.map_err(|e| {
error!("Failed to re-mint access token for grace replay: {:?}", e);
ApiError::InternalError(None)
})?;
let refresh_jwt = tranquil_pds::auth::create_refresh_token_with_jti(
&replay.did,
key_bytes,
&replay.refresh_jti,
replay.refresh_expires_at,
)
.map_err(|e| {
error!("Failed to re-mint refresh token for grace replay: {:?}", e);
ApiError::InternalError(None)
})?;
Ok((access_jwt, refresh_jwt))
}
async fn build_refresh_session_output(
state: &AppState,
did: Did,
access_jwt: String,
refresh_jwt: String,
) -> Result<Json<RefreshSessionOutput>, ApiError> {
let did_resolver = state.did_resolver.clone();
let (db_result, did_doc) = tokio::join!(
state.repos.user.get_session_info_by_did(&session_row.did),
did_resolver.fetch_did_document(&did_for_doc)
state.repos.user.get_session_info_by_did(&did),
did_resolver.fetch_did_document(&did)
);
match db_result {
Ok(Some(u)) => {
@@ -600,10 +779,10 @@ pub async fn refresh_session(
let account_state =
AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None);
Ok(Json(RefreshSessionOutput {
access_jwt: new_access_meta.token,
refresh_jwt: new_refresh_meta.token,
access_jwt,
refresh_jwt,
handle,
did: session_row.did,
did,
email: u.email,
email_confirmed: u.channel_verification.email,
preferred_channel: u.preferred_comms_channel.as_str().to_string(),
@@ -616,7 +795,7 @@ pub async fn refresh_session(
}))
}
Ok(None) => {
error!("User not found for existing session: {}", session_row.did);
error!("User not found for existing session: {}", did);
Err(ApiError::InternalError(None))
}
Err(e) => {
@@ -987,7 +1166,7 @@ pub async fn revoke_session(
state
.repos
.session
.delete_session_by_id(session_id)
.delete_session_by_id(session_id, &auth.did)
.await
.log_db_err("deleting session")?;
let cache_key = tranquil_pds::cache_keys::session_key(&auth.did, &access_jti);
+4 -6
View File
@@ -6,8 +6,8 @@ use tranquil_pds::api::error::{ApiError, DbResultExt};
use tranquil_pds::auth::{
Active, Auth, decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, hash_backup_code,
is_backup_code_format, require_legacy_session_mfa, verify_backup_code, verify_password_mfa,
verify_totp_code, verify_totp_mfa,
is_backup_code_format, verify_backup_code, verify_password_mfa, verify_totp_code,
verify_totp_mfa,
};
use tranquil_pds::rate_limit::{TotpVerifyLimit, check_user_rate_limit_with_message};
use tranquil_pds::state::AppState;
@@ -163,11 +163,9 @@ pub async fn disable_totp(
auth: Auth<Active>,
Json(input): Json<DisableTotpInput>,
) -> Result<Json<EmptyResponse>, ApiError> {
let session_mfa = require_legacy_session_mfa(&state, &auth).await?;
let _rate_limit = check_user_rate_limit_with_message::<TotpVerifyLimit>(
&state,
session_mfa.did(),
&auth.did,
"Too many verification attempts. Please try again in a few minutes.",
)
.await?;
@@ -184,7 +182,7 @@ pub async fn disable_totp(
tranquil_pds::auth::legacy_2fa::clear_challenge(state.cache.as_ref(), &auth.did).await;
info!(did = %session_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
info!(did = %password_mfa.did(), "TOTP disabled (verified via {} and {})", password_mfa.method(), totp_mfa.method());
Ok(Json(EmptyResponse {}))
}
+5 -4
View File
@@ -5,10 +5,11 @@ mod verify;
pub use token::{
create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata,
create_access_token_with_delegation, create_access_token_with_metadata,
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata,
create_service_token, create_service_token_hs256,
create_access_token_with_delegation, create_access_token_with_jti,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata,
create_refresh_token_with_jti, create_refresh_token_with_metadata, create_service_token,
create_service_token_hs256,
};
pub use totp::{
+70 -9
View File
@@ -4,7 +4,7 @@ use super::types::{
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use chrono::{Duration, Utc};
use chrono::{DateTime, Duration, Utc};
use hmac::{Hmac, Mac};
use k256::ecdsa::{Signature, SigningKey, signature::Signer};
use sha2::Sha256;
@@ -35,7 +35,7 @@ pub fn create_access_token_with_scope_metadata(
scope,
TokenType::Access,
key_bytes,
Duration::minutes(15),
Duration::minutes(120),
hostname,
)
}
@@ -54,7 +54,7 @@ pub fn create_access_token_with_delegation(
scope,
TokenType::Access,
key_bytes,
Duration::minutes(15),
Duration::minutes(120),
act,
hostname,
)
@@ -69,11 +69,59 @@ pub fn create_refresh_token_with_metadata(
TokenScope::Refresh.as_str(),
TokenType::Refresh,
key_bytes,
Duration::days(14),
Duration::days(90),
None,
)
}
/// Re-mint an access token carrying a specific `jti` and expiry. Used by the
/// refresh grace window to reproduce a session's current access token without
/// persisting the signed JWT itself.
pub fn create_access_token_with_jti(
did: &str,
key_bytes: &[u8],
scopes: Option<&str>,
controller_did: Option<&str>,
hostname: Option<&str>,
jti: &str,
expires_at: DateTime<Utc>,
) -> Result<String> {
let scope = scopes.unwrap_or(TokenScope::Access.as_str());
let act = controller_did.map(|c| ActClaim { sub: c.to_string() });
Ok(create_signed_token_pinned(
did,
scope,
TokenType::Access,
key_bytes,
expires_at,
jti.to_string(),
act,
hostname,
)?
.token)
}
/// Re-mint a refresh token carrying a specific `jti` and expiry. Counterpart to
/// [`create_access_token_with_jti`] for the refresh grace window.
pub fn create_refresh_token_with_jti(
did: &str,
key_bytes: &[u8],
jti: &str,
expires_at: DateTime<Utc>,
) -> Result<String> {
Ok(create_signed_token_pinned(
did,
TokenScope::Refresh.as_str(),
TokenType::Refresh,
key_bytes,
expires_at,
jti.to_string(),
None,
None,
)?
.token)
}
pub fn create_service_token(
did: &str,
aud: &str,
@@ -122,14 +170,27 @@ fn create_signed_token_with_act(
act: Option<ActClaim>,
hostname: Option<&str>,
) -> Result<TokenWithMetadata> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expires_at = Utc::now()
.checked_add_signed(duration)
.expect("valid timestamp");
let jti = uuid::Uuid::new_v4().to_string();
create_signed_token_pinned(did, scope, typ, key_bytes, expires_at, jti, act, hostname)
}
#[allow(clippy::too_many_arguments)]
fn create_signed_token_pinned(
did: &str,
scope: &str,
typ: TokenType,
key_bytes: &[u8],
expires_at: DateTime<Utc>,
jti: String,
act: Option<ActClaim>,
hostname: Option<&str>,
) -> Result<TokenWithMetadata> {
let signing_key = SigningKey::from_slice(key_bytes)?;
let expiration = expires_at.timestamp();
let jti = uuid::Uuid::new_v4().to_string();
let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| {
tranquil_config::try_get()
@@ -198,7 +259,7 @@ pub fn create_access_token_hs256_with_metadata(
TokenScope::Access.as_str(),
TokenType::Access,
secret,
Duration::minutes(15),
Duration::minutes(120),
)
}
@@ -211,7 +272,7 @@ pub fn create_refresh_token_hs256_with_metadata(
TokenScope::Refresh.as_str(),
TokenType::Refresh,
secret,
Duration::days(14),
Duration::days(90),
)
}
+14 -1
View File
@@ -15,7 +15,9 @@ impl TokenType {
match self {
Self::Access => "at+jwt",
Self::Refresh => "refresh+jwt",
Self::Service => "jwt",
// RFC 7519 §5.1 recommends the uppercase "JWT".
// and for atproto inter-service auth its a requirement.
Self::Service => "JWT",
}
}
}
@@ -290,6 +292,17 @@ mod tests {
assert!(TokenType::from_str("bearer").is_err());
}
#[test]
fn service_token_header_serializes_typ_as_uppercase_jwt() {
// RFC 7519 §5.1 recommends the JWT `typ` header value be uppercase "JWT".
let header = Header {
alg: SigningAlgorithm::ES256K,
typ: TokenType::Service,
};
let json = serde_json::to_string(&header).expect("serialize header");
assert!(json.contains(r#""typ":"JWT""#), "got {json}");
}
#[test]
fn signing_algorithm_case_insensitive() {
assert_eq!(
+1
View File
@@ -16,5 +16,6 @@ tranquil-ripple = { workspace = true }
async-trait = { workspace = true }
base64 = { workspace = true }
redis = { workspace = true, optional = true }
thiserror = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
+16 -30
View File
@@ -160,18 +160,17 @@ impl Cache for NoOpCache {
}
}
pub struct NoOpRateLimiter;
#[async_trait]
impl DistributedRateLimiter for NoOpRateLimiter {
async fn check_rate_limit(&self, _key: &str, _limit: u32, _window_ms: u64) -> bool {
true
}
#[derive(Debug, thiserror::Error)]
pub enum CacheInitError {
#[error("ripple config: {0}")]
Config(#[from] tranquil_ripple::RippleConfigError),
#[error("ripple start: {0}")]
Start(#[from] tranquil_ripple::RippleStartError),
}
pub async fn create_cache(
shutdown: tokio_util::sync::CancellationToken,
) -> (Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>) {
) -> Result<(Arc<dyn Cache>, Arc<dyn DistributedRateLimiter>), CacheInitError> {
let cache_cfg = tranquil_config::try_get().map(|c| &c.cache);
let backend = cache_cfg.map(|c| c.backend.as_str()).unwrap_or("ripple");
let valkey_url = cache_cfg.and_then(|c| c.valkey_url.as_deref());
@@ -183,7 +182,7 @@ pub async fn create_cache(
Ok(cache) => {
tracing::info!("using valkey cache at {url}");
let rate_limiter = Arc::new(RedisRateLimiter::new(cache.connection()));
return (Arc::new(cache), rate_limiter);
return Ok((Arc::new(cache), rate_limiter));
}
Err(e) => {
tracing::warn!("failed to connect to valkey: {e}. falling back to ripple.");
@@ -201,26 +200,13 @@ pub async fn create_cache(
);
}
match tranquil_ripple::RippleConfig::from_config() {
Ok(config) => {
let peer_count = config.seed_peers.len();
match tranquil_ripple::RippleEngine::start(config, shutdown).await {
Ok((cache, rate_limiter, _bound_addr)) => {
match peer_count {
0 => tracing::info!("ripple cache started (single-node)"),
n => tracing::info!("ripple cache started ({n} seed peers)"),
}
(cache, rate_limiter)
}
Err(e) => {
tracing::error!("ripple engine failed to start: {e:#}. running without cache.");
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
}
}
}
Err(e) => {
tracing::error!("ripple config error: {e:#}. running without cache.");
(Arc::new(NoOpCache), Arc::new(NoOpRateLimiter))
}
let config = tranquil_ripple::RippleConfig::from_config()?;
let peer_count = config.seed_peers.len();
let (cache, rate_limiter, _bound_addr) =
tranquil_ripple::RippleEngine::start(config, shutdown).await?;
match peer_count {
0 => tracing::info!("ripple cache started as a single node"),
n => tracing::info!("ripple cache started with {n} seed peers"),
}
Ok((cache, rate_limiter))
}
+5 -3
View File
@@ -9,6 +9,7 @@ use std::time::Duration;
use async_trait::async_trait;
use hickory_resolver::TokioAsyncResolver;
use hickory_resolver::config::{ResolverConfig, ResolverOpts};
use lettre::message::Mailbox;
use lettre::transport::smtp::AsyncSmtpTransport;
use lettre::transport::smtp::PoolConfig;
@@ -128,9 +129,10 @@ fn build_smarthost(
fn build_direct_mx(cfg: &tranquil_config::TranquilConfig) -> Result<SendMode, SendError> {
let helo = resolve_helo(cfg)?;
let resolver = TokioAsyncResolver::tokio_from_system_conf()
.map(Arc::new)
.map_err(|e| config_invalid("system DNS configuration", e))?;
let resolver = Arc::new(TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
tracing::warn!("falling back to default DNS resolvers: {}", e);
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
}));
let max_concurrent = cfg.email.direct_mx.max_concurrent_sends.max(1);
Ok(SendMode::DirectMx {
resolver,
+120 -2
View File
@@ -259,6 +259,9 @@ impl TranquilConfig {
// -- tls --------------------------------------------------------------
self.server.tls.validate(&mut errors);
// -- cache ------------------------------------------------------------
self.cache.validate(&mut errors);
// -- SSO providers ----------------------------------------------------
self.validate_sso_provider("sso.github", &self.sso.github, &mut errors);
self.validate_sso_provider("sso.google", &self.sso.google, &mut errors);
@@ -513,6 +516,11 @@ pub struct TlsConfig {
/// Path to the TLS private key.
#[config(env = "TLS_KEY_PATH")]
pub key_path: Option<String>,
/// Serve HTTP/3 over QUIC on the same UDP port as the TCP listener.
/// Requires cert_path and key_path.
#[config(env = "TLS_HTTP3", default = false)]
pub http3: bool,
}
impl TlsConfig {
@@ -532,6 +540,13 @@ impl TlsConfig {
.to_string(),
);
}
if self.http3 && self.material().is_none() {
errors.push(
"server.tls.http3 (TLS_HTTP3) requires server.tls.cert_path \
and erver.tls.key_path"
.to_string(),
);
}
}
}
@@ -617,7 +632,9 @@ pub struct SecretsConfig {
#[config(env = "MASTER_KEY")]
pub master_key: Option<String>,
/// PLC rotation key (DID key). If not set, user-level keys are used.
/// Optional operator-held PLC recovery key, as a public `did:key`. The PDS
/// continues to sign PLC operations with the per-account signing key, which
/// always remains in `rotationKeys`.
#[config(env = "PLC_ROTATION_KEY")]
pub plc_rotation_key: Option<String>,
@@ -774,6 +791,31 @@ pub struct CacheConfig {
pub ripple: RippleCacheConfig,
}
impl CacheConfig {
pub fn validate(&self, errors: &mut Vec<String>) {
let clustered = self
.ripple
.peers
.as_deref()
.unwrap_or(&[])
.iter()
.any(|p| !p.trim().is_empty());
let keyed = self
.ripple
.cluster_key
.as_deref()
.is_some_and(|k| !k.trim().is_empty());
if self.backend == "ripple" && clustered && !keyed && !self.ripple.allow_insecure {
errors.push(
"cache.ripple.peers (RIPPLE_PEERS) is set without cache.ripple.cluster_key \
(RIPPLE_CLUSTER_KEY); set the cluster key to authenticate peers, or set \
cache.ripple.allow_insecure (RIPPLE_ALLOW_INSECURE) for a trusted private network"
.to_string(),
);
}
}
}
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct PlcConfig {
@@ -1432,7 +1474,9 @@ fn split_comma_list(value: &str) -> Result<Vec<String>, std::convert::Infallible
#[derive(Debug, Config)]
#[config(layer_attr(serde(deny_unknown_fields)))]
pub struct RippleCacheConfig {
/// Address to bind the Ripple gossip protocol listener.
/// Address to bind the Ripple gossip protocol listener. With the default
/// value and no cluster_key or peers configured, the listener binds
/// loopback instead and runs as a single node.
#[config(env = "RIPPLE_BIND", default = "0.0.0.0:0")]
pub bind_addr: String,
@@ -1451,6 +1495,16 @@ pub struct RippleCacheConfig {
/// Maximum cache size in megabytes.
#[config(env = "RIPPLE_CACHE_MAX_MB", default = 256)]
pub cache_max_mb: usize,
/// Pre-shared cluster key authenticating ripple peers. Every node in the
/// cluster must set the same value. When unset, peers are unauthenticated.
#[config(env = "RIPPLE_CLUSTER_KEY")]
pub cluster_key: Option<String>,
/// Allow ripple to bind a non-loopback address without a cluster key.
/// Peers will be unauthenticated. Intended for trusted private networks.
#[config(env = "RIPPLE_ALLOW_INSECURE", default = false)]
pub allow_insecure: bool,
}
#[derive(Debug, Config)]
@@ -1799,6 +1853,7 @@ port = 587
TlsConfig {
cert_path: None,
key_path: None,
http3: false,
}
.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
@@ -1810,6 +1865,7 @@ port = 587
TlsConfig {
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
key_path: Some("/etc/tranquil/key.pem".to_string()),
http3: false,
}
.validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
@@ -1821,6 +1877,7 @@ port = 587
TlsConfig {
cert_path: Some("/etc/tranquil/cert.pem".to_string()),
key_path: None,
http3: false,
}
.validate(&mut errors);
assert!(
@@ -1829,6 +1886,67 @@ port = 587
);
}
#[test]
fn tls_validate_rejects_http3_without_material() {
let mut errors = Vec::new();
TlsConfig {
cert_path: None,
key_path: None,
http3: true,
}
.validate(&mut errors);
assert!(
errors.iter().any(|e| e.contains("http3")),
"expected http3 error, got {errors:?}"
);
}
fn cache_config_for_test(
peers: Option<Vec<String>>,
cluster_key: Option<&str>,
allow_insecure: bool,
) -> CacheConfig {
CacheConfig {
backend: "ripple".to_string(),
valkey_url: None,
ripple: RippleCacheConfig {
bind_addr: "0.0.0.0:0".to_string(),
peers,
machine_id: None,
gossip_interval_ms: 200,
cache_max_mb: 256,
cluster_key: cluster_key.map(str::to_string),
allow_insecure,
},
}
}
#[test]
fn cache_validate_rejects_clustered_keyless_ripple() {
let mut errors = Vec::new();
cache_config_for_test(Some(vec!["10.0.0.7:7000".to_string()]), None, false)
.validate(&mut errors);
assert!(
errors.iter().any(|e| e.contains("RIPPLE_CLUSTER_KEY")),
"expected cluster key error, got {errors:?}"
);
}
#[test]
fn cache_validate_accepts_keyed_insecure_or_standalone() {
let mut errors = Vec::new();
cache_config_for_test(
Some(vec!["10.0.0.7:7000".to_string()]),
Some("nautilus-secret"),
false,
)
.validate(&mut errors);
cache_config_for_test(Some(vec!["10.0.0.7:7000".to_string()]), None, true)
.validate(&mut errors);
cache_config_for_test(None, None, false).validate(&mut errors);
assert!(errors.is_empty(), "expected no errors, got {errors:?}");
}
#[derive(Default)]
struct EmailOverrides {
from_address: Option<&'static str>,
-11
View File
@@ -268,17 +268,6 @@ pub trait InfraRepository: Send + Sync {
code: &'a str,
) -> Result<ValidatedInviteCode<'a>, InviteCodeError>;
async fn decrement_invite_code_uses(
&self,
code: &ValidatedInviteCode<'_>,
) -> Result<(), DbError>;
async fn record_invite_code_use(
&self,
code: &ValidatedInviteCode<'_>,
used_by_user: Uuid,
) -> Result<(), DbError>;
async fn get_invite_codes_for_account(
&self,
for_account: &Did,
+2 -1
View File
@@ -43,7 +43,8 @@ pub use repo::{
pub use scope::{DbScope, InvalidScopeError};
pub use sequence::{SequenceNumber, deserialize_optional_sequence};
pub use session::{
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult,
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType,
REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshGraceReplay, RefreshSessionResult,
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
SessionRepository, SessionToken, SessionTokenCreate,
};
+50 -22
View File
@@ -158,15 +158,53 @@ pub struct SessionMfaStatus {
pub last_reauth_at: Option<DateTime<Utc>>,
}
/// Window during which a just-rotated refresh token may still be presented by a
/// benignly-racing or retrying client without being treated as a compromise.
/// The window is measured per rotated token from its own rotation time
/// (`used_refresh_tokens.used_at` / the metastore marker's `rotated_at_ms`),
/// matching the reference atproto PDS `REFRESH_GRACE_MS`
/// (`account-manager.ts::rotateRefreshToken`), which shortens a rotated refresh
/// token's lifetime to a 2-hour revocation grace period.
pub const REFRESH_GRACE_PERIOD_SECS: i64 = 2 * 60 * 60;
/// The session's current token identity (plus its signing key) needed to re-mint
/// the access/refresh JWTs during a grace-window replay. We carry the key so the
/// caller can verify the presented token's signature before issuing anything.
#[derive(Debug, Clone)]
pub struct RefreshGraceReplay {
pub did: Did,
pub scope: Option<String>,
pub controller_did: Option<Did>,
pub access_jti: String,
pub refresh_jti: String,
pub access_expires_at: DateTime<Utc>,
pub refresh_expires_at: DateTime<Utc>,
pub key_bytes: Vec<u8>,
pub encryption_version: i32,
}
#[derive(Debug, Clone)]
pub enum RefreshSessionResult {
Success,
TokenAlreadyUsed,
ConcurrentRefresh,
GraceReplay(RefreshGraceReplay),
Compromise,
}
#[derive(Debug, Clone)]
pub enum RefreshGraceLookup {
NotUsed,
Replay(RefreshGraceReplay),
Compromised {
did: Did,
session_id: SessionId,
key_bytes: Vec<u8>,
encryption_version: i32,
},
}
#[derive(Debug, Clone)]
pub struct SessionRefreshData {
pub did: Did,
pub old_refresh_jti: String,
pub session_id: SessionId,
pub new_access_jti: String,
@@ -189,18 +227,17 @@ pub trait SessionRepository: Send + Sync {
refresh_jti: &str,
) -> Result<Option<SessionForRefresh>, DbError>;
async fn update_session_tokens(
async fn delete_session_by_access_jti(
&self,
access_jti: &str,
did: &Did,
) -> Result<u64, DbError>;
async fn delete_session_by_id(
&self,
session_id: SessionId,
new_access_jti: &str,
new_refresh_jti: &str,
new_access_expires_at: DateTime<Utc>,
new_refresh_expires_at: DateTime<Utc>,
) -> Result<(), DbError>;
async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError>;
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError>;
did: &Did,
) -> Result<u64, DbError>;
async fn delete_sessions_by_did(&self, did: &Did) -> Result<u64, DbError>;
@@ -230,16 +267,7 @@ pub trait SessionRepository: Send + Sync {
app_password_name: &str,
) -> Result<Vec<String>, DbError>;
async fn check_refresh_token_used(
&self,
refresh_jti: &str,
) -> Result<Option<SessionId>, DbError>;
async fn mark_refresh_token_used(
&self,
refresh_jti: &str,
session_id: SessionId,
) -> Result<bool, DbError>;
async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result<RefreshGraceLookup, DbError>;
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError>;
+1 -3
View File
@@ -589,8 +589,6 @@ pub trait UserRepository: Send + Sync {
async fn cleanup_expired_handle_reservations(&self) -> Result<u64, DbError>;
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError>;
async fn complete_passkey_setup(
&self,
input: &CompletePasskeySetupInput,
@@ -1019,6 +1017,7 @@ pub enum CreateAccountError {
EmailTaken,
DidExists,
InvalidToken,
InviteCodeUnavailable,
Database(String),
}
@@ -1034,7 +1033,6 @@ pub struct CreateDelegatedAccountInput {
pub commit_cid: String,
pub repo_rev: String,
pub genesis_block_cids: Vec<Vec<u8>>,
pub invite_code: Option<String>,
}
#[derive(Debug, Clone)]
-32
View File
@@ -225,38 +225,6 @@ impl InfraRepository for PostgresInfraRepository {
}
}
async fn decrement_invite_code_uses(
&self,
code: &ValidatedInviteCode<'_>,
) -> Result<(), DbError> {
sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code.code()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn record_invite_code_use(
&self,
code: &ValidatedInviteCode<'_>,
used_by_user: Uuid,
) -> Result<(), DbError> {
sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code.code(),
used_by_user
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn get_invite_codes_for_account(
&self,
for_account: &Did,
+95 -88
View File
@@ -1,10 +1,11 @@
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use chrono::{DateTime, Duration, Utc};
use sqlx::PgPool;
use tranquil_db_traits::{
AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType,
RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus,
SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate,
REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshGraceReplay, RefreshSessionResult,
SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData,
SessionRepository, SessionToken, SessionTokenCreate,
};
use tranquil_types::Did;
use uuid::Uuid;
@@ -113,38 +114,15 @@ impl SessionRepository for PostgresSessionRepository {
}))
}
async fn update_session_tokens(
async fn delete_session_by_access_jti(
&self,
session_id: SessionId,
new_access_jti: &str,
new_refresh_jti: &str,
new_access_expires_at: DateTime<Utc>,
new_refresh_expires_at: DateTime<Utc>,
) -> Result<(), DbError> {
sqlx::query!(
r#"
UPDATE session_tokens
SET access_jti = $1, refresh_jti = $2, access_expires_at = $3,
refresh_expires_at = $4, updated_at = NOW()
WHERE id = $5
"#,
new_access_jti,
new_refresh_jti,
new_access_expires_at,
new_refresh_expires_at,
session_id.as_i32()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(())
}
async fn delete_session_by_access_jti(&self, access_jti: &str) -> Result<u64, DbError> {
access_jti: &str,
did: &Did,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM session_tokens WHERE access_jti = $1",
access_jti
"DELETE FROM session_tokens WHERE access_jti = $1 AND did = $2",
access_jti,
did.as_str()
)
.execute(&self.pool)
.await
@@ -153,10 +131,15 @@ impl SessionRepository for PostgresSessionRepository {
Ok(result.rows_affected())
}
async fn delete_session_by_id(&self, session_id: SessionId) -> Result<u64, DbError> {
async fn delete_session_by_id(
&self,
session_id: SessionId,
did: &Did,
) -> Result<u64, DbError> {
let result = sqlx::query!(
"DELETE FROM session_tokens WHERE id = $1",
session_id.as_i32()
"DELETE FROM session_tokens WHERE id = $1 AND did = $2",
session_id.as_i32(),
did.as_str()
)
.execute(&self.pool)
.await
@@ -267,40 +250,52 @@ impl SessionRepository for PostgresSessionRepository {
Ok(rows)
}
async fn check_refresh_token_used(
&self,
refresh_jti: &str,
) -> Result<Option<SessionId>, DbError> {
let row = sqlx::query_scalar!(
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1",
async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result<RefreshGraceLookup, DbError> {
let row = sqlx::query!(
r#"
SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did,
st.access_jti, st.refresh_jti, st.access_expires_at, st.refresh_expires_at,
k.key_bytes, k.encryption_version
FROM used_refresh_tokens u
JOIN session_tokens st ON st.id = u.session_id
JOIN users us ON st.did = us.did
JOIN user_keys k ON us.id = k.user_id
WHERE u.refresh_jti = $1
"#,
refresh_jti
)
.fetch_optional(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(row.map(SessionId::new))
}
// No marker (or a missing users/user_keys join row) degrades to NotUsed.
// That is safe: the normal refresh path then fails closed with "Invalid
// refresh token" without mutating any state.
let Some(r) = row else {
return Ok(RefreshGraceLookup::NotUsed);
};
async fn mark_refresh_token_used(
&self,
refresh_jti: &str,
session_id: SessionId,
) -> Result<bool, DbError> {
let result = sqlx::query!(
r#"
INSERT INTO used_refresh_tokens (refresh_jti, session_id)
VALUES ($1, $2)
ON CONFLICT (refresh_jti) DO NOTHING
"#,
refresh_jti,
session_id.as_i32()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
Ok(result.rows_affected() > 0)
let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS);
if r.used_at > grace_cutoff {
Ok(RefreshGraceLookup::Replay(RefreshGraceReplay {
did: Did::from(r.did),
scope: r.scope,
controller_did: r.controller_did.map(Did::from),
access_jti: r.access_jti,
refresh_jti: r.refresh_jti,
access_expires_at: r.access_expires_at,
refresh_expires_at: r.refresh_expires_at,
key_bytes: r.key_bytes,
encryption_version: r.encryption_version.unwrap_or(0),
}))
} else {
Ok(RefreshGraceLookup::Compromised {
did: Did::from(r.did),
session_id: SessionId::new(r.session_id),
key_bytes: r.key_bytes,
encryption_version: r.encryption_version.unwrap_or(0),
})
}
}
async fn list_app_passwords(&self, user_id: Uuid) -> Result<Vec<AppPasswordRecord>, DbError> {
@@ -524,21 +519,10 @@ impl SessionRepository for PostgresSessionRepository {
) -> Result<RefreshSessionResult, DbError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
if let Ok(Some(session_id)) = sqlx::query_scalar!(
"SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE",
data.old_refresh_jti
)
.fetch_optional(&mut *tx)
.await
{
let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id)
.execute(&mut *tx)
.await;
tx.commit().await.map_err(map_sqlx_error)?;
return Ok(RefreshSessionResult::TokenAlreadyUsed);
}
let result = sqlx::query!(
// Atomically claim the old refresh jti. The INSERT serializes concurrent
// rotations of the same token: exactly one request inserts the row, the
// rest see `rows_affected == 0`.
let claimed = sqlx::query!(
"INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING",
data.old_refresh_jti,
data.session_id.as_i32()
@@ -547,19 +531,42 @@ impl SessionRepository for PostgresSessionRepository {
.await
.map_err(map_sqlx_error)?;
if result.rows_affected() == 0 {
let _ = sqlx::query!(
"DELETE FROM session_tokens WHERE id = $1",
data.session_id.as_i32()
)
.execute(&mut *tx)
.await;
tx.commit().await.map_err(map_sqlx_error)?;
return Ok(RefreshSessionResult::ConcurrentRefresh);
if claimed.rows_affected() == 0 {
// Another request already rotated this token. Nothing to write, so
// end our transaction before reading the winner's committed row.
tx.rollback().await.map_err(map_sqlx_error)?;
// Within the grace window (measured from this token's own rotation
// time) we replay the session's current tokens so a benignly-racing
// client keeps a working session instead of being revoked.
match self.lookup_refresh_grace(&data.old_refresh_jti).await? {
RefreshGraceLookup::Replay(replay) => {
return Ok(RefreshSessionResult::GraceReplay(replay));
}
RefreshGraceLookup::Compromised { .. } | RefreshGraceLookup::NotUsed => {
// Outside the grace window, or the marker/session vanished
// concurrently: genuine reuse. Revoke the session (delete is
// idempotent).
sqlx::query!(
"DELETE FROM session_tokens WHERE id = $1",
data.session_id.as_i32()
)
.execute(&self.pool)
.await
.map_err(map_sqlx_error)?;
return Ok(RefreshSessionResult::Compromise);
}
}
}
// We won the rotation.
sqlx::query!(
"UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5",
r#"
UPDATE session_tokens
SET access_jti = $1, refresh_jti = $2, access_expires_at = $3,
refresh_expires_at = $4, updated_at = NOW()
WHERE id = $5
"#,
data.new_access_jti,
data.new_refresh_jti,
data.new_access_expires_at,
+35 -91
View File
@@ -43,6 +43,38 @@ pub(crate) fn map_sqlx_error(e: sqlx::Error) -> DbError {
}
}
async fn consume_invite_code(
conn: &mut sqlx::PgConnection,
code: &str,
user_id: Uuid,
) -> Result<(), tranquil_db_traits::CreateAccountError> {
let map_err = |e: sqlx::Error| tranquil_db_traits::CreateAccountError::Database(e.to_string());
let decremented = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1 AND available_uses > 0 AND COALESCE(disabled, false) = false",
code
)
.execute(&mut *conn)
.await
.map_err(map_err)?
.rows_affected();
if decremented == 0 {
return Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable);
}
sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *conn)
.await
.map_err(map_err)?;
Ok(())
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn get_by_did(&self, did: &Did) -> Result<Option<UserRow>, DbError> {
@@ -2521,20 +2553,7 @@ impl UserRepository for PostgresUserRepository {
})?;
if let Some(code) = &input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
consume_invite_code(&mut tx, code, user_id).await?;
}
if let Some(birthdate_pref) = &input.birthdate_pref {
@@ -2650,23 +2669,6 @@ impl UserRepository for PostgresUserRepository {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
if let Some(code) = &input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
}
tx.commit().await.map_err(|e: sqlx::Error| {
tranquil_db_traits::CreateAccountError::Database(e.to_string())
})?;
@@ -2784,20 +2786,7 @@ impl UserRepository for PostgresUserRepository {
})?;
if let Some(code) = &input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
consume_invite_code(&mut tx, code, user_id).await?;
}
if let Some(birthdate_pref) = &input.birthdate_pref {
@@ -2932,20 +2921,7 @@ impl UserRepository for PostgresUserRepository {
})?;
if let Some(code) = &input.invite_code {
let _ = sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await;
let _ = sqlx::query!(
"INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
code,
user_id
)
.execute(&mut *tx)
.await;
consume_invite_code(&mut tx, code, user_id).await?;
}
if let Some(birthdate_pref) = &input.birthdate_pref {
@@ -3124,38 +3100,6 @@ impl UserRepository for PostgresUserRepository {
Ok(result.rows_affected())
}
async fn check_and_consume_invite_code(&self, code: &str) -> Result<bool, DbError> {
let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?;
let invite = sqlx::query!(
"SELECT available_uses FROM invite_codes WHERE code = $1 FOR UPDATE",
code
)
.fetch_optional(&mut *tx)
.await
.map_err(map_sqlx_error)?;
let Some(row) = invite else {
return Ok(false);
};
if row.available_uses <= 0 {
return Ok(false);
}
sqlx::query!(
"UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
code
)
.execute(&mut *tx)
.await
.map_err(map_sqlx_error)?;
tx.commit().await.map_err(map_sqlx_error)?;
Ok(true)
}
async fn complete_passkey_setup(
&self,
input: &tranquil_db_traits::CompletePasskeySetupInput,
+4 -1
View File
@@ -85,7 +85,10 @@ pub fn nsid_to_authority(nsid: &str) -> Result<String, ResolveError> {
}
pub async fn resolve_did_from_dns(authority: &str) -> Result<String, ResolveError> {
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
let resolver = TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
tracing::warn!("falling back to default DNS resolvers: {}", e);
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
});
let extract_did = |lookup: hickory_resolver::lookup::TxtLookup| -> Option<String> {
lookup
@@ -351,34 +351,16 @@ pub async fn consent_post(
} else {
original_scope_str.to_string()
};
let requested_scopes: Vec<&str> = effective_scope_str.split_whitespace().collect();
let has_granular_scopes = requested_scopes.iter().any(|s| is_granular_scope(s));
let user_denied_some_granular = has_granular_scopes
&& requested_scopes
.iter()
.filter(|s| is_granular_scope(s))
.any(|s| !form.approved_scopes.contains(&s.to_string()));
let atproto_was_requested = requested_scopes.contains(&"atproto");
if atproto_was_requested
&& !has_granular_scopes
&& !form.approved_scopes.contains(&"atproto".to_string())
{
if atproto_was_requested && !form.approved_scopes.contains(&"atproto".to_string()) {
return json_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"The atproto scope was requested and must be approved",
);
}
let final_approved: Vec<String> = if user_denied_some_granular {
form.approved_scopes
.iter()
.filter(|s| *s != "atproto")
.cloned()
.collect()
} else {
form.approved_scopes.clone()
};
let final_approved: Vec<String> = form.approved_scopes.clone();
if final_approved.is_empty() {
return json_error(
StatusCode::BAD_REQUEST,
@@ -148,7 +148,14 @@ pub async fn handle_authorization_code_grant(
.await
.ok()
.flatten();
let granted_scopes = grant.map(|g| g.granted_scopes).unwrap_or_default();
let granted_scopes = match grant {
Some(g) => g.granted_scopes,
None => {
return Err(OAuthError::InvalidGrant(
"Delegation grant not found or revoked".to_string(),
));
}
};
let requested = authorized.parameters.scope.as_deref().unwrap_or("atproto");
let intersected = intersect_scopes(requested, granted_scopes.as_str());
(Some(intersected), Some(controller.clone()))
@@ -10,6 +10,7 @@ use tranquil_db_traits::{SsoAction, SsoProviderType};
use tranquil_types::RequestId;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::invite::check_registration_invite;
use tranquil_pds::auth::extractor::extract_auth_token_from_header;
use tranquil_pds::auth::{generate_app_password, validate_bearer_token_cached};
use tranquil_pds::rate_limit::{
@@ -985,18 +986,8 @@ pub async fn complete_registration(
None => None,
};
let _validated_invite_code = if let Some(ref code) = input.invite_code {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Err(ApiError::InviteCodeRequired);
}
None
};
let invite_registration =
check_registration_invite(&state, input.invite_code.as_deref()).await?;
let handle_typed: tranquil_pds::types::Handle =
handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
@@ -1052,15 +1043,9 @@ pub async fn complete_registration(
d.to_string()
}
_ => {
let rotation_key = tranquil_config::get()
.secrets
.plc_rotation_key
.clone()
.unwrap_or_else(|| tranquil_pds::plc::signing_key_to_did_key(&signing_key));
let genesis_result = match tranquil_pds::plc::create_genesis_operation(
&signing_key,
&rotation_key,
tranquil_config::get().secrets.plc_rotation_key.as_deref(),
&handle,
&pds_endpoint,
) {
@@ -1169,7 +1154,7 @@ pub async fn complete_registration(
commit_cid: commit_cid.to_string(),
repo_rev: rev.as_ref().to_string(),
genesis_block_cids,
invite_code: input.invite_code.clone(),
invite_code: invite_registration.into_invite_code(),
birthdate_pref,
sso_provider: pending_preview.provider,
sso_provider_user_id: pending_preview.provider_user_id.clone().into_inner(),
@@ -1196,6 +1181,9 @@ pub async fn complete_registration(
Err(tranquil_db_traits::CreateAccountError::InvalidToken) => {
return Err(ApiError::SsoSessionExpired);
}
Err(tranquil_db_traits::CreateAccountError::InviteCodeUnavailable) => {
return Err(ApiError::InvalidInviteCode);
}
Err(e) => {
tracing::error!("Error creating SSO account: {:?}", e);
return Err(ApiError::InternalError(None));
+2
View File
@@ -94,6 +94,8 @@ frontend = []
native-tls-roots = ["tranquil-oauth/native-tls-roots"]
[dev-dependencies]
tempfile = "3"
totp-rs = { workspace = true }
ciborium = { workspace = true }
ctor = { workspace = true }
testcontainers = { workspace = true }
+30 -2
View File
@@ -15,6 +15,7 @@ struct ErrorBody<'a> {
#[derive(Debug)]
pub enum ApiError {
InternalError(Option<String>),
RepoCorruption,
AuthenticationRequired,
AuthenticationFailed(Option<String>),
InvalidRequest(String),
@@ -121,10 +122,34 @@ pub enum ApiError {
},
}
const MST_NODE_MISSING_MARKER: &str = "MST node not found";
impl ApiError {
pub fn is_repo_corruption(&self) -> bool {
matches!(self, Self::RepoCorruption)
}
pub fn detail_is_repo_corruption(detail: &str) -> bool {
detail.contains(tranquil_store::blockstore::BLOCK_CORRUPTION_MARKER)
|| detail.contains(MST_NODE_MISSING_MARKER)
}
pub fn from_mst_error(context: &str, e: &jacquard_repo::error::RepoError) -> Self {
let detail = format!("{e:#}");
if Self::detail_is_repo_corruption(&detail) {
tracing::warn!("{context}: repairable MST damage: {detail}");
Self::RepoCorruption
} else {
tracing::error!("{context}: {detail}");
Self::InternalError(None)
}
}
fn status_code(&self) -> StatusCode {
match self {
Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR,
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
StatusCode::INTERNAL_SERVER_ERROR
}
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
StatusCode::BAD_GATEWAY
}
@@ -223,7 +248,9 @@ impl ApiError {
}
fn error_name(&self) -> Cow<'static, str> {
match self {
Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalServerError"),
Self::InternalError(_) | Self::RepoCorruption | Self::DatabaseError => {
Cow::Borrowed("InternalServerError")
}
Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => {
Cow::Borrowed("UpstreamError")
}
@@ -332,6 +359,7 @@ impl ApiError {
Self::InternalError(msg) => msg
.clone()
.unwrap_or_else(|| "Internal Server Error".into()),
Self::RepoCorruption => "Internal Server Error".into(),
Self::AuthenticationFailed(msg) => msg
.clone()
.unwrap_or_else(|| "Authentication failed".into()),
+94
View File
@@ -0,0 +1,94 @@
use tranquil_db_traits::InviteCodeError;
use crate::api::error::ApiError;
use crate::state::AppState;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InviteRegistration {
Bootstrap,
Standard(Option<String>),
}
impl InviteRegistration {
pub fn into_invite_code(self) -> Option<String> {
match self {
InviteRegistration::Bootstrap => None,
InviteRegistration::Standard(code) => code,
}
}
}
fn bootstrap_registration(
expected_code: &str,
user_count: i64,
invite_code: Option<&str>,
) -> Option<Result<InviteRegistration, ApiError>> {
if user_count != 0 {
return None;
}
Some(match invite_code {
Some(code) if code == expected_code => Ok(InviteRegistration::Bootstrap),
_ => Err(ApiError::InvalidInviteCode),
})
}
pub async fn check_registration_invite(
state: &AppState,
invite_code: Option<&str>,
) -> Result<InviteRegistration, ApiError> {
if let Some(expected) = state.bootstrap_invite_code.as_deref() {
let user_count = state.repos.user.count_users().await.unwrap_or(1);
if let Some(decision) = bootstrap_registration(expected, user_count, invite_code) {
return decision;
}
}
match invite_code.map(str::trim).filter(|code| !code.is_empty()) {
Some(code) => match state.repos.infra.validate_invite_code(code).await {
Ok(_) => Ok(InviteRegistration::Standard(Some(code.to_owned()))),
Err(InviteCodeError::DatabaseError(e)) => {
tracing::error!("failed to validate invite code: {e:?}");
Err(ApiError::InternalError(None))
}
Err(_) => Err(ApiError::InvalidInviteCode),
},
None => match tranquil_config::get().server.invite_code_required {
true => Err(ApiError::InviteCodeRequired),
false => Ok(InviteRegistration::Standard(None)),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bootstrap_taken_for_matching_code_on_empty_instance() {
assert!(matches!(
bootstrap_registration("squid-bootstrap", 0, Some("squid-bootstrap")),
Some(Ok(InviteRegistration::Bootstrap))
));
}
#[test]
fn bootstrap_rejects_wrong_code_on_empty_instance() {
assert!(matches!(
bootstrap_registration("squid-bootstrap", 0, Some("whelk")),
Some(Err(ApiError::InvalidInviteCode))
));
}
#[test]
fn bootstrap_rejects_missing_code_on_empty_instance() {
assert!(matches!(
bootstrap_registration("squid-bootstrap", 0, None),
Some(Err(ApiError::InvalidInviteCode))
));
}
#[test]
fn bootstrap_falls_through_once_users_exist() {
assert!(bootstrap_registration("squid-bootstrap", 1, Some("squid-bootstrap")).is_none());
}
}
+1
View File
@@ -1,4 +1,5 @@
pub mod error;
pub mod invite;
pub mod proxy;
pub mod proxy_client;
pub mod responses;
+48 -2
View File
@@ -109,6 +109,35 @@ fn is_protected_method(method: &str) -> bool {
PROTECTED_METHODS.contains(method)
}
/// Fetch the `feed` generator record from the AppView and return its `did`.
async fn resolve_feed_generator_did(appview_url: &str, query: Option<&str>) -> Option<String> {
#[derive(serde::Deserialize)]
struct GetFeedQuery {
feed: String,
}
let feed = serde_urlencoded::from_str::<GetFeedQuery>(query?)
.ok()?
.feed;
let at_uri = crate::types::AtUri::new(feed).ok()?;
let repo = at_uri.did()?;
let collection = at_uri.collection()?;
let rkey = at_uri.rkey()?;
let resp = proxy_client()
.get(format!("{appview_url}/xrpc/com.atproto.repo.getRecord"))
.query(&[("repo", repo), ("collection", collection), ("rkey", rkey)])
.send()
.await
.ok()?;
if !resp.status().is_success() {
warn!(status = %resp.status(), "getFeed proxy: getRecord for feed generator failed");
return None;
}
let body: serde_json::Value = resp.json().await.ok()?;
body.get("value")?.get("did")?.as_str().map(str::to_string)
}
pub struct XrpcProxyLayer {
state: AppState,
}
@@ -287,10 +316,27 @@ async fn proxy_handler(
},
};
// getFeed must be audienced to the feed generator, not the AppView.
let (token_aud, token_lxm) = if method == "app.bsky.feed.getFeed" {
match resolve_feed_generator_did(&resolved.url, query.as_deref()).await {
Some(feed_did) => (feed_did, "app.bsky.feed.getFeedSkeleton"),
None => {
warn!(
"getFeed proxy: could not resolve feed generator DID; refusing \
to mint an AppView-audienced token"
);
return ApiError::InvalidRequest("Could not resolve feed".into())
.into_response();
}
}
} else {
(resolved.did.clone(), method)
};
match crate::auth::create_service_token(
&auth_user.did,
&resolved.did,
Some(method),
&token_aud,
Some(token_lxm),
&key_bytes,
) {
Ok(new_token) => {
+31 -27
View File
@@ -1,11 +1,10 @@
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cache::Cache;
use crate::util::{generate_token_code, normalize_token_code};
const TOKEN_TTL_SECS: u64 = 900;
const BASE32_CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmailTokenPurpose {
@@ -46,14 +45,6 @@ fn cache_key(did: &str, purpose: EmailTokenPurpose) -> String {
format!("email_token:{}:{}", purpose.as_str(), did)
}
fn generate_short_token() -> String {
let mut rng = rand::thread_rng();
let token: String = (0..10)
.map(|_| BASE32_CHARS[rng.gen_range(0..BASE32_CHARS.len())] as char)
.collect();
format!("{}-{}", &token[0..5], &token[5..10])
}
fn current_timestamp() -> u64 {
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0)
}
@@ -67,9 +58,9 @@ pub async fn create_email_token(
return Err(TokenError::CacheUnavailable);
}
let token = generate_short_token();
let token = generate_token_code();
let data = TokenData {
token: token.clone(),
token: normalize_token_code(&token),
created_at: current_timestamp(),
};
@@ -108,10 +99,9 @@ pub async fn validate_email_token(
return Err(TokenError::ExpiredToken);
}
let normalized_input = token.to_uppercase().replace('-', "");
let normalized_stored = data.token.to_uppercase().replace('-', "");
let normalized_input = normalize_token_code(token);
if !constant_time_eq(normalized_input.as_bytes(), normalized_stored.as_bytes()) {
if !constant_time_eq(normalized_input.as_bytes(), data.token.as_bytes()) {
return Err(TokenError::InvalidToken);
}
@@ -260,20 +250,19 @@ mod tests {
#[tokio::test]
async fn test_token_format() {
(0..100).for_each(|_| {
let token = generate_short_token();
// The emitted token is the display form: uppercase `XXXXX-XXXXX`.
let cache = MockCache::new();
let did = "did:plc:test123";
(0..50).for_each(|_| {
let token = futures::executor::block_on(create_email_token(
&cache,
did,
EmailTokenPurpose::UpdateEmail,
))
.unwrap();
assert_eq!(token.len(), 11);
assert_eq!(&token[5..6], "-");
assert!(
token[0..5]
.chars()
.all(|c| BASE32_CHARS.contains(&(c as u8)))
);
assert!(
token[6..11]
.chars()
.all(|c| BASE32_CHARS.contains(&(c as u8)))
);
assert_eq!(token, token.to_uppercase());
});
}
@@ -292,6 +281,21 @@ mod tests {
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let did = "did:plc:test123";
let token = create_email_token(&cache, did, EmailTokenPurpose::UpdateEmail)
.await
.unwrap();
let no_hyphen = token.replace('-', "");
let result =
validate_email_token(&cache, did, EmailTokenPurpose::UpdateEmail, &no_hyphen).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_noop_cache_returns_unavailable() {
let cache = crate::cache::NoOpCache;
+227 -31
View File
@@ -1,15 +1,14 @@
use chrono::Utc;
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::cache::Cache;
use crate::types::Did;
use crate::util::{generate_token_code, normalize_token_code};
const CHALLENGE_TTL_SECS: u64 = 300;
const MIN_REMAINING_TTL_SECS: u64 = 10;
const MAX_ATTEMPTS: u8 = 5;
const CODE_LENGTH: usize = 8;
const COOLDOWN_SECS: u64 = 60;
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -94,7 +93,8 @@ async fn validate_challenge_internal(
return Err(ValidationError::ChallengeExpired);
}
if !constant_time_eq(code.as_bytes(), data.code.as_bytes()) {
let normalized_input = normalize_token_code(code);
if !constant_time_eq(normalized_input.as_bytes(), data.code.as_bytes()) {
let updated = ChallengeData {
code: data.code,
attempts: data.attempts + 1,
@@ -127,17 +127,19 @@ fn cooldown_key(did: &str) -> String {
format!("legacy_2fa_cooldown:{}", did)
}
fn generate_code() -> String {
let mut rng = rand::thread_rng();
(0..CODE_LENGTH)
.map(|_| rng.gen_range(0..10).to_string())
.collect()
}
fn current_timestamp() -> u64 {
u64::try_from(Utc::now().timestamp()).unwrap_or(0)
}
pub fn looks_like_totp_token(code: &str) -> bool {
let c = code.trim();
(c.len() == 6 && c.bytes().all(|b| b.is_ascii_digit())) || crate::auth::is_backup_code_format(c)
}
pub fn used_totp_factor(has_totp: bool, auth_factor_token: Option<&str>) -> bool {
has_totp && auth_factor_token.is_some_and(looks_like_totp_token)
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
@@ -148,6 +150,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
== 0
}
#[derive(Debug)]
pub enum Legacy2faOutcome {
NotRequired,
Blocked,
@@ -177,6 +180,7 @@ pub async fn process_legacy_2fa(
did: &Did,
ctx: &Legacy2faContext,
auth_factor_token: Option<&str>,
verify_totp: impl AsyncFnOnce(&str) -> bool,
) -> Result<Legacy2faOutcome, Legacy2faFlowError> {
if !ctx.requires_2fa() {
return Ok(Legacy2faOutcome::NotRequired);
@@ -192,8 +196,16 @@ pub async fn process_legacy_2fa(
Ok(Legacy2faOutcome::ChallengeSent(code))
}
Some(token) => {
validate_challenge(cache, did, token).await?;
Ok(Legacy2faOutcome::Verified)
if ctx.has_totp && looks_like_totp_token(token) {
if verify_totp(token).await {
Ok(Legacy2faOutcome::Verified)
} else {
Err(Legacy2faFlowError::Validation(ValidationError::InvalidCode))
}
} else {
validate_challenge(cache, did, token).await?;
Ok(Legacy2faOutcome::Verified)
}
}
}
}
@@ -219,11 +231,11 @@ async fn create_challenge_code(
return Err(ChallengeError::RateLimited);
}
let code = generate_code();
let display = generate_token_code();
let now = current_timestamp();
let data = ChallengeData {
code: code.clone(),
code: normalize_token_code(&display),
attempts: 0,
created_at: now,
};
@@ -244,7 +256,7 @@ async fn create_challenge_code(
.await
.map_err(|_| ChallengeError::CacheError)?;
Ok(ChallengeCode(code))
Ok(ChallengeCode(display))
}
#[derive(Debug)]
@@ -332,12 +344,46 @@ mod tests {
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
assert_eq!(code.as_str().len(), CODE_LENGTH);
assert_eq!(code.as_str().len(), 11);
let result = validate_challenge(&cache, &did, code.as_str()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_challenge_code_format() {
let cache = MockCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
let code = code.as_str();
assert_eq!(code.len(), 11);
assert_eq!(&code[5..6], "-");
assert_eq!(code, code.to_uppercase());
}
#[tokio::test]
async fn test_case_insensitive_validation() {
let cache = MockCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
let lowercase = code.as_str().to_lowercase();
let result = validate_challenge(&cache, &did, &lowercase).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_hyphen_insensitive_validation() {
let cache = MockCache::new();
let did = Did::new("did:plc:test123".to_string()).unwrap();
let code = create_challenge(&cache, &did).await.unwrap();
let no_hyphen = code.as_str().replace('-', "");
let result = validate_challenge(&cache, &did, &no_hyphen).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_invalid_code_rejected() {
let cache = MockCache::new();
@@ -396,15 +442,6 @@ mod tests {
assert_eq!(result.unwrap_err(), ChallengeError::CacheUnavailable);
}
#[tokio::test]
async fn test_code_generation_is_numeric() {
(0..100).for_each(|_| {
let code = generate_code();
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert_eq!(code.len(), CODE_LENGTH);
});
}
#[tokio::test]
async fn test_constant_time_eq() {
assert!(constant_time_eq(b"12345678", b"12345678"));
@@ -425,7 +462,9 @@ mod tests {
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
@@ -440,7 +479,9 @@ mod tests {
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::NotRequired));
}
@@ -455,7 +496,9 @@ mod tests {
allow_legacy_login: false,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Blocked));
}
@@ -470,7 +513,9 @@ mod tests {
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
}
@@ -485,7 +530,9 @@ mod tests {
allow_legacy_login: false,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, None).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, None, reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::ChallengeSent(_)));
}
@@ -502,7 +549,7 @@ mod tests {
let code = create_challenge(&cache, &did).await.unwrap();
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()))
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some(code.as_str()), reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
@@ -532,4 +579,153 @@ mod tests {
let result = validate_challenge(&cache, &did, "12345678").await;
assert_eq!(result.unwrap_err(), ValidationError::CacheUnavailable);
}
async fn reject_totp(_code: &str) -> bool {
false
}
async fn accept_totp(_code: &str) -> bool {
true
}
#[tokio::test]
async fn test_totp_shaped_token_accepted_via_verifier() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp1".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
};
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), accept_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_totp_shaped_token_rejected_does_not_touch_email_challenge() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp2".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: true,
allow_legacy_login: true,
};
// An email challenge exists for this user.
let email_code = create_challenge(&cache, &did).await.unwrap();
// Five wrong TOTP-shaped attempts. If these incremented the email attempt
// counter, the email challenge would be exhausted (MAX_ATTEMPTS = 5).
for _ in 0..5 {
let err = process_legacy_2fa(&cache, &did, &ctx, Some("000000"), reject_totp)
.await
.unwrap_err();
assert!(matches!(
err,
Legacy2faFlowError::Validation(ValidationError::InvalidCode)
));
}
// The email challenge is still valid and consumable.
let outcome =
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_email_shaped_token_routes_to_email_path_when_totp_present() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp3".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: true,
allow_legacy_login: true,
};
let email_code = create_challenge(&cache, &did).await.unwrap();
// reject_totp would fail if this routed to the verifier; it must route to email.
let outcome =
process_legacy_2fa(&cache, &did, &ctx, Some(email_code.as_str()), reject_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_backup_code_shaped_token_routes_to_verifier() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp4".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: false,
has_totp: true,
allow_legacy_login: true,
};
// No email challenge created. If this routed to email it would be
// ChallengeNotFound; Verified proves it went to the verifier.
let outcome = process_legacy_2fa(&cache, &did, &ctx, Some("ABCD2345"), accept_totp)
.await
.unwrap();
assert!(matches!(outcome, Legacy2faOutcome::Verified));
}
#[tokio::test]
async fn test_totp_shaped_token_ignored_when_no_totp() {
let cache = MockCache::new();
let did = Did::new("did:plc:totp5".to_string()).unwrap();
let ctx = Legacy2faContext {
is_app_password: false,
email_2fa_enabled: true,
has_totp: false,
allow_legacy_login: false,
};
// has_totp = false -> 6-digit token routes to email path; no challenge -> NotFound.
let err = process_legacy_2fa(&cache, &did, &ctx, Some("123456"), reject_totp)
.await
.unwrap_err();
assert!(matches!(
err,
Legacy2faFlowError::Validation(ValidationError::ChallengeNotFound)
));
}
#[tokio::test]
async fn test_looks_like_totp_token() {
// 6-digit TOTP codes
assert!(looks_like_totp_token("123456"));
assert!(looks_like_totp_token(" 000000 "));
// backup-code format (8 chars, backup alphabet)
assert!(looks_like_totp_token("ABCD2345"));
// email challenge codes normalize to 10 alphanumeric chars -> not TOTP-shaped
assert!(!looks_like_totp_token("ABCDEFGHIJ"));
assert!(!looks_like_totp_token("ABCDE-FGHIJ"));
// wrong lengths / non-digits
assert!(!looks_like_totp_token("12345"));
assert!(!looks_like_totp_token("1234567"));
assert!(!looks_like_totp_token("12345A"));
assert!(!looks_like_totp_token(""));
}
#[test]
fn test_used_totp_factor() {
// strong MFA factors completed the login -> true
assert!(used_totp_factor(true, Some("123456")));
assert!(used_totp_factor(true, Some("ABCD2345")));
// email-shaped code, or no token, or no TOTP on the account -> false
assert!(!used_totp_factor(true, Some("ABCDEFGHIJ")));
assert!(!used_totp_factor(true, None));
assert!(!used_totp_factor(false, Some("123456")));
assert!(!used_totp_factor(true, Some("")));
}
}
+10 -9
View File
@@ -46,14 +46,15 @@ pub use tranquil_auth::{
ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType,
TokenVerifyError, TokenWithMetadata, TotpError, UnsafeClaims, create_access_token,
create_access_token_hs256, create_access_token_hs256_with_metadata,
create_access_token_with_delegation, create_access_token_with_metadata,
create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256,
create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata,
create_service_token, create_service_token_hs256, generate_backup_codes,
generate_qr_png_base64, generate_totp_secret, generate_totp_uri, get_algorithm_from_token,
get_did_from_token, get_jti_from_token, hash_backup_code, is_backup_code_format,
verify_access_token, verify_access_token_hs256, verify_access_token_typed, verify_backup_code,
verify_refresh_token, verify_refresh_token_hs256, verify_token, verify_totp_code,
create_access_token_with_delegation, create_access_token_with_jti,
create_access_token_with_metadata, create_access_token_with_scope_metadata,
create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata,
create_refresh_token_with_jti, create_refresh_token_with_metadata, create_service_token,
create_service_token_hs256, generate_backup_codes, generate_qr_png_base64,
generate_totp_secret, generate_totp_uri, get_algorithm_from_token, get_did_from_token,
get_jti_from_token, hash_backup_code, is_backup_code_format, verify_access_token,
verify_access_token_hs256, verify_access_token_typed, verify_backup_code, verify_refresh_token,
verify_refresh_token_hs256, verify_token, verify_totp_code,
};
pub fn lxm_permits(lxm: &str, expected: &str) -> bool {
@@ -522,7 +523,7 @@ async fn validate_bearer_token_with_options_internal(
auth_source: AuthSource::OAuth,
});
} else {
return Err(TokenValidationError::TokenExpired);
return Err(TokenValidationError::OAuthTokenExpired);
}
}
+1 -3
View File
@@ -1,6 +1,4 @@
pub use tranquil_cache::{
Cache, CacheError, DistributedRateLimiter, NoOpCache, NoOpRateLimiter, create_cache,
};
pub use tranquil_cache::{Cache, CacheError, DistributedRateLimiter, NoOpCache, create_cache};
#[cfg(feature = "valkey")]
pub use tranquil_cache::{RedisRateLimiter, ValkeyCache};
+2 -2
View File
@@ -5,8 +5,8 @@ pub use roles::{
CanAddControllers, CanControlAccounts, verify_can_add_controllers, verify_can_control_accounts,
};
pub use scopes::{
InvalidDelegationScopeError, SCOPE_PRESETS, ScopePreset, ValidatedDelegationScope,
intersect_scopes,
EDITOR_FULL_SCOPES, InvalidDelegationScopeError, OWNER_FULL_SCOPES, SCOPE_PRESETS, ScopePreset,
ValidatedDelegationScope, intersect_scopes,
};
pub use tranquil_db_traits::DelegationActionType;
+100 -33
View File
@@ -12,12 +12,17 @@ pub struct ScopePreset {
pub scopes: &'static str,
}
pub const OWNER_FULL_SCOPES: &str = "atproto repo:* blob:*/* identity:* account:*?action=manage";
pub const EDITOR_FULL_SCOPES: &str =
"atproto repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
pub const SCOPE_PRESETS: &[ScopePreset] = &[
ScopePreset {
name: "owner",
label: "Owner",
description: "Full control including delegation management",
scopes: "atproto",
scopes: OWNER_FULL_SCOPES,
},
ScopePreset {
name: "admin",
@@ -29,7 +34,7 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
name: "editor",
label: "Editor",
description: "Post content and upload media",
scopes: "repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*",
scopes: EDITOR_FULL_SCOPES,
},
ScopePreset {
name: "viewer",
@@ -40,36 +45,19 @@ pub const SCOPE_PRESETS: &[ScopePreset] = &[
];
pub fn intersect_scopes(requested: &str, granted: &str) -> String {
if granted.is_empty() {
return String::new();
}
let requested_set: HashSet<&str> = requested.split_whitespace().collect();
let granted_set: HashSet<&str> = granted.split_whitespace().collect();
let granted_has_atproto = granted_set.contains("atproto");
let requested_has_atproto = requested_set.contains("atproto");
if granted_has_atproto {
let mut scopes: Vec<&str> = requested_set.into_iter().collect();
scopes.sort();
return scopes.join(" ");
}
if requested_has_atproto {
let mut scopes: Vec<&str> = granted_set.into_iter().collect();
scopes.sort();
return scopes.join(" ");
}
let mut result: Vec<&str> = requested_set
let mut scopes: Vec<&str> = requested_set
.iter()
.filter(|requested_scope| any_granted_covers(requested_scope, &granted_set))
.filter(|requested_scope| {
**requested_scope != "atproto" && any_granted_covers(requested_scope, &granted_set)
})
.copied()
.chain(requested_set.contains("atproto").then_some("atproto"))
.collect();
result.sort();
result.join(" ")
scopes.sort();
scopes.join(" ")
}
fn any_granted_covers(requested: &str, granted: &HashSet<&str>) -> bool {
@@ -159,17 +147,90 @@ mod tests {
}
#[test]
fn test_intersect_granted_atproto() {
let result = intersect_scopes("repo:* blob:*/*", "atproto");
fn test_intersect_owner_grant_covers_requested() {
let result = intersect_scopes("repo:* blob:*/*", OWNER_FULL_SCOPES);
assert!(result.contains("repo:*"));
assert!(result.contains("blob:*/*"));
}
#[test]
fn test_intersect_requested_atproto() {
let result = intersect_scopes("atproto", "repo:* blob:*/*");
assert!(result.contains("repo:*"));
fn test_intersect_bare_atproto_grant_is_auth_only() {
let requested = "atproto repo:*?action=create blob:*/*";
assert_eq!(intersect_scopes(requested, "atproto"), "atproto");
}
#[test]
fn test_intersect_bare_atproto_request_is_auth_only() {
assert_eq!(intersect_scopes("atproto", "repo:* blob:*/*"), "atproto");
}
#[test]
fn test_intersect_downscoped_request_keeps_atproto() {
let approved = "atproto repo:*?action=create blob:*/* account:*?action=manage";
let result = intersect_scopes(approved, OWNER_FULL_SCOPES);
assert!(result.split_whitespace().any(|s| s == "atproto"));
assert!(result.contains("account:*?action=manage"));
assert!(result.contains("repo:*?action=create"));
assert!(result.contains("blob:*/*"));
assert!(!result.contains("identity"));
}
#[test]
fn test_intersect_owner_passes_through_identity() {
let requested = "atproto repo:*?action=create identity:* account:*?action=manage";
let result = intersect_scopes(requested, OWNER_FULL_SCOPES);
assert!(result.contains("identity:*"));
assert!(result.contains("account:*?action=manage"));
}
#[test]
fn test_intersect_admin_excludes_identity() {
let requested = "atproto repo:*?action=create identity:* account:*?action=manage";
let granted = "atproto repo:* blob:*/* account:*?action=manage";
let result = intersect_scopes(requested, granted);
assert!(!result.contains("identity"));
assert!(result.contains("account:*?action=manage"));
}
#[test]
fn test_intersect_admin_excludes_identity_coverage_path() {
let requested = "repo:*?action=create identity:* account:*?action=manage";
let granted = "atproto repo:* blob:*/* account:*?action=manage";
let result = intersect_scopes(requested, granted);
assert!(!result.contains("identity"));
assert!(result.contains("account:*?action=manage"));
assert!(result.contains("repo:*?action=create"));
}
#[test]
fn test_intersect_editor_grant_keeps_atproto() {
let editor = SCOPE_PRESETS
.iter()
.find(|p| p.name == "editor")
.expect("editor preset")
.scopes;
let requested = "atproto repo:*?action=create identity:* account:*?action=manage blob:*/*";
let result = intersect_scopes(requested, editor);
assert!(result.split_whitespace().any(|s| s == "atproto"));
assert!(result.contains("repo:*?action=create"));
assert!(result.contains("blob:*/*"));
assert!(!result.contains("identity"));
assert!(!result.contains("account"));
}
#[test]
fn test_intersect_guarantees_atproto_for_custom_grant() {
let result = intersect_scopes(
"atproto repo:*?action=create blob:*/*",
"repo:*?action=create blob:*/*",
);
assert!(result.split_whitespace().any(|s| s == "atproto"));
assert!(result.contains("blob:*/*"));
}
#[test]
fn test_intersect_no_atproto_request_stays_empty_when_uncovered() {
assert_eq!(intersect_scopes("identity:*", "repo:* blob:*/*"), "");
}
#[test]
@@ -181,8 +242,14 @@ mod tests {
}
#[test]
fn test_intersect_empty_granted() {
assert_eq!(intersect_scopes("atproto", ""), "");
fn test_intersect_viewer_grant_keeps_atproto() {
let requested = "atproto repo:*?action=create blob:*/* identity:*";
assert_eq!(intersect_scopes(requested, ""), "atproto");
}
#[test]
fn test_intersect_empty_grant_without_atproto_request_is_empty() {
assert_eq!(intersect_scopes("repo:*?action=create", ""), "");
}
#[test]
+4 -1
View File
@@ -19,7 +19,10 @@ pub enum HandleResolutionError {
}
pub async fn resolve_handle_dns(handle: &str) -> Result<String, HandleResolutionError> {
let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default());
let resolver = TokioAsyncResolver::tokio_from_system_conf().unwrap_or_else(|e| {
tracing::warn!("falling back to default DNS resolvers: {}", e);
TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default())
});
let query_name = format!("_atproto.{}", handle);
let txt_lookup = resolver
.txt_lookup(&query_name)
+4 -3
View File
@@ -51,6 +51,8 @@ pub const BUILD_VERSION: &str = concat!(
#[cfg(not(debug_assertions))]
pub const BUILD_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GENERAL_BODY_LIMIT: usize = 16 * 1024 * 1024;
pub struct ExternalRoutes {
pub xrpc: Router<AppState>,
pub oauth: Router<AppState>,
@@ -97,9 +99,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
.nest("/.well-known", well_known_router)
.route("/metrics", get(metrics::metrics_handler))
.merge(external.extra)
.layer(DefaultBodyLimit::max(
tranquil_config::get().server.max_blob_size as usize,
))
.layer(DefaultBodyLimit::max(GENERAL_BODY_LIMIT))
.layer(axum::middleware::map_response(rewrite_extractor_errors))
.layer(middleware::from_fn(metrics::metrics_middleware))
.layer(
@@ -111,6 +111,7 @@ pub fn app_with_routes(state: AppState, external: ExternalRoutes) -> Router {
http::header::CONTENT_TYPE,
http::header::CONTENT_ENCODING,
http::header::ACCEPT_ENCODING,
http::header::USER_AGENT,
util::HEADER_DPOP,
util::HEADER_ATPROTO_PROXY,
util::HEADER_ATPROTO_ACCEPT_LABELERS,
+360 -12
View File
@@ -39,31 +39,82 @@ pub enum PlcOpType {
Tombstone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Error)]
#[error("service type must not be empty")]
pub struct EmptyServiceType;
mod custom_service_type {
use super::EmptyServiceType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CustomServiceType(String);
impl CustomServiceType {
pub(super) fn new(name: String) -> Result<Self, EmptyServiceType> {
match name.as_str() {
"" => Err(EmptyServiceType),
_ => Ok(Self(name)),
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
}
pub use custom_service_type::CustomServiceType;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ServiceType {
#[serde(rename = "AtprotoPersonalDataServer")]
Pds,
#[serde(rename = "AtprotoAppView")]
AppView,
#[serde(rename = "AtprotoLabeler")]
Labeler,
Other(CustomServiceType),
}
impl ServiceType {
pub fn as_str(self) -> &'static str {
pub fn as_str(&self) -> &str {
match self {
Self::Pds => "AtprotoPersonalDataServer",
Self::AppView => "AtprotoAppView",
Self::Labeler => "AtprotoLabeler",
Self::Other(name) => name.as_str(),
}
}
}
pub fn is_pds(self) -> bool {
matches!(self, Self::Pds)
impl TryFrom<String> for ServiceType {
type Error = EmptyServiceType;
fn try_from(name: String) -> Result<Self, Self::Error> {
match name.as_str() {
"AtprotoPersonalDataServer" => Ok(Self::Pds),
"AtprotoLabeler" => Ok(Self::Labeler),
_ => CustomServiceType::new(name).map(Self::Other),
}
}
}
impl Serialize for ServiceType {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for ServiceType {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let name = String::deserialize(deserializer)?;
Self::try_from(name).map_err(serde::de::Error::custom)
}
}
pub const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01];
pub const P256_MULTICODEC_PREFIX: [u8; 2] = [0x80, 0x24];
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlcOperation {
@@ -365,6 +416,100 @@ pub fn signing_key_to_did_key(signing_key: &SigningKey) -> String {
format!("did:key:{}", encoded)
}
pub fn rotation_keys_for(
configured_rotation_key: Option<&str>,
signing_key: &SigningKey,
) -> Vec<String> {
let signing_did_key = signing_key_to_did_key(signing_key);
match configured_rotation_key {
Some(key) if key != signing_did_key.as_str() => vec![key.to_string(), signing_did_key],
_ => vec![signing_did_key],
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequiredRotationKey {
Signing,
Operator,
}
impl RequiredRotationKey {
pub fn message(self) -> &'static str {
match self {
Self::Signing => {
"Rotation keys must include the PDS-managed signing key so the server can sign future operations"
}
Self::Operator => {
"Rotation keys must include the operator-held PLC recovery key configured for this server"
}
}
}
}
pub fn missing_required_rotation_key(
rotation_keys: &[&str],
signing_did_key: &str,
configured_rotation_key: Option<&str>,
) -> Option<RequiredRotationKey> {
if !rotation_keys.contains(&signing_did_key) {
return Some(RequiredRotationKey::Signing);
}
match configured_rotation_key {
Some(operator_key) if !rotation_keys.contains(&operator_key) => {
Some(RequiredRotationKey::Operator)
}
_ => None,
}
}
fn validate_compressed_did_key<V, E>(
key_bytes: &[u8],
label: &str,
did_key: &str,
parse: impl Fn(&[u8]) -> Result<V, E>,
) -> Result<(), String>
where
E: std::fmt::Display,
{
if key_bytes.len() != 33 {
return Err(format!(
"`{did_key}` must be a compressed {label} public key"
));
}
parse(key_bytes)
.map(|_| ())
.map_err(|e| format!("`{did_key}` is not a valid {label} public key: {e}"))
}
pub fn validate_rotation_did_key(did_key: &str) -> Result<(), String> {
let multibase_part = did_key
.strip_prefix("did:key:")
.ok_or_else(|| format!("must be a did:key, got `{did_key}`"))?;
let (base, decoded) = multibase::decode(multibase_part)
.map_err(|e| format!("`{did_key}` is not valid multibase: {e}"))?;
if base != multibase::Base::Base58Btc {
return Err(format!("`{did_key}` must use base58btc multibase encoding"));
}
let (prefix, key_bytes) = decoded.split_at(decoded.len().min(2));
if prefix == SECP256K1_MULTICODEC_PREFIX {
validate_compressed_did_key(
key_bytes,
"secp256k1",
did_key,
k256::ecdsa::VerifyingKey::from_sec1_bytes,
)
} else if prefix == P256_MULTICODEC_PREFIX {
validate_compressed_did_key(
key_bytes,
"p256",
did_key,
p256::ecdsa::VerifyingKey::from_sec1_bytes,
)
} else {
Err(format!("`{did_key}` is not a secp256k1 or p256 did:key"))
}
}
pub struct GenesisResult {
pub did: String,
pub signed_operation: Value,
@@ -372,13 +517,14 @@ pub struct GenesisResult {
pub fn create_genesis_operation(
signing_key: &SigningKey,
rotation_key: &str,
configured_rotation_key: Option<&str>,
handle: &str,
pds_endpoint: &str,
) -> Result<GenesisResult, PlcError> {
let signing_did_key = signing_key_to_did_key(signing_key);
let rotation_keys = rotation_keys_for(configured_rotation_key, signing_key);
let mut verification_methods = HashMap::new();
verification_methods.insert("atproto".to_string(), signing_did_key.clone());
verification_methods.insert("atproto".to_string(), signing_did_key);
let mut services = HashMap::new();
services.insert(
"atproto_pds".to_string(),
@@ -389,7 +535,7 @@ pub fn create_genesis_operation(
);
let genesis_op = PlcOperation {
op_type: PlcOpType::Operation,
rotation_keys: vec![rotation_key.to_string()],
rotation_keys,
verification_methods,
also_known_as: vec![format!("at://{}", handle)],
services,
@@ -601,6 +747,132 @@ mod tests {
assert!(did_key.starts_with("did:key:z"));
}
#[test]
fn test_rotation_keys_default_is_signing_key() {
let key = SigningKey::random(&mut rand::thread_rng());
let signing_did_key = signing_key_to_did_key(&key);
assert_eq!(rotation_keys_for(None, &key), vec![signing_did_key]);
}
#[test]
fn test_rotation_keys_prepends_operator_key() {
let key = SigningKey::random(&mut rand::thread_rng());
let signing_did_key = signing_key_to_did_key(&key);
let operator_key = "did:key:zQ3shScallopRecoveryKey";
assert_eq!(
rotation_keys_for(Some(operator_key), &key),
vec![operator_key.to_string(), signing_did_key.clone()]
);
}
#[test]
fn test_rotation_keys_dedupes_when_operator_equals_signing() {
let key = SigningKey::random(&mut rand::thread_rng());
let signing_did_key = signing_key_to_did_key(&key);
assert_eq!(
rotation_keys_for(Some(&signing_did_key), &key),
vec![signing_did_key]
);
}
#[test]
fn test_genesis_includes_signing_key_with_operator_rotation_key() {
let key = SigningKey::random(&mut rand::thread_rng());
let signing_did_key = signing_key_to_did_key(&key);
let operator_key = "did:key:zQ3shWhelkOperatorKey";
let result =
create_genesis_operation(&key, Some(operator_key), "whelk.nel.pet", "https://nel.pet")
.unwrap();
let rotation_keys = result.signed_operation["rotationKeys"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>();
assert_eq!(rotation_keys, vec![operator_key, signing_did_key.as_str()]);
assert!(
verify_operation_signature(
&result.signed_operation,
&[operator_key.to_string(), signing_did_key]
)
.unwrap()
);
}
fn p256_did_key(key: &p256::ecdsa::SigningKey) -> String {
let point = key.verifying_key().to_encoded_point(true);
let mut prefixed = Vec::from(P256_MULTICODEC_PREFIX);
prefixed.extend_from_slice(point.as_bytes());
format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &prefixed)
)
}
#[test]
fn test_validate_rotation_did_key_accepts_secp256k1() {
let key = SigningKey::random(&mut rand::thread_rng());
assert!(validate_rotation_did_key(&signing_key_to_did_key(&key)).is_ok());
}
#[test]
fn test_validate_rotation_did_key_accepts_p256() {
let key = p256::ecdsa::SigningKey::random(&mut rand::thread_rng());
assert!(validate_rotation_did_key(&p256_did_key(&key)).is_ok());
}
#[test]
fn test_validate_rotation_did_key_rejects_non_did_key() {
assert!(validate_rotation_did_key("did:plc:squid").is_err());
assert!(validate_rotation_did_key("zSomeMultibaseButNoPrefix").is_err());
}
#[test]
fn test_validate_rotation_did_key_rejects_unknown_multicodec() {
let mut ed25519 = vec![0xed, 0x01];
ed25519.extend_from_slice(&[0u8; 32]);
let did_key = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base58Btc, &ed25519)
);
assert!(validate_rotation_did_key(&did_key).is_err());
}
#[test]
fn test_validate_rotation_did_key_rejects_non_base58btc() {
let key = SigningKey::random(&mut rand::thread_rng());
let point = key.verifying_key().to_encoded_point(true);
let mut prefixed = Vec::from(SECP256K1_MULTICODEC_PREFIX);
prefixed.extend_from_slice(point.as_bytes());
let hex_did_key = format!(
"did:key:{}",
multibase::encode(multibase::Base::Base16Lower, &prefixed)
);
assert!(validate_rotation_did_key(&hex_did_key).is_err());
}
#[test]
fn test_missing_required_rotation_key() {
let signing = "did:key:zSigning";
let operator = "did:key:zOperator";
assert_eq!(
missing_required_rotation_key(&[operator], signing, None),
Some(RequiredRotationKey::Signing)
);
assert_eq!(
missing_required_rotation_key(&[signing], signing, Some(operator)),
Some(RequiredRotationKey::Operator)
);
assert_eq!(
missing_required_rotation_key(&[operator, signing], signing, Some(operator)),
None
);
assert_eq!(
missing_required_rotation_key(&[signing], signing, None),
None
);
}
#[test]
fn test_cid_for_cbor() {
let value = json!({
@@ -625,4 +897,80 @@ mod tests {
let signed = sign_operation(&op, &key).unwrap();
assert!(signed.get("sig").is_some());
}
#[test]
fn test_service_type_known_round_trip() {
let cases = [
(ServiceType::Pds, "\"AtprotoPersonalDataServer\""),
(ServiceType::Labeler, "\"AtprotoLabeler\""),
];
cases.iter().for_each(|(variant, encoded)| {
assert_eq!(serde_json::to_string(variant).unwrap(), *encoded);
assert_eq!(
serde_json::from_str::<ServiceType>(encoded).unwrap(),
*variant
);
});
}
#[test]
fn test_service_type_custom_round_trips() {
let parsed: ServiceType = serde_json::from_str("\"ConchFeedGenerator\"").unwrap();
assert_eq!(
parsed,
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
);
assert_eq!(
serde_json::to_string(&parsed).unwrap(),
"\"ConchFeedGenerator\""
);
}
#[test]
fn test_service_type_custom_normalizes_known_names() {
assert_eq!(
ServiceType::try_from("AtprotoPersonalDataServer".to_string()).unwrap(),
ServiceType::Pds
);
assert_eq!(
ServiceType::try_from("AtprotoLabeler".to_string()).unwrap(),
ServiceType::Labeler
);
}
#[test]
fn test_appview_is_not_a_named_type() {
assert_eq!(
ServiceType::try_from("AtprotoAppView".to_string()).unwrap(),
ServiceType::Other(CustomServiceType::new("AtprotoAppView".to_string()).unwrap())
);
}
#[test]
fn test_service_type_rejects_empty() {
assert!(ServiceType::try_from(String::new()).is_err());
assert!(serde_json::from_str::<ServiceType>("\"\"").is_err());
}
#[test]
fn test_plc_operation_with_custom_service_round_trips() {
let op_json = json!({
"type": "plc_operation",
"rotationKeys": ["did:key:zScallop"],
"verificationMethods": { "atproto": "did:key:zUni" },
"alsoKnownAs": ["at://whelk.nel.pet"],
"services": {
"atproto_pds": { "type": "AtprotoPersonalDataServer", "endpoint": "https://nel.pet" },
"custom_feedgen": { "type": "ConchFeedGenerator", "endpoint": "https://feed.nel.pet" }
},
"prev": null
});
let op: PlcOperation = serde_json::from_value(op_json.clone()).unwrap();
assert_eq!(
op.services["custom_feedgen"].service_type,
ServiceType::try_from("ConchFeedGenerator".to_string()).unwrap()
);
assert_eq!(op.services["atproto_pds"].service_type, ServiceType::Pds);
assert_eq!(serde_json::to_value(&op).unwrap(), op_json);
}
}
+36 -3
View File
@@ -7,12 +7,13 @@ use cid::Cid;
use jacquard_repo::error::RepoError;
use jacquard_repo::repo::CommitData;
use jacquard_repo::storage::BlockStore;
use tranquil_store::blockstore::TranquilBlockStore;
use tranquil_store::blockstore::{RepairOutcome, TranquilBlockStore};
use tranquil_store::{RealIO, SystemClock};
#[derive(Clone)]
pub enum AnyBlockStore {
Postgres(PostgresBlockStore),
TranquilStore(TranquilBlockStore),
TranquilStore(TranquilBlockStore<RealIO, SystemClock>),
}
impl AnyBlockStore {
@@ -23,7 +24,7 @@ impl AnyBlockStore {
}
}
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore> {
pub fn as_tranquil_store(&self) -> Option<&TranquilBlockStore<RealIO, SystemClock>> {
match self {
Self::TranquilStore(s) => Some(s),
Self::Postgres(_) => None,
@@ -36,6 +37,38 @@ impl AnyBlockStore {
Self::TranquilStore(s) => s.decrement_refs(cids).await,
}
}
pub async fn repair_structure(
&self,
entries: &[(String, Cid)],
expected_root: Cid,
) -> Result<RepairOutcome, RepoError> {
match self {
Self::Postgres(s) => {
let nodes =
tranquil_store::blockstore::rebuild_mst_nodes(entries, expected_root).await?;
let nodes_total = nodes.len();
let cids: Vec<Cid> = nodes.iter().map(|(cid, _)| *cid).collect();
let present = s.get_many(&cids).await?;
let missing: Vec<(Cid, Bytes)> = nodes
.into_iter()
.zip(present)
.filter_map(|((cid, bytes), found)| found.is_none().then_some((cid, bytes)))
.collect();
let nodes_repaired = missing.len() as u64;
if !missing.is_empty() {
s.put_many(missing).await?;
}
Ok(RepairOutcome {
nodes_total,
nodes_repaired,
})
}
Self::TranquilStore(s) => {
tranquil_store::blockstore::rebuild_and_repair_mst(s, entries, expected_root).await
}
}
}
}
impl BlockStore for AnyBlockStore {
+351 -5
View File
@@ -16,7 +16,8 @@ use k256::ecdsa::SigningKey;
use serde_json::{Value, json};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::str::FromStr;
use std::sync::Arc;
use std::sync::{Arc, LazyLock};
use std::time::{Duration, Instant};
use tokio::sync::OwnedMutexGuard;
use tracing::{error, warn};
use uuid::Uuid;
@@ -230,16 +231,247 @@ pub async fn begin_repo_write(
Ok((ctx, mst))
}
pub async fn repair_repo_structure(
state: &AppState,
user_id: Uuid,
) -> Result<tranquil_store::blockstore::RepairOutcome, ApiError> {
let _write_lock = state.repo_write_locks.lock(user_id).await;
let root_cid_str = state
.repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.map_err(|e| {
error!("repair: DB error fetching repo root: {}", e);
ApiError::InternalError(None)
})?
.ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())))?;
let current_root_cid = Cid::from_str(root_cid_str.as_str())
.map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())))?;
let commit_bytes = state
.block_store
.get(&current_root_cid)
.await
.map_err(|e| {
error!("repair: failed to load commit block: {}", e);
ApiError::InternalError(None)
})?
.ok_or_else(|| ApiError::InternalError(Some("Commit block not found".into())))?;
let commit = Commit::from_cbor(&commit_bytes).map_err(|e| {
error!("repair: failed to parse commit: {}", e);
ApiError::InternalError(None)
})?;
let data_root = commit.data;
let repo_rev = commit.rev().to_string();
let records = state
.repos
.repo
.get_all_records(user_id)
.await
.map_err(|e| {
error!("repair: get_all_records failed: {}", e);
ApiError::InternalError(None)
})?;
let entries: Vec<(String, Cid)> = records
.into_iter()
.filter_map(|r| {
Cid::from_str(r.record_cid.as_str())
.ok()
.map(|cid| (format!("{}/{}", r.collection, r.rkey), cid))
})
.collect();
warn!(
user_id = %user_id,
records = entries.len(),
"repair: rebuilding full MST from record set"
);
let outcome = state
.block_store
.repair_structure(&entries, data_root)
.await
.map_err(|e| {
error!("repair: structural repair failed: {}", e);
ApiError::InternalError(Some("Structural repair failed".into()))
})?;
if outcome.nodes_repaired > 0 {
let leaf_present = futures::future::try_join_all(
entries.iter().map(|(_, cid)| state.block_store.has(cid)),
)
.await
.map_err(|e| {
error!("repair: leaf presence check failed: {e}");
ApiError::InternalError(None)
})?;
let missing: Vec<Cid> = entries
.iter()
.zip(leaf_present)
.filter_map(|((_, cid), present)| (!present).then_some(*cid))
.collect();
if !missing.is_empty() {
error!(
user_id = %user_id,
missing = missing.len(),
sample = ?missing.iter().take(5).map(|c| c.to_string()).collect::<Vec<_>>(),
"repair: unrecoverable leaf data loss after structural repair"
);
return Err(ApiError::InternalError(Some(format!(
"unrecoverable leaf data loss: {} record block(s) missing after structural repair",
missing.len()
))));
}
let block_cids =
crate::scheduled::collect_current_repo_blocks(&state.block_store, &current_root_cid)
.await
.map_err(|e| {
error!("repair: re-walk for user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
state
.repos
.repo
.insert_user_blocks(user_id, &block_cids, &repo_rev)
.await
.map_err(|e| {
error!("repair: user_blocks backfill failed: {}", e);
ApiError::InternalError(None)
})?;
warn!(
user_id = %user_id,
blocks = block_cids.len(),
"repair: backfilled user_blocks from repaired DAG"
);
}
Ok(outcome)
}
pub async fn with_repair_retry<T, F, Fut>(
state: &AppState,
user_id: Uuid,
mut attempt: F,
) -> Result<T, ApiError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, ApiError>>,
{
match attempt().await {
Err(e) if e.is_repo_corruption() => {
warn!(
"structural MST damage during repo write for user {user_id}, repairing and retrying"
);
match repair_repo_structure(state, user_id).await {
Ok(outcome) if outcome.nodes_repaired > 0 => attempt().await,
Ok(_) => {
warn!(
user_id = %user_id,
"structural repair rewrote no nodes; damage is not in the MST structure, returning original error without retry"
);
Err(e)
}
Err(repair_err) => {
error!(user_id = %user_id, "structural repair failed: {repair_err:?}");
Err(e)
}
}
}
other => other,
}
}
const REPAIR_COOLDOWN: Duration = Duration::from_secs(60);
const REPAIR_NOOP_COOLDOWN: Duration = Duration::from_secs(600);
struct RepairSlot {
in_flight: bool,
next_allowed: Instant,
}
struct RepairGuard {
slots: parking_lot::Mutex<HashMap<Uuid, RepairSlot>>,
}
impl RepairGuard {
fn try_claim(&self, user_id: Uuid, now: Instant) -> bool {
let mut slots = self.slots.lock();
let slot = slots.entry(user_id).or_insert(RepairSlot {
in_flight: false,
next_allowed: now,
});
if slot.in_flight || now < slot.next_allowed {
return false;
}
slot.in_flight = true;
true
}
fn release(&self, user_id: Uuid, now: Instant, cooldown: Duration) {
if let Some(slot) = self.slots.lock().get_mut(&user_id) {
slot.in_flight = false;
slot.next_allowed = now + cooldown;
}
}
}
static REPAIR_GUARD: LazyLock<RepairGuard> = LazyLock::new(|| RepairGuard {
slots: parking_lot::Mutex::new(HashMap::new()),
});
struct RepairLease {
user_id: Uuid,
cooldown: Duration,
}
impl Drop for RepairLease {
fn drop(&mut self) {
REPAIR_GUARD.release(self.user_id, Instant::now(), self.cooldown);
}
}
pub fn schedule_repo_repair(state: &AppState, user_id: Uuid) {
if !REPAIR_GUARD.try_claim(user_id, Instant::now()) {
return;
}
let state = state.clone();
tokio::spawn(async move {
let mut lease = RepairLease {
user_id,
cooldown: REPAIR_COOLDOWN,
};
match repair_repo_structure(&state, user_id).await {
Ok(outcome) => {
if outcome.nodes_repaired == 0 {
lease.cooldown = REPAIR_NOOP_COOLDOWN;
}
warn!(
user_id = %user_id,
nodes_repaired = outcome.nodes_repaired,
nodes_total = outcome.nodes_total,
"background MST repair complete"
);
}
Err(e) => error!(user_id = %user_id, "background MST repair failed: {e:?}"),
}
});
}
pub async fn finalize_repo_write(
state: &AppState,
ctx: RepoWriteContext,
mst: Mst<TrackingBlockStore>,
params: FinalizeParams<'_>,
) -> Result<CommitResult, ApiError> {
let new_mst_root = mst.persist().await.map_err(|e| {
error!("MST persist failed: {}", e);
ApiError::InternalError(None)
})?;
let new_mst_root = mst
.persist()
.await
.map_err(|e| ApiError::from_mst_error("MST persist", &e))?;
let written_bytes = ctx.tracking_store.take_written_blocks();
let new_tree_cids: Vec<Cid> = written_bytes.keys().copied().collect();
@@ -861,3 +1093,117 @@ pub async fn sequence_genesis_commit(
.await
.map_err(|e| CommitError::DatabaseError(format!("genesis commit event: {}", e)))
}
#[cfg(test)]
mod repair_guard_tests {
use super::*;
fn guard() -> RepairGuard {
RepairGuard {
slots: parking_lot::Mutex::new(HashMap::new()),
}
}
#[test]
fn claim_dedups_in_flight_then_respects_cooldown() {
let g = guard();
let user = Uuid::from_u128(1);
let t0 = Instant::now();
assert!(g.try_claim(user, t0), "first claim must succeed");
assert!(
!g.try_claim(user, t0),
"second claim while a repair is in flight must be rejected"
);
g.release(user, t0, REPAIR_COOLDOWN);
assert!(
!g.try_claim(user, t0 + Duration::from_secs(1)),
"claim within the cooldown window must be rejected"
);
assert!(
g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
"claim after the cooldown window must succeed"
);
}
#[test]
fn distinct_users_do_not_block_each_other() {
let g = guard();
let t0 = Instant::now();
assert!(g.try_claim(Uuid::from_u128(1), t0));
assert!(g.try_claim(Uuid::from_u128(2), t0));
}
#[test]
fn concurrent_claims_for_one_user_admit_exactly_one() {
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Barrier};
let g = Arc::new(guard());
let user = Uuid::from_u128(42);
let now = Instant::now();
let winners = Arc::new(AtomicUsize::new(0));
let gate = Arc::new(Barrier::new(32));
let handles: Vec<_> = (0..32)
.map(|_| {
let g = Arc::clone(&g);
let winners = Arc::clone(&winners);
let gate = Arc::clone(&gate);
std::thread::spawn(move || {
gate.wait();
if g.try_claim(user, now) {
winners.fetch_add(1, Ordering::Relaxed);
}
})
})
.collect();
handles
.into_iter()
.for_each(|h| h.join().expect("worker thread panicked"));
assert_eq!(
winners.load(Ordering::Relaxed),
1,
"exactly one concurrent claim must win the dedup race"
);
}
#[test]
fn release_re_enables_claim_after_cooldown_for_recurring_corruption() {
let g = guard();
let user = Uuid::from_u128(7);
let t0 = Instant::now();
assert!(g.try_claim(user, t0));
g.release(user, t0, REPAIR_COOLDOWN);
let after_cooldown = t0 + REPAIR_COOLDOWN + Duration::from_millis(1);
assert!(
g.try_claim(user, after_cooldown),
"a fresh corruption after the cooldown must be repairable again"
);
assert!(
!g.try_claim(user, after_cooldown),
"the re-claimed repair must again dedup while in flight"
);
}
#[test]
fn noop_repair_uses_longer_cooldown() {
let g = guard();
let user = Uuid::from_u128(9);
let t0 = Instant::now();
assert!(g.try_claim(user, t0));
g.release(user, t0, REPAIR_NOOP_COOLDOWN);
assert!(
!g.try_claim(user, t0 + REPAIR_COOLDOWN + Duration::from_millis(1)),
"after a no-op repair the standard cooldown must not re-admit a claim"
);
assert!(
g.try_claim(user, t0 + REPAIR_NOOP_COOLDOWN + Duration::from_millis(1)),
"after the longer no-op cooldown a fresh claim must be admitted"
);
}
}
+62 -15
View File
@@ -180,6 +180,10 @@ pub async fn collect_current_repo_blocks(
let block = match block_store.get(&cid).await {
Ok(Some(b)) => b,
Ok(None) => continue,
Err(e) if crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}")) => {
warn!(cid = %cid, error = %format!("{e:#}"), "skipping corrupt block during repo walk");
continue;
}
Err(e) => anyhow::bail!("Failed to get block {}: {:?}", cid, e),
};
@@ -574,7 +578,10 @@ impl CompactionBlocklist {
}
fn run_compaction_pass(
store: &tranquil_store::blockstore::TranquilBlockStore,
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
liveness_threshold: f64,
grace_period_ms: u64,
blocklist: &parking_lot::Mutex<CompactionBlocklist>,
@@ -727,10 +734,53 @@ async fn delete_account_data(
const CAR_BLOCK_BATCH_SIZE: usize = 500;
#[derive(Debug)]
pub enum RepoCarError {
MissingBlocks(Vec<Cid>),
Source(anyhow::Error),
}
impl RepoCarError {
pub fn is_repairable(&self) -> bool {
match self {
Self::MissingBlocks(_) => true,
Self::Source(e) => {
crate::api::error::ApiError::detail_is_repo_corruption(&format!("{e:#}"))
}
}
}
}
impl std::fmt::Display for RepoCarError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingBlocks(cids) => write!(
f,
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
cids.len(),
cids.iter()
.take(5)
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ")
),
Self::Source(e) => write!(f, "{e:#}"),
}
}
}
impl std::error::Error for RepoCarError {}
impl From<anyhow::Error> for RepoCarError {
fn from(e: anyhow::Error) -> Self {
Self::Source(e)
}
}
pub async fn generate_repo_car(
block_store: &AnyBlockStore,
head_cid: &Cid,
) -> anyhow::Result<Vec<u8>> {
) -> Result<Vec<u8>, RepoCarError> {
let block_cids_bytes = collect_current_repo_blocks(block_store, head_cid).await?;
let block_cids: Vec<Cid> = block_cids_bytes
.iter()
@@ -757,16 +807,7 @@ pub async fn generate_repo_car(
.filter_map(|(cid, block_opt)| block_opt.is_none().then_some(*cid))
.collect();
if !missing.is_empty() {
anyhow::bail!(
"repo CAR is incomplete: {} block(s) referenced by the MST are missing from storage. First 5: {}",
missing.len(),
missing
.iter()
.take(5)
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(", ")
);
return Err(RepoCarError::MissingBlocks(missing));
}
chunk
@@ -800,7 +841,7 @@ pub async fn generate_repo_car_from_user_blocks(
block_store: &AnyBlockStore,
user_id: uuid::Uuid,
_head_cid: &Cid,
) -> anyhow::Result<Vec<u8>> {
) -> Result<Vec<u8>, RepoCarError> {
use std::str::FromStr;
let repo_root_cid_str: String = repo_repo
@@ -836,7 +877,10 @@ fn cid_to_bytes(cid: &Cid) -> anyhow::Result<CidBytes> {
}
fn walk_repo_dag_sync(
store: &tranquil_store::blockstore::TranquilBlockStore,
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
head_cid: &Cid,
reachable: &mut std::collections::HashSet<CidBytes>,
phantom_files: &mut std::collections::HashSet<tranquil_store::blockstore::DataFileId>,
@@ -960,7 +1004,10 @@ fn paginate_repos(
}
pub fn run_reachability_walk(
store: &tranquil_store::blockstore::TranquilBlockStore,
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
repo_repo: &dyn RepoRepository,
) -> anyhow::Result<ReachabilityResult> {
let rt = tokio::runtime::Handle::current();
+49 -2
View File
@@ -359,7 +359,9 @@ impl AppState {
let rate_limiters = Arc::new(RateLimiters::new());
let repo_write_locks = Arc::new(RepoWriteLocks::new());
let circuit_breakers = Arc::new(CircuitBreakers::new());
let (cache, distributed_rate_limiter) = create_cache(shutdown.clone()).await;
let (cache, distributed_rate_limiter) = create_cache(shutdown.clone())
.await
.expect("Failed to initialize cache and distributed rate limiter at startup");
let did_resolver = Arc::new(DidResolver::new());
let cross_pds_oauth = Arc::new(CrossPdsOAuthClient::new(cache.clone()));
let sso_config = SsoConfig::init();
@@ -471,12 +473,55 @@ impl AppState {
}
struct TranquilStoreWiring {
blockstore: tranquil_store::blockstore::TranquilBlockStore,
blockstore: tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
signal_provider: Arc<dyn tranquil_signal::SignalStoreProvider>,
repos: PostgresRepositories,
segments_dir: PathBuf,
}
fn migrate_delegation_preset_scopes(metastore: &tranquil_store::metastore::Metastore) {
const MARKER_KEY: &str = "migration:delegation_preset_scopes_v1";
const LEGACY_EDITOR_SCOPES: &str =
"repo:*?action=create repo:*?action=update repo:*?action=delete blob:*/*";
let infra = metastore.infra_ops();
if infra.get_server_config(MARKER_KEY).ok().flatten().is_some() {
return;
}
let ops = metastore.delegation_ops();
let owners = match ops.remap_grant_scopes("atproto", crate::delegation::OWNER_FULL_SCOPES) {
Ok(n) => n,
Err(e) => {
tracing::error!(error = ?e, "delegation owner-scope migration failed, will retry on next start");
return;
}
};
let editors = match ops
.remap_grant_scopes(LEGACY_EDITOR_SCOPES, crate::delegation::EDITOR_FULL_SCOPES)
{
Ok(n) => n,
Err(e) => {
tracing::error!(error = ?e, "delegation editor-scope migration failed, will retry on next start");
return;
}
};
if owners + editors > 0 {
tracing::info!(
owners,
editors,
"upgraded legacy delegation grants to preset scopes"
);
}
if let Err(e) = infra.upsert_server_config(MARKER_KEY, "done") {
tracing::error!(error = ?e, "failed to record delegation scope migration marker, will retry");
}
}
fn wire_tranquil_store(
store_cfg: &tranquil_config::TranquilStoreConfig,
shutdown: CancellationToken,
@@ -614,6 +659,8 @@ fn wire_tranquil_store(
}
}
migrate_delegation_preset_scopes(&metastore);
let notifier = bridge.notifier();
let signal_db = metastore.database().clone();
let signal_ks = metastore.signal_keyspace();
+13 -15
View File
@@ -92,30 +92,20 @@ impl CarVerifier {
pub fn verify_car_structure_only(
&self,
expected_did: &Did,
root_cid: &Cid,
blocks: &HashMap<Cid, Bytes>,
) -> Result<VerifiedCar, VerifyError> {
) -> Result<StructureVerifiedCar, VerifyError> {
let root_block = blocks
.get(root_cid)
.ok_or_else(|| VerifyError::BlockNotFound(root_cid.to_string()))?;
let commit =
Commit::from_cbor(root_block).map_err(|e| VerifyError::InvalidCommit(e.to_string()))?;
let commit_did = commit.did().as_str();
if commit_did != expected_did.as_str() {
return Err(VerifyError::DidMismatch {
commit_did: commit_did.to_string(),
expected_did: expected_did.to_string(),
});
}
let commit_did = commit.did().to_string().into();
let data_cid = commit.data();
self.verify_mst_structure(data_cid, blocks)?;
debug!(
"MST structure verified for DID {} (signature verification skipped for migration)",
expected_did
);
Ok(VerifiedCar {
did: expected_did.clone(),
debug!("MST structure verified for commit: {:?}", commit);
Ok(StructureVerifiedCar {
did: commit_did,
rev: commit.rev().to_string(),
data_cid: *data_cid,
prev: commit.prev().cloned(),
@@ -289,6 +279,14 @@ impl CarVerifier {
}
}
#[derive(Debug, Clone)]
pub struct StructureVerifiedCar {
pub did: Did,
pub rev: String,
pub data_cid: Cid,
pub prev: Option<Cid>,
}
#[derive(Debug, Clone)]
pub struct VerifiedCar {
pub did: Did,
+49 -2
View File
@@ -33,7 +33,20 @@ pub fn generate_token_code() -> String {
.map(|_| chars[rng.gen_range(0..chars.len())])
.collect()
};
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng))
// Human-entered short codes are displayed in uppercase; base32 digits are
// unaffected by the conversion.
format!("{}-{}", gen_segment(&mut rng), gen_segment(&mut rng)).to_uppercase()
}
/// Normalize a user-entered short code so that codes are accepted
/// case-insensitively and regardless of the separating hyphen or surrounding
/// whitespace.
pub fn normalize_token_code(input: &str) -> String {
input
.chars()
.filter(|c| !c.is_whitespace() && *c != '-')
.collect::<String>()
.to_uppercase()
}
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
@@ -491,10 +504,44 @@ mod tests {
assert!(
code.chars()
.filter(|&c| c != '-')
.all(|c| BASE32_ALPHABET.contains(c))
.all(|c| BASE32_ALPHABET.to_uppercase().contains(c))
);
}
#[test]
fn test_generate_token_code_is_uppercase() {
(0..100).for_each(|_| {
let code = generate_token_code();
assert_eq!(code, code.to_uppercase(), "code must be uppercase: {code}");
});
}
#[test]
fn test_normalize_token_code_strips_hyphen_and_uppercases() {
assert_eq!(normalize_token_code("k7m2p-q9rst"), "K7M2PQ9RST");
assert_eq!(normalize_token_code("K7M2P-Q9RST"), "K7M2PQ9RST");
}
#[test]
fn test_normalize_token_code_strips_whitespace() {
assert_eq!(normalize_token_code(" k7m2p-q9rst \n"), "K7M2PQ9RST");
}
#[test]
fn test_normalize_token_code_is_idempotent() {
let once = normalize_token_code("k7m2p-q9rst");
assert_eq!(normalize_token_code(&once), once);
}
#[test]
fn test_generated_code_round_trips_through_normalize() {
let code = generate_token_code();
// A user re-typing the displayed code lowercased and without the hyphen
// must normalize to the same canonical form as the code itself.
let retyped = code.to_lowercase().replace('-', "");
assert_eq!(normalize_token_code(&code), normalize_token_code(&retyped));
}
#[test]
fn test_json_to_ipld_cid_link() {
let json = serde_json::json!({
@@ -0,0 +1,98 @@
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_pds::scheduled::{RepoCarError, generate_repo_car};
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const RECORD_COUNT: usize = 200;
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_tree(any: &AnyBlockStore) -> Cid {
let mut mst = Mst::new(Arc::new(any.clone()));
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let cid = any
.put(format!("record body {i}").as_bytes())
.await
.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
}
mst.persist().await.expect("persist mst")
}
fn shred_data_files(data_dir: &std::path::Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
#[tokio::test]
async fn car_export_error_is_classified_as_repo_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let root = build_tree(&any).await;
generate_repo_car(&any, &root)
.await
.expect("pristine CAR must generate");
shred_data_files(&dir.path().join("data"));
let err = generate_repo_car(&any, &root)
.await
.expect_err("corrupt CAR export must error");
assert!(
err.is_repairable(),
"{err} should classify as repairable so the sync path can self-heal"
);
}
#[tokio::test]
async fn car_export_missing_block_is_repairable() {
let dir = tempfile::tempdir().expect("tempdir");
let source = open_store(dir.path());
let root = build_tree(&source).await;
let pristine = open_store(&dir.path().join("pristine"));
let head_block = source
.get(&root)
.await
.expect("read root")
.expect("root present");
pristine.put(&head_block).await.expect("seed root only");
let err = generate_repo_car(&pristine, &root)
.await
.expect_err("CAR export over a store missing MST children must error");
assert!(
matches!(err, RepoCarError::MissingBlocks(ref cids) if !cids.is_empty()),
"{err} should surface as MissingBlocks when referenced blocks are absent"
);
assert!(err.is_repairable());
}
+20 -2
View File
@@ -30,6 +30,7 @@ static TEST_TEMP_DIR: OnceLock<PathBuf> = OnceLock::new();
static CLUSTER: OnceLock<Vec<ServerInstance>> = OnceLock::new();
static TEST_REPOS: OnceLock<Arc<tranquil_db::PostgresRepositories>> = OnceLock::new();
static TEST_BLOCK_STORE: OnceLock<tranquil_pds::repo::AnyBlockStore> = OnceLock::new();
static TEST_APP_STATE: OnceLock<AppState> = OnceLock::new();
#[allow(dead_code)]
pub fn is_store_backend() -> bool {
@@ -130,6 +131,13 @@ pub fn pds_endpoint() -> String {
format!("https://{}", pds_hostname())
}
#[allow(dead_code)]
pub fn store_data_dir() -> Option<PathBuf> {
std::env::var("TRANQUIL_STORE_DATA_DIR")
.ok()
.map(PathBuf::from)
}
pub async fn base_url() -> &'static str {
SERVER_URL.get_or_init(|| {
let (tx, rx) = std::sync::mpsc::channel();
@@ -579,6 +587,7 @@ async fn spawn_server(config: ServerConfig) -> ServerInstance {
if let Some((cache, distributed_rate_limiter)) = config.cache {
state = state.with_cache(cache, distributed_rate_limiter);
}
TEST_APP_STATE.set(state.clone()).ok();
tranquil_sync::listener::start_sequencer_listener(state.clone()).await;
let app = tranquil_pds::app_with_routes(
state,
@@ -683,6 +692,8 @@ pub async fn spawn_cluster(pool: Option<sqlx::PgPool>, node_count: usize) -> Vec
machine_id: i as u64 + 1,
gossip_interval_ms: 100,
cache_max_bytes: 64 * 1024 * 1024,
cluster_key: None,
allow_insecure: false,
};
let (cache, rate_limiter, addr) = RippleEngine::start(config, shutdown.clone())
.await
@@ -921,6 +932,14 @@ pub async fn get_test_block_store() -> &'static tranquil_pds::repo::AnyBlockStor
.expect("TEST_BLOCK_STORE not initialized")
}
#[allow(dead_code)]
pub async fn get_test_app_state() -> &'static AppState {
base_url().await;
TEST_APP_STATE
.get()
.expect("TEST_APP_STATE not initialized")
}
#[allow(dead_code)]
pub async fn flushed_max_seq(
repos: &tranquil_db::PostgresRepositories,
@@ -950,8 +969,7 @@ pub async fn sequenced_event_for_did(
.await
.expect("get_events_since_seq")
.into_iter()
.filter(|event| &event.did == did)
.last()
.rfind(|event| &event.did == did)
.unwrap_or_else(|| panic!("event for did {did} not found after flush"))
}
+185
View File
@@ -0,0 +1,185 @@
mod common;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use common::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
fn decode_jwt_claims(jwt: &str) -> Value {
let payload = jwt
.split('.')
.nth(1)
.expect("malformed jwt: no claims segment");
let bytes = URL_SAFE_NO_PAD
.decode(payload)
.expect("malformed jwt: claims not base64url");
serde_json::from_slice(&bytes).expect("malformed jwt: claims not json")
}
struct CaptureAuth(Arc<Mutex<Option<String>>>);
impl Respond for CaptureAuth {
fn respond(&self, req: &Request) -> ResponseTemplate {
let auth = req
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.map(str::to_string);
*self.0.lock().unwrap() = auth;
ResponseTemplate::new(200).set_body_json(json!({ "feed": [] }))
}
}
/// getFeed's service-auth token must be audienced to the feed generator, not the AppView.
#[tokio::test]
async fn get_feed_service_auth_is_audienced_to_feed_generator() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
// One mock server doubles as the AppView: did:web doc, getRecord, and getFeed.
let appview = MockServer::start().await;
let appview_uri = appview.uri();
let host = appview_uri
.strip_prefix("http://")
.expect("mock uri should be http");
// Literal-colon host so did:web resolves over http to the local mock.
let appview_did = format!("did:web:{host}");
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": appview_did,
"service": [{
"id": "#bsky_appview",
"type": "BskyAppView",
"serviceEndpoint": appview_uri,
}]
})))
.mount(&appview)
.await;
let feed_did = "did:web:feedgen.example.com";
let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed";
// The feed generator record resolves to its service DID.
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"uri": feed_uri,
"value": { "$type": "app.bsky.feed.generator", "did": feed_did },
})))
.mount(&appview)
.await;
// Capture the Authorization header the AppView is handed for getFeed.
let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getFeed"))
.respond_with(CaptureAuth(captured.clone()))
.mount(&appview)
.await;
// Send the params a real client sends, so feed extraction must ignore extras.
let res = client
.get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await))
.query(&[("feed", feed_uri), ("limit", "30"), ("cursor", "abc123")])
.header("authorization", format!("Bearer {}", token))
.header("atproto-proxy", format!("{}#bsky_appview", appview_did))
.send()
.await
.expect("getFeed proxy request failed");
assert_eq!(
res.status(),
StatusCode::OK,
"getFeed proxy should succeed: {:?}",
res.text().await
);
let auth = captured
.lock()
.unwrap()
.clone()
.expect("AppView received no Authorization header");
let jwt = auth
.strip_prefix("Bearer ")
.expect("forwarded auth should be a bearer token");
let claims = decode_jwt_claims(jwt);
assert_eq!(
claims["aud"].as_str(),
Some(feed_did),
"service-auth token must be audienced to the feed generator, got {:?}",
claims["aud"]
);
assert_eq!(
claims["lxm"].as_str(),
Some("app.bsky.feed.getFeedSkeleton"),
"service-auth token lxm must be getFeedSkeleton, got {:?}",
claims["lxm"]
);
}
/// An unresolvable feed generator must be refused, not forwarded with an AppView aud.
#[tokio::test]
async fn get_feed_refuses_when_feed_generator_unresolvable() {
let client = client();
let (token, _did) = create_account_and_login(&client).await;
let appview = MockServer::start().await;
let appview_uri = appview.uri();
let host = appview_uri
.strip_prefix("http://")
.expect("mock uri should be http");
let appview_did = format!("did:web:{host}");
Mock::given(method("GET"))
.and(path("/.well-known/did.json"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"id": appview_did,
"service": [{
"id": "#bsky_appview",
"type": "BskyAppView",
"serviceEndpoint": appview_uri,
}]
})))
.mount(&appview)
.await;
// getRecord fails, so the feed generator DID can't be resolved.
Mock::given(method("GET"))
.and(path("/xrpc/com.atproto.repo.getRecord"))
.respond_with(ResponseTemplate::new(404).set_body_json(json!({
"error": "RecordNotFound",
})))
.mount(&appview)
.await;
// getFeed must never be reached with an AppView-audienced token.
Mock::given(method("GET"))
.and(path("/xrpc/app.bsky.feed.getFeed"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "feed": [] })))
.expect(0)
.mount(&appview)
.await;
let feed_uri = "at://did:plc:feedcreator00000000000000/app.bsky.feed.generator/myfeed";
let res = client
.get(format!("{}/xrpc/app.bsky.feed.getFeed", base_url().await))
.query(&[("feed", feed_uri)])
.header("authorization", format!("Bearer {token}"))
.header("atproto-proxy", format!("{appview_did}#bsky_appview"))
.send()
.await
.expect("getFeed proxy request failed");
assert_eq!(
res.status(),
StatusCode::BAD_REQUEST,
"unresolvable feed should be rejected, got {}",
res.status()
);
}
@@ -5,7 +5,12 @@ use common::*;
use reqwest::StatusCode;
use serde_json::{Value, json};
fn run_compaction(store: &tranquil_store::blockstore::TranquilBlockStore) {
fn run_compaction(
store: &tranquil_store::blockstore::TranquilBlockStore<
tranquil_store::RealIO,
tranquil_store::SystemClock,
>,
) {
let liveness = store.compaction_liveness(0).unwrap();
liveness
.iter()
@@ -73,7 +73,7 @@ fn write_varint(buf: &mut Vec<u8>, mut value: u64) {
}
#[tokio::test]
async fn test_import_rejects_car_for_different_user() {
async fn test_import_doesnt_reject_car_for_different_user() {
let client = client();
let (token_a, _did_a) = create_account_and_login(&client).await;
let (_token_b, did_b) = create_account_and_login(&client).await;
@@ -99,15 +99,9 @@ async fn test_import_rejects_car_for_different_user() {
.send()
.await
.expect("Import failed");
assert_eq!(import_res.status(), StatusCode::FORBIDDEN);
assert_eq!(import_res.status(), StatusCode::OK);
let body: serde_json::Value = import_res.json().await.unwrap();
assert!(
body["error"] == "InvalidRepo"
|| body["error"] == "InvalidRequest"
|| body["error"] == "DidMismatch",
"Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}",
body
);
assert!(body.is_object() && body.as_object().unwrap().is_empty());
}
#[tokio::test]
@@ -0,0 +1,97 @@
mod common;
use common::*;
use reqwest::{Client, StatusCode};
use serde_json::{Value, json};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::invite::{InviteRegistration, check_registration_invite};
async fn create_invite_code(client: &Client, admin_jwt: &str, use_count: u32) -> String {
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createInviteCode",
base_url().await
))
.bearer_auth(admin_jwt)
.json(&json!({ "useCount": use_count }))
.send()
.await
.expect("failed to create invite code");
assert_eq!(res.status(), StatusCode::OK);
let body: Value = res.json().await.expect("invite code response not json");
body["code"].as_str().expect("missing code").to_string()
}
#[tokio::test]
async fn check_registration_invite_validates_without_consuming() {
let state = get_test_app_state().await;
let client = client();
let (admin_jwt, _did) = create_admin_account_and_login(&client).await;
let code = create_invite_code(&client, &admin_jwt, 1).await;
assert_eq!(
check_registration_invite(state, Some(&code)).await.unwrap(),
InviteRegistration::Standard(Some(code.clone()))
);
assert_eq!(
state
.repos
.infra
.get_invite_code_available_uses(&code)
.await
.unwrap(),
Some(1),
"validation must not consume the invite"
);
assert_eq!(
check_registration_invite(state, Some(&format!(" {code} ")))
.await
.unwrap(),
InviteRegistration::Standard(Some(code.clone())),
"surrounding whitespace must be trimmed into the validated code"
);
assert!(matches!(
check_registration_invite(state, Some("whelk")).await,
Err(ApiError::InvalidInviteCode)
));
}
#[tokio::test]
async fn create_account_consumes_invite_code_exactly_once() {
let client = client();
let (admin_jwt, _did) = create_admin_account_and_login(&client).await;
let code = create_invite_code(&client, &admin_jwt, 2).await;
let handle = format!("u{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.createAccount",
base_url().await
))
.json(&json!({
"handle": handle,
"email": format!("{handle}@nel.pet"),
"password": "Testpass123!",
"inviteCode": code,
}))
.send()
.await
.expect("createAccount request failed");
let status = res.status();
let text = res.text().await.unwrap_or_default();
assert_eq!(status, StatusCode::OK, "createAccount failed: {text}");
let state = get_test_app_state().await;
assert_eq!(
state
.repos
.infra
.get_invite_code_available_uses(&code)
.await
.unwrap(),
Some(1),
"a single registration must consume exactly one invite use"
);
}
+69 -2
View File
@@ -673,8 +673,12 @@ async fn test_deactivated_account_behavior() {
assert_eq!(post_body["error"], "AccountDeactivated");
}
// End-to-end regression for the legacy refresh grace window and the
// verify-before-mutate fixes: a signed replay within the 2h grace window converges
// on the winner's current tokens, while a forged replay is rejected without
// destroying the session.
#[tokio::test]
async fn test_refresh_token_replay_protection() {
async fn test_refresh_token_replay_grace_and_forgery() {
let url = base_url().await;
let http_client = client();
let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
@@ -728,6 +732,7 @@ async fn test_refresh_token_replay_protection() {
let confirmed: Value = confirm.json().await.unwrap();
let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string();
// 1. Rotate refresh_jwt once; capture the winner's tokens.
let first = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", refresh_jwt))
@@ -735,12 +740,74 @@ async fn test_refresh_token_replay_protection() {
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let first_body: Value = first.json().await.unwrap();
let winner_refresh = first_body["refreshJwt"].as_str().unwrap().to_string();
let winner_access = first_body["accessJwt"].as_str().unwrap().to_string();
// 2. Signed replay of the original (now-rotated) token within the grace
// window: 200, returning the session's current tokens. The re-minted JWTs are
// not byte-identical to the winner's because the pinned claims still carry a
// fresh `iat` (see create_signed_token_pinned), so assert they VERIFY and WORK
// instead: the returned refreshJwt drives a subsequent refresh.
let replay = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", refresh_jwt))
.send()
.await
.unwrap();
assert_eq!(replay.status(), StatusCode::UNAUTHORIZED);
assert_eq!(replay.status(), StatusCode::OK);
let replay_body: Value = replay.json().await.unwrap();
let replayed_refresh = replay_body["refreshJwt"].as_str().unwrap().to_string();
let replayed_access = replay_body["accessJwt"].as_str().unwrap().to_string();
// The replayed access token authenticates.
let who = http_client
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
.header("Authorization", format!("Bearer {}", replayed_access))
.send()
.await
.unwrap();
assert_eq!(who.status(), StatusCode::OK);
// The replayed refresh token rotates the session forward (200), proving the
// grace replay handed back the live session's current credentials.
let from_replay = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", replayed_refresh))
.send()
.await
.unwrap();
assert_eq!(from_replay.status(), StatusCode::OK);
let from_replay_body: Value = from_replay.json().await.unwrap();
let current_refresh = from_replay_body["refreshJwt"].as_str().unwrap().to_string();
assert!(!winner_refresh.is_empty() && !winner_access.is_empty());
// 3. Forged replay: tamper the original token's signature segment with
// same-length valid base64url garbage. Must be rejected (401) WITHOUT
// destroying the session.
let parts: Vec<&str> = refresh_jwt.split('.').collect();
assert_eq!(parts.len(), 3);
let garbage_sig: String = parts[2]
.chars()
.map(|c| if c == 'A' { 'B' } else { 'A' })
.collect();
assert_eq!(garbage_sig.len(), parts[2].len());
let forged = format!("{}.{}.{}", parts[0], parts[1], garbage_sig);
let forged_res = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", forged))
.send()
.await
.unwrap();
assert_eq!(forged_res.status(), StatusCode::UNAUTHORIZED);
// The session survived the forgery: the current refresh token still works.
let after_forge = http_client
.post(format!("{}/xrpc/com.atproto.server.refreshSession", url))
.header("Authorization", format!("Bearer {}", current_refresh))
.send()
.await
.unwrap();
assert_eq!(after_forge.status(), StatusCode::OK);
}
+121 -12
View File
@@ -15,6 +15,63 @@ async fn enable_totp_for_user(did: &str) {
.unwrap();
}
const KNOWN_TOTP_SECRET: [u8; 20] = [0u8; 20];
async fn enable_totp_encrypted(did: &str) {
let encrypted = tranquil_pds::auth::encrypt_totp_secret(&KNOWN_TOTP_SECRET)
.expect("encrypt totp secret");
let repos = get_test_repos().await;
repos
.user
.enable_totp_verified(&Did::new(did.to_string()).unwrap(), &encrypted)
.await
.unwrap();
}
fn current_totp_code() -> String {
use totp_rs::{Algorithm, TOTP};
let totp = TOTP::new(
Algorithm::SHA1,
6,
1,
30,
KNOWN_TOTP_SECRET.to_vec(),
None,
String::new(),
)
.expect("valid totp params");
totp.generate_current().expect("generate totp code")
}
async fn obtain_legacy_session(client: &reqwest::Client, handle: &str, did: &str) -> String {
let base = base_url().await;
let resp = client
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
.json(&json!({ "identifier": handle, "password": "Testpass123!" }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let code = get_2fa_code_from_queue(did)
.await
.expect("2FA code should be in queue");
let resp = client
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
.json(&json!({ "identifier": handle, "password": "Testpass123!", "authFactorToken": code }))
.send()
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: Value = resp.json().await.unwrap();
body["accessJwt"]
.as_str()
.expect("accessJwt in legacy session")
.to_string()
}
async fn set_allow_legacy_login(did: &str, allow: bool) {
let repos = get_test_repos().await;
repos
@@ -40,19 +97,17 @@ async fn get_2fa_code_from_queue(did: &str) -> Option<String> {
.await
.ok()?;
const ALPHABET: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
comms.first().and_then(|c| {
c.body
.lines()
.find(|line: &&str| line.chars().all(|c: char| c.is_ascii_digit()) && line.len() == 8)
.map(|s: &str| s.to_string())
.or_else(|| {
c.body
.split_whitespace()
.find(|word: &&str| {
word.chars().all(|c: char| c.is_ascii_digit()) && word.len() == 8
})
.map(|s: &str| s.to_string())
})
c.body.split_whitespace().find_map(|word: &str| {
let candidate = word.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-');
let normalized = candidate.replace('-', "");
if normalized.len() == 10 && normalized.chars().all(|ch| ALPHABET.contains(ch)) {
Some(candidate.to_string())
} else {
None
}
})
})
}
@@ -421,6 +476,60 @@ async fn test_email_auth_factor_requires_code() {
assert_eq!(body["emailAuthFactor"], true);
}
#[tokio::test]
async fn test_disable_totp_allowed_with_legacy_session() {
let client = client();
let base = base_url().await;
let (_token, did) = create_account_and_login(&client).await;
enable_totp_encrypted(&did).await;
set_allow_legacy_login(&did, true).await;
clear_2fa_challenges_for_user(&did).await;
let handle = get_handle(&did).await;
let legacy_token = obtain_legacy_session(&client, &handle, &did).await;
let bad = client
.post(format!("{}/xrpc/com.atproto.server.disableTotp", base))
.bearer_auth(&legacy_token)
.json(&json!({ "password": "nope", "code": "123456" }))
.send()
.await
.unwrap();
let bad_body: Value = bad.json().await.unwrap();
assert_ne!(
bad_body["error"], "MfaVerificationRequired",
"legacy session must not be blocked by the MFA gate before credential verification: {:?}",
bad_body
);
assert_eq!(bad_body["error"], "InvalidPassword");
let ok = client
.post(format!("{}/xrpc/com.atproto.server.disableTotp", base))
.bearer_auth(&legacy_token)
.json(&json!({ "password": "Testpass123!", "code": current_totp_code() }))
.send()
.await
.unwrap();
let ok_status = ok.status();
let ok_body: Value = ok.json().await.unwrap();
assert_eq!(
ok_status,
StatusCode::OK,
"disabling TOTP from a legacy session with valid password and code should succeed: {:?}",
ok_body
);
let status = client
.get(format!("{}/xrpc/com.atproto.server.getTotpStatus", base))
.bearer_auth(&legacy_token)
.send()
.await
.unwrap();
let status_body: Value = status.json().await.unwrap();
assert_eq!(status_body["enabled"], false);
}
#[tokio::test]
async fn test_email_auth_factor_disabled_no_challenge() {
let client = client();
@@ -773,3 +773,166 @@ async fn test_list_records_comprehensive() {
.expect("Failed with nonexistent repo");
assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_missing_type_is_filled_from_collection() {
let client = client();
let (did, jwt) = setup_new_user("missing-type").await;
let now = Utc::now().to_rfc3339();
let create_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "text": "no type set", "createdAt": now }
}))
.send()
.await
.expect("Failed to create record without $type");
assert_eq!(
create_res.status(),
StatusCode::OK,
"createRecord should fill missing $type from collection"
);
let create_body: Value = create_res.json().await.unwrap();
let create_rkey = create_body["uri"]
.as_str()
.unwrap()
.rsplit('/')
.next()
.unwrap()
.to_string();
let get_created = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", &create_rkey),
])
.send()
.await
.expect("Failed to get created record");
let created_body: Value = get_created.json().await.unwrap();
assert_eq!(created_body["value"]["$type"], "app.bsky.feed.post");
let put_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.putRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.actor.profile",
"rkey": "self",
"record": { "displayName": "No Type" }
}))
.send()
.await
.expect("Failed to put record without $type");
assert_eq!(
put_res.status(),
StatusCode::OK,
"putRecord should fill missing $type from collection"
);
let get_put = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.actor.profile"),
("rkey", "self"),
])
.send()
.await
.expect("Failed to get put record");
let put_body: Value = get_put.json().await.unwrap();
assert_eq!(put_body["value"]["$type"], "app.bsky.actor.profile");
let apply_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"writes": [
{ "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-no-type", "value": { "text": "batch no type", "createdAt": now } }
]
}))
.send()
.await
.expect("Failed to apply writes without $type");
assert_eq!(
apply_res.status(),
StatusCode::OK,
"applyWrites should fill missing $type from collection"
);
let get_batch = client
.get(format!(
"{}/xrpc/com.atproto.repo.getRecord",
base_url().await
))
.query(&[
("repo", did.as_str()),
("collection", "app.bsky.feed.post"),
("rkey", "batch-no-type"),
])
.send()
.await
.expect("Failed to get batch record");
let batch_body: Value = get_batch.json().await.unwrap();
assert_eq!(batch_body["value"]["$type"], "app.bsky.feed.post");
let mismatch_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "$type": "app.bsky.feed.like", "text": "wrong type", "createdAt": now }
}))
.send()
.await
.expect("Failed to send mismatch request");
assert_eq!(
mismatch_res.status(),
StatusCode::BAD_REQUEST,
"explicit mismatched $type should still be rejected"
);
let non_string_type_res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({
"repo": did,
"collection": "app.bsky.feed.post",
"record": { "$type": 123, "text": "non-string type", "createdAt": now }
}))
.send()
.await
.expect("Failed to send non-string type request");
assert_eq!(
non_string_type_res.status(),
StatusCode::BAD_REQUEST,
"present non-string $type should be rejected, not overwritten"
);
}
+20 -4
View File
@@ -158,6 +158,9 @@ async fn test_session_lifecycle_refresh_invalidates_old() {
let refresh_body: Value = refresh_res.json().await.unwrap();
let new_refresh_jwt = refresh_body["refreshJwt"].as_str().unwrap();
assert_ne!(refresh_jwt, new_refresh_jwt, "Refresh tokens should differ");
// A signed reuse within the grace window is benign: it replays the session's
// current tokens rather than revoking. The deep assertions live in
// jwt_security.rs::test_refresh_token_replay_grace_and_forgery.
let reuse_res = client
.post(format!(
"{}/xrpc/com.atproto.server.refreshSession",
@@ -167,11 +170,24 @@ async fn test_session_lifecycle_refresh_invalidates_old() {
.send()
.await
.expect("Failed reuse attempt");
assert!(
reuse_res.status() == StatusCode::UNAUTHORIZED
|| reuse_res.status() == StatusCode::BAD_REQUEST,
"Old refresh token should be invalid after use"
assert_eq!(
reuse_res.status(),
StatusCode::OK,
"Signed reuse within grace replays the session"
);
let reuse_body: Value = reuse_res.json().await.unwrap();
let replayed_refresh = reuse_body["refreshJwt"].as_str().unwrap();
// The replayed refresh token works for a subsequent refresh.
let followup = client
.post(format!(
"{}/xrpc/com.atproto.server.refreshSession",
base_url().await
))
.bearer_auth(replayed_refresh)
.send()
.await
.expect("Failed followup refresh");
assert_eq!(followup.status(), StatusCode::OK);
}
#[tokio::test]
@@ -0,0 +1,185 @@
use std::path::Path;
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_store::blockstore::{
BLOCK_HEADER_SIZE, BlockStoreConfig, CID_SIZE, GroupCommitConfig, TranquilBlockStore,
};
const RECORD_COUNT: usize = 300;
fn open_store(dir: &Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
let mut mst = Mst::new(Arc::new(any.clone()));
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let body = format!("record body number {i}").into_bytes();
let cid = any.put(&body).await.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
entries.push((key, cid));
}
let data_root = mst.persist().await.expect("persist mst");
(data_root, entries)
}
fn shred_data_files(data_dir: &Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
fn corrupt_block_with_cid(data_dir: &Path, target: &[u8]) -> bool {
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut pos = BLOCK_HEADER_SIZE;
while pos + CID_SIZE + 4 <= bytes.len() {
let cid = bytes[pos..pos + CID_SIZE].to_vec();
let len = u32::from_le_bytes(
bytes[pos + CID_SIZE..pos + CID_SIZE + 4]
.try_into()
.unwrap(),
) as usize;
let data_start = pos + CID_SIZE + 4;
let rec_end = data_start + len + 4;
if rec_end > bytes.len() {
break;
}
if cid.as_slice() == target && len > 0 {
bytes[data_start] ^= 0xFF;
std::fs::write(&path, &bytes).expect("write corrupted data file");
return true;
}
pos = rec_end;
}
}
false
}
#[tokio::test]
async fn corrupt_mst_node_classifies_as_repo_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (root, entries) = build_repo(&any).await;
shred_data_files(&dir.path().join("data"));
let mst = Mst::load(Arc::new(any.clone()), root, None);
let mut classified_corruption = false;
for (key, _) in &entries {
if let Err(e) = mst.get(key).await {
assert!(
ApiError::from_mst_error("audit", &e).is_repo_corruption(),
"corrupt MST node must classify as RepoCorruption via from_mst_error; raw error: {e}"
);
assert!(
ApiError::detail_is_repo_corruption(&e.to_string()),
"to_string of corrupt-node error must carry the marker; raw error: {e}"
);
classified_corruption = true;
break;
}
}
assert!(
classified_corruption,
"shredded tree must produce at least one corrupt-node read error"
);
}
#[tokio::test]
async fn missing_mst_node_classifies_as_repo_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (root, entries) = build_repo(&any).await;
let empty_dir = tempfile::tempdir().expect("empty tempdir");
let empty = open_store(empty_dir.path());
let mst = Mst::load(Arc::new(empty.clone()), root, None);
let err = mst
.get(&entries[0].0)
.await
.expect_err("loading a root absent from the store must error");
assert!(
ApiError::from_mst_error("audit", &err).is_repo_corruption(),
"a missing MST node must classify as repairable so self-heal triggers; raw error: {err}"
);
assert!(
ApiError::detail_is_repo_corruption(&format!("{err:#}")),
"missing-node error must carry a repairable marker; raw error: {err}"
);
}
#[tokio::test]
async fn leaf_block_corruption_is_not_repaired_by_structural_repair() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (root, entries) = build_repo(&any).await;
let (_, rec_cid) = &entries[0];
assert!(
any.get(rec_cid).await.expect("read leaf").is_some(),
"leaf must be readable before corruption"
);
let target = rec_cid.to_bytes();
assert!(
corrupt_block_with_cid(&dir.path().join("data"), &target),
"must locate the record leaf block to corrupt"
);
let read_err = any
.get(rec_cid)
.await
.expect_err("corrupt leaf must fail to read");
assert!(
ApiError::detail_is_repo_corruption(&read_err.to_string()),
"corrupt leaf read error must carry the marker; raw error: {read_err}"
);
let outcome = any
.repair_structure(&entries, root)
.await
.expect("structural repair must succeed");
assert_eq!(
outcome.nodes_repaired, 0,
"structural repair only touches MST nodes, so a leaf-only corruption yields zero repairs"
);
assert!(
any.get(rec_cid).await.is_err(),
"leaf corruption is NOT healed by structural repair"
);
}
@@ -0,0 +1,152 @@
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const RECORD_COUNT: usize = 300;
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
let mut mst = Mst::new(Arc::new(any.clone()));
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let body = format!("record body number {i}").into_bytes();
let cid = any.put(&body).await.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
entries.push((key, cid));
}
let data_root = mst.persist().await.expect("persist mst");
(data_root, entries)
}
fn shred_data_files(data_dir: &std::path::Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
async fn walk_all(
any: &AnyBlockStore,
root: Cid,
entries: &[(String, Cid)],
) -> Result<usize, String> {
let mst = Mst::load(Arc::new(any.clone()), root, None);
let mut resolved = 0usize;
for (key, expected) in entries {
match mst.get(key).await {
Ok(Some(cid)) if cid == *expected => resolved += 1,
Ok(Some(cid)) => {
return Err(format!("{key}: resolved to {cid} != expected {expected}"));
}
Ok(None) => return Err(format!("{key}: missing")),
Err(e) => return Err(format!("{key}: read error {e}")),
}
}
Ok(resolved)
}
fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) {
std::fs::create_dir_all(dst).expect("create dst dir");
for entry in std::fs::read_dir(src).expect("read src dir") {
let entry = entry.expect("dir entry");
let from = entry.path();
let to = dst.join(entry.file_name());
if from.is_dir() {
copy_dir_recursive(&from, &to);
} else {
std::fs::copy(&from, &to).expect("copy file");
}
}
}
#[tokio::test]
async fn pristine_repo_survives_copy_and_reopen() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (data_root, entries) = build_repo(&any).await;
assert_eq!(
walk_all(&any, data_root, &entries).await.expect("pristine"),
RECORD_COUNT
);
let snap = tempfile::tempdir().expect("snap tempdir");
copy_dir_recursive(dir.path(), snap.path());
let reopened = open_store(snap.path());
assert_eq!(
walk_all(&reopened, data_root, &entries)
.await
.expect("pristine repo must survive copy+reopen"),
RECORD_COUNT
);
}
#[tokio::test]
async fn repair_survives_crash_and_reopen() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (data_root, entries) = build_repo(&any).await;
assert_eq!(
walk_all(&any, data_root, &entries).await.expect("pristine"),
RECORD_COUNT
);
shred_data_files(&dir.path().join("data"));
assert!(
walk_all(&any, data_root, &entries).await.is_err(),
"corruption must break the walk"
);
let outcome = any
.repair_structure(&entries, data_root)
.await
.expect("repair_structure");
assert!(outcome.nodes_repaired > 0, "repair must rewrite a node");
assert_eq!(
walk_all(&any, data_root, &entries)
.await
.expect("in-process walk after repair"),
RECORD_COUNT
);
let snap = tempfile::tempdir().expect("snap tempdir");
copy_dir_recursive(dir.path(), snap.path());
let reopened = open_store(snap.path());
assert_eq!(
walk_all(&reopened, data_root, &entries)
.await
.expect("every key must resolve after crash-recovery of a repair"),
RECORD_COUNT,
"repair did not survive reopen"
);
}
@@ -0,0 +1,151 @@
mod common;
use cid::Cid;
use common::{base_url, client, create_account_and_login, store_data_dir};
use reqwest::StatusCode;
use serde_json::{Value, json};
use std::str::FromStr;
use tranquil_store::blockstore::{BLOCK_HEADER_SIZE, CID_SIZE};
#[ctor::ctor]
fn force_store_backend() {
unsafe {
std::env::set_var("TRANQUIL_TEST_BACKEND", "store");
}
}
const COLLECTION: &str = "app.bsky.feed.post";
fn post_record(i: usize) -> Value {
json!({
"$type": COLLECTION,
"text": format!("self-heal record {i}"),
"createdAt": "2024-01-01T00:00:00.000Z"
})
}
async fn apply_creates(token: &str, did: &str, start: usize, count: usize) {
let writes: Vec<Value> = (start..start + count)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": COLLECTION,
"rkey": format!("selfheal{i:05}"),
"value": post_record(i)
})
})
.collect();
let res = client()
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(token)
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
.send()
.await
.expect("applyWrites send");
assert_eq!(
res.status(),
StatusCode::OK,
"applyWrites failed: {:?}",
res.text().await
);
}
async fn latest_commit_cid(did: &str) -> Cid {
let res = client()
.get(format!(
"{}/xrpc/com.atproto.sync.getLatestCommit?did={did}",
base_url().await
))
.send()
.await
.expect("getLatestCommit send");
assert_eq!(res.status(), StatusCode::OK, "getLatestCommit failed");
let body: Value = res.json().await.expect("getLatestCommit json");
Cid::from_str(body["cid"].as_str().expect("commit cid")).expect("parse commit cid")
}
fn collect_tqb(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
collect_tqb(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("tqb") {
out.push(path);
}
}
}
fn corrupt_every_block_except(data_dir: &std::path::Path, keep: &[u8]) -> usize {
let mut corrupted = 0usize;
let mut files = Vec::new();
collect_tqb(data_dir, &mut files);
for path in files {
let mut bytes = std::fs::read(&path).expect("read tqb");
let mut pos = BLOCK_HEADER_SIZE;
while pos + CID_SIZE + 4 <= bytes.len() {
let cid = &bytes[pos..pos + CID_SIZE];
let len = u32::from_le_bytes(
bytes[pos + CID_SIZE..pos + CID_SIZE + 4]
.try_into()
.unwrap(),
) as usize;
let data_start = pos + CID_SIZE + 4;
let rec_end = data_start + len + 4;
if rec_end > bytes.len() {
break;
}
if cid != keep && len > 0 {
bytes[data_start] ^= 0xFF;
corrupted += 1;
}
pos = rec_end;
}
std::fs::write(&path, &bytes).expect("write corrupted tqb");
}
corrupted
}
#[tokio::test]
async fn write_self_heals_after_mst_node_corruption() {
let client = client();
let (token, did) = create_account_and_login(&client).await;
apply_creates(&token, &did, 0, 150).await;
apply_creates(&token, &did, 150, 150).await;
let commit_cid = latest_commit_cid(&did).await;
let commit_bytes = commit_cid.to_bytes();
let data_dir = store_data_dir().expect("store backend data dir");
let corrupted = corrupt_every_block_except(&data_dir, &commit_bytes);
assert!(corrupted > 0, "expected to corrupt committed blocks");
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.createRecord",
base_url().await
))
.bearer_auth(&token)
.json(&json!({
"repo": did,
"collection": COLLECTION,
"validate": false,
"record": post_record(9999)
}))
.send()
.await
.expect("createRecord send");
assert_eq!(
res.status(),
StatusCode::OK,
"write should self-heal corrupted MST and succeed: {:?}",
res.text().await
);
}
@@ -0,0 +1,108 @@
use std::sync::Arc;
use cid::Cid;
use jacquard_repo::mst::Mst;
use jacquard_repo::storage::BlockStore;
use tranquil_pds::repo::AnyBlockStore;
use tranquil_store::blockstore::{BlockStoreConfig, GroupCommitConfig, TranquilBlockStore};
const RECORD_COUNT: usize = 300;
fn open_store(dir: &std::path::Path) -> AnyBlockStore {
let cfg = BlockStoreConfig {
data_dir: dir.join("data"),
index_dir: dir.join("index"),
max_file_size: 64 * 1024,
group_commit: GroupCommitConfig::default(),
shard_count: 1,
};
AnyBlockStore::TranquilStore(TranquilBlockStore::open(cfg).expect("open block store"))
}
async fn build_repo(any: &AnyBlockStore) -> (Cid, Vec<(String, Cid)>) {
let mut mst = Mst::new(Arc::new(any.clone()));
let mut entries: Vec<(String, Cid)> = Vec::with_capacity(RECORD_COUNT);
for i in 0..RECORD_COUNT {
let key = format!("app.bsky.feed.post/{i:0>6}");
let body = format!("record body number {i}").into_bytes();
let cid = any.put(&body).await.expect("put record");
mst.add_mut(&key, cid).await.expect("mst add");
entries.push((key, cid));
}
let data_root = mst.persist().await.expect("persist mst");
(data_root, entries)
}
fn shred_data_files(data_dir: &std::path::Path) {
let mut shredded = false;
for entry in std::fs::read_dir(data_dir).expect("read data dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tqb") {
continue;
}
let mut bytes = std::fs::read(&path).expect("read data file");
let mut off = 5usize;
while off + 48 < bytes.len() {
bytes[off..off + 48].iter_mut().for_each(|b| *b = 0xFF);
off += 192;
shredded = true;
}
std::fs::write(&path, &bytes).expect("write corrupted data file");
}
assert!(shredded, "no .tqb data file was corrupted");
}
async fn walk_all(
any: &AnyBlockStore,
root: Cid,
entries: &[(String, Cid)],
) -> Result<usize, String> {
let mst = Mst::load(Arc::new(any.clone()), root, None);
let mut resolved = 0usize;
for (key, expected) in entries {
match mst.get(key).await {
Ok(Some(cid)) if cid == *expected => resolved += 1,
Ok(Some(cid)) => {
return Err(format!("{key}: resolved to {cid} != expected {expected}"));
}
Ok(None) => return Err(format!("{key}: missing")),
Err(e) => return Err(format!("{key}: read error {e}")),
}
}
Ok(resolved)
}
#[tokio::test]
async fn repair_restores_mst_after_node_corruption() {
let dir = tempfile::tempdir().expect("tempdir");
let any = open_store(dir.path());
let (data_root, entries) = build_repo(&any).await;
let resolved = walk_all(&any, data_root, &entries)
.await
.expect("pristine tree must resolve every key");
assert_eq!(resolved, RECORD_COUNT);
shred_data_files(&dir.path().join("data"));
let broken = walk_all(&any, data_root, &entries).await;
assert!(
broken.is_err(),
"corruption must break the MST walk, got {broken:?}"
);
let outcome = any
.repair_structure(&entries, data_root)
.await
.expect("repair_structure");
assert!(
outcome.nodes_repaired > 0,
"repair must rewrite at least one node, got {outcome:?}"
);
let resolved = walk_all(&any, data_root, &entries)
.await
.expect("every key must resolve after repair");
assert_eq!(resolved, RECORD_COUNT);
}
+9 -3
View File
@@ -45,9 +45,12 @@ async fn test_request_password_reset_creates_code() {
.expect("user not found");
assert!(info.code.is_some());
assert!(info.expires_at.is_some());
// The stored code is normalized: uppercase base32, 10 chars, no hyphen.
// The hyphenated display form only appears in the email.
let code = info.code.unwrap();
assert!(code.contains('-'));
assert_eq!(code.len(), 11);
assert!(!code.contains('-'));
assert_eq!(code.len(), 10);
assert_eq!(code, code.to_uppercase());
}
#[tokio::test]
@@ -109,7 +112,10 @@ async fn test_reset_password_with_valid_token() {
.await
.expect("failed to look up user")
.expect("user not found");
let token = info.code.expect("No reset code");
let stored = info.code.expect("No reset code");
// Submit a variant a user might actually type: lowercased, with the display
// hyphen re-inserted. Normalization must still accept it.
let token = format!("{}-{}", &stored[0..5], &stored[5..10]).to_lowercase();
let res = client
.post(format!(
"{}/xrpc/com.atproto.server.resetPassword",
+13 -3
View File
@@ -188,12 +188,22 @@ async fn test_plc_token_lifecycle() {
"PLC token should be created in database"
);
let first = &tokens[0];
// The token is persisted in canonical (normalized) form: uppercase base32,
// 10 chars, no hyphen. The hyphenated display form only appears in the email.
assert_eq!(
first.token.len(),
11,
"Token should be in format xxxxx-xxxxx"
10,
"Stored token should be the 10-char canonical form"
);
assert!(
!first.token.contains('-'),
"Stored token should not contain a hyphen"
);
assert_eq!(
first.token,
first.token.to_uppercase(),
"Stored token should be uppercase"
);
assert!(first.token.contains('-'), "Token should contain hyphen");
assert!(
first.expires_at > chrono::Utc::now(),
"Token should not be expired"
@@ -0,0 +1,154 @@
mod common;
mod helpers;
use cid::Cid;
use common::*;
use helpers::*;
use jacquard_repo::commit::Commit;
use jacquard_repo::storage::BlockStore;
use serde_json::json;
use std::str::FromStr;
use tranquil_types::Did;
#[tokio::test]
async fn repair_fails_loud_on_missing_leaf_block() {
let client = client();
let repos = get_test_repos().await;
let block_store = get_test_block_store().await;
let state = get_test_app_state().await;
let Some(pg) = block_store.as_postgres() else {
eprintln!(
"repair_fails_loud_on_missing_leaf_block: requires postgres backend, skipping under store backend"
);
return;
};
let pool = pg.pool();
let (did, jwt) = setup_new_user("repair-leaf-loss").await;
let writes: Vec<serde_json::Value> = (0..6)
.map(|i| {
json!({
"$type": "com.atproto.repo.applyWrites#create",
"collection": "app.bsky.feed.post",
"rkey": format!("leafloss{i:05}"),
"value": {
"$type": "app.bsky.feed.post",
"text": format!("repair leaf loss {i}"),
"createdAt": "2026-01-01T00:00:00.000Z"
}
})
})
.collect();
let res = client
.post(format!(
"{}/xrpc/com.atproto.repo.applyWrites",
base_url().await
))
.bearer_auth(&jwt)
.json(&json!({ "repo": did, "validate": false, "writes": writes }))
.send()
.await
.expect("applyWrites send");
assert_eq!(
res.status(),
reqwest::StatusCode::OK,
"applyWrites failed: {:?}",
res.text().await
);
let user_id = repos
.user
.get_id_by_did(&Did::new(did.clone()).unwrap())
.await
.expect("DB error")
.expect("user not found");
let root_str = repos
.repo
.get_repo_root_cid_by_user_id(user_id)
.await
.expect("DB error")
.expect("repo root not found");
let commit_cid = Cid::from_str(&root_str).expect("parse commit cid");
let commit_bytes = block_store
.get(&commit_cid)
.await
.expect("read commit")
.expect("commit present");
let mst_root_cid = Commit::from_cbor(&commit_bytes).expect("parse commit").data;
let records = repos
.repo
.get_all_records(user_id)
.await
.expect("get_all_records");
assert!(!records.is_empty(), "repo must contain records");
let leaf_cid = Cid::from_str(records[0].record_cid.as_str()).expect("parse leaf cid");
assert!(
block_store
.get(&leaf_cid)
.await
.expect("read leaf")
.is_some(),
"leaf must be present before corruption"
);
repos
.repo
.delete_user_blocks(user_id, &[leaf_cid.to_bytes()])
.await
.expect("clear leaf user_blocks row");
sqlx::query("DELETE FROM blocks WHERE cid = $1")
.bind(mst_root_cid.to_bytes())
.execute(pool)
.await
.expect("delete mst root node block");
sqlx::query("DELETE FROM blocks WHERE cid = $1")
.bind(leaf_cid.to_bytes())
.execute(pool)
.await
.expect("delete leaf record block");
assert!(
block_store
.get(&mst_root_cid)
.await
.expect("read")
.is_none(),
"mst root node must be gone to force a structural repair"
);
assert!(
block_store.get(&leaf_cid).await.expect("read").is_none(),
"leaf block must be gone to simulate data loss"
);
let err = tranquil_pds::repo_ops::repair_repo_structure(state, user_id)
.await
.expect_err("repair must fail loud when a leaf block is unrecoverable");
let detail = format!("{err:?}");
assert!(
detail.contains("leaf data loss"),
"expected an unrecoverable-leaf-loss error, got: {detail}"
);
assert!(
block_store
.get(&mst_root_cid)
.await
.expect("read")
.is_some(),
"structural repair must still re-insert the regenerable MST node"
);
let recorded = repos
.repo
.get_user_block_cids_since_rev(user_id, "")
.await
.expect("read user_blocks");
assert!(
!recorded.contains(&leaf_cid.to_bytes()),
"missing leaf must not be phantom-inserted into user_blocks"
);
}
@@ -225,8 +225,8 @@ fn test_delegation_validate_multiple() {
}
#[test]
fn test_delegation_intersect_empty_granted_returns_empty() {
assert_eq!(intersect_scopes("atproto", ""), "");
fn test_delegation_intersect_empty_grant_keeps_only_atproto() {
assert_eq!(intersect_scopes("atproto", ""), "atproto");
assert_eq!(intersect_scopes("repo:*", ""), "");
}
+6 -2
View File
@@ -11,17 +11,21 @@ tranquil-infra = { workspace = true }
async-trait = { workspace = true }
backon = { workspace = true }
bincode = { workspace = true }
bytes = { workspace = true }
foca = { workspace = true }
hkdf = { workspace = true }
metrics = { workspace = true }
parking_lot = { workspace = true }
quinn = { workspace = true }
rand = "0.9"
rcgen = { workspace = true }
rustls = { workspace = true }
serde = { workspace = true }
socket2 = "0.6.2"
sha2 = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "io-util", "sync", "time"] }
tokio-util = { workspace = true }
tracing = { workspace = true }
x509-parser = { workspace = true }
[dev-dependencies]
futures = { workspace = true }
+90 -1
View File
@@ -1,4 +1,4 @@
use std::net::SocketAddr;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
pub(crate) fn fnv1a(data: &[u8]) -> u64 {
data.iter().fold(0xcbf29ce484222325u64, |hash, &byte| {
@@ -13,6 +13,8 @@ pub struct RippleConfig {
pub machine_id: u64,
pub gossip_interval_ms: u64,
pub cache_max_bytes: usize,
pub cluster_key: Option<String>,
pub allow_insecure: bool,
}
impl RippleConfig {
@@ -53,18 +55,105 @@ impl RippleConfig {
.saturating_mul(1024)
.saturating_mul(1024);
let cluster_key = ripple
.cluster_key
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let bind_addr = effective_bind_addr(
bind_addr,
cluster_key.is_some(),
ripple.allow_insecure,
!seed_peers.is_empty(),
);
Ok(Self {
bind_addr,
seed_peers,
machine_id,
gossip_interval_ms,
cache_max_bytes,
cluster_key,
allow_insecure: ripple.allow_insecure,
})
}
}
fn effective_bind_addr(
bind_addr: SocketAddr,
has_cluster_key: bool,
allow_insecure: bool,
has_peers: bool,
) -> SocketAddr {
let standalone_default = !has_cluster_key
&& !allow_insecure
&& !has_peers
&& bind_addr.ip().is_unspecified()
&& bind_addr.port() == 0;
match standalone_default {
false => bind_addr,
true => {
let loopback: IpAddr = match bind_addr.ip() {
IpAddr::V4(_) => Ipv4Addr::LOCALHOST.into(),
IpAddr::V6(_) => Ipv6Addr::LOCALHOST.into(),
};
tracing::info!(
"ripple has no cluster key and no peers, binding loopback as a single node"
);
SocketAddr::new(loopback, 0)
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum RippleConfigError {
#[error("invalid address: {0}")]
InvalidAddr(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn addr(s: &str) -> SocketAddr {
s.parse().unwrap()
}
#[test]
fn standalone_default_bind_rewrites_to_loopback() {
assert_eq!(
effective_bind_addr(addr("0.0.0.0:0"), false, false, false),
addr("127.0.0.1:0")
);
assert_eq!(
effective_bind_addr(addr("[::]:0"), false, false, false),
addr("[::1]:0")
);
}
#[test]
fn explicit_or_clustered_binds_are_untouched() {
assert_eq!(
effective_bind_addr(addr("0.0.0.0:7000"), false, false, false),
addr("0.0.0.0:7000")
);
assert_eq!(
effective_bind_addr(addr("0.0.0.0:0"), true, false, false),
addr("0.0.0.0:0")
);
assert_eq!(
effective_bind_addr(addr("0.0.0.0:0"), false, true, false),
addr("0.0.0.0:0")
);
assert_eq!(
effective_bind_addr(addr("0.0.0.0:0"), false, false, true),
addr("0.0.0.0:0")
);
assert_eq!(
effective_bind_addr(addr("192.0.2.7:0"), false, false, false),
addr("192.0.2.7:0")
);
}
}
+14 -2
View File
@@ -5,7 +5,7 @@ use crate::eviction::MemoryBudget;
use crate::gossip::{GossipEngine, PeerId};
use crate::metrics;
use crate::rate_limiter::RippleRateLimiter;
use crate::transport::Transport;
use crate::transport::{ClusterKey, Transport};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
@@ -21,8 +21,20 @@ impl RippleEngine {
{
let store = Arc::new(ShardedCrdtStore::new(config.machine_id));
let cluster_key = match config.cluster_key.as_deref() {
Some(secret) => Some(ClusterKey::new(secret)),
None => {
if !config.bind_addr.ip().is_loopback() && !config.allow_insecure {
return Err(RippleStartError::Config(format!(
"ripple is bound to non-loopback {} without RIPPLE_CLUSTER_KEY. Set the cluster key to authenticate peers, or set RIPPLE_ALLOW_INSECURE=true to bind unauthenticated on a trusted network",
config.bind_addr
)));
}
None
}
};
let (transport, incoming_rx) =
Transport::bind(config.bind_addr, config.machine_id, shutdown.clone())
Transport::bind(config.bind_addr, cluster_key, shutdown.clone())
.await
.map_err(|e| RippleStartError::Bind(e.to_string()))?;
+26 -1
View File
@@ -4,6 +4,7 @@ use crate::crdt::lww_map::LwwDelta;
use crate::metrics;
use crate::transport::{ChannelTag, IncomingFrame, Transport};
use foca::{Config, Foca, Notification, Runtime, Timer};
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::StdRng;
use std::collections::HashSet;
@@ -187,6 +188,7 @@ impl GossipEngine {
let (timer_tx, mut timer_rx) = mpsc::channel::<(Timer<PeerId>, Duration)>(256);
const WATERMARK_STALE_SECS: u64 = 30;
const ANTI_ENTROPY_SECS: u64 = 30;
tokio::spawn(async move {
let mut runtime = BufferedRuntime::new();
@@ -216,6 +218,11 @@ impl GossipEngine {
let mut gossip_tick = tokio::time::interval(Duration::from_millis(gossip_interval_ms));
gossip_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut anti_entropy_tick =
tokio::time::interval(Duration::from_secs(ANTI_ENTROPY_SECS));
anti_entropy_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut ae_rng = StdRng::from_os_rng();
loop {
tokio::select! {
_ = shutdown.cancelled() => {
@@ -265,7 +272,6 @@ impl GossipEngine {
}
}
}
_ => {}
}
}
_ = gossip_tick.tick() => {
@@ -341,6 +347,25 @@ impl GossipEngine {
"gossip health check"
);
}
_ = anti_entropy_tick.tick() => {
let peers: Vec<SocketAddr> = members.active_peers().collect();
if !peers.is_empty() {
let snapshot = store.peek_full_state();
if !snapshot.is_empty() {
let peer = peers[ae_rng.random_range(0..peers.len())];
chunk_and_serialize(&snapshot).into_iter().for_each(|chunk| {
let t = transport.clone();
let c = shutdown.clone();
tokio::spawn(async move {
tokio::select! {
_ = c.cancelled() => {}
_ = t.send(peer, ChannelTag::CrdtSync, &chunk) => {}
}
});
});
}
}
}
}
}
})
+1 -1
View File
@@ -8,5 +8,5 @@ pub mod metrics;
pub mod rate_limiter;
pub mod transport;
pub use config::RippleConfig;
pub use config::{RippleConfig, RippleConfigError};
pub use engine::{RippleEngine, RippleStartError};
+16
View File
@@ -50,6 +50,14 @@ pub fn describe_metrics() {
"tranquil_ripple_gossip_delta_bytes",
"Size of CRDT delta chunks in bytes"
);
metrics::describe_counter!(
"tranquil_ripple_transport_write_failures_total",
"Total outbound frame writes that failed or timed out"
);
metrics::describe_counter!(
"tranquil_ripple_transport_inbound_dropped_total",
"Total inbound frames dropped because the buffer budget was saturated"
);
}
pub fn record_cache_hit() {
@@ -103,3 +111,11 @@ pub fn record_gossip_drop() {
pub fn record_gossip_delta_bytes(bytes: usize) {
histogram!("tranquil_ripple_gossip_delta_bytes").record(bytes as f64);
}
pub fn record_transport_write_failure() {
counter!("tranquil_ripple_transport_write_failures_total").increment(1);
}
pub fn record_transport_inbound_dropped() {
counter!("tranquil_ripple_transport_inbound_dropped_total").increment(1);
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More