@universis/signer-worker 1.1.0 → 1.6.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.
@@ -0,0 +1,34 @@
1
+ import { ApplicationBase } from '@themost/common';
2
+ import { SignerWorker, ApplicationBaseWithContainer } from './SignerWorker';
3
+ import { Application } from 'express';
4
+ import { remoteSignerWorkerRouter } from './remoteSignerWorkerRouter';
5
+
6
+ class ScopeAccessConfiguration {
7
+ public elements = [];
8
+ }
9
+
10
+ class RemoteSignerWorker extends SignerWorker {
11
+
12
+ constructor(app: ApplicationBase) {
13
+ super(app);
14
+ }
15
+
16
+ install(): void {
17
+ // get configuration
18
+ this.options = Object.assign({
19
+ host: 'http://localhost:8480/'
20
+ }, this.getApplication().getConfiguration().getSourceAt('settings/universis/remoteSignerWorker'));
21
+ const thisApp = this.getApplication() as ApplicationBaseWithContainer;
22
+ if (thisApp && thisApp.container) {
23
+ thisApp.container.subscribe((container: Application) => {
24
+ if (container) {
25
+ // use signer worker router (for authenticated users)
26
+ container.use('/services/remoteSigner/', remoteSignerWorkerRouter(this.options.host));
27
+ }
28
+ });
29
+ }
30
+ }
31
+
32
+ }
33
+
34
+ export { RemoteSignerWorker };
@@ -0,0 +1,339 @@
1
+ import { ApplicationBase, ApplicationService, HttpError, TraceUtils } from '@themost/common';
2
+ import { BehaviorSubject } from 'rxjs';
3
+ import { Application } from 'express';
4
+ import { ExpressDataContext } from '@themost/express';
5
+ import request from 'superagent';
6
+ import { URL } from 'url';
7
+ import path from 'path';
8
+ import { writeFile as writeFileWithCallback, unlink as unlinkWithCallback } from 'fs';
9
+ import { promisify } from 'util';
10
+ import crypto from 'crypto';
11
+ import os from 'os';
12
+ import { DataCacheStrategy } from '@themost/data';
13
+ import { Certificate, JsonWebKey, VerifySignatureResult, SignJsonSignatureResult, SignerServiceImpl } from './SignerServiceImpl';
14
+ import {Secret, TOTP, TOTPOptions} from 'xotp';
15
+
16
+ const writeFile = promisify(writeFileWithCallback);
17
+ const unlink = promisify(unlinkWithCallback);
18
+ declare interface ApplicationBaseWithContainer extends ApplicationBase {
19
+ container?: BehaviorSubject<Application>;
20
+ }
21
+
22
+ function tmpFile(extension: string = '.tmp'): string {
23
+ const tmpDir = os.tmpdir();
24
+ const filename = `file-${crypto.randomBytes(16).toString('hex')}${extension}`;
25
+ return path.join(tmpDir, filename);
26
+ }
27
+
28
+
29
+ export class SignerService extends ApplicationService implements SignerServiceImpl {
30
+
31
+ protected options?: {
32
+ host?: string;
33
+ user?: string;
34
+ password?: string;
35
+ secret?: string;
36
+ thumbprint?: string;
37
+ inspector?: {
38
+ text?: string[];
39
+ textPosition: string;
40
+ width?: number;
41
+ height?: number;
42
+ }
43
+ }
44
+
45
+ constructor(app: ApplicationBase) {
46
+ super(app);
47
+
48
+ // get signer worker configuration
49
+ const options = app.getConfiguration().getSourceAt('settings/universis/signerService') || {};
50
+
51
+ this.options = Object.assign({}, {
52
+ 'host': 'http://localhost:2465/'
53
+ }, options);
54
+
55
+ const thisApp = app as ApplicationBaseWithContainer;
56
+ if (thisApp && thisApp.container) {
57
+ thisApp.container.subscribe((container: Application) => {
58
+ if (container) {
59
+ container.get('/api/signer/.well-known/jwks', async (_req: any, res: any, next: any) => {
60
+ try {
61
+ const results = await this.getJwks();
62
+ return res.json(results);
63
+ } catch (err) {
64
+ return next(err);
65
+ }
66
+ });
67
+ container.get('/api/signer/.well-known/certs', async (_req: any, res: any, next: any) => {
68
+ try {
69
+ const results = await this.getCerts();
70
+ return res.json(results);
71
+ } catch (err) {
72
+ return next(err);
73
+ }
74
+ });
75
+ }
76
+ });
77
+ }
78
+ }
79
+
80
+ /**
81
+ * @param {ExpressDataContext} context The underlying data context
82
+ * @param {*} data The data to be verified
83
+ * @param {{filename: string, contentType: string}=} options Extra attributes (optional)
84
+ * @returns {Promise<VerifySignatureResult[]>}
85
+ */
86
+ async verify(context: ExpressDataContext, data: unknown, options?: { filename: string; contentType: string }): Promise<VerifySignatureResult[]> {
87
+ if (data == null) {
88
+ throw new Error('The given data cannot be empty during sign.');
89
+ }
90
+ const json = (typeof data === 'object') ? JSON.stringify(data) : JSON.stringify({
91
+ value: data,
92
+ });
93
+ const file = tmpFile('.json');
94
+ // write data to temporary file
95
+ await writeFile(file, json, { encoding: 'utf8' });
96
+ try {
97
+ return await this.verifyFile(context, file, options);
98
+ } catch (err) {
99
+ throw err;
100
+ } finally {
101
+ try {
102
+ await unlink(file); // remove temporary file
103
+ } catch (e) {
104
+ // ignore error
105
+ }
106
+ }
107
+ }
108
+
109
+ public async getJwks(): Promise<JsonWebKey[]> {
110
+ try {
111
+ const cache = this.getApplication().getConfiguration().getStrategy(DataCacheStrategy);
112
+ // check if cache is available
113
+ return cache.getOrDefault('/api/signer/keystore/jwks', async () => {
114
+ try {
115
+ const response = await request
116
+ .get(new URL('/keystore/jwks', this.options.host).toString())
117
+ .auth(this.options.user, this.options.password);
118
+ if (response.ok) {
119
+ return response.body;
120
+ }
121
+ // noinspection ExceptionCaughtLocallyJS
122
+ throw new HttpError(response.status, 'Something went wrong while getting json web key set');
123
+ } catch (err) {
124
+ TraceUtils.error(err);
125
+ throw new HttpError(err.status, 'Failed to get json web key set', err.message);
126
+ }
127
+ });
128
+ } catch (err) {
129
+ throw new HttpError(err.status, 'Failed to get json web key set', err.message);
130
+ }
131
+ }
132
+
133
+ public async getCerts(): Promise<Certificate[]> {
134
+ const cache = this.getApplication().getConfiguration().getStrategy(DataCacheStrategy);
135
+ return cache.getOrDefault('/api/signer/keystore/certs', async () => {
136
+ try {
137
+ const response = await request.get(new URL('/keystore/certs', this.options.host).toString())
138
+ .auth(this.options.user, this.options.password);
139
+ if (response.ok) {
140
+ return (response.body as Certificate[]).filter((item) => {
141
+ return item.notAfter && new Date(item.notAfter) > new Date();
142
+ });
143
+ }
144
+ return [];
145
+ } catch (err) {
146
+ TraceUtils.error(err);
147
+ throw new HttpError(err.status, 'Failed to get certificates', err.message);
148
+ }
149
+ });
150
+ }
151
+
152
+ /**
153
+ * Generates a one-time password using the configured secret.
154
+ */
155
+ public generateVerificationCode(options?: TOTPOptions): string {
156
+ if (this.options.secret == null) {
157
+ throw new Error('One-time password is not supported');
158
+ }
159
+ const secret = Secret.from(this.options.secret, 'base32');
160
+ const opts: TOTPOptions = Object.assign({
161
+ algorithm: 'sha1',
162
+ digits: 6,
163
+ period: 30,
164
+ }, options);
165
+ const totp = new TOTP(opts);
166
+ return totp.generate({ secret });
167
+ }
168
+
169
+ /**
170
+ * @param {ExpressDataContext} context The underlying data context
171
+ * @param {*} data Any JSON object to be signed
172
+ * @param {{timestampServer?:string}} extraOptions
173
+ * @returns {Promise<SignJsonSignatureResult>} An array of signatures
174
+ */
175
+ async sign(context: ExpressDataContext, data: unknown, extraOptions?: {
176
+ timestampServer?: string,
177
+ reason?: string
178
+ }): Promise<SignJsonSignatureResult> {
179
+ if (data == null) {
180
+ throw new Error('The given data cannot be empty during sign.');
181
+ }
182
+ const json = (typeof data === 'object') ? JSON.stringify(data) : JSON.stringify({
183
+ value: data,
184
+ });
185
+ const file = tmpFile('.json');
186
+ // write data to temporary file
187
+ await writeFile(file, json, { encoding: 'utf8' });
188
+ try {
189
+ return await this.signFinal<SignJsonSignatureResult>(context, file, 'application/json', extraOptions);
190
+ } catch(err) {
191
+ if (err.response) {
192
+ throw new HttpError(err.status, 'Failed to sign data', err.message);
193
+ }
194
+ throw err;
195
+ } finally {
196
+ try {
197
+ await unlink(file); // remove temporary file
198
+ } catch (e) {
199
+ // ignore error
200
+ }
201
+ }
202
+ }
203
+
204
+ public async signFile(context: ExpressDataContext, file: string, contentType?: string, extraOptions?: {
205
+ page?: number,
206
+ position?: number[],
207
+ timestampServer?: string,
208
+ reason?: string
209
+ }): Promise<Buffer> {
210
+ return this.signFinal<Buffer>(context, file, contentType, extraOptions);
211
+ }
212
+
213
+ public async verifyFile(context: ExpressDataContext, file: string, options?: { filename: string; contentType: string }): Promise<VerifySignatureResult[]> {
214
+ try {
215
+ const response = await request.post(new URL('/verify', this.options.host).toString())
216
+ .attach('file',file, options);
217
+ if (response.ok) {
218
+ return response.body;
219
+ }
220
+ // noinspection ExceptionCaughtLocallyJS
221
+ throw new HttpError(response.statusCode);
222
+ } catch (err) {
223
+ if (err.response) {
224
+ TraceUtils.error(err);
225
+ throw new HttpError(err.response.status, 'Failed to verify data', err.message);
226
+ }
227
+ throw err;
228
+ }
229
+ }
230
+
231
+ public async inspect(context: ExpressDataContext, file: string, options?: { filename: string; contentType: string }): Promise<{ page: number, position: number[], text: string }> {
232
+ try {
233
+ const fields: Record<string, string> = {};
234
+ if (this.options.inspector) {
235
+ Object.assign(fields, this.options.inspector);
236
+ }
237
+ const response = await request.post(new URL('/signature/inspect', this.options.host).toString())
238
+ .field(fields)
239
+ .attach('file',file, options);
240
+ if (response.ok) {
241
+ return response.body;
242
+ }
243
+ // noinspection ExceptionCaughtLocallyJS
244
+ throw new HttpError(response.statusCode);
245
+ } catch (err) {
246
+ if (err.response) {
247
+ TraceUtils.error(err);
248
+ throw new HttpError(err.response.status, 'Failed to verify data', err.message);
249
+ }
250
+ throw err;
251
+ }
252
+ }
253
+
254
+ public requiresVerificationCode(): boolean {
255
+ return typeof this.options.secret === 'string' && this.options.secret.length > 0;
256
+ }
257
+
258
+ protected async signFinal<T>(context: ExpressDataContext, file: string, contentType?: string, extraOptions?: {
259
+ position?: number[],
260
+ page?: number,
261
+ timestampServer?: string,
262
+ reason?: string
263
+ }): Promise<T> {
264
+ // try to find thumbprint if empty
265
+ if (typeof this.options.thumbprint === 'undefined') {
266
+ const [cert] = await this.getCerts();
267
+ if (cert) {
268
+ this.options.thumbprint = cert.thumbprint;
269
+ } else {
270
+ this.options.thumbprint = '';
271
+ }
272
+ }
273
+ const fields = Object.assign({}, extraOptions);
274
+ // if the file is a PDF or the content type is application/pdf, try to inspect signature position
275
+ if (path.extname(file) === '.pdf' || contentType === 'application/pdf' ) {
276
+ if (extraOptions?.page && extraOptions?.position) {
277
+ // if page and position are defined, use them
278
+ Object.assign(fields, {
279
+ page: extraOptions.page,
280
+ position: extraOptions.position.map((element) => {
281
+ return Math.round(element);
282
+ }).join(',')
283
+ });
284
+ } else if (this.options.inspector) {
285
+ // if inspector is defined, use it
286
+ const inspectorResult = await this.inspect(context, file);
287
+ if (inspectorResult?.page && inspectorResult?.position) {
288
+ Object.assign(fields, {
289
+ page: inspectorResult.page,
290
+ position: inspectorResult.position.map((element) => {
291
+ return Math.round(element);
292
+ }).join(',')
293
+ })
294
+ }
295
+ }
296
+ }
297
+
298
+ if (this.options.thumbprint.length) {
299
+ Object.assign(fields, {
300
+ thumbprint: this.options.thumbprint,
301
+ });
302
+ }
303
+ const fileOptions = {
304
+ filename: path.basename(file)
305
+ };
306
+ if (contentType) {
307
+ Object.assign(fileOptions, {
308
+ contentType
309
+ });
310
+ }
311
+ try {
312
+ // if secret is defined, generate one-time password
313
+ if (this.requiresVerificationCode()) {
314
+ Object.assign(fields, {
315
+ otp: this.generateVerificationCode()
316
+ });
317
+ }
318
+ const response = await request.post(new URL('/sign', this.options.host).toString())
319
+ .type('form')
320
+ .auth(this.options.user, this.options.password)
321
+ .field(fields)
322
+ .buffer(contentType !== 'application/json')
323
+ .attach('file',file, fileOptions);
324
+ if (response.ok) {
325
+ return response.body as T;
326
+ }
327
+ // noinspection ExceptionCaughtLocallyJS
328
+ throw new HttpError(response.status);
329
+ } catch (err) {
330
+ if (err.response) {
331
+ TraceUtils.error(err);
332
+ throw new HttpError(err.response.status, 'Failed to sign data', err.message);
333
+ }
334
+ throw err;
335
+ }
336
+
337
+ }
338
+
339
+ }
@@ -0,0 +1,58 @@
1
+ import { ExpressDataContext } from '@themost/express';
2
+
3
+
4
+ export declare interface Certificate {
5
+ version: number;
6
+ subjectDN: string;
7
+ sigAlgName?: string;
8
+ sigAlgOID?: string;
9
+ issuerDN: string;
10
+ serialNumber: any;
11
+ notAfter: string;
12
+ notBefore: string;
13
+ expired: boolean;
14
+ thumbprint: string;
15
+ commonName: string;
16
+ }
17
+
18
+ export declare interface JsonWebKey {
19
+ kty: string;
20
+ use: string;
21
+ kid: string;
22
+ alg: string;
23
+ n: string;
24
+ e: string
25
+ }
26
+
27
+ export declare interface VerifySignatureResult {
28
+ valid: boolean;
29
+ certificates: Certificate[];
30
+ signatureProperties?: {
31
+ signingDate: string;
32
+ reason: string;
33
+ signingCertificate: Certificate
34
+ };
35
+ }
36
+
37
+ export declare interface SignJsonSignatureResult {
38
+ payload: string;
39
+ signatures: { protected: string; signature: string }[];
40
+ }
41
+
42
+ export declare interface SignerServiceImpl {
43
+ getCerts(): Promise<Certificate[]>;
44
+ getJwks(): Promise<JsonWebKey[]>;
45
+ verify(context: ExpressDataContext, document: string, options?: { filename: string; contentType: string }): Promise<VerifySignatureResult[]>
46
+ verifyFile(context: ExpressDataContext, file: string, options?: { filename: string; contentType: string }): Promise<VerifySignatureResult[]>
47
+ signFile(context: ExpressDataContext, file: string, contentType?: string, extraOptions?: {
48
+ page?: number
49
+ position?: number[],
50
+ timestampServer?: string,
51
+ reason?: string
52
+ }): Promise<Buffer>;
53
+ sign(context: ExpressDataContext, data: unknown, extraOptions?: {
54
+ timestampServer?: string,
55
+ reason?: string
56
+ }): Promise<SignJsonSignatureResult>;
57
+
58
+ }
@@ -1,80 +1,17 @@
1
- import { ApplicationBase, ApplicationService, HttpError, HttpForbiddenError } from '@themost/common';
1
+ import {ApplicationBase, ApplicationService, HttpError, HttpForbiddenError, TraceUtils} from '@themost/common';
2
2
  import { BehaviorSubject } from 'rxjs';
3
- import { Application, RequestHandler, Router } from 'express';
3
+ import { Application, RequestHandler } from 'express';
4
4
  import { signerWorkerRouter } from './signerWorkerRouter';
5
5
  import { SignerScopeAccess } from './SignerScopeAccess';
6
6
  import { ExpressDataContext, serviceRouter } from '@themost/express';
7
7
  import request from 'superagent';
8
- import path from 'path';
8
+ import { VerifySignatureResult } from './SignerServiceImpl';
9
+ import {URL} from 'url';
9
10
 
10
- declare interface ApplicationBaseWithContainer extends ApplicationBase {
11
+ export declare interface ApplicationBaseWithContainer extends ApplicationBase {
11
12
  container?: BehaviorSubject<Application>;
12
13
  }
13
14
 
14
- declare interface Certificate {
15
- version: number;
16
- subjectDN: string;
17
- sigAlgName?: string;
18
- sigAlgOID?: string;
19
- issuerDN: string;
20
- serialNumber: any;
21
- notAfter: string;
22
- notBefore: string;
23
- expired: boolean;
24
- thumbprint: string;
25
- commonName: string;
26
- }
27
-
28
-
29
- declare interface VerifySignatureResult {
30
- valid: boolean;
31
- certificates: Certificate[];
32
- signatureProperties?: {
33
- signingDate: string;
34
- reason: string;
35
- signingCertificate: Certificate
36
- };
37
- }
38
-
39
-
40
- function insertRouterAfter(parent: Router, after: any, insert: any) {
41
- const afterIndex = parent.stack.findIndex( (item) => {
42
- return item === after;
43
- });
44
- if (afterIndex < 0) {
45
- throw new Error('Target router cannot be found in parent stack.');
46
- }
47
- const findIndex = parent.stack.findIndex( (item) => {
48
- return item === insert;
49
- });
50
- if (findIndex < 0) {
51
- throw new Error('Router to be inserted cannot be found in parent stack.');
52
- }
53
- // remove last router
54
- parent.stack.splice(findIndex, 1);
55
- // move up
56
- parent.stack.splice(afterIndex + 1, 0, insert);
57
- }
58
-
59
- function insertRouterBefore(parent: Router, before: any, insert: any) {
60
- const beforeIndex = parent.stack.findIndex( (item) => {
61
- return item === before;
62
- });
63
- if (beforeIndex < 0) {
64
- throw new Error('Target router cannot be found in parent stack.');
65
- }
66
- const findIndex = parent.stack.findIndex( (item) => {
67
- return item === insert;
68
- });
69
- if (findIndex < 0) {
70
- throw new Error('Router to be inserted cannot be found in parent stack.');
71
- }
72
- // remove last router
73
- parent.stack.splice(findIndex, 1);
74
- // move up
75
- parent.stack.splice(beforeIndex, 0, insert);
76
- }
77
-
78
15
  class ScopeAccessConfiguration {
79
16
  public elements = [];
80
17
  }
@@ -84,7 +21,7 @@ class ScopeAccessConfiguration {
84
21
  * @returns {Function}
85
22
  */
86
23
  function validateScope(): RequestHandler {
87
- return (req: any, res: any, next: any) => {
24
+ return (req: any, _res: any, next: any) => {
88
25
  const scopeAccessConfiguration = req.context.getApplication().getConfiguration().getStrategy(ScopeAccessConfiguration);
89
26
  if (scopeAccessConfiguration == null) {
90
27
  return next();
@@ -101,38 +38,61 @@ class ScopeAccessConfiguration {
101
38
  }
102
39
 
103
40
  export class SignerWorker extends ApplicationService {
41
+
42
+ protected options?: {
43
+ host?: string;
44
+ }
45
+
104
46
  constructor(app: ApplicationBase) {
105
47
  super(app);
106
- const thisApp = app as ApplicationBaseWithContainer;
48
+ this.install();
49
+ }
50
+
51
+ install(): void {
52
+ // get configuration
53
+ this.options = Object.assign({
54
+ host: 'http://localhost:2465/'
55
+ }, this.getApplication().getConfiguration().getSourceAt('settings/universis/signerWorker'));
56
+ const thisApp = this.getApplication() as ApplicationBaseWithContainer;
107
57
  if (thisApp && thisApp.container) {
108
58
  thisApp.container.subscribe((container: Application) => {
109
59
  if (container) {
110
60
  // add scope access
111
- const scopeAccess = app.getConfiguration().getStrategy(ScopeAccessConfiguration);
61
+ const scopeAccess = thisApp.getConfiguration().getStrategy(ScopeAccessConfiguration);
112
62
  if (scopeAccess) {
113
63
  scopeAccess.elements.push.apply(scopeAccess.elements, SignerScopeAccess);
114
64
  }
115
65
  // use signer worker router (for authenticated users)
116
- serviceRouter.use('/signer/', signerWorkerRouter());
66
+ serviceRouter.use('/signer/', signerWorkerRouter(this.options.host));
117
67
  }
118
68
  });
119
69
  }
120
70
  }
121
71
 
122
72
  /**
123
- *
73
+ *
124
74
  * @param {ExpressDataContext} context The underlying data context
125
75
  * @param {string} document The file path
126
76
  * @param {{filename: string, contentType: string}=} options Extra attributes (optional)
127
- * @returns
77
+ * @returns Promise<VerifySignatureResult[]>
128
78
  */
129
79
  async verify(context: ExpressDataContext, document: string, options?: { filename: string; contentType: string }): Promise<VerifySignatureResult[]> {
130
- const response = await request.post('http://localhost:2465/verify')
131
- .attach('file',document, options);
132
- if (response.ok) {
133
- return response.body;
80
+ try {
81
+ const response = await request.post(new URL('/verify', this.options.host).toString())
82
+ .attach('file',document, options);
83
+ if (response.ok) {
84
+ return response.body;
85
+ }
86
+ // noinspection ExceptionCaughtLocallyJS
87
+ throw new HttpError(response.statusCode);
88
+ } catch (err) {
89
+ if (err.response) {
90
+ TraceUtils.error(err);
91
+ throw new HttpError(err.response.status);
92
+ }
93
+ throw err;
134
94
  }
135
- throw new HttpError(response.statusCode);
95
+
136
96
  }
137
97
 
138
98
  }
package/src/index.ts CHANGED
@@ -1,2 +1,6 @@
1
1
  export * from './SignerWorker';
2
- export * from './signerWorkerRouter';
2
+ export * from './SignerServiceImpl';
3
+ export * from './signerWorkerRouter';
4
+ export * from './SignerService';
5
+ export * from './RemoteSignerWorker';
6
+ export * from './remoteSignerWorkerRouter';
@@ -0,0 +1,16 @@
1
+ import { HttpMethodNotAllowedError } from '@themost/common';
2
+ import express from 'express';
3
+ import { createProxyMiddleware } from 'http-proxy-middleware';
4
+
5
+ export function remoteSignerWorkerRouter(target: string = 'http://localhost:8480/') {
6
+ const router = express.Router();
7
+ router.use(createProxyMiddleware({
8
+ target,
9
+ pathRewrite: {
10
+ '^/services/remoteSigner' : ''
11
+ },
12
+ logLevel: 'warn',
13
+ changeOrigin: true
14
+ }));
15
+ return router;
16
+ }
@@ -2,16 +2,17 @@ import { HttpMethodNotAllowedError } from '@themost/common';
2
2
  import express from 'express';
3
3
  import { createProxyMiddleware } from 'http-proxy-middleware';
4
4
 
5
- export function signerWorkerRouter() {
5
+ export function signerWorkerRouter(target: string = 'http://localhost:2465/') {
6
6
  const router = express.Router();
7
7
  router.use('/sign', (req, res, next) => {
8
8
  return next(new HttpMethodNotAllowedError('This method is not supported when using remote services.'))
9
9
  });
10
10
  router.use(createProxyMiddleware({
11
- target: 'http://localhost:2465/',
11
+ target,
12
12
  pathRewrite: {
13
13
  '^/api/signer' : ''
14
14
  },
15
+ logLevel: 'warn',
15
16
  changeOrigin: true
16
17
  }));
17
18
  return router;