@xshieldai/aegis-guard 0.3.1 β 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 +75 -5
- package/package.json +2 -2
- package/src/approval-token.ts +104 -30
- package/src/envelope.ts +41 -8
- package/src/idempotency.ts +76 -15
- package/src/quality.ts +16 -3
- package/src/sense.ts +27 -6
- package/src/signing.ts +94 -28
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# @xshieldai/aegis-guard
|
|
2
2
|
|
|
3
|
-
> **π Verification status (2026-10-05 IST β v0.
|
|
4
|
-
> - **Tests:** β
**
|
|
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
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
6
|
> - **Live demo:** β οΈ planned (Tier 3, see [PROOF-STACK.md](https://github.com/rocketlang/aegis/blob/master/PROOF-STACK.md))
|
|
7
|
-
> - **
|
|
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
|
|
|
@@ -30,10 +31,30 @@ bun add @xshieldai/aegis-guard
|
|
|
30
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
59
|
import { verifyApprovalToken, verifyScopedApprovalToken } from '@xshieldai/aegis-guard';
|
|
39
60
|
|
|
@@ -47,6 +68,8 @@ 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
|
|
@@ -70,6 +93,8 @@ 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
|
|
@@ -80,9 +105,13 @@ const fp = buildIdempotencyFingerprint({ amount: args.amount, vessel_id: args.ve
|
|
|
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
|
|
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
|
|
@@ -92,6 +121,8 @@ import { verifyAndConsumeNonce } from '@xshieldai/aegis-guard';
|
|
|
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
|
|
@@ -104,8 +135,23 @@ 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:
|
|
@@ -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
|
|
@@ -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)
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xshieldai/aegis-guard",
|
|
3
|
-
"version": "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",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"LICENSE"
|
|
42
42
|
],
|
|
43
43
|
"scripts": {
|
|
44
|
-
"test": "bun test
|
|
44
|
+
"test": "bun test",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"prepublishOnly": "node ../../scripts/publish-guard.mjs"
|
|
47
47
|
},
|
package/src/approval-token.ts
CHANGED
|
@@ -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,
|
|
35
|
-
// a service that cannot sign
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
'AEG-E-016: token
|
|
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
|
|
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 (
|
|
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
|
-
|
|
172
|
+
cap,
|
|
119
173
|
'AEG-E-016: irreversible operation requires nonce for replay prevention',
|
|
120
174
|
);
|
|
121
175
|
}
|
|
122
|
-
|
|
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 (
|
|
191
|
+
if (consumed !== true) {
|
|
125
192
|
throw new IrrNoApprovalError(
|
|
126
|
-
|
|
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 (
|
|
164
|
-
throw new IrrNoApprovalError(expectedCapability, 'AEG-E-016:
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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:
|
|
83
|
-
agent_id: String(data.agent_id ?? data.session_id
|
|
84
|
-
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
}
|
package/src/idempotency.ts
CHANGED
|
@@ -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 β
|
|
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
|
|
26
|
-
|
|
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-${
|
|
34
|
-
event_type:
|
|
35
|
-
|
|
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:
|
|
38
|
-
? `duplicate externalRef ${
|
|
39
|
-
:
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return Buffer.from(JSON.stringify(
|
|
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/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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
39
|
-
|
|
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
|
|
33
|
-
// long-lived services both get one disk read, not one per verify.
|
|
34
|
-
|
|
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():
|
|
49
|
+
function loadKeys(): Keys {
|
|
37
50
|
const dir = aegisDir();
|
|
38
|
-
|
|
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 {
|
|
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
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
}
|