| … | … | @@ -139,6 +139,48 @@ export async function requireWriter (ctx, registry) { |
| 139 | 139 | throw new AuthError('writer permission required', 403) |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | /** | |
| 143 | * Require an AUTHENTICATED, non-revoked identity — but NOT write permission. | |
| 144 | * | |
| 145 | * For SELF-SCOPED reads: handlers returning only the caller's own data, which | |
| 146 | * leak nothing cross-user. A newly TOFU-registered user gets `['read']` by | |
| 147 | * default (handlers/users.js:457), and requireWriter above passes only 'admin' | |
| 148 | * or 'write' — so a read-only user was locked out of its own profile surface. | |
| 149 | * That is what stopped a fresh identity from listing its own devices and | |
| 150 | * copying its link invite (regression surfaced 2026-08-09; the writer call in | |
| 151 | * devices.js predates it, but #12's default-deny removed the anonymous path | |
| 152 | * that used to mask it). | |
| 153 | * | |
| 154 | * Strictly WEAKER than requireWriter, strictly STRONGER than anonymous: | |
| 155 | * - anonymous is still rejected — you need an identity to ask for "my" | |
| 156 | * anything — so #12's default-deny surface is NOT reopened. | |
| 157 | * - the #14 revocation gate is KEPT. A revoked identity retains its User | |
| 158 | * record and globalPermissions, so any perms-only check still passes for | |
| 159 | * it; revocation must be denied first. That bypass was real once. | |
| 160 | * | |
| 161 | * Additive: requireWriter and requireAdmin are untouched. | |
| 162 | */ | |
| 163 | export async function requireIdentity (ctx, registry) { | |
| 164 | if (ctx && ctx.transport === 'uds') return // local UDS = trusted | |
| 165 | ||
| 166 | const identity = ctx && ctx.identity | |
| 167 | if (!identity || !identity.publicKey) { | |
| 168 | throw new AuthError('authentication required') | |
| 169 | } | |
| 170 | ||
| 171 | // Intrinsic owner: same short-circuit as requireWriter — the key running the | |
| 172 | // daemon IS the owner, no registry record required. | |
| 173 | if (identity.ownerMatched) return | |
| 174 | ||
| 175 | if (!registry) { | |
| 176 | throw new AuthError('identity registry not available', 500) | |
| 177 | } | |
| 178 | ||
| 179 | if (await isIdentityRevoked(registry, identity.publicKey)) { | |
| 180 | throw new AuthError('identity revoked', 403) | |
| 181 | } | |
| 182 | } | |
| 183 | ||
| 142 | 184 | /** |
| 143 | 185 | * Non-throwing probe — does ctx belong to a user who would pass |
| 144 | 186 | * requireAdmin? Used by SSR page renderers to decide whether to SHOW |
| … | … | @@ -11,7 +11,7 @@ |
| 11 | 11 | */ |
| 12 | 12 | |
| 13 | 13 | import { DeviceLinker } from '../device-linker.js' |
| 14 | import { requireAdmin, requireWriter } from '../auth/check.js' | |
| 14 | import { requireAdmin, requireWriter, requireIdentity } from '../auth/check.js' | |
| 15 | 15 | |
| 16 | 16 | export function deviceHandlers (svc) { |
| 17 | 17 | // Active invite sessions — keyed by inviteHex. Cleaned up on completion |
| … | … | @@ -167,7 +167,13 @@ export function deviceHandlers (svc) { |
| 167 | 167 | { |
| 168 | 168 | name: 'list-my-devices', |
| 169 | 169 | fn: async (ctx, params = {}) => { |
| 170 | await requireWriter(ctx, svc.identityRegistry) | |
| 170 | // SELF-SCOPED: resolves the caller's OWN user below and returns only | |
| 171 | // that user's identities, so this leaks nothing cross-user and does | |
| 172 | // not need write permission. requireWriter locked out read-only users | |
| 173 | // — the default perms for a fresh TOFU registration are ['read'] — so | |
| 174 | // a new identity could not see its own devices or copy its link | |
| 175 | // invite. requireIdentity still rejects anonymous and revoked callers. | |
| 176 | await requireIdentity(ctx, svc.identityRegistry) | |
| 171 | 177 | |
| 172 | 178 | const user = await svc.identityRegistry.getUserByIdentity( |
| 173 | 179 | ctx.identity.publicKey |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.217", | |
| 3 | "version": "0.1.218", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,93 @@ |
| 1 | /** | |
| 2 | * requireIdentity — the gate for SELF-SCOPED reads. | |
| 3 | * | |
| 4 | * A newly TOFU-registered user gets `['read']` by default | |
| 5 | * (handlers/users.js:457). requireWriter passes only 'admin' or 'write', so | |
| 6 | * gating a self-scoped handler with it locks a read-only identity out of its | |
| 7 | * OWN data. That is what stopped a fresh identity from listing its own devices | |
| 8 | * and copying its link invite: the daemon's rpc-audit-log recorded | |
| 9 | * {"method":"list-my-devices","trust":"authenticated","error":"writer permission required"} | |
| 10 | * alongside anonymous denials of the same method. | |
| 11 | * | |
| 12 | * requireIdentity must sit strictly between anonymous and writer: | |
| 13 | * - anonymous -> DENIED (does not reopen the #12 default-deny surface) | |
| 14 | * - revoked -> DENIED (#14: a revoked identity keeps its perms, so the | |
| 15 | * revocation gate must run before/without a perms check) | |
| 16 | * - read-only -> ALLOWED <- the regression this fixes | |
| 17 | * - uds -> ALLOWED (local transport is trusted) | |
| 18 | */ | |
| 19 | ||
| 20 | import test from 'brittle' | |
| 21 | ||
| 22 | import { requireIdentity, requireWriter } from '../lib/auth/check.js' | |
| 23 | ||
| 24 | const PUB = 'a'.repeat(64) | |
| 25 | ||
| 26 | // Minimal registry stand-in. isIdentityRevoked only calls registry.isRevoked; | |
| 27 | // requireWriter additionally calls getUserByIdentity for the perms lookup. | |
| 28 | function makeRegistry ({ perms = ['read'], revoked = false } = {}) { | |
| 29 | return { | |
| 30 | async isRevoked () { return revoked }, | |
| 31 | async getUserByIdentity () { return { userId: 'u1', globalPermissions: perms } } | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | function ctxFor (publicKey) { | |
| 36 | return { transport: 'http', identity: publicKey ? { publicKey } : null } | |
| 37 | } | |
| 38 | ||
| 39 | test('read-only identity is ALLOWED (the regression)', async (t) => { | |
| 40 | await t.execution( | |
| 41 | requireIdentity(ctxFor(PUB), makeRegistry({ perms: ['read'] })), | |
| 42 | 'a default ["read"] user reaches its own self-scoped data' | |
| 43 | ) | |
| 44 | }) | |
| 45 | ||
| 46 | test('the same read-only identity is REJECTED by requireWriter', async (t) => { | |
| 47 | // Documents precisely why the gate had to change rather than the perms. | |
| 48 | await t.exception( | |
| 49 | requireWriter(ctxFor(PUB), makeRegistry({ perms: ['read'] })), | |
| 50 | /writer permission required/, | |
| 51 | 'requireWriter denies a read-only user — the observed audit-log error' | |
| 52 | ) | |
| 53 | }) | |
| 54 | ||
| 55 | test('anonymous is REJECTED — #12 default-deny is not reopened', async (t) => { | |
| 56 | await t.exception( | |
| 57 | requireIdentity(ctxFor(null), makeRegistry()), | |
| 58 | /authentication required/, | |
| 59 | 'no identity means no "my" anything' | |
| 60 | ) | |
| 61 | }) | |
| 62 | ||
| 63 | test('revoked identity is REJECTED even though its perms would pass', async (t) => { | |
| 64 | // #14: revocation must be denied BEFORE perms are consulted, because a | |
| 65 | // revoked identity retains its User record and globalPermissions. | |
| 66 | await t.exception( | |
| 67 | requireIdentity(ctxFor(PUB), makeRegistry({ perms: ['admin'], revoked: true })), | |
| 68 | /identity revoked/, | |
| 69 | 'revocation gate still runs' | |
| 70 | ) | |
| 71 | }) | |
| 72 | ||
| 73 | test('uds transport is trusted', async (t) => { | |
| 74 | await t.execution( | |
| 75 | requireIdentity({ transport: 'uds', identity: null }, makeRegistry()), | |
| 76 | 'local UDS bypasses, matching requireWriter' | |
| 77 | ) | |
| 78 | }) | |
| 79 | ||
| 80 | test('intrinsic owner passes without a registry record', async (t) => { | |
| 81 | await t.execution( | |
| 82 | requireIdentity({ transport: 'http', identity: { publicKey: PUB, ownerMatched: true } }, null), | |
| 83 | 'ownerMatched short-circuits, matching requireWriter' | |
| 84 | ) | |
| 85 | }) | |
| 86 | ||
| 87 | test('missing registry is a 500, not a silent allow', async (t) => { | |
| 88 | await t.exception( | |
| 89 | requireIdentity(ctxFor(PUB), null), | |
| 90 | /identity registry not available/, | |
| 91 | 'cannot verify revocation => refuse rather than pass' | |
| 92 | ) | |
| 93 | }) |