@ltorresu82/firmagob-client 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luis Torres
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @ltorresu82/firmagob-client
2
+
3
+ Cliente TypeScript para integrar FirmaGob Chile sin depender de librerias PDF deprecadas.
4
+
5
+ Estado inicial:
6
+
7
+ - cliente HTTP/JWT para FirmaGob v2;
8
+ - soporte para firma por hash;
9
+ - utilidades para preparar e inyectar una firma PKCS#7 externa en PDFs;
10
+ - sin dependencias runtime.
11
+
12
+ Este paquete no es un SDK oficial de Gobierno Digital.
13
+
14
+ ## Fuentes oficiales
15
+
16
+ El diseño del cliente se guia por la documentacion y ejemplos publicados por Gobierno Digital:
17
+
18
+ - [Manual de Integracion API FirmaGob, v.17 - Febrero 2026](https://firma.digital.gob.cl/biblioteca/manuales-firmagob/manual-api-firma/)
19
+ - [Organizacion oficial digital-gob-cl en GitHub](https://github.com/digital-gob-cl)
20
+ - [digital-gob-cl/firma-hash-ejemplo](https://github.com/digital-gob-cl/firma-hash-ejemplo)
21
+ - [digital-gob-cl/firma-pdf-ejemplo](https://github.com/digital-gob-cl/firma-pdf-ejemplo)
22
+ - [digital-gob-cl/firma-pdf-layout-ejemplo](https://github.com/digital-gob-cl/firma-pdf-layout-ejemplo)
23
+ - [digital-gob-cl/firma-json-ejemplo](https://github.com/digital-gob-cl/firma-json-ejemplo)
24
+ - [digital-gob-cl/firma-xml-ejemplo](https://github.com/digital-gob-cl/firma-xml-ejemplo)
25
+
26
+ Cuando exista diferencia entre este paquete y una fuente oficial vigente, debe prevalecer la fuente oficial.
27
+
28
+ ## Instalacion
29
+
30
+ ```bash
31
+ npm install @ltorresu82/firmagob-client
32
+ ```
33
+
34
+ ## Firma de hashes
35
+
36
+ ```ts
37
+ import { FirmaGobClient, Purpose } from "@ltorresu82/firmagob-client";
38
+
39
+ const client = new FirmaGobClient({
40
+ apiTokenKey: process.env.FIRMAGOB_API_TOKEN_KEY!,
41
+ secret: process.env.FIRMAGOB_SECRET!,
42
+ entity: process.env.FIRMAGOB_ENTITY!,
43
+ run: process.env.FIRMAGOB_RUN!,
44
+ purpose: Purpose.Unattended,
45
+ environment: "test",
46
+ });
47
+
48
+ const response = await client.signHashes([
49
+ { content: "sha256-base64-del-pdf-preparado", contentType: "application/pdf" },
50
+ ]);
51
+ ```
52
+
53
+ El JWT incluye los claims `entity`, `run`, `purpose` y `expiration`. Por defecto el token expira en 5 minutos, alineado con los ejemplos oficiales. Se puede ajustar con `tokenTtlSeconds` si el ambiente lo requiere.
54
+
55
+ ## PDF con firma externa
56
+
57
+ ```ts
58
+ import {
59
+ embedExternalSignature,
60
+ preparePdfForExternalSignature,
61
+ sha256Base64,
62
+ } from "@ltorresu82/firmagob-client";
63
+
64
+ const prepared = preparePdfForExternalSignature(pdfWithPlaceholder);
65
+ const hash = sha256Base64(prepared.bytesToHash);
66
+
67
+ // Enviar hash a FirmaGob y recibir PKCS#7 base64.
68
+ const signedPdf = embedExternalSignature({
69
+ preparedPdf: prepared.bytesToHash,
70
+ pkcs7Signature: firmaGobPkcs7Base64,
71
+ placeholderLength: prepared.placeholderLength,
72
+ signatureOffset: prepared.signatureOffset,
73
+ });
74
+ ```
75
+
76
+ ## Validacion sandbox
77
+
78
+ El repositorio incluye un ejemplo de firma por hash basado en el flujo oficial. Por seguridad, no trae credenciales embebidas; las lee desde variables de entorno.
79
+
80
+ Variables requeridas:
81
+
82
+ - `FIRMAGOB_ENTITY`
83
+ - `FIRMAGOB_API_TOKEN_KEY`
84
+ - `FIRMAGOB_RUN`
85
+ - `FIRMAGOB_PURPOSE`
86
+ - `FIRMAGOB_SECRET`
87
+ - `FIRMAGOB_ENDPOINT_API`
88
+
89
+ Validacion local sin llamada a FirmaGob:
90
+
91
+ ```bash
92
+ npm run validate:sandbox:dry-run
93
+ ```
94
+
95
+ Validacion real contra sandbox:
96
+
97
+ ```bash
98
+ npm run validate:sandbox
99
+ ```
100
+
101
+ El ejemplo escribe evidencia temporal en `tmp/sandbox-hash/`.
102
+
103
+ Para solicitar credenciales a la institucion o al equipo FirmaGob, ver [docs/credentials.md](docs/credentials.md).
104
+
105
+ ## Desarrollo
106
+
107
+ ```bash
108
+ npm install
109
+ npm test
110
+ npm pack --dry-run
111
+ ```
@@ -0,0 +1,71 @@
1
+ export type Environment = "test" | "production";
2
+ export declare const Purpose: {
3
+ readonly Attended: "Propósito General";
4
+ readonly Unattended: "Desatendido";
5
+ };
6
+ export type Purpose = (typeof Purpose)[keyof typeof Purpose];
7
+ export type FirmaGobClientConfig = {
8
+ apiTokenKey: string;
9
+ secret: string;
10
+ entity: string;
11
+ run: string;
12
+ purpose?: Purpose;
13
+ environment?: Environment;
14
+ tokenTtlSeconds?: number;
15
+ fetch?: typeof fetch;
16
+ testUrl?: string;
17
+ productionUrl?: string;
18
+ };
19
+ export type FirmaGobFileInput = {
20
+ content: string;
21
+ contentType: string;
22
+ checksum?: string;
23
+ description?: string;
24
+ layout?: string;
25
+ references?: string[];
26
+ xmlObjects?: string[];
27
+ };
28
+ export type FirmaGobHashInput = {
29
+ content: string;
30
+ contentType?: "application/pdf";
31
+ description?: string;
32
+ };
33
+ export type FirmaGobSignOutput = {
34
+ metadata: {
35
+ otpExpired: boolean;
36
+ filesSigned: number;
37
+ signedFailed: number;
38
+ objectReceived: number;
39
+ };
40
+ status: number;
41
+ error?: string;
42
+ idSolicitud?: number;
43
+ files?: Array<Record<string, unknown>>;
44
+ hashes?: Array<{
45
+ content: string;
46
+ status: "OK" | "error";
47
+ contentType: string;
48
+ documentStatus: string;
49
+ checksum_original: string | null;
50
+ hashOriginal?: string;
51
+ }>;
52
+ };
53
+ export declare class FirmaGobClientError extends Error {
54
+ readonly status?: number | undefined;
55
+ readonly responseBody?: string | undefined;
56
+ constructor(message: string, status?: number | undefined, responseBody?: string | undefined);
57
+ }
58
+ export declare class FirmaGobClient {
59
+ private readonly config;
60
+ private readonly fetchImpl;
61
+ constructor(config: FirmaGobClientConfig);
62
+ signHashes(hashes: FirmaGobHashInput[], options?: {
63
+ otp?: string;
64
+ }): Promise<FirmaGobSignOutput>;
65
+ signFiles(files: FirmaGobFileInput[], options?: {
66
+ otp?: string;
67
+ }): Promise<FirmaGobSignOutput>;
68
+ createToken(now?: Date): string;
69
+ private sign;
70
+ private get url();
71
+ }
@@ -0,0 +1,118 @@
1
+ import { createHmac } from "node:crypto";
2
+ export const Purpose = {
3
+ Attended: "Propósito General",
4
+ Unattended: "Desatendido",
5
+ };
6
+ export class FirmaGobClientError extends Error {
7
+ status;
8
+ responseBody;
9
+ constructor(message, status, responseBody) {
10
+ super(message);
11
+ this.status = status;
12
+ this.responseBody = responseBody;
13
+ this.name = "FirmaGobClientError";
14
+ }
15
+ }
16
+ const TEST_URL = "https://api.firma.cert.digital.gob.cl/firma/v2/files/tickets";
17
+ const PRODUCTION_URL = "https://api.firma.digital.gob.cl/firma/v2/files/tickets";
18
+ const DEFAULT_TOKEN_TTL_SECONDS = 5 * 60;
19
+ export class FirmaGobClient {
20
+ config;
21
+ fetchImpl;
22
+ constructor(config) {
23
+ this.config = config;
24
+ assertRequired("apiTokenKey", config.apiTokenKey);
25
+ assertRequired("secret", config.secret);
26
+ assertRequired("entity", config.entity);
27
+ assertRequired("run", config.run);
28
+ this.fetchImpl = config.fetch ?? fetch;
29
+ }
30
+ async signHashes(hashes, options = {}) {
31
+ if (hashes.length === 0) {
32
+ throw new FirmaGobClientError("At least one hash is required");
33
+ }
34
+ return this.sign({
35
+ hashes: hashes.map((hash) => ({
36
+ "content-type": hash.contentType ?? "application/pdf",
37
+ content: hash.content,
38
+ description: hash.description,
39
+ })),
40
+ }, options.otp);
41
+ }
42
+ async signFiles(files, options = {}) {
43
+ if (files.length === 0) {
44
+ throw new FirmaGobClientError("At least one file is required");
45
+ }
46
+ return this.sign({
47
+ files: files.map((file) => ({
48
+ "content-type": file.contentType,
49
+ content: file.content,
50
+ checksum: file.checksum,
51
+ description: file.description,
52
+ layout: file.layout,
53
+ references: file.references,
54
+ xmlObjects: file.xmlObjects,
55
+ })),
56
+ }, options.otp);
57
+ }
58
+ createToken(now = new Date()) {
59
+ const tokenTtlSeconds = this.config.tokenTtlSeconds ?? DEFAULT_TOKEN_TTL_SECONDS;
60
+ const header = base64UrlEncodeJson({ alg: "HS256", typ: "JWT" });
61
+ const payload = base64UrlEncodeJson({
62
+ entity: this.config.entity,
63
+ run: this.config.run,
64
+ purpose: this.config.purpose ?? Purpose.Unattended,
65
+ expiration: new Date(now.getTime() + tokenTtlSeconds * 1000).toISOString(),
66
+ });
67
+ const unsignedToken = `${header}.${payload}`;
68
+ const signature = createHmac("sha256", this.config.secret)
69
+ .update(unsignedToken)
70
+ .digest("base64url");
71
+ return `${unsignedToken}.${signature}`;
72
+ }
73
+ async sign(payload, otp) {
74
+ const purpose = this.config.purpose ?? Purpose.Unattended;
75
+ if (purpose === Purpose.Attended && !otp) {
76
+ throw new FirmaGobClientError("Attended signatures require an OTP");
77
+ }
78
+ const response = await this.fetchImpl(this.url, {
79
+ method: "POST",
80
+ headers: {
81
+ "content-type": "application/json",
82
+ ...(otp ? { otp } : {}),
83
+ },
84
+ body: JSON.stringify({
85
+ api_token_key: this.config.apiTokenKey,
86
+ token: this.createToken(),
87
+ ...payload,
88
+ }),
89
+ });
90
+ const body = await response.text();
91
+ if (!response.ok) {
92
+ throw new FirmaGobClientError(`FirmaGob request failed with HTTP ${response.status}`, response.status, body);
93
+ }
94
+ return parseJsonResponse(body);
95
+ }
96
+ get url() {
97
+ if ((this.config.environment ?? "test") === "production") {
98
+ return this.config.productionUrl ?? PRODUCTION_URL;
99
+ }
100
+ return this.config.testUrl ?? TEST_URL;
101
+ }
102
+ }
103
+ function assertRequired(name, value) {
104
+ if (!value || value.trim().length === 0) {
105
+ throw new FirmaGobClientError(`Missing required config: ${name}`);
106
+ }
107
+ }
108
+ function base64UrlEncodeJson(value) {
109
+ return Buffer.from(JSON.stringify(value)).toString("base64url");
110
+ }
111
+ function parseJsonResponse(body) {
112
+ try {
113
+ return JSON.parse(body);
114
+ }
115
+ catch (error) {
116
+ throw new FirmaGobClientError("FirmaGob returned a non-JSON response", undefined, body);
117
+ }
118
+ }
@@ -0,0 +1,3 @@
1
+ export { FirmaGobClient, FirmaGobClientError, Environment, Purpose, type FirmaGobClientConfig, type FirmaGobFileInput, type FirmaGobHashInput, type FirmaGobSignOutput, } from "./firmagob-client.js";
2
+ export { DEFAULT_BYTE_RANGE_PLACEHOLDER, embedExternalSignature, preparePdfForExternalSignature, type PreparedPdfForExternalSignature, } from "./pdf-external-signature.js";
3
+ export { sha256Base64 } from "./sha256.js";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { FirmaGobClient, FirmaGobClientError, Purpose, } from "./firmagob-client.js";
2
+ export { DEFAULT_BYTE_RANGE_PLACEHOLDER, embedExternalSignature, preparePdfForExternalSignature, } from "./pdf-external-signature.js";
3
+ export { sha256Base64 } from "./sha256.js";
@@ -0,0 +1,16 @@
1
+ export declare const DEFAULT_BYTE_RANGE_PLACEHOLDER = "********** ********** **********";
2
+ export type PreparedPdfForExternalSignature = {
3
+ bytesToHash: Buffer;
4
+ placeholderLength: number;
5
+ signatureOffset: number;
6
+ byteRange: [number, number, number, number];
7
+ };
8
+ export declare function preparePdfForExternalSignature(pdfBuffer: Buffer | Uint8Array, options?: {
9
+ byteRangePlaceholder?: string;
10
+ }): PreparedPdfForExternalSignature;
11
+ export declare function embedExternalSignature(input: {
12
+ preparedPdf: Buffer | Uint8Array;
13
+ pkcs7Signature: Buffer | Uint8Array | string;
14
+ placeholderLength: number;
15
+ signatureOffset: number;
16
+ }): Buffer;
@@ -0,0 +1,72 @@
1
+ export const DEFAULT_BYTE_RANGE_PLACEHOLDER = "********** ********** **********";
2
+ export function preparePdfForExternalSignature(pdfBuffer, options = {}) {
3
+ const placeholder = options.byteRangePlaceholder ?? DEFAULT_BYTE_RANGE_PLACEHOLDER;
4
+ let pdf = removeTrailingNewLine(Buffer.from(pdfBuffer));
5
+ const byteRangePosition = pdf.indexOf(placeholder);
6
+ if (byteRangePosition < 0) {
7
+ throw new Error(`Could not find ByteRange placeholder: ${placeholder}`);
8
+ }
9
+ const byteRangeEnd = byteRangePosition + placeholder.length;
10
+ const contentsPosition = pdf.indexOf("/Contents ", byteRangeEnd);
11
+ if (contentsPosition < 0) {
12
+ throw new Error("Could not find /Contents after /ByteRange");
13
+ }
14
+ const signatureStart = pdf.indexOf("<", contentsPosition);
15
+ const signatureEnd = pdf.indexOf(">", signatureStart);
16
+ if (signatureStart < 0 || signatureEnd < 0) {
17
+ throw new Error("Could not find /Contents hex placeholder");
18
+ }
19
+ const placeholderLengthWithBrackets = signatureEnd + 1 - signatureStart;
20
+ const placeholderLength = placeholderLengthWithBrackets - 2;
21
+ const byteRange = [
22
+ 0,
23
+ signatureStart,
24
+ signatureStart + placeholderLengthWithBrackets,
25
+ pdf.length - (signatureStart + placeholderLengthWithBrackets),
26
+ ];
27
+ const byteRangeText = `/ByteRange [${byteRange.join(" ")}]`;
28
+ if (byteRangeText.length > placeholder.length) {
29
+ throw new Error("Calculated /ByteRange does not fit in placeholder");
30
+ }
31
+ const paddedByteRangeText = byteRangeText + " ".repeat(placeholder.length - byteRangeText.length);
32
+ pdf = Buffer.concat([
33
+ pdf.subarray(0, byteRangePosition),
34
+ Buffer.from(paddedByteRangeText),
35
+ pdf.subarray(byteRangeEnd),
36
+ ]);
37
+ const bytesToHash = Buffer.concat([
38
+ pdf.subarray(0, byteRange[1]),
39
+ pdf.subarray(byteRange[2], byteRange[2] + byteRange[3]),
40
+ ]);
41
+ return {
42
+ bytesToHash,
43
+ placeholderLength,
44
+ signatureOffset: byteRange[1],
45
+ byteRange,
46
+ };
47
+ }
48
+ export function embedExternalSignature(input) {
49
+ const pdf = Buffer.from(input.preparedPdf);
50
+ const signature = Buffer.isBuffer(input.pkcs7Signature)
51
+ ? input.pkcs7Signature
52
+ : typeof input.pkcs7Signature === "string"
53
+ ? Buffer.from(input.pkcs7Signature, "base64")
54
+ : Buffer.from(input.pkcs7Signature);
55
+ const signatureHex = signature.toString("hex");
56
+ if (signatureHex.length > input.placeholderLength) {
57
+ throw new Error("PKCS#7 signature is larger than PDF placeholder");
58
+ }
59
+ const paddedSignatureHex = signatureHex.padEnd(input.placeholderLength, "0");
60
+ return Buffer.concat([
61
+ pdf.subarray(0, input.signatureOffset),
62
+ Buffer.from(`<${paddedSignatureHex}>`),
63
+ pdf.subarray(input.signatureOffset),
64
+ ]);
65
+ }
66
+ function removeTrailingNewLine(pdf) {
67
+ let end = pdf.length;
68
+ while (end > 0 && (pdf[end - 1] === 0x0a || pdf[end - 1] === 0x0d)) {
69
+ end -= 1;
70
+ }
71
+ return end === pdf.length ? pdf : pdf.subarray(0, end);
72
+ }
@@ -0,0 +1 @@
1
+ export declare function sha256Base64(input: Buffer | Uint8Array | string): string;
package/dist/sha256.js ADDED
@@ -0,0 +1,4 @@
1
+ import { createHash } from "node:crypto";
2
+ export function sha256Base64(input) {
3
+ return createHash("sha256").update(input).digest("base64");
4
+ }
@@ -0,0 +1,58 @@
1
+ # Credenciales de integracion FirmaGob
2
+
3
+ Este paquete no incluye credenciales de prueba ni productivas. Para ejecutar una validacion real contra FirmaGob se debe solicitar una aplicacion API habilitada para la institucion.
4
+
5
+ ## Variables requeridas
6
+
7
+ | Variable | Descripcion |
8
+ | --- | --- |
9
+ | `FIRMAGOB_ENTITY` | Codigo o nombre de entidad registrado para la institucion en FirmaGob. |
10
+ | `FIRMAGOB_API_TOKEN_KEY` | Identificador publico de la aplicacion API registrada. |
11
+ | `FIRMAGOB_SECRET` | Secreto de la aplicacion API usado para firmar el JWT HS256. |
12
+ | `FIRMAGOB_RUN` | RUN del firmante habilitado, sin puntos, guion ni digito verificador. |
13
+ | `FIRMAGOB_PURPOSE` | Proposito del certificado: `Desatendido` o `Propósito General`. |
14
+ | `FIRMAGOB_ENDPOINT_API` | Endpoint API del ambiente correspondiente. |
15
+
16
+ Endpoint de certificacion usado por los ejemplos oficiales:
17
+
18
+ ```text
19
+ https://api.firma.cert.digital.gob.cl/firma/v2/files/tickets
20
+ ```
21
+
22
+ Endpoint productivo:
23
+
24
+ ```text
25
+ https://api.firma.digital.gob.cl/firma/v2/files/tickets
26
+ ```
27
+
28
+ ## Preguntas que deben quedar resueltas
29
+
30
+ - Si la integracion usara firma desatendida o firma atendida con OTP.
31
+ - Que funcionario o certificado institucional quedara autorizado para firmar en certificacion.
32
+ - Si existiran credenciales separadas para desarrollo, QA, UAT y produccion.
33
+ - Quien custodia el secreto y cual es el procedimiento de rotacion/revocacion.
34
+ - Si los documentos se firmaran por hash o por archivo completo. Para PDFs de mas de 5 MB se debe usar firma por hash.
35
+ - Que metadata debe conservar el sistema: hash, `idSolicitud`, fecha/hora, firmante, proposito, respuesta de FirmaGob y documento final.
36
+
37
+ ## Solicitud sugerida
38
+
39
+ ```text
40
+ Necesitamos las credenciales de integracion API FirmaGob para ambiente de certificacion de la institucion:
41
+
42
+ - Codigo de entidad/institucion registrado en FirmaGob
43
+ - API token key de la aplicacion
44
+ - Secret de la aplicacion
45
+ - RUN del firmante habilitado para pruebas, sin puntos, guion ni digito verificador
46
+ - Purpose autorizado: Desatendido o Propósito General
47
+ - Endpoint API de certificacion
48
+ - Confirmacion de si el certificado requiere OTP
49
+
50
+ Estas credenciales seran usadas inicialmente para validar firma por hash de PDFs en ambiente de desarrollo, sin documentos productivos.
51
+ ```
52
+
53
+ ## Manejo seguro
54
+
55
+ - No commitear valores reales en este repositorio.
56
+ - No imprimir secretos en logs ni evidencia.
57
+ - Cargar las variables desde el gestor de secretos del ambiente o desde un archivo local fuera del repositorio.
58
+ - Rotar el secreto si se comparte por canales no controlados.
@@ -0,0 +1,179 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ DEFAULT_BYTE_RANGE_PLACEHOLDER,
5
+ FirmaGobClient,
6
+ Purpose,
7
+ embedExternalSignature,
8
+ preparePdfForExternalSignature,
9
+ sha256Base64,
10
+ } from "../dist/index.js";
11
+
12
+ const dryRun = process.argv.includes("--dry-run");
13
+ const outputDir = join("tmp", "sandbox-hash");
14
+ const placeholderLength = 15000 * 2;
15
+
16
+ mkdirSync(outputDir, { recursive: true });
17
+
18
+ const sourcePdf = createMinimalPdfWithSignaturePlaceholder({
19
+ reason: "Validacion sandbox FirmaGob",
20
+ placeholderLength,
21
+ });
22
+ const prepared = preparePdfForExternalSignature(sourcePdf);
23
+ const hash = sha256Base64(prepared.bytesToHash);
24
+
25
+ writeFileSync(join(outputDir, "source-with-placeholder.pdf"), sourcePdf);
26
+ writeFileSync(join(outputDir, "hash.txt"), `${hash}\n`);
27
+
28
+ if (dryRun) {
29
+ const signedPdf = embedExternalSignature({
30
+ preparedPdf: prepared.bytesToHash,
31
+ pkcs7Signature: Buffer.from("dry-run-signature"),
32
+ placeholderLength: prepared.placeholderLength,
33
+ signatureOffset: prepared.signatureOffset,
34
+ });
35
+
36
+ writeFileSync(join(outputDir, "dry-run-signed.pdf"), signedPdf);
37
+ console.log(`Dry run OK. Evidence written to ${outputDir}`);
38
+ process.exit(0);
39
+ }
40
+
41
+ const config = readConfigFromEnv();
42
+ const client = new FirmaGobClient({
43
+ apiTokenKey: config.apiTokenKey,
44
+ secret: config.secret,
45
+ entity: config.entity,
46
+ run: config.run,
47
+ purpose: config.purpose,
48
+ environment: "test",
49
+ testUrl: config.endpointApi,
50
+ });
51
+
52
+ console.log("Signing prepared PDF hash with FirmaGob sandbox...");
53
+ const response = await client.signHashes([
54
+ { content: hash, contentType: "application/pdf" },
55
+ ]);
56
+
57
+ if (response.status !== 200 || response.metadata.signedFailed > 0) {
58
+ writeFileSync(
59
+ join(outputDir, "firmagob-error.json"),
60
+ `${JSON.stringify(response, null, 2)}\n`
61
+ );
62
+ throw new Error("FirmaGob sandbox signing failed");
63
+ }
64
+
65
+ const pkcs7 = response.hashes?.[0]?.content;
66
+
67
+ if (!pkcs7) {
68
+ throw new Error("FirmaGob response did not include hashes[0].content");
69
+ }
70
+
71
+ const signedPdf = embedExternalSignature({
72
+ preparedPdf: prepared.bytesToHash,
73
+ pkcs7Signature: pkcs7,
74
+ placeholderLength: prepared.placeholderLength,
75
+ signatureOffset: prepared.signatureOffset,
76
+ });
77
+
78
+ writeFileSync(join(outputDir, "firmagob-response.json"), `${JSON.stringify(response, null, 2)}\n`);
79
+ writeFileSync(join(outputDir, "signed.pdf"), signedPdf);
80
+ console.log(`Signed PDF written to ${join(outputDir, "signed.pdf")}`);
81
+
82
+ function readConfigFromEnv() {
83
+ const required = {
84
+ entity: "FIRMAGOB_ENTITY",
85
+ apiTokenKey: "FIRMAGOB_API_TOKEN_KEY",
86
+ run: "FIRMAGOB_RUN",
87
+ purpose: "FIRMAGOB_PURPOSE",
88
+ secret: "FIRMAGOB_SECRET",
89
+ endpointApi: "FIRMAGOB_ENDPOINT_API",
90
+ };
91
+ const missing = Object.values(required).filter((name) => !process.env[name]);
92
+
93
+ if (missing.length > 0) {
94
+ throw new Error(`Missing required environment variables: ${missing.join(", ")}`);
95
+ }
96
+
97
+ return {
98
+ entity: process.env[required.entity],
99
+ apiTokenKey: process.env[required.apiTokenKey],
100
+ run: process.env[required.run],
101
+ purpose: normalizePurpose(process.env[required.purpose]),
102
+ secret: process.env[required.secret],
103
+ endpointApi: process.env[required.endpointApi],
104
+ };
105
+ }
106
+
107
+ function normalizePurpose(value) {
108
+ if (value === Purpose.Attended || value === Purpose.Unattended) {
109
+ return value;
110
+ }
111
+
112
+ throw new Error(
113
+ `Unsupported FIRMAGOB_PURPOSE. Expected "${Purpose.Unattended}" or "${Purpose.Attended}".`
114
+ );
115
+ }
116
+
117
+ function createMinimalPdfWithSignaturePlaceholder({ reason, placeholderLength }) {
118
+ const contents = "0".repeat(placeholderLength);
119
+ const objects = [
120
+ "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] /SigFlags 3 >> >>",
121
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
122
+ [
123
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792]",
124
+ "/Resources << /Font << /F1 4 0 R >> >>",
125
+ "/Contents 6 0 R",
126
+ "/Annots [5 0 R] >>",
127
+ ].join(" "),
128
+ "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
129
+ [
130
+ "<< /Type /Annot /Subtype /Widget /FT /Sig /Rect [72 72 260 120]",
131
+ "/T (Signature1) /F 4 /P 3 0 R /V 7 0 R >>",
132
+ ].join(" "),
133
+ "<< /Length 63 >>\nstream\nBT /F1 16 Tf 72 700 Td (Documento sandbox FirmaGob) Tj ET\nendstream",
134
+ [
135
+ "<< /Type /Sig /Filter /Adobe.PPKLite /SubFilter /adbe.pkcs7.detached",
136
+ `/ByteRange [0 ${DEFAULT_BYTE_RANGE_PLACEHOLDER}]`,
137
+ `/Contents <${contents}>`,
138
+ `/Reason (${escapePdfString(reason)})`,
139
+ `/M (D:${formatPdfDate(new Date())}) >>`,
140
+ ].join(" "),
141
+ ];
142
+
143
+ return buildPdf(objects);
144
+ }
145
+
146
+ function buildPdf(objects) {
147
+ const chunks = ["%PDF-1.7\n"];
148
+ const offsets = [0];
149
+
150
+ for (const [index, object] of objects.entries()) {
151
+ offsets.push(Buffer.byteLength(chunks.join(""), "latin1"));
152
+ chunks.push(`${index + 1} 0 obj\n${object}\nendobj\n`);
153
+ }
154
+
155
+ const xrefOffset = Buffer.byteLength(chunks.join(""), "latin1");
156
+ chunks.push(`xref\n0 ${objects.length + 1}\n`);
157
+ chunks.push("0000000000 65535 f \n");
158
+
159
+ for (const offset of offsets.slice(1)) {
160
+ chunks.push(`${String(offset).padStart(10, "0")} 00000 n \n`);
161
+ }
162
+
163
+ chunks.push(
164
+ `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`
165
+ );
166
+
167
+ return Buffer.from(chunks.join(""), "latin1");
168
+ }
169
+
170
+ function escapePdfString(value) {
171
+ return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
172
+ }
173
+
174
+ function formatPdfDate(date) {
175
+ return date
176
+ .toISOString()
177
+ .replace(/[-:]/g, "")
178
+ .replace(/\.\d{3}Z$/, "Z");
179
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ltorresu82/firmagob-client",
3
+ "version": "0.1.0",
4
+ "description": "Cliente TypeScript para integrar FirmaGob Chile con firma por hash y PDFs firmados externamente",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist",
10
+ "docs",
11
+ "examples",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "clean": "node -e \"import('node:fs').then(fs => fs.rmSync('dist', { recursive: true, force: true }))\"",
17
+ "build": "npm run clean && tsc -p tsconfig.build.json",
18
+ "build:test": "npm run clean && tsc -p tsconfig.json",
19
+ "test": "npm run build:test && node --test dist/**/*.test.js",
20
+ "validate:sandbox": "npm run build && node examples/sign-hash-sandbox.js",
21
+ "validate:sandbox:dry-run": "npm run build && node examples/sign-hash-sandbox.js --dry-run",
22
+ "publish:dry-run": "npm publish --dry-run --access public",
23
+ "prepublishOnly": "npm test && npm audit",
24
+ "prepack": "npm run build"
25
+ },
26
+ "keywords": [
27
+ "firma",
28
+ "digital",
29
+ "firma-gob",
30
+ "chile",
31
+ "pdf"
32
+ ],
33
+ "author": "Luis Torres",
34
+ "license": "MIT",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/ltorresu82/firmagob-client.git"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/ltorresu82/firmagob-client/issues"
41
+ },
42
+ "homepage": "https://github.com/ltorresu82/firmagob-client#readme",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "engines": {
47
+ "node": ">=20"
48
+ },
49
+ "devDependencies": {
50
+ "@types/node": "^24.1.0",
51
+ "typescript": "^5.4.5"
52
+ }
53
+ }