| … | … | @@ -1,9 +1,9 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.9.83", | |
| 3 | "version": "0.9.84", | |
| 4 | 4 | "runtime": "bare", |
| 5 | "rootTreeHash": "f82982ad11e6c8dd975397eea77a9275e482d586f00d73698f22743e490357a2", | |
| 6 | "publishedAt": "2026-09-09T01:28:01.706Z", | |
| 5 | "rootTreeHash": "56aa2c4ac316ad8079e304e67d3c11883811150e7112b6fd21cd06d8a9168787", | |
| 6 | "publishedAt": "2026-09-09T01:31:20.229Z", | |
| 7 | 7 | "deps": [ |
| 8 | 8 | { |
| 9 | 9 | "name": "@hyperswarm/secret-stream", |
| … | … | @@ -3929,7 +3929,7 @@ |
| 3929 | 3929 | } |
| 3930 | 3930 | ], |
| 3931 | 3931 | "reason": "version-splits only", |
| 3932 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 3932 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 3933 | 3933 | }, |
| 3934 | 3934 | { |
| 3935 | 3935 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3944,7 +3944,7 @@ |
| 3944 | 3944 | } |
| 3945 | 3945 | ], |
| 3946 | 3946 | "reason": "version-splits only", |
| 3947 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 3947 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 3948 | 3948 | }, |
| 3949 | 3949 | { |
| 3950 | 3950 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3969,7 +3969,7 @@ |
| 3969 | 3969 | } |
| 3970 | 3970 | ], |
| 3971 | 3971 | "reason": "version-splits only", |
| 3972 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 3972 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 3973 | 3973 | }, |
| 3974 | 3974 | { |
| 3975 | 3975 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3984,7 +3984,7 @@ |
| 3984 | 3984 | } |
| 3985 | 3985 | ], |
| 3986 | 3986 | "reason": "version-splits only", |
| 3987 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 3987 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 3988 | 3988 | }, |
| 3989 | 3989 | { |
| 3990 | 3990 | "type": "VERSION_CONFLICT", |
| … | … | @@ -3999,7 +3999,7 @@ |
| 3999 | 3999 | } |
| 4000 | 4000 | ], |
| 4001 | 4001 | "reason": "version-splits only", |
| 4002 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 4002 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 4003 | 4003 | }, |
| 4004 | 4004 | { |
| 4005 | 4005 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4014,7 +4014,7 @@ |
| 4014 | 4014 | } |
| 4015 | 4015 | ], |
| 4016 | 4016 | "reason": "version-splits only", |
| 4017 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 4017 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 4018 | 4018 | }, |
| 4019 | 4019 | { |
| 4020 | 4020 | "type": "VERSION_CONFLICT", |
| … | … | @@ -4034,7 +4034,7 @@ |
| 4034 | 4034 | } |
| 4035 | 4035 | ], |
| 4036 | 4036 | "reason": "version-splits only", |
| 4037 | "acceptedAt": "2026-09-09T01:28:01.743Z" | |
| 4037 | "acceptedAt": "2026-09-09T01:31:20.241Z" | |
| 4038 | 4038 | } |
| 4039 | 4039 | ] |
| 4040 | 4040 | } |
| … | … | @@ -74,9 +74,19 @@ async function main () { |
| 74 | 74 | // Both callers benefit: Publisher's Creates, and router.js:287's `Accept` for an inbound Follow — |
| 75 | 75 | // which logged "will retry" while having no retry that survived a restart. A follow that arrives |
| 76 | 76 | // while this box is down is the main thing genuinely owed to somebody, and it is now owed durably. |
| 77 | // ⛔ THE QUEUE CARRIES A KEY ID, AND THIS TURNS IT BACK INTO A SIGNER. A signer is a live object | |
| 78 | // and cannot survive a restart in a JSON file, so the durable entry records WHO signs and the | |
| 79 | // signer is resolved per attempt. Without this every queued activity fell back to the process | |
| 80 | // signer: a registered user's posts went out signed with the primary's key against the primary's | |
| 81 | // key id, and Mastodon dropped every one of them after answering 202. | |
| 82 | // | |
| 83 | // ⚠ Assigned below, once PerUser exists. A null resolver means a queued entry with a signerId is | |
| 84 | // HELD rather than sent under the wrong identity — the safe direction. | |
| 85 | let signerByKeyId = null | |
| 77 | 86 | const outbound = createQueuedDeliverer({ |
| 78 | 87 | queue: new DeliveryQueue(join(dataDir, 'queue.json')), |
| 79 | 88 | deliver: sendNow, |
| 89 | resolveSigner: (keyId) => (signerByKeyId ? signerByKeyId(keyId) : null), | |
| 80 | 90 | log |
| 81 | 91 | }) |
| 82 | 92 | const deliver = outbound.deliver |
| … | … | @@ -293,6 +303,17 @@ async function main () { |
| 293 | 303 | ctx.users = users |
| 294 | 304 | Object.assign(ctx, perUser.resolvers()) |
| 295 | 305 | |
| 306 | // Resolve a queued entry's key id back to that user's signer. Scanning the registry is right | |
| 307 | // here: the key id IS the actor id plus '#main-key', so this cannot match somebody else by | |
| 308 | // accident, and there are a handful of users rather than thousands. | |
| 309 | signerByKeyId = (keyId) => { | |
| 310 | for (const u of users.all()) { | |
| 311 | const s = perUser.signer(u) | |
| 312 | if (s && s.keyId === keyId) return s | |
| 313 | } | |
| 314 | return null | |
| 315 | } | |
| 316 | ||
| 296 | 317 | /** |
| 297 | 318 | * A registration becomes a served actor: hold the feed, add the user. |
| 298 | 319 | * |
| … | … | @@ -37,7 +37,7 @@ export const BACKOFF = [60_000, 300_000, 900_000, 3_600_000, 10_800_000, 21_600_ |
| 37 | 37 | const keyFor = (inbox, activity) => `${activity && activity.id ? activity.id : '?'}|${inbox}` |
| 38 | 38 | |
| 39 | 39 | class BaseQueue { |
| 40 | async add ({ to, inbox, activity }) { | |
| 40 | async add ({ to, inbox, activity, signerId = null }) { | |
| 41 | 41 | const state = await this._read() |
| 42 | 42 | const id = keyFor(inbox, activity) |
| 43 | 43 | if (state.pending[id] || state.dead[id]) return { id, added: false } |
| … | … | @@ -46,6 +46,19 @@ class BaseQueue { |
| 46 | 46 | to, |
| 47 | 47 | inbox, |
| 48 | 48 | activity, |
| 49 | // ⛔ WHICH IDENTITY SIGNS THIS, by its PUBLIC key id, resolved to a live signer at send time. | |
| 50 | // A signer is a live object and cannot survive a restart in a JSON file, so carrying it by | |
| 51 | // reference was never possible — but carrying NOTHING meant every queued activity fell back to | |
| 52 | // the PROCESS signer. Every post and profile Update for a registered user went out signed with | |
| 53 | // the primary's key, against the primary's key id, while the activity's `actor` was the | |
| 54 | // registered user. Mastodon verifies that signature happily — it is a real key — resolves the | |
| 55 | // signing account to the primary, sees an actor that does not match, and DROPS the activity | |
| 56 | // after already answering 202. Seventeen accepted, none stored, no error anywhere. Measured on | |
| 57 | // the wire: actor .../users/ashatron/actor signed with keyId https://host/ap/actor#main-key. | |
| 58 | // | |
| 59 | // A keyId is public — it is in every actor document — so this keeps the queue's "holds nothing | |
| 60 | // secret" property while making the durable path know whose delivery it carries. | |
| 61 | signerId, | |
| 49 | 62 | attempts: 0, |
| 50 | 63 | nextAt: 0, // due immediately |
| 51 | 64 | createdAt: Date.now(), |
| … | … | @@ -160,7 +173,7 @@ export class MemoryDeliveryQueue extends BaseQueue { |
| 160 | 173 | * this one retries slow (hours) for a server that is actually down. They answer different |
| 161 | 174 | * questions and collapsing them would lose one of the answers. |
| 162 | 175 | */ |
| 163 | export function createQueuedDeliverer ({ queue, deliver, log = () => {}, now = Date.now, intervalMs = 60_000, setTimer = setInterval, clearTimer = clearInterval }) { | |
| 176 | export function createQueuedDeliverer ({ queue, deliver, resolveSigner = null, log = () => {}, now = Date.now, intervalMs = 60_000, setTimer = setInterval, clearTimer = clearInterval }) { | |
| 164 | 177 | if (!queue) throw new Error('createQueuedDeliverer: a queue is required') |
| 165 | 178 | if (typeof deliver !== 'function') throw new Error('createQueuedDeliverer: a deliver function is required') |
| 166 | 179 | |
| … | … | @@ -169,7 +182,19 @@ export function createQueuedDeliverer ({ queue, deliver, log = () => {}, now = D |
| 169 | 182 | |
| 170 | 183 | async function attempt (e) { |
| 171 | 184 | try { |
| 172 | const res = await deliver({ to: e.to, inbox: e.inbox, activity: e.activity }) | |
| 185 | // ⛔ AN UNRESOLVABLE SIGNER IS NOT A REASON TO USE ANOTHER. Falling through to the process | |
| 186 | // signer is precisely the defect above: it signs one person's activity with another's key. | |
| 187 | // The entry stays queued — a released user, or a key not loaded yet, is a state to wait in, | |
| 188 | // not one to send from somebody else's identity. | |
| 189 | let signer = null | |
| 190 | if (e.signerId) { | |
| 191 | signer = resolveSigner ? resolveSigner(e.signerId) : null | |
| 192 | if (!signer) { | |
| 193 | log({ msg: 'queued delivery HELD — cannot resolve its signer', to: e.to, signerId: e.signerId }) | |
| 194 | return | |
| 195 | } | |
| 196 | } | |
| 197 | const res = await deliver({ to: e.to, inbox: e.inbox, activity: e.activity, signer }) | |
| 173 | 198 | if (res && res.ok) { |
| 174 | 199 | await queue.succeed(e.id) |
| 175 | 200 | log({ msg: 'queued delivery sent', to: e.to, inbox: e.inbox, attempts: e.attempts }) |
| … | … | @@ -202,8 +227,8 @@ export function createQueuedDeliverer ({ queue, deliver, log = () => {}, now = D |
| 202 | 227 | |
| 203 | 228 | return { |
| 204 | 229 | /** Enqueue. Durable before the network is touched, and does not block the caller on it. */ |
| 205 | async deliver ({ to, inbox = null, activity }) { | |
| 206 | const { id, added } = await queue.add({ to, inbox, activity }) | |
| 230 | async deliver ({ to, inbox = null, activity, signer = null }) { | |
| 231 | const { id, added } = await queue.add({ to, inbox, activity, signerId: (signer && signer.keyId) || null }) | |
| 207 | 232 | if (!added) { |
| 208 | 233 | log({ msg: 'delivery already queued, not duplicating', to, id }) |
| 209 | 234 | return { queued: false, id } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "ap-bridge", |
| 3 | "version": "0.9.84", | |
| 3 | "version": "0.9.85", | |
| 4 | 4 | "description": "ActivityPub membrane — a second projection of the self-custody atproto peer", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -197,3 +197,86 @@ test('★ enqueue returns immediately — Publisher fires and forgets', async (t |
| 197 | 197 | t.absent(started, 'accepting the activity does not block on the network') |
| 198 | 198 | t.is((await queue.list()).length, 1, 'it is durable before anything is attempted') |
| 199 | 199 | }) |
| 200 | ||
| 201 | // ⛔⛔ THE QUEUE ERASED WHO WAS SIGNING, AND EVERY PER-USER DELIVERY WENT OUT AS THE PRIMARY. | |
| 202 | // | |
| 203 | // createDeliverer grew a per-delivery `signer` in 0.9.52 so one bridge could front many people. | |
| 204 | // This wrapper kept "createDeliverer's signature" as it was written for a single actor and called | |
| 205 | // `deliver({ to, inbox, activity })` — dropping it. A signer is a live object and cannot be written | |
| 206 | // to a JSON file, so nothing about the durable path could have carried it as-is. | |
| 207 | // | |
| 208 | // The result was invisible from our side and fatal on theirs: every activity for a registered user | |
| 209 | // was signed with the PRIMARY's key against the primary's key id, while the activity's `actor` was | |
| 210 | // the registered user. Mastodon verifies the signature (it is a real key), resolves the signing | |
| 211 | // account to the primary, sees an actor that does not match, and DROPS the activity — after having | |
| 212 | // already returned 202. Measured on the wire: actor .../users/ashatron/actor signed with | |
| 213 | // keyId https://ap.7rnx.net/ap/actor#main-key. 17 activities accepted, none stored, no refetch. | |
| 214 | // | |
| 215 | // ⛔ AND AN UNRESOLVABLE SIGNER MUST NOT FALL BACK. Falling back to the process signer is exactly | |
| 216 | // the bug; signing as somebody else is worse than not sending. | |
| 217 | test('⛔⛔ a queued delivery is signed by the identity that queued it', async (t) => { | |
| 218 | const seen = [] | |
| 219 | const q = new MemoryDeliveryQueue() | |
| 220 | const signers = { ash: { keyId: 'https://h/ap/users/ash/actor#main-key' } } | |
| 221 | const qd = createQueuedDeliverer({ | |
| 222 | queue: q, | |
| 223 | deliver: async (d) => { seen.push(d); return { ok: true } }, | |
| 224 | resolveSigner: (id) => Object.values(signers).find((s) => s.keyId === id) || null, | |
| 225 | setTimer: () => null, | |
| 226 | clearTimer: () => {}, | |
| 227 | log: () => {} | |
| 228 | }) | |
| 229 | const deliver = qd.deliver | |
| 230 | ||
| 231 | await deliver({ | |
| 232 | to: 'https://remote/users/bob', | |
| 233 | inbox: 'https://remote/inbox', | |
| 234 | activity: { id: 'a1', type: 'Create', actor: 'https://h/ap/users/ash/actor' }, | |
| 235 | signer: signers.ash | |
| 236 | }) | |
| 237 | await qd.drain() | |
| 238 | ||
| 239 | t.is(seen.length, 1) | |
| 240 | t.ok(seen[0].signer, 'a signer reached the wire') | |
| 241 | t.is(seen[0].signer.keyId, signers.ash.keyId, 'and it is THEIRS, not the process default') | |
| 242 | }) | |
| 243 | ||
| 244 | test('⛔ a queued delivery whose signer cannot be resolved is NOT sent as somebody else', async (t) => { | |
| 245 | const seen = [] | |
| 246 | const q = new MemoryDeliveryQueue() | |
| 247 | const qd = createQueuedDeliverer({ | |
| 248 | queue: q, | |
| 249 | deliver: async (d) => { seen.push(d); return { ok: true } }, | |
| 250 | resolveSigner: () => null, // the user was released, or their key is gone | |
| 251 | setTimer: () => null, | |
| 252 | clearTimer: () => {}, | |
| 253 | log: () => {} | |
| 254 | }) | |
| 255 | const deliver = qd.deliver | |
| 256 | ||
| 257 | await deliver({ | |
| 258 | to: 'https://remote/users/bob', | |
| 259 | inbox: 'https://remote/inbox', | |
| 260 | activity: { id: 'a2', type: 'Create', actor: 'https://h/ap/users/gone/actor' }, | |
| 261 | signer: { keyId: 'https://h/ap/users/gone/actor#main-key' } | |
| 262 | }) | |
| 263 | await qd.drain() | |
| 264 | ||
| 265 | t.is(seen.length, 0, 'nothing went out under the wrong identity') | |
| 266 | t.is(await q.size(), 1, 'and it is still queued rather than silently dropped') | |
| 267 | }) | |
| 268 | ||
| 269 | test('★ a delivery with no signer still uses the process default, exactly as before', async (t) => { | |
| 270 | const seen = [] | |
| 271 | const qd = createQueuedDeliverer({ | |
| 272 | queue: new MemoryDeliveryQueue(), | |
| 273 | deliver: async (d) => { seen.push(d); return { ok: true } }, | |
| 274 | setTimer: () => null, | |
| 275 | clearTimer: () => {}, | |
| 276 | log: () => {} | |
| 277 | }) | |
| 278 | await qd.deliver({ to: 'https://remote/users/bob', inbox: 'https://remote/inbox', activity: { id: 'a3', type: 'Create' } }) | |
| 279 | await qd.drain() | |
| 280 | t.is(seen.length, 1) | |
| 281 | t.absent(seen[0].signer, 'the primary keeps the deliverer it always had') | |
| 282 | }) |