@atp-protocol/spec 1.0.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 +28 -0
- package/schemas/decision.schema.json +36 -0
- package/schemas/intent.schema.json +60 -0
- package/schemas/receipt.schema.json +138 -0
- package/src/index.mjs +74 -0
- package/src/replay.mjs +70 -0
- package/src/signing.mjs +128 -0
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@atp-protocol/spec",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "ATP protocol constants and JSON schemas.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "James Everest <james@transientintelligence.com> (https://transientintelligence.com)",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.mjs",
|
|
10
|
+
"./schemas/intent": "./schemas/intent.schema.json",
|
|
11
|
+
"./schemas/decision": "./schemas/decision.schema.json",
|
|
12
|
+
"./schemas/receipt": "./schemas/receipt.schema.json"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src",
|
|
16
|
+
"schemas"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test ./test/smoke.test.mjs"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"base64-url": "^2.3.3",
|
|
26
|
+
"canonicalize": "^2.1.0"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.transientintelligence.com/atp/decision.schema.json",
|
|
4
|
+
"title": "ATP Decision",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"decision_id",
|
|
8
|
+
"intent_id",
|
|
9
|
+
"outcome",
|
|
10
|
+
"reason_code",
|
|
11
|
+
"decided_at"
|
|
12
|
+
],
|
|
13
|
+
"properties": {
|
|
14
|
+
"decision_id": {
|
|
15
|
+
"type": "string",
|
|
16
|
+
"pattern": "^TD-\\d+$"
|
|
17
|
+
},
|
|
18
|
+
"intent_id": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"pattern": "^TI-\\d+$"
|
|
21
|
+
},
|
|
22
|
+
"outcome": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"enum": ["allow", "approve", "deny"]
|
|
25
|
+
},
|
|
26
|
+
"reason_code": {
|
|
27
|
+
"type": "string",
|
|
28
|
+
"minLength": 1
|
|
29
|
+
},
|
|
30
|
+
"decided_at": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"format": "date-time"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"additionalProperties": true
|
|
36
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.transientintelligence.com/atp/intent.schema.json",
|
|
4
|
+
"title": "ATP Intent",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"intent_id",
|
|
8
|
+
"actor_id",
|
|
9
|
+
"connector",
|
|
10
|
+
"action",
|
|
11
|
+
"action_class",
|
|
12
|
+
"target",
|
|
13
|
+
"context",
|
|
14
|
+
"governance_profile",
|
|
15
|
+
"requested_at"
|
|
16
|
+
],
|
|
17
|
+
"properties": {
|
|
18
|
+
"intent_id": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"pattern": "^TI-\\d+$"
|
|
21
|
+
},
|
|
22
|
+
"metadata": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"description": "OPTIONAL. Open namespace for implementation-specific intent key-value pairs.",
|
|
25
|
+
"maxProperties": 20,
|
|
26
|
+
"additionalProperties": true
|
|
27
|
+
},
|
|
28
|
+
"actor_id": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"minLength": 1
|
|
31
|
+
},
|
|
32
|
+
"connector": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"minLength": 1
|
|
35
|
+
},
|
|
36
|
+
"action": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"minLength": 1
|
|
39
|
+
},
|
|
40
|
+
"action_class": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"minLength": 1
|
|
43
|
+
},
|
|
44
|
+
"target": {
|
|
45
|
+
"type": "object"
|
|
46
|
+
},
|
|
47
|
+
"context": {
|
|
48
|
+
"type": "object"
|
|
49
|
+
},
|
|
50
|
+
"governance_profile": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"minLength": 1
|
|
53
|
+
},
|
|
54
|
+
"requested_at": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"format": "date-time"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"additionalProperties": true
|
|
60
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.transientintelligence.com/atp/receipt.schema.json",
|
|
4
|
+
"title": "ATP Receipt",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": [
|
|
7
|
+
"schemaVersion",
|
|
8
|
+
"receipt_id",
|
|
9
|
+
"intent_id",
|
|
10
|
+
"decision_id",
|
|
11
|
+
"execution_status",
|
|
12
|
+
"captured_at",
|
|
13
|
+
"signature",
|
|
14
|
+
"occurred_at",
|
|
15
|
+
"received_at",
|
|
16
|
+
"sealed_at",
|
|
17
|
+
"event_snapshot",
|
|
18
|
+
"event_snapshot_hash",
|
|
19
|
+
"correlation_id",
|
|
20
|
+
"intent",
|
|
21
|
+
"decision"
|
|
22
|
+
],
|
|
23
|
+
"properties": {
|
|
24
|
+
"schemaVersion": {
|
|
25
|
+
"const": "1.0.0"
|
|
26
|
+
},
|
|
27
|
+
"metadata": {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"description": "OPTIONAL. Open namespace for implementation-specific, vendor, or domain-extensibility key-value pairs (e.g. trace_id, environment_name, token_counts).",
|
|
30
|
+
"maxProperties": 20,
|
|
31
|
+
"additionalProperties": true
|
|
32
|
+
},
|
|
33
|
+
"receipt_id": {
|
|
34
|
+
"type": "string",
|
|
35
|
+
"pattern": "^TR-\\d+$"
|
|
36
|
+
},
|
|
37
|
+
"intent_id": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"pattern": "^TI-\\d+$"
|
|
40
|
+
},
|
|
41
|
+
"decision_id": {
|
|
42
|
+
"type": "string",
|
|
43
|
+
"pattern": "^TD-\\d+$"
|
|
44
|
+
},
|
|
45
|
+
"execution_status": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": ["executed", "blocked", "expired", "error"]
|
|
48
|
+
},
|
|
49
|
+
"captured_at": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"format": "date-time"
|
|
52
|
+
},
|
|
53
|
+
"signature": {
|
|
54
|
+
"oneOf": [
|
|
55
|
+
{
|
|
56
|
+
"type": "object",
|
|
57
|
+
"required": ["alg", "kid", "sig", "canonicalization"],
|
|
58
|
+
"properties": {
|
|
59
|
+
"alg": { "type": "string", "enum": ["Ed25519"] },
|
|
60
|
+
"version": { "type": "string" },
|
|
61
|
+
"kid": { "type": "string", "minLength": 1 },
|
|
62
|
+
"sig": { "type": "string", "minLength": 1 },
|
|
63
|
+
"canonicalization": { "const": "RFC8785-JCS" }
|
|
64
|
+
},
|
|
65
|
+
"additionalProperties": true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"type": "string",
|
|
69
|
+
"pattern": "^sha256:[a-f0-9]{64}$",
|
|
70
|
+
"description": "Legacy hash-only signature. Implementations SHOULD migrate to Ed25519 object form."
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
},
|
|
74
|
+
"occurred_at": {
|
|
75
|
+
"type": "string",
|
|
76
|
+
"format": "date-time"
|
|
77
|
+
},
|
|
78
|
+
"received_at": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"format": "date-time"
|
|
81
|
+
},
|
|
82
|
+
"sealed_at": {
|
|
83
|
+
"type": "string",
|
|
84
|
+
"format": "date-time"
|
|
85
|
+
},
|
|
86
|
+
"event_snapshot": {
|
|
87
|
+
"type": "object",
|
|
88
|
+
"minProperties": 1
|
|
89
|
+
},
|
|
90
|
+
"event_snapshot_hash": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
93
|
+
},
|
|
94
|
+
"correlation_id": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"minLength": 1
|
|
97
|
+
},
|
|
98
|
+
"input_hash": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"description": "RECOMMENDED. Hash of canonicalized input payload. Implementations SHOULD use this instead of embedding raw input.",
|
|
101
|
+
"required": ["alg", "digest"],
|
|
102
|
+
"properties": {
|
|
103
|
+
"alg": { "type": "string", "enum": ["sha256"] },
|
|
104
|
+
"digest": { "type": "string", "minLength": 1 }
|
|
105
|
+
},
|
|
106
|
+
"additionalProperties": false
|
|
107
|
+
},
|
|
108
|
+
"output_hash": {
|
|
109
|
+
"type": "object",
|
|
110
|
+
"description": "RECOMMENDED. Hash of canonicalized output payload. Implementations SHOULD use this instead of embedding raw output.",
|
|
111
|
+
"required": ["alg", "digest"],
|
|
112
|
+
"properties": {
|
|
113
|
+
"alg": { "type": "string", "enum": ["sha256"] },
|
|
114
|
+
"digest": { "type": "string", "minLength": 1 }
|
|
115
|
+
},
|
|
116
|
+
"additionalProperties": false
|
|
117
|
+
},
|
|
118
|
+
"cost": {
|
|
119
|
+
"type": "object",
|
|
120
|
+
"description": "OPTIONAL. Financial or resource cost attributed to this action.",
|
|
121
|
+
"required": ["amount", "currency"],
|
|
122
|
+
"properties": {
|
|
123
|
+
"amount": { "type": "string", "description": "Decimal string to avoid floating-point ambiguity." },
|
|
124
|
+
"currency": { "type": "string", "description": "ISO 4217 code or protocol-specific token symbol (e.g. USD, EUR, BTC, FLW)." },
|
|
125
|
+
"unit": { "type": "string" },
|
|
126
|
+
"payer": { "type": "string", "description": "Actor funding the action." }
|
|
127
|
+
},
|
|
128
|
+
"additionalProperties": false
|
|
129
|
+
},
|
|
130
|
+
"intent": {
|
|
131
|
+
"$ref": "https://schemas.transientintelligence.com/atp/intent.schema.json"
|
|
132
|
+
},
|
|
133
|
+
"decision": {
|
|
134
|
+
"$ref": "https://schemas.transientintelligence.com/atp/decision.schema.json"
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"additionalProperties": true
|
|
138
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
|
|
7
|
+
export const ATP_PROTOCOL = "ATP";
|
|
8
|
+
export const ATP_VERSION = "1.0";
|
|
9
|
+
|
|
10
|
+
export const ATP_SIGNING_MODES = Object.freeze({
|
|
11
|
+
ED25519: "Ed25519",
|
|
12
|
+
LEGACY_SHA256: "legacy-sha256"
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export const ATP_DEPRECATED = Object.freeze({
|
|
16
|
+
LEGACY_SHA256_SIGNATURE: {
|
|
17
|
+
code: "receipt_deprecated_legacy_signature",
|
|
18
|
+
message:
|
|
19
|
+
"sha256: string signature is deprecated. Implementations MUST migrate to Ed25519 object form. This form will not be accepted in ATP 2.0."
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const ATP_ID_PATTERNS = Object.freeze({
|
|
24
|
+
receipt: "^TR-\\d+$",
|
|
25
|
+
intent: "^TI-\\d+$",
|
|
26
|
+
decision: "^TD-\\d+$"
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const ATP_DECISION_OUTCOMES = Object.freeze(["allow", "approve", "deny"]);
|
|
30
|
+
export const ATP_EXECUTION_STATUSES = Object.freeze(["executed", "blocked", "expired", "error"]);
|
|
31
|
+
|
|
32
|
+
export const ATP_RECEIPT_VALIDATION_CODES = Object.freeze({
|
|
33
|
+
MISSING_REQUIRED_FIELD: "receipt_missing_required_field",
|
|
34
|
+
MISSING_REQUIRED_OBJECT: "receipt_missing_required_object",
|
|
35
|
+
INVALID_RECEIPT_ID_FORMAT: "receipt_invalid_id_format",
|
|
36
|
+
INVALID_INTENT_ID_FORMAT: "intent_invalid_id_format",
|
|
37
|
+
INVALID_DECISION_ID_FORMAT: "decision_invalid_id_format",
|
|
38
|
+
INTENT_ID_MISMATCH: "receipt_intent_id_mismatch",
|
|
39
|
+
DECISION_ID_MISMATCH: "receipt_decision_id_mismatch",
|
|
40
|
+
INVALID_SCHEMA_VERSION: "receipt_invalid_schema_version",
|
|
41
|
+
INVALID_SIGNATURE_FORMAT: "receipt_invalid_signature_format",
|
|
42
|
+
SIGNATURE_VERIFICATION_FAILED: "receipt_signature_verification_failed",
|
|
43
|
+
INVALID_DATETIME_FORMAT: "receipt_invalid_datetime_format",
|
|
44
|
+
INVALID_CAPTURED_AT: "receipt_invalid_captured_at",
|
|
45
|
+
INVALID_EXECUTION_STATUS: "receipt_invalid_execution_status",
|
|
46
|
+
INVALID_DECISION_OUTCOME: "decision_invalid_outcome",
|
|
47
|
+
INVALID_SNAPSHOT_HASH: "receipt_invalid_snapshot_hash",
|
|
48
|
+
INVALID_TIMESTAMP_ORDER: "receipt_invalid_timestamp_order",
|
|
49
|
+
REPLAY_DETECTED: "receipt_replay_detected",
|
|
50
|
+
OUTSIDE_WINDOW: "receipt_outside_window",
|
|
51
|
+
KEY_NOT_FOUND: "receipt_key_not_found"
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export {
|
|
55
|
+
ATP_SIGNING_ALGORITHM,
|
|
56
|
+
ATP_SIGNING_VERSION,
|
|
57
|
+
generateSigningKeyPair,
|
|
58
|
+
signReceipt,
|
|
59
|
+
verifyReceiptSignature,
|
|
60
|
+
receiptFingerprint,
|
|
61
|
+
canonicalJSONString,
|
|
62
|
+
canonicalBytes,
|
|
63
|
+
exportPublicKeyAsJwk,
|
|
64
|
+
buildJwks
|
|
65
|
+
} from "./signing.mjs";
|
|
66
|
+
|
|
67
|
+
export { ReplayGuard } from "./replay.mjs";
|
|
68
|
+
|
|
69
|
+
export function getSchemaPath(name) {
|
|
70
|
+
if (!["intent", "decision", "receipt"].includes(name)) {
|
|
71
|
+
throw new Error(`Unknown ATP schema '${name}'`);
|
|
72
|
+
}
|
|
73
|
+
return resolve(__dirname, `../schemas/${name}.schema.json`);
|
|
74
|
+
}
|
package/src/replay.mjs
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const ATP_RECEIPT_VALIDATION_CODES = {
|
|
2
|
+
MISSING_REQUIRED_FIELD: "receipt_missing_required_field",
|
|
3
|
+
INVALID_DATETIME_FORMAT: "receipt_invalid_datetime_format",
|
|
4
|
+
REPLAY_DETECTED: "receipt_replay_detected",
|
|
5
|
+
OUTSIDE_WINDOW: "receipt_outside_window"
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const DEFAULT_WINDOW_MS = 5 * 60 * 1000;
|
|
9
|
+
const DEFAULT_SKEW_MS = 30 * 1000;
|
|
10
|
+
|
|
11
|
+
export class ReplayGuard {
|
|
12
|
+
#seen = new Map();
|
|
13
|
+
#windowMs;
|
|
14
|
+
#skewMs;
|
|
15
|
+
|
|
16
|
+
constructor({ windowMs = DEFAULT_WINDOW_MS, skewMs = DEFAULT_SKEW_MS } = {}) {
|
|
17
|
+
this.#windowMs = windowMs;
|
|
18
|
+
this.#skewMs = skewMs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
check(receipt) {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
this.#evict(now);
|
|
24
|
+
|
|
25
|
+
const receiptId = String(receipt?.receipt_id ?? "");
|
|
26
|
+
if (!receiptId) {
|
|
27
|
+
return { ok: false, reason: ATP_RECEIPT_VALIDATION_CODES.MISSING_REQUIRED_FIELD, detail: "receipt_id missing" };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const sealedAt = Date.parse(String(receipt?.sealed_at ?? ""));
|
|
31
|
+
if (!Number.isFinite(sealedAt)) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
reason: ATP_RECEIPT_VALIDATION_CODES.INVALID_DATETIME_FORMAT,
|
|
35
|
+
detail: `sealed_at ${String(receipt?.sealed_at ?? "")} must be a valid date-time`
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const earliest = now - this.#windowMs;
|
|
39
|
+
const latest = now + this.#skewMs;
|
|
40
|
+
if (sealedAt < earliest || sealedAt > latest) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: ATP_RECEIPT_VALIDATION_CODES.OUTSIDE_WINDOW,
|
|
44
|
+
detail: `sealed_at ${receipt.sealed_at} is outside observation window`
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (this.#seen.has(receiptId)) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: ATP_RECEIPT_VALIDATION_CODES.REPLAY_DETECTED,
|
|
52
|
+
detail: `receipt_id ${receiptId} already observed`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.#seen.set(receiptId, now);
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
#evict(now) {
|
|
61
|
+
const cutoff = now - this.#windowMs;
|
|
62
|
+
for (const [id, observedAt] of this.#seen) {
|
|
63
|
+
if (observedAt < cutoff) this.#seen.delete(id);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get size() {
|
|
68
|
+
return this.#seen.size;
|
|
69
|
+
}
|
|
70
|
+
}
|
package/src/signing.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { sign as cryptoSign, verify as cryptoVerify, generateKeyPairSync, createHash, createPublicKey } from "node:crypto";
|
|
2
|
+
import canonicalize from "canonicalize";
|
|
3
|
+
import base64Url from "base64-url";
|
|
4
|
+
|
|
5
|
+
export const ATP_SIGNING_ALGORITHM = "Ed25519";
|
|
6
|
+
export const ATP_SIGNING_VERSION = "ATP-ED25519-1";
|
|
7
|
+
|
|
8
|
+
const BASE64_URL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
9
|
+
|
|
10
|
+
function encodeBase64Url(buffer) {
|
|
11
|
+
return base64Url.escape(buffer.toString("base64"));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function decodeBase64Url(value) {
|
|
15
|
+
if (typeof value !== "string" || value.trim().length === 0 || !BASE64_URL_PATTERN.test(value)) {
|
|
16
|
+
throw new Error("signature must be base64url (A-Z, a-z, 0-9, -, _)");
|
|
17
|
+
}
|
|
18
|
+
return Buffer.from(base64Url.unescape(value), "base64");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function generateSigningKeyPair() {
|
|
22
|
+
return generateKeyPairSync("ed25519", {
|
|
23
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
24
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function canonicalJSONString(receipt) {
|
|
29
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) {
|
|
30
|
+
throw new TypeError("receipt must be a non-null object");
|
|
31
|
+
}
|
|
32
|
+
const clone = structuredClone(receipt);
|
|
33
|
+
delete clone.signature;
|
|
34
|
+
const canonical = canonicalize(clone);
|
|
35
|
+
if (typeof canonical !== "string") {
|
|
36
|
+
throw new TypeError("failed to canonicalize receipt to RFC8785-JCS string");
|
|
37
|
+
}
|
|
38
|
+
return canonical;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function canonicalBytes(receipt) {
|
|
42
|
+
return Buffer.from(canonicalJSONString(receipt), "utf8");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function signReceipt(receipt, privateKeyPem, keyId) {
|
|
46
|
+
const payload = canonicalBytes(receipt);
|
|
47
|
+
const sigBuffer = cryptoSign(null, payload, privateKeyPem);
|
|
48
|
+
const sig = encodeBase64Url(sigBuffer);
|
|
49
|
+
return {
|
|
50
|
+
...receipt,
|
|
51
|
+
signature: {
|
|
52
|
+
alg: ATP_SIGNING_ALGORITHM,
|
|
53
|
+
version: ATP_SIGNING_VERSION,
|
|
54
|
+
kid: keyId,
|
|
55
|
+
sig,
|
|
56
|
+
canonicalization: "RFC8785-JCS"
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function verifyReceiptSignature(receipt, publicKeyPem, options = {}) {
|
|
62
|
+
const sig = receipt?.signature;
|
|
63
|
+
if (!sig || typeof sig !== "object") {
|
|
64
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: "signature object missing" };
|
|
65
|
+
}
|
|
66
|
+
if (typeof publicKeyPem !== "string" || publicKeyPem.trim().length === 0) {
|
|
67
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: "public key missing or empty" };
|
|
68
|
+
}
|
|
69
|
+
if (sig.alg !== ATP_SIGNING_ALGORITHM) {
|
|
70
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: `unsupported algorithm '${sig.alg}'` };
|
|
71
|
+
}
|
|
72
|
+
if (sig.version !== undefined && sig.version !== ATP_SIGNING_VERSION) {
|
|
73
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: `unsupported signature version '${sig.version}'` };
|
|
74
|
+
}
|
|
75
|
+
if (sig.canonicalization !== "RFC8785-JCS") {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: "receipt_invalid_signature",
|
|
79
|
+
detail: "canonicalization must be RFC8785-JCS"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (typeof sig.kid !== "string" || sig.kid.trim().length === 0) {
|
|
83
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: "kid field missing or empty" };
|
|
84
|
+
}
|
|
85
|
+
if (typeof options?.expectedKid === "string" && options.expectedKid.trim().length > 0 && sig.kid !== options.expectedKid) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "receipt_invalid_signature",
|
|
89
|
+
detail: `kid mismatch expected '${options.expectedKid}' but got '${sig.kid}'`
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (typeof sig.sig !== "string" || !sig.sig.trim()) {
|
|
93
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: "sig field missing or empty" };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const payload = canonicalBytes(receipt);
|
|
97
|
+
const sigBuffer = decodeBase64Url(sig.sig);
|
|
98
|
+
if (sigBuffer.length !== 64) {
|
|
99
|
+
return { ok: false, reason: "receipt_invalid_signature", detail: "Ed25519 signature must be exactly 64 bytes" };
|
|
100
|
+
}
|
|
101
|
+
const valid = cryptoVerify(null, payload, publicKeyPem, sigBuffer);
|
|
102
|
+
if (!valid) return { ok: false, reason: "receipt_signature_verification_failed", detail: "signature mismatch" };
|
|
103
|
+
return { ok: true };
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return { ok: false, reason: "receipt_signature_verification_failed", detail: String(error?.message ?? error) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function exportPublicKeyAsJwk(publicKeyPem, kid) {
|
|
110
|
+
const keyObj = createPublicKey(publicKeyPem);
|
|
111
|
+
const raw = keyObj.export({ format: "jwk" });
|
|
112
|
+
return {
|
|
113
|
+
kty: "OKP",
|
|
114
|
+
crv: "Ed25519",
|
|
115
|
+
kid,
|
|
116
|
+
use: "sig",
|
|
117
|
+
x: raw.x
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function buildJwks(entries) {
|
|
122
|
+
return { keys: entries };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function receiptFingerprint(receipt) {
|
|
126
|
+
const payload = canonicalBytes(receipt);
|
|
127
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
128
|
+
}
|