@aamp/protocol 1.1.2 → 1.1.4
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/dist/agent.d.ts +7 -2
- package/dist/agent.js +15 -1
- package/dist/constants.d.ts +1 -0
- package/dist/constants.js +4 -1
- package/dist/express.d.ts +3 -1
- package/dist/express.js +28 -36
- package/dist/nextjs.d.ts +3 -1
- package/dist/nextjs.js +15 -30
- package/dist/publisher.d.ts +19 -20
- package/dist/publisher.js +210 -41
- package/dist/types.d.ts +30 -17
- package/package.json +23 -23
- package/src/agent.ts +87 -71
- package/src/constants.ts +38 -34
- package/src/crypto.ts +68 -68
- package/src/express.ts +94 -103
- package/src/index.ts +12 -12
- package/src/nextjs.ts +91 -108
- package/src/publisher.ts +306 -106
- package/src/types.ts +112 -97
- package/test/handshake.spec.ts +62 -66
- package/tsconfig.json +14 -14
package/dist/agent.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Layer 2: Agent SDK
|
|
3
3
|
*/
|
|
4
|
-
import { AccessPurpose, SignedAccessRequest, FeedbackSignal, QualityFlag } from './types';
|
|
4
|
+
import { AccessPurpose, SignedAccessRequest, FeedbackSignal, QualityFlag, AgentIdentityManifest } from './types';
|
|
5
5
|
export interface AccessOptions {
|
|
6
6
|
adsDisplayed?: boolean;
|
|
7
7
|
}
|
|
@@ -10,10 +10,15 @@ export declare class AAMPAgent {
|
|
|
10
10
|
agentId: string;
|
|
11
11
|
/**
|
|
12
12
|
* Initialize the Agent Identity (Ephemeral or Persisted)
|
|
13
|
-
* @param customAgentId
|
|
13
|
+
* @param customAgentId For PRODUCTION, this should be your domain (e.g., "bot.openai.com")
|
|
14
14
|
*/
|
|
15
15
|
initialize(customAgentId?: string): Promise<void>;
|
|
16
16
|
createAccessRequest(resource: string, purpose: AccessPurpose, options?: AccessOptions): Promise<SignedAccessRequest>;
|
|
17
|
+
/**
|
|
18
|
+
* Helper: Generate the JSON file you must host on your domain
|
|
19
|
+
* Host this at: https://{agentId}/.well-known/aamp-agent.json
|
|
20
|
+
*/
|
|
21
|
+
getIdentityManifest(contactEmail?: string): Promise<AgentIdentityManifest>;
|
|
17
22
|
/**
|
|
18
23
|
* NEW IN V1.1: Quality Feedback Loop
|
|
19
24
|
* Allows the Agent to report spam or verify quality of a resource.
|
package/dist/agent.js
CHANGED
|
@@ -10,7 +10,7 @@ class AAMPAgent {
|
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
12
|
* Initialize the Agent Identity (Ephemeral or Persisted)
|
|
13
|
-
* @param customAgentId
|
|
13
|
+
* @param customAgentId For PRODUCTION, this should be your domain (e.g., "bot.openai.com")
|
|
14
14
|
*/
|
|
15
15
|
async initialize(customAgentId) {
|
|
16
16
|
this.keyPair = await (0, crypto_1.generateKeyPair)();
|
|
@@ -34,6 +34,20 @@ class AAMPAgent {
|
|
|
34
34
|
const publicKeyExport = await (0, crypto_1.exportPublicKey)(this.keyPair.publicKey);
|
|
35
35
|
return { header, signature, publicKey: publicKeyExport };
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Helper: Generate the JSON file you must host on your domain
|
|
39
|
+
* Host this at: https://{agentId}/.well-known/aamp-agent.json
|
|
40
|
+
*/
|
|
41
|
+
async getIdentityManifest(contactEmail) {
|
|
42
|
+
if (!this.keyPair)
|
|
43
|
+
throw new Error("Agent not initialized.");
|
|
44
|
+
const publicKey = await (0, crypto_1.exportPublicKey)(this.keyPair.publicKey);
|
|
45
|
+
return {
|
|
46
|
+
agent_id: this.agentId,
|
|
47
|
+
public_key: publicKey,
|
|
48
|
+
contact_email: contactEmail
|
|
49
|
+
};
|
|
50
|
+
}
|
|
37
51
|
/**
|
|
38
52
|
* NEW IN V1.1: Quality Feedback Loop
|
|
39
53
|
* Allows the Agent to report spam or verify quality of a resource.
|
package/dist/constants.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* These values are immutable and defined by the AAMP Specification.
|
|
4
4
|
*/
|
|
5
5
|
export declare const AAMP_VERSION = "1.1";
|
|
6
|
+
export declare const WELL_KNOWN_AGENT_PATH = "/.well-known/aamp-agent.json";
|
|
6
7
|
export declare const HEADERS: {
|
|
7
8
|
readonly PAYLOAD: "x-aamp-payload";
|
|
8
9
|
readonly SIGNATURE: "x-aamp-signature";
|
package/dist/constants.js
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* These values are immutable and defined by the AAMP Specification.
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.MAX_CLOCK_SKEW_MS = exports.CRYPTO_CONFIG = exports.HEADERS = exports.AAMP_VERSION = void 0;
|
|
7
|
+
exports.MAX_CLOCK_SKEW_MS = exports.CRYPTO_CONFIG = exports.HEADERS = exports.WELL_KNOWN_AGENT_PATH = exports.AAMP_VERSION = void 0;
|
|
8
8
|
exports.AAMP_VERSION = '1.1';
|
|
9
|
+
// The path where Agents MUST host their public key to prove identity.
|
|
10
|
+
// Example: https://bot.openai.com/.well-known/aamp-agent.json
|
|
11
|
+
exports.WELL_KNOWN_AGENT_PATH = '/.well-known/aamp-agent.json';
|
|
9
12
|
// HTTP Headers used for the handshake
|
|
10
13
|
exports.HEADERS = {
|
|
11
14
|
// Transport: The signed payload (Base64 encoded JSON of ProtocolHeader)
|
package/dist/express.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { AccessPolicy, ContentOrigin } from './types';
|
|
1
|
+
import { AccessPolicy, ContentOrigin, UnauthenticatedStrategy, IdentityCache } from './types';
|
|
2
2
|
export interface AAMPConfig {
|
|
3
3
|
policy: Omit<AccessPolicy, 'version'>;
|
|
4
4
|
meta: {
|
|
5
5
|
origin: keyof typeof ContentOrigin;
|
|
6
6
|
paymentPointer?: string;
|
|
7
7
|
};
|
|
8
|
+
strategy?: UnauthenticatedStrategy;
|
|
9
|
+
cache?: IdentityCache;
|
|
8
10
|
}
|
|
9
11
|
export declare class AAMP {
|
|
10
12
|
private publisher;
|
package/dist/express.js
CHANGED
|
@@ -8,13 +8,9 @@ exports.AAMP = void 0;
|
|
|
8
8
|
const publisher_1 = require("./publisher");
|
|
9
9
|
const types_1 = require("./types");
|
|
10
10
|
const crypto_1 = require("./crypto");
|
|
11
|
-
const constants_1 = require("./constants");
|
|
12
11
|
class AAMP {
|
|
13
12
|
constructor(config) {
|
|
14
|
-
this.publisher = new publisher_1.AAMPPublisher({
|
|
15
|
-
version: '1.1',
|
|
16
|
-
...config.policy
|
|
17
|
-
});
|
|
13
|
+
this.publisher = new publisher_1.AAMPPublisher({ version: '1.1', ...config.policy }, config.strategy || 'PASSIVE', config.cache);
|
|
18
14
|
this.origin = types_1.ContentOrigin[config.meta.origin];
|
|
19
15
|
this.ready = (0, crypto_1.generateKeyPair)().then(keys => {
|
|
20
16
|
return this.publisher.initialize(keys);
|
|
@@ -29,39 +25,35 @@ class AAMP {
|
|
|
29
25
|
middleware() {
|
|
30
26
|
return async (req, res, next) => {
|
|
31
27
|
await this.ready;
|
|
32
|
-
//
|
|
33
|
-
const headers =
|
|
34
|
-
Object.
|
|
35
|
-
|
|
28
|
+
// Normalize headers to lowercase dictionary
|
|
29
|
+
const headers = {};
|
|
30
|
+
Object.keys(req.headers).forEach(key => {
|
|
31
|
+
headers[key.toLowerCase()] = req.headers[key];
|
|
36
32
|
});
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
};
|
|
50
|
-
const agentKey = await crypto.subtle.importKey("spki", new Uint8Array(atob(keyHeader).split('').map(c => c.charCodeAt(0))), { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
|
|
51
|
-
// Pass raw headerJson to ensure signature matches exactly what was signed
|
|
52
|
-
const result = await this.publisher.verifyRequest(signedRequest, agentKey, headerJson);
|
|
53
|
-
if (!result.allowed) {
|
|
54
|
-
res.status(403).json({ error: result.reason });
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
// Verified!
|
|
58
|
-
req.aamp = { verified: true, ...requestHeader };
|
|
59
|
-
}
|
|
60
|
-
catch (e) {
|
|
61
|
-
res.status(400).json({ error: "Invalid AAMP Signature" });
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
33
|
+
// Retrieve Raw Payload if available (optional but good for crypto)
|
|
34
|
+
// Note: Express body parsing might interfere, so we usually rely on the header content.
|
|
35
|
+
const rawPayload = headers['x-aamp-payload'];
|
|
36
|
+
// Evaluate Visitor
|
|
37
|
+
const result = await this.publisher.evaluateVisitor(headers, rawPayload);
|
|
38
|
+
// Enforce Decision
|
|
39
|
+
if (!result.allowed) {
|
|
40
|
+
res.status(result.status).json({
|
|
41
|
+
error: result.reason,
|
|
42
|
+
visitor_type: result.visitorType
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
64
45
|
}
|
|
46
|
+
// Inject Provenance Headers (For the humans/agents that got through)
|
|
47
|
+
const respHeaders = await this.publisher.generateResponseHeaders(this.origin);
|
|
48
|
+
Object.entries(respHeaders).forEach(([k, v]) => {
|
|
49
|
+
res.setHeader(k, v);
|
|
50
|
+
});
|
|
51
|
+
// Attach metadata to request for downstream use
|
|
52
|
+
req.aamp = {
|
|
53
|
+
verified: result.visitorType === 'VERIFIED_AGENT',
|
|
54
|
+
type: result.visitorType,
|
|
55
|
+
...result.metadata
|
|
56
|
+
};
|
|
65
57
|
next();
|
|
66
58
|
};
|
|
67
59
|
}
|
package/dist/nextjs.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AccessPolicy, ContentOrigin } from './types';
|
|
1
|
+
import { AccessPolicy, ContentOrigin, UnauthenticatedStrategy, IdentityCache } from './types';
|
|
2
2
|
type NextRequest = any;
|
|
3
3
|
type NextResponse = any;
|
|
4
4
|
export interface AAMPConfig {
|
|
@@ -7,6 +7,8 @@ export interface AAMPConfig {
|
|
|
7
7
|
origin: keyof typeof ContentOrigin;
|
|
8
8
|
paymentPointer?: string;
|
|
9
9
|
};
|
|
10
|
+
strategy?: UnauthenticatedStrategy;
|
|
11
|
+
cache?: IdentityCache;
|
|
10
12
|
}
|
|
11
13
|
export declare class AAMPNext {
|
|
12
14
|
private publisher;
|
package/dist/nextjs.js
CHANGED
|
@@ -8,7 +8,6 @@ exports.AAMPNext = void 0;
|
|
|
8
8
|
const publisher_1 = require("./publisher");
|
|
9
9
|
const types_1 = require("./types");
|
|
10
10
|
const crypto_1 = require("./crypto");
|
|
11
|
-
const constants_1 = require("./constants");
|
|
12
11
|
const createJsonResponse = (body, status = 200) => {
|
|
13
12
|
return new Response(JSON.stringify(body), {
|
|
14
13
|
status,
|
|
@@ -17,10 +16,7 @@ const createJsonResponse = (body, status = 200) => {
|
|
|
17
16
|
};
|
|
18
17
|
class AAMPNext {
|
|
19
18
|
constructor(config) {
|
|
20
|
-
this.publisher = new publisher_1.AAMPPublisher({
|
|
21
|
-
version: '1.1',
|
|
22
|
-
...config.policy
|
|
23
|
-
});
|
|
19
|
+
this.publisher = new publisher_1.AAMPPublisher({ version: '1.1', ...config.policy }, config.strategy || 'PASSIVE', config.cache);
|
|
24
20
|
this.origin = types_1.ContentOrigin[config.meta.origin];
|
|
25
21
|
this.ready = (0, crypto_1.generateKeyPair)().then(keys => this.publisher.initialize(keys));
|
|
26
22
|
}
|
|
@@ -33,33 +29,22 @@ class AAMPNext {
|
|
|
33
29
|
withProtection(handler) {
|
|
34
30
|
return async (req) => {
|
|
35
31
|
await this.ready;
|
|
36
|
-
//
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
};
|
|
49
|
-
const agentKey = await crypto.subtle.importKey("spki", new Uint8Array(atob(keyHeader).split('').map(c => c.charCodeAt(0))), { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
|
|
50
|
-
// Pass raw headerJson to ensure signature matches exactly what was signed
|
|
51
|
-
const result = await this.publisher.verifyRequest(signedRequest, agentKey, headerJson);
|
|
52
|
-
if (!result.allowed) {
|
|
53
|
-
return createJsonResponse({ error: result.reason }, 403);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
catch (e) {
|
|
57
|
-
return createJsonResponse({ error: "Invalid AAMP Signature" }, 400);
|
|
58
|
-
}
|
|
32
|
+
// Extract Headers map
|
|
33
|
+
const headers = {};
|
|
34
|
+
req.headers.forEach((value, key) => {
|
|
35
|
+
headers[key.toLowerCase()] = value;
|
|
36
|
+
});
|
|
37
|
+
// Evaluate
|
|
38
|
+
const result = await this.publisher.evaluateVisitor(headers, headers['x-aamp-payload']);
|
|
39
|
+
if (!result.allowed) {
|
|
40
|
+
return createJsonResponse({
|
|
41
|
+
error: result.reason,
|
|
42
|
+
visitor_type: result.visitorType
|
|
43
|
+
}, result.status);
|
|
59
44
|
}
|
|
60
|
-
//
|
|
45
|
+
// Execute Handler
|
|
61
46
|
const response = await handler(req);
|
|
62
|
-
//
|
|
47
|
+
// Inject Provenance
|
|
63
48
|
const aampHeaders = await this.publisher.generateResponseHeaders(this.origin);
|
|
64
49
|
if (response && response.headers) {
|
|
65
50
|
Object.entries(aampHeaders).forEach(([k, v]) => {
|
package/dist/publisher.d.ts
CHANGED
|
@@ -1,31 +1,30 @@
|
|
|
1
|
-
|
|
2
|
-
* Layer 2: Publisher Middleware
|
|
3
|
-
* Used by content owners to enforce policy and log access.
|
|
4
|
-
*/
|
|
5
|
-
import { AccessPolicy, SignedAccessRequest, ContentOrigin, FeedbackSignal } from './types';
|
|
6
|
-
export interface VerificationResult {
|
|
7
|
-
allowed: boolean;
|
|
8
|
-
reason: string;
|
|
9
|
-
responseHeaders?: Record<string, string>;
|
|
10
|
-
}
|
|
1
|
+
import { AccessPolicy, ContentOrigin, EvaluationResult, IdentityCache, UnauthenticatedStrategy } from './types';
|
|
11
2
|
export declare class AAMPPublisher {
|
|
12
3
|
private policy;
|
|
13
4
|
private keyPair;
|
|
14
|
-
|
|
5
|
+
private unauthenticatedStrategy;
|
|
6
|
+
private cache;
|
|
7
|
+
private readonly CACHE_TTL_SECONDS;
|
|
8
|
+
constructor(policy: AccessPolicy, strategy?: UnauthenticatedStrategy, cacheImpl?: IdentityCache);
|
|
15
9
|
initialize(keyPair: CryptoKeyPair): Promise<void>;
|
|
16
10
|
getPolicy(): AccessPolicy;
|
|
17
11
|
/**
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
*
|
|
12
|
+
* Main Entry Point: Evaluate ANY visitor (Human, Bot, or Agent)
|
|
13
|
+
*/
|
|
14
|
+
evaluateVisitor(reqHeaders: Record<string, string | undefined>, rawPayload?: string): Promise<EvaluationResult>;
|
|
15
|
+
/**
|
|
16
|
+
* Browser Heuristics (Hardened)
|
|
17
|
+
* 1. Checks Known Bot Signatures (Fast Fail)
|
|
18
|
+
* 2. Checks Trusted Upstream Signals (Cloudflare/Vercel)
|
|
19
|
+
* 3. Checks Browser Header Consistency
|
|
23
20
|
*/
|
|
24
|
-
|
|
21
|
+
private performBrowserHeuristics;
|
|
25
22
|
/**
|
|
26
|
-
*
|
|
27
|
-
* Part of the AAMP Immune System.
|
|
23
|
+
* Handle AAMP Protocol Logic
|
|
28
24
|
*/
|
|
29
|
-
|
|
25
|
+
private handleAgent;
|
|
26
|
+
private verifyRequestLogic;
|
|
27
|
+
private verifyDnsBinding;
|
|
28
|
+
private isDomain;
|
|
30
29
|
generateResponseHeaders(origin: ContentOrigin): Promise<Record<string, string>>;
|
|
31
30
|
}
|
package/dist/publisher.js
CHANGED
|
@@ -3,17 +3,45 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.AAMPPublisher = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* Layer 2: Publisher Middleware
|
|
6
|
-
* Used by content owners to enforce policy and
|
|
6
|
+
* Used by content owners to enforce policy, log access, and filter bots.
|
|
7
7
|
*/
|
|
8
|
-
const types_1 = require("./types");
|
|
9
|
-
const crypto_1 = require("./crypto");
|
|
10
8
|
const constants_1 = require("./constants");
|
|
9
|
+
const crypto_1 = require("./crypto");
|
|
10
|
+
const types_1 = require("./types");
|
|
11
|
+
/**
|
|
12
|
+
* Default In-Memory Cache (Fallback only)
|
|
13
|
+
* NOT recommended for high-traffic Serverless production.
|
|
14
|
+
*/
|
|
15
|
+
class MemoryCache {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.store = new Map();
|
|
18
|
+
}
|
|
19
|
+
async get(key) {
|
|
20
|
+
const item = this.store.get(key);
|
|
21
|
+
if (!item)
|
|
22
|
+
return null;
|
|
23
|
+
if (Date.now() > item.exp) {
|
|
24
|
+
this.store.delete(key);
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return item.val;
|
|
28
|
+
}
|
|
29
|
+
async set(key, value, ttlSeconds) {
|
|
30
|
+
this.store.set(key, {
|
|
31
|
+
val: value,
|
|
32
|
+
exp: Date.now() + (ttlSeconds * 1000)
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
11
36
|
class AAMPPublisher {
|
|
12
|
-
constructor(policy) {
|
|
37
|
+
constructor(policy, strategy = 'PASSIVE', cacheImpl) {
|
|
13
38
|
this.keyPair = null;
|
|
39
|
+
// Default TTL: 1 Hour
|
|
40
|
+
this.CACHE_TTL_SECONDS = 3600;
|
|
14
41
|
this.policy = policy;
|
|
42
|
+
this.unauthenticatedStrategy = strategy;
|
|
43
|
+
this.cache = cacheImpl || new MemoryCache();
|
|
15
44
|
}
|
|
16
|
-
// Publishers now need keys too, to sign their Content Origin declarations
|
|
17
45
|
async initialize(keyPair) {
|
|
18
46
|
this.keyPair = keyPair;
|
|
19
47
|
}
|
|
@@ -21,54 +49,195 @@ class AAMPPublisher {
|
|
|
21
49
|
return this.policy;
|
|
22
50
|
}
|
|
23
51
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* @param request The parsed request object
|
|
27
|
-
* @param requestPublicKey The agent's public key
|
|
28
|
-
* @param rawPayload (Optional) The raw string received over the wire. REQUIRED for robust verification.
|
|
52
|
+
* Main Entry Point: Evaluate ANY visitor (Human, Bot, or Agent)
|
|
29
53
|
*/
|
|
30
|
-
async
|
|
31
|
-
// 1.
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
return
|
|
54
|
+
async evaluateVisitor(reqHeaders, rawPayload) {
|
|
55
|
+
// 1. Check for AAMP Headers
|
|
56
|
+
const hasAamp = reqHeaders[constants_1.HEADERS.PAYLOAD] && reqHeaders[constants_1.HEADERS.SIGNATURE] && reqHeaders[constants_1.HEADERS.PUBLIC_KEY];
|
|
57
|
+
if (hasAamp) {
|
|
58
|
+
// It claims to be an Agent. Verify it.
|
|
59
|
+
return await this.handleAgent(reqHeaders, rawPayload);
|
|
36
60
|
}
|
|
37
|
-
// 2.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
61
|
+
// 2. It's not an AAMP Agent. Apply Strategy.
|
|
62
|
+
if (this.unauthenticatedStrategy === 'STRICT') {
|
|
63
|
+
return {
|
|
64
|
+
allowed: false,
|
|
65
|
+
status: 401,
|
|
66
|
+
reason: "STRICT_MODE: Only AAMP verified agents allowed.",
|
|
67
|
+
visitorType: 'UNIDENTIFIED_BOT'
|
|
68
|
+
};
|
|
41
69
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
if (this.unauthenticatedStrategy === 'PASSIVE') {
|
|
71
|
+
return {
|
|
72
|
+
allowed: true,
|
|
73
|
+
status: 200,
|
|
74
|
+
reason: "PASSIVE_MODE: Allowed without verification.",
|
|
75
|
+
visitorType: 'LIKELY_HUMAN'
|
|
76
|
+
};
|
|
45
77
|
}
|
|
46
|
-
// 3.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
78
|
+
// 3. HYBRID MODE: Heuristic Analysis (The "Lazy Bot" Filter)
|
|
79
|
+
const isHuman = this.performBrowserHeuristics(reqHeaders);
|
|
80
|
+
if (isHuman) {
|
|
81
|
+
return {
|
|
82
|
+
allowed: true,
|
|
83
|
+
status: 200,
|
|
84
|
+
reason: "BROWSER_VERIFIED: Heuristics passed.",
|
|
85
|
+
visitorType: 'LIKELY_HUMAN'
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
return {
|
|
90
|
+
allowed: false,
|
|
91
|
+
status: 403,
|
|
92
|
+
reason: "BOT_DETECTED: Request lacks browser signatures and AAMP headers.",
|
|
93
|
+
visitorType: 'UNIDENTIFIED_BOT'
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Browser Heuristics (Hardened)
|
|
99
|
+
* 1. Checks Known Bot Signatures (Fast Fail)
|
|
100
|
+
* 2. Checks Trusted Upstream Signals (Cloudflare/Vercel)
|
|
101
|
+
* 3. Checks Browser Header Consistency
|
|
102
|
+
*/
|
|
103
|
+
performBrowserHeuristics(headers) {
|
|
104
|
+
const userAgent = headers['user-agent'] || '';
|
|
105
|
+
// A. The "Obvious Bot" Blocklist (Fast Fail)
|
|
106
|
+
const botSignatures = ['python-requests', 'curl', 'wget', 'scrapy', 'bot', 'crawler', 'spider'];
|
|
107
|
+
// Exception: Googlebot (if you want SEO). We'll treat Googlebot as a bot,
|
|
108
|
+
// real implementations might white-list it via IP verification (not possible in just JS headers).
|
|
109
|
+
if (botSignatures.some(sig => userAgent.toLowerCase().includes(sig))) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// B. Trusted Infrastructure Signals (The Real World Solution)
|
|
113
|
+
// If Cloudflare or Vercel says "This is a real user", we trust them.
|
|
114
|
+
// Cloudflare: 'cf-visitor' exists. 'cf-ipcountry' exists.
|
|
115
|
+
if (headers['cf-visitor'] || headers['cf-ray'])
|
|
116
|
+
return true;
|
|
117
|
+
// Vercel: 'x-vercel-id'
|
|
118
|
+
if (headers['x-vercel-id'])
|
|
119
|
+
return true;
|
|
120
|
+
// AWS CloudFront: 'cloudfront-viewer-address'
|
|
121
|
+
if (headers['cloudfront-viewer-address'])
|
|
122
|
+
return true;
|
|
123
|
+
// C. The "Browser Fingerprint" (Fallback for direct connections)
|
|
124
|
+
// Real browsers almost always send these headers
|
|
125
|
+
const hasAcceptLanguage = !!headers['accept-language'];
|
|
126
|
+
const hasSecFetchDest = !!headers['sec-fetch-dest'];
|
|
127
|
+
const hasUpgradeInsecure = !!headers['upgrade-insecure-requests'];
|
|
128
|
+
// If it has typical browser headers, we allow it.
|
|
129
|
+
if (hasAcceptLanguage && (hasSecFetchDest || hasUpgradeInsecure)) {
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
// If it has no browser headers and no trusted proxy headers -> It's likely a script.
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Handle AAMP Protocol Logic
|
|
137
|
+
*/
|
|
138
|
+
async handleAgent(reqHeaders, rawPayload) {
|
|
139
|
+
try {
|
|
140
|
+
const payloadHeader = reqHeaders[constants_1.HEADERS.PAYLOAD];
|
|
141
|
+
const sigHeader = reqHeaders[constants_1.HEADERS.SIGNATURE];
|
|
142
|
+
const keyHeader = reqHeaders[constants_1.HEADERS.PUBLIC_KEY];
|
|
143
|
+
const headerJson = atob(payloadHeader);
|
|
144
|
+
const requestHeader = JSON.parse(headerJson);
|
|
145
|
+
const signedRequest = {
|
|
146
|
+
header: requestHeader,
|
|
147
|
+
signature: sigHeader,
|
|
148
|
+
publicKey: keyHeader
|
|
149
|
+
};
|
|
150
|
+
const agentKey = await crypto.subtle.importKey("spki", new Uint8Array(atob(keyHeader).split('').map(c => c.charCodeAt(0))), { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
|
|
151
|
+
// Verify Core Logic
|
|
152
|
+
const result = await this.verifyRequestLogic(signedRequest, agentKey, headerJson);
|
|
153
|
+
if (!result.allowed) {
|
|
50
154
|
return {
|
|
51
155
|
allowed: false,
|
|
52
|
-
|
|
156
|
+
status: 403,
|
|
157
|
+
reason: result.reason,
|
|
158
|
+
visitorType: 'VERIFIED_AGENT'
|
|
53
159
|
};
|
|
54
160
|
}
|
|
161
|
+
return {
|
|
162
|
+
allowed: true,
|
|
163
|
+
status: 200,
|
|
164
|
+
reason: "AAMP_VERIFIED",
|
|
165
|
+
visitorType: 'VERIFIED_AGENT',
|
|
166
|
+
metadata: requestHeader
|
|
167
|
+
};
|
|
55
168
|
}
|
|
56
|
-
|
|
57
|
-
|
|
169
|
+
catch (e) {
|
|
170
|
+
return { allowed: false, status: 400, reason: "INVALID_SIGNATURE", visitorType: 'UNIDENTIFIED_BOT' };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async verifyRequestLogic(request, requestPublicKey, rawPayload) {
|
|
174
|
+
// 1. Replay Attack Prevention
|
|
175
|
+
const requestTime = new Date(request.header.ts).getTime();
|
|
176
|
+
if (Math.abs(Date.now() - requestTime) > constants_1.MAX_CLOCK_SKEW_MS) {
|
|
177
|
+
return { allowed: false, reason: 'TIMESTAMP_INVALID', identityVerified: false };
|
|
178
|
+
}
|
|
179
|
+
// 2. Crypto Verification
|
|
58
180
|
const signableString = rawPayload || JSON.stringify(request.header);
|
|
59
|
-
const
|
|
60
|
-
if (!
|
|
61
|
-
return { allowed: false, reason: 'CRYPTO_FAIL:
|
|
181
|
+
const isCryptoValid = await (0, crypto_1.verifySignature)(requestPublicKey, signableString, request.signature);
|
|
182
|
+
if (!isCryptoValid)
|
|
183
|
+
return { allowed: false, reason: 'CRYPTO_FAIL', identityVerified: false };
|
|
184
|
+
// 3. Identity Verification (DNS Binding) with Cache
|
|
185
|
+
let identityVerified = false;
|
|
186
|
+
const claimedDomain = request.header.agent_id;
|
|
187
|
+
const pubKeyString = await (0, crypto_1.exportPublicKey)(requestPublicKey);
|
|
188
|
+
// Check Cache First
|
|
189
|
+
const cachedKey = await this.cache.get(claimedDomain);
|
|
190
|
+
if (cachedKey === pubKeyString) {
|
|
191
|
+
identityVerified = true;
|
|
192
|
+
}
|
|
193
|
+
else if (this.isDomain(claimedDomain)) {
|
|
194
|
+
// Cache Miss: Perform DNS Fetch
|
|
195
|
+
identityVerified = await this.verifyDnsBinding(claimedDomain, pubKeyString);
|
|
196
|
+
if (identityVerified) {
|
|
197
|
+
await this.cache.set(claimedDomain, pubKeyString, this.CACHE_TTL_SECONDS);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (this.policy.requireIdentityBinding && !identityVerified) {
|
|
201
|
+
return { allowed: false, reason: 'IDENTITY_FAIL: DNS Binding could not be verified.', identityVerified: false };
|
|
202
|
+
}
|
|
203
|
+
// 4. Policy Check: Purpose
|
|
204
|
+
if (request.header.purpose === types_1.AccessPurpose.CRAWL_TRAINING && !this.policy.allowTraining) {
|
|
205
|
+
return { allowed: false, reason: 'POLICY_DENIED: Training not allowed.', identityVerified };
|
|
206
|
+
}
|
|
207
|
+
if (request.header.purpose === types_1.AccessPurpose.RAG_RETRIEVAL && !this.policy.allowRAG) {
|
|
208
|
+
return { allowed: false, reason: 'POLICY_DENIED: RAG not allowed.', identityVerified };
|
|
209
|
+
}
|
|
210
|
+
// 5. Policy Check: Economics
|
|
211
|
+
if (this.policy.requiresPayment) {
|
|
212
|
+
const isAdExempt = this.policy.allowAdSupportedAccess && request.header.context.ads_displayed;
|
|
213
|
+
if (!isAdExempt) {
|
|
214
|
+
return { allowed: false, reason: 'PAYMENT_REQUIRED: Content requires payment or ads.', identityVerified };
|
|
215
|
+
}
|
|
62
216
|
}
|
|
63
|
-
return { allowed: true, reason: 'OK' };
|
|
217
|
+
return { allowed: true, reason: 'OK', identityVerified };
|
|
64
218
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
219
|
+
async verifyDnsBinding(domain, requestKeySpki) {
|
|
220
|
+
try {
|
|
221
|
+
// Allow HTTP for localhost testing
|
|
222
|
+
const protocol = (domain.includes('localhost') || domain.match(/:\d+$/)) ? 'http' : 'https';
|
|
223
|
+
const url = `${protocol}://${domain}${constants_1.WELL_KNOWN_AGENT_PATH}`;
|
|
224
|
+
// In production, we need a short timeout to prevent hanging
|
|
225
|
+
const controller = new AbortController();
|
|
226
|
+
const timeoutId = setTimeout(() => controller.abort(), 1500); // 1.5s max for DNS check
|
|
227
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
228
|
+
clearTimeout(timeoutId);
|
|
229
|
+
if (!response.ok)
|
|
230
|
+
return false;
|
|
231
|
+
const manifest = await response.json();
|
|
232
|
+
return manifest.agent_id === domain && manifest.public_key === requestKeySpki;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
isDomain(s) {
|
|
239
|
+
// Basic regex, allows localhost with ports
|
|
240
|
+
return /^[a-zA-Z0-9.-]+(:\d+)?$/.test(s) || /^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(s);
|
|
72
241
|
}
|
|
73
242
|
async generateResponseHeaders(origin) {
|
|
74
243
|
if (!this.keyPair)
|