@git-stunts/git-warp 10.7.0 → 11.2.1
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/README.md +53 -32
- package/SECURITY.md +64 -0
- package/bin/cli/commands/check.js +168 -0
- package/bin/cli/commands/doctor/checks.js +422 -0
- package/bin/cli/commands/doctor/codes.js +46 -0
- package/bin/cli/commands/doctor/index.js +239 -0
- package/bin/cli/commands/doctor/types.js +89 -0
- package/bin/cli/commands/history.js +73 -0
- package/bin/cli/commands/info.js +139 -0
- package/bin/cli/commands/install-hooks.js +128 -0
- package/bin/cli/commands/materialize.js +99 -0
- package/bin/cli/commands/path.js +88 -0
- package/bin/cli/commands/query.js +194 -0
- package/bin/cli/commands/registry.js +28 -0
- package/bin/cli/commands/seek.js +592 -0
- package/bin/cli/commands/trust.js +154 -0
- package/bin/cli/commands/verify-audit.js +113 -0
- package/bin/cli/commands/view.js +45 -0
- package/bin/cli/infrastructure.js +336 -0
- package/bin/cli/schemas.js +177 -0
- package/bin/cli/shared.js +244 -0
- package/bin/cli/types.js +85 -0
- package/bin/presenters/index.js +214 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +543 -0
- package/bin/warp-graph.js +19 -2824
- package/index.d.ts +32 -2
- package/index.js +2 -0
- package/package.json +9 -7
- package/src/domain/WarpGraph.js +106 -3252
- package/src/domain/errors/QueryError.js +2 -2
- package/src/domain/errors/TrustError.js +29 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AuditMessageCodec.js +137 -0
- package/src/domain/services/AuditReceiptService.js +471 -0
- package/src/domain/services/AuditVerifierService.js +693 -0
- package/src/domain/services/HttpSyncServer.js +36 -22
- package/src/domain/services/MessageCodecInternal.js +3 -0
- package/src/domain/services/MessageSchemaDetector.js +2 -2
- package/src/domain/services/SyncAuthService.js +69 -3
- package/src/domain/services/WarpMessageCodec.js +4 -1
- package/src/domain/trust/TrustCanonical.js +42 -0
- package/src/domain/trust/TrustCrypto.js +111 -0
- package/src/domain/trust/TrustEvaluator.js +180 -0
- package/src/domain/trust/TrustRecordService.js +274 -0
- package/src/domain/trust/TrustStateBuilder.js +209 -0
- package/src/domain/trust/canonical.js +68 -0
- package/src/domain/trust/reasonCodes.js +64 -0
- package/src/domain/trust/schemas.js +160 -0
- package/src/domain/trust/verdict.js +42 -0
- package/src/domain/types/git-cas.d.ts +20 -0
- package/src/domain/utils/RefLayout.js +59 -0
- package/src/domain/warp/PatchSession.js +18 -0
- package/src/domain/warp/Writer.js +18 -3
- package/src/domain/warp/_internal.js +26 -0
- package/src/domain/warp/_wire.js +58 -0
- package/src/domain/warp/_wiredMethods.d.ts +100 -0
- package/src/domain/warp/checkpoint.methods.js +397 -0
- package/src/domain/warp/fork.methods.js +323 -0
- package/src/domain/warp/materialize.methods.js +188 -0
- package/src/domain/warp/materializeAdvanced.methods.js +339 -0
- package/src/domain/warp/patch.methods.js +529 -0
- package/src/domain/warp/provenance.methods.js +284 -0
- package/src/domain/warp/query.methods.js +279 -0
- package/src/domain/warp/subscribe.methods.js +272 -0
- package/src/domain/warp/sync.methods.js +549 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
- package/src/ports/CommitPort.js +10 -0
- package/src/ports/RefPort.js +17 -0
- package/src/hooks/post-merge.sh +0 -60
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust V1 record service.
|
|
3
|
+
*
|
|
4
|
+
* Manages the append-only chain of signed trust records stored under
|
|
5
|
+
* `refs/warp/<graph>/trust/records`. Each record is a Git commit
|
|
6
|
+
* whose message carries CBOR-encoded record data.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/trust/TrustRecordService
|
|
9
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md Section 7
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { buildTrustRecordRef } from '../utils/RefLayout.js';
|
|
13
|
+
import { TrustRecordSchema } from './schemas.js';
|
|
14
|
+
import { verifyRecordId } from './TrustCanonical.js';
|
|
15
|
+
import TrustError from '../errors/TrustError.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {Object} AppendOptions
|
|
19
|
+
* @property {boolean} [skipSignatureVerify=false] - Skip signature verification (for testing)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export class TrustRecordService {
|
|
23
|
+
/**
|
|
24
|
+
* @param {Object} options
|
|
25
|
+
* @param {*} options.persistence - GraphPersistencePort adapter
|
|
26
|
+
* @param {*} options.codec - CodecPort adapter (CBOR)
|
|
27
|
+
*/
|
|
28
|
+
constructor({ persistence, codec }) {
|
|
29
|
+
this._persistence = persistence;
|
|
30
|
+
this._codec = codec;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Appends a signed trust record to the chain.
|
|
35
|
+
*
|
|
36
|
+
* Validates:
|
|
37
|
+
* 1. Schema conformance
|
|
38
|
+
* 2. RecordId integrity (content-addressed)
|
|
39
|
+
* 3. Signature envelope completeness (alg + sig fields present)
|
|
40
|
+
* 4. Prev-link consistency (must match current tip's last recordId)
|
|
41
|
+
*
|
|
42
|
+
* Note: Full cryptographic signature verification (Ed25519 verify against
|
|
43
|
+
* issuer public key) is NOT performed here — it requires the trust state
|
|
44
|
+
* to resolve the issuer's key, which is a chicken-and-egg problem for
|
|
45
|
+
* genesis records. Crypto verification happens during `buildState()` /
|
|
46
|
+
* evaluation when the full key set is available.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} graphName
|
|
49
|
+
* @param {Record<string, *>} record - Complete signed trust record
|
|
50
|
+
* @param {AppendOptions} [options]
|
|
51
|
+
* @returns {Promise<{commitSha: string, ref: string}>}
|
|
52
|
+
*/
|
|
53
|
+
async appendRecord(graphName, record, options = {}) {
|
|
54
|
+
// 1. Schema validation
|
|
55
|
+
const parsed = TrustRecordSchema.safeParse(record);
|
|
56
|
+
if (!parsed.success) {
|
|
57
|
+
throw new TrustError(
|
|
58
|
+
`Trust record schema validation failed: ${parsed.error.message}`,
|
|
59
|
+
{ code: 'E_TRUST_RECORD_INVALID' },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 2. RecordId integrity
|
|
64
|
+
if (!verifyRecordId(record)) {
|
|
65
|
+
throw new TrustError(
|
|
66
|
+
'Trust record recordId does not match content',
|
|
67
|
+
{ code: 'E_TRUST_RECORD_ID_MISMATCH' },
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Signature envelope check (structural, not cryptographic)
|
|
72
|
+
if (!options.skipSignatureVerify) {
|
|
73
|
+
this._verifySignatureEnvelope(record);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Prev-link consistency
|
|
77
|
+
const ref = buildTrustRecordRef(graphName);
|
|
78
|
+
const { tipSha, recordId: currentTip } = await this._readTip(ref);
|
|
79
|
+
|
|
80
|
+
if (record.prev !== currentTip) {
|
|
81
|
+
throw new TrustError(
|
|
82
|
+
`Prev-link mismatch: record.prev=${record.prev}, chain tip=${currentTip}`,
|
|
83
|
+
{ code: 'E_TRUST_PREV_MISMATCH' },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 5. Persist as Git commit (passes tipSha to avoid re-reading ref)
|
|
88
|
+
const commitSha = await this._persistRecord(ref, record, tipSha);
|
|
89
|
+
return { commitSha, ref };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reads all trust records from the chain, oldest first.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} graphName
|
|
96
|
+
* @param {Object} [options]
|
|
97
|
+
* @param {string} [options.tip] - Override tip commit (for pinned reads)
|
|
98
|
+
* @returns {Promise<Array<Record<string, *>>>}
|
|
99
|
+
*/
|
|
100
|
+
async readRecords(graphName, options = {}) {
|
|
101
|
+
const ref = buildTrustRecordRef(graphName);
|
|
102
|
+
let tip = options.tip ?? null;
|
|
103
|
+
|
|
104
|
+
if (!tip) {
|
|
105
|
+
try {
|
|
106
|
+
tip = await this._persistence.readRef(ref);
|
|
107
|
+
} catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
if (!tip) {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const records = [];
|
|
116
|
+
let current = tip;
|
|
117
|
+
|
|
118
|
+
while (current) {
|
|
119
|
+
const info = await this._persistence.getNodeInfo(current);
|
|
120
|
+
const record = this._codec.decode(
|
|
121
|
+
await this._persistence.readBlob(
|
|
122
|
+
(await this._persistence.readTreeOids(
|
|
123
|
+
await this._persistence.getCommitTree(current),
|
|
124
|
+
))['record.cbor'],
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
records.unshift(record);
|
|
129
|
+
|
|
130
|
+
if (info.parents.length === 0) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
current = info.parents[0];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return records;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Verifies the structural integrity of a record chain.
|
|
141
|
+
*
|
|
142
|
+
* Checks:
|
|
143
|
+
* - Prev-links form an unbroken chain
|
|
144
|
+
* - No duplicate recordIds
|
|
145
|
+
* - Each record passes schema validation
|
|
146
|
+
* - First record has prev=null
|
|
147
|
+
*
|
|
148
|
+
* @param {Array<Record<string, *>>} records - Records in chain order (oldest first)
|
|
149
|
+
* @returns {{valid: boolean, errors: Array<{index: number, error: string}>}}
|
|
150
|
+
*/
|
|
151
|
+
verifyChain(records) {
|
|
152
|
+
/** @type {Array<{index: number, error: string}>} */
|
|
153
|
+
const errors = [];
|
|
154
|
+
const seenIds = new Set();
|
|
155
|
+
|
|
156
|
+
for (let i = 0; i < records.length; i++) {
|
|
157
|
+
const record = records[i];
|
|
158
|
+
|
|
159
|
+
// Schema validation
|
|
160
|
+
const parsed = TrustRecordSchema.safeParse(record);
|
|
161
|
+
if (!parsed.success) {
|
|
162
|
+
errors.push({ index: i, error: `Schema: ${parsed.error.message}` });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// RecordId integrity
|
|
167
|
+
if (!verifyRecordId(record)) {
|
|
168
|
+
errors.push({ index: i, error: 'RecordId does not match content' });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Duplicate detection
|
|
172
|
+
if (seenIds.has(record.recordId)) {
|
|
173
|
+
errors.push({ index: i, error: `Duplicate recordId: ${record.recordId}` });
|
|
174
|
+
}
|
|
175
|
+
seenIds.add(record.recordId);
|
|
176
|
+
|
|
177
|
+
// Prev-link check
|
|
178
|
+
if (i === 0) {
|
|
179
|
+
if (record.prev !== null) {
|
|
180
|
+
errors.push({ index: i, error: `Genesis record must have prev=null, got ${record.prev}` });
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
const expectedPrev = records[i - 1].recordId;
|
|
184
|
+
if (record.prev !== expectedPrev) {
|
|
185
|
+
errors.push({
|
|
186
|
+
index: i,
|
|
187
|
+
error: `Prev-link mismatch: expected ${expectedPrev}, got ${record.prev}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { valid: errors.length === 0, errors };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Validates that a record's signature envelope is structurally complete.
|
|
198
|
+
*
|
|
199
|
+
* Checks for presence of `alg` and `sig` fields. Does NOT perform
|
|
200
|
+
* cryptographic verification — that requires the issuer's public key
|
|
201
|
+
* from the trust state, which is resolved during evaluation.
|
|
202
|
+
*
|
|
203
|
+
* @param {Record<string, *>} record
|
|
204
|
+
* @throws {TrustError} if signature envelope is missing or malformed
|
|
205
|
+
* @private
|
|
206
|
+
*/
|
|
207
|
+
_verifySignatureEnvelope(record) {
|
|
208
|
+
if (!record.signature || !record.signature.sig || !record.signature.alg) {
|
|
209
|
+
throw new TrustError(
|
|
210
|
+
'Trust record missing or malformed signature',
|
|
211
|
+
{ code: 'E_TRUST_SIGNATURE_MISSING' },
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Reads the tip commit SHA and its recordId.
|
|
218
|
+
* @param {string} ref
|
|
219
|
+
* @returns {Promise<{tipSha: string|null, recordId: string|null}>}
|
|
220
|
+
* @private
|
|
221
|
+
*/
|
|
222
|
+
async _readTip(ref) {
|
|
223
|
+
let tipSha;
|
|
224
|
+
try {
|
|
225
|
+
tipSha = await this._persistence.readRef(ref);
|
|
226
|
+
} catch {
|
|
227
|
+
return { tipSha: null, recordId: null };
|
|
228
|
+
}
|
|
229
|
+
if (!tipSha) {
|
|
230
|
+
return { tipSha: null, recordId: null };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const treeOid = await this._persistence.getCommitTree(tipSha);
|
|
234
|
+
const entries = await this._persistence.readTreeOids(treeOid);
|
|
235
|
+
const blobOid = entries['record.cbor'];
|
|
236
|
+
if (!blobOid) {
|
|
237
|
+
return { tipSha, recordId: null };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const record = this._codec.decode(await this._persistence.readBlob(blobOid));
|
|
241
|
+
return { tipSha, recordId: record.recordId ?? null };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Persists a trust record as a Git commit.
|
|
246
|
+
* @param {string} ref
|
|
247
|
+
* @param {Record<string, *>} record
|
|
248
|
+
* @param {string|null} parentSha - Resolved tip SHA (null for genesis)
|
|
249
|
+
* @returns {Promise<string>} commit SHA
|
|
250
|
+
* @private
|
|
251
|
+
*/
|
|
252
|
+
async _persistRecord(ref, record, parentSha) {
|
|
253
|
+
// Encode record as CBOR blob
|
|
254
|
+
const encoded = this._codec.encode(record);
|
|
255
|
+
const blobOid = await this._persistence.writeBlob(encoded);
|
|
256
|
+
|
|
257
|
+
// Create tree with single entry
|
|
258
|
+
const treeOid = await this._persistence.writeTree({ 'record.cbor': blobOid });
|
|
259
|
+
|
|
260
|
+
const parents = parentSha ? [parentSha] : [];
|
|
261
|
+
const message = `trust: ${record.recordType} ${record.recordId.slice(0, 12)}`;
|
|
262
|
+
|
|
263
|
+
const commitSha = await this._persistence.createCommit({
|
|
264
|
+
tree: treeOid,
|
|
265
|
+
parents,
|
|
266
|
+
message,
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// CAS update ref — fails atomically if a concurrent append changed the tip
|
|
270
|
+
await this._persistence.compareAndSwapRef(ref, commitSha, parentSha);
|
|
271
|
+
|
|
272
|
+
return commitSha;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust V1 state builder.
|
|
3
|
+
*
|
|
4
|
+
* Pure function that walks an ordered sequence of trust records and
|
|
5
|
+
* accumulates the trust state: active/revoked keys and writer bindings.
|
|
6
|
+
*
|
|
7
|
+
* No I/O, no side effects, no infrastructure imports.
|
|
8
|
+
*
|
|
9
|
+
* @module domain/trust/TrustStateBuilder
|
|
10
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md Section 11
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { TrustRecordSchema } from './schemas.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} TrustState
|
|
17
|
+
* @property {Map<string, {publicKey: string, addedAt: string}>} activeKeys - keyId → key info
|
|
18
|
+
* @property {Map<string, {publicKey: string, revokedAt: string, reasonCode: string}>} revokedKeys
|
|
19
|
+
* @property {Map<string, {keyId: string, boundAt: string}>} writerBindings - "writerId\0keyId" → binding
|
|
20
|
+
* @property {Map<string, {keyId: string, revokedAt: string, reasonCode: string}>} revokedBindings
|
|
21
|
+
* @property {Array<{recordId: string, error: string}>} errors
|
|
22
|
+
* @property {number} recordsProcessed - Total number of records fed to the builder
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Builds trust state from an ordered sequence of trust records.
|
|
27
|
+
*
|
|
28
|
+
* Records MUST be in chain order (oldest first). The builder enforces:
|
|
29
|
+
* - Monotonic revocation: once a key is revoked, it cannot be re-added
|
|
30
|
+
* - Binding validity: WRITER_BIND_ADD requires the referenced key to be active
|
|
31
|
+
* - Schema validation: each record is validated against TrustRecordSchema
|
|
32
|
+
*
|
|
33
|
+
* @param {Array<Record<string, *>>} records - Trust records in chain order
|
|
34
|
+
* @returns {TrustState} Frozen trust state
|
|
35
|
+
*/
|
|
36
|
+
export function buildState(records) {
|
|
37
|
+
/** @type {Map<string, {publicKey: string, addedAt: string}>} */
|
|
38
|
+
const activeKeys = new Map();
|
|
39
|
+
/** @type {Map<string, {publicKey: string, revokedAt: string, reasonCode: string}>} */
|
|
40
|
+
const revokedKeys = new Map();
|
|
41
|
+
/** @type {Map<string, {keyId: string, boundAt: string}>} */
|
|
42
|
+
const writerBindings = new Map();
|
|
43
|
+
/** @type {Map<string, {keyId: string, revokedAt: string, reasonCode: string}>} */
|
|
44
|
+
const revokedBindings = new Map();
|
|
45
|
+
/** @type {Array<{recordId: string, error: string}>} */
|
|
46
|
+
const errors = [];
|
|
47
|
+
|
|
48
|
+
for (const record of records) {
|
|
49
|
+
const parsed = TrustRecordSchema.safeParse(record);
|
|
50
|
+
if (!parsed.success) {
|
|
51
|
+
errors.push({
|
|
52
|
+
recordId: record.recordId ?? '(unknown)',
|
|
53
|
+
error: `Schema validation failed: ${parsed.error.message}`,
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const rec = parsed.data;
|
|
59
|
+
processRecord(rec, activeKeys, revokedKeys, writerBindings, revokedBindings, errors);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return Object.freeze({ activeKeys, revokedKeys, writerBindings, revokedBindings, errors, recordsProcessed: records.length });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {*} rec
|
|
67
|
+
* @param {Map<string, {publicKey: string, addedAt: string}>} activeKeys
|
|
68
|
+
* @param {Map<string, {publicKey: string, revokedAt: string, reasonCode: string}>} revokedKeys
|
|
69
|
+
* @param {Map<string, {keyId: string, boundAt: string}>} writerBindings
|
|
70
|
+
* @param {Map<string, {keyId: string, revokedAt: string, reasonCode: string}>} revokedBindings
|
|
71
|
+
* @param {Array<{recordId: string, error: string}>} errors
|
|
72
|
+
*/
|
|
73
|
+
function processRecord(rec, activeKeys, revokedKeys, writerBindings, revokedBindings, errors) {
|
|
74
|
+
switch (rec.recordType) {
|
|
75
|
+
case 'KEY_ADD':
|
|
76
|
+
handleKeyAdd(rec, activeKeys, revokedKeys, errors);
|
|
77
|
+
break;
|
|
78
|
+
case 'KEY_REVOKE':
|
|
79
|
+
handleKeyRevoke(rec, activeKeys, revokedKeys, errors);
|
|
80
|
+
break;
|
|
81
|
+
case 'WRITER_BIND_ADD':
|
|
82
|
+
handleBindAdd(rec, activeKeys, revokedKeys, writerBindings, errors);
|
|
83
|
+
break;
|
|
84
|
+
case 'WRITER_BIND_REVOKE':
|
|
85
|
+
handleBindRevoke(rec, writerBindings, revokedBindings, errors);
|
|
86
|
+
break;
|
|
87
|
+
default:
|
|
88
|
+
errors.push({ recordId: rec.recordId, error: `Unknown recordType: ${rec.recordType}` });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @param {*} rec
|
|
94
|
+
* @param {Map<string, {publicKey: string, addedAt: string}>} activeKeys
|
|
95
|
+
* @param {Map<string, {publicKey: string, revokedAt: string, reasonCode: string}>} revokedKeys
|
|
96
|
+
* @param {Array<{recordId: string, error: string}>} errors
|
|
97
|
+
*/
|
|
98
|
+
function handleKeyAdd(rec, activeKeys, revokedKeys, errors) {
|
|
99
|
+
const { keyId, publicKey } = rec.subject;
|
|
100
|
+
|
|
101
|
+
if (revokedKeys.has(keyId)) {
|
|
102
|
+
errors.push({
|
|
103
|
+
recordId: rec.recordId,
|
|
104
|
+
error: `Cannot re-add revoked key: ${keyId}`,
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (activeKeys.has(keyId)) {
|
|
110
|
+
errors.push({
|
|
111
|
+
recordId: rec.recordId,
|
|
112
|
+
error: `Duplicate KEY_ADD for already-active key: ${keyId}`,
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
activeKeys.set(keyId, { publicKey, addedAt: rec.issuedAt });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {*} rec
|
|
122
|
+
* @param {Map<string, {publicKey: string, addedAt: string}>} activeKeys
|
|
123
|
+
* @param {Map<string, {publicKey: string, revokedAt: string, reasonCode: string}>} revokedKeys
|
|
124
|
+
* @param {Array<{recordId: string, error: string}>} errors
|
|
125
|
+
*/
|
|
126
|
+
function handleKeyRevoke(rec, activeKeys, revokedKeys, errors) {
|
|
127
|
+
const { keyId, reasonCode } = rec.subject;
|
|
128
|
+
|
|
129
|
+
if (revokedKeys.has(keyId)) {
|
|
130
|
+
errors.push({
|
|
131
|
+
recordId: rec.recordId,
|
|
132
|
+
error: `Key already revoked: ${keyId}`,
|
|
133
|
+
});
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const keyInfo = activeKeys.get(keyId);
|
|
138
|
+
if (!keyInfo) {
|
|
139
|
+
errors.push({
|
|
140
|
+
recordId: rec.recordId,
|
|
141
|
+
error: `Cannot revoke unknown key: ${keyId}`,
|
|
142
|
+
});
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
activeKeys.delete(keyId);
|
|
147
|
+
revokedKeys.set(keyId, {
|
|
148
|
+
publicKey: keyInfo.publicKey,
|
|
149
|
+
revokedAt: rec.issuedAt,
|
|
150
|
+
reasonCode,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* @param {*} rec
|
|
156
|
+
* @param {Map<string, {publicKey: string, addedAt: string}>} activeKeys
|
|
157
|
+
* @param {Map<string, {publicKey: string, revokedAt: string, reasonCode: string}>} revokedKeys
|
|
158
|
+
* @param {Map<string, {keyId: string, boundAt: string}>} writerBindings
|
|
159
|
+
* @param {Array<{recordId: string, error: string}>} errors
|
|
160
|
+
*/
|
|
161
|
+
function handleBindAdd(rec, activeKeys, revokedKeys, writerBindings, errors) {
|
|
162
|
+
const { writerId, keyId } = rec.subject;
|
|
163
|
+
const bindingKey = `${writerId}\0${keyId}`;
|
|
164
|
+
|
|
165
|
+
if (revokedKeys.has(keyId)) {
|
|
166
|
+
errors.push({
|
|
167
|
+
recordId: rec.recordId,
|
|
168
|
+
error: `Cannot bind writer to revoked key: ${keyId}`,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!activeKeys.has(keyId)) {
|
|
174
|
+
errors.push({
|
|
175
|
+
recordId: rec.recordId,
|
|
176
|
+
error: `Cannot bind writer to unknown key: ${keyId}`,
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
writerBindings.set(bindingKey, { keyId, boundAt: rec.issuedAt });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {*} rec
|
|
186
|
+
* @param {Map<string, {keyId: string, boundAt: string}>} writerBindings
|
|
187
|
+
* @param {Map<string, {keyId: string, revokedAt: string, reasonCode: string}>} revokedBindings
|
|
188
|
+
* @param {Array<{recordId: string, error: string}>} errors
|
|
189
|
+
*/
|
|
190
|
+
function handleBindRevoke(rec, writerBindings, revokedBindings, errors) {
|
|
191
|
+
const { writerId, keyId, reasonCode } = rec.subject;
|
|
192
|
+
const bindingKey = `${writerId}\0${keyId}`;
|
|
193
|
+
|
|
194
|
+
const binding = writerBindings.get(bindingKey);
|
|
195
|
+
if (!binding) {
|
|
196
|
+
errors.push({
|
|
197
|
+
recordId: rec.recordId,
|
|
198
|
+
error: `Cannot revoke non-existent binding: writer=${writerId} key=${keyId}`,
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
writerBindings.delete(bindingKey);
|
|
204
|
+
revokedBindings.set(bindingKey, {
|
|
205
|
+
keyId,
|
|
206
|
+
revokedAt: rec.issuedAt,
|
|
207
|
+
reasonCode,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust V1 canonical serialization helpers.
|
|
3
|
+
*
|
|
4
|
+
* Domain separation constants and unsigned record extraction functions
|
|
5
|
+
* for recordId computation and signature payloads.
|
|
6
|
+
*
|
|
7
|
+
* @module domain/trust/canonical
|
|
8
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md Section 6
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { canonicalStringify } from '../utils/canonicalStringify.js';
|
|
12
|
+
|
|
13
|
+
// ── Domain separation prefixes ──────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/** Domain prefix for recordId computation. */
|
|
16
|
+
export const TRUST_RECORD_ID_DOMAIN = 'git-warp:trust-record:v1\0';
|
|
17
|
+
|
|
18
|
+
/** Domain prefix for signature payload. */
|
|
19
|
+
export const TRUST_SIGN_DOMAIN = 'git-warp:trust-sign:v1\0';
|
|
20
|
+
|
|
21
|
+
// ── Unsigned record helpers ─────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns the record payload used for recordId computation.
|
|
25
|
+
* Strips `recordId` and `signature` — these are derived, not inputs.
|
|
26
|
+
*
|
|
27
|
+
* @param {Record<string, *>} record
|
|
28
|
+
* @returns {Record<string, *>}
|
|
29
|
+
*/
|
|
30
|
+
export function unsignedRecordForId(record) {
|
|
31
|
+
const out = { ...record };
|
|
32
|
+
delete out.recordId;
|
|
33
|
+
delete out.signature;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Returns the record payload used for signature computation.
|
|
39
|
+
* Strips `signature` only — `recordId` is included in signed payload.
|
|
40
|
+
*
|
|
41
|
+
* @param {Record<string, *>} record
|
|
42
|
+
* @returns {Record<string, *>}
|
|
43
|
+
*/
|
|
44
|
+
export function unsignedRecordForSignature(record) {
|
|
45
|
+
const out = { ...record };
|
|
46
|
+
delete out.signature;
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Computes the canonical string for recordId hashing.
|
|
52
|
+
*
|
|
53
|
+
* @param {Record<string, *>} record - Full record (recordId and signature will be stripped)
|
|
54
|
+
* @returns {string} Domain-separated canonical JSON string
|
|
55
|
+
*/
|
|
56
|
+
export function recordIdPayload(record) {
|
|
57
|
+
return TRUST_RECORD_ID_DOMAIN + canonicalStringify(unsignedRecordForId(record));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Computes the canonical string for signature verification.
|
|
62
|
+
*
|
|
63
|
+
* @param {Record<string, *>} record - Full record (signature will be stripped)
|
|
64
|
+
* @returns {string} Domain-separated canonical JSON string
|
|
65
|
+
*/
|
|
66
|
+
export function signaturePayload(record) {
|
|
67
|
+
return TRUST_SIGN_DOMAIN + canonicalStringify(unsignedRecordForSignature(record));
|
|
68
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust V1 reason code registry.
|
|
3
|
+
*
|
|
4
|
+
* Every trust explanation MUST include a reasonCode from this registry.
|
|
5
|
+
* Codes are stable — renaming or removing a code is a breaking change
|
|
6
|
+
* that requires a spec version bump.
|
|
7
|
+
*
|
|
8
|
+
* @module domain/trust/reasonCodes
|
|
9
|
+
* @see docs/specs/TRUST_V1_CRYPTO.md Section 15
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** @type {Readonly<Record<string, string>>} */
|
|
13
|
+
export const TRUST_REASON_CODES = Object.freeze({
|
|
14
|
+
// ── Positive ────────────────────────────────────────────────────────────
|
|
15
|
+
/** Writer has at least one active binding to an active key. */
|
|
16
|
+
WRITER_BOUND_TO_ACTIVE_KEY: 'WRITER_BOUND_TO_ACTIVE_KEY',
|
|
17
|
+
|
|
18
|
+
// ── Negative ────────────────────────────────────────────────────────────
|
|
19
|
+
/** Writer has no active bindings. */
|
|
20
|
+
WRITER_HAS_NO_ACTIVE_BINDING: 'WRITER_HAS_NO_ACTIVE_BINDING',
|
|
21
|
+
/** Writer's binding references a revoked key. */
|
|
22
|
+
WRITER_BOUND_KEY_REVOKED: 'WRITER_BOUND_KEY_REVOKED',
|
|
23
|
+
/** Writer's binding has been explicitly revoked. */
|
|
24
|
+
BINDING_REVOKED: 'BINDING_REVOKED',
|
|
25
|
+
/** Binding references a keyId not found in record log. */
|
|
26
|
+
KEY_UNKNOWN: 'KEY_UNKNOWN',
|
|
27
|
+
|
|
28
|
+
// ── System ──────────────────────────────────────────────────────────────
|
|
29
|
+
/** Trust record ref does not exist. */
|
|
30
|
+
TRUST_REF_MISSING: 'TRUST_REF_MISSING',
|
|
31
|
+
/** Pinned commit does not exist or is invalid. */
|
|
32
|
+
TRUST_PIN_INVALID: 'TRUST_PIN_INVALID',
|
|
33
|
+
/** Record fails schema validation. */
|
|
34
|
+
TRUST_RECORD_SCHEMA_INVALID: 'TRUST_RECORD_SCHEMA_INVALID',
|
|
35
|
+
/** Record signature verification failed. */
|
|
36
|
+
TRUST_SIGNATURE_INVALID: 'TRUST_SIGNATURE_INVALID',
|
|
37
|
+
/** Record chain linking is broken. */
|
|
38
|
+
TRUST_RECORD_CHAIN_INVALID: 'TRUST_RECORD_CHAIN_INVALID',
|
|
39
|
+
/** Policy value is unknown or unsupported. */
|
|
40
|
+
TRUST_POLICY_INVALID: 'TRUST_POLICY_INVALID',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** @type {ReadonlySet<string>} */
|
|
44
|
+
export const POSITIVE_CODES = Object.freeze(new Set([
|
|
45
|
+
TRUST_REASON_CODES.WRITER_BOUND_TO_ACTIVE_KEY,
|
|
46
|
+
]));
|
|
47
|
+
|
|
48
|
+
/** @type {ReadonlySet<string>} */
|
|
49
|
+
export const NEGATIVE_CODES = Object.freeze(new Set([
|
|
50
|
+
TRUST_REASON_CODES.WRITER_HAS_NO_ACTIVE_BINDING,
|
|
51
|
+
TRUST_REASON_CODES.WRITER_BOUND_KEY_REVOKED,
|
|
52
|
+
TRUST_REASON_CODES.BINDING_REVOKED,
|
|
53
|
+
TRUST_REASON_CODES.KEY_UNKNOWN,
|
|
54
|
+
]));
|
|
55
|
+
|
|
56
|
+
/** @type {ReadonlySet<string>} */
|
|
57
|
+
export const SYSTEM_CODES = Object.freeze(new Set([
|
|
58
|
+
TRUST_REASON_CODES.TRUST_REF_MISSING,
|
|
59
|
+
TRUST_REASON_CODES.TRUST_PIN_INVALID,
|
|
60
|
+
TRUST_REASON_CODES.TRUST_RECORD_SCHEMA_INVALID,
|
|
61
|
+
TRUST_REASON_CODES.TRUST_SIGNATURE_INVALID,
|
|
62
|
+
TRUST_REASON_CODES.TRUST_RECORD_CHAIN_INVALID,
|
|
63
|
+
TRUST_REASON_CODES.TRUST_POLICY_INVALID,
|
|
64
|
+
]));
|