@aamp/protocol 1.1.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/dist/src/agent.d.ts +25 -0
- package/dist/src/agent.js +55 -0
- package/dist/src/constants.d.ts +21 -0
- package/dist/src/constants.js +32 -0
- package/dist/src/crypto.d.ts +8 -0
- package/dist/src/crypto.js +39 -0
- package/dist/src/express.d.ts +20 -0
- package/dist/src/express.js +75 -0
- package/dist/src/index.d.ts +12 -0
- package/dist/src/index.js +30 -0
- package/dist/src/nextjs.d.ts +23 -0
- package/dist/src/nextjs.js +78 -0
- package/dist/src/publisher.d.ts +31 -0
- package/dist/src/publisher.js +84 -0
- package/dist/src/types.d.ts +81 -0
- package/dist/src/types.js +28 -0
- package/dist/test/handshake.spec.d.ts +1 -0
- package/dist/test/handshake.spec.js +53 -0
- package/package.json +24 -0
- package/src/agent.ts +72 -0
- package/src/constants.ts +35 -0
- package/src/crypto.ts +53 -0
- package/src/express.ts +104 -0
- package/src/index.ts +13 -0
- package/src/nextjs.ts +109 -0
- package/src/publisher.ts +107 -0
- package/src/types.ts +98 -0
- package/test/handshake.spec.ts +67 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* Integration Test: Protocol Handshake
|
|
5
|
+
* Run using: npm test
|
|
6
|
+
* Location: sdk/typescript/test/handshake.spec.ts
|
|
7
|
+
*/
|
|
8
|
+
const agent_1 = require("../src/agent");
|
|
9
|
+
const publisher_1 = require("../src/publisher");
|
|
10
|
+
const types_1 = require("../src/types");
|
|
11
|
+
function base64ToUint8Array(base64) {
|
|
12
|
+
const binaryString = atob(base64);
|
|
13
|
+
const len = binaryString.length;
|
|
14
|
+
const bytes = new Uint8Array(len);
|
|
15
|
+
for (let i = 0; i < len; i++) {
|
|
16
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
17
|
+
}
|
|
18
|
+
return bytes;
|
|
19
|
+
}
|
|
20
|
+
async function runTest() {
|
|
21
|
+
console.log("--- STARTING AAMP HANDSHAKE TEST ---");
|
|
22
|
+
// 1. Setup Publisher with Economic Policy
|
|
23
|
+
const publisher = new publisher_1.AAMPPublisher({
|
|
24
|
+
version: '1.1',
|
|
25
|
+
allowTraining: false,
|
|
26
|
+
allowRAG: true,
|
|
27
|
+
attributionRequired: true,
|
|
28
|
+
requiresPayment: true, // Publisher wants money...
|
|
29
|
+
allowAdSupportedAccess: true, // ...OR ads
|
|
30
|
+
paymentPointer: '$wallet.example.com/publisher'
|
|
31
|
+
});
|
|
32
|
+
// 2. Initialize Agent
|
|
33
|
+
const agent = new agent_1.AAMPAgent();
|
|
34
|
+
await agent.initialize();
|
|
35
|
+
// TEST CASE A: Requesting RAG without Ads (Should FAIL due to Payment Requirement)
|
|
36
|
+
console.log("\n[TEST A] Requesting RAG (No Ads)...");
|
|
37
|
+
const reqA = await agent.createAccessRequest('/doc/1', types_1.AccessPurpose.RAG_RETRIEVAL, { adsDisplayed: false });
|
|
38
|
+
// Fix: Cast to any to avoid TS2769 error (Uint8Array vs BufferSource mismatch in ts-node)
|
|
39
|
+
const keyA = await crypto.subtle.importKey("spki", base64ToUint8Array(reqA.publicKey), { name: "ECDSA", namedCurve: "P-256" }, true, ["verify"]);
|
|
40
|
+
const resA = await publisher.verifyRequest(reqA, keyA);
|
|
41
|
+
console.log("Result A (Expect Deny):", resA);
|
|
42
|
+
if (resA.allowed)
|
|
43
|
+
throw new Error("Test A Failed (Should require payment)");
|
|
44
|
+
// TEST CASE B: Requesting RAG WITH Ads (Should SUCCEED via Exemption)
|
|
45
|
+
console.log("\n[TEST B] Requesting RAG (With Ads)...");
|
|
46
|
+
const reqB = await agent.createAccessRequest('/doc/1', types_1.AccessPurpose.RAG_RETRIEVAL, { adsDisplayed: true });
|
|
47
|
+
const resB = await publisher.verifyRequest(reqB, keyA);
|
|
48
|
+
console.log("Result B (Expect Allow):", resB);
|
|
49
|
+
if (!resB.allowed)
|
|
50
|
+
throw new Error("Test B Failed (Should allow via Ad exemption)");
|
|
51
|
+
console.log("\n--- ALL TESTS PASSED ---");
|
|
52
|
+
}
|
|
53
|
+
runTest().catch(console.error);
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aamp/protocol",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "TypeScript reference implementation of AAMP",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "ts-node test/handshake.spec.ts",
|
|
10
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/aamp-protocol/aamp.git"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"typescript": "^5.0.0",
|
|
21
|
+
"ts-node": "^10.9.0",
|
|
22
|
+
"@types/node": "^20.0.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 2: Agent SDK
|
|
3
|
+
*/
|
|
4
|
+
import { AccessPurpose, ProtocolHeader, SignedAccessRequest, FeedbackSignal, QualityFlag } from './types';
|
|
5
|
+
import { generateKeyPair, signData, exportPublicKey } from './crypto';
|
|
6
|
+
import { AAMP_VERSION } from './constants';
|
|
7
|
+
|
|
8
|
+
export interface AccessOptions {
|
|
9
|
+
adsDisplayed?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class AAMPAgent {
|
|
13
|
+
private keyPair: CryptoKeyPair | null = null;
|
|
14
|
+
public agentId: string = "pending";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Initialize the Agent Identity (Ephemeral or Persisted)
|
|
18
|
+
* @param customAgentId Optional persistent ID for this agent. If omitted, generates a session ID.
|
|
19
|
+
*/
|
|
20
|
+
async initialize(customAgentId?: string) {
|
|
21
|
+
this.keyPair = await generateKeyPair();
|
|
22
|
+
// Use the provided ID (authentic) or generate a session ID (ephemeral)
|
|
23
|
+
this.agentId = customAgentId || "agent_" + Math.random().toString(36).substring(7);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async createAccessRequest(
|
|
27
|
+
resource: string,
|
|
28
|
+
purpose: AccessPurpose,
|
|
29
|
+
options: AccessOptions = {}
|
|
30
|
+
): Promise<SignedAccessRequest> {
|
|
31
|
+
if (!this.keyPair) throw new Error("Agent not initialized. Call initialize() first.");
|
|
32
|
+
|
|
33
|
+
const header: ProtocolHeader = {
|
|
34
|
+
v: AAMP_VERSION,
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
agent_id: this.agentId,
|
|
37
|
+
resource,
|
|
38
|
+
purpose,
|
|
39
|
+
context: {
|
|
40
|
+
ads_displayed: options.adsDisplayed || false
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const signature = await signData(this.keyPair.privateKey, JSON.stringify(header));
|
|
45
|
+
const publicKeyExport = await exportPublicKey(this.keyPair.publicKey);
|
|
46
|
+
|
|
47
|
+
return { header, signature, publicKey: publicKeyExport };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* NEW IN V1.1: Quality Feedback Loop
|
|
52
|
+
* Allows the Agent to report spam or verify quality of a resource.
|
|
53
|
+
*/
|
|
54
|
+
async generateFeedback(
|
|
55
|
+
resource: string,
|
|
56
|
+
score: number,
|
|
57
|
+
flags: QualityFlag[]
|
|
58
|
+
): Promise<{ signal: FeedbackSignal, signature: string }> {
|
|
59
|
+
if (!this.keyPair) throw new Error("Agent not initialized.");
|
|
60
|
+
|
|
61
|
+
const signal: FeedbackSignal = {
|
|
62
|
+
target_resource: resource,
|
|
63
|
+
agent_id: this.agentId,
|
|
64
|
+
quality_score: Math.max(0, Math.min(1, score)),
|
|
65
|
+
flags,
|
|
66
|
+
timestamp: new Date().toISOString()
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const signature = await signData(this.keyPair.privateKey, JSON.stringify(signal));
|
|
70
|
+
return { signal, signature };
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1: Protocol Constants
|
|
3
|
+
* These values are immutable and defined by the AAMP Specification.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const AAMP_VERSION = '1.1';
|
|
7
|
+
|
|
8
|
+
// HTTP Headers used for the handshake
|
|
9
|
+
export const HEADERS = {
|
|
10
|
+
// Transport: The signed payload (Base64 encoded JSON of ProtocolHeader)
|
|
11
|
+
PAYLOAD: 'x-aamp-payload',
|
|
12
|
+
// Transport: The cryptographic signature (Hex)
|
|
13
|
+
SIGNATURE: 'x-aamp-signature',
|
|
14
|
+
// Transport: The Agent's Public Key (Base64 SPKI)
|
|
15
|
+
PUBLIC_KEY: 'x-aamp-public-key',
|
|
16
|
+
|
|
17
|
+
// Informational / Legacy (Optional if Payload is present)
|
|
18
|
+
AGENT_ID: 'x-aamp-agent-id',
|
|
19
|
+
TIMESTAMP: 'x-aamp-timestamp',
|
|
20
|
+
ALGORITHM: 'x-aamp-alg',
|
|
21
|
+
|
|
22
|
+
// v1.1 Addition: Provenance (Server to Agent)
|
|
23
|
+
CONTENT_ORIGIN: 'x-aamp-content-origin',
|
|
24
|
+
PROVENANCE_SIG: 'x-aamp-provenance-sig'
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
// Cryptographic Settings
|
|
28
|
+
export const CRYPTO_CONFIG = {
|
|
29
|
+
ALGORITHM_NAME: 'ECDSA',
|
|
30
|
+
CURVE: 'P-256',
|
|
31
|
+
HASH: 'SHA-256',
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
// Tolerance
|
|
35
|
+
export const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; // 5 minutes
|
package/src/crypto.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1: Cryptographic Primitives
|
|
3
|
+
* Implementation of ECDSA P-256 signing/verification.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export async function generateKeyPair(): Promise<CryptoKeyPair> {
|
|
7
|
+
// Uses standard Web Crypto API (Node 19+ compatible)
|
|
8
|
+
return await crypto.subtle.generateKey(
|
|
9
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
10
|
+
true,
|
|
11
|
+
["sign", "verify"]
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function signData(privateKey: CryptoKey, data: string): Promise<string> {
|
|
16
|
+
const encoder = new TextEncoder();
|
|
17
|
+
const encoded = encoder.encode(data);
|
|
18
|
+
const signature = await crypto.subtle.sign(
|
|
19
|
+
{ name: "ECDSA", hash: { name: "SHA-256" } },
|
|
20
|
+
privateKey,
|
|
21
|
+
encoded as any
|
|
22
|
+
);
|
|
23
|
+
return bufToHex(signature);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function verifySignature(publicKey: CryptoKey, data: string, signatureHex: string): Promise<boolean> {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
const encodedData = encoder.encode(data);
|
|
29
|
+
const signatureBytes = hexToBuf(signatureHex);
|
|
30
|
+
|
|
31
|
+
return await crypto.subtle.verify(
|
|
32
|
+
{ name: "ECDSA", hash: { name: "SHA-256" } },
|
|
33
|
+
publicKey,
|
|
34
|
+
signatureBytes as any,
|
|
35
|
+
encodedData as any
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function exportPublicKey(key: CryptoKey): Promise<string> {
|
|
40
|
+
const exported = await crypto.subtle.exportKey("spki", key);
|
|
41
|
+
return btoa(String.fromCharCode(...new Uint8Array(exported)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Helpers
|
|
45
|
+
function bufToHex(buffer: ArrayBuffer): string {
|
|
46
|
+
return Array.from(new Uint8Array(buffer))
|
|
47
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
48
|
+
.join('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hexToBuf(hex: string): Uint8Array {
|
|
52
|
+
return new Uint8Array(hex.match(/.{1,2}/g)!.map(byte => parseInt(byte, 16)));
|
|
53
|
+
}
|
package/src/express.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3: Framework Adapters
|
|
3
|
+
* Zero-friction integration for Express/Node.js.
|
|
4
|
+
*/
|
|
5
|
+
import { AAMPPublisher } from './publisher';
|
|
6
|
+
import { AccessPolicy, ContentOrigin, SignedAccessRequest } from './types';
|
|
7
|
+
import { generateKeyPair, verifySignature } from './crypto';
|
|
8
|
+
import { HEADERS } from './constants';
|
|
9
|
+
|
|
10
|
+
export interface AAMPConfig {
|
|
11
|
+
policy: Omit<AccessPolicy, 'version'>;
|
|
12
|
+
meta: {
|
|
13
|
+
origin: keyof typeof ContentOrigin;
|
|
14
|
+
paymentPointer?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class AAMP {
|
|
19
|
+
private publisher: AAMPPublisher;
|
|
20
|
+
private origin: ContentOrigin;
|
|
21
|
+
private ready: Promise<void>;
|
|
22
|
+
|
|
23
|
+
private constructor(config: AAMPConfig) {
|
|
24
|
+
this.publisher = new AAMPPublisher({
|
|
25
|
+
version: '1.1',
|
|
26
|
+
...config.policy
|
|
27
|
+
} as AccessPolicy);
|
|
28
|
+
|
|
29
|
+
this.origin = ContentOrigin[config.meta.origin];
|
|
30
|
+
|
|
31
|
+
this.ready = generateKeyPair().then(keys => {
|
|
32
|
+
return this.publisher.initialize(keys);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static init(config: AAMPConfig): AAMP {
|
|
37
|
+
return new AAMP(config);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Express Middleware
|
|
42
|
+
*/
|
|
43
|
+
middleware() {
|
|
44
|
+
return async (req: any, res: any, next: any) => {
|
|
45
|
+
await this.ready;
|
|
46
|
+
|
|
47
|
+
// 1. Inject Provenance Headers (Passive Protection)
|
|
48
|
+
const headers = await this.publisher.generateResponseHeaders(this.origin);
|
|
49
|
+
Object.entries(headers).forEach(([k, v]) => {
|
|
50
|
+
res.setHeader(k, v);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// 2. Active Verification (If Agent sends signed headers)
|
|
54
|
+
const payloadHeader = req.headers[HEADERS.PAYLOAD];
|
|
55
|
+
const sigHeader = req.headers[HEADERS.SIGNATURE];
|
|
56
|
+
const keyHeader = req.headers[HEADERS.PUBLIC_KEY];
|
|
57
|
+
|
|
58
|
+
if (payloadHeader && sigHeader && keyHeader) {
|
|
59
|
+
try {
|
|
60
|
+
const headerJson = atob(payloadHeader); // RAW STRING
|
|
61
|
+
const requestHeader = JSON.parse(headerJson);
|
|
62
|
+
|
|
63
|
+
const signedRequest: SignedAccessRequest = {
|
|
64
|
+
header: requestHeader,
|
|
65
|
+
signature: sigHeader,
|
|
66
|
+
publicKey: keyHeader
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const agentKey = await crypto.subtle.importKey(
|
|
70
|
+
"spki",
|
|
71
|
+
new Uint8Array(atob(keyHeader).split('').map(c => c.charCodeAt(0))),
|
|
72
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
73
|
+
true,
|
|
74
|
+
["verify"]
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Pass raw headerJson to ensure signature matches exactly what was signed
|
|
78
|
+
const result = await this.publisher.verifyRequest(signedRequest, agentKey, headerJson);
|
|
79
|
+
|
|
80
|
+
if (!result.allowed) {
|
|
81
|
+
res.status(403).json({ error: result.reason });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Verified!
|
|
86
|
+
req.aamp = { verified: true, ...requestHeader };
|
|
87
|
+
|
|
88
|
+
} catch (e) {
|
|
89
|
+
res.status(400).json({ error: "Invalid AAMP Signature" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
next();
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
discoveryHandler() {
|
|
99
|
+
return (req: any, res: any) => {
|
|
100
|
+
res.setHeader('Content-Type', 'application/json');
|
|
101
|
+
res.send(JSON.stringify(this.publisher.getPolicy(), null, 2));
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AAMP SDK Public API
|
|
3
|
+
*
|
|
4
|
+
* This is the main entry point for the library.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export * from './types';
|
|
8
|
+
export * from './constants';
|
|
9
|
+
export * from './agent';
|
|
10
|
+
export * from './publisher';
|
|
11
|
+
export * from './crypto';
|
|
12
|
+
export * from './express'; // Node.js / Express Adapter
|
|
13
|
+
export { AAMPNext } from './nextjs'; // Serverless / Next.js Adapter
|
package/src/nextjs.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3: Framework Adapters
|
|
3
|
+
* Serverless integration for Next.js (App Router & API Routes).
|
|
4
|
+
*/
|
|
5
|
+
import { AAMPPublisher } from './publisher';
|
|
6
|
+
import { AccessPolicy, ContentOrigin, SignedAccessRequest } from './types';
|
|
7
|
+
import { generateKeyPair } from './crypto';
|
|
8
|
+
import { HEADERS } from './constants';
|
|
9
|
+
|
|
10
|
+
type NextRequest = any;
|
|
11
|
+
type NextResponse = any;
|
|
12
|
+
|
|
13
|
+
const createJsonResponse = (body: any, status = 200) => {
|
|
14
|
+
return new Response(JSON.stringify(body), {
|
|
15
|
+
status,
|
|
16
|
+
headers: { 'Content-Type': 'application/json' }
|
|
17
|
+
});
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export interface AAMPConfig {
|
|
21
|
+
policy: Omit<AccessPolicy, 'version'>;
|
|
22
|
+
meta: {
|
|
23
|
+
origin: keyof typeof ContentOrigin;
|
|
24
|
+
paymentPointer?: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class AAMPNext {
|
|
29
|
+
private publisher: AAMPPublisher;
|
|
30
|
+
private origin: ContentOrigin;
|
|
31
|
+
private ready: Promise<void>;
|
|
32
|
+
|
|
33
|
+
private constructor(config: AAMPConfig) {
|
|
34
|
+
this.publisher = new AAMPPublisher({
|
|
35
|
+
version: '1.1',
|
|
36
|
+
...config.policy
|
|
37
|
+
} as AccessPolicy);
|
|
38
|
+
this.origin = ContentOrigin[config.meta.origin];
|
|
39
|
+
|
|
40
|
+
this.ready = generateKeyPair().then(keys => this.publisher.initialize(keys));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
static init(config: AAMPConfig): AAMPNext {
|
|
44
|
+
return new AAMPNext(config);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serverless Route Wrapper
|
|
49
|
+
*/
|
|
50
|
+
withProtection(handler: (req: NextRequest) => Promise<NextResponse>) {
|
|
51
|
+
return async (req: NextRequest) => {
|
|
52
|
+
await this.ready;
|
|
53
|
+
|
|
54
|
+
// 1. Active Verification
|
|
55
|
+
const payloadHeader = req.headers.get(HEADERS.PAYLOAD);
|
|
56
|
+
const sigHeader = req.headers.get(HEADERS.SIGNATURE);
|
|
57
|
+
const keyHeader = req.headers.get(HEADERS.PUBLIC_KEY);
|
|
58
|
+
|
|
59
|
+
if (payloadHeader && sigHeader && keyHeader) {
|
|
60
|
+
try {
|
|
61
|
+
const headerJson = atob(payloadHeader); // RAW STRING
|
|
62
|
+
const requestHeader = JSON.parse(headerJson);
|
|
63
|
+
|
|
64
|
+
const signedRequest: SignedAccessRequest = {
|
|
65
|
+
header: requestHeader,
|
|
66
|
+
signature: sigHeader,
|
|
67
|
+
publicKey: keyHeader
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const agentKey = await crypto.subtle.importKey(
|
|
71
|
+
"spki",
|
|
72
|
+
new Uint8Array(atob(keyHeader).split('').map(c => c.charCodeAt(0))),
|
|
73
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
74
|
+
true,
|
|
75
|
+
["verify"]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Pass raw headerJson to ensure signature matches exactly what was signed
|
|
79
|
+
const result = await this.publisher.verifyRequest(signedRequest, agentKey, headerJson);
|
|
80
|
+
|
|
81
|
+
if (!result.allowed) {
|
|
82
|
+
return createJsonResponse({ error: result.reason }, 403);
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return createJsonResponse({ error: "Invalid AAMP Signature" }, 400);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// 2. Execute Handler
|
|
90
|
+
const response = await handler(req);
|
|
91
|
+
|
|
92
|
+
// 3. Inject Provenance Headers (Passive)
|
|
93
|
+
const aampHeaders = await this.publisher.generateResponseHeaders(this.origin);
|
|
94
|
+
if (response && response.headers) {
|
|
95
|
+
Object.entries(aampHeaders).forEach(([k, v]) => {
|
|
96
|
+
response.headers.set(k, v);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return response;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
discoveryHandler() {
|
|
105
|
+
return async () => {
|
|
106
|
+
return createJsonResponse(this.publisher.getPolicy());
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/publisher.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 2: Publisher Middleware
|
|
3
|
+
* Used by content owners to enforce policy and log access.
|
|
4
|
+
*/
|
|
5
|
+
import { AccessPolicy, AccessPurpose, SignedAccessRequest, ContentOrigin, FeedbackSignal } from './types';
|
|
6
|
+
import { verifySignature, signData } from './crypto';
|
|
7
|
+
import { MAX_CLOCK_SKEW_MS, HEADERS } from './constants';
|
|
8
|
+
|
|
9
|
+
export interface VerificationResult {
|
|
10
|
+
allowed: boolean;
|
|
11
|
+
reason: string;
|
|
12
|
+
responseHeaders?: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class AAMPPublisher {
|
|
16
|
+
private policy: AccessPolicy;
|
|
17
|
+
private keyPair: CryptoKeyPair | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(policy: AccessPolicy) {
|
|
20
|
+
this.policy = policy;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Publishers now need keys too, to sign their Content Origin declarations
|
|
24
|
+
async initialize(keyPair: CryptoKeyPair) {
|
|
25
|
+
this.keyPair = keyPair;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getPolicy(): AccessPolicy {
|
|
29
|
+
return this.policy;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Verifies an incoming AI access request.
|
|
34
|
+
*
|
|
35
|
+
* @param request The parsed request object
|
|
36
|
+
* @param requestPublicKey The agent's public key
|
|
37
|
+
* @param rawPayload (Optional) The raw string received over the wire. REQUIRED for robust verification.
|
|
38
|
+
*/
|
|
39
|
+
async verifyRequest(
|
|
40
|
+
request: SignedAccessRequest,
|
|
41
|
+
requestPublicKey: CryptoKey,
|
|
42
|
+
rawPayload?: string
|
|
43
|
+
): Promise<VerificationResult> {
|
|
44
|
+
|
|
45
|
+
// 1. Replay Attack Prevention (Timestamp Check)
|
|
46
|
+
const requestTime = new Date(request.header.ts).getTime();
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
if (Math.abs(now - requestTime) > MAX_CLOCK_SKEW_MS) {
|
|
49
|
+
return { allowed: false, reason: 'TIMESTAMP_INVALID: Clock skew exceeded.' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Policy Enforcement (Usage Type)
|
|
53
|
+
// STRICT CHECK: Training
|
|
54
|
+
if (request.header.purpose === AccessPurpose.CRAWL_TRAINING && !this.policy.allowTraining) {
|
|
55
|
+
return { allowed: false, reason: 'POLICY_DENIED: Training not allowed by site owner.' };
|
|
56
|
+
}
|
|
57
|
+
// STRICT CHECK: RAG / Retrieval
|
|
58
|
+
if (request.header.purpose === AccessPurpose.RAG_RETRIEVAL && !this.policy.allowRAG) {
|
|
59
|
+
return { allowed: false, reason: 'POLICY_DENIED: RAG Retrieval not allowed.' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Economic Policy Enforcement
|
|
63
|
+
if (this.policy.requiresPayment) {
|
|
64
|
+
const isExemptViaAds = this.policy.allowAdSupportedAccess && request.header.context.ads_displayed;
|
|
65
|
+
|
|
66
|
+
if (!isExemptViaAds) {
|
|
67
|
+
return {
|
|
68
|
+
allowed: false,
|
|
69
|
+
reason: 'PAYMENT_REQUIRED: Site requires payment or ad-supported access.'
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 4. Cryptographic Verification (The "Proof")
|
|
75
|
+
// CRITICAL: We prefer the rawPayload if available to avoid JSON parsing/stringify mismatches.
|
|
76
|
+
const signableString = rawPayload || JSON.stringify(request.header);
|
|
77
|
+
|
|
78
|
+
const isValid = await verifySignature(requestPublicKey, signableString, request.signature);
|
|
79
|
+
|
|
80
|
+
if (!isValid) {
|
|
81
|
+
return { allowed: false, reason: 'CRYPTO_FAIL: Signature verification failed.' };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { allowed: true, reason: 'OK' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Verifies a Feedback Signal (Spam Report) from an Agent.
|
|
89
|
+
* Part of the AAMP Immune System.
|
|
90
|
+
*/
|
|
91
|
+
async verifyFeedback(signal: FeedbackSignal, signature: string, agentPublicKey: CryptoKey): Promise<boolean> {
|
|
92
|
+
const signableString = JSON.stringify(signal);
|
|
93
|
+
return await verifySignature(agentPublicKey, signableString, signature);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async generateResponseHeaders(origin: ContentOrigin): Promise<Record<string, string>> {
|
|
97
|
+
if (!this.keyPair) throw new Error("Publisher keys not initialized");
|
|
98
|
+
|
|
99
|
+
const payload = JSON.stringify({ origin, ts: Date.now() });
|
|
100
|
+
const signature = await signData(this.keyPair.privateKey, payload);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
[HEADERS.CONTENT_ORIGIN]: origin,
|
|
104
|
+
[HEADERS.PROVENANCE_SIG]: signature
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 1: Protocol Definitions
|
|
3
|
+
* Shared types used by both Agent and Publisher.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export enum AccessPurpose {
|
|
7
|
+
CRAWL_TRAINING = 'CRAWL_TRAINING',
|
|
8
|
+
RAG_RETRIEVAL = 'RAG_RETRIEVAL',
|
|
9
|
+
SUMMARY = 'SUMMARY',
|
|
10
|
+
QUOTATION = 'QUOTATION',
|
|
11
|
+
EMBEDDING = 'EMBEDDING'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export enum ContentOrigin {
|
|
15
|
+
HUMAN = 'HUMAN', // Created by humans. High training value.
|
|
16
|
+
SYNTHETIC = 'SYNTHETIC', // Created by AI. Risk of model collapse.
|
|
17
|
+
HYBRID = 'HYBRID' // Edited by humans, drafted by AI.
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export enum QualityFlag {
|
|
21
|
+
SEO_SPAM = 'SEO_SPAM',
|
|
22
|
+
INACCURATE = 'INACCURATE',
|
|
23
|
+
HATE_SPEECH = 'HATE_SPEECH',
|
|
24
|
+
HIGH_QUALITY = 'HIGH_QUALITY'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Optional Rate Limiting (The Speed Limit)
|
|
29
|
+
* Defines technical boundaries for the handshake.
|
|
30
|
+
*/
|
|
31
|
+
export interface RateLimitConfig {
|
|
32
|
+
requestsPerMinute: number;
|
|
33
|
+
tokensPerMinute?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Optional Monetization (The Settlement Layer)
|
|
38
|
+
*
|
|
39
|
+
* AAMP is neutral. Settlement can happen via:
|
|
40
|
+
* 1. BROKER: A 3rd party clearing house (e.g., "AI-AdSense").
|
|
41
|
+
* 2. CRYPTO: Direct on-chain settlement.
|
|
42
|
+
* 3. TREATY: A private legal contract signed offline (Enterprise).
|
|
43
|
+
*/
|
|
44
|
+
export interface MonetizationConfig {
|
|
45
|
+
method: 'BROKER' | 'CRYPTO' | 'PRIVATE_TREATY';
|
|
46
|
+
/**
|
|
47
|
+
* The destination for settlement.
|
|
48
|
+
* - If BROKER: The API URL of the clearing house.
|
|
49
|
+
* - If CRYPTO: The wallet address.
|
|
50
|
+
* - If TREATY: The Contract ID or "Contact Sales".
|
|
51
|
+
*/
|
|
52
|
+
location: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AccessPolicy {
|
|
56
|
+
version: '1.1';
|
|
57
|
+
allowTraining: boolean;
|
|
58
|
+
allowRAG: boolean;
|
|
59
|
+
attributionRequired: boolean;
|
|
60
|
+
|
|
61
|
+
// Economic Signals
|
|
62
|
+
allowAdSupportedAccess: boolean; // If true, Agents showing ads are exempt from payment
|
|
63
|
+
requiresPayment: boolean; // If true, Access is denied unless Ad-Supported condition is met
|
|
64
|
+
paymentPointer?: string; // @deprecated (Legacy support)
|
|
65
|
+
|
|
66
|
+
// V1.1: Optional Traffic Control
|
|
67
|
+
rateLimit?: RateLimitConfig;
|
|
68
|
+
|
|
69
|
+
// V1.1: Optional Settlement Info
|
|
70
|
+
// If undefined, parties are assumed to have no economic relationship or settled offline.
|
|
71
|
+
monetization?: MonetizationConfig;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ProtocolHeader {
|
|
75
|
+
v: '1.1';
|
|
76
|
+
ts: string;
|
|
77
|
+
agent_id: string;
|
|
78
|
+
resource: string;
|
|
79
|
+
purpose: AccessPurpose;
|
|
80
|
+
// Access Context
|
|
81
|
+
context: {
|
|
82
|
+
ads_displayed: boolean; // Is the AI Agent displaying ads alongside this content?
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SignedAccessRequest {
|
|
87
|
+
header: ProtocolHeader;
|
|
88
|
+
signature: string;
|
|
89
|
+
publicKey?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FeedbackSignal {
|
|
93
|
+
target_resource: string;
|
|
94
|
+
agent_id: string;
|
|
95
|
+
quality_score: number; // 0.0 to 1.0
|
|
96
|
+
flags: QualityFlag[];
|
|
97
|
+
timestamp: string;
|
|
98
|
+
}
|