aegis-desktop 0.8.12 → 0.8.13
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/lib/auto.js +423 -0
- package/lib/avatar/packs/core-wardrobe.pack.json +1 -1
- package/lib/avatar/packs/local-voice-core.pack.json +1 -1
- package/lib/avatar/packs/seasonal-winter.pack.json +1 -1
- package/lib/avatar/profile.js +10 -1
- package/lib/fingerprint.js +327 -0
- package/lib/local/autonomous.js +1 -0
- package/lib/local/prompt.js +6 -0
- package/lib/settings.js +154 -1
- package/main.js +316 -17
- package/package.json +2 -2
- package/preload.js +84 -4
- package/renderer/app.js +208 -4
- package/renderer/fingerprint.css +143 -0
- package/renderer/fingerprint.js +583 -0
- package/renderer/index.html +163 -1
- package/vendor/aegis.js +180 -18
- package/vendor/attest.js +439 -0
- package/vendor/authorship-key.js +366 -0
- package/vendor/device-fingerprint.js +85 -3
- package/vendor/fingerprint.js +1061 -0
- package/vendor/presence-constants.js +104 -0
- package/vendor/presence.js +435 -0
- package/vendor/record-vault.js +834 -0
- package/vendor/touch.js +455 -0
|
@@ -0,0 +1,1061 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VENDORED — do not edit. Generated by tools/vendor-fingerprint.mjs.
|
|
5
|
+
*
|
|
6
|
+
* source: aegiscodex 046ba6f — src/fingerprint.js
|
|
7
|
+
* rewrite: ESM -> CJS, module syntax only. No constant, purpose string,
|
|
8
|
+
* attested field list or digest differs from the engine's.
|
|
9
|
+
* verify: node --test test/fingerprint-interop.test.mjs
|
|
10
|
+
* refresh: node tools/vendor-fingerprint.mjs
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* fingerprint.js — φ(α), the agent fingerprint.
|
|
14
|
+
*
|
|
15
|
+
* The covenant defines the thing this file mints:
|
|
16
|
+
*
|
|
17
|
+
* Fingerprint φ(α) cryptographically verifiable identity
|
|
18
|
+
* carries reputation + track record + behavioural signature
|
|
19
|
+
*
|
|
20
|
+
* and then states the structural claim that makes it worth having: *identity and
|
|
21
|
+
* reputation are properties of the agent, not of the platform hosting it.* φ(α)
|
|
22
|
+
* travels. It is not a session token.
|
|
23
|
+
*
|
|
24
|
+
* So this module answers three separate questions, and keeps them separate:
|
|
25
|
+
*
|
|
26
|
+
* 1. "same credential?" `keyFingerprint(secret)` — one-way, truncated.
|
|
27
|
+
* 2. "same behaviour?" `behaviouralSignature(observations)` — a digest
|
|
28
|
+
* over what the agent actually did, order-independent
|
|
29
|
+
* so a re-ordered transcript is still the same agent.
|
|
30
|
+
* 3. "is this record intact?" `verify(record, secret)` — an HMAC attestation,
|
|
31
|
+
* compared in constant time.
|
|
32
|
+
*
|
|
33
|
+
* Four rules, and each is an assertion in `tests/fingerprint.test.mjs` rather
|
|
34
|
+
* than a sentence in a doc:
|
|
35
|
+
*
|
|
36
|
+
* 1. **A fingerprint is not an identity.** `keyFingerprint` says "same
|
|
37
|
+
* credential", never "same person" — two agents on one box can legitimately
|
|
38
|
+
* share a key. The record therefore keeps credential identity and behavioural
|
|
39
|
+
* signature in separate fields and `same()` requires the caller to say which
|
|
40
|
+
* one they mean.
|
|
41
|
+
* 2. **No key material escapes.** The raw secret is never a field, never a
|
|
42
|
+
* substring of one, and `assertNoLeak` re-checks that on every minted record:
|
|
43
|
+
* no field other than `attestation` may carry a long hex run. `keyFingerprint`
|
|
44
|
+
* is truncated to the same 12 hex the avatar registry already uses, so a
|
|
45
|
+
* fingerprint minted here indexes the same way a registry row does.
|
|
46
|
+
* 3. **A signature over nothing is not evidence.** `behaviouralSignature([])`
|
|
47
|
+
* returns `null`, and the record records `behaviourSamples: 0`. Absence of
|
|
48
|
+
* track record reads as absence, never as a zero-valued hash. The thumb axis
|
|
49
|
+
* follows the same rule: fewer than `touch.MIN_STROKES` strokes gives
|
|
50
|
+
* `touch: null`, `touchSamples: 0`.
|
|
51
|
+
* 4. **Verification is recomputation.** `verify` re-derives the attestation
|
|
52
|
+
* from the attested fields only (`ATTESTED_FIELDS`), so appending fields to
|
|
53
|
+
* a record cannot invalidate it and editing any attested field must.
|
|
54
|
+
*
|
|
55
|
+
* The fourth axis — `touch`, the thumb's gesture dynamics — is described in
|
|
56
|
+
* `src/touch.js`, including the part that matters most: it is **not** a
|
|
57
|
+
* fingerprint, because this hardware has no fingerprint sensor and the trackpad
|
|
58
|
+
* exposes no contact geometry through X. It is a behavioural biometric. Two
|
|
59
|
+
* consequences are structural here rather than documented:
|
|
60
|
+
*
|
|
61
|
+
* - The thumb signature is **not keyed by the secret**. A credential says "same
|
|
62
|
+
* key"; a habit says "same thumb". Keying one with the other would make every
|
|
63
|
+
* agent sharing a credential look like the same hand.
|
|
64
|
+
* - `mint` **recomputes** `touch` from the profile's medians via
|
|
65
|
+
* `touch.signatureFromMedians`, so a `signature` field sitting in a captured
|
|
66
|
+
* profile file is never trusted.
|
|
67
|
+
*
|
|
68
|
+
* The fifth axis is the **AI's own**: `aiKey` / `ai` / `aiSamples` / `aiModel`.
|
|
69
|
+
*
|
|
70
|
+
* Every axis above describes the *human* half of a turn — their credential, their
|
|
71
|
+
* demands, their thumb, their tree. Until v4 the mind on the other side of the
|
|
72
|
+
* turn was present only as `model`, a free-text label nobody attested, which is
|
|
73
|
+
* the one thing this module refuses elsewhere: a fabricated field is not
|
|
74
|
+
* evidence. So the record now carries both halves of a session, and they stay
|
|
75
|
+
* separate for the same reason credential identity and behaviour stay separate:
|
|
76
|
+
*
|
|
77
|
+
* - `aiKey` is `keyFingerprint` of the credential the session *ran on* — the
|
|
78
|
+
* model provider key, not the authorship key. Same derivation, same namespace,
|
|
79
|
+
* so `same(a, b, 'ai-credential')` answers "same mind?" and `same(a, b,
|
|
80
|
+
* 'credential')` still answers "same author?" without one reading as the
|
|
81
|
+
* other. It is `null` when the minter nominated no AI credential: an
|
|
82
|
+
* un-nominated key must not hash to a shared value, and the record says
|
|
83
|
+
* "nobody said which mind this was" rather than inventing one.
|
|
84
|
+
* - `ai` is a digest over the assistant's turns (which tools, which targets,
|
|
85
|
+
* which outcomes, which model), under its own frozen purpose string so an AI
|
|
86
|
+
* digest and a human-turn digest over identical text can never collide. It is
|
|
87
|
+
* **unkeyed**, exactly like `touch`: a habit says "same mind", not "same key".
|
|
88
|
+
* - `aiSamples` counts the AI turns that carried signal, and `ai` is `null` when
|
|
89
|
+
* none did — rule 3 again: absence of track record reads as absence.
|
|
90
|
+
*
|
|
91
|
+
* `aiModel` is the attested spelling of what `model` was only claiming: the model
|
|
92
|
+
* the session actually ran on, bounded and normalised like every other field.
|
|
93
|
+
*
|
|
94
|
+
* Record versions 1–3 have different attested bodies and are all kept, selected
|
|
95
|
+
* by `record.v`: an older record still verifies under its own field list rather
|
|
96
|
+
* than being silently unreadable.
|
|
97
|
+
*
|
|
98
|
+
* Pure: node:crypto only (plus the equally pure `src/touch.js`). No network, no
|
|
99
|
+
* spawn, no Electron — testable in plain Node, and safe to call in the
|
|
100
|
+
* autonomous queue where nothing is interactive.
|
|
101
|
+
*/
|
|
102
|
+
|
|
103
|
+
const crypto = require('node:crypto');
|
|
104
|
+
const fs = require('node:fs');
|
|
105
|
+
|
|
106
|
+
const { signatureFromMedians, MIN_STROKES: TOUCH_MIN_STROKES } = require('./touch.js');
|
|
107
|
+
|
|
108
|
+
/** Bump when the record shape changes; `verify` refuses versions it cannot read. */
|
|
109
|
+
const FP_VERSION = 4;
|
|
110
|
+
|
|
111
|
+
const FP_ALGO = 'sha256';
|
|
112
|
+
const FP_PREFIX = 'φ_';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `sha256(secret).slice(0, 12)` — deliberately identical in shape and length to
|
|
116
|
+
* `desktop/lib/avatar/identity.js`'s FINGERPRINT_LENGTH, because this value is
|
|
117
|
+
* what that module's `keyFingerprint` rung resolves against. Short on purpose:
|
|
118
|
+
* it is an index for a registry row, not an authenticator.
|
|
119
|
+
*/
|
|
120
|
+
const KEY_FP_LENGTH = 12;
|
|
121
|
+
|
|
122
|
+
/** Behavioural signatures are truncated too — they are labels for comparison. */
|
|
123
|
+
const SIG_LENGTH = 16;
|
|
124
|
+
|
|
125
|
+
/** 128-bit HMAC tag. Not the key, so the long-hex rule below exempts it. */
|
|
126
|
+
const ATTEST_LENGTH = 32;
|
|
127
|
+
|
|
128
|
+
/** Longest hex run tolerated in a non-attestation field before we call it a leak. */
|
|
129
|
+
const LEAK_HEX_RUN = 24;
|
|
130
|
+
|
|
131
|
+
const MAX_LABEL = 40;
|
|
132
|
+
const MAX_FIELD = 64;
|
|
133
|
+
const MAX_TOOLS = 64;
|
|
134
|
+
const MAX_OBSERVATIONS = 4096;
|
|
135
|
+
const MAX_DEPTH = 8;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The code axis. `CODE_LEN` is deliberately SIG_LENGTH: a 16-hex digest is
|
|
139
|
+
* shorter than `LEAK_HEX_RUN`, so the aggregate can never be mistaken for key
|
|
140
|
+
* material by `assertNoLeak`, and it is the same length as the behavioural
|
|
141
|
+
* signature, which is the same kind of claim.
|
|
142
|
+
*/
|
|
143
|
+
const CODE_LEN = SIG_LENGTH;
|
|
144
|
+
const MAX_CODE_FILES = 4096;
|
|
145
|
+
const MAX_PATH = 200;
|
|
146
|
+
|
|
147
|
+
/** v1 attested body — no thumb axis. Kept so v1 records still verify. */
|
|
148
|
+
const ATTESTED_FIELDS_V1 = Object.freeze([
|
|
149
|
+
'v',
|
|
150
|
+
'algo',
|
|
151
|
+
'keyFingerprint',
|
|
152
|
+
'label',
|
|
153
|
+
'account',
|
|
154
|
+
'model',
|
|
155
|
+
'tools',
|
|
156
|
+
'behaviour',
|
|
157
|
+
'behaviourSamples',
|
|
158
|
+
'createdAt',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The purpose prefix of the behavioural digest is FROZEN and must not track
|
|
163
|
+
* `FP_VERSION`.
|
|
164
|
+
*
|
|
165
|
+
* It used to be `aegiscode-behaviour-v${FP_VERSION}`, which quietly coupled two
|
|
166
|
+
* unrelated things: bumping the *record shape* changed the *behavioural axis*.
|
|
167
|
+
* Same observations, new version, different signature — so a v2 record's
|
|
168
|
+
* `behaviour` could no longer be reproduced by re-running the tool that minted
|
|
169
|
+
* it, which is precisely what makes the axis comparable across time. The
|
|
170
|
+
* algorithm has not changed since v2, so the string is pinned there.
|
|
171
|
+
*/
|
|
172
|
+
const BEHAVIOUR_PURPOSE = 'aegiscode-behaviour-v2';
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The AI axis's purpose prefix, frozen for the same reason and separately from
|
|
176
|
+
* `BEHAVIOUR_PURPOSE`.
|
|
177
|
+
*
|
|
178
|
+
* Domain separation is the whole point of the string here: the human's turns and
|
|
179
|
+
* the assistant's turns are hashed from the same observation primer, so without a
|
|
180
|
+
* distinct prefix a record whose two axes happened to see the same text would
|
|
181
|
+
* carry one digest under two names — and "same author" would silently read as
|
|
182
|
+
* "same mind". Pinned to v1 and not to `FP_VERSION`: the algorithm must not
|
|
183
|
+
* change because the record shape did.
|
|
184
|
+
*/
|
|
185
|
+
const AI_PURPOSE = 'aegiscode-ai-v1';
|
|
186
|
+
|
|
187
|
+
/** v2 attested body — adds the thumb axis. Field order is load-bearing and frozen. */
|
|
188
|
+
const ATTESTED_FIELDS_V2 = Object.freeze([
|
|
189
|
+
'v',
|
|
190
|
+
'algo',
|
|
191
|
+
'keyFingerprint',
|
|
192
|
+
'label',
|
|
193
|
+
'account',
|
|
194
|
+
'model',
|
|
195
|
+
'tools',
|
|
196
|
+
'behaviour',
|
|
197
|
+
'behaviourSamples',
|
|
198
|
+
'touch',
|
|
199
|
+
'touchSamples',
|
|
200
|
+
'createdAt',
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* v3 attested body — adds the code axis: *which revision of which files produced
|
|
205
|
+
* this turn*. Rule 4 still holds — appending a field to a record must not
|
|
206
|
+
* invalidate it — so a v1 or v2 record keeps verifying under its own list.
|
|
207
|
+
*/
|
|
208
|
+
const ATTESTED_FIELDS_V3 = Object.freeze([
|
|
209
|
+
'v',
|
|
210
|
+
'algo',
|
|
211
|
+
'keyFingerprint',
|
|
212
|
+
'label',
|
|
213
|
+
'account',
|
|
214
|
+
'model',
|
|
215
|
+
'tools',
|
|
216
|
+
'behaviour',
|
|
217
|
+
'behaviourSamples',
|
|
218
|
+
'touch',
|
|
219
|
+
'touchSamples',
|
|
220
|
+
'code',
|
|
221
|
+
'codeFiles',
|
|
222
|
+
'codeDirty',
|
|
223
|
+
'createdAt',
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* v4 attested body — adds the AI axis: *which mind answered, and what it did*,
|
|
228
|
+
* beside the human half the earlier versions already carried.
|
|
229
|
+
*
|
|
230
|
+
* The AI fields sit before `createdAt` and after the human's, so the two halves
|
|
231
|
+
* read in the order a session happens (the demand, then the answer). Field order
|
|
232
|
+
* is load-bearing and frozen: this list is the attestation preimage, so
|
|
233
|
+
* reordering it would invalidate every v4 record ever minted.
|
|
234
|
+
*/
|
|
235
|
+
const ATTESTED_FIELDS_V4 = Object.freeze([
|
|
236
|
+
'v',
|
|
237
|
+
'algo',
|
|
238
|
+
'keyFingerprint',
|
|
239
|
+
'label',
|
|
240
|
+
'account',
|
|
241
|
+
'model',
|
|
242
|
+
'tools',
|
|
243
|
+
'behaviour',
|
|
244
|
+
'behaviourSamples',
|
|
245
|
+
'touch',
|
|
246
|
+
'touchSamples',
|
|
247
|
+
'code',
|
|
248
|
+
'codeFiles',
|
|
249
|
+
'codeDirty',
|
|
250
|
+
'aiKey',
|
|
251
|
+
'aiModel',
|
|
252
|
+
'ai',
|
|
253
|
+
'aiSamples',
|
|
254
|
+
'createdAt',
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
/** The fields the current version attests. Exported for callers that print them. */
|
|
258
|
+
const ATTESTED_FIELDS = ATTESTED_FIELDS_V4;
|
|
259
|
+
|
|
260
|
+
const ATTESTED_FIELDS_BY_VERSION = Object.freeze({
|
|
261
|
+
1: ATTESTED_FIELDS_V1,
|
|
262
|
+
2: ATTESTED_FIELDS_V2,
|
|
263
|
+
3: ATTESTED_FIELDS_V3,
|
|
264
|
+
4: ATTESTED_FIELDS_V4,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
/** null for a version this build cannot read — verification refuses rather than guesses. */
|
|
268
|
+
function attestedFieldsFor(version) {
|
|
269
|
+
// `v` comes from untrusted JSON on the verify path. Two coercions make a
|
|
270
|
+
// bare property read wrong here: numeric keys are stored as strings, so
|
|
271
|
+
// ATTESTED_FIELDS_BY_VERSION['2'] resolves and a record carrying the *string*
|
|
272
|
+
// "2" would pin its own field list; and an inherited key ('constructor',
|
|
273
|
+
// '__proto__') resolves to a function or object rather than a field list.
|
|
274
|
+
// Require a real integer and an own property.
|
|
275
|
+
if (typeof version !== 'number' || !Number.isInteger(version)) return null;
|
|
276
|
+
return Object.prototype.hasOwnProperty.call(ATTESTED_FIELDS_BY_VERSION, version)
|
|
277
|
+
? ATTESTED_FIELDS_BY_VERSION[version]
|
|
278
|
+
: null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
// canonicalisation
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Collapse a value to its comparable form: trimmed, whitespace-collapsed,
|
|
287
|
+
* lowercased. Hashing is the only consumer, so display casing is preserved only
|
|
288
|
+
* in `label`.
|
|
289
|
+
*/
|
|
290
|
+
function normalise(value) {
|
|
291
|
+
if (value === null || value === undefined) return '';
|
|
292
|
+
return String(value).trim().replace(/\s+/g, ' ').toLowerCase();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Deterministic JSON: keys sorted, undefined/function/symbol dropped, numbers
|
|
297
|
+
* non-finite -> null, strings normalised, recursion bounded. Two structurally
|
|
298
|
+
* equal records always produce byte-identical output, which is what lets
|
|
299
|
+
* `verify` be a recomputation rather than a stored comparison.
|
|
300
|
+
*/
|
|
301
|
+
function canonical(value, depth = 0) {
|
|
302
|
+
if (depth > MAX_DEPTH) return '"[depth]"';
|
|
303
|
+
if (value === null || value === undefined) return 'null';
|
|
304
|
+
|
|
305
|
+
const t = typeof value;
|
|
306
|
+
if (t === 'number') return Number.isFinite(value) ? String(value) : 'null';
|
|
307
|
+
if (t === 'boolean') return value ? 'true' : 'false';
|
|
308
|
+
if (t === 'string') return JSON.stringify(normalise(value));
|
|
309
|
+
if (t === 'function' || t === 'symbol' || t === 'bigint') return 'null';
|
|
310
|
+
|
|
311
|
+
if (Array.isArray(value)) {
|
|
312
|
+
return '[' + value.map((v) => canonical(v, depth + 1)).join(',') + ']';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (t === 'object') {
|
|
316
|
+
const keys = Object.keys(value)
|
|
317
|
+
.filter((k) => value[k] !== undefined && typeof value[k] !== 'function')
|
|
318
|
+
.sort();
|
|
319
|
+
return (
|
|
320
|
+
'{' +
|
|
321
|
+
keys.map((k) => JSON.stringify(k) + ':' + canonical(value[k], depth + 1)).join(',') +
|
|
322
|
+
'}'
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return 'null';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Purpose-prefixed digest, hex. The prefix keeps one purpose's digest from ever
|
|
330
|
+
* colliding with another's over the same input. */
|
|
331
|
+
function digest(purpose, body) {
|
|
332
|
+
return crypto.createHash(FP_ALGO).update(purpose + '\n' + body, 'utf8').digest('hex');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// redaction helpers
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* A label safe to store and display: bounded length, and control characters /
|
|
341
|
+
* newlines stripped so a value can never forge a second record line. Mirrors
|
|
342
|
+
* `avatar/identity.js`'s `safeLabel`, including its rule that a rejected value
|
|
343
|
+
* is never echoed back.
|
|
344
|
+
*/
|
|
345
|
+
function safeLabel(value, max = MAX_LABEL) {
|
|
346
|
+
if (value === null || value === undefined) return '';
|
|
347
|
+
const cleaned = String(value)
|
|
348
|
+
// eslint-disable-next-line no-control-regex
|
|
349
|
+
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
|
350
|
+
.replace(/\s+/g, ' ')
|
|
351
|
+
.trim();
|
|
352
|
+
if (!cleaned) return '';
|
|
353
|
+
return cleaned.length > max ? cleaned.slice(0, max) : cleaned;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Any run of >= `n` hex characters. Keys, digests and seeds all look like this. */
|
|
357
|
+
function longHexRun(value, n = LEAK_HEX_RUN) {
|
|
358
|
+
return new RegExp(`[0-9a-fA-F]{${n},}`).test(String(value));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const HEX_ATTESTATION = new RegExp(`^[0-9a-f]{${ATTEST_LENGTH}}$`);
|
|
362
|
+
|
|
363
|
+
function isFingerprintShape(value) {
|
|
364
|
+
return new RegExp(`^[0-9a-f]{${KEY_FP_LENGTH}}$`).test(String(value || ''));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Refuse to return a record that carries the secret or anything that looks like
|
|
369
|
+
* a key. Throws rather than redacting: a caller that reached this point has a
|
|
370
|
+
* bug in the shape of what it passes in, and silently dropping the field would
|
|
371
|
+
* hide it.
|
|
372
|
+
*/
|
|
373
|
+
function assertNoLeak(record, secret, alsoSecret) {
|
|
374
|
+
const secrets = [secret, alsoSecret]
|
|
375
|
+
.map((v) => (v === null || v === undefined ? '' : String(v)))
|
|
376
|
+
.filter(Boolean);
|
|
377
|
+
for (const [key, value] of Object.entries(record)) {
|
|
378
|
+
if (value === null || value === undefined) continue;
|
|
379
|
+
const text = Array.isArray(value) ? value.join('\u0000') : String(value);
|
|
380
|
+
|
|
381
|
+
for (const s of secrets) {
|
|
382
|
+
if (text.includes(s)) {
|
|
383
|
+
throw new Error(`fingerprint: field "${key}" would carry the secret verbatim`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (key === 'attestation') {
|
|
387
|
+
if (!HEX_ATTESTATION.test(text)) {
|
|
388
|
+
throw new Error('fingerprint: attestation must be 32 lowercase hex characters');
|
|
389
|
+
}
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (longHexRun(text)) {
|
|
393
|
+
throw new Error(`fingerprint: field "${key}" carries a long hex run (possible key leak)`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* The same rule, applied to the *raw* inputs before any bounding.
|
|
401
|
+
*
|
|
402
|
+
* Without this, truncation is a laundering route: `label: 'x'.repeat(12) + secret`
|
|
403
|
+
* gets cut to MAX_LABEL, the secret no longer appears whole, and twenty hex
|
|
404
|
+
* characters of a live key land in the record unremarked. A caller that hands a
|
|
405
|
+
* credential to a display field has a bug, and rule 2 says a caller bug in this
|
|
406
|
+
* shape becomes loud rather than quiet — which is exactly what `assertNoLeak`
|
|
407
|
+
* cannot do once the value has already been trimmed to fit.
|
|
408
|
+
*/
|
|
409
|
+
function assertInputsClean(inputs, secret, alsoSecret) {
|
|
410
|
+
const secrets = [secret, alsoSecret]
|
|
411
|
+
.map((v) => (v === null || v === undefined ? '' : String(v)))
|
|
412
|
+
.filter(Boolean);
|
|
413
|
+
if (!secrets.length) return true;
|
|
414
|
+
for (const field of ['label', 'account', 'model', 'tools', 'aiModel']) {
|
|
415
|
+
const value = inputs[field];
|
|
416
|
+
if (value === null || value === undefined) continue;
|
|
417
|
+
const text = Array.isArray(value) ? value.join('\u0000') : String(value);
|
|
418
|
+
if (secrets.some((s) => text.includes(s))) {
|
|
419
|
+
throw new Error(`fingerprint: input "${field}" would carry the secret verbatim`);
|
|
420
|
+
}
|
|
421
|
+
if (longHexRun(text)) {
|
|
422
|
+
throw new Error(`fingerprint: input "${field}" carries a long hex run (possible key leak)`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// 1. credential identity
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* `sha256('aegiscode-agent-v1:' + secret).slice(0, 12)`, hex — or `null` when no
|
|
434
|
+
* secret is available, because "no credential" must not hash to a shared value
|
|
435
|
+
* that would make every keyless agent look like the same agent.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} secret - API key, device fingerprint seed, or any stable credential.
|
|
438
|
+
* @returns {string|null}
|
|
439
|
+
*/
|
|
440
|
+
function keyFingerprint(secret) {
|
|
441
|
+
if (secret === null || secret === undefined) return null;
|
|
442
|
+
const s = String(secret).trim();
|
|
443
|
+
if (!s) return null;
|
|
444
|
+
return digest('aegiscode-agent-v1', s).slice(0, KEY_FP_LENGTH);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
// 2. behavioural signature
|
|
449
|
+
// ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
/** One observation rendered canonically, or '' if it carries nothing usable. */
|
|
452
|
+
function observationKey(observation) {
|
|
453
|
+
if (typeof observation === 'string' || typeof observation === 'number') {
|
|
454
|
+
return normalise(observation);
|
|
455
|
+
}
|
|
456
|
+
if (!observation || typeof observation !== 'object') return '';
|
|
457
|
+
const parts = [observation.tool, observation.target, observation.outcome, observation.model]
|
|
458
|
+
.map((p) => normalise(p))
|
|
459
|
+
.join('|');
|
|
460
|
+
return parts.replace(/^\|+|\|+$/g, '') ? parts : '';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** How many observations actually carried signal. */
|
|
464
|
+
function countObservations(observations) {
|
|
465
|
+
if (!Array.isArray(observations)) return 0;
|
|
466
|
+
return observations.slice(0, MAX_OBSERVATIONS).filter((o) => observationKey(o) !== '').length;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A digest over a set of observations — deduplicated and sorted, so the same set
|
|
471
|
+
* of actions fingerprints identically however the transcript was ordered, and a
|
|
472
|
+
* replayed action adds nothing. This is the "behavioural signature" of the
|
|
473
|
+
* covenant, not a transcript hash: two agents that used the same tools the same
|
|
474
|
+
* way match, and a padded log does not inflate the match.
|
|
475
|
+
*
|
|
476
|
+
* The purpose string is a parameter because this primer serves two axes that must
|
|
477
|
+
* not be able to agree by accident — the human's turns and the assistant's (see
|
|
478
|
+
* `AI_PURPOSE`). Callers outside this file get `behaviouralSignature` and
|
|
479
|
+
* `aiSignature`, each with its purpose bound.
|
|
480
|
+
*
|
|
481
|
+
* @returns {string|null} null when there is no observable behaviour — see rule 3.
|
|
482
|
+
*/
|
|
483
|
+
function signatureFromObservations(observations, purpose) {
|
|
484
|
+
if (!Array.isArray(observations)) return null;
|
|
485
|
+
const keys = observations
|
|
486
|
+
.slice(0, MAX_OBSERVATIONS)
|
|
487
|
+
.map(observationKey)
|
|
488
|
+
.filter(Boolean);
|
|
489
|
+
if (!keys.length) return null;
|
|
490
|
+
const unique = Array.from(new Set(keys)).sort();
|
|
491
|
+
return digest(purpose, unique.join('\n')).slice(0, SIG_LENGTH);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function behaviouralSignature(observations) {
|
|
495
|
+
return signatureFromObservations(observations, BEHAVIOUR_PURPOSE);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* The AI axis's behavioural signature: the same primer over the assistant's own
|
|
500
|
+
* turns, under `AI_PURPOSE`.
|
|
501
|
+
*
|
|
502
|
+
* `null` for no observations, for the same reason `behaviouralSignature` is: a
|
|
503
|
+
* mind with no recorded track record has no track record, and rendering that as a
|
|
504
|
+
* zero-valued hash would be a claim the evidence does not support.
|
|
505
|
+
*/
|
|
506
|
+
function aiSignature(observations) {
|
|
507
|
+
return signatureFromObservations(observations, AI_PURPOSE);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Bounded, deduplicated tool list in canonical order. */
|
|
511
|
+
function normaliseTools(tools) {
|
|
512
|
+
if (!Array.isArray(tools)) return Object.freeze([]);
|
|
513
|
+
const seen = new Set();
|
|
514
|
+
for (const t of tools.slice(0, MAX_TOOLS)) {
|
|
515
|
+
const n = normalise(typeof t === 'string' ? t : (t && (t.name || t.id)) || '');
|
|
516
|
+
if (n) seen.add(n);
|
|
517
|
+
}
|
|
518
|
+
return Object.freeze(Array.from(seen).sort());
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
// 2b. the code axis — which revision of which files produced this turn
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* A repo-relative path in comparable form, or `null` if unusable.
|
|
527
|
+
*
|
|
528
|
+
* Separators are normalised to `/` and a leading `./` is dropped so the same
|
|
529
|
+
* file reached two ways hashes once. Absolute paths and any path containing a
|
|
530
|
+
* `..` segment are refused rather than "cleaned": a record whose paths could
|
|
531
|
+
* escape its own root would name files outside the tree it claims to describe,
|
|
532
|
+
* and the whole point of the axis is that the claim is recomputable.
|
|
533
|
+
*/
|
|
534
|
+
function normaliseCodePath(value) {
|
|
535
|
+
if (value === null || value === undefined) return null;
|
|
536
|
+
const raw = String(value).replace(/\\/g, '/').trim();
|
|
537
|
+
if (!raw) return null;
|
|
538
|
+
if (raw.startsWith('/') || /^[a-zA-Z]:\//.test(raw)) return null;
|
|
539
|
+
const parts = [];
|
|
540
|
+
for (const seg of raw.split('/')) {
|
|
541
|
+
if (seg === '' || seg === '.') continue;
|
|
542
|
+
if (seg === '..') return null;
|
|
543
|
+
parts.push(seg);
|
|
544
|
+
}
|
|
545
|
+
const rel = parts.join('/');
|
|
546
|
+
if (!rel) return null;
|
|
547
|
+
return rel.length > MAX_PATH ? null : rel;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* One file's contribution: `sha256('aegiscode-code-v3' + '\0' + relpath + '\0' +
|
|
552
|
+
* content)`.
|
|
553
|
+
*
|
|
554
|
+
* The path is inside the hash on purpose. Hashing contents alone would make a
|
|
555
|
+
* rename invisible, and a tree where `auth.js` and `handler.js` swap names is
|
|
556
|
+
* not the same tree. Length-prefixing nothing and delimiting with NUL keeps
|
|
557
|
+
* path and content from bleeding into each other: `('ab', 'c')` and `('a',
|
|
558
|
+
* 'bc')` cannot collide.
|
|
559
|
+
*/
|
|
560
|
+
function codeFileDigest(relpath, content) {
|
|
561
|
+
const rel = normaliseCodePath(relpath);
|
|
562
|
+
if (!rel) throw new Error(`fingerprint: unusable code path ${JSON.stringify(String(relpath))}`);
|
|
563
|
+
const buf = Buffer.isBuffer(content)
|
|
564
|
+
? content
|
|
565
|
+
: Buffer.from(content === null || content === undefined ? '' : String(content), 'utf8');
|
|
566
|
+
return crypto
|
|
567
|
+
.createHash(FP_ALGO)
|
|
568
|
+
.update(`aegiscode-code-v3\u0000${rel}\u0000`, 'utf8')
|
|
569
|
+
.update(buf)
|
|
570
|
+
.digest('hex');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Fold per-file digests, in sorted path order, into one 16-hex aggregate.
|
|
575
|
+
*
|
|
576
|
+
* Order-independence comes from the sort, not from the caller: a directory walk
|
|
577
|
+
* returns files in whatever order the filesystem feels like, and two walks of
|
|
578
|
+
* one tree must agree. The fold chains each file's digest into the running
|
|
579
|
+
* accumulator, so moving a byte from one file to another changes the result —
|
|
580
|
+
* unlike summing or XOR-ing per-file digests, which would not.
|
|
581
|
+
*
|
|
582
|
+
* @param {Array<{path: string, content: string|Buffer}>} files
|
|
583
|
+
* @returns {{code: string|null, codeFiles: number}} `null`/`0` for no files —
|
|
584
|
+
* absence of code evidence reads as absence (rule 3), never as a
|
|
585
|
+
* zero-valued hash that a caller might mistake for a real revision.
|
|
586
|
+
*/
|
|
587
|
+
function codeDigest(files) {
|
|
588
|
+
if (!Array.isArray(files)) return { code: null, codeFiles: 0 };
|
|
589
|
+
|
|
590
|
+
// Sorted input, duplicates resolved last-wins: a path listed twice is the same
|
|
591
|
+
// file twice, and folding both would make the aggregate depend on how many
|
|
592
|
+
// times the caller mentioned it.
|
|
593
|
+
const byPath = new Map();
|
|
594
|
+
for (const f of files) {
|
|
595
|
+
if (!f || typeof f !== 'object') continue;
|
|
596
|
+
const rel = normaliseCodePath(f.path);
|
|
597
|
+
if (!rel) continue;
|
|
598
|
+
byPath.set(rel, { path: rel, content: f.content });
|
|
599
|
+
}
|
|
600
|
+
const entries = Array.from(byPath.keys()).sort();
|
|
601
|
+
if (!entries.length) return { code: null, codeFiles: 0 };
|
|
602
|
+
if (entries.length > MAX_CODE_FILES) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`fingerprint: ${entries.length} code files exceeds MAX_CODE_FILES (${MAX_CODE_FILES}); ` +
|
|
605
|
+
'hash a subtree rather than silently attesting a prefix of one',
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
let acc = digest('aegiscode-code-v3-root', String(entries.length));
|
|
610
|
+
for (const rel of entries) {
|
|
611
|
+
const d = codeFileDigest(rel, byPath.get(rel).content);
|
|
612
|
+
acc = digest('aegiscode-code-v3-fold', `${acc}\n${rel}\n${d}`);
|
|
613
|
+
}
|
|
614
|
+
return { code: acc.slice(0, CODE_LEN), codeFiles: entries.length };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* The code axis of a record, with dirtiness passed through honestly.
|
|
619
|
+
*
|
|
620
|
+
* `codeDirty` is `true`/`false` only when the caller actually knew; anything
|
|
621
|
+
* else is `null`. The CLI cannot tell clean from dirty without spawning `git`,
|
|
622
|
+
* and this module's callers are promised a module that spawns nothing — so
|
|
623
|
+
* "unknown" is a state the record is allowed to be in, and it is stored as
|
|
624
|
+
* unknown rather than being rendered as `false`, which would be a claim the
|
|
625
|
+
* evidence does not support.
|
|
626
|
+
*/
|
|
627
|
+
function codeAxisFrom(files, codeDirty) {
|
|
628
|
+
const { code, codeFiles } = codeDigest(files);
|
|
629
|
+
const dirty = typeof codeDirty === 'boolean' ? codeDirty : null;
|
|
630
|
+
return { code, codeFiles, codeDirty: code ? dirty : null };
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ---------------------------------------------------------------------------
|
|
634
|
+
// 2c. the AI axis — which mind answered, and what it did
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Fold the AI half of a record out of the caller's inputs.
|
|
639
|
+
*
|
|
640
|
+
* Every field degrades to `null`/`0` rather than to a stand-in value, and for the
|
|
641
|
+
* same reason the other axes do: a record may carry only what was observed.
|
|
642
|
+
*
|
|
643
|
+
* - no nominated credential ⇒ `aiKey: null`. NOT a hash of the authorship
|
|
644
|
+
* key: the authorship key says who *sealed* the record, and reusing it here
|
|
645
|
+
* would make every record look like it ran under its author's own key, which
|
|
646
|
+
* is a claim about the session that nobody made.
|
|
647
|
+
* - no AI observations ⇒ `ai: null, aiSamples: 0` (rule 3).
|
|
648
|
+
* - no model named ⇒ `aiModel: null`, never the empty string.
|
|
649
|
+
*
|
|
650
|
+
* @param {object} [axis]
|
|
651
|
+
* @param {string} [axis.secret] - the credential the session ran on. Never stored.
|
|
652
|
+
* @param {string} [axis.model] - the model that answered.
|
|
653
|
+
* @param {Array} [axis.observations]- the assistant's turns: `{tool, target, outcome, model}`.
|
|
654
|
+
* @returns {{aiKey: string|null, aiModel: string|null, ai: string|null, aiSamples: number}}
|
|
655
|
+
*/
|
|
656
|
+
function aiAxisFrom(axis = {}) {
|
|
657
|
+
const { secret, model, observations } = axis || {};
|
|
658
|
+
const aiKey = keyFingerprint(secret);
|
|
659
|
+
const ai = aiSignature(observations);
|
|
660
|
+
return {
|
|
661
|
+
aiKey,
|
|
662
|
+
aiModel: safeLabel(model, MAX_FIELD) || null,
|
|
663
|
+
ai,
|
|
664
|
+
aiSamples: ai ? countObservations(observations) : 0,
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---------------------------------------------------------------------------
|
|
669
|
+
// 3. mint / attest / verify
|
|
670
|
+
// ---------------------------------------------------------------------------
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* The exact bytes the attestation covers. The field list is chosen by the
|
|
674
|
+
* record's own `v`, so a v1 record is attested over v1's fields and keeps
|
|
675
|
+
* verifying after the shape moved on. Unknown fields are ignored either way.
|
|
676
|
+
*/
|
|
677
|
+
function attestationBody(record) {
|
|
678
|
+
const fields = attestedFieldsFor(record && record.v);
|
|
679
|
+
if (!fields) {
|
|
680
|
+
throw new Error(`fingerprint: cannot attest version ${record && record.v}`);
|
|
681
|
+
}
|
|
682
|
+
const body = {};
|
|
683
|
+
for (const field of fields) {
|
|
684
|
+
const v = record[field];
|
|
685
|
+
body[field] = v === undefined ? null : v;
|
|
686
|
+
}
|
|
687
|
+
return canonical(body);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** HMAC-SHA256(secret, attested fields), truncated to 128 bits. */
|
|
691
|
+
function attest(record, secret) {
|
|
692
|
+
if (!secret) throw new Error('fingerprint: a secret is required to attest a record');
|
|
693
|
+
return crypto
|
|
694
|
+
.createHmac(FP_ALGO, String(secret))
|
|
695
|
+
.update(attestationBody(record), 'utf8')
|
|
696
|
+
.digest('hex')
|
|
697
|
+
.slice(0, ATTEST_LENGTH);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function isoTs(value) {
|
|
701
|
+
if (value === null || value === undefined) return new Date().toISOString();
|
|
702
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
703
|
+
if (Number.isNaN(d.getTime())) throw new Error('fingerprint: createdAt is not a valid date');
|
|
704
|
+
return d.toISOString();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* The thumb axis of a record, recomputed from a captured profile's medians.
|
|
709
|
+
*
|
|
710
|
+
* Deliberately ignores any `signature` field the profile carries: recomputation
|
|
711
|
+
* is what makes the record's thumb axis trustworthy, and trusting the file's own
|
|
712
|
+
* hash would make it the one field in φ(α) that was taken on faith. Unusable
|
|
713
|
+
* input degrades to "no thumb axis" (`null`/`0`), never to a zero-valued hash and
|
|
714
|
+
* never to a leaked string.
|
|
715
|
+
*
|
|
716
|
+
* @param {object} profile - shape as produced by `src/touch.js`'s `profile()`.
|
|
717
|
+
* @returns {{signature: string|null, samples: number}}
|
|
718
|
+
*/
|
|
719
|
+
function touchSignatureFrom(profile) {
|
|
720
|
+
if (!profile || typeof profile !== 'object') return { signature: null, samples: 0 };
|
|
721
|
+
const samples = Number(profile.samples) || 0;
|
|
722
|
+
const signature = signatureFromMedians(profile.medians, samples);
|
|
723
|
+
return { signature, samples: signature ? samples : 0 };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Mint φ(α).
|
|
728
|
+
*
|
|
729
|
+
* @param {object} input
|
|
730
|
+
* @param {string} input.secret - credential; required. Never stored.
|
|
731
|
+
* @param {string} [input.label] - human display name.
|
|
732
|
+
* @param {string} [input.account] - cloud account id (survives key rotation).
|
|
733
|
+
* @param {string} [input.model] - model the agent runs on.
|
|
734
|
+
* @param {string[]} [input.tools] - tools it can reach.
|
|
735
|
+
* @param {Array} [input.observations] - what it did; feeds the behavioural signature.
|
|
736
|
+
* @param {object} [input.touch] - thumb profile from `src/touch.js`; feeds the
|
|
737
|
+
* gesture-dynamics signature. NOT keyed by the
|
|
738
|
+
* secret: a credential is not a hand.
|
|
739
|
+
* @param {Array<{path: string, content: string|Buffer}>} [input.code]
|
|
740
|
+
* - the revision this turn ran against, as
|
|
741
|
+
* repo-relative path + content pairs. The
|
|
742
|
+
* walk that produces them lives in the caller
|
|
743
|
+
* (a tool), so this module stays I/O-free
|
|
744
|
+
* apart from test vectors.
|
|
745
|
+
* @param {boolean} [input.codeDirty] - whether that tree had uncommitted changes,
|
|
746
|
+
* when the caller could actually tell.
|
|
747
|
+
* @param {string|Date} [input.createdAt]
|
|
748
|
+
* @returns {object} frozen, attested record.
|
|
749
|
+
*/
|
|
750
|
+
function mint(input = {}) {
|
|
751
|
+
const {
|
|
752
|
+
secret,
|
|
753
|
+
label,
|
|
754
|
+
account,
|
|
755
|
+
model,
|
|
756
|
+
tools,
|
|
757
|
+
observations,
|
|
758
|
+
touch,
|
|
759
|
+
code,
|
|
760
|
+
codeDirty,
|
|
761
|
+
aiSecret,
|
|
762
|
+
aiModel,
|
|
763
|
+
aiObservations,
|
|
764
|
+
createdAt,
|
|
765
|
+
} = input;
|
|
766
|
+
const kf = keyFingerprint(secret);
|
|
767
|
+
if (!kf) {
|
|
768
|
+
throw new Error('fingerprint: a secret (API key or device seed) is required to mint φ(α)');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Before bounding: a key in a display field must refuse, not get truncated.
|
|
772
|
+
// Both credentials are checked — the AI's key is a live provider key, and a
|
|
773
|
+
// record carrying it in `aiModel` would be exactly the leak this guard exists
|
|
774
|
+
// to catch.
|
|
775
|
+
assertInputsClean({ label, account, model, tools, aiModel }, secret, aiSecret);
|
|
776
|
+
|
|
777
|
+
const thumb = touchSignatureFrom(touch);
|
|
778
|
+
const codeAxis = codeAxisFrom(code, codeDirty);
|
|
779
|
+
const ai = aiAxisFrom({ secret: aiSecret, model: aiModel, observations: aiObservations });
|
|
780
|
+
|
|
781
|
+
const record = {
|
|
782
|
+
v: FP_VERSION,
|
|
783
|
+
algo: FP_ALGO,
|
|
784
|
+
keyFingerprint: kf,
|
|
785
|
+
label: safeLabel(label, MAX_LABEL),
|
|
786
|
+
account: safeLabel(account, MAX_FIELD) || null,
|
|
787
|
+
model: safeLabel(model, MAX_FIELD) || null,
|
|
788
|
+
tools: normaliseTools(tools),
|
|
789
|
+
behaviour: behaviouralSignature(observations),
|
|
790
|
+
behaviourSamples: countObservations(observations),
|
|
791
|
+
touch: thumb.signature,
|
|
792
|
+
touchSamples: thumb.samples,
|
|
793
|
+
code: codeAxis.code,
|
|
794
|
+
codeFiles: codeAxis.codeFiles,
|
|
795
|
+
codeDirty: codeAxis.codeDirty,
|
|
796
|
+
aiKey: ai.aiKey,
|
|
797
|
+
aiModel: ai.aiModel,
|
|
798
|
+
ai: ai.ai,
|
|
799
|
+
aiSamples: ai.aiSamples,
|
|
800
|
+
createdAt: isoTs(createdAt),
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
const sealed = { ...record, attestation: attest(record, secret) };
|
|
804
|
+
assertNoLeak(sealed, secret, aiSecret);
|
|
805
|
+
return Object.freeze(sealed);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** `φ_<keyFingerprint><behaviour|——>`: short, stable, safe to log. */
|
|
809
|
+
function id(record) {
|
|
810
|
+
if (!record || !isFingerprintShape(record.keyFingerprint)) {
|
|
811
|
+
throw new Error('fingerprint: not a fingerprinted record');
|
|
812
|
+
}
|
|
813
|
+
return `${FP_PREFIX}${record.keyFingerprint}-${record.behaviour || 'unobserved'}`;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function sameReason(record) {
|
|
817
|
+
if (!record || typeof record !== 'object') return 'not a record';
|
|
818
|
+
if (!attestedFieldsFor(record.v)) return `unsupported version ${record.v}`;
|
|
819
|
+
if (record.algo !== FP_ALGO) return `unsupported algo ${record.algo}`;
|
|
820
|
+
if (!isFingerprintShape(record.keyFingerprint)) return 'keyFingerprint is malformed';
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Verify a record by recomputation, not by trusting its stored tag.
|
|
826
|
+
*
|
|
827
|
+
* @returns {{ok: boolean, reason: string|null, id: string|null, credentialMatch: boolean|null}}
|
|
828
|
+
*/
|
|
829
|
+
function verify(record, secret) {
|
|
830
|
+
const bad = sameReason(record);
|
|
831
|
+
if (bad) return { ok: false, reason: bad, id: null, credentialMatch: null };
|
|
832
|
+
|
|
833
|
+
const credentialMatch = secret ? keyFingerprint(secret) === record.keyFingerprint : null;
|
|
834
|
+
|
|
835
|
+
if (!secret) {
|
|
836
|
+
return {
|
|
837
|
+
ok: false,
|
|
838
|
+
reason: 'no secret supplied — attestation cannot be recomputed',
|
|
839
|
+
id: id(record),
|
|
840
|
+
credentialMatch: null,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const expected = attest(record, secret);
|
|
845
|
+
const got = String(record.attestation || '');
|
|
846
|
+
const a = Buffer.from(expected, 'utf8');
|
|
847
|
+
const b = Buffer.from(got.padEnd(expected.length, ' ').slice(0, expected.length), 'utf8');
|
|
848
|
+
const tagOk = a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
849
|
+
|
|
850
|
+
if (!tagOk) {
|
|
851
|
+
return { ok: false, reason: 'attestation mismatch', id: id(record), credentialMatch };
|
|
852
|
+
}
|
|
853
|
+
if (!credentialMatch) {
|
|
854
|
+
return {
|
|
855
|
+
ok: false,
|
|
856
|
+
reason: 'attestation valid but secret does not match keyFingerprint',
|
|
857
|
+
id: id(record),
|
|
858
|
+
credentialMatch: false,
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
return { ok: true, reason: null, id: id(record), credentialMatch: true };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Compare two records on one axis. Explicit on purpose: "same credential" and
|
|
866
|
+
* "same behaviour" are different claims (rule 1), and a caller that does not say
|
|
867
|
+
* which one it wants gets an error rather than an answer.
|
|
868
|
+
*
|
|
869
|
+
* @param {'credential'|'behaviour'|'full'} axis
|
|
870
|
+
*/
|
|
871
|
+
function same(a, b, axis = 'full') {
|
|
872
|
+
if (!a || !b) return false;
|
|
873
|
+
if (axis === 'credential') return a.keyFingerprint === b.keyFingerprint;
|
|
874
|
+
if (axis === 'behaviour') {
|
|
875
|
+
return Boolean(a.behaviour) && a.behaviour === b.behaviour;
|
|
876
|
+
}
|
|
877
|
+
if (axis === 'full') {
|
|
878
|
+
return a.keyFingerprint === b.keyFingerprint && a.behaviour === b.behaviour;
|
|
879
|
+
}
|
|
880
|
+
if (axis === 'ai-credential') {
|
|
881
|
+
return Boolean(a.aiKey) && a.aiKey === b.aiKey;
|
|
882
|
+
}
|
|
883
|
+
if (axis === 'ai') {
|
|
884
|
+
return Boolean(a.ai) && a.ai === b.ai;
|
|
885
|
+
}
|
|
886
|
+
if (axis === 'mind') {
|
|
887
|
+
return (
|
|
888
|
+
Boolean(a.aiKey) && a.aiKey === b.aiKey && Boolean(a.ai) && a.ai === b.ai
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
throw new Error(`fingerprint: unknown comparison axis "${axis}"`);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** Display form. Carries no field a key could hide in. */
|
|
895
|
+
function describe(record) {
|
|
896
|
+
const bad = sameReason(record);
|
|
897
|
+
if (bad) return `φ(?) ${bad}`;
|
|
898
|
+
const bits = [
|
|
899
|
+
`key ${record.keyFingerprint}`,
|
|
900
|
+
record.behaviour ? `behaviour ${record.behaviour}` : 'behaviour unobserved',
|
|
901
|
+
record.behaviourSamples ? `${record.behaviourSamples} samples` : null,
|
|
902
|
+
record.touch ? `touch ${record.touch}` : 'touch unobserved',
|
|
903
|
+
record.touchSamples ? `${record.touchSamples} strokes` : null,
|
|
904
|
+
record.code
|
|
905
|
+
? `code ${record.code}${record.codeDirty === null ? '' : record.codeDirty ? ' (dirty)' : ' (clean)'}`
|
|
906
|
+
: 'code unobserved',
|
|
907
|
+
record.codeFiles ? `${record.codeFiles} files` : null,
|
|
908
|
+
record.ai && record.aiKey
|
|
909
|
+
? `ai ${record.ai} on ${record.aiKey}`
|
|
910
|
+
: record.ai
|
|
911
|
+
? `ai ${record.ai} (no key nominated)`
|
|
912
|
+
: record.aiKey
|
|
913
|
+
? `ai key ${record.aiKey}, behaviour unobserved`
|
|
914
|
+
: 'ai unobserved',
|
|
915
|
+
record.aiSamples ? `${record.aiSamples} ai turn(s)` : null,
|
|
916
|
+
record.aiModel ? `ran on ${record.aiModel}` : null,
|
|
917
|
+
record.model ? `model ${record.model}` : null,
|
|
918
|
+
record.tools && record.tools.length ? `tools ${record.tools.length}` : null,
|
|
919
|
+
].filter(Boolean);
|
|
920
|
+
return `${record.label || 'agent'} · ${bits.join(' · ')}`;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
// CLI — `node src/fingerprint.js --label x --observations file.json`
|
|
925
|
+
// `node src/fingerprint.js --ai-observations ai.json --ai-model gpt-5`
|
|
926
|
+
// `node src/fingerprint.js --verify record.json`
|
|
927
|
+
//
|
|
928
|
+
// The secret comes from AEGIS_FINGERPRINT_SECRET or --secret-file, never from
|
|
929
|
+
// argv: argv is visible in `ps` to every process on the box.
|
|
930
|
+
// ---------------------------------------------------------------------------
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* The AI half's credential, from the same two rungs as the authorship secret and
|
|
934
|
+
* never from argv (a key in `ps` is a published key).
|
|
935
|
+
*
|
|
936
|
+
* It is read from `AEGIS_AI_SECRET`, then `--ai-secret-file`. Deliberately a
|
|
937
|
+
* separate variable from `AEGIS_FINGERPRINT_SECRET`: the authorship key seals the
|
|
938
|
+
* record, the AI key names the mind that answered, and a single variable would
|
|
939
|
+
* make "sealed by" and "ran on" the same value by default — the conflation the two
|
|
940
|
+
* fields exist to prevent.
|
|
941
|
+
*/
|
|
942
|
+
function readAiSecret(argv, env) {
|
|
943
|
+
if (env.AEGIS_AI_SECRET) return String(env.AEGIS_AI_SECRET).trim();
|
|
944
|
+
const i = argv.indexOf('--ai-secret-file');
|
|
945
|
+
if (i !== -1 && argv[i + 1]) {
|
|
946
|
+
return fs.readFileSync(argv[i + 1], 'utf8').trim();
|
|
947
|
+
}
|
|
948
|
+
return '';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function readSecret(argv, env) {
|
|
952
|
+
if (env.AEGIS_FINGERPRINT_SECRET) return String(env.AEGIS_FINGERPRINT_SECRET).trim();
|
|
953
|
+
const i = argv.indexOf('--secret-file');
|
|
954
|
+
if (i !== -1 && argv[i + 1]) {
|
|
955
|
+
return fs.readFileSync(argv[i + 1], 'utf8').trim();
|
|
956
|
+
}
|
|
957
|
+
return '';
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function flag(argv, name, fallback = null) {
|
|
961
|
+
const i = argv.indexOf(name);
|
|
962
|
+
return i !== -1 && argv[i + 1] !== undefined ? argv[i + 1] : fallback;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
function main(argv, env = process.env) {
|
|
966
|
+
const secret = readSecret(argv, env);
|
|
967
|
+
|
|
968
|
+
if (argv.includes('--verify')) {
|
|
969
|
+
const file = flag(argv, '--verify');
|
|
970
|
+
if (!file) throw new Error('fingerprint: --verify needs a record file');
|
|
971
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
972
|
+
const result = verify(record, secret);
|
|
973
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
974
|
+
return result.ok ? 0 : 1;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const obsFile = flag(argv, '--observations');
|
|
978
|
+
const observations = obsFile ? JSON.parse(fs.readFileSync(obsFile, 'utf8')) : [];
|
|
979
|
+
|
|
980
|
+
// The thumb profile comes from `tools/aegis-touchpad.mjs capture`. Only its
|
|
981
|
+
// medians are used; its own `signature` field is recomputed, not believed.
|
|
982
|
+
const touchFile = flag(argv, '--touch');
|
|
983
|
+
const touch = touchFile ? JSON.parse(fs.readFileSync(touchFile, 'utf8')) : null;
|
|
984
|
+
|
|
985
|
+
// The AI half: which mind answered, and what it did. The observations file is a
|
|
986
|
+
// separate one because the two axes are separate claims — a session log's
|
|
987
|
+
// `reply` text is not an AI observation, and `--ai-observations` is the caller
|
|
988
|
+
// saying it actually has some.
|
|
989
|
+
const aiObsFile = flag(argv, '--ai-observations');
|
|
990
|
+
const aiObservations = aiObsFile ? JSON.parse(fs.readFileSync(aiObsFile, 'utf8')) : null;
|
|
991
|
+
|
|
992
|
+
const record = mint({
|
|
993
|
+
secret,
|
|
994
|
+
label: flag(argv, '--label', ''),
|
|
995
|
+
account: flag(argv, '--account', ''),
|
|
996
|
+
model: flag(argv, '--model', ''),
|
|
997
|
+
tools: (flag(argv, '--tools', '') || '').split(',').filter(Boolean),
|
|
998
|
+
observations,
|
|
999
|
+
touch,
|
|
1000
|
+
aiSecret: readAiSecret(argv, env),
|
|
1001
|
+
aiModel: flag(argv, '--ai-model', ''),
|
|
1002
|
+
aiObservations,
|
|
1003
|
+
});
|
|
1004
|
+
process.stdout.write(JSON.stringify(record, null, 2) + '\n');
|
|
1005
|
+
return 0;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
const API = {
|
|
1009
|
+
// identity
|
|
1010
|
+
mint,
|
|
1011
|
+
verify,
|
|
1012
|
+
attest,
|
|
1013
|
+
attestationBody,
|
|
1014
|
+
id,
|
|
1015
|
+
same,
|
|
1016
|
+
describe,
|
|
1017
|
+
// pieces
|
|
1018
|
+
keyFingerprint,
|
|
1019
|
+
behaviouralSignature,
|
|
1020
|
+
aiSignature,
|
|
1021
|
+
aiAxisFrom,
|
|
1022
|
+
observationKey,
|
|
1023
|
+
countObservations,
|
|
1024
|
+
normaliseTools,
|
|
1025
|
+
safeLabel,
|
|
1026
|
+
touchSignatureFrom,
|
|
1027
|
+
normaliseCodePath,
|
|
1028
|
+
codeFileDigest,
|
|
1029
|
+
codeDigest,
|
|
1030
|
+
codeAxisFrom,
|
|
1031
|
+
// guards & constants
|
|
1032
|
+
canonical,
|
|
1033
|
+
assertNoLeak,
|
|
1034
|
+
assertInputsClean,
|
|
1035
|
+
longHexRun,
|
|
1036
|
+
isFingerprintShape,
|
|
1037
|
+
attestedFieldsFor,
|
|
1038
|
+
FP_VERSION,
|
|
1039
|
+
FP_ALGO,
|
|
1040
|
+
FP_PREFIX,
|
|
1041
|
+
KEY_FP_LENGTH,
|
|
1042
|
+
SIG_LENGTH,
|
|
1043
|
+
ATTEST_LENGTH,
|
|
1044
|
+
LEAK_HEX_RUN,
|
|
1045
|
+
CODE_LEN,
|
|
1046
|
+
MAX_CODE_FILES,
|
|
1047
|
+
MAX_PATH,
|
|
1048
|
+
BEHAVIOUR_PURPOSE,
|
|
1049
|
+
AI_PURPOSE,
|
|
1050
|
+
ATTESTED_FIELDS,
|
|
1051
|
+
ATTESTED_FIELDS_V1,
|
|
1052
|
+
ATTESTED_FIELDS_V2,
|
|
1053
|
+
ATTESTED_FIELDS_V3,
|
|
1054
|
+
ATTESTED_FIELDS_V4,
|
|
1055
|
+
ATTESTED_FIELDS_BY_VERSION,
|
|
1056
|
+
main,
|
|
1057
|
+
};
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
module.exports = API;
|