| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License"); you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| |
| import { |
| createHash, |
| createPrivateKey, |
| createPublicKey, |
| generateKeyPairSync, |
| randomBytes, |
| sign, |
| timingSafeEqual, |
| verify, |
| } from 'node:crypto'; |
| import { |
| decodePeerMeshInvitation as decodePeerMeshInvitationWire, |
| type PeerMeshInvitationV1, |
| } from '../protocol/peer-mesh.js'; |
| import { PEER_MESH_MEMBER_ADVERTISEMENT_MAX_BYTES, PEER_MESH_MAX_MEMBERS } from './limits.js'; |
| import { canonicalPeerMeshDisplayName } from './display-name.js'; |
| |
| export { |
| PEER_MESH_MAX_INVITATION_RECORDS, |
| PEER_MESH_MAX_MEMBERS, |
| PEER_MESH_MAX_MESHES, |
| PEER_MESH_MAX_PENDING_INVITATIONS, |
| PEER_MESH_MAX_TRANSIT_ADDRESSES_PER_RELAY, |
| PEER_MESH_MAX_TRANSIT_RELAY_ADDRESSES, |
| } from './limits.js'; |
| |
| export interface PeerMeshRosterV1 { |
| readonly version: 1; |
| readonly meshId: string; |
| readonly authorityPeerId: string; |
| readonly revision: number; |
| readonly members: readonly string[]; |
| readonly closed: boolean; |
| readonly displayName?: string; |
| } |
| |
| export interface SignedPeerMeshRosterV1 { |
| readonly roster: PeerMeshRosterV1; |
| readonly authorityPublicKey: string; |
| readonly signature: string; |
| } |
| |
| export interface PeerMeshAuthorityKeyPair { |
| readonly publicKey: string; |
| readonly privateKey: string; |
| } |
| |
| export interface PeerMeshMemberAdvertisementV1 { |
| readonly version: 1; |
| readonly meshId: string; |
| readonly peerId: string; |
| readonly revision: number; |
| readonly endpointKind?: 'client' | 'host'; |
| readonly displayName?: string; |
| readonly offersTransit: boolean; |
| } |
| |
| export interface SignedPeerMeshMemberAdvertisementV1 { |
| readonly advertisement: PeerMeshMemberAdvertisementV1; |
| readonly publicKey: string; |
| readonly signature: string; |
| } |
| |
| export function generatePeerMeshAuthorityKeyPair(): PeerMeshAuthorityKeyPair { |
| const { publicKey, privateKey } = generateKeyPairSync('ed25519'); |
| return Object.freeze({ |
| publicKey: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'), |
| privateKey: privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url'), |
| }); |
| } |
| |
| export function peerMeshId(authorityPublicKey: string): string { |
| decodePublicKey(authorityPublicKey); |
| return `mesh_${createHash('sha256').update(authorityPublicKey).digest('base64url')}`; |
| } |
| |
| export function signPeerMeshRoster( |
| roster: PeerMeshRosterV1, |
| keys: PeerMeshAuthorityKeyPair, |
| ): SignedPeerMeshRosterV1 { |
| const canonical = canonicalPeerMeshRoster(roster); |
| if (canonical.meshId !== peerMeshId(keys.publicKey)) { |
| throw new Error('Peer Mesh roster does not belong to the authority key'); |
| } |
| const privateKey = createPrivateKey({ |
| key: Buffer.from(keys.privateKey, 'base64url'), |
| format: 'der', |
| type: 'pkcs8', |
| }); |
| if (privateKey.asymmetricKeyType !== 'ed25519') { |
| throw new Error('Peer Mesh authority key must be Ed25519'); |
| } |
| return Object.freeze({ |
| roster: canonical, |
| authorityPublicKey: keys.publicKey, |
| signature: sign(null, encodeRoster(canonical), privateKey).toString('base64url'), |
| }); |
| } |
| |
| export function validatePeerMeshAuthorityKeyPair(keys: PeerMeshAuthorityKeyPair): void { |
| let privateKey; |
| try { |
| privateKey = createPrivateKey({ |
| key: decodeCanonicalBase64Url(keys.privateKey, 'authority private key'), |
| format: 'der', |
| type: 'pkcs8', |
| }); |
| } catch (error) { |
| throw new Error('Invalid Peer Mesh authority private key', { |
| cause: error, |
| }); |
| } |
| if (privateKey.asymmetricKeyType !== 'ed25519') { |
| throw new Error('Peer Mesh authority key must be Ed25519'); |
| } |
| const derived = createPublicKey(privateKey).export({ |
| format: 'der', |
| type: 'spki', |
| }); |
| if (derived.toString('base64url') !== keys.publicKey) { |
| throw new Error('Peer Mesh authority private key does not match its public key'); |
| } |
| } |
| |
| export function decodeSignedPeerMeshRoster(value: unknown): SignedPeerMeshRosterV1 { |
| const record = exactObject(value, 'signed Peer Mesh roster', [ |
| 'roster', |
| 'authorityPublicKey', |
| 'signature', |
| ]); |
| const authorityPublicKey = string(record.authorityPublicKey, 'authorityPublicKey', 256); |
| const roster = canonicalPeerMeshRoster(record.roster); |
| if (roster.meshId !== peerMeshId(authorityPublicKey)) { |
| throw new Error('Peer Mesh roster has the wrong authority'); |
| } |
| const signature = string(record.signature, 'signature', 128); |
| const signatureBytes = decodeCanonicalBase64Url(signature, 'roster signature'); |
| if (signatureBytes.length !== 64) throw new Error('Invalid Peer Mesh roster signature'); |
| const verified = verify( |
| null, |
| encodeRoster(roster), |
| decodePublicKey(authorityPublicKey), |
| signatureBytes, |
| ); |
| if (!verified) throw new Error('Peer Mesh roster signature is invalid'); |
| return Object.freeze({ roster, authorityPublicKey, signature }); |
| } |
| |
| export function createPeerMeshInvitationSecret(): string { |
| return randomBytes(32).toString('base64url'); |
| } |
| |
| export function peerMeshInvitationSecretDigest(secret: string): string { |
| return createHash('sha256').update(decodeSecret(secret)).digest('base64url'); |
| } |
| |
| export function matchesPeerMeshInvitationSecret(secret: string, expectedDigest: string): boolean { |
| const actual = Buffer.from(peerMeshInvitationSecretDigest(secret), 'base64url'); |
| const expected = Buffer.from(expectedDigest, 'base64url'); |
| return actual.length === expected.length && timingSafeEqual(actual, expected); |
| } |
| |
| export function validatePeerMeshInvitation(value: unknown): PeerMeshInvitationV1 { |
| const invitation = decodePeerMeshInvitationWire(value); |
| const authorityPublicKey = invitation.authorityPublicKey; |
| const meshId = invitation.meshId; |
| if (meshId !== peerMeshId(authorityPublicKey)) { |
| throw new Error('Peer Mesh invitation has the wrong authority'); |
| } |
| return Object.freeze({ |
| ...invitation, |
| secret: validateSecret(invitation.secret), |
| }); |
| } |
| |
| export function canonicalPeerMeshRoster(value: unknown): PeerMeshRosterV1 { |
| const keys = ['version', 'meshId', 'authorityPeerId', 'revision', 'members', 'closed']; |
| if (value && typeof value === 'object' && !Array.isArray(value)) { |
| if (Object.hasOwn(value, 'displayName')) keys.push('displayName'); |
| } |
| const record = exactObject(value, 'Peer Mesh roster', keys); |
| if (record.version !== 1) throw new Error('Unsupported Peer Mesh roster version'); |
| const members = stringArray(record.members, 'members', PEER_MESH_MAX_MEMBERS, 256) |
| .map((member) => token(member, 'member', 256)) |
| .sort(); |
| if (members.length === 0 || new Set(members).size !== members.length) { |
| throw new Error('Peer Mesh roster members must be unique and non-empty'); |
| } |
| const authorityPeerId = token(record.authorityPeerId, 'authorityPeerId', 256); |
| if (!members.includes(authorityPeerId)) { |
| throw new Error('Peer Mesh authority must be present in its roster'); |
| } |
| if (typeof record.closed !== 'boolean') throw new Error('Invalid Peer Mesh roster closed'); |
| return Object.freeze({ |
| version: 1, |
| meshId: string(record.meshId, 'meshId', 128), |
| authorityPeerId, |
| revision: integer(record.revision, 'revision', 1), |
| members: Object.freeze(members), |
| closed: record.closed, |
| ...(record.displayName === undefined |
| ? {} |
| : { displayName: canonicalPeerMeshDisplayName(record.displayName) }), |
| }); |
| } |
| |
| export function canonicalPeerMeshMemberAdvertisement( |
| value: unknown, |
| ): PeerMeshMemberAdvertisementV1 { |
| const keys = ['version', 'meshId', 'peerId', 'revision', 'offersTransit']; |
| if (value && typeof value === 'object' && !Array.isArray(value)) { |
| if (Object.hasOwn(value, 'endpointKind')) keys.push('endpointKind'); |
| if (Object.hasOwn(value, 'displayName')) keys.push('displayName'); |
| } |
| const record = exactObject(value, 'Peer Mesh member advertisement', keys); |
| if (record.version !== 1) { |
| throw new Error('Unsupported Peer Mesh member advertisement version'); |
| } |
| if ( |
| record.endpointKind !== undefined && |
| record.endpointKind !== 'client' && |
| record.endpointKind !== 'host' |
| ) { |
| throw new Error('Invalid Peer Mesh endpoint kind'); |
| } |
| if (typeof record.offersTransit !== 'boolean') { |
| throw new Error('Invalid Peer Mesh transit advertisement'); |
| } |
| const advertisement = Object.freeze({ |
| version: 1 as const, |
| meshId: string(record.meshId, 'meshId', 128), |
| peerId: token(record.peerId, 'peerId', 256), |
| revision: integer(record.revision, 'advertisement revision', 1), |
| ...(record.endpointKind === undefined ? {} : { endpointKind: record.endpointKind }), |
| ...(record.displayName === undefined |
| ? {} |
| : { displayName: canonicalPeerMeshDisplayName(record.displayName) }), |
| offersTransit: record.offersTransit, |
| }); |
| if ( |
| peerMeshMemberAdvertisementSigningBytes(advertisement).byteLength > |
| PEER_MESH_MEMBER_ADVERTISEMENT_MAX_BYTES |
| ) { |
| throw new Error('Peer Mesh member advertisement is too large'); |
| } |
| return advertisement; |
| } |
| |
| export function decodeSignedPeerMeshMemberAdvertisement( |
| value: unknown, |
| ): SignedPeerMeshMemberAdvertisementV1 { |
| const record = exactObject(value, 'signed Peer Mesh member advertisement', [ |
| 'advertisement', |
| 'publicKey', |
| 'signature', |
| ]); |
| const publicKey = canonicalProof(record.publicKey, 'advertisement public key', 256); |
| const signature = canonicalProof(record.signature, 'advertisement signature', 256); |
| return Object.freeze({ |
| advertisement: canonicalPeerMeshMemberAdvertisement(record.advertisement), |
| publicKey, |
| signature, |
| }); |
| } |
| |
| export function peerMeshMemberAdvertisementSigningBytes( |
| advertisement: PeerMeshMemberAdvertisementV1, |
| ): Buffer { |
| return Buffer.from( |
| `maka.peer-mesh.member-advertisement.v1\n${JSON.stringify({ |
| ...(advertisement.displayName ? { displayName: advertisement.displayName } : {}), |
| ...(advertisement.endpointKind ? { endpointKind: advertisement.endpointKind } : {}), |
| meshId: advertisement.meshId, |
| offersTransit: advertisement.offersTransit, |
| peerId: advertisement.peerId, |
| revision: advertisement.revision, |
| version: advertisement.version, |
| })}`, |
| ); |
| } |
| |
| function encodeRoster(roster: PeerMeshRosterV1): Buffer { |
| return Buffer.from( |
| `maka.peer-mesh.roster.v1\n${JSON.stringify({ |
| authorityPeerId: roster.authorityPeerId, |
| closed: roster.closed, |
| ...(roster.displayName ? { displayName: roster.displayName } : {}), |
| members: roster.members, |
| meshId: roster.meshId, |
| revision: roster.revision, |
| version: roster.version, |
| })}`, |
| ); |
| } |
| |
| function decodePublicKey(encoded: string) { |
| try { |
| const key = createPublicKey({ |
| key: decodeCanonicalBase64Url(encoded, 'authority public key'), |
| format: 'der', |
| type: 'spki', |
| }); |
| if (key.asymmetricKeyType !== 'ed25519') { |
| throw new Error('Peer Mesh authority key must be Ed25519'); |
| } |
| return key; |
| } catch (error) { |
| throw new Error('Invalid Peer Mesh authority public key', { cause: error }); |
| } |
| } |
| |
| function validateSecret(value: unknown): string { |
| const secret = string(value, 'secret', 64); |
| decodeSecret(secret); |
| return secret; |
| } |
| |
| function decodeSecret(secret: string): Buffer { |
| const bytes = decodeCanonicalBase64Url(secret, 'invitation secret'); |
| if (bytes.length !== 32) { |
| throw new Error('Invalid Peer Mesh invitation secret'); |
| } |
| return bytes; |
| } |
| |
| function decodeCanonicalBase64Url(value: string, label: string): Buffer { |
| const bytes = Buffer.from(value, 'base64url'); |
| if (bytes.toString('base64url') !== value) { |
| throw new Error(`Invalid Peer Mesh ${label}`); |
| } |
| return bytes; |
| } |
| |
| function canonicalProof(value: unknown, label: string, maxBytes: number): string { |
| const encoded = string(value, label, Math.ceil((maxBytes * 4) / 3)); |
| const bytes = decodeCanonicalBase64Url(encoded, label); |
| if (bytes.length === 0 || bytes.length > maxBytes) { |
| throw new Error(`Invalid Peer Mesh ${label}`); |
| } |
| return encoded; |
| } |
| |
| function object(value: unknown, label: string): Record<string, unknown> { |
| if (!value || typeof value !== 'object' || Array.isArray(value)) { |
| throw new Error(`Invalid ${label}`); |
| } |
| return value as Record<string, unknown>; |
| } |
| |
| function exactObject( |
| value: unknown, |
| label: string, |
| keys: readonly string[], |
| ): Record<string, unknown> { |
| const record = object(value, label); |
| if ( |
| Object.keys(record).length !== keys.length || |
| keys.some((key) => !Object.hasOwn(record, key)) |
| ) { |
| throw new Error(`Invalid ${label}`); |
| } |
| return record; |
| } |
| |
| function string(value: unknown, label: string, max: number): string { |
| if (typeof value !== 'string' || value.length === 0 || value.length > max) { |
| throw new Error(`Invalid Peer Mesh ${label}`); |
| } |
| return value; |
| } |
| |
| function token(value: unknown, label: string, max: number): string { |
| const result = string(value, label, max); |
| if (/\s|[\u0000-\u001f\u007f]/u.test(result)) { |
| throw new Error(`Invalid Peer Mesh ${label}`); |
| } |
| return result; |
| } |
| |
| function integer(value: unknown, label: string, minimum: number): number { |
| if (!Number.isSafeInteger(value) || (value as number) < minimum) { |
| throw new Error(`Invalid Peer Mesh ${label}`); |
| } |
| return value as number; |
| } |
| |
| function stringArray(value: unknown, label: string, maxItems: number, maxLength: number): string[] { |
| if (!Array.isArray(value) || value.length > maxItems) { |
| throw new Error(`Invalid Peer Mesh ${label}`); |
| } |
| return value.map((item) => string(item, label, maxLength)); |
| } |