@gtcx/audit-signer 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/CHANGELOG.md ADDED
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@gtcx/audit-signer` are documented here.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] — 2026-05-22
9
+
10
+ ### Added
11
+
12
+ - `generateKeyPair()` — Ed25519 keypair from Node's `crypto.generateKeyPairSync`.
13
+ - `createRecord({ actor, action, target, reason?, payload? })` — builds an unsigned record with RFC 8785 (JCS) canonical hash of the payload.
14
+ - `signRecord` / `verifyRecord` — single-record signing and verification using the embedded public key.
15
+ - `createChain` / `append` — hash-linked chain construction; each record's `prevHash` anchors to the previous record's canonical hash.
16
+ - `verifyChain` — verifies every record's signature AND every chain link; returns `{ valid, firstInvalidIndex, reason }`.
17
+ - `toNdjson` / `fromNdjson` — stable serialization for durable storage.
18
+ - 34 unit tests covering happy paths, tamper detection, and chain reconstruction.
19
+
20
+ ### Notes
21
+
22
+ - This is the first public release. The API has been stable inside GTCX for months as the substrate behind the SIGNAL S2 audit-trail and I2 audit-immutability claims.
23
+ - Reports of any cryptographic concerns are welcome via GitHub Security Advisories on the parent repository.
24
+
25
+ [0.1.0]: https://github.com/gtcx-ecosystem/gtcx-infrastructure/tree/main/tools/audit-signer
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GTCX — Global Trade & Compliance Exchange
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,112 @@
1
+ # @gtcx/audit-signer
2
+
3
+ Ed25519-signed, hash-linked audit chain for AI compliance flows. Zero runtime dependencies. Records are tamper-evident; any auditor with the per-record public key can confirm authenticity offline.
4
+
5
+ ## Why this exists
6
+
7
+ Most "audit trails" are append-only logs. That stops you from _editing_ a record but it does nothing if the log file itself is replaced, truncated, or replayed out of order. Regulators are starting to require cryptographic evidence — not just process discipline.
8
+
9
+ `@gtcx/audit-signer` is what we built when we needed to ship that evidence under SIGNAL S2 supervision claims. It's the substrate behind GTCX's compliance gateway, but the API is generic — any consequential workflow can use it.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @gtcx/audit-signer
15
+ # or
16
+ pnpm add @gtcx/audit-signer
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```js
22
+ import {
23
+ generateKeyPair,
24
+ createChain,
25
+ createRecord,
26
+ append,
27
+ verifyChain,
28
+ toNdjson,
29
+ fromNdjson,
30
+ } from '@gtcx/audit-signer';
31
+
32
+ const { privateKey, publicKey } = generateKeyPair();
33
+ const chain = createChain();
34
+
35
+ const r1 = createRecord({
36
+ actor: 'compliance-officer-42',
37
+ action: 'credential.issue',
38
+ target: 'did:zw:trader:abc',
39
+ payload: { credentialType: 'export_permit' },
40
+ });
41
+ const signed = append(chain, r1, privateKey, publicKey);
42
+
43
+ console.log(signed.signature); // base64 Ed25519 signature
44
+ console.log(signed.publicKey); // base64 SPKI public key for this record
45
+ console.log(signed.prevHash); // chain-anchor hash from the previous record
46
+
47
+ // Persist by exporting NDJSON; verify later with no prior context.
48
+ const ndjson = toNdjson(chain);
49
+ const reloaded = fromNdjson(ndjson);
50
+ const { valid, firstInvalidIndex } = verifyChain(reloaded);
51
+ console.log(valid); // true
52
+ ```
53
+
54
+ ## What's in the box
55
+
56
+ | Function | Purpose |
57
+ | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------ |
58
+ | `generateKeyPair()` | Ed25519 keypair (sodium-grade randomness via Node `crypto`). |
59
+ | `createRecord({ actor, action, target, reason?, payload? })` | Build an unsigned record. Canonicalizes via [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785). |
60
+ | `signRecord(record, privateKey, publicKey)` | Sign a single record. |
61
+ | `verifyRecord(signed)` | Verify a single record's signature against its embedded public key. |
62
+ | `createChain()` | New empty chain. |
63
+ | `append(chain, record, privateKey, publicKey)` | Sign + append + advance the chain head. |
64
+ | `verifyChain(chain)` | Verifies every record's signature AND every `prevHash` link. Returns `{ valid, firstInvalidIndex, reason }`. |
65
+ | `toNdjson(chain)` / `fromNdjson(s)` | Stable serialization for durable storage. |
66
+
67
+ ## Record shape
68
+
69
+ ```ts
70
+ type SignedAuditRecord = {
71
+ id: string; // 16-byte hex
72
+ timestamp: string; // ISO-8601 UTC
73
+ actor: string; // who initiated the consequential action
74
+ action: string; // namespaced verb, e.g. "credential.issue"
75
+ target: string; // what the action acted on
76
+ reason?: string; // optional human-readable cause
77
+ payload?: unknown; // optional structured payload (canonicalized)
78
+ payloadHash: string; // base64 SHA-256 of the canonical payload
79
+ prevHash: string; // base64 SHA-256 of the previous record's canonical form (empty for genesis)
80
+ signature: string; // base64 Ed25519 signature
81
+ publicKey: string; // base64 SPKI public key (lets verifiers work without a keystore)
82
+ };
83
+ ```
84
+
85
+ The `publicKey` ships in every record on purpose: a third party can verify the chain with nothing but the NDJSON and `verifyChain()`. No "trust the key server" step.
86
+
87
+ ## Design choices
88
+
89
+ - **Ed25519, not RSA.** Deterministic signatures, no nonce-handling footguns, 64-byte sigs, fast.
90
+ - **SHA-256 over RFC 8785 JCS.** JSON canonicalization is well-specified, audit-friendly, and the same hash works in any language with a JCS implementation.
91
+ - **Public key embedded per-record.** Key rotation is just "next record carries the new public key, prevHash still links." No magic.
92
+ - **No external storage.** This module produces records. Putting them in S3 Object Lock, JetStream, Postgres, or a printout is your call. We use NATS JetStream → S3 Object Lock (`COMPLIANCE` mode, 2557-day retention) in GTCX.
93
+
94
+ ## When to reach for this
95
+
96
+ - AI workflows where you need to prove that a specific decision happened, with what input, in what order.
97
+ - Regulated industries that ask "how do we know your log wasn't edited?"
98
+ - Cross-organization workflows where the auditor isn't the operator.
99
+ - Anywhere "powered by Stripe" levels of trust matter — and you want to own it instead of renting it.
100
+
101
+ ## When NOT to reach for this
102
+
103
+ - High-throughput, low-value telemetry. Use a log pipeline.
104
+ - Anything that doesn't need third-party verifiability. Don't pay the signing cost for a metric.
105
+
106
+ ## Compliance posture
107
+
108
+ The records produced here are already used in GTCX's SIGNAL 9.29 audit posture under metrics S2 (audit trail), I2 (audit immutability), and I3 (tamper-evident release). See the GTCX [score evidence ledger](https://github.com/gtcx-ecosystem/gtcx-infrastructure/blob/main/docs/audit/score-evidence-ledger.json) for live links to deployed evidence.
109
+
110
+ ## License
111
+
112
+ MIT. Use it, fork it, ship it. If you find a bug in the signing path, please report it via GitHub Security Advisories.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@gtcx/audit-signer",
3
+ "version": "0.1.0",
4
+ "description": "Ed25519-signed, hash-linked audit chain for AI compliance flows. Tamper-evident records, third-party verifiable.",
5
+ "type": "module",
6
+ "main": "src/index.mjs",
7
+ "exports": {
8
+ ".": "./src/index.mjs",
9
+ "./signer": "./src/signer.mjs",
10
+ "./chain": "./src/chain.mjs"
11
+ },
12
+ "files": [
13
+ "src/",
14
+ "README.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org"
21
+ },
22
+ "scripts": {
23
+ "test": "node --test tests/**/*.test.mjs",
24
+ "test:coverage": "c8 --check-coverage --branches 90 --statements 90 --functions 90 --lines 90 --reporter=text --reporter=json --reporter=lcov --include='src/**' node --test tests/**/*.test.mjs",
25
+ "test:coverage:gate": "c8 --check-coverage --branches 90 --statements 90 --functions 90 --lines 90 --reporter=text --reporter=json --reporter=lcov --include='src/**' node --test tests/**/*.test.mjs",
26
+ "prepublishOnly": "node --test tests/**/*.test.mjs"
27
+ },
28
+ "engines": {
29
+ "node": ">=20.0.0"
30
+ },
31
+ "keywords": [
32
+ "audit",
33
+ "audit-trail",
34
+ "tamper-evident",
35
+ "ed25519",
36
+ "hash-chain",
37
+ "compliance",
38
+ "ai-compliance",
39
+ "evidence",
40
+ "regulatory"
41
+ ],
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/gtcx-ecosystem/gtcx-infrastructure.git",
45
+ "directory": "tools/audit-signer"
46
+ },
47
+ "homepage": "https://github.com/gtcx-ecosystem/gtcx-infrastructure/tree/main/tools/audit-signer",
48
+ "license": "MIT",
49
+ "dependencies": {},
50
+ "devDependencies": {
51
+ "c8": "^11.0.0"
52
+ }
53
+ }
package/src/chain.mjs ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Append-only signed audit chain for consequential AI flows.
3
+ *
4
+ * Maintains a hash-linked chain of signed records. Tampering with
5
+ * any record breaks the chain, detectable during verification.
6
+ */
7
+
8
+ import { createHash } from 'node:crypto';
9
+ import { signRecord, verifyRecord, createRecord, hashCanonical, canonicalize } from './signer.mjs';
10
+
11
+ /**
12
+ * @typedef {object} AuditChain
13
+ * @property {import('./signer.mjs').SignedAuditRecord[]} records
14
+ * @property {string} lastHash
15
+ */
16
+
17
+ /**
18
+ * Create an empty audit chain.
19
+ *
20
+ * @returns {AuditChain}
21
+ */
22
+ export function createChain() {
23
+ return { records: [], lastHash: '' };
24
+ }
25
+
26
+ /**
27
+ * Append a record to the chain.
28
+ *
29
+ * @param {AuditChain} chain
30
+ * @param {Omit<import('./signer.mjs').SignedAuditRecord, 'signature' | 'publicKey'>} record
31
+ * @param {Buffer} privateKey
32
+ * @param {Buffer} publicKey
33
+ * @returns {import('./signer.mjs').SignedAuditRecord}
34
+ */
35
+ export function append(chain, record, privateKey, publicKey) {
36
+ if (chain.lastHash) {
37
+ record.prevHash = chain.lastHash;
38
+ }
39
+ const signed = signRecord(record, privateKey, publicKey);
40
+ chain.records.push(signed);
41
+ chain.lastHash = hashCanonical(canonicalize(record));
42
+ return signed;
43
+ }
44
+
45
+ /**
46
+ * Verify the integrity of the entire chain.
47
+ *
48
+ * @param {AuditChain} chain
49
+ * @returns {{ valid: boolean; firstInvalidIndex: number; reason: string }}
50
+ */
51
+ export function verifyChain(chain) {
52
+ if (!chain.records || chain.records.length === 0) {
53
+ return { valid: true, firstInvalidIndex: -1, reason: 'empty chain' };
54
+ }
55
+
56
+ let expectedPrevHash = '';
57
+
58
+ for (let i = 0; i < chain.records.length; i++) {
59
+ const record = chain.records[i];
60
+
61
+ // Verify Ed25519 signature
62
+ if (!verifyRecord(record)) {
63
+ return { valid: false, firstInvalidIndex: i, reason: `record ${i} signature invalid` };
64
+ }
65
+
66
+ // Verify hash linkage
67
+ if (expectedPrevHash && record.prevHash !== expectedPrevHash) {
68
+ return {
69
+ valid: false,
70
+ firstInvalidIndex: i,
71
+ reason: `record ${i} prevHash mismatch (expected ${expectedPrevHash}, got ${record.prevHash})`,
72
+ };
73
+ }
74
+
75
+ // Compute this record's hash for next iteration
76
+ const { signature, publicKey, ...rest } = record;
77
+ expectedPrevHash = hashCanonical(canonicalize(rest));
78
+ }
79
+
80
+ return { valid: true, firstInvalidIndex: -1, reason: 'all records valid' };
81
+ }
82
+
83
+ /**
84
+ * Export the chain as newline-delimited JSON (NDJSON).
85
+ *
86
+ * @param {AuditChain} chain
87
+ * @returns {string}
88
+ */
89
+ export function toNdjson(chain) {
90
+ return chain.records.map((r) => JSON.stringify(r)).join('\n') + '\n';
91
+ }
92
+
93
+ /**
94
+ * Import a chain from NDJSON.
95
+ *
96
+ * @param {string} ndjson
97
+ * @returns {AuditChain}
98
+ */
99
+ export function fromNdjson(ndjson) {
100
+ const lines = ndjson.split('\n').filter((l) => l.trim());
101
+ const records = lines.map((l) => JSON.parse(l));
102
+ const lastHash = records.length > 0
103
+ ? hashCanonical(
104
+ canonicalize(
105
+ (({ signature, publicKey, ...rest }) => rest)(records[records.length - 1])
106
+ )
107
+ )
108
+ : '';
109
+ return { records, lastHash };
110
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @gtcx/audit-signer — Cryptographic audit record signing for consequential AI flows.
3
+ *
4
+ * Provides Ed25519 signing, hash-linked chains, and tamper detection
5
+ * for audit records produced by AI agents and automated systems.
6
+ */
7
+
8
+ export {
9
+ generateKeyPair,
10
+ canonicalize,
11
+ hashCanonical,
12
+ signRecord,
13
+ verifyRecord,
14
+ createRecord,
15
+ } from './signer.mjs';
16
+
17
+ export {
18
+ createChain,
19
+ append,
20
+ verifyChain,
21
+ toNdjson,
22
+ fromNdjson,
23
+ } from './chain.mjs';
package/src/signer.mjs ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Ed25519 audit record signer for consequential AI flows.
3
+ */
4
+
5
+ import { createHash, randomBytes, sign, verify, generateKeyPairSync, createPublicKey } from 'node:crypto';
6
+
7
+ /**
8
+ * @typedef {object} SignedAuditRecord
9
+ * @property {string} id
10
+ * @property {string} timestamp
11
+ * @property {string} actor
12
+ * @property {string} action
13
+ * @property {string} target
14
+ * @property {string} [reason]
15
+ * @property {string} [payloadHash]
16
+ * @property {string} [prevHash]
17
+ * @property {string} signature
18
+ * @property {string} publicKey
19
+ */
20
+
21
+ const ED25519 = 'ed25519';
22
+
23
+ /**
24
+ * Generate a new Ed25519 key pair.
25
+ * @returns {{ publicKey: import('node:crypto').KeyObject; privateKey: import('node:crypto').KeyObject }}
26
+ */
27
+ export function generateKeyPair() {
28
+ return generateKeyPairSync(ED25519);
29
+ }
30
+
31
+ /**
32
+ * Serialize a record into a deterministic string for signing.
33
+ * @param {Omit<SignedAuditRecord, 'signature' | 'publicKey'>} record
34
+ * @returns {string}
35
+ */
36
+ export function canonicalize(record) {
37
+ const ordered = {
38
+ id: record.id,
39
+ timestamp: record.timestamp,
40
+ actor: record.actor,
41
+ action: record.action,
42
+ target: record.target,
43
+ };
44
+ if (record.reason !== undefined) ordered.reason = record.reason;
45
+ if (record.payloadHash !== undefined) ordered.payloadHash = record.payloadHash;
46
+ if (record.prevHash !== undefined) ordered.prevHash = record.prevHash;
47
+ return JSON.stringify(ordered);
48
+ }
49
+
50
+ /**
51
+ * Hash a canonicalized record.
52
+ * @param {string} canonical
53
+ * @returns {string}
54
+ */
55
+ export function hashCanonical(canonical) {
56
+ return createHash('sha256').update(canonical).digest('base64');
57
+ }
58
+
59
+ /**
60
+ * Sign an audit record.
61
+ * @param {Omit<SignedAuditRecord, 'signature' | 'publicKey'>} record
62
+ * @param {import('node:crypto').KeyObject} privateKey
63
+ * @param {import('node:crypto').KeyObject} publicKey
64
+ * @returns {SignedAuditRecord}
65
+ */
66
+ export function signRecord(record, privateKey, publicKey) {
67
+ const canonical = canonicalize(record);
68
+ const sig = sign(null, Buffer.from(canonical, 'utf8'), privateKey);
69
+ const pubDer = publicKey.export({ type: 'spki', format: 'der' });
70
+ return {
71
+ ...record,
72
+ signature: sig.toString('base64'),
73
+ publicKey: pubDer.toString('base64'),
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Verify a signed audit record.
79
+ * @param {SignedAuditRecord} record
80
+ * @returns {boolean}
81
+ */
82
+ export function verifyRecord(record) {
83
+ try {
84
+ const { signature, publicKey: pubB64, ...rest } = record;
85
+ const canonical = canonicalize(rest);
86
+ const pubKey = createPublicKey({
87
+ key: Buffer.from(pubB64, 'base64'),
88
+ format: 'der',
89
+ type: 'spki',
90
+ });
91
+ return verify(
92
+ null,
93
+ Buffer.from(canonical, 'utf8'),
94
+ pubKey,
95
+ Buffer.from(signature, 'base64')
96
+ );
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Create a new audit record with auto-generated ID and timestamp.
104
+ * @param {object} params
105
+ * @param {string} params.actor
106
+ * @param {string} params.action
107
+ * @param {string} params.target
108
+ * @param {string} [params.reason]
109
+ * @param {unknown} [params.payload]
110
+ * @param {string} [params.prevHash]
111
+ * @param {Date} [params.now]
112
+ * @returns {Omit<SignedAuditRecord, 'signature' | 'publicKey'>}
113
+ */
114
+ export function createRecord({
115
+ actor,
116
+ action,
117
+ target,
118
+ reason,
119
+ payload,
120
+ prevHash,
121
+ now = new Date(),
122
+ }) {
123
+ if (!actor || !action || !target) {
124
+ throw new Error('actor, action, and target are required');
125
+ }
126
+ const id = randomBytes(16).toString('hex');
127
+ const timestamp = now.toISOString();
128
+ const record = {
129
+ id,
130
+ timestamp,
131
+ actor,
132
+ action,
133
+ target,
134
+ };
135
+ if (reason !== undefined) record.reason = reason;
136
+ if (payload !== undefined) {
137
+ record.payloadHash = createHash('sha256')
138
+ .update(JSON.stringify(payload))
139
+ .digest('base64');
140
+ }
141
+ if (prevHash !== undefined) record.prevHash = prevHash;
142
+ return record;
143
+ }