@xshieldai/aegis-guard 0.3.0 β†’ 0.4.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/README.md CHANGED
@@ -1,10 +1,11 @@
1
- # @rocketlang/aegis-guard
1
+ # @xshieldai/aegis-guard
2
2
 
3
- > **πŸ” Verification status (2026-05-17 IST)**
4
- > - **Tests:** βœ… **63/63 passing** ([tests/aegis-guard.test.ts](tests/aegis-guard.test.ts) β€” `bun test`)
5
- > - **Examples:** βœ… runnable quickstart: [examples/quickstart.ts](examples/quickstart.ts) β€” `bun run examples/quickstart.ts` shows all 5 Locks in action with receipts
6
- > - **Live demo:** ⚠️ planned (Tier 3, see [PROOF-STACK.md](../../PROOF-STACK.md))
7
- > - **Phase-1 limits:** documented in the v0.2.0 ACC section below
3
+ > **πŸ” Verification status (2026-10-05 IST β€” v0.4.0)**
4
+ > - **Tests:** βœ… **177/177 passing** in the repository; the npm package ships `src/` only, so run them from a clone (`bun test`): 70 in [tests/aegis-guard.test.ts](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/tests/aegis-guard.test.ts) and 107 in [tests/hardening.test.ts](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/tests/hardening.test.ts) for key trust, malformed tokens, nonces, the duplicate check, the audit event, the quality bar, the envelope helpers and the stated limits. No test contacts a network or touches `~/.aegis`.
5
+ > - **Examples:** βœ… runnable quickstart, in the repository (not in the npm package): [examples/quickstart.ts](https://github.com/rocketlang/aegis/blob/master/packages/aegis-guard/examples/quickstart.ts) β€” `bun run examples/quickstart.ts` shows all 5 Locks in action with receipts
6
+ > - **Live demo:** ⚠️ planned (Tier 3, see [PROOF-STACK.md](https://github.com/rocketlang/aegis/blob/master/PROOF-STACK.md))
7
+ > - **Limits:** see "What this does not do" below, and the v0.2.0 ACC section
8
+ > - **Upgrading from 0.3.x:** some calls that passed are now refused, and minting no longer makes a key. See "Upgrading from 0.3.x".
8
9
 
9
10
  AEGIS Guard SDK β€” reusable approval-token, nonce, idempotency, SENSE, and quality-evidence primitives for AEGIS-governed services.
10
11
 
@@ -25,17 +26,37 @@ The Five Locks were proven across 13 batches (62–74) of carbonx-backend. This
25
26
  ## Install
26
27
 
27
28
  ```bash
28
- bun add @rocketlang/aegis-guard
29
+ bun add @xshieldai/aegis-guard
29
30
  # or
30
- npm install @rocketlang/aegis-guard
31
+ npm install @xshieldai/aegis-guard
31
32
  ```
32
33
 
34
+ ## Keys β€” who signs, who verifies
35
+
36
+ An approval token is an EdDSA-signed JWT. One box signs; every other box only verifies.
37
+
38
+ | Box | Holds | Set up by |
39
+ |---|---|---|
40
+ | The approving authority (AEGIS) | `~/.aegis/approval-signing.key` and `.pub` | `ensureSigningKeypair()`, once, at boot |
41
+ | Every service that checks approvals | the authority's **public** key | the file `~/.aegis/approval-signing.pub`, or the PEM in `AEGIS_APPROVAL_PUBKEY_PEM` |
42
+
43
+ `AEGIS_DIR` replaces `~/.aegis`. The rules the package enforces (`AEG-HG-2B-007`):
44
+
45
+ - **Minting never makes a key.** `mintApprovalToken()` throws on a box with no private key.
46
+ - **A box that was given a public key never makes its own.** `ensureSigningKeypair()` throws there and writes nothing.
47
+ - **Keys that disagree are not trusted.** If the private key, the public file and the environment PEM are not all the same Ed25519 key, every verification is refused and the reason names the conflict.
48
+ - **No key, no approval.** With no key material at all, verification is refused.
49
+
33
50
  ## Usage
34
51
 
35
52
  ### LOCK_1 + LOCK_2 β€” decision + identity
36
53
 
54
+ `verifyApprovalToken()` checks, in order: the signature; that service, capability and operation match exactly; that `expires_at` is a number and not past; that `issued_at`, when present, is a number, not more than 60 seconds ahead of the clock and not after `expires_at`; that `status`, when present, is exactly `approved`. Anything else throws `IrrNoApprovalError` (`code: 'IRR-NOAPPROVAL'`) β€” including a token that is not text.
55
+
56
+ Mint with `issued_at` and `expires_at` in milliseconds: `mintApprovalToken({ service_id, capability, operation, nonce, issued_at: Date.now(), expires_at: Date.now() + 60_000 })`.
57
+
37
58
  ```typescript
38
- import { verifyApprovalToken, verifyScopedApprovalToken } from '@rocketlang/aegis-guard';
59
+ import { verifyApprovalToken, verifyScopedApprovalToken } from '@xshieldai/aegis-guard';
39
60
 
40
61
  // LOCK_1 β€” base token verification (service_id + capability + operation)
41
62
  const payload = verifyApprovalToken(token, 'my-service', 'settle', 'record_settle');
@@ -47,10 +68,12 @@ const payload = verifyScopedApprovalToken(
47
68
  );
48
69
  ```
49
70
 
71
+ Each scope field must be the token's own field and strictly equal (`100` is not `"100"`). A field the caller has no value for (`undefined` or `null`) is refused: it would bind the token to nothing.
72
+
50
73
  ### LOCK_3 β€” observability (SENSE)
51
74
 
52
75
  ```typescript
53
- import { emitAegisSenseEvent, digestApprovalToken, configureSenseTransport } from '@rocketlang/aegis-guard';
76
+ import { emitAegisSenseEvent, digestApprovalToken, configureSenseTransport } from '@xshieldai/aegis-guard';
54
77
 
55
78
  // Wire your logger (default: process.stdout JSON)
56
79
  configureSenseTransport((event) => logger.info(event, `SENSE:${event.event_type}`));
@@ -70,32 +93,40 @@ emitAegisSenseEvent({
70
93
  });
71
94
  ```
72
95
 
96
+ If `approval_token_ref` is not already a 24-hex digest, the SDK digests it before the event reaches the transport or the bus, so a raw token passed by mistake is not written to the log.
97
+
73
98
  ### LOCK_4 β€” rollback guard (idempotency check)
74
99
 
75
100
  ```typescript
76
- import { checkIdempotency, buildIdempotencyFingerprint } from '@rocketlang/aegis-guard';
101
+ import { checkIdempotency, buildIdempotencyFingerprint } from '@xshieldai/aegis-guard';
77
102
 
78
103
  const existing = await db.findByExternalRef(args.externalRef);
79
104
  const fp = buildIdempotencyFingerprint({ amount: args.amount, vessel_id: args.vesselId });
80
105
  const { isDuplicate, safeNoOp } = checkIdempotency(args.externalRef, existing, fp, existing?.fingerprint);
81
106
 
82
107
  if (isDuplicate && safeNoOp) return existing; // safe no-op
83
- if (isDuplicate && !safeNoOp) throw new Error('payload mismatch on duplicate externalRef');
108
+ if (isDuplicate && !safeNoOp) throw new Error('duplicate externalRef: payload differs or cannot be compared');
84
109
  ```
85
110
 
111
+ A duplicate is a safe no-op only when **both** fingerprints are present and equal (`AEG-HG-2B-009`). If the existing record has no stored fingerprint, the result is `safeNoOp: false, comparable: false`: store the fingerprint with the record.
112
+
113
+ `buildIdempotencyFingerprint()` sorts keys at every depth and keeps `NaN`, `Β±Infinity`, `bigint`, `Map` and `Set` distinct. The fingerprint is the payload, base64-encoded β€” **not a hash**. Anyone who can read a stored fingerprint can read the payload; keep secrets out of it or hash it before storing.
114
+
86
115
  ### LOCK_5 β€” nonce replay prevention
87
116
 
88
117
  ```typescript
89
- import { verifyAndConsumeNonce } from '@rocketlang/aegis-guard';
118
+ import { verifyAndConsumeNonce } from '@xshieldai/aegis-guard';
90
119
 
91
120
  // Requires nonce in payload; throws IrrNoApprovalError on missing or replayed nonce
92
121
  await verifyAndConsumeNonce(payload, redisNonceStore);
93
122
  ```
94
123
 
124
+ The nonce must be non-empty text and the payload must be in date. Only a store that answers exactly `true` has consumed the nonce; a store that throws propagates its error (fail closed). This call takes a payload and checks no signature: pass it what `verifyApprovalToken()` returned.
125
+
95
126
  ### Quality evidence
96
127
 
97
128
  ```typescript
98
- import { buildQualityMaskAtPromotion, meetsHgQualityRequirement } from '@rocketlang/aegis-guard';
129
+ import { buildQualityMaskAtPromotion, meetsHgQualityRequirement } from '@xshieldai/aegis-guard';
99
130
 
100
131
  const mask = buildQualityMaskAtPromotion({
101
132
  tests_passed: true,
@@ -104,14 +135,29 @@ const mask = buildQualityMaskAtPromotion({
104
135
  });
105
136
 
106
137
  const ready = meetsHgQualityRequirement('HG-2B-financial', mask);
138
+ // false here: the financial bar needs all twelve promotion bits
107
139
  ```
108
140
 
141
+ `meetsHgQualityRequirement()` returns `false` for a group that is not in the table and for a mask that is not a whole number made of bits 0–11 (`-1`, `"4095"`, `4095.9`, or a mask with a drift bit set). `HG_REQUIRED_MASKS` is frozen.
142
+
143
+ ### Session envelope
144
+
145
+ ```typescript
146
+ import { issueEnvelope, verifyEnvelope } from '@xshieldai/aegis-guard';
147
+
148
+ const env = await issueEnvelope({ service_key: 'my-service', declared_caps: ['read'] });
149
+ const audit = await verifyEnvelope(env.session_id);
150
+ if (!audit.verified || audit.drift_detected) { /* quarantine the session */ }
151
+ ```
152
+
153
+ Both call the Aegis dashboard (`aegis_url`, or `AEGIS_URL`, or `http://localhost:4850`; http and https only) and read its answer strictly (`ASE-016`): `verified` is true only when the answer says exactly `true` for the session asked about; an answer with no session id or seal throws. They trust that endpoint and the connection to it β€” they do not recompute the seal.
154
+
109
155
  ## NonceStore β€” production wiring
110
156
 
111
157
  The default `defaultNonceStore` is in-memory (single-process only). Multi-instance deployments must provide a Redis-backed store:
112
158
 
113
159
  ```typescript
114
- import { type NonceStore } from '@rocketlang/aegis-guard';
160
+ import { type NonceStore } from '@xshieldai/aegis-guard';
115
161
 
116
162
  const redisNonceStore: NonceStore = {
117
163
  async consumeNonce(nonce, ttlMs) {
@@ -130,6 +176,30 @@ const redisNonceStore: NonceStore = {
130
176
  - `quality_drift_score`: bits 12–15, `aegis-quality-16bit-v1`
131
177
  - AEG-Q-003 invariant: bits 12–15 must **never** be set in `quality_mask_at_promotion`
132
178
 
179
+ ## Upgrading from 0.3.x
180
+
181
+ - **Call `ensureSigningKeypair()` once on the authority box before minting.** Minting no longer makes a key. On a verifier box, do not call it at all.
182
+ - **Mint with a numeric `expires_at`.** A token without one is refused (it used to never expire).
183
+ - **`verifyApprovalToken()` refuses a `status` other than `approved`.** Before, only the scoped call looked at it.
184
+ - **`checkIdempotency()` without a stored fingerprint is no longer a safe no-op.** Read the new `comparable` field.
185
+ - **Fingerprints of nested payloads change** (inner keys are now sorted). A flat payload of text, finite numbers, booleans and null fingerprints as before. A retry that straddles the upgrade with a nested payload is reported as a mismatch.
186
+ - **A stale `approval-signing.pub` next to the private key is now a conflict**, and verification is refused until the two agree.
187
+ - `verifyEnvelope()` throws when the audit is for another session; `issueEnvelope()` throws on an answer with no session id or seal.
188
+
189
+ ## What this does not do
190
+
191
+ Each of these is pinned by a test in `tests/hardening.test.ts`, so it cannot change unnoticed β€” except the two about what is absent (a shared nonce store, a revocation list), which no test can show.
192
+
193
+ - **No upper limit on a token's life.** The signer chooses `expires_at`.
194
+ - **An empty scope checks nothing more** than `verifyApprovalToken()`. A scope value that is an object never matches.
195
+ - **`verifyAndConsumeNonce()` checks no signature and no status.** It trusts the payload it is given.
196
+ - **A key file swapped under a running process is not noticed until restart.** A change of `AEGIS_APPROVAL_PUBKEY_PEM` is.
197
+ - **The default nonce store is one process's memory.** Two instances each accept the same nonce once.
198
+ - **The fingerprint is not a hash**, a field set to `undefined` is the same as the field absent, and a `Date` is the same as its ISO text.
199
+ - **Only the `approval_token_ref` field is digested.** A raw token placed anywhere else in a SENSE event reaches the transport.
200
+ - **A SENSE transport that throws reaches the caller**, and the three snapshots are not checked for presence.
201
+ - **Revocation is a field in the token.** There is no revocation list: a token signed as `approved` stays valid until it expires.
202
+
133
203
  ## License
134
204
 
135
205
  AGPL-3.0 β€” Capt. Anil Sharma, powerpbox.org
@@ -145,7 +215,7 @@ v0.2.0 behaves identically to v0.1.0 β€” no emission, no state, no side effect.
145
215
  ### Wire it in 3 lines
146
216
 
147
217
  ```typescript
148
- import { setEventBus, type EventBus, type AccReceipt } from '@rocketlang/aegis-guard';
218
+ import { setEventBus, type EventBus, type AccReceipt } from '@xshieldai/aegis-guard';
149
219
 
150
220
  const myBus: EventBus = {
151
221
  emit: (r: AccReceipt) => console.log(`[ACC] ${r.event_type} verdict=${r.verdict} ${r.summary}`),
@@ -161,7 +231,7 @@ Now every primitive call emits a receipt. Pass `null` to `setEventBus` to detach
161
231
  |---|---|---|
162
232
  | `verifyApprovalToken` | `lock.approval.verified` (PASS) | `lock.approval.rejected` (FAIL) |
163
233
  | `verifyAndConsumeNonce` | `lock.nonce.consumed` (PASS) | `lock.nonce.rejected` (FAIL) |
164
- | `checkIdempotency` | `lock.idempotency.duplicate` (PASS) OR `lock.idempotency.mismatch` (WARN) | (no event for non-duplicate path) |
234
+ | `checkIdempotency` | `lock.idempotency.duplicate` (PASS), `lock.idempotency.mismatch` (WARN) or `lock.idempotency.unverifiable` (WARN, no fingerprint to compare) | (no event for non-duplicate path) |
165
235
  | `emitAegisSenseEvent` | `lock.sense.emitted` (PASS or WARN if irreversible) | β€” |
166
236
 
167
237
  ### Receipt shape
@@ -200,12 +270,12 @@ consumers ingest these events without translation.
200
270
  etc.) are a consumer choice β€” implement the `EventBus` interface and
201
271
  call `setEventBus(yourBus)`.
202
272
 
203
- ### Use with `@rocketlang/aegis-suite`
273
+ ### Use with `@xshieldai/aegis-suite`
204
274
 
205
275
  If you installed the meta-package, you can wire all 6 primitives in one call:
206
276
 
207
277
  ```typescript
208
- import { wireAllToBus } from '@rocketlang/aegis-suite'; // available in suite v0.2.0+
278
+ import { wireAllToBus } from '@xshieldai/aegis-suite'; // available in suite v0.2.0+
209
279
  wireAllToBus(); // default: in-memory bus + SQLite writer to ~/.aegis/acc-events.db
210
280
  ```
211
281
 
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@xshieldai/aegis-guard",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "AEGIS Guard SDK β€” Five Locks primitives (approval-token, nonce, idempotency, SENSE, quality-evidence) + opt-in Agentic Control Center event bus for AEGIS-governed services",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
7
7
  "author": "Capt. Anil Sharma <capt.anil.sharma@powerpbox.org>",
8
- "homepage": "https://github.com/rocketlang/aegis/tree/main/packages/aegis-guard",
8
+ "homepage": "https://github.com/rocketlang/aegis/tree/master/packages/aegis-guard",
9
9
  "repository": {
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/rocketlang/aegis.git",
@@ -41,7 +41,7 @@
41
41
  "LICENSE"
42
42
  ],
43
43
  "scripts": {
44
- "test": "bun test tests/aegis-guard.test.ts",
44
+ "test": "bun test",
45
45
  "typecheck": "tsc --noEmit",
46
46
  "prepublishOnly": "node ../../scripts/publish-guard.mjs"
47
47
  },
package/src/acc-bus.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // Copyright (c) 2026 Capt. Anil Sharma (rocketlang). All rights reserved.
3
3
  // See LICENSE for details.
4
4
  //
5
- // @rocketlang/aegis-guard β€” opt-in ACC event bus integration (v0.2.0)
5
+ // @xshieldai/aegis-guard β€” opt-in ACC event bus integration (v0.2.0)
6
6
  // @rule:ACC-003 β€” Opt-in event bus. emit only when setEventBus() called.
7
7
  // @rule:ACC-004 β€” Lightweight OSS receipt shape (strict subset of EE PRAMANA).
8
8
  // @rule:ACC-YK-003 β€” Stateless-primitive contract preserved. No bus = no emit.
@@ -1,6 +1,9 @@
1
1
  // @rule:AEG-E-016 β€” approval tokens are scoped to service_id + capability + operation
2
2
  // @rule:AEG-HG-2B-005 β€” approval token references in SENSE use digest, not raw token material
3
3
  // @rule:AEG-HG-2B-006 β€” nonce protects the approval; idempotency protects the operation (separate locks)
4
+ // @rule:AEG-HG-2B-008 β€” every field a decision rests on is checked for its type before it is
5
+ // compared; anything the gate cannot read is a refusal, with the
6
+ // package's own error, never a pass and never a TypeError
4
7
 
5
8
  import { createHash } from 'crypto';
6
9
  import { IrrNoApprovalError } from './errors.js';
@@ -23,16 +26,25 @@ export interface ApprovalTokenPayload {
23
26
  [key: string]: unknown;
24
27
  }
25
28
 
29
+ const isTime = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v) && v > 0;
30
+ const isText = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
31
+ const say = (v: unknown): string => (typeof v === 'string' ? v : typeof v).slice(0, 60);
32
+ const own = (o: object, k: string): boolean => Object.prototype.hasOwnProperty.call(o, k);
33
+
26
34
  // @rule:AEG-HG-2B-005 β€” SENSE stores proof reference, not proof secret.
27
35
  // Returns first 24 hex chars of SHA-256 (96 bits) β€” sufficient for correlation, not reconstruction.
28
36
  export function digestApprovalToken(token: string): string {
29
- return createHash('sha256').update(token).digest('hex').slice(0, 24);
37
+ return createHash('sha256').update(String(token)).digest('hex').slice(0, 24);
30
38
  }
31
39
 
32
40
  // @rule:AEG-E-016 @rule:KGT-002 β€” mint an AEGIS-signed EdDSA JWT (KGT-T1.1).
33
41
  // Signing requires the AEGIS private key (~/.aegis/approval-signing.key) β€” present on
34
- // the AEGIS box, auto-provisioned by the :4850 dashboard. Throws where the key is absent:
35
- // a service that cannot sign cannot mint, by design.
42
+ // the AEGIS box, where the :4850 dashboard provisions it at boot with
43
+ // ensureSigningKeypair(). Throws where the key is absent: a service that cannot sign
44
+ // cannot mint, by design. Minting never makes a key.
45
+ //
46
+ // The payload is signed as given. verifyApprovalToken() will refuse a token without a
47
+ // numeric expires_at, so mint with one.
36
48
  export function mintApprovalToken(payload: ApprovalTokenPayload): string {
37
49
  return signApprovalJwt(payload);
38
50
  }
@@ -41,6 +53,8 @@ export function mintApprovalToken(payload: ApprovalTokenPayload): string {
41
53
  // @rule:KGT-002 β€” signature verified FIRST, fail-closed: unsigned/tampered/alg:none/
42
54
  // wrong-key tokens are rejected before any scope check. Legacy base64url(JSON) tokens
43
55
  // (pre-KGT-T1.1, forgeable) are rejected outright.
56
+ // @rule:AEG-HG-2B-008 β€” expires_at must be a number; a status other than 'approved' is a
57
+ // refusal here, in LOCK_1, not only in the scoped check.
44
58
  export function verifyApprovalToken(
45
59
  token: string,
46
60
  expectedServiceId: string,
@@ -48,41 +62,71 @@ export function verifyApprovalToken(
48
62
  expectedOperation: string,
49
63
  ): ApprovalTokenPayload {
50
64
  // @rule:ACC-003 @rule:ACC-004 β€” emit ACC receipt on success OR failure
51
- const scope = `${expectedServiceId}/${expectedCapability}/${expectedOperation}`;
65
+ const cap = isText(expectedCapability) ? expectedCapability : 'unknown';
66
+ const scope = `${say(expectedServiceId)}/${say(expectedCapability)}/${say(expectedOperation)}`;
52
67
  try {
68
+ if (!isText(expectedServiceId) || !isText(expectedCapability) || !isText(expectedOperation)) {
69
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: the expected service, capability and operation must each be non-empty text');
70
+ }
53
71
  const jwt = verifyApprovalJwt(token);
54
72
  if (!jwt.ok) {
55
- throw new IrrNoApprovalError(expectedCapability, `KGT-002: ${jwt.reason}`);
73
+ throw new IrrNoApprovalError(cap, `KGT-002: ${jwt.reason}`);
56
74
  }
57
75
  const payload = jwt.payload as ApprovalTokenPayload;
58
76
 
59
77
  if (payload.service_id !== expectedServiceId) {
60
78
  throw new IrrNoApprovalError(
61
- expectedCapability,
62
- `AEG-E-016: token scoped to '${payload.service_id}', not '${expectedServiceId}'`,
79
+ cap,
80
+ `AEG-E-016: token scoped to '${say(payload.service_id)}', not '${expectedServiceId}'`,
63
81
  );
64
82
  }
65
83
  if (payload.capability !== expectedCapability) {
66
84
  throw new IrrNoApprovalError(
67
- expectedCapability,
68
- `AEG-E-016: token capability '${payload.capability}' does not match '${expectedCapability}'`,
85
+ cap,
86
+ `AEG-E-016: token capability '${say(payload.capability)}' does not match '${expectedCapability}'`,
69
87
  );
70
88
  }
71
89
  if (payload.operation !== expectedOperation) {
72
90
  throw new IrrNoApprovalError(
73
- expectedCapability,
74
- `AEG-E-016: token operation '${payload.operation}' does not match '${expectedOperation}'`,
91
+ cap,
92
+ `AEG-E-016: token operation '${say(payload.operation)}' does not match '${expectedOperation}'`,
75
93
  );
76
94
  }
95
+ // A token with no expiry, or one written as text, used to compare false against the
96
+ // clock and so never expired.
97
+ if (!isTime(payload.expires_at)) {
98
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: token has no numeric expires_at');
99
+ }
77
100
  if (Date.now() > payload.expires_at) {
78
- throw new IrrNoApprovalError(expectedCapability, 'AEG-E-016: token expired');
101
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: token expired');
102
+ }
103
+ if (payload.issued_at !== undefined) {
104
+ if (!isTime(payload.issued_at)) {
105
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: token issued_at is not a number');
106
+ }
107
+ if (payload.issued_at > Date.now() + CLOCK_SKEW_MS) {
108
+ throw new IrrNoApprovalError(
109
+ cap,
110
+ 'AEG-E-016: token issued_at is in the future (clock skew > 60s or forged timestamp)',
111
+ );
112
+ }
113
+ if (payload.issued_at > payload.expires_at) {
114
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: token expires before it was issued');
115
+ }
79
116
  }
80
- if (payload.issued_at !== undefined && payload.issued_at > Date.now() + CLOCK_SKEW_MS) {
117
+ // Absent means approved (tokens minted before the field existed). Anything else that
118
+ // is not exactly 'approved' is not an approval.
119
+ if (payload.status !== undefined && payload.status !== 'approved') {
81
120
  throw new IrrNoApprovalError(
82
- expectedCapability,
83
- 'AEG-E-016: token issued_at is in the future (clock skew > 60s or forged timestamp)',
121
+ cap,
122
+ payload.status === 'revoked' ? 'AEG-E-016: token revoked'
123
+ : payload.status === 'denied' ? 'AEG-E-016: token denied'
124
+ : `AEG-E-016: token status '${say(payload.status)}' is not 'approved'`,
84
125
  );
85
126
  }
127
+ if (payload.nonce !== undefined && !isText(payload.nonce)) {
128
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: token nonce is not text');
129
+ }
86
130
 
87
131
  emitAccReceipt({
88
132
  receipt_id: `aegis-guard-verify-${digestApprovalToken(token)}`,
@@ -106,29 +150,52 @@ export function verifyApprovalToken(
106
150
 
107
151
  // @rule:AEG-HG-2B-006 β€” consume nonce before any state mutation; missing nonce = hard reject.
108
152
  // Nonce TTL is bounded by token lifetime; store unavailable = fail CLOSED (throws, never open).
153
+ // @rule:AEG-HG-2B-008 β€” the nonce must be text, the payload must be in date, and only a
154
+ // store that answers exactly `true` has consumed it.
155
+ //
156
+ // This takes a payload, not a token: it does not check a signature. Pass it what
157
+ // verifyApprovalToken() returned.
109
158
  export async function verifyAndConsumeNonce(
110
159
  payload: ApprovalTokenPayload,
111
160
  store: NonceStore = defaultNonceStore,
112
161
  ): Promise<void> {
113
162
  // @rule:ACC-003 @rule:ACC-004 β€” emit ACC receipt on success OR failure
114
- const scope = `${payload.service_id}/${payload.capability}/${payload.operation}`;
163
+ const p = (payload !== null && typeof payload === 'object' ? payload : {}) as ApprovalTokenPayload;
164
+ const cap = isText(p.capability) ? p.capability : 'unknown';
165
+ const scope = `${say(p.service_id)}/${say(p.capability)}/${say(p.operation)}`;
115
166
  try {
116
- if (!payload.nonce) {
167
+ if (p !== payload) {
168
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: no approval payload');
169
+ }
170
+ if (payload.nonce === undefined || payload.nonce === null || payload.nonce === '') {
117
171
  throw new IrrNoApprovalError(
118
- payload.capability,
172
+ cap,
119
173
  'AEG-E-016: irreversible operation requires nonce for replay prevention',
120
174
  );
121
175
  }
122
- const ttlMs = Math.max(0, payload.expires_at - Date.now());
176
+ // An object as a nonce is a different object each time the token is parsed, so it
177
+ // would never be seen twice.
178
+ if (!isText(payload.nonce)) {
179
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: nonce is not text');
180
+ }
181
+ if (!isTime(payload.expires_at)) {
182
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: approval has no numeric expires_at');
183
+ }
184
+ // An expired approval has nothing left to protect, and a nonce stored for zero
185
+ // milliseconds is forgotten at once: the same approval could be consumed again.
186
+ const ttlMs = payload.expires_at - Date.now();
187
+ if (ttlMs <= 0) {
188
+ throw new IrrNoApprovalError(cap, 'AEG-E-016: approval expired');
189
+ }
123
190
  const consumed = await store.consumeNonce(payload.nonce, ttlMs);
124
- if (!consumed) {
191
+ if (consumed !== true) {
125
192
  throw new IrrNoApprovalError(
126
- payload.capability,
127
- `AEG-E-016: nonce '${payload.nonce}' already consumed β€” approval replay rejected`,
193
+ cap,
194
+ `AEG-E-016: nonce '${payload.nonce.slice(0, 60)}' already consumed β€” approval replay rejected`,
128
195
  );
129
196
  }
130
197
  emitAccReceipt({
131
- receipt_id: `aegis-guard-nonce-${payload.nonce}`,
198
+ receipt_id: `aegis-guard-nonce-${payload.nonce.slice(0, 80)}`,
132
199
  event_type: 'lock.nonce.consumed',
133
200
  verdict: 'PASS',
134
201
  rules_fired: ['AEG-HG-2B-006'],
@@ -149,6 +216,10 @@ export async function verifyAndConsumeNonce(
149
216
  // @rule:AEG-E-016 β€” HG-2B: verify scope fields declared by the caller service.
150
217
  // requiredScope: Record<string, unknown> β€” caller declares which fields to bind; SDK enforces them.
151
218
  // Service-agnostic: the caller owns the field names; the SDK never names domain concepts.
219
+ // @rule:AEG-HG-2B-008 β€” a field the caller has no value for binds nothing, so it is a
220
+ // refusal: before v0.4.0 `{ vessel_id: undefined }` matched any token without a vessel_id.
221
+ //
222
+ // An empty requiredScope checks nothing more than verifyApprovalToken() does.
152
223
  export function verifyScopedApprovalToken(
153
224
  token: string,
154
225
  expectedServiceId: string,
@@ -160,19 +231,22 @@ export function verifyScopedApprovalToken(
160
231
  token, expectedServiceId, expectedCapability, expectedOperation,
161
232
  );
162
233
 
163
- if (payload.status === 'revoked') {
164
- throw new IrrNoApprovalError(expectedCapability, 'AEG-E-016: token revoked');
165
- }
166
- if (payload.status === 'denied') {
167
- throw new IrrNoApprovalError(expectedCapability, 'AEG-E-016: token denied');
234
+ if (requiredScope === null || typeof requiredScope !== 'object' || Array.isArray(requiredScope)) {
235
+ throw new IrrNoApprovalError(expectedCapability, 'AEG-E-016: required scope is not an object of fields');
168
236
  }
169
237
 
170
238
  for (const [field, contextValue] of Object.entries(requiredScope)) {
171
- const tokenValue = payload[field];
239
+ if (contextValue === undefined || contextValue === null) {
240
+ throw new IrrNoApprovalError(
241
+ expectedCapability,
242
+ `AEG-E-016: scope field '${field}' has no value to bind the token to`,
243
+ );
244
+ }
245
+ const tokenValue = own(payload, field) ? payload[field] : undefined;
172
246
  if (tokenValue !== contextValue) {
173
247
  throw new IrrNoApprovalError(
174
248
  expectedCapability,
175
- `AEG-E-016: token ${field} '${String(tokenValue)}' does not match scope '${String(contextValue)}'`,
249
+ `AEG-E-016: token ${field} '${String(tokenValue).slice(0, 60)}' does not match scope '${String(contextValue).slice(0, 60)}'`,
176
250
  );
177
251
  }
178
252
  }
package/src/envelope.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @ankr/aegis-guard β€” Agent Session Envelope helpers (ASE-T020)
1
+ // @xshieldai/aegis-guard β€” Agent Session Envelope helpers (ASE-T020)
2
2
  //
3
3
  // issueEnvelope β€” POST /api/v1/aegis/session β€” proxy-native agent frameworks use this at startup
4
4
  // verifyEnvelope β€” GET /api/v1/aegis/sessions/:id/audit β€” check seal integrity + drift
@@ -6,6 +6,12 @@
6
6
  // @rule:ASE-001 every proxy-native agent session must call issueEnvelope before its first LLM call
7
7
  // @rule:ASE-002 sealed_hash is computed by Aegis at issuance and returned in the response
8
8
  // @rule:INF-ASE-002 if sealed_hash_verified=false the session must be quarantined immediately
9
+ // @rule:ASE-016 the helpers read the answer strictly: a seal is verified only when the
10
+ // answer says exactly `true`, for the session that was asked about; an
11
+ // answer with no session id or no seal is not an envelope
12
+ //
13
+ // These helpers trust the Aegis endpoint they are pointed at (aegis_url, or AEGIS_URL, or
14
+ // http://localhost:4850) and the connection to it. They do not recompute the seal.
9
15
 
10
16
  export interface IssueEnvelopeParams {
11
17
  /** agent_type must be 'proxy-native' for frameworks calling this directly */
@@ -48,7 +54,13 @@ export interface EnvelopeVerifyResult {
48
54
  }
49
55
 
50
56
  function aegisBase(override?: string): string {
51
- return override ?? process.env.AEGIS_URL ?? "http://localhost:4850";
57
+ const base = override ?? process.env.AEGIS_URL ?? "http://localhost:4850";
58
+ // Only http and https. Some runtimes will fetch a file: address and hand its contents
59
+ // back as if Aegis had answered.
60
+ let scheme = "";
61
+ try { scheme = new URL(base).protocol; } catch { throw new Error("aegis-guard: the Aegis address is not a URL"); }
62
+ if (scheme !== "http:" && scheme !== "https:") throw new Error(`aegis-guard: unsupported Aegis address scheme ${scheme}`);
63
+ return base.replace(/\/+$/, "");
52
64
  }
53
65
 
54
66
  // @rule:ASE-001 issue a sealed envelope before the first action
@@ -78,10 +90,19 @@ export async function issueEnvelope(params: IssueEnvelopeParams): Promise<Envelo
78
90
  }
79
91
 
80
92
  const data = await resp.json() as Record<string, unknown>;
93
+ // @rule:ASE-016 β€” a 200 that carries an error, or no session id, or no seal, is not an
94
+ // envelope. Before v0.4.0 it came back as a result with empty strings in it.
95
+ if (data === null || typeof data !== "object" || data.ok === false) {
96
+ throw new Error(`issueEnvelope failed: ${String((data as Record<string, unknown> | null)?.error ?? "the answer is not an envelope").slice(0, 200)}`);
97
+ }
98
+ if (typeof data.session_id !== "string" || data.session_id === "" ||
99
+ typeof data.sealed_hash !== "string" || data.sealed_hash === "") {
100
+ throw new Error("issueEnvelope failed: the answer has no session_id or no sealed_hash");
101
+ }
81
102
  return {
82
- session_id: String(data.session_id ?? ""),
83
- agent_id: String(data.agent_id ?? data.session_id ?? ""),
84
- sealed_hash: String(data.sealed_hash ?? ""),
103
+ session_id: data.session_id,
104
+ agent_id: String(data.agent_id ?? data.session_id),
105
+ sealed_hash: data.sealed_hash,
85
106
  issued_at: String(data.issued_at ?? ""),
86
107
  expires_at: String(data.expires_at ?? ""),
87
108
  budget_usd: Number(data.budget_usd ?? 0),
@@ -105,15 +126,27 @@ export async function verifyEnvelope(
105
126
  }
106
127
 
107
128
  const data = await resp.json() as Record<string, unknown>;
129
+ if (data === null || typeof data !== "object" || data.ok === false) {
130
+ throw new Error(`verifyEnvelope failed: ${String((data as Record<string, unknown> | null)?.error ?? "the answer is not an audit").slice(0, 200)}`);
131
+ }
132
+ // @rule:ASE-016 β€” the audit must be for the session that was asked about.
133
+ if (data.session_id !== sessionId) {
134
+ throw new Error("verifyEnvelope failed: the audit that came back is for another session, or names none");
135
+ }
108
136
  const drift_set: string[] = Array.isArray(data.drift_set) ? data.drift_set as string[] : [];
109
137
  return {
110
138
  session_id: sessionId,
111
- verified: Boolean(data.sealed_hash_verified ?? false),
112
- drift_detected: Boolean(data.drift_detected ?? drift_set.length > 0),
139
+ // Only the boolean true. Boolean("false") is true, and so is Boolean("tampered").
140
+ verified: data.sealed_hash_verified === true,
141
+ // Drift unless the answer says exactly false AND the list is an empty list (or absent).
142
+ drift_detected: drift_set.length > 0 ||
143
+ (data.drift_set !== undefined && !Array.isArray(data.drift_set)) ||
144
+ (data.drift_detected !== undefined && data.drift_detected !== false),
113
145
  drift_set,
114
146
  declared_caps: Array.isArray(data.declared_caps) ? data.declared_caps as string[] : [],
115
147
  actual_caps_used: Array.isArray(data.actual_caps_used) ? data.actual_caps_used as string[] : [],
116
- budget_usd: Number(data.budget_usd ?? 0),
117
- budget_used_usd: Number(data.budget_used_usd ?? 0),
148
+ // The audit route names these budget_allocated and budget_used.
149
+ budget_usd: Number(data.budget_usd ?? data.budget_allocated ?? 0),
150
+ budget_used_usd: Number(data.budget_used_usd ?? data.budget_used ?? 0),
118
151
  };
119
152
  }
@@ -1,4 +1,6 @@
1
1
  // @rule:AEG-HG-2B-006 β€” idempotency protects the operation; nonce protects the approval (separate locks)
2
+ // @rule:AEG-HG-2B-009 β€” a duplicate is a safe no-op only when both fingerprints are known
3
+ // and equal; a fingerprint that was never stored proves nothing
2
4
  // Pattern: check DB for externalRef before mutating. Matching fingerprint = safe no-op. Mismatch = warn + reject.
3
5
 
4
6
  import { emitAccReceipt } from './acc-bus.js';
@@ -7,11 +9,21 @@ export interface IdempotencyCheckResult {
7
9
  isDuplicate: boolean;
8
10
  payloadMismatch: boolean;
9
11
  safeNoOp: boolean;
12
+ /**
13
+ * False when a record exists but one of the two fingerprints is missing, so the
14
+ * payloads could not be compared. safeNoOp is then false. Added in v0.4.0.
15
+ */
16
+ comparable: boolean;
10
17
  }
11
18
 
19
+ const isText = (v: unknown): v is string => typeof v === 'string' && v.length > 0;
20
+
12
21
  // Functional helper: does not touch DB. Caller provides existingRecord and fingerprints.
13
- // When isDuplicate=true and payloadMismatch=false: safeNoOp=true β€” return existing record, do not re-execute.
22
+ // When isDuplicate=true and payloadMismatch=false and comparable=true: safeNoOp=true β€”
23
+ // return existing record, do not re-execute.
14
24
  // When isDuplicate=true and payloadMismatch=true: safeNoOp=false β€” log warning; reject or escalate.
25
+ // When isDuplicate=true and comparable=false: safeNoOp=false β€” the record exists but nothing
26
+ // shows the new request is the same one. Before v0.4.0 this case was a safe no-op.
15
27
  export function checkIdempotency(
16
28
  _externalRef: string,
17
29
  existingRecord: unknown,
@@ -20,31 +32,80 @@ export function checkIdempotency(
20
32
  ): IdempotencyCheckResult {
21
33
  const isDuplicate = existingRecord !== null && existingRecord !== undefined;
22
34
  if (!isDuplicate) {
23
- return { isDuplicate: false, payloadMismatch: false, safeNoOp: false };
35
+ return { isDuplicate: false, payloadMismatch: false, safeNoOp: false, comparable: false };
24
36
  }
25
- const payloadMismatch =
26
- existingFingerprint !== undefined && existingFingerprint !== newFingerprint;
37
+ const comparable = isText(newFingerprint) && isText(existingFingerprint);
38
+ const payloadMismatch = comparable && existingFingerprint !== newFingerprint;
27
39
  const result: IdempotencyCheckResult = {
28
40
  isDuplicate: true,
29
41
  payloadMismatch,
30
- safeNoOp: !payloadMismatch,
42
+ safeNoOp: comparable && !payloadMismatch,
43
+ comparable,
31
44
  };
45
+ const ref = String(_externalRef).slice(0, 80);
32
46
  emitAccReceipt({
33
- receipt_id: `aegis-guard-idem-${_externalRef}`,
34
- event_type: payloadMismatch ? 'lock.idempotency.mismatch' : 'lock.idempotency.duplicate',
35
- verdict: payloadMismatch ? 'WARN' : 'PASS',
47
+ receipt_id: `aegis-guard-idem-${ref}`,
48
+ event_type: !comparable
49
+ ? 'lock.idempotency.unverifiable'
50
+ : payloadMismatch ? 'lock.idempotency.mismatch' : 'lock.idempotency.duplicate',
51
+ verdict: result.safeNoOp ? 'PASS' : 'WARN',
36
52
  rules_fired: ['AEG-HG-2B-006'],
37
- summary: payloadMismatch
38
- ? `duplicate externalRef ${_externalRef} with payload mismatch β€” caller must reject or escalate`
39
- : `duplicate externalRef ${_externalRef} β€” safe no-op, return existing`,
53
+ summary: !comparable
54
+ ? `duplicate externalRef ${ref} with no fingerprint to compare β€” caller must reject or escalate`
55
+ : payloadMismatch
56
+ ? `duplicate externalRef ${ref} with payload mismatch β€” caller must reject or escalate`
57
+ : `duplicate externalRef ${ref} β€” safe no-op, return existing`,
40
58
  });
41
59
  return result;
42
60
  }
43
61
 
62
+ // One spelling for one value, at every depth: keys sorted, and the values JSON cannot
63
+ // carry written so that they differ from each other and from null.
64
+ //
65
+ // The values JSON cannot carry are written as an object with one key that starts with "$".
66
+ // So that a payload cannot be written to look like one of them, a real key that starts
67
+ // with "$" gets a second "$" in front: { $bigint: "1" } and 1n do not collide.
68
+ function canonical(v: unknown, seen: Set<object>, fromToJson = false): unknown {
69
+ if (typeof v === 'bigint') return { $bigint: v.toString() };
70
+ if (typeof v === 'number' && !Number.isFinite(v)) return { $number: String(v) };
71
+ if (v === null || typeof v !== 'object') return v;
72
+ // toJSON is asked once. What it returns is read as data, and its own toJSON is not
73
+ // called: a value whose toJSON returns itself would otherwise never finish.
74
+ if (!fromToJson && typeof (v as { toJSON?: unknown }).toJSON === 'function') {
75
+ return canonical((v as { toJSON: () => unknown }).toJSON(), seen, true);
76
+ }
77
+ if (seen.has(v)) throw new Error('buildIdempotencyFingerprint: the payload refers to itself');
78
+ seen.add(v);
79
+ let out: unknown;
80
+ if (Array.isArray(v)) out = v.map((x) => canonical(x, seen));
81
+ else if (v instanceof Map) out = { $map: [...v.entries()].map(([k, x]) => [canonical(k, seen), canonical(x, seen)]) };
82
+ else if (v instanceof Set) out = { $set: [...v.values()].map((x) => canonical(x, seen)) };
83
+ else {
84
+ const o: Record<string, unknown> = {};
85
+ for (const k of Object.keys(v).sort()) {
86
+ const x = (v as Record<string, unknown>)[k];
87
+ if (typeof x === 'function' || typeof x === 'symbol') continue; // as JSON leaves them out
88
+ o[k.startsWith('$') ? '$' + k : k] = canonical(x, seen);
89
+ }
90
+ out = o;
91
+ }
92
+ seen.delete(v);
93
+ return out;
94
+ }
95
+
44
96
  // Build a stable base64 fingerprint from an arbitrary operation payload.
97
+ //
98
+ // The fingerprint is the payload itself, encoded β€” not a hash. Whoever can read a stored
99
+ // fingerprint can read the payload. Keep secrets out of it, or hash it before storing.
100
+ //
101
+ // For a flat payload of text, finite numbers, booleans and null the output is the same as
102
+ // before v0.4.0. It differs for: nested objects (their keys are now sorted too), NaN and
103
+ // Β±Infinity (were null), bigint (threw), Map and Set (were {}), and a key that starts with
104
+ // "$" (it gets a second "$"). A field set to undefined is the same as the field being
105
+ // absent, as it always was.
45
106
  export function buildIdempotencyFingerprint(payload: Record<string, unknown>): string {
46
- const sorted = Object.keys(payload)
47
- .sort()
48
- .reduce<Record<string, unknown>>((acc, k) => { acc[k] = payload[k]; return acc; }, {});
49
- return Buffer.from(JSON.stringify(sorted)).toString('base64');
107
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
108
+ throw new Error('buildIdempotencyFingerprint: the payload must be an object of fields');
109
+ }
110
+ return Buffer.from(JSON.stringify(canonical(payload, new Set()))).toString('base64');
50
111
  }
package/src/index.ts CHANGED
@@ -1,4 +1,4 @@
1
- // @rocketlang/aegis-guard β€” AEGIS Guard SDK public API
1
+ // @xshieldai/aegis-guard β€” AEGIS Guard SDK public API
2
2
  // Five Locks proved in carbonx-backend (batches 62-74). Batch 93 makes them reusable.
3
3
  // v0.2.0 adds opt-in Agentic Control Center (ACC) event bus integration.
4
4
 
package/src/quality.ts CHANGED
@@ -35,6 +35,7 @@ const PROMOTION_BIT_MAP: Array<[keyof QualityEvidenceInput, number]> = [
35
35
  // @rule:AEG-Q-003 β€” bits 12-15 are never touched by this function (point-in-time only)
36
36
  export function buildQualityMaskAtPromotion(evidence: QualityEvidenceInput): number {
37
37
  let mask = 0;
38
+ if (evidence === null || typeof evidence !== 'object') return mask; // no evidence, no bits
38
39
  for (const [field, bit] of PROMOTION_BIT_MAP) {
39
40
  if (evidence[field] === true) mask |= (1 << bit);
40
41
  }
@@ -58,23 +59,35 @@ const DRIFT_BIT_MAP: Array<[keyof QualityDriftInput, number]> = [
58
59
  // @rule:AEG-Q-002 β€” drift bits 12-15 only; never OR'd with promotion mask into a single field
59
60
  export function buildQualityDriftScore(drift: QualityDriftInput): number {
60
61
  let score = 0;
62
+ if (drift === null || typeof drift !== 'object') return score;
61
63
  for (const [field, bit] of DRIFT_BIT_MAP) {
62
64
  if (drift[field] === true) score |= (1 << bit);
63
65
  }
64
66
  return score;
65
67
  }
66
68
 
67
- // HG group minimum quality mask requirements (promotion bits 0-11 only)
68
- export const HG_REQUIRED_MASKS = {
69
+ // HG group minimum quality mask requirements (promotion bits 0-11 only).
70
+ // Frozen: the table is exported, and before v0.4.0 any code in the process could lower a
71
+ // bar by assigning to it.
72
+ export const HG_REQUIRED_MASKS = Object.freeze({
69
73
  'HG-1': 0x0302,
70
74
  'HG-2A': 0x0B83,
71
75
  'HG-2B': 0x0FAB,
72
76
  'HG-2B-financial': 0x0FFF,
73
- } as const;
77
+ } as const);
74
78
 
75
79
  export type HgGroup = keyof typeof HG_REQUIRED_MASKS;
76
80
 
81
+ const PROMOTION_BITS = 0x0fff;
82
+
83
+ // @rule:AEG-Q-003 β€” a promotion mask is a whole number made of bits 0-11 and nothing else.
84
+ // Anything that is not one does not meet any bar: before v0.4.0 the bitwise test coerced
85
+ // its input, so -1, the text "4095" and 4095.9 all met the financial bar, and so did a
86
+ // mask carrying drift bits.
77
87
  export function meetsHgQualityRequirement(hgGroup: HgGroup, qualityMask: number): boolean {
88
+ if (typeof hgGroup !== 'string' || !Object.prototype.hasOwnProperty.call(HG_REQUIRED_MASKS, hgGroup)) return false;
89
+ if (typeof qualityMask !== 'number' || !Number.isInteger(qualityMask)) return false;
90
+ if (qualityMask < 0 || (qualityMask & ~PROMOTION_BITS) !== 0 || qualityMask > PROMOTION_BITS) return false;
78
91
  const required = HG_REQUIRED_MASKS[hgGroup];
79
92
  return (qualityMask & required) === required;
80
93
  }
package/src/sense.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  // @rule:AEG-HG-2B-004 β€” gate_phase tags event to soak vs live phase
4
4
  // @rule:AEG-HG-2B-005 β€” approval_token_ref must be a digest (digestApprovalToken), never raw token
5
5
 
6
+ import { createHash } from 'crypto';
7
+ import { emitAccReceipt } from './acc-bus.js';
8
+
6
9
  export interface AegisSenseEvent {
7
10
  event_type: string;
8
11
  service_id: string;
@@ -28,19 +31,37 @@ function defaultJsonTransport(event: AegisSenseEvent): void {
28
31
  let _transport: SenseTransport = defaultJsonTransport;
29
32
 
30
33
  export function configureSenseTransport(transport: SenseTransport): void {
31
- _transport = transport;
34
+ _transport = typeof transport === 'function' ? transport : defaultJsonTransport;
35
+ }
36
+
37
+ // The shape digestApprovalToken() returns: 24 lower-case hex characters.
38
+ const DIGEST = /^[0-9a-f]{24}$/;
39
+
40
+ // @rule:AEG-HG-2B-005 β€” the event is the audit record, and an audit record must not hold
41
+ // the thing it audits. A reference that is not already a digest is digested here, so a
42
+ // caller who passes the raw token by mistake does not write it to the log. (Same digest
43
+ // as digestApprovalToken; computed here so this module does not import the token module.)
44
+ function asDigest(ref: unknown): string | undefined {
45
+ if (ref === undefined || ref === null || ref === '') return undefined;
46
+ if (typeof ref === 'string' && DIGEST.test(ref)) return ref;
47
+ return createHash('sha256').update(String(ref)).digest('hex').slice(0, 24);
32
48
  }
33
49
 
34
50
  // @rule:CA-003 β€” all three snapshot fields are required by the type; callers must supply them.
35
- // approval_token_ref, if present, must already be the output of digestApprovalToken (AEG-HG-2B-005).
51
+ // The SDK does not check that they are present.
36
52
  // @rule:ACC-003 β€” also emit an ACC receipt for cockpit observability (no-op when bus unset).
53
+ //
54
+ // If the transport throws, this throws: the caller of an irreversible operation should
55
+ // know its audit event was not written. (A bus that throws is different: it is swallowed.)
37
56
  export function emitAegisSenseEvent(event: AegisSenseEvent): void {
38
- _transport(event);
39
- emitAccReceiptFromSense(event);
57
+ const ref = asDigest(event?.approval_token_ref);
58
+ const safe: AegisSenseEvent = { ...event };
59
+ if (ref === undefined) delete safe.approval_token_ref;
60
+ else safe.approval_token_ref = ref;
61
+ _transport(safe);
62
+ emitAccReceiptFromSense(safe);
40
63
  }
41
64
 
42
- import { emitAccReceipt } from './acc-bus.js';
43
-
44
65
  function emitAccReceiptFromSense(event: AegisSenseEvent): void {
45
66
  emitAccReceipt({
46
67
  receipt_id: `aegis-guard-sense-${event.correlation_id || Date.now()}`,
package/src/signing.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // @rule:AEG-E-016 β€” approval tokens are AEGIS-signed; an unsigned approval is a forgery
2
2
  // @rule:KGT-002 β€” fail-closed: no key material β†’ verification DENIES, never falls open
3
+ // @rule:AEG-HG-2B-007 β€” a box that was given a public key to trust never makes a key of its own,
4
+ // and a box whose configured keys disagree trusts none of them
3
5
  //
4
6
  // KGT-T1.1 β€” Ed25519 JWT signing for approval tokens.
5
7
  // Before this module, mintApprovalToken emitted base64url(JSON): any client could
@@ -29,50 +31,92 @@ function aegisDir(): string {
29
31
  return process.env.AEGIS_DIR ?? join(process.env.HOME ?? homedir(), '.aegis');
30
32
  }
31
33
 
32
- // Cache keyed by resolved dir so tests (which point AEGIS_DIR at a temp dir) and
33
- // long-lived services both get one disk read, not one per verify.
34
- let cache: { dir: string; priv: KeyObject | null; pub: KeyObject | null } | null = null;
34
+ // Cache keyed by resolved dir and by the environment key, so tests (which point AEGIS_DIR
35
+ // at a temp dir) and long-lived services both get one disk read, not one per verify.
36
+ // A key FILE that changes under a running process is not noticed until restart.
37
+ interface Keys {
38
+ dir: string;
39
+ env: string;
40
+ priv: KeyObject | null;
41
+ pub: KeyObject | null;
42
+ /** Why no key is trusted although key material exists. */
43
+ conflict: string | null;
44
+ }
45
+ let cache: Keys | null = null;
46
+
47
+ const pemOf = (k: KeyObject): string => k.export({ type: 'spki', format: 'pem' }).toString();
35
48
 
36
- function loadKeys(): { priv: KeyObject | null; pub: KeyObject | null } {
49
+ function loadKeys(): Keys {
37
50
  const dir = aegisDir();
38
- if (cache && cache.dir === dir) return cache;
51
+ const envPem = process.env.AEGIS_APPROVAL_PUBKEY_PEM ?? '';
52
+ if (cache && cache.dir === dir && cache.env === envPem) return cache;
39
53
 
40
54
  let priv: KeyObject | null = null;
41
55
  let pub: KeyObject | null = null;
56
+ let conflict: string | null = null;
42
57
 
43
58
  const keyPath = join(dir, KEY_FILE);
59
+ const pubPath = join(dir, PUB_FILE);
60
+
44
61
  if (existsSync(keyPath)) {
45
62
  try {
46
63
  priv = createPrivateKey(readFileSync(keyPath, 'utf8'));
64
+ if (priv.asymmetricKeyType !== 'ed25519') throw new Error('not an ed25519 key');
47
65
  pub = createPublicKey(priv);
48
- } catch { priv = null; pub = null; }
66
+ } catch {
67
+ priv = null; pub = null;
68
+ conflict = `${KEY_FILE} exists but is not a usable Ed25519 private key`;
69
+ }
49
70
  }
50
71
 
51
- // Verifier-only deployments: public key via env PEM or the .pub file.
52
- if (!pub) {
53
- const envPem = process.env.AEGIS_APPROVAL_PUBKEY_PEM;
54
- const pubPath = join(dir, PUB_FILE);
55
- try {
56
- if (envPem) pub = createPublicKey(envPem);
57
- else if (existsSync(pubPath)) pub = createPublicKey(readFileSync(pubPath, 'utf8'));
58
- } catch { pub = null; }
72
+ // Every public key this box has been told about: the file, and the environment.
73
+ const told: Array<[string, KeyObject]> = [];
74
+ if (existsSync(pubPath)) {
75
+ try { told.push([PUB_FILE, createPublicKey(readFileSync(pubPath, 'utf8'))]); }
76
+ catch { conflict ??= `${PUB_FILE} exists but is not a usable public key`; }
77
+ }
78
+ if (envPem) {
79
+ try { told.push(['AEGIS_APPROVAL_PUBKEY_PEM', createPublicKey(envPem)]); }
80
+ catch { conflict ??= 'AEGIS_APPROVAL_PUBKEY_PEM is set but is not a usable public key'; }
59
81
  }
60
82
 
61
- cache = { dir, priv, pub };
83
+ // @rule:AEG-HG-2B-007 β€” all of them must be the same Ed25519 key. If the private key and a
84
+ // public key the box was told about disagree, one of them was replaced, and the box
85
+ // cannot know which: it trusts none.
86
+ for (const [name, k] of told) {
87
+ if (k.asymmetricKeyType !== 'ed25519') { conflict ??= `${name} is not an Ed25519 key`; continue; }
88
+ if (!pub) pub = k;
89
+ else if (pemOf(pub) !== pemOf(k)) conflict ??= `${name} is a different key from the other key material on this box`;
90
+ }
91
+ if (conflict) { priv = null; pub = null; }
92
+
93
+ cache = { dir, env: envPem, priv, pub, conflict };
62
94
  return cache;
63
95
  }
64
96
 
65
- // Generate the per-machine keypair if absent. Called lazily by the mint path and by
66
- // the AEGIS dashboard at boot (the authority guarantees the key exists on its box).
97
+ // Generate the per-machine keypair if absent. Called by the AEGIS dashboard at boot: the
98
+ // authority guarantees the key exists on its box. It is NOT called by the mint path.
99
+ //
100
+ // @rule:AEG-HG-2B-007 β€” it refuses on a box that already trusts a public key (the file, or the
101
+ // environment) and has no private key. Such a box is a verifier. Making a key there would
102
+ // replace the authority's key with the box's own, and the box would then accept approvals
103
+ // it signed itself.
67
104
  export function ensureSigningKeypair(): { publicKeyPem: string } {
68
105
  const dir = aegisDir();
69
106
  const keyPath = join(dir, KEY_FILE);
70
107
  const pubPath = join(dir, PUB_FILE);
71
108
 
72
109
  if (!existsSync(keyPath)) {
110
+ if (existsSync(pubPath) || process.env.AEGIS_APPROVAL_PUBKEY_PEM) {
111
+ throw new Error(
112
+ 'AEG-HG-2B-007: this box holds a public approval key and no private key β€” it verifies, it ' +
113
+ 'does not sign. Refusing to generate a keypair over the key it was given.',
114
+ );
115
+ }
73
116
  const { privateKey, publicKey } = generateKeyPairSync('ed25519');
74
117
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
75
- writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
118
+ // 'wx': never write over a key another process made between the check and here.
119
+ writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600, flag: 'wx' });
76
120
  writeFileSync(pubPath, publicKey.export({ type: 'spki', format: 'pem' }), { mode: 0o644 });
77
121
  cache = null;
78
122
  } else if (!existsSync(pubPath)) {
@@ -82,13 +126,14 @@ export function ensureSigningKeypair(): { publicKeyPem: string } {
82
126
  cache = null;
83
127
  }
84
128
 
85
- const { pub } = loadKeys();
86
- return { publicKeyPem: pub!.export({ type: 'spki', format: 'pem' }).toString() };
129
+ const { pub, conflict } = loadKeys();
130
+ if (!pub) throw new Error(`AEG-HG-2B-007: no usable approval signing key${conflict ? ` β€” ${conflict}` : ''}`);
131
+ return { publicKeyPem: pemOf(pub) };
87
132
  }
88
133
 
89
134
  export function getPublicKeyPem(): string | null {
90
135
  const { pub } = loadKeys();
91
- return pub ? pub.export({ type: 'spki', format: 'pem' }).toString() : null;
136
+ return pub ? pemOf(pub) : null;
92
137
  }
93
138
 
94
139
  function b64url(buf: Buffer | string): string {
@@ -97,13 +142,16 @@ function b64url(buf: Buffer | string): string {
97
142
 
98
143
  // Sign a payload as a compact EdDSA JWT: b64url(header).b64url(payload).b64url(sig).
99
144
  // Throws if no private key β€” only the AEGIS box (or a box it provisioned) can mint.
145
+ // It never makes a key: before v0.4.0 it did, on any box, at the first mint. The authority
146
+ // calls ensureSigningKeypair() once, at boot.
100
147
  export function signApprovalJwt(payload: Record<string, unknown>): string {
101
- ensureSigningKeypair();
102
- const { priv } = loadKeys();
148
+ const { priv, conflict } = loadKeys();
103
149
  if (!priv) {
104
150
  throw new Error(
105
151
  'KGT-002: no AEGIS approval signing key β€” cannot mint (fail-closed). ' +
106
- `Expected ${join(aegisDir(), KEY_FILE)}; the AEGIS dashboard (:4850) provisions it at boot.`,
152
+ (conflict ? `${conflict}. ` : '') +
153
+ `Expected ${join(aegisDir(), KEY_FILE)}; the AEGIS dashboard (:4850) provisions it at boot ` +
154
+ 'with ensureSigningKeypair().',
107
155
  );
108
156
  }
109
157
  const header = b64url(JSON.stringify({ alg: APPROVAL_JWT_ALG, typ: 'JWT' }));
@@ -120,6 +168,9 @@ export type JwtVerifyResult =
120
168
  // Fail-closed on every path: malformed, wrong alg (alg:none forgery), bad signature,
121
169
  // or no public key available at all.
122
170
  export function verifyApprovalJwt(token: string): JwtVerifyResult {
171
+ if (typeof token !== 'string' || token.length === 0) {
172
+ return { ok: false, reason: 'malformed token: not text' };
173
+ }
123
174
  const parts = token.split('.');
124
175
  if (parts.length !== 3) {
125
176
  return {
@@ -133,7 +184,9 @@ export function verifyApprovalJwt(token: string): JwtVerifyResult {
133
184
 
134
185
  let alg: unknown;
135
186
  try {
136
- alg = (JSON.parse(Buffer.from(header, 'base64url').toString('utf8')) as { alg?: unknown }).alg;
187
+ const h: unknown = JSON.parse(Buffer.from(header, 'base64url').toString('utf8'));
188
+ if (h === null || typeof h !== 'object' || Array.isArray(h)) throw new Error('not an object');
189
+ alg = (h as { alg?: unknown }).alg;
137
190
  } catch {
138
191
  return { ok: false, reason: 'malformed token: header is not base64url JSON' };
139
192
  }
@@ -141,26 +194,39 @@ export function verifyApprovalJwt(token: string): JwtVerifyResult {
141
194
  return { ok: false, reason: `alg '${String(alg)}' rejected β€” only ${APPROVAL_JWT_ALG} approvals are trusted` };
142
195
  }
143
196
 
144
- const { pub } = loadKeys();
197
+ const { pub, conflict } = loadKeys();
145
198
  if (!pub) {
146
199
  return {
147
200
  ok: false,
148
201
  reason: 'KGT-002: no AEGIS public key available β€” verification fails closed. ' +
202
+ (conflict ? `AEG-HG-2B-007: ${conflict}. ` : '') +
149
203
  'Provision ~/.aegis/approval-signing.pub or set AEGIS_APPROVAL_PUBKEY_PEM ' +
150
204
  '(GET :4850/api/v2/enforcement/signing-key).',
151
205
  };
152
206
  }
153
207
 
208
+ // One approval, one spelling: the signature segment must be exactly the 64 bytes in
209
+ // base64url. The decoder ignores characters it does not know, so without this check
210
+ // "<token>!!" would verify as well, as a second token with a different digest.
211
+ const sigBytes = Buffer.from(sig, 'base64url');
212
+ if (sigBytes.length !== 64 || sigBytes.toString('base64url') !== sig) {
213
+ return { ok: false, reason: 'signature invalid β€” token was not minted by AEGIS' };
214
+ }
215
+
154
216
  let valid = false;
155
217
  try {
156
- valid = edVerify(null, Buffer.from(`${header}.${body}`), pub, Buffer.from(sig, 'base64url'));
218
+ valid = edVerify(null, Buffer.from(`${header}.${body}`), pub, sigBytes);
157
219
  } catch {
158
220
  valid = false;
159
221
  }
160
222
  if (!valid) return { ok: false, reason: 'signature invalid β€” token was not minted by AEGIS' };
161
223
 
162
224
  try {
163
- return { ok: true, payload: JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) };
225
+ const payload: unknown = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
226
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
227
+ return { ok: false, reason: 'malformed token: payload is not a JSON object' };
228
+ }
229
+ return { ok: true, payload: payload as Record<string, unknown> };
164
230
  } catch {
165
231
  return { ok: false, reason: 'malformed token: payload is not base64url JSON' };
166
232
  }