| … | … | @@ -1052,6 +1052,14 @@ const server = http.createServer(async (req, res) => { |
| 1052 | 1052 | // so a write authors as THAT DID whoever is signed in — and sign-in admits any DID that can prove |
| 1053 | 1053 | // a delegation from its own #atproto key. Without this, any atproto account posts as wj62. |
| 1054 | 1054 | if (req.method === 'POST' && !OPEN_POST.has(path)) { |
| 1055 | // ⛔ SAY "READ-ONLY" BEFORE ASKING AN HPP THAT ISN'T THERE. A lens instance (stortron) has no | |
| 1056 | // write transport, so every hpp call throws — including the device-revocation check. Fail-closed | |
| 1057 | // then surfaced as "this device has been revoked" to a signed-in user whose device was perfectly | |
| 1058 | // fine. Reported live on favela.7rnx.net. The refusal is right; the reason was a fabrication. | |
| 1059 | if (!writeTransportConfigured(process.env)) { | |
| 1060 | console.log(JSON.stringify({ msg: 'write refused', path, viewer: viewer ? viewer.did : null, status: 403, reason: 'read-only instance' })) | |
| 1061 | return json(res, 403, JSON.stringify({ error: 'this instance is read-only — it serves feeds and cannot author records' })) | |
| 1062 | } | |
| 1055 | 1063 | const ctx = { identity: viewer } |
| 1056 | 1064 | const isDeviceRevoked = (k) => writer.isDeviceRevoked(k) |
| 1057 | 1065 | try { |
| … | … | @@ -88,12 +88,18 @@ export function extractIdentity (req, sessions) { |
| 88 | 88 | * right for a write: there, an unreachable registry must not wave through a device that may have been |
| 89 | 89 | * revoked. Hence `failOpen`, defaulting to pgv3's behaviour and flipped by the write guards. |
| 90 | 90 | */ |
| 91 | export const UNKNOWN = 'unknown' | |
| 92 | ||
| 91 | 93 | export async function isRevoked ({ subKey, isDeviceRevoked, failOpen = true } = {}) { |
| 92 | 94 | if (!subKey || typeof isDeviceRevoked !== 'function') return false |
| 93 | 95 | try { |
| 94 | 96 | return !!(await isDeviceRevoked(subKey)) |
| 95 | 97 | } catch { |
| 96 | return !failOpen // fail-open → "not revoked"; fail-closed → treat as revoked | |
| 98 | // ⛔ A CHECK THAT COULD NOT RUN IS NOT A POSITIVE REVOCATION. This returned `true` on the write | |
| 99 | // path, so a read-only instance — one with no hpp to ask — told a signed-in user "this device has | |
| 100 | // been revoked". Their device was fine; nobody could check. Reported live on favela.7rnx.net. | |
| 101 | // Fail-closed still DENIES; it must simply not assert something untrue about the user's device. | |
| 102 | return failOpen ? false : UNKNOWN | |
| 97 | 103 | } |
| 98 | 104 | } |
| 99 | 105 | |
| … | … | @@ -104,9 +110,9 @@ export async function isRevoked ({ subKey, isDeviceRevoked, failOpen = true } = |
| 104 | 110 | export async function requireIdentity (ctx, opts = {}) { |
| 105 | 111 | const identity = ctx && ctx.identity |
| 106 | 112 | if (!identity || !identity.did) throw new AuthError('authentication required') |
| 107 | if (await isRevoked({ subKey: identity.subKey, ...opts })) { | |
| 108 | throw new AuthError('this device has been revoked', 403) | |
| 109 | } | |
| 113 | const verdict = await isRevoked({ subKey: identity.subKey, ...opts }) | |
| 114 | if (verdict === UNKNOWN) throw new AuthError('device status could not be verified', 403) | |
| 115 | if (verdict) throw new AuthError('this device has been revoked', 403) | |
| 110 | 116 | } |
| 111 | 117 | |
| 112 | 118 | /** |
| … | … | @@ -174,4 +180,4 @@ export async function mayWrite (ctx, opts = {}) { |
| 174 | 180 | } |
| 175 | 181 | } |
| 176 | 182 | |
| 177 | export default { extractToken, extractIdentity, requireIdentity, requireMember, requireRepoOwner, mayWrite, isRevoked, AuthError, COOKIE_NAME } | |
| 183 | export default { UNKNOWN, extractToken, extractIdentity, requireIdentity, requireMember, requireRepoOwner, mayWrite, isRevoked, AuthError, COOKIE_NAME } |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "favela-web", |
| 3 | "version": "0.3.56", | |
| 3 | "version": "0.3.57", | |
| 4 | 4 | "description": "favela-web \u2014 the web/SSR surface of Favela, the native P2P-local, community-owned social client + appview over the self-custody atproto stack (hyper-proto-peer). One of the Favela surfaces (alongside favela-cli and favela-native). bsky is an optional POSSE export, not the home. See DESIGN.md.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "bin/favela-web.js", |
| … | … | @@ -9,7 +9,7 @@ |
| 9 | 9 | import { SessionStore } from '../lib/sessions.js' |
| 10 | 10 | import { |
| 11 | 11 | AuthError, COOKIE_NAME, extractToken, extractIdentity, |
| 12 | isRevoked, requireIdentity, requireMember, requireRepoOwner, mayWrite | |
| 12 | isRevoked, UNKNOWN, requireIdentity, requireMember, requireRepoOwner, mayWrite | |
| 13 | 13 | } from '../lib/check.js' |
| 14 | 14 | |
| 15 | 15 | let pass = 0 |
| … | … | @@ -59,7 +59,11 @@ const ID = { did: DID, subKey: 'beef' } |
| 59 | 59 | // reader out, but it must not wave a WRITE through either. |
| 60 | 60 | const boom = async () => { throw new Error('registry unreachable') } |
| 61 | 61 | eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: boom }), false, 'read path FAILS OPEN on a registry error (pgv3 behaviour)') |
| 62 | eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: boom, failOpen: false }), true, 'write path FAILS CLOSED on the same error') | |
| 62 | // ⛔ A CHECK THAT COULD NOT RUN IS NOT A POSITIVE REVOCATION. Returning `true` here made the write | |
| 63 | // path answer "this device has been revoked" to a user whose device was fine — the real cause was a | |
| 64 | // read-only instance with no hpp to ask. It still DENIES; it must just say something true. | |
| 65 | eq(await isRevoked({ subKey: 'beef', isDeviceRevoked: boom, failOpen: false }), UNKNOWN, | |
| 66 | 'write path reports UNKNOWN, not revoked, when the check itself fails') | |
| 63 | 67 | } |
| 64 | 68 | |
| 65 | 69 | // ── requireIdentity ───────────────────────────────────────────────── |
| … | … | @@ -70,6 +74,14 @@ const ID = { did: DID, subKey: 'beef' } |
| 70 | 74 | await requireIdentity({ identity: ID }) |
| 71 | 75 | ok(true, 'a signed-in identity passes') |
| 72 | 76 | await rejects(() => requireIdentity({ identity: ID }, { isDeviceRevoked: async () => true }), 403, 'a REVOKED device is refused even with a valid session') |
| 77 | // Both deny — but a user must never be told they were revoked when nobody could check. | |
| 78 | try { | |
| 79 | await requireIdentity({ identity: ID }, { isDeviceRevoked: async () => { throw new Error('no hpp') }, failOpen: false }) | |
| 80 | ok(false, 'an unverifiable device should be refused') | |
| 81 | } catch (e) { | |
| 82 | ok(/could not be verified/i.test(e.message) && !/revoked/i.test(e.message), | |
| 83 | `an unverifiable device is refused WITHOUT claiming revocation (got: ${e.message})`) | |
| 84 | } | |
| 73 | 85 | } |
| 74 | 86 | |
| 75 | 87 | // ── requireMember — the seat for the invite gate ───────────────────── |