@apitap/core 1.3.1 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apitap/core",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Intercept web API traffic during browsing. Generate portable skill files so AI agents can call APIs directly instead of scraping.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -53,6 +53,24 @@ export function deriveKey(machineId: string, saltFile?: string): Buffer {
53
53
  }
54
54
  }
55
55
 
56
+ /**
57
+ * Derive a purpose-specific encryption key from the master key.
58
+ * Uses HKDF-like expansion: HMAC-SHA256(masterKey, purpose-string).
59
+ */
60
+ export function deriveEncryptionKey(machineId: string, saltFile?: string): Buffer {
61
+ const master = deriveKey(machineId, saltFile);
62
+ return createHmac('sha256', master).update('apitap-encryption-v1').digest();
63
+ }
64
+
65
+ /**
66
+ * Derive a purpose-specific signing key from the master key.
67
+ * Uses HKDF-like expansion: HMAC-SHA256(masterKey, purpose-string).
68
+ */
69
+ export function deriveSigningKey(machineId: string, saltFile?: string): Buffer {
70
+ const master = deriveKey(machineId, saltFile);
71
+ return createHmac('sha256', master).update('apitap-signing-v1').digest();
72
+ }
73
+
56
74
  /**
57
75
  * Encrypt plaintext using AES-256-GCM.
58
76
  * Each call generates a random IV for semantic security.
@@ -1,7 +1,7 @@
1
1
  // src/auth/manager.ts
2
2
  import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { encrypt, decrypt, deriveKey, type EncryptedData } from './crypto.js';
4
+ import { encrypt, decrypt, deriveKey, deriveEncryptionKey, type EncryptedData } from './crypto.js';
5
5
  import type { StoredAuth, StoredToken, StoredSession } from '../types.js';
6
6
 
7
7
  const AUTH_FILENAME = 'auth.enc';
@@ -12,10 +12,12 @@ const AUTH_FILENAME = 'auth.enc';
12
12
  */
13
13
  export class AuthManager {
14
14
  private key: Buffer;
15
+ private legacyKey: Buffer;
15
16
  private authPath: string;
16
17
 
17
18
  constructor(baseDir: string, machineId: string, saltFile?: string) {
18
- this.key = deriveKey(machineId, saltFile);
19
+ this.key = deriveEncryptionKey(machineId, saltFile);
20
+ this.legacyKey = deriveKey(machineId, saltFile);
19
21
  this.authPath = join(baseDir, AUTH_FILENAME);
20
22
  }
21
23
 
@@ -141,8 +143,13 @@ export class AuthManager {
141
143
  try {
142
144
  const content = await readFile(this.authPath, 'utf-8');
143
145
  const encrypted: EncryptedData = JSON.parse(content);
144
- const plaintext = decrypt(encrypted, this.key);
145
- return JSON.parse(plaintext);
146
+ try {
147
+ // Try new encryption key first
148
+ return JSON.parse(decrypt(encrypted, this.key));
149
+ } catch {
150
+ // Fall back to legacy key (pre-key-separation installs)
151
+ return JSON.parse(decrypt(encrypted, this.legacyKey));
152
+ }
146
153
  } catch {
147
154
  return {};
148
155
  }
@@ -10,7 +10,7 @@ import { verifyEndpoints } from './verifier.js';
10
10
  import { signSkillFile } from '../skill/signing.js';
11
11
  import { writeSkillFile } from '../skill/store.js';
12
12
  import { AuthManager, getMachineId } from '../auth/manager.js';
13
- import { deriveKey } from '../auth/crypto.js';
13
+ import { deriveSigningKey } from '../auth/crypto.js';
14
14
  import { homedir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import type { CapturedExchange, PageSnapshot, PageElement, InteractionResult, FinishResult } from '../types.js';
@@ -204,7 +204,7 @@ export class CaptureSession {
204
204
 
205
205
  // Finalize: generate skill files, verify, sign, write
206
206
  const machineId = await getMachineId();
207
- const key = deriveKey(machineId);
207
+ const key = deriveSigningKey(machineId);
208
208
  const authManager = new AuthManager(APITAP_DIR, machineId);
209
209
 
210
210
  const domains: FinishResult['domains'] = [];
package/src/cli.ts CHANGED
@@ -4,7 +4,7 @@ import { capture } from './capture/monitor.js';
4
4
  import { writeSkillFile, readSkillFile, listSkillFiles } from './skill/store.js';
5
5
  import { replayEndpoint } from './replay/engine.js';
6
6
  import { AuthManager, getMachineId } from './auth/manager.js';
7
- import { deriveKey } from './auth/crypto.js';
7
+ import { deriveSigningKey } from './auth/crypto.js';
8
8
  import { signSkillFile } from './skill/signing.js';
9
9
  import { importSkillFile } from './skill/importer.js';
10
10
  import { resolveAndValidateUrl } from './skill/ssrf.js';
@@ -194,7 +194,7 @@ async function handleCapture(positional: string[], flags: Record<string, string
194
194
 
195
195
  // Get machine ID for signing and auth storage
196
196
  const machineId = await getMachineId();
197
- const key = deriveKey(machineId);
197
+ const key = deriveSigningKey(machineId);
198
198
  const authManager = new AuthManager(APITAP_DIR, machineId);
199
199
 
200
200
  // Write skill files for each domain
@@ -378,7 +378,9 @@ async function handleReplay(positional: string[], flags: Record<string, string |
378
378
  process.exit(1);
379
379
  }
380
380
 
381
- const skill = await readSkillFile(domain, SKILLS_DIR);
381
+ const machineId = await getMachineId();
382
+ const signingKey = deriveSigningKey(machineId);
383
+ const skill = await readSkillFile(domain, SKILLS_DIR, { verifySignature: true, signingKey });
382
384
  if (!skill) {
383
385
  console.error(`Error: No skill file found for "${domain}".`);
384
386
  process.exit(1);
@@ -394,7 +396,6 @@ async function handleReplay(positional: string[], flags: Record<string, string |
394
396
  }
395
397
 
396
398
  // Merge stored auth into endpoint headers for replay
397
- const machineId = await getMachineId();
398
399
  const authManager = new AuthManager(APITAP_DIR, machineId);
399
400
  const storedAuth = await authManager.retrieve(domain);
400
401
 
@@ -446,7 +447,7 @@ async function handleImport(positional: string[], flags: Record<string, string |
446
447
 
447
448
  // Get local key for signature verification
448
449
  const machineId = await getMachineId();
449
- const key = deriveKey(machineId);
450
+ const key = deriveSigningKey(machineId);
450
451
 
451
452
  // DNS-resolving SSRF check before importing (prevents DNS rebinding attacks)
452
453
  try {
@@ -863,9 +864,9 @@ async function handleDiscover(positional: string[], flags: Record<string, string
863
864
  if (save && result.skillFile) {
864
865
  const { writeSkillFile } = await import('./skill/store.js');
865
866
  const { signSkillFile } = await import('./skill/signing.js');
866
- const { deriveKey } = await import('./auth/crypto.js');
867
+ const { deriveSigningKey } = await import('./auth/crypto.js');
867
868
  const machineId = await getMachineId();
868
- const key = deriveKey(machineId);
869
+ const key = deriveSigningKey(machineId);
869
870
 
870
871
  const signed = signSkillFile(result.skillFile, key);
871
872
  const path = await writeSkillFile(signed, SKILLS_DIR);
package/src/mcp.ts CHANGED
@@ -7,6 +7,7 @@ import { searchSkills } from './skill/search.js';
7
7
  import { readSkillFile } from './skill/store.js';
8
8
  import { replayEndpoint } from './replay/engine.js';
9
9
  import { AuthManager, getMachineId } from './auth/manager.js';
10
+ import { deriveSigningKey } from './auth/crypto.js';
10
11
  import { requestAuth } from './auth/handoff.js';
11
12
  import { CaptureSession } from './capture/session.js';
12
13
  import { discover } from './discovery/index.js';
@@ -153,7 +154,9 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
153
154
  },
154
155
  },
155
156
  async ({ domain, endpointId, params, fresh, maxBytes }) => {
156
- const skill = await readSkillFile(domain, skillsDir);
157
+ const machineId = await getMachineId();
158
+ const signingKey = deriveSigningKey(machineId);
159
+ const skill = await readSkillFile(domain, skillsDir, { verifySignature: true, signingKey });
157
160
  if (!skill) {
158
161
  return {
159
162
  content: [{ type: 'text' as const, text: `No skill file found for "${domain}". Use apitap_capture to capture it first.` }],
@@ -170,7 +173,6 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
170
173
  }
171
174
 
172
175
  // Get auth manager for token injection and header auth
173
- const machineId = await getMachineId();
174
176
  const authManager = new AuthManager(APITAP_DIR, machineId);
175
177
 
176
178
  try {
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  import os from 'node:os';
8
8
  import net from 'node:net';
9
9
  import { signSkillFile } from './skill/signing.js';
10
- import { deriveKey } from './auth/crypto.js';
10
+ import { deriveSigningKey } from './auth/crypto.js';
11
11
  import { getMachineId } from './auth/manager.js';
12
12
 
13
13
  const SKILLS_DIR = path.join(os.homedir(), '.apitap', 'skills');
@@ -18,7 +18,7 @@ async function signSkillJson(skillJson: string): Promise<string> {
18
18
  try {
19
19
  const skill = JSON.parse(skillJson);
20
20
  const machineId = await getMachineId();
21
- const key = deriveKey(machineId);
21
+ const key = deriveSigningKey(machineId);
22
22
  const signed = signSkillFile(skill, key);
23
23
  return JSON.stringify(signed);
24
24
  } catch {
@@ -58,6 +58,10 @@ async function recoverDeletedComments(
58
58
  const recovered = new Map<string, { author: string; body: string }>();
59
59
  if (commentIds.length === 0) return recovered;
60
60
 
61
+ // Third-party disclosure: PullPush is an external service.
62
+ // Users can opt out via APITAP_NO_THIRD_PARTY=1.
63
+ if (process.env.APITAP_NO_THIRD_PARTY === '1') return recovered;
64
+
61
65
  try {
62
66
  const ids = commentIds.join(',');
63
67
  const ppUrl = `https://api.pullpush.io/reddit/search/comment/?ids=${ids}`;
@@ -569,17 +569,20 @@ export async function replayMultiple(
569
569
 
570
570
  const { readSkillFile } = await import('../skill/store.js');
571
571
  const { AuthManager, getMachineId } = await import('../auth/manager.js');
572
+ const { deriveSigningKey } = await import('../auth/crypto.js');
573
+
574
+ const machineId = await getMachineId();
575
+ const signingKey = deriveSigningKey(machineId);
572
576
 
573
577
  // Deduplicate skill file reads
574
578
  const skillCache = new Map<string, SkillFile | null>();
575
579
  const uniqueDomains = [...new Set(requests.map(r => r.domain))];
576
580
  await Promise.all(uniqueDomains.map(async (domain) => {
577
- const skill = await readSkillFile(domain, options.skillsDir);
581
+ const skill = await readSkillFile(domain, options.skillsDir, { verifySignature: true, signingKey });
578
582
  skillCache.set(domain, skill);
579
583
  }));
580
584
 
581
585
  // Shared auth manager
582
- const machineId = await getMachineId();
583
586
  const authManager = new AuthManager(
584
587
  (await import('node:os')).homedir() + '/.apitap',
585
588
  machineId,
@@ -3,6 +3,7 @@ import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import type { SkillFile, SkillSummary } from '../types.js';
6
+ import { validateSkillFile } from './validate.js';
6
7
 
7
8
  const DEFAULT_SKILLS_DIR = join(homedir(), '.apitap', 'skills');
8
9
 
@@ -52,7 +53,8 @@ export async function readSkillFile(
52
53
  const path = skillPath(domain, skillsDir);
53
54
  try {
54
55
  const content = await readFile(path, 'utf-8');
55
- const skill = JSON.parse(content) as SkillFile;
56
+ const raw = JSON.parse(content);
57
+ const skill = validateSkillFile(raw);
56
58
 
57
59
  // If verification requested, check signature
58
60
  if (options?.verifySignature && options.signingKey) {
@@ -60,8 +62,9 @@ export async function readSkillFile(
60
62
  // Imported files had foreign signature stripped — can't verify, warn only
61
63
  // Future: re-sign on import with local key
62
64
  } else if (!skill.signature) {
63
- // No signature present on non-imported file
64
- throw new Error(`Skill file for ${domain} has no signature — file may be tampered`);
65
+ // Phase 2: warn for unsigned files, don't reject
66
+ // (user-created or pre-signing files are legitimately unsigned)
67
+ console.error(`[apitap] Warning: skill file for ${domain} is unsigned`);
65
68
  } else {
66
69
  const { verifySignature } = await import('./signing.js');
67
70
  if (!verifySignature(skill, options.signingKey)) {
@@ -0,0 +1,72 @@
1
+ // src/skill/validate.ts
2
+ import type { SkillFile } from '../types.js';
3
+ import { validateUrl } from './ssrf.js';
4
+
5
+ const ALLOWED_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']);
6
+
7
+ /**
8
+ * Validate a parsed JSON object as a SkillFile.
9
+ * Throws on invalid input — fail fast, fail loud.
10
+ * SSRF checks are optional here (default: off) because the replay engine
11
+ * already enforces SSRF at request time with DNS resolution.
12
+ */
13
+ export function validateSkillFile(raw: unknown, options?: { checkSsrf?: boolean }): SkillFile {
14
+ if (!raw || typeof raw !== 'object') {
15
+ throw new Error('Skill file must be an object');
16
+ }
17
+ const obj = raw as Record<string, unknown>;
18
+
19
+ // domain
20
+ if (typeof obj.domain !== 'string' || obj.domain.length === 0 || obj.domain.length > 253) {
21
+ throw new Error('Invalid domain: must be a string of 1-253 characters');
22
+ }
23
+
24
+ // baseUrl — must be a valid URL; SSRF checked at replay time
25
+ if (typeof obj.baseUrl !== 'string') {
26
+ throw new Error('Missing baseUrl');
27
+ }
28
+ try {
29
+ const url = new URL(obj.baseUrl);
30
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
31
+ throw new Error('non-HTTP scheme');
32
+ }
33
+ } catch {
34
+ throw new Error(`Invalid baseUrl: must be a valid HTTP(S) URL`);
35
+ }
36
+ if (options?.checkSsrf) {
37
+ const ssrf = validateUrl(obj.baseUrl);
38
+ if (!ssrf.safe) {
39
+ throw new Error(`Unsafe baseUrl: ${ssrf.reason}`);
40
+ }
41
+ }
42
+
43
+ // endpoints
44
+ if (!Array.isArray(obj.endpoints)) {
45
+ throw new Error('Missing or invalid endpoints array');
46
+ }
47
+ if (obj.endpoints.length > 500) {
48
+ throw new Error('Too many endpoints (max 500)');
49
+ }
50
+
51
+ for (let i = 0; i < obj.endpoints.length; i++) {
52
+ const ep = obj.endpoints[i];
53
+ if (!ep || typeof ep !== 'object') {
54
+ throw new Error(`Endpoint ${i}: must be an object`);
55
+ }
56
+ const e = ep as Record<string, unknown>;
57
+ if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 200) {
58
+ throw new Error(`Endpoint ${i}: id must be a string of 1-200 characters`);
59
+ }
60
+ if (typeof e.method !== 'string' || !ALLOWED_METHODS.has(e.method)) {
61
+ throw new Error(`Endpoint ${i}: method must be one of ${[...ALLOWED_METHODS].join(', ')}`);
62
+ }
63
+ if (typeof e.path !== 'string' || !e.path.startsWith('/')) {
64
+ throw new Error(`Endpoint ${i}: path must start with /`);
65
+ }
66
+ if (e.path.length > 2000) {
67
+ throw new Error(`Endpoint ${i}: path exceeds 2000 characters`);
68
+ }
69
+ }
70
+
71
+ return raw as SkillFile;
72
+ }