OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 7603eff

0.1.143: bind a release to the identity that owns the name
A release signature proves an entry was signed by the device key inside it (0.1.142). It says nothing about whether that device may publish under this app NAME — the release registry and the name registry were never connected, so a valid signature from any key looked exactly as good as one from the owner. checkPublisherTrust joins them: resolve the entry's device to its identity root via the attestation chain, then require that root to be the one the NameRegistry claim is anchored to. The chain must attest THIS entry's device, or it proves nothing about this release — a valid chain lifted from elsewhere is refused. This is the payoff for #142. Anchored to a device key, the check would refuse every release published from any other device the same identity owns; the test for that (claim from one device, release from another, both accepted) is the one that would fail if the anchor regressed. TRANSITIONAL. A missing chain, an unclaimed name, or a pre-#142 device-anchored claim is INDETERMINATE, not a refusal — no entry in the wild carries a chain yet, and all 13 live claims are still anchored to a device key. Only a present-and-contradictory pair is refused, so nothing today can trip it. Enforcement (treating indeterminate as refusal) is a later flip, after publishers re-publish and the claims are re-anchored with `pgh names update`. NOT YET WIRED into the install path — resolveRelease has the entry but not the claim, and threading it wants a proper read of that path rather than a hurried one. The module is inert until then, deliberately. 7/7 here; 325/325, 930/930 across the suite. Forge org #144 step 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
7603eff654ec93b908605791f3d23796ca319cb0
by plankatron <fleeky@gmail.com> · 2026-08-25T16:11:41.000Z

Changed files (3) +294 -1

lib/publisher-trust.js +128 -0
……@@ -0,0 +1,128 @@
1/**
2 * Does this release actually come from the identity that owns the name?
3 *
4 * The release registry proves a release was signed by the device key it names
5 * (lib/release-registry.js). It says nothing about whether that device is
6 * allowed to publish under this app NAME — the registry and the name registry
7 * are separate structures, and a signature only binds an entry to whatever key
8 * happens to be in it.
9 *
10 * The missing link is the NameRegistry claim, which records the publisher's
11 * identityRoot. This module joins the two: resolve the entry's device key back
12 * to its root via the attestation chain, then require that root to be the one
13 * the claim is anchored to.
14 *
15 * That is why the anchor had to be the ROOT and not a device key (org #142) —
16 * bound to a device, this check would refuse every release published from any
17 * other device the identity owns, and would have to be re-pointed on every
18 * device rotation.
19 *
20 * TRANSITIONAL, deliberately (org #144 step 3):
21 *
22 * - Entries published before `publisherChain` existed carry no chain.
23 * - Claims made before #142 are anchored to a device key, not a root.
24 * - An app installed by pgh:// URL may have no name claim at all.
25 *
26 * So a missing piece is `indeterminate`, not a refusal. Only a present-and-
27 * contradictory pair is refused. Enforcement — treating indeterminate as a
28 * refusal — is a later flip, once publishers have re-published and the
29 * existing claims have been re-anchored with `pgh names update`.
30 */
31
32import IdentityKey from 'keet-identity-key'
33import b4a from 'b4a'
34
35export const TRUST_OK = 'ok'
36export const TRUST_INDETERMINATE = 'indeterminate'
37export const TRUST_REFUSED = 'refused'
38
39/**
40 * @param {object} entry release entry ({ publisherDevicePubkey, publisherChain })
41 * @param {object} claim NameRegistry claim ({ publisher: { identityRoot } })
42 * @returns {{ status: string, reason: string, root?: string }}
43 */
44export function checkPublisherTrust (entry, claim) {
45 if (!entry) return { status: TRUST_INDETERMINATE, reason: 'no release entry' }
46
47 const claimedRoot = claim && claim.publisher && claim.publisher.identityRoot
48 if (!claimedRoot) {
49 return { status: TRUST_INDETERMINATE, reason: 'name is not claimed, so there is no root to check against' }
50 }
51
52 const device = entry.publisherDevicePubkey
53 if (!device) {
54 return { status: TRUST_INDETERMINATE, reason: 'entry names no publisher device key' }
55 }
56
57 // A claim still anchored to a device key (pre-#142) cannot answer the
58 // question — matching it would only prove "same device", which is the
59 // property we are deliberately not depending on.
60 if (claimedRoot === device && !entry.publisherChain) {
61 return {
62 status: TRUST_INDETERMINATE,
63 reason: 'claim is anchored to a device key, not a root (pre-#142 claim; re-anchor with `pgh names update`)'
64 }
65 }
66
67 if (!entry.publisherChain) {
68 return {
69 status: TRUST_INDETERMINATE,
70 reason: 'entry carries no attestation chain (published before signing was wired)'
71 }
72 }
73
74 let verified
75 try {
76 const proof = b4a.from(entry.publisherChain, 'base64')
77 if (!proof || proof.length === 0) {
78 return { status: TRUST_REFUSED, reason: 'attestation chain is empty' }
79 }
80 verified = IdentityKey.verify(proof)
81 } catch (err) {
82 return { status: TRUST_REFUSED, reason: 'attestation chain did not parse: ' + err.message }
83 }
84 if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) {
85 return { status: TRUST_REFUSED, reason: 'attestation chain failed verification' }
86 }
87
88 // The chain must describe THIS entry's device, or it proves nothing about
89 // this release — it could be any valid chain lifted from anywhere.
90 const chainDevice = b4a.toString(verified.devicePublicKey, 'hex')
91 if (chainDevice !== device) {
92 return {
93 status: TRUST_REFUSED,
94 reason: 'chain attests device ' + chainDevice.slice(0, 12) + '… but the entry was signed by ' +
95 String(device).slice(0, 12) + '…'
96 }
97 }
98
99 const root = b4a.toString(verified.identityPublicKey, 'hex')
100 if (root !== claimedRoot) {
101 return {
102 status: TRUST_REFUSED,
103 reason: 'release publisher resolves to identity ' + root.slice(0, 12) +
104 '… but the name is owned by ' + String(claimedRoot).slice(0, 12) + '…',
105 root
106 }
107 }
108
109 return { status: TRUST_OK, reason: 'publisher device chains to the identity that owns the name', root }
110}
111
112/**
113 * Throwing form for install paths. Refuses only a contradiction; an
114 * indeterminate result passes and is returned so the caller can report it.
115 */
116export function assertPublisherTrust (entry, claim, { name } = {}) {
117 const result = checkPublisherTrust(entry, claim)
118 if (result.status === TRUST_REFUSED) {
119 throw Object.assign(
120 new Error(
121 'refusing ' + (name || (entry && entry.name)) + '@' + (entry && entry.version) +
122 ': ' + result.reason
123 ),
124 { code: 'EPUBLISHERMISMATCH', result }
125 )
126 }
127 return result
128}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.142",
3 "version": "0.1.143",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/publisher-trust.test.js +165 -0
……@@ -0,0 +1,165 @@
1/**
2 * Binding a release to the identity that owns the name (forge org #144 step 3).
3 *
4 * A release signature proves the entry was signed by the device key inside it.
5 * It does not prove that device may publish under this app NAME — nothing
6 * connected the release registry to the name registry. This is that link:
7 * resolve the entry's device to its identity root via the attestation chain,
8 * and require that root to be the one the NameRegistry claim is anchored to.
9 *
10 * It is also the payoff for #142. Anchored to a device key, this check would
11 * refuse every release published from any other device the same identity owns.
12 *
13 * Transitional: a missing chain or a pre-#142 claim is INDETERMINATE, not a
14 * refusal — no entry in the wild carries a chain yet, and all 13 live claims
15 * are still anchored to a device key. Only a present-and-contradictory pair is
16 * refused. Enforcement is a later flip.
17 */
18
19import test from 'brittle'
20import IdentityKey from 'keet-identity-key'
21import crypto from 'hypercore-crypto'
22import b4a from 'b4a'
23
24import {
25 checkPublisherTrust,
26 assertPublisherTrust,
27 TRUST_OK,
28 TRUST_INDETERMINATE,
29 TRUST_REFUSED
30} from '../lib/publisher-trust.js'
31
32// A root identity with one or more attested devices.
33async function makeIdentity () {
34 const mnemonic = IdentityKey.generateMnemonic()
35 const identityKey = await IdentityKey.from({ mnemonic })
36 const root = b4a.toString(identityKey.identityKeyPair.publicKey, 'hex')
37
38 async function attest () {
39 const device = crypto.keyPair()
40 const proof = await identityKey.bootstrap(device.publicKey)
41 return {
42 devicePubkey: b4a.toString(device.publicKey, 'hex'),
43 chain: b4a.toString(proof, 'base64')
44 }
45 }
46
47 return { root, attest }
48}
49
50function entryFor (device, overrides = {}) {
51 return {
52 name: 'demo',
53 version: '1.0.0',
54 treeHash: 'a'.repeat(64),
55 publisherDevicePubkey: device.devicePubkey,
56 publisherChain: device.chain,
57 ...overrides
58 }
59}
60
61const claimFor = (root) => ({ name: 'demo', publisher: { identityRoot: root } })
62
63test('a release from the owning identity is accepted', async (t) => {
64 const owner = await makeIdentity()
65 const device = await owner.attest()
66
67 const r = checkPublisherTrust(entryFor(device), claimFor(owner.root))
68 t.is(r.status, TRUST_OK, r.reason)
69 t.is(r.root, owner.root, 'resolved to the owning root')
70})
71
72test('a SECOND device of the same identity is accepted — the point of #142', async (t) => {
73 const owner = await makeIdentity()
74 const laptop = await owner.attest()
75 const server = await owner.attest()
76
77 t.not(laptop.devicePubkey, server.devicePubkey, 'genuinely different devices')
78
79 // The name was claimed from one device; the release was published from the
80 // other. Anchored to a device key this would be refused.
81 t.is(checkPublisherTrust(entryFor(laptop), claimFor(owner.root)).status, TRUST_OK)
82 t.is(checkPublisherTrust(entryFor(server), claimFor(owner.root)).status, TRUST_OK)
83})
84
85test('a release from a DIFFERENT identity is refused', async (t) => {
86 const owner = await makeIdentity()
87 const attacker = await makeIdentity()
88 const attackerDevice = await attacker.attest()
89
90 const r = checkPublisherTrust(entryFor(attackerDevice), claimFor(owner.root))
91 t.is(r.status, TRUST_REFUSED)
92 t.ok(/is owned by/.test(r.reason), r.reason)
93})
94
95test('a chain lifted from someone else is refused (it must attest THIS device)', async (t) => {
96 const owner = await makeIdentity()
97 const ownerDevice = await owner.attest()
98 const other = await owner.attest()
99
100 // Correct root, valid chain — but the chain describes a different device
101 // than the one that signed the entry.
102 const frankenstein = entryFor(ownerDevice, { publisherChain: other.chain })
103
104 const r = checkPublisherTrust(frankenstein, claimFor(owner.root))
105 t.is(r.status, TRUST_REFUSED)
106 t.ok(/but the entry was signed by/.test(r.reason), r.reason)
107})
108
109test('a garbage chain is refused, not treated as missing', async (t) => {
110 const owner = await makeIdentity()
111 const device = await owner.attest()
112
113 const r = checkPublisherTrust(
114 entryFor(device, { publisherChain: b4a.toString(b4a.from('not a proof'), 'base64') }),
115 claimFor(owner.root)
116 )
117 t.is(r.status, TRUST_REFUSED, r.reason)
118})
119
120test('indeterminate: no claim, no chain, or a pre-#142 device-anchored claim', async (t) => {
121 const owner = await makeIdentity()
122 const device = await owner.attest()
123
124 t.is(
125 checkPublisherTrust(entryFor(device), null).status,
126 TRUST_INDETERMINATE,
127 'unclaimed name'
128 )
129
130 t.is(
131 checkPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status,
132 TRUST_INDETERMINATE,
133 'entry predates signing'
134 )
135
136 // What every live claim looks like today: anchored to the device key.
137 const legacyClaim = claimFor(device.devicePubkey)
138 t.is(
139 checkPublisherTrust(entryFor(device, { publisherChain: null }), legacyClaim).status,
140 TRUST_INDETERMINATE,
141 'pre-#142 claim anchored to a device key'
142 )
143})
144
145test('assertPublisherTrust throws only on a contradiction', async (t) => {
146 const owner = await makeIdentity()
147 const attacker = await makeIdentity()
148 const device = await owner.attest()
149 const attackerDevice = await attacker.attest()
150
151 t.is(assertPublisherTrust(entryFor(device), claimFor(owner.root)).status, TRUST_OK)
152
153 // Indeterminate must pass — otherwise nothing installs until every publisher
154 // has re-published and every claim has been re-anchored.
155 t.is(
156 assertPublisherTrust(entryFor(device, { publisherChain: null }), claimFor(owner.root)).status,
157 TRUST_INDETERMINATE
158 )
159
160 t.exception(
161 () => assertPublisherTrust(entryFor(attackerDevice), claimFor(owner.root), { name: 'demo' }),
162 /refusing demo@1\.0\.0/,
163 'contradiction throws'
164 )
165})