@tak-ps/node-tak 12.24.0 → 12.26.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.
@@ -1 +1 @@
1
- {"root":["../cli.ts","../index.ts","../test/default.test.ts","../test/findCoT.test.ts","../test/mission-layer.test.ts","../test/mission.test.ts","../test/pipeline.test.ts","../test/queue.test.ts","../test/uploadPackage.test.ts","../lib/api.ts","../lib/auth.ts","../lib/commands.ts","../lib/fetch.ts","../lib/multipart.ts","../lib/stream.ts","../lib/api/certificate.ts","../lib/api/client.ts","../lib/api/contacts.ts","../lib/api/credentials.ts","../lib/api/export.ts","../lib/api/files.ts","../lib/api/groups.ts","../lib/api/iconsets.ts","../lib/api/injectors.ts","../lib/api/locate.ts","../lib/api/mission-invite.ts","../lib/api/mission-layer.ts","../lib/api/mission-log.ts","../lib/api/mission.ts","../lib/api/oauth.ts","../lib/api/package.ts","../lib/api/profile.ts","../lib/api/query.ts","../lib/api/repeater.ts","../lib/api/security.ts","../lib/api/subscriptions.ts","../lib/api/types.ts","../lib/api/video.ts","../lib/utils/queue.ts"],"version":"6.0.3"}
1
+ {"root":["../cli.ts","../index.ts","../test/certificate.test.ts","../test/default.test.ts","../test/findCoT.test.ts","../test/html-error.test.ts","../test/mission-layer.test.ts","../test/mission.test.ts","../test/pipeline.test.ts","../test/queue.test.ts","../test/uploadPackage.test.ts","../lib/api.ts","../lib/auth.ts","../lib/commands.ts","../lib/fetch.ts","../lib/multipart.ts","../lib/stream.ts","../lib/api/certificate.ts","../lib/api/client.ts","../lib/api/contacts.ts","../lib/api/credentials.ts","../lib/api/export.ts","../lib/api/files.ts","../lib/api/groups.ts","../lib/api/iconsets.ts","../lib/api/injectors.ts","../lib/api/locate.ts","../lib/api/mission-invite.ts","../lib/api/mission-layer.ts","../lib/api/mission-log.ts","../lib/api/mission.ts","../lib/api/oauth.ts","../lib/api/package.ts","../lib/api/profile.ts","../lib/api/query.ts","../lib/api/repeater.ts","../lib/api/security.ts","../lib/api/subscriptions.ts","../lib/api/types.ts","../lib/api/video.ts","../lib/utils/html-error.ts","../lib/utils/queue.ts"],"version":"6.0.3"}
package/index.ts CHANGED
@@ -9,6 +9,7 @@ import TAKAPI from './lib/api.js';
9
9
  import { TAKAuth } from './lib/auth.js';
10
10
  import { Queue } from './lib/utils/queue.js';
11
11
  export * from './lib/auth.js';
12
+ export * from './lib/utils/html-error.js';
12
13
 
13
14
  /* eslint-disable no-control-regex */
14
15
  export const REGEX_CONTROL = /[\u000B-\u001F\u007F-\u009F]/g;
@@ -1,4 +1,8 @@
1
1
  import Commands from '../commands.js';
2
+ import Err from '@openaddresses/batch-error';
3
+ import { Readable } from 'node:stream';
4
+ import { X509Certificate } from 'node:crypto';
5
+ import { TAKServerError } from '../utils/html-error.js';
2
6
  import { Type } from '@sinclair/typebox';
3
7
  import type { Static } from '@sinclair/typebox';
4
8
  import { TAKItem, TAKList } from './types.js';
@@ -22,6 +26,44 @@ export const Certificate = Type.Object({
22
26
  export const TAKList_Certificate = TAKList(Certificate);
23
27
  export const TAKItem_Certificate = TAKItem(Certificate);
24
28
 
29
+ export const CertificateValidation = Type.Object({
30
+ fingerprint: Type.String({ description: 'SHA-256 fingerprint of the DER encoded certificate - the TAK Server `hash`' }),
31
+ subject: Type.String(),
32
+ username: Type.String({ description: 'Common Name of the certificate subject - the TAK Server `userDn`' }),
33
+ validFrom: Type.String(),
34
+ validTo: Type.String(),
35
+ expired: Type.Boolean({ description: 'validTo is in the past' }),
36
+ known: Type.Boolean({ description: 'The TAK Server has a record for this fingerprint' }),
37
+ revoked: Type.Boolean({ description: 'The TAK Server record carries a revocationDate' }),
38
+ revocationDate: Type.Optional(Type.String({ format: 'date-time' })),
39
+ valid: Type.Boolean({ description: 'Neither expired nor revoked' }),
40
+ certificate: Type.Optional(Certificate)
41
+ });
42
+
43
+ export const CertificateProbe = Type.Object({
44
+ accepted: Type.Boolean({ description: 'The TAK Server authenticated the credentials' }),
45
+ version: Type.Optional(Type.String({ description: 'TAK Server version string when accepted' })),
46
+ reason: Type.Optional(Type.Union([
47
+ Type.Literal('revoked', { description: 'X509Authenticator raised RevokedException' }),
48
+ Type.Literal('rejected', { description: 'Authentication failed for another reason (BadCredentialsException)' }),
49
+ Type.Literal('tls', { description: 'The TLS handshake failed - typically an expired or untrusted client certificate' })
50
+ ])),
51
+ message: Type.Optional(Type.String())
52
+ });
53
+
54
+ /**
55
+ * Extract the Common Name from an X509 subject string as reported by Node
56
+ * (newline separated `key=value` pairs)
57
+ */
58
+ function commonName(subject: string): string | undefined {
59
+ for (const line of subject.split('\n')) {
60
+ const [key, ...rest] = line.split('=');
61
+ if (key.trim() === 'CN') return rest.join('=').trim();
62
+ }
63
+
64
+ return undefined;
65
+ }
66
+
25
67
  export default class CertificateCommands extends Commands {
26
68
  schema = {}
27
69
 
@@ -46,6 +88,19 @@ export default class CertificateCommands extends Commands {
46
88
  return await this.api.fetch(url) as Static<typeof TAKList_Certificate>;
47
89
  }
48
90
 
91
+ /**
92
+ * List Active Certificates
93
+ *
94
+ * The most recently issued certificate for each Client UID / User DN pair
95
+ *
96
+ * {@link https://docs.tak.gov/api/takserver/redoc#tag/cert-manager-admin-api/operation/getActive TAK Server Docs}.
97
+ */
98
+ async listActive(): Promise<Static<typeof TAKList_Certificate>> {
99
+ const url = new URL('/Marti/api/certadmin/cert/active', this.api.url);
100
+
101
+ return await this.api.fetch(url) as Static<typeof TAKList_Certificate>;
102
+ }
103
+
49
104
  /**
50
105
  * List Revoked Certificates
51
106
  *
@@ -79,6 +134,105 @@ export default class CertificateCommands extends Commands {
79
134
  return await this.api.fetch(url) as Static<typeof TAKList_Certificate>;
80
135
  }
81
136
 
137
+ /**
138
+ * Probe whether the TAK Server accepts the credentials this API instance was created with
139
+ *
140
+ * The X509 filter runs on every request and rethrows on failure, so `GET /Marti/api/version`
141
+ * (an anonymous, DB-free endpoint returning a short string) is the cheapest authoritative
142
+ * check - unlike {@link validate} it reflects the server's actual enforcement (e.g. whether
143
+ * `x509checkRevocation` is enabled) and requires no admin credentials. An expired client
144
+ * certificate is rejected during the TLS handshake and surfaces as `reason: 'tls'`.
145
+ *
146
+ * Only authentication failures are returned as a verdict - any other error (server
147
+ * unreachable, unexpected status) is rethrown so it is not mistaken for a rejection
148
+ */
149
+ async probe(): Promise<Static<typeof CertificateProbe>> {
150
+ const url = new URL('/Marti/api/version', this.api.url);
151
+
152
+ try {
153
+ const version = String(await this.api.fetch(url)).trim();
154
+
155
+ return { accepted: true, version };
156
+ } catch (err) {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ const details = err instanceof TAKServerError ? err.details : '';
159
+ const haystack = `${message}\n${details}`;
160
+
161
+ let reason: Static<typeof CertificateProbe>['reason'];
162
+ if (/RevokedException|revoked certificate/i.test(haystack)) {
163
+ reason = 'revoked';
164
+ } else if (/BadCredentialsException|AuthenticationException|TAK Server authentication/i.test(haystack)) {
165
+ reason = 'rejected';
166
+ } else if (
167
+ err instanceof Err && err.status === 400 && !(err instanceof TAKServerError)
168
+ && /certificate|handshake|alert|EPROTO|ECONNRESET|SSL|TLS/i.test(haystack)
169
+ ) {
170
+ reason = 'tls';
171
+ } else {
172
+ throw err;
173
+ }
174
+
175
+ return { accepted: false, reason, message };
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Validate a PEM encoded client certificate against local expiry and the TAK Server revocation record
181
+ *
182
+ * Expiry is evaluated locally from the certificate. Revocation is looked up via the
183
+ * Certificate Admin API using the same SHA-256 fingerprint the TAK Server `X509Authenticator`
184
+ * consults - `get(hash)` first (a single cached row) falling back to the user's certificate
185
+ * list when the TAK Server answers 500, which is how it reports an unknown hash.
186
+ *
187
+ * Note: The TAK Server only enforces revocation when `auth.x509checkRevocation` (or
188
+ * `x509TokenAuth`) is enabled in CoreConfig - `revoked` reports the stored state regardless
189
+ */
190
+ async validate(
191
+ pem: string,
192
+ opts: {
193
+ now?: Date;
194
+ } = {}
195
+ ): Promise<Static<typeof CertificateValidation>> {
196
+ const x509 = new X509Certificate(pem);
197
+ const now = opts.now ?? new Date();
198
+
199
+ const username = commonName(x509.subject);
200
+ if (!username) throw new Error('Certificate subject does not contain a Common Name');
201
+
202
+ const fingerprint = x509.fingerprint256.toUpperCase();
203
+
204
+ let certificate: Static<typeof Certificate> | undefined;
205
+ try {
206
+ // Single cached row lookup - the same query X509Authenticator performs
207
+ certificate = (await this.get(fingerprint)).data;
208
+ } catch (err) {
209
+ // The TAK Server reports an unknown hash via sendError(500), which is
210
+ // indistinguishable from a genuine failure - disambiguate against the
211
+ // (uncached, but authoritative) list of the user's certificates
212
+ if (!(err instanceof Err) || err.status < 500) throw err;
213
+
214
+ certificate = (await this.list(username)).data
215
+ .find((cert) => cert.hash.toUpperCase() === fingerprint);
216
+ }
217
+
218
+ const expired = Date.parse(x509.validTo) < now.getTime();
219
+ const revoked = !!certificate?.revocationDate;
220
+
221
+ return {
222
+ fingerprint,
223
+ subject: x509.subject,
224
+ username,
225
+ validFrom: x509.validFrom,
226
+ validTo: x509.validTo,
227
+ expired,
228
+ known: !!certificate,
229
+ revoked,
230
+ revocationDate: certificate?.revocationDate,
231
+ valid: !expired && !revoked,
232
+ certificate
233
+ };
234
+ }
235
+
82
236
  /**
83
237
  * Get Single Certificate
84
238
  *
@@ -107,6 +261,36 @@ export default class CertificateCommands extends Commands {
107
261
  }) as string;
108
262
  }
109
263
 
264
+ /**
265
+ * Download Certificates by Id
266
+ *
267
+ * Returns a ZIP archive stream containing one `{n}_{userDn}_ClientCert.pem` entry per certificate
268
+ *
269
+ * {@link https://docs.tak.gov/api/takserver/redoc#tag/cert-manager-admin-api/operation/downloadCertificates TAK Server Docs}.
270
+ */
271
+ async downloadIds(
272
+ ids: Array<string | number>
273
+ ): Promise<Readable> {
274
+ if (ids.length === 0) {
275
+ throw new Error('At least one ID must be provided');
276
+ }
277
+
278
+ const url = new URL(`/Marti/api/certadmin/cert/download/${ids.map((id) => encodeURIComponent(String(id))).join(',')}`, this.api.url);
279
+
280
+ const res = await this.api.fetch(url, {
281
+ method: 'GET'
282
+ }, true);
283
+
284
+ // The raw fetch path skips status validation - without this check a
285
+ // TAK Server error page would be streamed as if it were the ZIP archive
286
+ if (res.status < 200 || res.status >= 400) {
287
+ const body = await res.text().catch(() => '');
288
+ throw new Err(res.status, null, body || `Status Code: ${res.status}`);
289
+ }
290
+
291
+ return res.body;
292
+ }
293
+
110
294
  /**
111
295
  * Revoke Single Certificate
112
296
  *
package/lib/api.ts CHANGED
@@ -21,6 +21,7 @@ import Subscription from './api/subscriptions.js';
21
21
  import Video from './api/video.js';
22
22
  import Export from './api/export.js';
23
23
  import Err from '@openaddresses/batch-error';
24
+ import { TAKServerError, isHTML } from './utils/html-error.js';
24
25
  import * as auth from './auth.js';
25
26
 
26
27
  type ErrorWithCause = Error & {
@@ -210,6 +211,9 @@ export default class TAKAPI {
210
211
  bdy = null;
211
212
  }
212
213
 
214
+ // TAK Server returns HTML error pages
215
+ if (isHTML(bdy)) throw new TAKServerError(res.status, bdy);
216
+
213
217
  throw new Err(res.status, null, bdy || `Status Code: ${res.status}`);
214
218
  }
215
219
 
@@ -219,7 +223,7 @@ export default class TAKAPI {
219
223
  return await res.text();
220
224
  }
221
225
  } catch (err) {
222
- if (err instanceof Error && err.name === 'PublicError') throw err;
226
+ if (err instanceof Err) throw err;
223
227
  throw new Err(400, null, formatFetchError(err));
224
228
  }
225
229
  }
@@ -0,0 +1,208 @@
1
+ import Err from '@openaddresses/batch-error';
2
+ import xmljs from '@tak-ps/xml-js';
3
+ import type { Element } from '@tak-ps/xml-js';
4
+
5
+ /**
6
+ * TAK Server (Tomcat) returns HTML "Exception Report" pages on failure.
7
+ * Parse them into a human readable message + plain-text details
8
+ * so clients don't render raw HTML.
9
+ */
10
+ export interface HTMLErrorDetails {
11
+ /** <title> or <h1> */
12
+ title?: string;
13
+ /** <p><b>Label</b> value</p> pairs - ie: Type, Message, Description */
14
+ fields: Record<string, string>;
15
+ /** <pre> stack traces */
16
+ exception?: string;
17
+ /** Visible text fallback */
18
+ text: string;
19
+ }
20
+
21
+ const HIDDEN = new Set(['style', 'script', 'head']);
22
+
23
+ function collapse(input: string): string {
24
+ return input.replace(/\s+/g, ' ').trim();
25
+ }
26
+
27
+ /** Linear-time tag stripper for malformed HTML - drops the contents of HIDDEN elements */
28
+ function stripTags(html: string): string {
29
+ const lower = html.toLowerCase();
30
+ let out = '';
31
+ let i = 0;
32
+
33
+ while (i < html.length) {
34
+ const open = html.indexOf('<', i);
35
+ if (open === -1) {
36
+ out += html.slice(i);
37
+ break;
38
+ }
39
+
40
+ out += html.slice(i, open) + ' ';
41
+
42
+ const close = html.indexOf('>', open);
43
+ if (close === -1) break;
44
+
45
+ const tag = lower.slice(open + 1, close).trim().split(/\s/, 1)[0];
46
+ i = close + 1;
47
+
48
+ if (HIDDEN.has(tag)) {
49
+ const end = lower.indexOf(`</${tag}`, i);
50
+ if (end === -1) break;
51
+ i = html.indexOf('>', end);
52
+ if (i === -1) break;
53
+ i += 1;
54
+ }
55
+ }
56
+
57
+ return collapse(out);
58
+ }
59
+
60
+ function textOf(el: Element, opts: { skipPre?: boolean } = {}): string {
61
+ if (el.type === 'text') return String(el.text ?? '');
62
+ if (el.type !== 'element' || !el.elements) return '';
63
+ if (opts.skipPre && el.name === 'pre') return '';
64
+
65
+ return el.elements.map((child) => textOf(child, opts)).join('');
66
+ }
67
+
68
+ function* walk(el: Element, skipHidden = true): Generator<Element> {
69
+ if (el.type === 'element' && el.name && skipHidden && HIDDEN.has(el.name.toLowerCase())) return;
70
+
71
+ if (el.type === 'element') yield el;
72
+
73
+ for (const child of el.elements || []) yield* walk(child, skipHidden);
74
+ }
75
+
76
+ function find(root: Element, name: string): Element | undefined {
77
+ for (const el of walk(root, false)) {
78
+ if (el.name?.toLowerCase() === name) return el;
79
+ }
80
+ }
81
+
82
+ export function isHTML(body: unknown): body is string {
83
+ if (typeof body !== 'string') return false;
84
+
85
+ const head = body.slice(0, 512).trimStart().toLowerCase();
86
+
87
+ return head.startsWith('<!doctype html')
88
+ || head.startsWith('<html')
89
+ || /^<(head|body|title|h1)[\s>]/.test(head);
90
+ }
91
+
92
+ /** Returns null if body is not HTML */
93
+ export function parseHTMLError(body: unknown): HTMLErrorDetails | null {
94
+ if (!isHTML(body)) return null;
95
+
96
+ const details: HTMLErrorDetails = {
97
+ fields: {},
98
+ text: '',
99
+ };
100
+
101
+ let root: Element;
102
+ try {
103
+ root = xmljs.xml2js(body, { compact: false }) as Element;
104
+ } catch {
105
+ // Not well-formed XHTML - fall back to visible text
106
+ details.text = stripTags(body);
107
+
108
+ return details;
109
+ }
110
+
111
+ const title = find(root, 'title') || find(root, 'h1');
112
+ if (title && collapse(textOf(title))) details.title = collapse(textOf(title));
113
+
114
+ const pres: string[] = [];
115
+ let text = '';
116
+
117
+ for (const el of walk(root)) {
118
+ const name = el.name?.toLowerCase();
119
+
120
+ if (name === 'p' && el.elements?.[0]?.name?.toLowerCase() === 'b') {
121
+ const label = collapse(textOf(el.elements[0]));
122
+ const value = collapse(el.elements.slice(1).map((child) => textOf(child)).join(''));
123
+
124
+ if (label && value) details.fields[label] = value;
125
+ } else if (name === 'pre') {
126
+ const value = textOf(el)
127
+ .split('\n')
128
+ .map((line) => line.trimEnd())
129
+ .join('\n')
130
+ .trim();
131
+
132
+ if (value) pres.push(value);
133
+ } else if (name === 'body') {
134
+ text = collapse(textOf(el, { skipPre: true }));
135
+ }
136
+ }
137
+
138
+ if (pres.length) details.exception = pres.join('\n\n');
139
+
140
+ details.text = text || collapse(textOf(root, { skipPre: true }));
141
+
142
+ return details;
143
+ }
144
+
145
+ /** Most useful human readable line from the page */
146
+ export function summarizeHTMLError(details: HTMLErrorDetails, status?: number): string {
147
+ const message = details.fields.Message
148
+ || details.fields.message
149
+ || details.fields.Description
150
+ || details.fields.description;
151
+
152
+ if (message) return message;
153
+
154
+ if (details.exception) {
155
+ // "<ExceptionClass>: <message>"
156
+ const first = details.exception.split('\n')[0].trim();
157
+ const colon = first.indexOf(': ');
158
+ if (colon !== -1 && colon < first.length - 2) return first.slice(colon + 2).trim();
159
+ }
160
+
161
+ if (details.title) return details.title;
162
+
163
+ if (details.text) return details.text.length > 200 ? details.text.slice(0, 197) + '...' : details.text;
164
+
165
+ return status ? `Status Code: ${status}` : 'Unknown TAK Server Error';
166
+ }
167
+
168
+ /** Plain-text breakdown for an "Advanced" details pane */
169
+ export function formatHTMLError(details: HTMLErrorDetails): string {
170
+ const lines: string[] = [];
171
+
172
+ if (details.title) lines.push(details.title);
173
+
174
+ for (const [label, value] of Object.entries(details.fields)) {
175
+ if (value) lines.push(`${label}: ${value}`);
176
+ }
177
+
178
+ if (details.exception) {
179
+ if (lines.length) lines.push('');
180
+ lines.push(details.exception);
181
+ }
182
+
183
+ if (!lines.length && details.text) lines.push(details.text);
184
+
185
+ return lines.join('\n');
186
+ }
187
+
188
+ /**
189
+ * TAK Server HTML error page - `safe` is the summary, `details` the full breakdown
190
+ */
191
+ export class TAKServerError extends Err {
192
+ details: string;
193
+ html: string;
194
+ parsed: HTMLErrorDetails;
195
+
196
+ constructor(status: number, html: string, parsed?: HTMLErrorDetails) {
197
+ const details = parsed || parseHTMLError(html) || {
198
+ fields: {},
199
+ text: stripTags(html)
200
+ };
201
+
202
+ super(status, null, summarizeHTMLError(details, status));
203
+
204
+ this.parsed = details;
205
+ this.details = formatHTMLError(details);
206
+ this.html = html;
207
+ }
208
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tak-ps/node-tak",
3
3
  "type": "module",
4
- "version": "12.24.0",
4
+ "version": "12.26.0",
5
5
  "description": "Lightweight JavaScript library for communicating with TAK Server",
6
6
  "author": "Nick Ingalls <nick@ingalls.ca>",
7
7
  "main": "dist/index.js",