diff --git a/jest.config.mjs b/jest.config.mjs index 63bb380ae..c74d516bc 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,6 +1,6 @@ import path from 'node:path'; import url from 'node:url'; -import tsconfigJSON from './tsconfig.json' assert { type: "json" }; +import tsconfigJSON from './tsconfig.json' with { type: "json" }; const projectPath = path.dirname(url.fileURLToPath(import.meta.url)); diff --git a/src/PolykeyAgent.ts b/src/PolykeyAgent.ts index 1af1a9fdf..54593a27a 100644 --- a/src/PolykeyAgent.ts +++ b/src/PolykeyAgent.ts @@ -774,6 +774,7 @@ class PolykeyAgent { }); const initialNodesShortlist = initialNodes.slice(0, 3); await this.nodeManager.syncNodeGraph( + options.network, initialNodesShortlist, undefined, false, diff --git a/src/client/callers/index.ts b/src/client/callers/index.ts index 7a00bc4ec..ab5981a93 100644 --- a/src/client/callers/index.ts +++ b/src/client/callers/index.ts @@ -46,6 +46,7 @@ import nodesFind from './nodesFind.js'; import nodesGetAll from './nodesGetAll.js'; import nodesListConnections from './nodesListConnections.js'; import nodesPing from './nodesPing.js'; +import nodesSyncGraph from './nodesSyncGraph.js'; import notificationsInboxClear from './notificationsInboxClear.js'; import notificationsInboxRead from './notificationsInboxRead.js'; import notificationsInboxRemove from './notificationsInboxRemove.js'; @@ -128,6 +129,7 @@ const clientManifest = { nodesGetAll, nodesListConnections, nodesPing, + nodesSyncGraph, notificationsInboxClear, notificationsInboxRead, notificationsInboxRemove, @@ -209,6 +211,7 @@ export { nodesGetAll, nodesListConnections, nodesPing, + nodesSyncGraph, notificationsInboxClear, notificationsInboxRead, notificationsInboxRemove, diff --git a/src/client/callers/nodesSyncGraph.ts b/src/client/callers/nodesSyncGraph.ts new file mode 100644 index 000000000..c868971b5 --- /dev/null +++ b/src/client/callers/nodesSyncGraph.ts @@ -0,0 +1,12 @@ +import type { HandlerTypes } from '@matrixai/rpc'; +import type NodesSyncGraph from '../handlers/NodesSyncGraph.js'; +import { UnaryCaller } from '@matrixai/rpc'; + +type CallerTypes = HandlerTypes; + +const nodesSyncGraph = new UnaryCaller< + CallerTypes['input'], + CallerTypes['output'] +>(); + +export default nodesSyncGraph; diff --git a/src/client/handlers/NodesSyncGraph.ts b/src/client/handlers/NodesSyncGraph.ts new file mode 100644 index 000000000..fa56ca6fc --- /dev/null +++ b/src/client/handlers/NodesSyncGraph.ts @@ -0,0 +1,48 @@ +import type { ContextTimed } from '@matrixai/contexts'; +import type { JSONValue } from '@matrixai/rpc'; +import type NodeManager from '../../nodes/NodeManager.js'; +import type { + ClientRPCRequestParams, + ClientRPCResponseResult, + NodesSyncGraphMessage, +} from '../types.js'; +import type { AgentClientManifest } from '../../nodes/agent/callers/index.js'; +import type { NodeId, NodeAddress } from '../../nodes/types.js'; +import { UnaryHandler } from '@matrixai/rpc'; +import * as nodesUtils from '../../nodes/utils.js'; + +class NodesSyncGraph extends UnaryHandler< + { + nodeManager: NodeManager; + }, + ClientRPCRequestParams, + ClientRPCResponseResult +> { + public handle = async ( + input: ClientRPCRequestParams, + _cancel: (reason?: any) => void, + _meta: Record | undefined, + ctx: ContextTimed, + ): Promise => { + const { + nodeManager, + }: { + nodeManager: NodeManager; + } = this.container; + // Convert the encoded node id to the binary one we expect + const parsedInitialNodes = input.initialNodes.map( + (value) => + [nodesUtils.decodeNodeId(value[0]), value[1]] as [NodeId, NodeAddress], + ); + await nodeManager.syncNodeGraph( + input.network, + parsedInitialNodes, + input.connectionTimeout, + true, + ctx, + ); + return {}; + }; +} + +export default NodesSyncGraph; diff --git a/src/client/handlers/index.ts b/src/client/handlers/index.ts index f4b9a6588..b4fe7467f 100644 --- a/src/client/handlers/index.ts +++ b/src/client/handlers/index.ts @@ -64,6 +64,7 @@ import NodesFind from './NodesFind.js'; import NodesGetAll from './NodesGetAll.js'; import NodesListConnections from './NodesListConnections.js'; import NodesPing from './NodesPing.js'; +import NodesSyncGraph from './NodesSyncGraph.js'; import NotificationsInboxClear from './NotificationsInboxClear.js'; import NotificationsInboxRead from './NotificationsInboxRead.js'; import NotificationsInboxRemove from './NotificationsInboxRemove.js'; @@ -169,6 +170,7 @@ const serverManifest = (container: { nodesGetAll: new NodesGetAll(container), nodesListConnections: new NodesListConnections(container), nodesPing: new NodesPing(container), + nodesSyncGraph: new NodesSyncGraph(container), notificationsInboxClear: new NotificationsInboxClear(container), notificationsInboxRead: new NotificationsInboxRead(container), notificationsInboxRemove: new NotificationsInboxRemove(container), @@ -252,6 +254,7 @@ export { NodesGetAll, NodesListConnections, NodesPing, + NodesSyncGraph, NotificationsInboxClear, NotificationsInboxRead, NotificationsInboxRemove, diff --git a/src/client/types.ts b/src/client/types.ts index 5b6f30837..e4e2cb969 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -151,6 +151,12 @@ type NodeConnectionMessage = NodeAddressMessage & { authenticated: boolean; }; +type NodesSyncGraphMessage = { + network: string; + initialNodes: Array<[NodeIdEncoded, NodeAddress]>; + connectionTimeout?: number; +}; + // Gestalts messages type ActionsListMessage = { @@ -428,6 +434,7 @@ export type { NodeAddressMessage, NodesFindMessage, NodeConnectionMessage, + NodesSyncGraphMessage, ActionsListMessage, SetIdentityActionMessage, SetNodeActionMessage, diff --git a/src/nodes/NodeManager.ts b/src/nodes/NodeManager.ts index 783cfbfb8..8341a7f8d 100644 --- a/src/nodes/NodeManager.ts +++ b/src/nodes/NodeManager.ts @@ -253,6 +253,7 @@ class NodeManager { protected syncNodeGraphHandler = async ( ctx: ContextTimed, _taskInfo: TaskInfo | undefined, + network: string | undefined, initialNodes: Array<[NodeIdEncoded, NodeAddress]>, connectionConnectTimeoutTime: number | undefined, ) => { @@ -299,6 +300,14 @@ class NodeManager { } if (ctx.signal.aborted) return; + if (network != null) { + if ((await this.getClaimNetworkAccess(network)) == null) { + await this.claimNetwork(successfulConnections[0].value.nodeId, network); + } else { + await this.switchNetwork(network); + } + } + // Attempt a findNode operation looking for ourselves await this.findNode( { @@ -1748,7 +1757,7 @@ class NodeManager { /** * This returns the `ClaimNetworkAccess` for the given network. */ - protected async getClaimNetworkAccess( + public async getClaimNetworkAccess( network: string, tran?: DBTransaction, ): Promise | undefined> { @@ -1875,6 +1884,12 @@ class NodeManager { network, targetNodeId, ); + + // Error out if a network access claim already exists + if ((await this.getClaimNetworkAccess(network, tran)) != null) { + throw new Error('TMP network access claim already exists'); + } + const encodedNetworkAuthority = claimsUtils.generateSignedClaim( claimNetworkAuthority.toSigned(), ); @@ -2787,6 +2802,7 @@ class NodeManager { * */ public syncNodeGraph( + network: string | undefined, initialNodes: Array<[NodeId, NodeAddress]>, connectionConnectTimeoutTime?: number, blocking?: boolean, @@ -2795,6 +2811,7 @@ class NodeManager { @startStop.ready(new nodesErrors.ErrorNodeManagerNotRunning()) @decorators.timedCancellable(true) public async syncNodeGraph( + network: string, initialNodes: Array<[NodeId, NodeAddress]>, connectionConnectTimeoutTime: number = this.connectionConnectTimeoutTime, blocking: boolean = false, @@ -2817,6 +2834,7 @@ class NodeManager { await this.syncNodeGraphHandler( ctx, undefined, + network, initialNodesParameter, connectionConnectTimeoutTime, ); @@ -2826,7 +2844,11 @@ class NodeManager { delay: 0, handlerId: this.syncNodeGraphHandlerId, lazy: true, - parameters: [initialNodesParameter, connectionConnectTimeoutTime], + parameters: [ + network, + initialNodesParameter, + connectionConnectTimeoutTime, + ], path: [this.tasksPath, this.syncNodeGraphHandlerId], priority: 0, }); diff --git a/tests/nodes/NodeManager.test.ts b/tests/nodes/NodeManager.test.ts index dd5cc55e4..da8a72676 100644 --- a/tests/nodes/NodeManager.test.ts +++ b/tests/nodes/NodeManager.test.ts @@ -2144,6 +2144,7 @@ describe(`${NodeManager.name}`, () => { const mockedRefreshBucket = jest.spyOn(nodeManager, 'refreshBucket'); await nodeManager.syncNodeGraph( + undefined, [ [ ncmPeers[0].nodeId, @@ -2203,6 +2204,7 @@ describe(`${NodeManager.name}`, () => { const mockedRefreshBucket = jest.spyOn(nodeManager, 'refreshBucket'); await nodeManager.syncNodeGraph( + undefined, [ [ ncmPeers[0].nodeId, @@ -2225,6 +2227,7 @@ describe(`${NodeManager.name}`, () => { }); test('network entry with syncNodeGraph handles failure to resolve hostnames', async () => { const syncP = nodeManager.syncNodeGraph( + undefined, [ [ncmPeers[0].nodeId, ['some.random.host' as Host, 55555 as Port]], [ncmPeers[0].nodeId, [localHost, 55555 as Port]], @@ -2309,6 +2312,7 @@ describe(`${NodeManager.name}`, () => { expect(await nodeGraph.nodesTotal()).toBe(0); await nodeManager.syncNodeGraph( + undefined, [ [ ncmPeers[0].nodeId, @@ -2963,5 +2967,146 @@ describe(`${NodeManager.name}`, () => { node1.nodeManager.claimNetwork(seedNodeId, network), ).rejects.toThrow(claimsErrors.ErrorEmptyStream); }); + test('node should automatically request a claim if it does not exist', async () => { + // Creating network credentials + const networkKeyPair = keysUtils.generateKeyPair(); + const networkNodeId = keysUtils.publicKeyToNodeId( + networkKeyPair.publicKey, + ); + const network = 'public.network.com'; + + // Setting up seed nodes claims + const seedNode = await createPeerNode(); + const [, seedNodeClaimNetworkAuthority] = + await seedNode.nodeManager.createClaimNetworkAuthority( + networkNodeId, + network, + false, + async (claim) => { + claim.signWithPrivateKey(networkKeyPair.privateKey); + return claim; + }, + ); + await seedNode.nodeManager.createSelfSignedClaimNetworkAccess( + seedNodeClaimNetworkAuthority, + ); + const seedNodeId = seedNode.keyRing.getNodeId(); + + // Setting up the new node entering the network + const node1 = await createPeerNode(); + // We intentionally do not claim the network manually + const node1Id = node1.keyRing.getNodeId(); + await allowNodeToJoin(seedNode.gestaltGraph, node1Id); + // Connect to the seed node + await node1.nodeConnectionManager.createConnection( + [seedNodeId], + localHost, + seedNode.nodeConnectionManager.port, + ); + + await node1.nodeManager.syncNodeGraph( + network, + [ + [ + seedNode.keyRing.getNodeId(), + [localHost, seedNode.nodeConnectionManager.port], + ], + ], + 1000, + true, + ); + + // We have now proved that a node can request access to the network from a node with network authority. + // Now we should be able to connect while authenticated to the seed node. + + // Re-initiate authentication + await seedNode.nodeConnectionManager.destroyConnection(node1Id, true); + await node1.nodeConnectionManager.destroyConnection(seedNodeId, true); + await node1.nodeConnectionManager.createConnection( + [seedNodeId], + localHost, + seedNode.nodeConnectionManager.port, + ); + + const networkAccess = + await node1.nodeManager.getClaimNetworkAccess(network); + if (networkAccess == null) fail('network access claim not found'); + claimNetworkAccessUtils.verifyClaimNetworkAccess( + networkNodeId, + node1Id, + network, + networkAccess, + ); + + await node1.nodeManager.withConnF(seedNodeId, undefined, async () => { + // Do nothing + }); + }); + test('node should not request new claim if it already exists', async () => { + // Creating network credentials + const networkKeyPair = keysUtils.generateKeyPair(); + const networkNodeId = keysUtils.publicKeyToNodeId( + networkKeyPair.publicKey, + ); + const network = 'test.network.com'; + + // Setting up seed nodes claims + const seedNode = await createPeerNode(); + const [, seedNodeClaimNetworkAuthority] = + await seedNode.nodeManager.createClaimNetworkAuthority( + networkNodeId, + network, + true, + async (claim) => { + claim.signWithPrivateKey(networkKeyPair.privateKey); + return claim; + }, + ); + await seedNode.nodeManager.createSelfSignedClaimNetworkAccess( + seedNodeClaimNetworkAuthority, + ); + const seedNodeId = seedNode.keyRing.getNodeId(); + + // Setting up the new node entering the network + const node1 = await createPeerNode(); + + const node1Id = node1.keyRing.getNodeId(); + await allowNodeToJoin(seedNode.gestaltGraph, node1Id); + + // Connect to the seednode + await node1.nodeConnectionManager.createConnection( + [seedNodeId], + localHost, + seedNode.nodeConnectionManager.port, + ); + + // Create a network access claim + await node1.nodeManager.claimNetwork(seedNodeId, network); + + // Re-initiate authentication + await seedNode.nodeConnectionManager.destroyConnection(node1Id, true); + await node1.nodeConnectionManager.destroyConnection(seedNodeId, true); + await node1.nodeConnectionManager.createConnection( + [seedNodeId], + localHost, + seedNode.nodeConnectionManager.port, + ); + + // Check the claim once we have re-authenticated + const token1 = await node1.nodeManager.getClaimNetworkAccess(network); + if (token1 == null) fail('network access claim not found'); + const token1Id = token1.payload.jti; + + // Try claiming again + await expect( + node1.nodeManager.claimNetwork(seedNodeId, network), + ).toReject(); + + // The token should not have changed + const token2 = await node1.nodeManager.getClaimNetworkAccess(network); + if (token2 == null) fail('network access claim not found'); + const token2Id = token2.payload.jti; + expect(token1Id).toBe(token2Id); + }); }); });