@mongodb-js/signing-utils 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/LICENSE +557 -0
  2. package/dist/.esm-wrapper.mjs +4 -0
  3. package/dist/index.d.ts +3 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +18 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/signing-clients/index.d.ts +28 -0
  8. package/dist/signing-clients/index.d.ts.map +1 -0
  9. package/dist/signing-clients/index.js +59 -0
  10. package/dist/signing-clients/index.js.map +1 -0
  11. package/dist/signing-clients/local-signing-client.d.ts +7 -0
  12. package/dist/signing-clients/local-signing-client.d.ts.map +1 -0
  13. package/dist/signing-clients/local-signing-client.js +38 -0
  14. package/dist/signing-clients/local-signing-client.js.map +1 -0
  15. package/dist/signing-clients/remote-signing-client.d.ts +16 -0
  16. package/dist/signing-clients/remote-signing-client.d.ts.map +1 -0
  17. package/dist/signing-clients/remote-signing-client.js +93 -0
  18. package/dist/signing-clients/remote-signing-client.js.map +1 -0
  19. package/dist/ssh-client.d.ts +14 -0
  20. package/dist/ssh-client.d.ts.map +1 -0
  21. package/dist/ssh-client.js +80 -0
  22. package/dist/ssh-client.js.map +1 -0
  23. package/dist/utils.d.ts +9 -0
  24. package/dist/utils.d.ts.map +1 -0
  25. package/dist/utils.js +19 -0
  26. package/dist/utils.js.map +1 -0
  27. package/package.json +74 -0
  28. package/src/garasign.sh +62 -0
  29. package/src/index.ts +27 -0
  30. package/src/signing-clients/index.ts +82 -0
  31. package/src/signing-clients/local-signing-client.spec.ts +44 -0
  32. package/src/signing-clients/local-signing-client.ts +44 -0
  33. package/src/signing-clients/remote-signing-client.spec.ts +96 -0
  34. package/src/signing-clients/remote-signing-client.ts +119 -0
  35. package/src/ssh-client.spec.ts +186 -0
  36. package/src/ssh-client.ts +92 -0
  37. package/src/utils.ts +21 -0
@@ -0,0 +1,44 @@
1
+ import fs from 'fs/promises';
2
+ import { LocalSigningClient } from './local-signing-client';
3
+ import { expect } from 'chai';
4
+ import { writeFileSync } from 'fs';
5
+
6
+ describe('LocalSigningClient', function () {
7
+ const signingScript = './garasign-temp.sh';
8
+ const fileToSign = 'file-to-sign.txt';
9
+ const fileNameAfterGpgSigning = 'file-to-sign.txt.sig';
10
+
11
+ beforeEach(async function () {
12
+ writeFileSync(
13
+ signingScript,
14
+ `
15
+ #!/bin/bash
16
+ echo "Signing script called with arguments: $@"
17
+ echo "signed content" > ${fileNameAfterGpgSigning}
18
+ `
19
+ );
20
+ await fs.writeFile(fileToSign, 'original content');
21
+ });
22
+
23
+ afterEach(async function () {
24
+ await Promise.allSettled(
25
+ [signingScript, fileToSign, fileNameAfterGpgSigning].map((file) =>
26
+ fs.rm(file)
27
+ )
28
+ );
29
+ });
30
+
31
+ it('executes the signing script correctly', async function () {
32
+ const localSigningClient = new LocalSigningClient({
33
+ signingScript: signingScript,
34
+ signingMethod: 'gpg',
35
+ });
36
+
37
+ await localSigningClient.sign(fileToSign);
38
+
39
+ const signedFile = (
40
+ await fs.readFile(fileNameAfterGpgSigning, 'utf-8')
41
+ ).trim();
42
+ expect(signedFile).to.equal('signed content');
43
+ });
44
+ });
@@ -0,0 +1,44 @@
1
+ import path from 'path';
2
+ import { spawnSync } from 'child_process';
3
+ import { debug, getEnv } from '../utils';
4
+ import type { SigningClient, SigningClientOptions } from '.';
5
+
6
+ const localClientDebug = debug.extend('LocalSigningClient');
7
+
8
+ /**
9
+ * The local signing client signs a file locally (as opposed to over an SSH connection).
10
+ *
11
+ * The LocalSigningClient takes the directory of the file to sign and uses this as a
12
+ * working directory. No temp directory / copying of files is necessary.
13
+ */
14
+ export class LocalSigningClient implements SigningClient {
15
+ constructor(
16
+ private options: Omit<SigningClientOptions, 'workingDirectory'>
17
+ ) {}
18
+
19
+ sign(file: string): Promise<void> {
20
+ localClientDebug(`Signing ${file}`);
21
+
22
+ const directoryOfFileToSign = path.dirname(file);
23
+
24
+ try {
25
+ const env = {
26
+ ...getEnv(),
27
+ method: this.options.signingMethod,
28
+ };
29
+
30
+ spawnSync('bash', [this.options.signingScript, path.basename(file)], {
31
+ cwd: directoryOfFileToSign,
32
+ env,
33
+ encoding: 'utf-8',
34
+ });
35
+
36
+ localClientDebug(`Signed file ${file}`);
37
+
38
+ return Promise.resolve();
39
+ } catch (error) {
40
+ localClientDebug({ error });
41
+ throw error;
42
+ }
43
+ }
44
+ }
@@ -0,0 +1,96 @@
1
+ import fs from 'fs/promises';
2
+ import { exec } from 'child_process';
3
+ import { RemoteSigningClient } from './remote-signing-client';
4
+ import { expect } from 'chai';
5
+ import type { SSHClient } from '../ssh-client';
6
+
7
+ const getMockedSSHClient = () => {
8
+ return {
9
+ getSftpConnection: () => {
10
+ return {
11
+ fastPut: async (
12
+ localFile: string,
13
+ remoteFile: string,
14
+ cb: (err?: Error) => void
15
+ ) => {
16
+ try {
17
+ await fs.copyFile(localFile, remoteFile);
18
+ cb();
19
+ } catch (err) {
20
+ cb(err as Error);
21
+ }
22
+ },
23
+ fastGet: async (
24
+ remoteFile: string,
25
+ localFile: string,
26
+ cb: (err?: Error) => void
27
+ ) => {
28
+ try {
29
+ await fs.copyFile(remoteFile, localFile);
30
+ cb();
31
+ } catch (err) {
32
+ cb(err as Error);
33
+ }
34
+ },
35
+ unlink: async (remoteFile: string, cb: (err?: Error) => void) => {
36
+ try {
37
+ await fs.unlink(remoteFile);
38
+ cb();
39
+ } catch (err) {
40
+ cb(err as Error);
41
+ }
42
+ },
43
+ };
44
+ },
45
+ exec: (command: string) => {
46
+ return new Promise((resolve, reject) => {
47
+ exec(command, { shell: 'bash' }, (err) => {
48
+ if (err) {
49
+ return reject(err);
50
+ }
51
+ return resolve('Ok');
52
+ });
53
+ });
54
+ },
55
+ disconnect: () => {},
56
+ } as unknown as SSHClient;
57
+ };
58
+
59
+ describe('RemoteSigningClient', function () {
60
+ const workingDirectoryPath = 'working-directory';
61
+ const fileToSign = 'file-to-sign.txt';
62
+ const signingScript = 'script.sh';
63
+
64
+ beforeEach(async function name() {
65
+ await fs.writeFile(fileToSign, 'RemoteSigningClient: original content');
66
+ await fs.writeFile(
67
+ signingScript,
68
+ `
69
+ #!/bin/bash
70
+ echo "Signing script called with arguments: $@"
71
+ echo "RemoteSigningClient: signed content" > $1
72
+ `
73
+ );
74
+ });
75
+
76
+ afterEach(async function () {
77
+ await Promise.allSettled([
78
+ fs.rm(workingDirectoryPath, { recursive: true, force: true }),
79
+ fs.rm(signingScript),
80
+ fs.rm(fileToSign),
81
+ ]);
82
+ });
83
+
84
+ it('signs the file correctly', async function () {
85
+ const remoteSigningClient = new RemoteSigningClient(getMockedSSHClient(), {
86
+ workingDirectory: workingDirectoryPath,
87
+ signingScript: signingScript,
88
+ signingMethod: 'gpg',
89
+ });
90
+
91
+ await remoteSigningClient.sign(fileToSign);
92
+
93
+ const signedFile = (await fs.readFile(fileToSign, 'utf-8')).trim();
94
+ expect(signedFile).to.equal('RemoteSigningClient: signed content');
95
+ });
96
+ });
@@ -0,0 +1,119 @@
1
+ import path from 'path';
2
+ import type { SFTPWrapper } from 'ssh2';
3
+ import type { SSHClient } from '../ssh-client';
4
+ import { debug, getEnv } from '../utils';
5
+ import type { SigningClient, SigningClientOptions } from '.';
6
+
7
+ export class RemoteSigningClient implements SigningClient {
8
+ private sftpConnection!: SFTPWrapper;
9
+
10
+ constructor(
11
+ private sshClient: SSHClient,
12
+ private options: SigningClientOptions
13
+ ) {}
14
+
15
+ /**
16
+ * Initialize the signing client and setup remote machine to be ready for signing
17
+ * the files. This will do following things:
18
+ * - Create a working directory on the remote machine
19
+ * - Copy the signing script to the remote machine
20
+ */
21
+ private async init() {
22
+ this.sftpConnection = await this.sshClient.getSftpConnection();
23
+ await this.sshClient.exec(`mkdir -p ${this.options.workingDirectory}`);
24
+
25
+ // Copy the signing script to the remote machine
26
+ {
27
+ const remoteScript = `${this.options.workingDirectory}/garasign.sh`;
28
+ await this.copyFile(this.options.signingScript, remoteScript);
29
+ await this.sshClient.exec(`chmod +x ${remoteScript}`);
30
+ }
31
+ }
32
+
33
+ private getRemoteFilePath(file: string) {
34
+ return `${this.options.workingDirectory}/temp-${Date.now()}-${path.basename(
35
+ file
36
+ )}`;
37
+ }
38
+
39
+ private async copyFile(file: string, remotePath: string): Promise<void> {
40
+ return new Promise((resolve, reject) => {
41
+ this.sftpConnection.fastPut(file, remotePath, (err) => {
42
+ if (err) {
43
+ return reject(err);
44
+ }
45
+ return resolve();
46
+ });
47
+ });
48
+ }
49
+
50
+ private async downloadFile(remotePath: string, file: string): Promise<void> {
51
+ return new Promise((resolve, reject) => {
52
+ this.sftpConnection.fastGet(remotePath, file, (err) => {
53
+ if (err) {
54
+ return reject(err);
55
+ }
56
+ return resolve();
57
+ });
58
+ });
59
+ }
60
+
61
+ private async removeFile(remotePath: string): Promise<void> {
62
+ return new Promise((resolve, reject) => {
63
+ this.sftpConnection.unlink(remotePath, (err) => {
64
+ if (err) {
65
+ return reject(err);
66
+ }
67
+ return resolve();
68
+ });
69
+ });
70
+ }
71
+
72
+ private async signRemoteFile(file: string) {
73
+ const env = getEnv();
74
+ /**
75
+ * Passing env variables as an option to ssh.exec() doesn't work as ssh config
76
+ * (`sshd_config.AllowEnv`) does not allow to pass env variables by default.
77
+ * So, here we are passing the env variables as part of the command.
78
+ */
79
+ const cmds = [
80
+ `cd '${this.options.workingDirectory}'`,
81
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
82
+ `export garasign_username=${env.garasign_username}`,
83
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
84
+ `export garasign_password=${env.garasign_password}`,
85
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
86
+ `export artifactory_username=${env.artifactory_username}`,
87
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
88
+ `export artifactory_password=${env.artifactory_password}`,
89
+ `export method=${this.options.signingMethod}`,
90
+ `./garasign.sh '${file}'`,
91
+ ];
92
+ const command = cmds.join(' && ');
93
+ const res = await this.sshClient.exec(command);
94
+ debug('Sign remote file response\n', res.trim());
95
+ }
96
+
97
+ async sign(file: string): Promise<void> {
98
+ const remotePath = this.getRemoteFilePath(file);
99
+ try {
100
+ // establish connection
101
+ await this.init();
102
+
103
+ await this.copyFile(file, remotePath);
104
+ debug(`SFTP: Copied file ${file} to ${remotePath}`);
105
+
106
+ await this.signRemoteFile(path.basename(remotePath));
107
+ debug(`SFTP: Signed file ${file}`);
108
+
109
+ await this.downloadFile(remotePath, file);
110
+ debug(`SFTP: Downloaded signed file to ${file}`);
111
+ } catch (error) {
112
+ debug({ error });
113
+ } finally {
114
+ await this.removeFile(remotePath);
115
+ debug(`SFTP: Removed remote file ${remotePath}`);
116
+ this.sshClient.disconnect();
117
+ }
118
+ }
119
+ }
@@ -0,0 +1,186 @@
1
+ import sinon from 'sinon';
2
+ import { SSHClient } from './ssh-client';
3
+ import { expect } from 'chai';
4
+ import { PassThrough } from 'stream';
5
+ import { promisify } from 'util';
6
+
7
+ describe('SSHClient', function () {
8
+ let sshClient: SSHClient;
9
+ let sandbox: sinon.SinonSandbox;
10
+
11
+ beforeEach(function () {
12
+ const sshClientOptions = {
13
+ host: 'example.com',
14
+ port: 22,
15
+ username: 'admin',
16
+ };
17
+ sshClient = new SSHClient(sshClientOptions);
18
+ sandbox = sinon.createSandbox();
19
+ });
20
+
21
+ afterEach(function () {
22
+ sandbox.restore();
23
+ });
24
+
25
+ describe('connect()', function () {
26
+ let connectStub: sinon.SinonStub;
27
+
28
+ beforeEach(function () {
29
+ connectStub = sandbox
30
+ .stub(sshClient['sshConnection'], 'connect')
31
+ .returns(sshClient['sshConnection']);
32
+ });
33
+ it('connects successfully on ready', async function () {
34
+ const connectPromise = sshClient.connect();
35
+ sshClient['sshConnection'].emit('ready');
36
+ await connectPromise;
37
+
38
+ expect(connectStub.calledOnce).to.be.true;
39
+ expect(connectStub.firstCall.firstArg).to.deep.equal({
40
+ host: 'example.com',
41
+ port: 22,
42
+ username: 'admin',
43
+ privateKey: undefined,
44
+ });
45
+ expect(sshClient).to.have.property('connected', true);
46
+ });
47
+
48
+ it('does not called client.connect when connected', async function () {
49
+ // connect the internal ssh client
50
+ sshClient['sshConnection'].emit('ready');
51
+
52
+ const connectPromise = sshClient.connect();
53
+ sshClient['sshConnection'].emit('ready');
54
+ await connectPromise;
55
+
56
+ expect(connectStub.calledOnce).to.be.false;
57
+ expect(sshClient).to.have.property('connected', true);
58
+ });
59
+
60
+ it('throws when connecting on error', async function () {
61
+ const connectPromise = sshClient.connect();
62
+ sshClient['sshConnection'].emit('error', new Error('Connection error'));
63
+
64
+ const error = await connectPromise.catch((e) => e);
65
+ expect(error).to.have.property('message', 'Connection error');
66
+ expect(connectStub.calledOnce).to.be.true;
67
+ expect(connectStub.firstCall.firstArg).to.deep.equal({
68
+ host: 'example.com',
69
+ port: 22,
70
+ username: 'admin',
71
+ privateKey: undefined,
72
+ });
73
+ expect(sshClient).to.have.property('connected', false);
74
+ });
75
+ });
76
+
77
+ describe('disconnect()', function () {
78
+ it('disconnects from SSH server', function () {
79
+ const endStub = sandbox.stub(sshClient['sshConnection'], 'end');
80
+ sshClient['sshConnection'].emit('ready');
81
+
82
+ sshClient.disconnect();
83
+ expect(endStub.calledOnce).to.be.true;
84
+ });
85
+ });
86
+
87
+ describe('exec()', function () {
88
+ const COMMAND = 'echo "Hello World"';
89
+ function makeMockClientChannel() {
90
+ const stream: PassThrough & { stderr: PassThrough } =
91
+ new PassThrough() as any;
92
+ stream.stderr = new PassThrough();
93
+ return stream;
94
+ }
95
+ let clientStream: ReturnType<typeof makeMockClientChannel>;
96
+ let execStub;
97
+
98
+ beforeEach(function () {
99
+ clientStream = makeMockClientChannel();
100
+ execStub = sandbox
101
+ .stub(sshClient['sshConnection'], 'exec')
102
+ .yieldsRight(undefined, clientStream);
103
+ sshClient['sshConnection'].emit('ready');
104
+ });
105
+
106
+ it('should throw when exec fails', async function () {
107
+ execStub.yieldsRight(new Error('Callback Error'));
108
+
109
+ sshClient['sshConnection'].emit('ready');
110
+
111
+ const err = await sshClient.exec(COMMAND).catch((e) => e);
112
+
113
+ expect(err).to.have.property('message', 'Callback Error');
114
+ expect(execStub.calledOnce).to.be.true;
115
+ expect(execStub.firstCall.firstArg).to.equal(COMMAND);
116
+ });
117
+
118
+ it('should throw when exec returns an error - code > 0', async function () {
119
+ const resultPromise = sshClient.exec(COMMAND);
120
+ // internally, exec() attaches event listeners to the `stream` after an `await` statement
121
+ // so we must queue a microtask to let the `exec` function resume before emitting events
122
+ // on the stream. otherwise, the events are emitted from the stream when there are no
123
+ // listeners on the stream.
124
+ await promisify(queueMicrotask)();
125
+ clientStream.stderr.push('Some Error');
126
+ clientStream.emit('close', 10);
127
+
128
+ const error = await resultPromise.catch((e) => e);
129
+ expect(error).to.have.a.property(
130
+ 'message',
131
+ 'Command failed with code 10. Error: Some Error'
132
+ );
133
+ expect(execStub.calledOnce).to.be.true;
134
+ expect(execStub.firstCall.firstArg).to.equal(COMMAND);
135
+ });
136
+
137
+ it('should return stdout when exec succeeds', async function () {
138
+ const resultPromise = sshClient.exec(COMMAND);
139
+ // internally, exec() attaches event listeners to the `stream` after an `await` statement
140
+ // so we must queue a microtask to let the `exec` function resume before emitting events
141
+ // on the stream. otherwise, the events are emitted from the stream when there are no
142
+ // listeners on the stream.
143
+ await promisify(queueMicrotask)();
144
+ clientStream.push('Hello World');
145
+ clientStream.emit('close', 0);
146
+ const result = await resultPromise;
147
+ expect(result).to.equal('Hello World');
148
+ expect(execStub.calledOnce).to.be.true;
149
+ expect(execStub.firstCall.firstArg).to.equal(COMMAND);
150
+ });
151
+ });
152
+
153
+ describe('getSftpConnection()', function () {
154
+ describe('when the ssh client is not connected', function () {
155
+ it('returns the sftp connection', async function () {
156
+ const error = await sshClient.getSftpConnection().catch((e) => e);
157
+ expect(error)
158
+ .to.be.instanceof(Error)
159
+ .to.match(/Not connected to ssh server/);
160
+ });
161
+ });
162
+
163
+ describe('when the ssh client is connected', function () {
164
+ let connectionStub: sinon.SinonStub;
165
+ beforeEach(function () {
166
+ connectionStub = sandbox
167
+ .stub(sshClient['sshConnection'], 'sftp')
168
+ .yieldsRight(undefined, 'mockedSFTP');
169
+
170
+ sshClient['sshConnection'].emit('ready');
171
+ });
172
+ it('returns the sftp connection', async function () {
173
+ const connection = await sshClient.getSftpConnection();
174
+ expect(connection).to.equal('mockedSFTP');
175
+ });
176
+
177
+ it('caches the sftp connection', async function () {
178
+ await sshClient.getSftpConnection();
179
+ connectionStub.yieldsRight(undefined, 'new value');
180
+
181
+ const connection = await sshClient.getSftpConnection();
182
+ expect(connection).to.equal('mockedSFTP');
183
+ });
184
+ });
185
+ });
186
+ });
@@ -0,0 +1,92 @@
1
+ import type { ClientChannel, ConnectConfig, SFTPWrapper } from 'ssh2';
2
+ import { Client } from 'ssh2';
3
+ import { readFile } from 'fs/promises';
4
+ import { debug } from './utils';
5
+ import { promisify } from 'util';
6
+ import { once } from 'events';
7
+
8
+ export class SSHClient {
9
+ private sshConnection: Client;
10
+ private sftpConnection?: SFTPWrapper;
11
+
12
+ private connected = false;
13
+
14
+ constructor(private sshClientOptions: ConnectConfig) {
15
+ this.sshConnection = new Client();
16
+ this.setupEventListeners();
17
+ }
18
+
19
+ setupEventListeners() {
20
+ this.sshConnection.on('ready', () => {
21
+ debug('SSH: Connection established');
22
+ this.connected = true;
23
+ });
24
+ this.sshConnection.on('error', (err) => {
25
+ debug('SSH: Connection error', err);
26
+ this.connected = false;
27
+ });
28
+ this.sshConnection.on('close', () => {
29
+ debug('SSH: Connection closed');
30
+ this.connected = false;
31
+ this.sshConnection.destroy();
32
+ });
33
+ }
34
+
35
+ async connect() {
36
+ if (this.connected) {
37
+ return;
38
+ }
39
+ const privateKey = this.sshClientOptions.privateKey
40
+ ? await readFile(this.sshClientOptions.privateKey)
41
+ : undefined;
42
+
43
+ const ready = once(this.sshConnection, 'ready');
44
+ this.sshConnection.connect({
45
+ ...this.sshClientOptions,
46
+ privateKey,
47
+ });
48
+ await ready;
49
+ }
50
+
51
+ disconnect() {
52
+ this.sshConnection.end();
53
+ this.connected = false;
54
+ }
55
+
56
+ async exec(command: string): Promise<string> {
57
+ if (!this.connected) {
58
+ throw new Error('Not connected to ssh server');
59
+ }
60
+ const stream: ClientChannel = await promisify(
61
+ this.sshConnection.exec.bind(this.sshConnection)
62
+ )(command);
63
+ let data = '';
64
+ stream.setEncoding('utf-8');
65
+ stream.stderr.setEncoding('utf-8');
66
+ stream.on('data', (chunk: string) => {
67
+ data += chunk;
68
+ });
69
+ stream.stderr.on('data', (chunk: string) => {
70
+ data += chunk;
71
+ });
72
+ const [code] = await once(stream, 'close');
73
+ if (code !== 0) {
74
+ throw new Error(
75
+ `Command failed with code ${code as number}. Error: ${data}`
76
+ );
77
+ }
78
+
79
+ return data;
80
+ }
81
+
82
+ async getSftpConnection(): Promise<SFTPWrapper> {
83
+ if (!this.connected) {
84
+ throw new Error('Not connected to ssh server');
85
+ }
86
+
87
+ this.sftpConnection =
88
+ this.sftpConnection ??
89
+ (await promisify(this.sshConnection.sftp.bind(this.sshConnection))());
90
+ return this.sftpConnection;
91
+ }
92
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { debug as debugFn } from 'debug';
2
+
3
+ export const debug = debugFn('signing-utils');
4
+
5
+ export function getEnv() {
6
+ const garasign_username =
7
+ process.env['GARASIGN_USERNAME'] ?? process.env['garasign_username'];
8
+ const garasign_password =
9
+ process.env['GARASIGN_PASSWORD'] ?? process.env['garasign_password'];
10
+ const artifactory_username =
11
+ process.env['ARTIFACTORY_USERNAME'] ?? process.env['artifactory_username'];
12
+ const artifactory_password =
13
+ process.env['ARTIFACTORY_PASSWORD'] ?? process.env['artifactory_password'];
14
+
15
+ return {
16
+ garasign_username,
17
+ garasign_password,
18
+ artifactory_username,
19
+ artifactory_password,
20
+ };
21
+ }