@xshieldai/aegis-guard 0.2.0 → 0.3.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,5 +1,11 @@
1
1
  # @rocketlang/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
8
+
3
9
  AEGIS Guard SDK — reusable approval-token, nonce, idempotency, SENSE, and quality-evidence primitives for AEGIS-governed services.
4
10
 
5
11
  **Carbonx proved the locks. Batch 93 makes the locks reusable.**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xshieldai/aegis-guard",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -42,7 +42,8 @@
42
42
  ],
43
43
  "scripts": {
44
44
  "test": "bun test tests/aegis-guard.test.ts",
45
- "typecheck": "tsc --noEmit"
45
+ "typecheck": "tsc --noEmit",
46
+ "prepublishOnly": "node ../../scripts/publish-guard.mjs"
46
47
  },
47
48
  "devDependencies": {
48
49
  "typescript": "^5.4.0",
@@ -6,6 +6,7 @@ import { createHash } from 'crypto';
6
6
  import { IrrNoApprovalError } from './errors.js';
7
7
  import { type NonceStore, defaultNonceStore } from './nonce.js';
8
8
  import { emitAccReceipt } from './acc-bus.js';
9
+ import { signApprovalJwt, verifyApprovalJwt } from './signing.js';
9
10
 
10
11
  // Token may arrive up to 60s before local clock (NTP tolerance).
11
12
  const CLOCK_SKEW_MS = 60_000;
@@ -28,14 +29,18 @@ export function digestApprovalToken(token: string): string {
28
29
  return createHash('sha256').update(token).digest('hex').slice(0, 24);
29
30
  }
30
31
 
31
- // @rule:AEG-E-016 — test/dev helper: encode a payload as base64url JSON.
32
- // Production tokens are minted by the AEGIS PROOF system (port 4850), not by services.
32
+ // @rule:AEG-E-016 @rule:KGT-002 — mint an AEGIS-signed EdDSA JWT (KGT-T1.1).
33
+ // 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.
33
36
  export function mintApprovalToken(payload: ApprovalTokenPayload): string {
34
- return Buffer.from(JSON.stringify(payload)).toString('base64url');
37
+ return signApprovalJwt(payload);
35
38
  }
36
39
 
37
40
  // @rule:AEG-E-016 — token must match service_id + capability + operation exactly.
38
- // Token format: base64url(JSON). Production replacement: JWT signed by AEGIS key at port 4850.
41
+ // @rule:KGT-002 — signature verified FIRST, fail-closed: unsigned/tampered/alg:none/
42
+ // wrong-key tokens are rejected before any scope check. Legacy base64url(JSON) tokens
43
+ // (pre-KGT-T1.1, forgeable) are rejected outright.
39
44
  export function verifyApprovalToken(
40
45
  token: string,
41
46
  expectedServiceId: string,
@@ -45,13 +50,11 @@ export function verifyApprovalToken(
45
50
  // @rule:ACC-003 @rule:ACC-004 — emit ACC receipt on success OR failure
46
51
  const scope = `${expectedServiceId}/${expectedCapability}/${expectedOperation}`;
47
52
  try {
48
- let payload: ApprovalTokenPayload;
49
- try {
50
- const decoded = Buffer.from(token, 'base64url').toString('utf8');
51
- payload = JSON.parse(decoded) as ApprovalTokenPayload;
52
- } catch {
53
- throw new IrrNoApprovalError(expectedCapability, 'token could not be decoded');
53
+ const jwt = verifyApprovalJwt(token);
54
+ if (!jwt.ok) {
55
+ throw new IrrNoApprovalError(expectedCapability, `KGT-002: ${jwt.reason}`);
54
56
  }
57
+ const payload = jwt.payload as ApprovalTokenPayload;
55
58
 
56
59
  if (payload.service_id !== expectedServiceId) {
57
60
  throw new IrrNoApprovalError(
package/src/index.ts CHANGED
@@ -48,6 +48,19 @@ export {
48
48
  verifyScopedApprovalToken,
49
49
  } from './approval-token.js';
50
50
 
51
+ // @rule:KGT-002 — KGT-T1.1 (v0.3.0): approval tokens are AEGIS-signed EdDSA JWTs.
52
+ // ensureSigningKeypair is called by the AEGIS dashboard at boot (minting authority);
53
+ // verifier-only boxes pin the public key via ~/.aegis/approval-signing.pub or
54
+ // AEGIS_APPROVAL_PUBKEY_PEM (served at :4850 /api/v2/enforcement/signing-key).
55
+ export {
56
+ APPROVAL_JWT_ALG,
57
+ type JwtVerifyResult,
58
+ ensureSigningKeypair,
59
+ getPublicKeyPem,
60
+ signApprovalJwt,
61
+ verifyApprovalJwt,
62
+ } from './signing.js';
63
+
51
64
  export {
52
65
  type IssueEnvelopeParams,
53
66
  type EnvelopeIssueResult,
package/src/signing.ts ADDED
@@ -0,0 +1,172 @@
1
+ // @rule:AEG-E-016 — approval tokens are AEGIS-signed; an unsigned approval is a forgery
2
+ // @rule:KGT-002 — fail-closed: no key material → verification DENIES, never falls open
3
+ //
4
+ // KGT-T1.1 — Ed25519 JWT signing for approval tokens.
5
+ // Before this module, mintApprovalToken emitted base64url(JSON): any client could
6
+ // fabricate an "approval". Now AEGIS (:4850, the minting authority) holds the private
7
+ // key; every verifier needs only the public key. Same key-management pattern as
8
+ // kernel/mudrika.ts: persistent per-machine key material under ~/.aegis, mode 600,
9
+ // generated once. Zero-dep — node:crypto ed25519 only.
10
+
11
+ import {
12
+ generateKeyPairSync,
13
+ createPrivateKey,
14
+ createPublicKey,
15
+ sign as edSign,
16
+ verify as edVerify,
17
+ type KeyObject,
18
+ } from 'crypto';
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
20
+ import { join } from 'path';
21
+ import { homedir } from 'os';
22
+
23
+ export const APPROVAL_JWT_ALG = 'EdDSA';
24
+
25
+ const KEY_FILE = 'approval-signing.key'; // PKCS8 PEM (private) — mode 600
26
+ const PUB_FILE = 'approval-signing.pub'; // SPKI PEM (public) — mode 644
27
+
28
+ function aegisDir(): string {
29
+ return process.env.AEGIS_DIR ?? join(process.env.HOME ?? homedir(), '.aegis');
30
+ }
31
+
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;
35
+
36
+ function loadKeys(): { priv: KeyObject | null; pub: KeyObject | null } {
37
+ const dir = aegisDir();
38
+ if (cache && cache.dir === dir) return cache;
39
+
40
+ let priv: KeyObject | null = null;
41
+ let pub: KeyObject | null = null;
42
+
43
+ const keyPath = join(dir, KEY_FILE);
44
+ if (existsSync(keyPath)) {
45
+ try {
46
+ priv = createPrivateKey(readFileSync(keyPath, 'utf8'));
47
+ pub = createPublicKey(priv);
48
+ } catch { priv = null; pub = null; }
49
+ }
50
+
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; }
59
+ }
60
+
61
+ cache = { dir, priv, pub };
62
+ return cache;
63
+ }
64
+
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).
67
+ export function ensureSigningKeypair(): { publicKeyPem: string } {
68
+ const dir = aegisDir();
69
+ const keyPath = join(dir, KEY_FILE);
70
+ const pubPath = join(dir, PUB_FILE);
71
+
72
+ if (!existsSync(keyPath)) {
73
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
74
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
75
+ writeFileSync(keyPath, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600 });
76
+ writeFileSync(pubPath, publicKey.export({ type: 'spki', format: 'pem' }), { mode: 0o644 });
77
+ cache = null;
78
+ } else if (!existsSync(pubPath)) {
79
+ // key exists but pub was lost — re-derive, never regenerate (verifiers may hold the pub)
80
+ const priv = createPrivateKey(readFileSync(keyPath, 'utf8'));
81
+ writeFileSync(pubPath, createPublicKey(priv).export({ type: 'spki', format: 'pem' }), { mode: 0o644 });
82
+ cache = null;
83
+ }
84
+
85
+ const { pub } = loadKeys();
86
+ return { publicKeyPem: pub!.export({ type: 'spki', format: 'pem' }).toString() };
87
+ }
88
+
89
+ export function getPublicKeyPem(): string | null {
90
+ const { pub } = loadKeys();
91
+ return pub ? pub.export({ type: 'spki', format: 'pem' }).toString() : null;
92
+ }
93
+
94
+ function b64url(buf: Buffer | string): string {
95
+ return Buffer.from(buf).toString('base64url');
96
+ }
97
+
98
+ // Sign a payload as a compact EdDSA JWT: b64url(header).b64url(payload).b64url(sig).
99
+ // Throws if no private key — only the AEGIS box (or a box it provisioned) can mint.
100
+ export function signApprovalJwt(payload: Record<string, unknown>): string {
101
+ ensureSigningKeypair();
102
+ const { priv } = loadKeys();
103
+ if (!priv) {
104
+ throw new Error(
105
+ '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.`,
107
+ );
108
+ }
109
+ const header = b64url(JSON.stringify({ alg: APPROVAL_JWT_ALG, typ: 'JWT' }));
110
+ const body = b64url(JSON.stringify(payload));
111
+ const sig = edSign(null, Buffer.from(`${header}.${body}`), priv);
112
+ return `${header}.${body}.${b64url(sig)}`;
113
+ }
114
+
115
+ export type JwtVerifyResult =
116
+ | { ok: true; payload: Record<string, unknown> }
117
+ | { ok: false; reason: string };
118
+
119
+ // Verify signature + structure ONLY (scope/expiry stay in approval-token.ts).
120
+ // Fail-closed on every path: malformed, wrong alg (alg:none forgery), bad signature,
121
+ // or no public key available at all.
122
+ export function verifyApprovalJwt(token: string): JwtVerifyResult {
123
+ const parts = token.split('.');
124
+ if (parts.length !== 3) {
125
+ return {
126
+ ok: false,
127
+ reason: parts.length === 1
128
+ ? 'unsigned legacy token rejected — approval tokens are AEGIS-signed JWTs (KGT-T1.1)'
129
+ : 'malformed token: expected 3 JWT segments',
130
+ };
131
+ }
132
+ const [header, body, sig] = parts;
133
+
134
+ let alg: unknown;
135
+ try {
136
+ alg = (JSON.parse(Buffer.from(header, 'base64url').toString('utf8')) as { alg?: unknown }).alg;
137
+ } catch {
138
+ return { ok: false, reason: 'malformed token: header is not base64url JSON' };
139
+ }
140
+ if (alg !== APPROVAL_JWT_ALG) {
141
+ return { ok: false, reason: `alg '${String(alg)}' rejected — only ${APPROVAL_JWT_ALG} approvals are trusted` };
142
+ }
143
+
144
+ const { pub } = loadKeys();
145
+ if (!pub) {
146
+ return {
147
+ ok: false,
148
+ reason: 'KGT-002: no AEGIS public key available — verification fails closed. ' +
149
+ 'Provision ~/.aegis/approval-signing.pub or set AEGIS_APPROVAL_PUBKEY_PEM ' +
150
+ '(GET :4850/api/v2/enforcement/signing-key).',
151
+ };
152
+ }
153
+
154
+ let valid = false;
155
+ try {
156
+ valid = edVerify(null, Buffer.from(`${header}.${body}`), pub, Buffer.from(sig, 'base64url'));
157
+ } catch {
158
+ valid = false;
159
+ }
160
+ if (!valid) return { ok: false, reason: 'signature invalid — token was not minted by AEGIS' };
161
+
162
+ try {
163
+ return { ok: true, payload: JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) };
164
+ } catch {
165
+ return { ok: false, reason: 'malformed token: payload is not base64url JSON' };
166
+ }
167
+ }
168
+
169
+ // test-only: drop the key cache so a test can swap AEGIS_DIR mid-suite
170
+ export function __resetSigningCache(): void {
171
+ cache = null;
172
+ }