@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 +25 -0
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/package.json +53 -0
- package/src/chain.mjs +110 -0
- package/src/index.mjs +23 -0
- package/src/signer.mjs +143 -0
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
|
+
}
|