OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / d7ac0f2

0.1.218: let a read-only identity list its own devices again
A newly TOFU-registered user gets ['read'] by default (handlers/users.js:457). list-my-devices was gated with requireWriter, which passes only 'admin' or 'write' — so a fresh identity could not see its own device list or copy its link invite. That capability existed before and was lost. The requireWriter call in devices.js predates this (Phase 2d, 2026-04-16), so the gate itself is not new. What changed is e0584c0 (2026-07-30, "default-deny the anonymous /rpc/ surface", org #12): list-my-devices is not in ANONYMOUS_METHODS, so the anonymous path that used to mask the writer gate closed, and the gate became reachable. Observed on stortron's rpc-audit-log, both failure modes on the same method: {"method":"list-my-devices","identity":null,"error":"anonymous access denied"} {"method":"list-my-devices","trust":"authenticated","error":"writer permission required"} New export requireIdentity (lib/auth/check.js) for SELF-SCOPED reads: an authenticated, non-revoked identity, with NO write requirement. It sits strictly between anonymous and writer: - anonymous DENIED — #12's default-deny surface is not reopened - revoked DENIED — #14's gate is kept verbatim. A revoked identity keeps its User record and globalPermissions, so a perms-only check still passes for it; revocation must be denied first. That bypass was real once. - read-only ALLOWED — the regression - uds/owner ALLOWED — same short-circuits as requireWriter list-my-devices is self-scoped: it resolves getUserByIdentity on the CALLER's own pubkey and returns only that user's identities, so it leaks nothing cross-user and never needed write permission. ADDITIVE ONLY: requireWriter and requireAdmin are untouched, so the three downstream projects sharing this auth pattern (hypernote, pear-git, hyper-social-web) see no behavioural change. Deliberately NOT changed: list-orgs also fails anonymously in the same audit log, but it is a genuine cross-user listing, not self-scoped. What anonymous visitors should see there needs its own decision. Tests: new test/require-identity.test.js 7/7 covering read-only allowed, anonymous denied, revoked denied despite admin perms, uds, ownerMatched, and missing-registry-refuses; plus a contrast case asserting requireWriter still denies the same read-only user. device-management, device-invite-writer- mismatch, admin-grant-permission, repo-request-writer-auth and upload-requires-writer all still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
d7ac0f25d373814d9f9b5165830aceeeb0d37ba7
by plankatron <fleeky@gmail.com> · 2026-08-09T04:25:04.000Z

Changed files (4) +144 -3

lib/auth/check.js +42 -0
……@@ -139,6 +139,48 @@ export async function requireWriter (ctx, registry) {
139139 throw new AuthError('writer permission required', 403)
140140}
141141
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 */
163export 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
142184/**
143185 * Non-throwing probe — does ctx belong to a user who would pass
144186 * requireAdmin? Used by SSR page renderers to decide whether to SHOW
lib/handlers/devices.js +8 -2
……@@ -11,7 +11,7 @@
1111 */
1212
1313import { DeviceLinker } from '../device-linker.js'
14import { requireAdmin, requireWriter } from '../auth/check.js'
14import { requireAdmin, requireWriter, requireIdentity } from '../auth/check.js'
1515
1616export function deviceHandlers (svc) {
1717 // Active invite sessions — keyed by inviteHex. Cleaned up on completion
……@@ -167,7 +167,13 @@ export function deviceHandlers (svc) {
167167 {
168168 name: 'list-my-devices',
169169 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)
171177
172178 const user = await svc.identityRegistry.getUserByIdentity(
173179 ctx.identity.publicKey
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.217",
3 "version": "0.1.218",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/require-identity.test.js +93 -0
……@@ -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
20import test from 'brittle'
21
22import { requireIdentity, requireWriter } from '../lib/auth/check.js'
23
24const PUB = 'a'.repeat(64)
25
26// Minimal registry stand-in. isIdentityRevoked only calls registry.isRevoked;
27// requireWriter additionally calls getUserByIdentity for the perms lookup.
28function makeRegistry ({ perms = ['read'], revoked = false } = {}) {
29 return {
30 async isRevoked () { return revoked },
31 async getUserByIdentity () { return { userId: 'u1', globalPermissions: perms } }
32 }
33}
34
35function ctxFor (publicKey) {
36 return { transport: 'http', identity: publicKey ? { publicKey } : null }
37}
38
39test('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
46test('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
55test('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
63test('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
73test('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
80test('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
87test('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})