aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-client/src/keyring.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-30 21:04:39 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-30 21:04:39 +0200
commit0378e8e0912a1a7e6cea4424e69d524e7afecbf8 (patch)
tree4ae94e32d6638b4c2cc1ae4f74cbe5d00c940636 /packages/meshbay-client/src/keyring.js
parent0ed56d3a1b4f71cf622d3e27edc87a15ef33c185 (diff)
downloadmeshbay-0378e8e0912a1a7e6cea4424e69d524e7afecbf8.tar.gz
fix: an identity signs a named kind, and a device approval answers a request
The desktop main process builds every transcript itself from fields (transcripts.js) and signs no raw bytes; the page's identity has the same contract (crypto.js transcriptFor). The keyring seals no bundle while browser access is off. On the node, device_add must redeem a pending request filed by the same keys, and device_revoke is signed under its own prefix (meshbay:device_revoke:v1), so a retirement signature admits nothing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-client/src/keyring.js')
-rw-r--r--packages/meshbay-client/src/keyring.js20
1 files changed, 17 insertions, 3 deletions
diff --git a/packages/meshbay-client/src/keyring.js b/packages/meshbay-client/src/keyring.js
index 1df2860..4fb6eac 100644
--- a/packages/meshbay-client/src/keyring.js
+++ b/packages/meshbay-client/src/keyring.js
@@ -19,6 +19,7 @@
'use strict';
const crypto = require('node:crypto');
+const { transcriptFor } = require('./transcripts.js');
// keyderive.js: the same numbers, or no bundle opens across the two.
const ARGON2 = { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 };
@@ -110,6 +111,14 @@ function createKeyring({ load, save, argon2 }) {
pkEdB64: b64(rawPublic(privateFrom(id.ed))),
pkXB64: b64(rawPublic(privateFrom(id.x))),
});
+ // A bundle is a copy of an identity for a browser to open with the
+ // passphrase. With browser access off none may exist, whatever the page asks:
+ // the page is where hostile content is parsed, and one bundle left on a node
+ // is all a passphrase-only sign-in on the web needs.
+ const accessOn = (userId) => state().access[userId] !== false;
+ const needAccess = (userId) => {
+ if (!accessOn(userId)) throw new Error('Refused: browser access is off for this account');
+ };
const keep = (userId, nodePk, id) => {
const s = state();
s.identities[userId] = s.identities[userId] || {};
@@ -195,6 +204,7 @@ function createKeyring({ load, save, argon2 }) {
/** The identity sealed for its node, under `M` (or the pending one). */
sealBundle(userId, nodePk, { pending: usePending = false } = {}) {
+ needAccess(userId);
const { m, v } = master(userId, { usePending });
const bundle = seal(stored(userId, nodePk), hkdf(m, `meshbay:bundle:v3|node|${nodePk}`),
userId, nodePk, v);
@@ -202,6 +212,7 @@ function createKeyring({ load, save, argon2 }) {
},
/** The recovery copy: sealed under the recovery key, owing nothing to `M`. */
sealRecovery(userId, nodePk, mnemonic, username) {
+ needAccess(userId);
const rk = hkdf(fromMnemonic(mnemonic), `meshbay:recovery:v1:${username}`);
return seal(stored(userId, nodePk), rk, userId, nodePk, 0);
},
@@ -212,8 +223,11 @@ function createKeyring({ load, save, argon2 }) {
},
currentFingerprint: (userId) => fingerprint(master(userId).m),
- sign(userId, nodePk, bytesB64) {
- return b64(crypto.sign(null, unb64(bytesB64), privateFrom(stored(userId, nodePk).ed)));
+ /** Sign what `kind` names, built from `fields` (transcripts.js). */
+ signAs(userId, nodePk, kind, fields) {
+ const id = stored(userId, nodePk);
+ const transcript = transcriptFor(kind, fields, { userId, nodePk, ...publicOf(id) });
+ return b64(crypto.sign(null, transcript, privateFrom(id.ed)));
},
shared(userId, nodePk, peerPkB64) {
const publicKey = crypto.createPublicKey({
@@ -228,7 +242,7 @@ function createKeyring({ load, save, argon2 }) {
// Whether this account leaves bundles on nodes for a browser to open. An
// account created here says no until the person says yes (natively, in
// main.js); any other account keeps what it always had.
- browserAccess: (userId) => state().access[userId] !== false,
+ browserAccess: accessOn,
setBrowserAccess(userId, on) {
const s = state();
s.access[userId] = Boolean(on);