| … | … | @@ -45,6 +45,12 @@ |
| 45 | 45 | import Autobase from 'autobase' |
| 46 | 46 | import Hyperbee from 'hyperbee' |
| 47 | 47 | import b4a from 'b4a' |
| 48 | import sodium from 'sodium-native' |
| 49 | // Imported UNCONDITIONALLY, and never injected. apply() must reach the same |
| 50 | // verdict on every peer replaying the same log — a registry that could not |
| 51 | // verify a chain would reject ops its peers accept, and the two views would |
| 52 | // diverge on identical history. Optional verification is not an option here. |
| 53 | import IdentityKey from 'keet-identity-key' |
| 48 | 54 | |
| 49 | 55 | export const OP_CLAIM_NAME = 'claim-name' |
| 50 | 56 | export const OP_UPDATE_NAME = 'update-name' |
| … | … | @@ -52,6 +58,18 @@ export const OP_RELEASE_NAME = 'release-name' |
| 52 | 58 | export const OP_ADD_WRITER = 'add-writer' |
| 53 | 59 | export const OP_REMOVE_WRITER = 'remove-writer' |
| 54 | 60 | |
| 61 | // Op version. Absent/1 = the legacy unsigned form, authorised by a bare string |
| 62 | // compare on `signedBy`. 2 = device-signed with an attestation chain back to |
| 63 | // the claim's identityRoot. |
| 64 | // |
| 65 | // Legacy ops are still accepted so that replaying EXISTING history reaches the |
| 66 | // same view it always did — `release-name` has been callable, so tombstones may |
| 67 | // already be in the log, and rejecting them on reindex would resurrect released |
| 68 | // names. Writers only ever emit v2. Closing the legacy path is a follow-up once |
| 69 | // the fleet is known to be on v2 (same in-band pattern as pgv3's schema guard). |
| 70 | export const OP_VERSION_SIGNED = 2 |
| 71 | |
| 72 | |
| 55 | 73 | export class NameRegistry { |
| 56 | 74 | /** |
| 57 | 75 | * @param {Corestore} corestore |
| … | … | @@ -115,10 +133,17 @@ export class NameRegistry { |
| 115 | 133 | if (!op.name || !op.signedBy) continue |
| 116 | 134 | const existing = await view.get('claim:' + op.name) |
| 117 | 135 | if (!existing) continue |
| 118 | | // Auth: op.signedBy must match the existing claim's identityRoot |
| 119 | | if (existing.value.publisher.identityRoot !== op.signedBy) continue |
| 120 | | |
| 121 | | const updated = { ...existing.value } |
| 136 | // v2: device signature + chain back to the claim's root. |
| 137 | // Legacy: the original string compare. See OP_VERSION_SIGNED. |
| 138 | if (!opAuthorised(op, existing.value)) continue |
| 139 | |
| 140 | // Deep-copy `publisher`: `{ ...existing.value }` is shallow, so |
| 141 | // assigning through updated.publisher mutated the object read out of |
| 142 | // the bee rather than a copy of it. |
| 143 | const updated = { |
| 144 | ...existing.value, |
| 145 | publisher: { ...existing.value.publisher } |
| 146 | } |
| 122 | 147 | if (op.updates && typeof op.updates === 'object') { |
| 123 | 148 | if (op.updates.registryKey) updated.publisher.registryKey = op.updates.registryKey |
| 124 | 149 | if (op.updates.serverKey) updated.publisher.serverKey = op.updates.serverKey |
| … | … | @@ -132,7 +157,7 @@ export class NameRegistry { |
| 132 | 157 | if (!op.name || !op.signedBy) continue |
| 133 | 158 | const existing = await view.get('claim:' + op.name) |
| 134 | 159 | if (!existing) continue |
| 135 | | if (existing.value.publisher.identityRoot !== op.signedBy) continue |
| 160 | if (!opAuthorised(op, existing.value)) continue |
| 136 | 161 | // Don't delete the claim; tombstone allows audit + reclaim semantics |
| 137 | 162 | await view.put('tombstone:' + op.name, { |
| 138 | 163 | releasedAt: Date.now(), |
| … | … | @@ -210,18 +235,18 @@ export class NameRegistry { |
| 210 | 235 | await this.update() |
| 211 | 236 | } |
| 212 | 237 | |
| 213 | | async updateName (name, updates, signedBy) { |
| 238 | async updateName (name, updates, signer) { |
| 214 | 239 | if (!this._ready) await this.ready() |
| 215 | | if (!name || !signedBy) throw new Error('updateName requires { name, signedBy }') |
| 216 | | const op = { type: OP_UPDATE_NAME, name, updates: updates || {}, signedBy } |
| 240 | if (!name) throw new Error('updateName requires { name }') |
| 241 | const op = buildAuthOp(OP_UPDATE_NAME, { name, updates: updates || {} }, signer) |
| 217 | 242 | await this._base.append(b4a.from(JSON.stringify(op))) |
| 218 | 243 | await this.update() |
| 219 | 244 | } |
| 220 | 245 | |
| 221 | | async release (name, signedBy) { |
| 246 | async release (name, signer) { |
| 222 | 247 | if (!this._ready) await this.ready() |
| 223 | | if (!name || !signedBy) throw new Error('release requires { name, signedBy }') |
| 224 | | const op = { type: OP_RELEASE_NAME, name, signedBy } |
| 248 | if (!name) throw new Error('release requires { name }') |
| 249 | const op = buildAuthOp(OP_RELEASE_NAME, { name }, signer) |
| 225 | 250 | await this._base.append(b4a.from(JSON.stringify(op))) |
| 226 | 251 | await this.update() |
| 227 | 252 | } |
| … | … | @@ -283,3 +308,141 @@ export class NameRegistry { |
| 283 | 308 | return this._base ? this._base.discoveryKey : null |
| 284 | 309 | } |
| 285 | 310 | } |
| 311 | |
| 312 | // ── Signed ops (v2) ──────────────────────────────────────────── |
| 313 | // |
| 314 | // A name is owned by an identity ROOT, but the root key is not available to |
| 315 | // sign with: a linked device holds only its own device key plus a keet proof |
| 316 | // that the root attested it. So the op is signed by the DEVICE and carries the |
| 317 | // chain, exactly as pear-git-v3 authenticates envelopes |
| 318 | // (lib/auth/keet-verifier.js). Verification resolves device -> root and then |
| 319 | // checks that root against the claim. |
| 320 | |
| 321 | /** |
| 322 | * Build an authorised update/release op. |
| 323 | * |
| 324 | * `signer` is the identity acting: `{ identityRoot, devicePubkey, |
| 325 | * deviceSecretKey, chain }` — exactly the fields loadOrCreateIdentity already |
| 326 | * returns. Produces a v2 signed op. |
| 327 | * |
| 328 | * A bare string is accepted as the legacy `signedBy` and produces an UNSIGNED |
| 329 | * op. That path exists only so callers not yet migrated keep working; it will |
| 330 | * stop being authorised when the legacy gate closes. |
| 331 | */ |
| 332 | export function buildAuthOp (type, fields, signer) { |
| 333 | if (typeof signer === 'string') { |
| 334 | if (!signer) throw new Error(type + ' requires a signer') |
| 335 | return { type, ...fields, signedBy: signer } |
| 336 | } |
| 337 | |
| 338 | if (!signer || !signer.identityRoot) { |
| 339 | throw new Error(type + ' requires a signer with { identityRoot, devicePubkey, deviceSecretKey, chain }') |
| 340 | } |
| 341 | const { identityRoot, devicePubkey, deviceSecretKey, chain } = signer |
| 342 | if (!devicePubkey || !deviceSecretKey || !chain) { |
| 343 | // Refuse rather than silently emitting an op that apply will drop — a |
| 344 | // dropped op is indistinguishable from replication lag at the call site. |
| 345 | throw new Error( |
| 346 | type + ': signer is missing ' + |
| 347 | [!devicePubkey && 'devicePubkey', !deviceSecretKey && 'deviceSecretKey', !chain && 'chain'] |
| 348 | .filter(Boolean).join(', ') + |
| 349 | ' (an identity with no attestation chain cannot prove it owns a name)' |
| 350 | ) |
| 351 | } |
| 352 | |
| 353 | return signOp({ |
| 354 | type, |
| 355 | v: OP_VERSION_SIGNED, |
| 356 | ...fields, |
| 357 | signedBy: identityRoot, |
| 358 | devicePubkey, |
| 359 | chain |
| 360 | }, deviceSecretKey) |
| 361 | } |
| 362 | |
| 363 | /** |
| 364 | * Canonical bytes an op's signature covers: every field except `sig`, keys |
| 365 | * sorted, JSON. Same shape as the release registry's canonicalBytes. |
| 366 | */ |
| 367 | export function canonicalOpBytes (op) { |
| 368 | const { sig, ...rest } = op // eslint-disable-line no-unused-vars |
| 369 | const ordered = {} |
| 370 | for (const k of Object.keys(rest).sort()) ordered[k] = rest[k] |
| 371 | return b4a.from(JSON.stringify(ordered)) |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Sign an op with a device secret key, returning a copy carrying `sig`. |
| 376 | */ |
| 377 | export function signOp (op, deviceSecretKey) { |
| 378 | const sig = b4a.alloc(sodium.crypto_sign_BYTES) |
| 379 | const secret = b4a.isBuffer(deviceSecretKey) |
| 380 | ? deviceSecretKey |
| 381 | : b4a.from(deviceSecretKey, 'hex') |
| 382 | sodium.crypto_sign_detached(sig, canonicalOpBytes(op), secret) |
| 383 | return { ...op, sig: b4a.toString(sig, 'hex') } |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Is this op authorised to act on `claim`? |
| 388 | * |
| 389 | * Returns true only when the op is signed by a device that the claim's |
| 390 | * identityRoot has attested. Fail-closed: any malformed field, any failed |
| 391 | * check, any thrown verifier is a refusal. |
| 392 | * |
| 393 | * Deterministic and side-effect free — it is called from apply(), so every |
| 394 | * peer must reach the same verdict from the same bytes. |
| 395 | */ |
| 396 | export function verifySignedOp (op, claim) { |
| 397 | if (!op || !claim || !claim.publisher) return false |
| 398 | const root = claim.publisher.identityRoot |
| 399 | if (!root || typeof op.signedBy !== 'string') return false |
| 400 | |
| 401 | // The op must name the root that actually owns the claim. |
| 402 | if (op.signedBy !== root) return false |
| 403 | |
| 404 | if (typeof op.devicePubkey !== 'string' || |
| 405 | typeof op.chain !== 'string' || |
| 406 | typeof op.sig !== 'string') { |
| 407 | return false |
| 408 | } |
| 409 | |
| 410 | // 1. The chain proves "root R attested device D". |
| 411 | let verified |
| 412 | try { |
| 413 | const proof = b4a.from(op.chain, 'base64') |
| 414 | if (!proof || proof.length === 0) return false |
| 415 | verified = IdentityKey.verify(proof) |
| 416 | } catch { |
| 417 | return false |
| 418 | } |
| 419 | if (!verified || !verified.identityPublicKey || !verified.devicePublicKey) return false |
| 420 | |
| 421 | // 2. That R is the claim's root, and that D is who the op says signed it. |
| 422 | // Without the second check the chain proves nothing about THIS op. |
| 423 | if (b4a.toString(verified.identityPublicKey, 'hex') !== root) return false |
| 424 | if (b4a.toString(verified.devicePublicKey, 'hex') !== op.devicePubkey) return false |
| 425 | |
| 426 | // 3. D actually signed these exact bytes. |
| 427 | try { |
| 428 | const sig = b4a.from(op.sig, 'hex') |
| 429 | if (sig.length !== sodium.crypto_sign_BYTES) return false |
| 430 | const devicePub = b4a.from(op.devicePubkey, 'hex') |
| 431 | if (devicePub.length !== sodium.crypto_sign_PUBLICKEYBYTES) return false |
| 432 | return sodium.crypto_sign_verify_detached(sig, canonicalOpBytes(op), devicePub) |
| 433 | } catch { |
| 434 | return false |
| 435 | } |
| 436 | } |
| 437 | |
| 438 | /** |
| 439 | * Authorisation gate shared by update-name and release-name. |
| 440 | * |
| 441 | * v2 ops must carry a valid device signature + chain. Legacy ops (no `v`) |
| 442 | * keep the original string compare so replaying existing history is unchanged |
| 443 | * — see OP_VERSION_SIGNED for why that path still exists. |
| 444 | */ |
| 445 | export function opAuthorised (op, claim) { |
| 446 | if (op && op.v >= OP_VERSION_SIGNED) return verifySignedOp(op, claim) |
| 447 | return !!(claim && claim.publisher && claim.publisher.identityRoot === op.signedBy) |
| 448 | } |