@mcpose/audit 2.0.2 → 3.0.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 +21 -0
- package/README.md +437 -28
- package/dist/chain.d.ts +55 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +265 -0
- package/dist/chain.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +488 -0
- package/dist/middleware.js.map +1 -0
- package/dist/sensitivity.d.ts +12 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js +24 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/signingKey.d.ts +11 -0
- package/dist/signingKey.d.ts.map +1 -0
- package/dist/signingKey.js +30 -0
- package/dist/signingKey.js.map +1 -0
- package/dist/subjectKeyStore.d.ts +17 -0
- package/dist/subjectKeyStore.d.ts.map +1 -0
- package/dist/subjectKeyStore.js +37 -0
- package/dist/subjectKeyStore.js.map +1 -0
- package/dist/types.d.ts +244 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +50 -0
- package/dist/verify.js.map +1 -0
- package/package.json +43 -14
- package/src/__tests__/chain.batch.test.ts +30 -0
- package/src/__tests__/chain.test.ts +399 -0
- package/src/__tests__/erasable.test.ts +499 -0
- package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
- package/src/__tests__/middleware.http.test.ts +172 -0
- package/src/__tests__/middleware.robustness.test.ts +737 -0
- package/src/__tests__/middleware.test.ts +1121 -0
- package/src/__tests__/properties.test.ts +737 -0
- package/src/__tests__/sensitivity.test.ts +72 -0
- package/src/__tests__/signingKey.test.ts +76 -0
- package/src/chain.ts +317 -0
- package/src/index.ts +31 -0
- package/src/middleware.ts +690 -0
- package/src/sensitivity.ts +33 -0
- package/src/signingKey.ts +35 -0
- package/src/subjectKeyStore.ts +38 -0
- package/src/types.ts +281 -0
- package/src/verify.ts +76 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createSensitivityResolver } from '../sensitivity.js';
|
|
3
|
+
import type { Identity } from 'mcpose';
|
|
4
|
+
|
|
5
|
+
const identity: Identity = {
|
|
6
|
+
sub: 'u1',
|
|
7
|
+
type: 'human',
|
|
8
|
+
roles: [],
|
|
9
|
+
claims: {},
|
|
10
|
+
resolvedAt: new Date().toISOString(),
|
|
11
|
+
source: 'jwt',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('createSensitivityResolver', () => {
|
|
15
|
+
it('returns the mapped tier for a known tool', () => {
|
|
16
|
+
const resolve = createSensitivityResolver({
|
|
17
|
+
transfer_funds: 'high',
|
|
18
|
+
get_balance: 'low',
|
|
19
|
+
});
|
|
20
|
+
expect(resolve('transfer_funds', identity, {})).toBe('high');
|
|
21
|
+
expect(resolve('get_balance', identity, {})).toBe('low');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns high for an unknown tool', () => {
|
|
25
|
+
const resolve = createSensitivityResolver({ get_balance: 'low' });
|
|
26
|
+
expect(resolve('unknown_tool', identity, {})).toBe('high');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('override fn takes precedence over the static map', () => {
|
|
30
|
+
const resolve = createSensitivityResolver(
|
|
31
|
+
{ get_balance: 'low' },
|
|
32
|
+
(_tool, _identity, args) => (args.pii ? 'high' : 'low'),
|
|
33
|
+
);
|
|
34
|
+
expect(resolve('get_balance', identity, { pii: true })).toBe('high');
|
|
35
|
+
expect(resolve('get_balance', identity, {})).toBe('low');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('override receives the map tier and can fall back to it', () => {
|
|
39
|
+
const resolve = createSensitivityResolver(
|
|
40
|
+
{ get_balance: 'medium' },
|
|
41
|
+
(_tool, _identity, _args, mapTier) => mapTier,
|
|
42
|
+
);
|
|
43
|
+
expect(resolve('get_balance', identity, {})).toBe('medium');
|
|
44
|
+
// Unknown tool: mapTier is already defaulted to high.
|
|
45
|
+
expect(resolve('unknown_tool', identity, {})).toBe('high');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('resolves prototype-inherited tool names to high (attacker-controlled names)', () => {
|
|
49
|
+
const resolve = createSensitivityResolver({ get_balance: 'low' });
|
|
50
|
+
// These exist on Object.prototype — a naive map lookup returns a
|
|
51
|
+
// truthy Function, bypassing the unknown⇒high default.
|
|
52
|
+
for (const tool of [
|
|
53
|
+
'toString',
|
|
54
|
+
'constructor',
|
|
55
|
+
'valueOf',
|
|
56
|
+
'hasOwnProperty',
|
|
57
|
+
'__proto__',
|
|
58
|
+
]) {
|
|
59
|
+
expect(resolve(tool, identity, {})).toBe('high');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('treats a garbage tier from the map or override as high (fail closed)', () => {
|
|
64
|
+
const badMap = createSensitivityResolver({
|
|
65
|
+
typo_tool: 'pubic' as never,
|
|
66
|
+
});
|
|
67
|
+
expect(badMap('typo_tool', identity, {})).toBe('high');
|
|
68
|
+
|
|
69
|
+
const badOverride = createSensitivityResolver({}, () => undefined as never);
|
|
70
|
+
expect(badOverride('anything', identity, {})).toBe('high');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
3
|
+
import { createDefaultSigningKeyProvider } from '../signingKey.js';
|
|
4
|
+
|
|
5
|
+
describe('createDefaultSigningKeyProvider', () => {
|
|
6
|
+
it('has correct shape', () => {
|
|
7
|
+
const provider = createDefaultSigningKeyProvider('secret');
|
|
8
|
+
expect(provider.algorithm).toBe('HMAC-SHA256');
|
|
9
|
+
expect(typeof provider.keyId).toBe('string');
|
|
10
|
+
expect(provider.keyId.length).toBeGreaterThan(0);
|
|
11
|
+
expect(typeof provider.sign).toBe('function');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('derives keyId as HMAC(secret, mcpose/v2/keyid), never bare SHA256(secret)', () => {
|
|
15
|
+
// v1 published SHA256(secret) as keyId, enabling offline brute-force of
|
|
16
|
+
// low-entropy secrets from any manifest. Pin the v2 formula and the label.
|
|
17
|
+
const provider = createDefaultSigningKeyProvider('secret');
|
|
18
|
+
expect(provider.keyId).toBe(
|
|
19
|
+
createHmac('sha256', Buffer.from('secret'))
|
|
20
|
+
.update('mcpose/v2/keyid')
|
|
21
|
+
.digest('hex'),
|
|
22
|
+
);
|
|
23
|
+
expect(provider.keyId).not.toBe(
|
|
24
|
+
createHash('sha256').update('secret').digest('hex'),
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('sign returns a non-empty buffer', async () => {
|
|
29
|
+
const provider = createDefaultSigningKeyProvider('secret');
|
|
30
|
+
const sig = await provider.sign(Buffer.from('hello'));
|
|
31
|
+
expect(sig).toBeInstanceOf(Buffer);
|
|
32
|
+
expect(sig.length).toBeGreaterThan(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('same secret + same data → same signature (deterministic)', async () => {
|
|
36
|
+
const p1 = createDefaultSigningKeyProvider('secret');
|
|
37
|
+
const p2 = createDefaultSigningKeyProvider('secret');
|
|
38
|
+
const data = Buffer.from('test-data');
|
|
39
|
+
const [s1, s2] = await Promise.all([p1.sign(data), p2.sign(data)]);
|
|
40
|
+
expect(s1.toString('hex')).toBe(s2.toString('hex'));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('different secrets → different signatures', async () => {
|
|
44
|
+
const p1 = createDefaultSigningKeyProvider('secret-a');
|
|
45
|
+
const p2 = createDefaultSigningKeyProvider('secret-b');
|
|
46
|
+
const data = Buffer.from('test-data');
|
|
47
|
+
const [s1, s2] = await Promise.all([p1.sign(data), p2.sign(data)]);
|
|
48
|
+
expect(s1.toString('hex')).not.toBe(s2.toString('hex'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('accepts a Buffer secret and keys it identically to its utf8 string form', async () => {
|
|
52
|
+
// Pins why the provider hands the secret to createHmac as given rather
|
|
53
|
+
// than normalizing a string to a Buffer first: createHmac encodes a
|
|
54
|
+
// string key as utf8 itself, so the conversion was unobservable and its
|
|
55
|
+
// mutants unkillable (#110). A non-ASCII secret makes the encoding
|
|
56
|
+
// load-bearing rather than incidental.
|
|
57
|
+
const text = 'sécret';
|
|
58
|
+
const fromString = createDefaultSigningKeyProvider(text);
|
|
59
|
+
const fromBuffer = createDefaultSigningKeyProvider(Buffer.from(text));
|
|
60
|
+
|
|
61
|
+
expect(fromBuffer.keyId).toBe(fromString.keyId);
|
|
62
|
+
expect(fromBuffer.keyId).toBe(
|
|
63
|
+
createHmac('sha256', Buffer.from(text, 'utf8'))
|
|
64
|
+
.update('mcpose/v2/keyid')
|
|
65
|
+
.digest('hex'),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const data = Buffer.from('data');
|
|
69
|
+
const [fromBufferSig, fromStringSig] = await Promise.all([
|
|
70
|
+
fromBuffer.sign(data),
|
|
71
|
+
fromString.sign(data),
|
|
72
|
+
]);
|
|
73
|
+
expect(fromBufferSig).toBeInstanceOf(Buffer);
|
|
74
|
+
expect(fromBufferSig.toString('hex')).toBe(fromStringSig.toString('hex'));
|
|
75
|
+
});
|
|
76
|
+
});
|
package/src/chain.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
2
|
+
import type { AuditEvent, MerkleProof } from './types.js';
|
|
3
|
+
|
|
4
|
+
// ── Canonical serialization ────────────────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// v1 hashed `JSON.stringify` output, which made object-key insertion order
|
|
7
|
+
// load-bearing: an independently written verifier that reconstructed the
|
|
8
|
+
// fields in a different order failed on every chainHash. v2 hashes a
|
|
9
|
+
// canonical form instead — lexicographically sorted keys at every depth —
|
|
10
|
+
// so only the field SET matters. See ADR-0004.
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Canonical JSON: object keys sorted lexicographically at every depth,
|
|
14
|
+
* `undefined`-valued keys skipped. Throws on values with no canonical JSON
|
|
15
|
+
* form (BigInt, function, symbol, circular references).
|
|
16
|
+
*
|
|
17
|
+
* Use for data the audit layer controls (chain preimages, manifest
|
|
18
|
+
* payloads). For arbitrary caller payloads use {@link stableStringify}.
|
|
19
|
+
*/
|
|
20
|
+
export function canonicalJson(value: unknown): string {
|
|
21
|
+
return serialize(value, new Set(), { strict: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Total variant of {@link canonicalJson} for arbitrary caller payloads:
|
|
26
|
+
* never throws. Circular references become `"[Circular]"`, BigInt becomes
|
|
27
|
+
* its decimal string. Used for `inputHash`/`outputHash`, so two
|
|
28
|
+
* semantically identical payloads hash identically regardless of the
|
|
29
|
+
* client's JSON key order.
|
|
30
|
+
*/
|
|
31
|
+
export function stableStringify(value: unknown): string {
|
|
32
|
+
return serialize(value, new Set(), { strict: false });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function serialize(
|
|
36
|
+
value: unknown,
|
|
37
|
+
seen: Set<object>,
|
|
38
|
+
opts: { strict: boolean },
|
|
39
|
+
): string {
|
|
40
|
+
if (value === null || value === undefined) return 'null';
|
|
41
|
+
|
|
42
|
+
switch (typeof value) {
|
|
43
|
+
case 'string':
|
|
44
|
+
case 'number':
|
|
45
|
+
case 'boolean':
|
|
46
|
+
return JSON.stringify(value);
|
|
47
|
+
case 'bigint':
|
|
48
|
+
if (opts.strict) {
|
|
49
|
+
throw new TypeError('canonicalJson: BigInt has no canonical JSON form');
|
|
50
|
+
}
|
|
51
|
+
return JSON.stringify(value.toString());
|
|
52
|
+
case 'function':
|
|
53
|
+
case 'symbol':
|
|
54
|
+
if (opts.strict) {
|
|
55
|
+
throw new TypeError(
|
|
56
|
+
`canonicalJson: ${typeof value} has no canonical JSON form`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return 'null';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const obj = value;
|
|
63
|
+
if (seen.has(obj)) {
|
|
64
|
+
if (opts.strict) {
|
|
65
|
+
throw new TypeError('canonicalJson: circular reference');
|
|
66
|
+
}
|
|
67
|
+
return '"[Circular]"';
|
|
68
|
+
}
|
|
69
|
+
seen.add(obj);
|
|
70
|
+
try {
|
|
71
|
+
if (Array.isArray(obj)) {
|
|
72
|
+
return `[${obj.map((item) => serialize(item, seen, opts)).join(',')}]`;
|
|
73
|
+
}
|
|
74
|
+
if (typeof (obj as { toJSON?: unknown }).toJSON === 'function') {
|
|
75
|
+
return serialize((obj as { toJSON: () => unknown }).toJSON(), seen, opts);
|
|
76
|
+
}
|
|
77
|
+
const entries = Object.entries(obj)
|
|
78
|
+
.filter(([, v]) => v !== undefined)
|
|
79
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
80
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${serialize(v, seen, opts)}`);
|
|
81
|
+
return `{${entries.join(',')}}`;
|
|
82
|
+
} finally {
|
|
83
|
+
seen.delete(obj);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Hash primitives ────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
export function sha256hex(data: string | Buffer): string {
|
|
90
|
+
return createHash('sha256').update(data).digest('hex');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function hmacSha256hex(data: string | Buffer, key: Buffer): string {
|
|
94
|
+
return createHmac('sha256', key).update(data).digest('hex');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── HMAC chain ─────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const DOMAIN_CHAIN_HASH = 'mcpose/v2/chain';
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The exact fields of an audit event that the chain covers, extracted from
|
|
103
|
+
* the event. Used by both the producer (middleware) and any verifier
|
|
104
|
+
* ({@link verifyAuditChain}) so the two cannot drift.
|
|
105
|
+
*
|
|
106
|
+
* NOT covered (bound only via inputHash/outputHash): the raw/encrypted
|
|
107
|
+
* payloads.
|
|
108
|
+
*/
|
|
109
|
+
export function chainPreimageFields(
|
|
110
|
+
event: Omit<AuditEvent, 'chainHash'> | AuditEvent,
|
|
111
|
+
): Record<string, unknown> {
|
|
112
|
+
return {
|
|
113
|
+
id: event.id,
|
|
114
|
+
startedAt: event.startedAt,
|
|
115
|
+
endedAt: event.endedAt,
|
|
116
|
+
...(event.sessionId === undefined ? {} : { sessionId: event.sessionId }),
|
|
117
|
+
...(event.delegatedFrom === undefined
|
|
118
|
+
? {}
|
|
119
|
+
: { delegatedFrom: event.delegatedFrom }),
|
|
120
|
+
// Required on every event, so it is a plain always-present field rather
|
|
121
|
+
// than an omission-guarded optional one (ADR-0019). Under the omission
|
|
122
|
+
// pattern an event with no proxy and one whose proxy was stripped hashed
|
|
123
|
+
// identically, so removing recorded provenance was undetectable.
|
|
124
|
+
proxy: event.proxy,
|
|
125
|
+
...(event.kind === undefined ? {} : { kind: event.kind }),
|
|
126
|
+
// Optional, omitted when absent (ADR-0012), so a default-mode event keeps
|
|
127
|
+
// the preimage it had before erasable mode existed. Covering it is what
|
|
128
|
+
// stops a stored event being reinterpreted under the other hash scheme:
|
|
129
|
+
// adding or stripping the marker breaks verification at that index
|
|
130
|
+
// (ADR-0018).
|
|
131
|
+
...(event.erasable === undefined ? {} : { erasable: event.erasable }),
|
|
132
|
+
identity: event.identity,
|
|
133
|
+
tool: event.tool,
|
|
134
|
+
duration_ms: event.duration_ms,
|
|
135
|
+
outcome: event.outcome,
|
|
136
|
+
// Required on every event, so it is a plain always-present field rather
|
|
137
|
+
// than an omission-guarded optional one (ADR-0015).
|
|
138
|
+
sensitivityTier: event.sensitivityTier,
|
|
139
|
+
...(event.rejectionReason === undefined
|
|
140
|
+
? {}
|
|
141
|
+
: { rejectionReason: event.rejectionReason }),
|
|
142
|
+
...(event.error === undefined ? {} : { error: event.error }),
|
|
143
|
+
inputHash: event.inputHash,
|
|
144
|
+
outputHash: event.outputHash,
|
|
145
|
+
replayManifestPosition: event.replayManifestPosition,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* `HMAC-SHA256(chainKey, canonicalJson({domain, prevChainHash, event}))`.
|
|
151
|
+
* Domain-separated and canonically framed: neither field order nor
|
|
152
|
+
* string-concatenation ambiguity can affect the hash. The entry is
|
|
153
|
+
* serialized without its own chainHash. First entry uses `prevChainHash: ''`.
|
|
154
|
+
*/
|
|
155
|
+
export function computeChainHash(
|
|
156
|
+
entryWithoutChainHash: Record<string, unknown>,
|
|
157
|
+
prevChainHash: string,
|
|
158
|
+
chainKey: Buffer,
|
|
159
|
+
): string {
|
|
160
|
+
const preimage = canonicalJson({
|
|
161
|
+
domain: DOMAIN_CHAIN_HASH,
|
|
162
|
+
prevChainHash,
|
|
163
|
+
event: entryWithoutChainHash,
|
|
164
|
+
});
|
|
165
|
+
return hmacSha256hex(preimage, chainKey);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Merkle tree ────────────────────────────────────────────────────────────────
|
|
169
|
+
//
|
|
170
|
+
// Leaves and internal nodes are domain-separated so an internal node can
|
|
171
|
+
// never be replayed as a leaf (second-preimage hardening). Odd layers
|
|
172
|
+
// duplicate the last node; ambiguity from that padding is neutralized by
|
|
173
|
+
// the signed `eventCount` in the manifest payload.
|
|
174
|
+
|
|
175
|
+
const LEAF_TAG = 'mcpose/v2/leaf\0';
|
|
176
|
+
const NODE_TAG = 'mcpose/v2/node\0';
|
|
177
|
+
|
|
178
|
+
function leafHash(chainHash: string): string {
|
|
179
|
+
return sha256hex(LEAF_TAG + chainHash);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function nodeHash(left: string, right: string): string {
|
|
183
|
+
return sha256hex(NODE_TAG + left + right);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function nextLayer(layer: string[]): string[] {
|
|
187
|
+
const next: string[] = [];
|
|
188
|
+
for (let i = 0; i < layer.length; i += 2) {
|
|
189
|
+
// `i < layer.length` guarantees `left`; a missing right sibling means an
|
|
190
|
+
// odd layer, whose last node is duplicated (ADR-0004) — same padding as
|
|
191
|
+
// `computeMerkleProof` and `computeMerkleRootAndProofs`.
|
|
192
|
+
const left = layer[i]!;
|
|
193
|
+
const right = layer[i + 1] ?? left;
|
|
194
|
+
next.push(nodeHash(left, right));
|
|
195
|
+
}
|
|
196
|
+
return next;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Merkle root over the events' `chainHash` values. Empty → `sha256('')`. */
|
|
200
|
+
export function computeMerkleRoot(hashes: string[]): string {
|
|
201
|
+
if (hashes.length === 0) return sha256hex('');
|
|
202
|
+
let layer = hashes.map(leafHash);
|
|
203
|
+
while (layer.length > 1) {
|
|
204
|
+
layer = nextLayer(layer);
|
|
205
|
+
}
|
|
206
|
+
// `hashes` is non-empty here and the loop exits only at length 1.
|
|
207
|
+
return layer[0]!;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function computeMerkleProof(
|
|
211
|
+
hashes: string[],
|
|
212
|
+
index: number,
|
|
213
|
+
): MerkleProof {
|
|
214
|
+
if (!Number.isInteger(index) || index < 0 || index >= hashes.length) {
|
|
215
|
+
throw new RangeError(
|
|
216
|
+
`computeMerkleProof: index ${index} out of range for ${hashes.length} leaves`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const siblings: string[] = [];
|
|
221
|
+
const directions: ('left' | 'right')[] = [];
|
|
222
|
+
|
|
223
|
+
let layer = hashes.map(leafHash);
|
|
224
|
+
let idx = index;
|
|
225
|
+
|
|
226
|
+
while (layer.length > 1) {
|
|
227
|
+
const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1;
|
|
228
|
+
// A sibling past the end means an odd layer whose last node is
|
|
229
|
+
// duplicated, and `idx` is then that last node — same padding as
|
|
230
|
+
// `nextLayer`, which must stay identical. `idx` is always in range.
|
|
231
|
+
siblings.push(layer[siblingIdx] ?? layer[idx]!);
|
|
232
|
+
directions.push(idx % 2 === 0 ? 'right' : 'left');
|
|
233
|
+
layer = nextLayer(layer);
|
|
234
|
+
idx = Math.floor(idx / 2);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { index, siblings, directions };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function proofFromLayers(index: number, layers: string[][]): MerkleProof {
|
|
241
|
+
const siblings: string[] = [];
|
|
242
|
+
const directions: ('left' | 'right')[] = [];
|
|
243
|
+
let idx = index;
|
|
244
|
+
|
|
245
|
+
for (let level = 0; level < layers.length - 1; level++) {
|
|
246
|
+
const layer = layers[level]!;
|
|
247
|
+
const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1;
|
|
248
|
+
siblings.push(layer[siblingIdx] ?? layer[idx]!);
|
|
249
|
+
directions.push(idx % 2 === 0 ? 'right' : 'left');
|
|
250
|
+
idx = Math.floor(idx / 2);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { index, siblings, directions };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Build a manifest root and every proof from the same set of tree layers. */
|
|
257
|
+
export function computeMerkleRootAndProofs(hashes: string[]): {
|
|
258
|
+
merkleRoot: string;
|
|
259
|
+
merkleProofs: MerkleProof[];
|
|
260
|
+
} {
|
|
261
|
+
if (hashes.length === 0) {
|
|
262
|
+
return { merkleRoot: sha256hex(''), merkleProofs: [] };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const layers = [hashes.map(leafHash)];
|
|
266
|
+
while (layers[layers.length - 1]!.length > 1) {
|
|
267
|
+
layers.push(nextLayer(layers[layers.length - 1]!));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
merkleRoot: layers[layers.length - 1]![0]!,
|
|
272
|
+
merkleProofs: hashes.map((_, index) => proofFromLayers(index, layers)),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Verifies a Merkle proof for an event's `chainHash` (the raw chain hash —
|
|
278
|
+
* the leaf tag is applied internally).
|
|
279
|
+
*
|
|
280
|
+
* Rejects malformed proofs: `siblings`/`directions` length mismatch,
|
|
281
|
+
* directions inconsistent with `proof.index` (each level's direction is
|
|
282
|
+
* fully determined by the index), or an index that the path cannot
|
|
283
|
+
* represent (`index >= 2 ** siblings.length`, which leaves high bits
|
|
284
|
+
* unconsumed after traversal).
|
|
285
|
+
*/
|
|
286
|
+
export function verifyMerkleProof(
|
|
287
|
+
leafChainHash: string,
|
|
288
|
+
proof: MerkleProof,
|
|
289
|
+
root: string,
|
|
290
|
+
): boolean {
|
|
291
|
+
if (
|
|
292
|
+
!Number.isSafeInteger(proof.index) ||
|
|
293
|
+
proof.index < 0 ||
|
|
294
|
+
proof.siblings.length !== proof.directions.length
|
|
295
|
+
) {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let current = leafHash(leafChainHash);
|
|
300
|
+
let idx = proof.index;
|
|
301
|
+
for (let i = 0; i < proof.siblings.length; i++) {
|
|
302
|
+
const expectedDir = idx % 2 === 0 ? 'right' : 'left';
|
|
303
|
+
if (proof.directions[i] !== expectedDir) return false;
|
|
304
|
+
const sibling = proof.siblings[i];
|
|
305
|
+
// A hole in a caller-supplied `siblings` array is a malformed proof.
|
|
306
|
+
if (sibling === undefined) return false;
|
|
307
|
+
current =
|
|
308
|
+
expectedDir === 'right'
|
|
309
|
+
? nodeHash(current, sibling)
|
|
310
|
+
: nodeHash(sibling, current);
|
|
311
|
+
idx = Math.floor(idx / 2);
|
|
312
|
+
}
|
|
313
|
+
// Bits left over mean the claimed position lies outside a tree of this
|
|
314
|
+
// depth: a two-leaf proof for position 0 must not also verify at 2 or 4.
|
|
315
|
+
if (idx !== 0) return false;
|
|
316
|
+
return current === root;
|
|
317
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
SensitivityTier,
|
|
3
|
+
SensitivityResolverFn,
|
|
4
|
+
SensitivityOverrideFn,
|
|
5
|
+
SigningKeyProvider,
|
|
6
|
+
SubjectKeyStore,
|
|
7
|
+
SubjectKeyTombstone,
|
|
8
|
+
AuditEventBase,
|
|
9
|
+
LowAuditEvent,
|
|
10
|
+
MediumAuditEvent,
|
|
11
|
+
HighAuditEvent,
|
|
12
|
+
AuditEvent,
|
|
13
|
+
MerkleProof,
|
|
14
|
+
ReplayManifest,
|
|
15
|
+
AuditOptions,
|
|
16
|
+
AuditMiddlewareHandle,
|
|
17
|
+
} from './types.js';
|
|
18
|
+
|
|
19
|
+
export { createSensitivityResolver } from './sensitivity.js';
|
|
20
|
+
export { createDefaultSigningKeyProvider } from './signingKey.js';
|
|
21
|
+
export { createInMemorySubjectKeyStore } from './subjectKeyStore.js';
|
|
22
|
+
export { createAuditMiddleware } from './middleware.js';
|
|
23
|
+
export {
|
|
24
|
+
computeMerkleRoot,
|
|
25
|
+
computeMerkleProof,
|
|
26
|
+
verifyMerkleProof,
|
|
27
|
+
canonicalJson,
|
|
28
|
+
stableStringify,
|
|
29
|
+
} from './chain.js';
|
|
30
|
+
export type { ChainVerification } from './verify.js';
|
|
31
|
+
export { verifyAuditChain, verifyManifestSignature } from './verify.js';
|