aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
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-hub/src
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-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js54
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js18
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js24
5 files changed, 99 insertions, 32 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 27e97d3..c239cc8 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -451,6 +451,7 @@ const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1');
const DEVICE_REQ_PREFIX = new TextEncoder().encode('meshbay:device_req:v1');
const DEVICE_ADD_PREFIX = new TextEncoder().encode('meshbay:device_add:v1');
const DEVICE_HELLO_PREFIX = new TextEncoder().encode('meshbay:device_hello:v1');
+const DEVICE_REVOKE_PREFIX = new TextEncoder().encode('meshbay:device_revoke:v1');
function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) {
const enc = new TextEncoder();
@@ -503,6 +504,22 @@ function deviceAddTranscript(nodePkB64, userId, pkEdB64, pkXB64, nonceNode, ts)
}
/**
+ * Retiring one of the account's devices. A prefix of its own: were it the
+ * admission transcript, a signature given to retire a key would admit it.
+ */
+function deviceRevokeTranscript(nodePkB64, userId, pkEdB64, nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64), enc.encode(userId), enc.encode(pkEdB64),
+ nonceNode, enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(DEVICE_REVOKE_PREFIX.length + body.length);
+ out.set(DEVICE_REVOKE_PREFIX, 0);
+ out.set(body, DEVICE_REVOKE_PREFIX.length);
+ return out;
+}
+
+/**
* "Which of this account's devices am I?", mirroring
* `meshbay_common/device.py:device_hello_transcript`.
*
@@ -560,6 +577,42 @@ function constantTimeEqual(a, b) {
return diff === 0;
}
+/**
+ * What an identity signs, by kind. The only way anything here gets signed with
+ * an identity: a caller names what it is signing and gives the fields, and the
+ * bytes are built from them — with the identity's own public keys wherever a
+ * transcript names them. The desktop application builds the same bytes in its
+ * main process (meshbay-client/src/transcripts.js) and signs nothing else,
+ * which is what makes a signature from it mean what its kind says.
+ *
+ * Bytes cross as base64: `nonceNode`, `nonce`, `ct`.
+ */
+function transcriptFor(kind, f, own) {
+ const nonceNode = () => b64decode(f.nonceNode);
+ switch (kind) {
+ case 'join':
+ return joinTranscript(f.nodePk, f.groupId || '', f.userId, own.pkEdB64, own.pkXB64,
+ nonceNode(), f.ts);
+ case 'device_hello':
+ return deviceHelloTranscript(f.nodePk, f.groupId || '', f.userId, own.pkEdB64,
+ nonceNode(), f.ts);
+ case 'device_request':
+ return deviceRequestTranscript(f.nodePk, f.userId, own.pkEdB64, own.pkXB64,
+ f.codeHash, nonceNode(), f.ts);
+ case 'device_add':
+ return deviceAddTranscript(f.nodePk, f.userId, f.pkEd, f.pkX, nonceNode(), f.ts);
+ case 'device_revoke':
+ return deviceRevokeTranscript(f.nodePk, f.userId, f.pkEd, nonceNode(), f.ts);
+ case 'chat':
+ return chatSigningTranscript(f.groupId || '', f.epoch, b64decode(own.pkEdB64),
+ b64decode(f.nonce), b64decode(f.ct));
+ case 'admin':
+ return adminTranscript(f.op, f.nodePk, f.groupId || '', f.subject, f.nonce, f.ts);
+ default:
+ throw new Error(`Refused: nothing is signed as "${kind}"`);
+ }
+}
+
/** Verify the node's Ed25519 signature over the handshake transcript (C3). */
async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
const raw = b64decode(nodePkB64);
@@ -576,6 +629,7 @@ window.MeshBayCrypto = {
inviteCreateSubject, tmdbConfigSubject, handshakeTranscript, handshakeProof, webrtcBinding,
challengeTranscript, joinTranscript, verifyNodeSignature, constantTimeEqual,
deviceRequestTranscript, deviceAddTranscript, deviceHelloTranscript,
+ deviceRevokeTranscript, transcriptFor,
deviceCodeHash,
sealChat, openChat, chatSigningTranscript, verifyChatSignature,
normalizeCode,
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
index d5226fc..1a5a4c0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-admin.js
@@ -32,9 +32,10 @@ extendTransport(class {
const ts = Math.floor(Date.now() / 1000);
// group_id is empty: operator authority is node-wide, not per group.
- const transcript = C.joinTranscript(
- this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('join', {
+ nodePk: this.nodePk, groupId: '', userId,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'join_request',
@@ -107,11 +108,12 @@ extendTransport(class {
}
if (!signFn) throw new Error('Admin challenge received but no signing key available');
- const transcript = window.MeshBayCrypto.adminTranscript(
- challenge.op, challenge.node_pk, challenge.group_id,
- challenge.subject, challenge.nonce, challenge.ts);
-
- const signature = await signFn(transcript);
+ // The fields, not the bytes: whatever holds the key builds the transcript
+ // from them (crypto.js, transcriptFor).
+ const signature = await signFn({
+ op: challenge.op, nodePk: challenge.node_pk, groupId: challenge.group_id,
+ subject: challenge.subject, nonce: challenge.nonce, ts: challenge.ts,
+ });
console.log('[MeshBay] _authorizeAdminOp: signed', challenge.op, 'op_id=', challenge.op_id,
'— sending admin_response');
const ack = await this._sendAndWait({
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
index f0604ce..e49eb4c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-chat.js
@@ -172,8 +172,11 @@ extendTransport(class {
const { nonce, ct } = await C.sealChat(
epochKey, gid, epoch, this.devicePk, plaintext);
const device = C.b64decode(this.devicePk);
- const sig = C.b64decode(await this._identity.sign(
- C.chatSigningTranscript(gid, epoch, device, nonce, ct)));
+ // The transcript names the signing device as this identity's own key,
+ // which is what `devicePk` is once the node has been told (device_hello).
+ const sig = C.b64decode(await this._identity.signAs('chat', {
+ groupId: gid, epoch, nonce: C.b64encode(nonce), ct: C.b64encode(ct),
+ }));
const msg = await this._sendAndWait({
type: 'chat_msg',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
index 1cb248a..1c6fc78 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
@@ -105,9 +105,10 @@ extendTransport(class {
const { pkEdB64, pkXB64 } = this._identity;
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.joinTranscript(
- this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('join', {
+ nodePk: this.nodePk, groupId: groupId || '', userId,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'join_request',
@@ -171,9 +172,10 @@ extendTransport(class {
const codeHash = await C.deviceCodeHash(
C.normalizeCode(code), pkEdB64, pkXB64);
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceRequestTranscript(
- this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_request', {
+ nodePk: this.nodePk, userId, codeHash,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_add_request', v: '0.1',
@@ -225,9 +227,10 @@ extendTransport(class {
async _countersign(userId, codeHash, pkEdB64, pkXB64) {
const C = window.MeshBayCrypto;
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceAddTranscript(
- this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_add', {
+ nodePk: this.nodePk, userId, pkEd: pkEdB64, pkX: pkXB64,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_add', v: '0.1',
pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
@@ -246,9 +249,10 @@ extendTransport(class {
async revokeDevice(userId, pkEdB64, pkXB64) {
const C = window.MeshBayCrypto;
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceAddTranscript(
- this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_revoke', {
+ nodePk: this.nodePk, userId, pkEd: pkEdB64,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig,
});
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 17a8e5d..9b86921 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -52,10 +52,13 @@ async function _pkEdFromSk(skPkcs8B64) {
*/
async function _identityFromKeys(skEdB64, skXB64) {
const skXRaw = Uint8Array.from(atob(skXB64), c => c.charCodeAt(0));
+ const own = { pkEdB64: await _pkEdFromSk(skEdB64), pkXB64: await _pkFromSk(skXB64) };
return {
- pkEdB64: await _pkEdFromSk(skEdB64),
- pkXB64: await _pkFromSk(skXB64),
- sign: (bytes) => window.MeshBayKeys.signBytes(skEdB64, bytes),
+ ...own,
+ // By kind and fields, never over bytes a caller chose (crypto.js,
+ // transcriptFor) — the same contract the desktop's main process keeps.
+ signAs: (kind, fields) => window.MeshBayKeys.signBytes(
+ skEdB64, window.MeshBayCrypto.transcriptFor(kind, fields, own)),
async shared(peerPkRaw) {
const sk = await crypto.subtle.importKey(
'pkcs8', skXRaw, { name: 'X25519' }, false, ['deriveBits']);
@@ -77,7 +80,8 @@ function _nativeIdentityHandle(keys, userId, nodePk, pub) {
pkXB64: pub.pkXB64,
sealedWith: pub.sealedWith || null,
native: true,
- sign: (bytes) => keys.sign(userId, nodePk, b64(bytes)),
+ // The main process builds the bytes from the kind and the fields.
+ signAs: (kind, fields) => keys.sign(userId, nodePk, kind, fields),
async shared(peerPkRaw) {
const out = await keys.shared(userId, nodePk, b64(peerPkRaw));
return Uint8Array.from(atob(out), c => c.charCodeAt(0)).buffer;
@@ -672,10 +676,10 @@ class MeshBayTransport {
set onNeedToken(fn) { this._onNeedToken = fn; }
get identity() { return this._identity; }
- /** Signs with this node's identity, or null when there is none yet. */
+ /** Signs an admin operation with this node's identity, or null when there is none yet. */
get signFn() {
const id = this._identity;
- return id ? (transcript) => id.sign(transcript) : null;
+ return id ? (fields) => id.signAs('admin', fields) : null;
}
/** Set on a first join: the identity created for this node, still to be left with it. */
@@ -1343,10 +1347,10 @@ class MeshBayTransport {
// substitution this mechanism exists to close.
const pkEdB64 = this._identity.pkEdB64;
const ts = Math.floor(Date.now() / 1000);
- const transcript = C.deviceHelloTranscript(
- this.nodePk, this._groupId || '', this._userId, pkEdB64,
- this._nonceNode, ts);
- const sig = await this._identity.sign(transcript);
+ const sig = await this._identity.signAs('device_hello', {
+ nodePk: this.nodePk, groupId: this._groupId || '', userId: this._userId,
+ nonceNode: C.b64encode(this._nonceNode), ts,
+ });
const resp = await this._sendAndWait({
type: 'device_hello', v: '2.0', pk_ed25519: pkEdB64, ts, sig,