blob: 449a7d9f31b860d4eab5537b253941f2d68750d0 [file]
/*
* 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));
}