n8n-nodes-nilai 0.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/LICENSE +21 -0
- package/README.md +54 -0
- package/dist/credentials/NilAiApi.credentials.d.ts +10 -0
- package/dist/credentials/NilAiApi.credentials.js +50 -0
- package/dist/credentials/nilai.svg +4 -0
- package/dist/nodes/NilAi/NilAi.node.d.ts +11 -0
- package/dist/nodes/NilAi/NilAi.node.js +209 -0
- package/dist/nodes/NilAi/attestation.d.ts +21 -0
- package/dist/nodes/NilAi/attestation.js +305 -0
- package/dist/nodes/NilAi/nilai.svg +4 -0
- package/index.js +1 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Moore
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# n8n-nodes-nilai
|
|
2
|
+
|
|
3
|
+
Use [Nillion **nilAI**](https://docs.nillion.com/build/private-llms/overview) — private LLM inference that runs inside a **Trusted Execution Environment (TEE)** — directly in your n8n workflows, with **built-in cryptographic verification** of every response.
|
|
4
|
+
|
|
5
|
+
This is an [n8n community node](https://docs.n8n.io/integrations/community-nodes/). It lets you add private, verifiable AI to any workflow with nothing more than an API key.
|
|
6
|
+
|
|
7
|
+
## Why nilAI?
|
|
8
|
+
|
|
9
|
+
Ordinary AI nodes send your data to a provider you have to trust. nilAI is different:
|
|
10
|
+
|
|
11
|
+
- **Private by construction.** Models run inside a TEE (a hardware-isolated, encrypted enclave). The operator — including Nillion — cannot see your prompts or the model's output.
|
|
12
|
+
- **Verifiable.** Every response is cryptographically signed *inside* the enclave. This node verifies that signature against the enclave's public key, so you can *prove* the answer came from a genuine Nillion TEE and was not altered in transit. The result is exposed as a `tee_verified` field.
|
|
13
|
+
- **Drop-in.** nilAI is OpenAI-compatible, so anything you do with a normal LLM — summarisation, classification, triage, extraction, Q&A — works here, privately.
|
|
14
|
+
|
|
15
|
+
### What is a TEE, briefly?
|
|
16
|
+
|
|
17
|
+
A Trusted Execution Environment is a secure region of a CPU/GPU that runs code in an encrypted, hardware-isolated enclave. Data inside is invisible to the host operating system, the cloud provider and the operator. The hardware can produce a signed *attestation* proving which code is running and that it is genuinely inside a real enclave. nilAI runs LLMs inside TEEs and signs each response, turning "trust us" into "verify it yourself."
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
In n8n: **Settings → Community Nodes → Install**, then enter `n8n-nodes-nilai`.
|
|
22
|
+
|
|
23
|
+
(For local development, build the package and load it via `N8N_CUSTOM_EXTENSIONS`.)
|
|
24
|
+
|
|
25
|
+
## Credentials
|
|
26
|
+
|
|
27
|
+
Create a **nilAI API** credential and paste your API key. Get one for free at the [Nillion developer portal](https://developer.nillion.com/nilai). The base URL defaults to Nillion mainnet.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Add the **nilAI** node, pick a model (loaded live from your endpoint), and enter a prompt. Output fields:
|
|
32
|
+
|
|
33
|
+
| Field | Meaning |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `text` | The model's response |
|
|
36
|
+
| `tee_verified` | `true` if the response signature verified against the enclave's public key |
|
|
37
|
+
| `signature` | The raw TEE signature (secp256k1 / ECDSA) |
|
|
38
|
+
| `usage` | Token usage |
|
|
39
|
+
|
|
40
|
+
Turn off **Simplify Output** to receive the full raw nilAI response.
|
|
41
|
+
|
|
42
|
+
## How verification works
|
|
43
|
+
|
|
44
|
+
nilAI signs each response inside the enclave (secp256k1 ECDSA over SHA-256). This node fetches the enclave's public key from `/v1/public_key`, reconstructs the exact signed bytes from the raw response, and verifies the signature — all locally, with no external dependencies.
|
|
45
|
+
|
|
46
|
+
## Resources
|
|
47
|
+
|
|
48
|
+
- [nilAI / Private LLMs docs](https://docs.nillion.com/build/private-llms/overview)
|
|
49
|
+
- [Nillion developer portal](https://developer.nillion.com/nilai)
|
|
50
|
+
- [n8n community nodes](https://docs.n8n.io/integrations/community-nodes/)
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { IAuthenticateGeneric, ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';
|
|
2
|
+
export declare class NilAiApi implements ICredentialType {
|
|
3
|
+
name: string;
|
|
4
|
+
displayName: string;
|
|
5
|
+
icon: "file:nilai.svg";
|
|
6
|
+
documentationUrl: string;
|
|
7
|
+
properties: INodeProperties[];
|
|
8
|
+
authenticate: IAuthenticateGeneric;
|
|
9
|
+
test: ICredentialTestRequest;
|
|
10
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NilAiApi = void 0;
|
|
4
|
+
class NilAiApi {
|
|
5
|
+
constructor() {
|
|
6
|
+
this.name = 'nilAiApi';
|
|
7
|
+
this.displayName = 'nilAI API';
|
|
8
|
+
this.icon = 'file:nilai.svg';
|
|
9
|
+
this.documentationUrl = 'https://developer.nillion.com/nilai';
|
|
10
|
+
this.properties = [
|
|
11
|
+
{
|
|
12
|
+
displayName: 'Don\'t have a key yet? Create a free nilAI account with starter credit at <a href="https://developer.nillion.com/nilai" target="_blank">developer.nillion.com/nilai</a>.',
|
|
13
|
+
name: 'signupNotice',
|
|
14
|
+
type: 'notice',
|
|
15
|
+
default: '',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
displayName: 'API Key',
|
|
19
|
+
name: 'apiKey',
|
|
20
|
+
type: 'string',
|
|
21
|
+
typeOptions: { password: true },
|
|
22
|
+
default: '',
|
|
23
|
+
required: true,
|
|
24
|
+
description: 'Paste your nilAI API key. Get one free (with starter credit) at developer.nillion.com/nilai.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
displayName: 'Base URL',
|
|
28
|
+
name: 'baseUrl',
|
|
29
|
+
type: 'string',
|
|
30
|
+
default: 'https://api.nilai.nillion.network',
|
|
31
|
+
description: 'nilAI API base URL. Defaults to Nillion mainnet.',
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
this.authenticate = {
|
|
35
|
+
type: 'generic',
|
|
36
|
+
properties: {
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: '=Bearer {{$credentials.apiKey}}',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
this.test = {
|
|
43
|
+
request: {
|
|
44
|
+
baseURL: '={{$credentials.baseUrl}}',
|
|
45
|
+
url: '/v1/models',
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
exports.NilAiApi = NilAiApi;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { IExecuteFunctions, ILoadOptionsFunctions, INodeExecutionData, INodePropertyOptions, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
2
|
+
export declare function verifyNilaiSignature(rawBody: string, publicKeyB64: string): boolean;
|
|
3
|
+
export declare class NilAi implements INodeType {
|
|
4
|
+
description: INodeTypeDescription;
|
|
5
|
+
methods: {
|
|
6
|
+
loadOptions: {
|
|
7
|
+
getModels(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]>;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.NilAi = void 0;
|
|
4
|
+
exports.verifyNilaiSignature = verifyNilaiSignature;
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
const attestation_1 = require("./attestation");
|
|
7
|
+
const SPKI_SECP256K1_COMPRESSED_PREFIX = Buffer.from('3036301006072a8648ce3d020106052b8104000a032200', 'hex');
|
|
8
|
+
function publicKeyFromCompressedBase64(b64) {
|
|
9
|
+
const point = Buffer.from(b64.trim().replace(/^"|"$/g, ''), 'base64');
|
|
10
|
+
const spki = Buffer.concat([SPKI_SECP256K1_COMPRESSED_PREFIX, point]);
|
|
11
|
+
return (0, crypto_1.createPublicKey)({ key: spki, format: 'der', type: 'spki' });
|
|
12
|
+
}
|
|
13
|
+
function verifyNilaiSignature(rawBody, publicKeyB64) {
|
|
14
|
+
try {
|
|
15
|
+
const obj = JSON.parse(rawBody);
|
|
16
|
+
const signatureB64 = obj.signature;
|
|
17
|
+
if (!signatureB64)
|
|
18
|
+
return false;
|
|
19
|
+
let preimage = rawBody.replace(`"signature":"${signatureB64}"`, '"signature":""');
|
|
20
|
+
for (const field of ['created_at', 'temperature', 'top_p']) {
|
|
21
|
+
preimage = preimage.replace(new RegExp(`("${field}":)(-?\\d+)([,}\\]])`), '$1$2.0$3');
|
|
22
|
+
}
|
|
23
|
+
const publicKey = publicKeyFromCompressedBase64(publicKeyB64);
|
|
24
|
+
return (0, crypto_1.verify)('sha256', Buffer.from(preimage, 'utf8'), { key: publicKey, dsaEncoding: 'der' }, Buffer.from(signatureB64, 'base64'));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
class NilAi {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.description = {
|
|
33
|
+
displayName: 'nilAI',
|
|
34
|
+
name: 'nilAi',
|
|
35
|
+
icon: 'file:nilai.svg',
|
|
36
|
+
group: ['transform'],
|
|
37
|
+
version: 1,
|
|
38
|
+
subtitle: '={{$parameter["model"]}}',
|
|
39
|
+
description: 'Private, verifiable AI inference inside a Trusted Execution Environment (TEE), powered by Nillion nilAI.',
|
|
40
|
+
defaults: {
|
|
41
|
+
name: 'nilAI',
|
|
42
|
+
},
|
|
43
|
+
inputs: ['main'],
|
|
44
|
+
outputs: ['main'],
|
|
45
|
+
credentials: [
|
|
46
|
+
{
|
|
47
|
+
name: 'nilAiApi',
|
|
48
|
+
required: true,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
properties: [
|
|
52
|
+
{
|
|
53
|
+
displayName: 'Model',
|
|
54
|
+
name: 'model',
|
|
55
|
+
type: 'options',
|
|
56
|
+
typeOptions: {
|
|
57
|
+
loadOptionsMethod: 'getModels',
|
|
58
|
+
},
|
|
59
|
+
default: 'google/gemma-4-26B-A4B-it',
|
|
60
|
+
description: 'The model to run inside the nilAI TEE. Loaded live from your nilAI endpoint.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
displayName: 'Instructions',
|
|
64
|
+
name: 'instructions',
|
|
65
|
+
type: 'string',
|
|
66
|
+
typeOptions: {
|
|
67
|
+
rows: 2,
|
|
68
|
+
},
|
|
69
|
+
default: '',
|
|
70
|
+
description: "What you want the model to do, e.g. 'Summarise this in 3 sentences and 3-5 key bullet points'.",
|
|
71
|
+
hint: 'Plain text — the task only. Keep your data out of here (put that in Input). Leave empty to send just the Input as-is.',
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
displayName: 'Input',
|
|
75
|
+
name: 'prompt',
|
|
76
|
+
type: 'string',
|
|
77
|
+
typeOptions: {
|
|
78
|
+
rows: 4,
|
|
79
|
+
},
|
|
80
|
+
default: '',
|
|
81
|
+
required: true,
|
|
82
|
+
description: 'The content to act on. The model receives Instructions followed by this.',
|
|
83
|
+
hint: 'Usually a reference to data from a previous node — e.g. the document text. Instructions + Input are combined into the prompt for you.',
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
displayName: 'Simplify Output',
|
|
87
|
+
name: 'simplify',
|
|
88
|
+
type: 'boolean',
|
|
89
|
+
default: true,
|
|
90
|
+
description: "Whether to return a clean result instead of nilAI's full raw response object.",
|
|
91
|
+
hint: 'On: tidy output — just the answer, the verification results and token usage. Off: the complete raw nilAI response (every field), handy for debugging.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
displayName: 'Verify TEE Signature',
|
|
95
|
+
name: 'verify',
|
|
96
|
+
type: 'boolean',
|
|
97
|
+
default: true,
|
|
98
|
+
description: "Whether to cryptographically verify this response's signature against the enclave's key. Adds a tee_verified field.",
|
|
99
|
+
hint: 'Proves THIS response was signed inside the nilAI enclave and was not altered in transit. Adds tee_verified (true/false).',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
displayName: 'Verify Enclave Attestation',
|
|
103
|
+
name: 'verifyAttestation',
|
|
104
|
+
type: 'boolean',
|
|
105
|
+
default: true,
|
|
106
|
+
description: "Whether to verify the serving enclave's AMD SEV-SNP hardware attestation against AMD's certificate chain. Adds an attestation object.",
|
|
107
|
+
hint: "Proves the SERVER is a genuine AMD SEV-SNP TEE running the expected nilAI build (chained to AMD's root certificates). Independent of the signature check — use either or both.",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
};
|
|
111
|
+
this.methods = {
|
|
112
|
+
loadOptions: {
|
|
113
|
+
async getModels() {
|
|
114
|
+
const credentials = await this.getCredentials('nilAiApi');
|
|
115
|
+
const baseUrl = (credentials.baseUrl || 'https://api.nilai.nillion.network').replace(/\/+$/, '');
|
|
116
|
+
const response = await this.helpers.httpRequestWithAuthentication.call(this, 'nilAiApi', {
|
|
117
|
+
method: 'GET',
|
|
118
|
+
url: `${baseUrl}/v1/models`,
|
|
119
|
+
json: true,
|
|
120
|
+
});
|
|
121
|
+
const models = (response?.data ?? response);
|
|
122
|
+
if (!Array.isArray(models))
|
|
123
|
+
return [];
|
|
124
|
+
return models
|
|
125
|
+
.map((model) => ({ name: model.id, value: model.id }))
|
|
126
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async execute() {
|
|
132
|
+
const items = this.getInputData();
|
|
133
|
+
const returnData = [];
|
|
134
|
+
const credentials = await this.getCredentials('nilAiApi');
|
|
135
|
+
const baseUrl = (credentials.baseUrl || 'https://api.nilai.nillion.network').replace(/\/+$/, '');
|
|
136
|
+
const verifyAttestation = this.getNodeParameter('verifyAttestation', 0, true);
|
|
137
|
+
const attestation = verifyAttestation ? await (0, attestation_1.verifyEnclaveAttestation)(this, baseUrl) : undefined;
|
|
138
|
+
let publicKeyB64;
|
|
139
|
+
for (let i = 0; i < items.length; i++) {
|
|
140
|
+
try {
|
|
141
|
+
const model = this.getNodeParameter('model', i);
|
|
142
|
+
const instructions = this.getNodeParameter('instructions', i, '');
|
|
143
|
+
const prompt = this.getNodeParameter('prompt', i);
|
|
144
|
+
const doVerify = this.getNodeParameter('verify', i);
|
|
145
|
+
const simplify = this.getNodeParameter('simplify', i);
|
|
146
|
+
const rawResponse = await this.helpers.httpRequestWithAuthentication.call(this, 'nilAiApi', {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
url: `${baseUrl}/v1/responses`,
|
|
149
|
+
headers: { 'Content-Type': 'application/json' },
|
|
150
|
+
body: JSON.stringify({ model, input: [instructions, prompt].map((s) => (s || '').trim()).filter(Boolean).join('\n\n') }),
|
|
151
|
+
json: false,
|
|
152
|
+
encoding: 'arraybuffer',
|
|
153
|
+
});
|
|
154
|
+
const rawBody = typeof rawResponse === 'string'
|
|
155
|
+
? rawResponse
|
|
156
|
+
: Buffer.from(rawResponse).toString('utf8');
|
|
157
|
+
const parsed = JSON.parse(rawBody);
|
|
158
|
+
let teeVerified = null;
|
|
159
|
+
if (doVerify) {
|
|
160
|
+
if (publicKeyB64 === undefined) {
|
|
161
|
+
publicKeyB64 = (await this.helpers.httpRequest({
|
|
162
|
+
method: 'GET',
|
|
163
|
+
url: `${baseUrl}/v1/public_key`,
|
|
164
|
+
json: true,
|
|
165
|
+
timeout: 15000,
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
teeVerified = verifyNilaiSignature(rawBody, publicKeyB64);
|
|
169
|
+
}
|
|
170
|
+
const outputItems = Array.isArray(parsed?.output) ? parsed.output : [];
|
|
171
|
+
const message = outputItems.find((o) => o?.type === 'message') ?? outputItems[0];
|
|
172
|
+
const contents = Array.isArray(message?.content) ? message.content : [];
|
|
173
|
+
const part = contents.find((c) => c?.type === 'output_text') ?? contents[0];
|
|
174
|
+
const text = typeof part?.text === 'string' ? part.text : null;
|
|
175
|
+
if (simplify) {
|
|
176
|
+
returnData.push({
|
|
177
|
+
json: {
|
|
178
|
+
text,
|
|
179
|
+
tee_verified: teeVerified,
|
|
180
|
+
attestation,
|
|
181
|
+
model: parsed?.model,
|
|
182
|
+
signature: parsed?.signature ?? null,
|
|
183
|
+
usage: parsed?.usage ?? null,
|
|
184
|
+
},
|
|
185
|
+
pairedItem: { item: i },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
returnData.push({
|
|
190
|
+
json: { ...parsed, tee_verified: teeVerified, attestation },
|
|
191
|
+
pairedItem: { item: i },
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (this.continueOnFail()) {
|
|
197
|
+
returnData.push({
|
|
198
|
+
json: { error: error.message },
|
|
199
|
+
pairedItem: { item: i },
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return [returnData];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
exports.NilAi = NilAi;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { IExecuteFunctions } from 'n8n-workflow';
|
|
2
|
+
export interface AttestationResult {
|
|
3
|
+
attestation_verified: boolean;
|
|
4
|
+
processor?: string;
|
|
5
|
+
nilcc_version?: string;
|
|
6
|
+
measurement?: string;
|
|
7
|
+
measurement_matches_known_build?: boolean | null;
|
|
8
|
+
report_data?: string;
|
|
9
|
+
checks?: Record<string, boolean>;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function detectProcessor(family: number, model: number): string | null;
|
|
13
|
+
export interface ReportedTcb {
|
|
14
|
+
bootloader: number;
|
|
15
|
+
tee: number;
|
|
16
|
+
snp: number;
|
|
17
|
+
microcode: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function verifyTcbExtensions(vcekDer: Buffer, tcb: ReportedTcb, chipIdHex: string): boolean;
|
|
20
|
+
export declare function checkReportDataBinding(reportDataHex: string, certDer: Buffer): boolean;
|
|
21
|
+
export declare function verifyEnclaveAttestation(ctx: IExecuteFunctions, baseUrl: string): Promise<AttestationResult>;
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectProcessor = detectProcessor;
|
|
4
|
+
exports.verifyTcbExtensions = verifyTcbExtensions;
|
|
5
|
+
exports.checkReportDataBinding = checkReportDataBinding;
|
|
6
|
+
exports.verifyEnclaveAttestation = verifyEnclaveAttestation;
|
|
7
|
+
const crypto_1 = require("crypto");
|
|
8
|
+
const tls_1 = require("tls");
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
const os_1 = require("os");
|
|
12
|
+
const KDS_DOMAIN = 'kdsintf.amd.com';
|
|
13
|
+
const KNOWN_MEASUREMENTS = {
|
|
14
|
+
'0.3.0': '981bcaa62bcd63cb03c7b93a67d1a8c17ff63e45e6d16323a8784bacbfcb254313741bcf32d0bccc795d6ea0e6ac1481',
|
|
15
|
+
};
|
|
16
|
+
const certChainCache = {};
|
|
17
|
+
const vcekCache = {};
|
|
18
|
+
function certCacheDir() {
|
|
19
|
+
for (const base of [(0, path_1.join)((0, os_1.homedir)(), '.n8n'), (0, os_1.tmpdir)()]) {
|
|
20
|
+
try {
|
|
21
|
+
const dir = (0, path_1.join)(base, '.nilai-cert-cache');
|
|
22
|
+
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
23
|
+
return dir;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return (0, os_1.tmpdir)();
|
|
29
|
+
}
|
|
30
|
+
function readCertCache(name) {
|
|
31
|
+
try {
|
|
32
|
+
const p = (0, path_1.join)(certCacheDir(), name);
|
|
33
|
+
return (0, fs_1.existsSync)(p) ? (0, fs_1.readFileSync)(p) : null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function writeCertCache(name, data) {
|
|
40
|
+
try {
|
|
41
|
+
(0, fs_1.writeFileSync)((0, path_1.join)(certCacheDir(), name), data);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function detectProcessor(family, model) {
|
|
47
|
+
if (family === 0x19) {
|
|
48
|
+
if (model <= 0x0f)
|
|
49
|
+
return 'Milan';
|
|
50
|
+
if ((model >= 0x10 && model <= 0x1f) || (model >= 0xa0 && model <= 0xaf))
|
|
51
|
+
return 'Genoa';
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (family === 0x1a)
|
|
55
|
+
return 'Turin';
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function leToBe48(le) {
|
|
59
|
+
const be = Buffer.from(le).reverse();
|
|
60
|
+
return be.subarray(be.length - 48);
|
|
61
|
+
}
|
|
62
|
+
const SNP_OID = {
|
|
63
|
+
bootloader: '2b060104019c78010301',
|
|
64
|
+
tee: '2b060104019c78010302',
|
|
65
|
+
snp: '2b060104019c78010303',
|
|
66
|
+
ucode: '2b060104019c78010308',
|
|
67
|
+
hwid: '2b060104019c780104',
|
|
68
|
+
};
|
|
69
|
+
function extValue(der, oidHex) {
|
|
70
|
+
const oid = Buffer.from(oidHex, 'hex');
|
|
71
|
+
const idx = der.indexOf(oid);
|
|
72
|
+
if (idx < 0)
|
|
73
|
+
return null;
|
|
74
|
+
let p = idx + oid.length;
|
|
75
|
+
if (der[p] === 0x01)
|
|
76
|
+
p += 3;
|
|
77
|
+
if (der[p] !== 0x04)
|
|
78
|
+
return null;
|
|
79
|
+
let len = der[p + 1];
|
|
80
|
+
let start = p + 2;
|
|
81
|
+
if (len & 0x80) {
|
|
82
|
+
const n = len & 0x7f;
|
|
83
|
+
len = 0;
|
|
84
|
+
for (let i = 0; i < n; i++)
|
|
85
|
+
len = (len << 8) | der[start + i];
|
|
86
|
+
start += n;
|
|
87
|
+
}
|
|
88
|
+
return der.subarray(start, start + len);
|
|
89
|
+
}
|
|
90
|
+
function intExt(der, oidHex) {
|
|
91
|
+
const v = extValue(der, oidHex);
|
|
92
|
+
if (!v || v[0] !== 0x02)
|
|
93
|
+
return null;
|
|
94
|
+
return v[v.length - 1];
|
|
95
|
+
}
|
|
96
|
+
function verifyTcbExtensions(vcekDer, tcb, chipIdHex) {
|
|
97
|
+
if (intExt(vcekDer, SNP_OID.bootloader) !== tcb.bootloader)
|
|
98
|
+
return false;
|
|
99
|
+
if (intExt(vcekDer, SNP_OID.tee) !== tcb.tee)
|
|
100
|
+
return false;
|
|
101
|
+
if (intExt(vcekDer, SNP_OID.snp) !== tcb.snp)
|
|
102
|
+
return false;
|
|
103
|
+
if (intExt(vcekDer, SNP_OID.ucode) !== tcb.microcode)
|
|
104
|
+
return false;
|
|
105
|
+
const hwid = extValue(vcekDer, SNP_OID.hwid);
|
|
106
|
+
if (!hwid || hwid.toString('hex') !== chipIdHex)
|
|
107
|
+
return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
function parseCertChain(pem) {
|
|
111
|
+
try {
|
|
112
|
+
const blocks = pem.match(/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g) ?? [];
|
|
113
|
+
let ark;
|
|
114
|
+
let ask;
|
|
115
|
+
for (const block of blocks) {
|
|
116
|
+
const c = new crypto_1.X509Certificate(block);
|
|
117
|
+
if (c.verify(c.publicKey))
|
|
118
|
+
ark = c;
|
|
119
|
+
else
|
|
120
|
+
ask = c;
|
|
121
|
+
}
|
|
122
|
+
return ark && ask ? { ark, ask } : null;
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
async function getServerCertDer(host) {
|
|
129
|
+
return new Promise((resolve) => {
|
|
130
|
+
let settled = false;
|
|
131
|
+
let socket;
|
|
132
|
+
const finish = (v) => {
|
|
133
|
+
if (settled)
|
|
134
|
+
return;
|
|
135
|
+
settled = true;
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
try {
|
|
138
|
+
socket?.destroy();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
}
|
|
142
|
+
resolve(v);
|
|
143
|
+
};
|
|
144
|
+
const timer = setTimeout(() => finish(null), 7000);
|
|
145
|
+
try {
|
|
146
|
+
socket = (0, tls_1.connect)({ host, port: 443, servername: host, rejectUnauthorized: true }, () => {
|
|
147
|
+
try {
|
|
148
|
+
const peer = socket.getPeerCertificate();
|
|
149
|
+
finish(peer && peer.raw ? peer.raw : null);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
finish(null);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
socket.on('error', () => finish(null));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
finish(null);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
function checkReportDataBinding(reportDataHex, certDer) {
|
|
163
|
+
try {
|
|
164
|
+
const spki = new crypto_1.X509Certificate(certDer).publicKey.export({ type: 'spki', format: 'der' });
|
|
165
|
+
const fingerprint = (0, crypto_1.createHash)('sha256').update(spki).digest('hex');
|
|
166
|
+
return (reportDataHex.length === 128 &&
|
|
167
|
+
reportDataHex.slice(0, 2) === '00' &&
|
|
168
|
+
reportDataHex.slice(2, 66) === fingerprint &&
|
|
169
|
+
/^0+$/.test(reportDataHex.slice(66)));
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
async function verifyEnclaveAttestation(ctx, baseUrl) {
|
|
176
|
+
try {
|
|
177
|
+
const reportResp = (await ctx.helpers.httpRequest({
|
|
178
|
+
method: 'GET',
|
|
179
|
+
url: `${baseUrl}/nilcc/api/v2/report`,
|
|
180
|
+
json: true, timeout: 15000,
|
|
181
|
+
}));
|
|
182
|
+
const report = reportResp.report;
|
|
183
|
+
const raw = Buffer.from(reportResp.raw_report, 'hex');
|
|
184
|
+
const nilccVersion = reportResp.environment?.nilcc_version;
|
|
185
|
+
if (raw.length < 0x4a0) {
|
|
186
|
+
throw new Error(`attestation report too short (${raw.length} bytes, expected 1184)`);
|
|
187
|
+
}
|
|
188
|
+
const policy = raw.readBigUInt64LE(0x08);
|
|
189
|
+
const debugAllowed = ((policy >> 19n) & 1n) === 1n;
|
|
190
|
+
const tcb = {
|
|
191
|
+
bootloader: raw[0x180],
|
|
192
|
+
tee: raw[0x181],
|
|
193
|
+
snp: raw[0x186],
|
|
194
|
+
microcode: raw[0x187],
|
|
195
|
+
};
|
|
196
|
+
const chipIdHex = raw.subarray(0x1a0, 0x1e0).toString('hex');
|
|
197
|
+
const reportVersion = raw.readUInt32LE(0x00);
|
|
198
|
+
const family = reportVersion >= 3 ? raw[0x188] : report.cpuid_fam_id;
|
|
199
|
+
const model = reportVersion >= 3 ? raw[0x189] : report.cpuid_mod_id;
|
|
200
|
+
const processor = detectProcessor(family, model);
|
|
201
|
+
if (!processor) {
|
|
202
|
+
throw new Error(`unsupported processor (family 0x${family.toString(16)}, model 0x${model.toString(16)})`);
|
|
203
|
+
}
|
|
204
|
+
const pad2 = (n) => String(n).padStart(2, '0');
|
|
205
|
+
if (!certChainCache[processor]) {
|
|
206
|
+
const chainCacheName = `certchain_${processor}.pem`;
|
|
207
|
+
const cachedChain = readCertCache(chainCacheName);
|
|
208
|
+
let chain = cachedChain ? parseCertChain(cachedChain.toString('utf8')) : null;
|
|
209
|
+
if (!chain) {
|
|
210
|
+
const chainPem = (await ctx.helpers.httpRequest({
|
|
211
|
+
method: 'GET',
|
|
212
|
+
url: `https://${KDS_DOMAIN}/vcek/v1/${processor}/cert_chain`,
|
|
213
|
+
encoding: 'text',
|
|
214
|
+
timeout: 15000,
|
|
215
|
+
}));
|
|
216
|
+
chain = parseCertChain(chainPem);
|
|
217
|
+
if (!chain)
|
|
218
|
+
throw new Error('AMD KDS returned an unparseable certificate chain');
|
|
219
|
+
writeCertCache(chainCacheName, Buffer.from(chainPem, 'utf8'));
|
|
220
|
+
}
|
|
221
|
+
certChainCache[processor] = chain;
|
|
222
|
+
}
|
|
223
|
+
const { ark, ask } = certChainCache[processor];
|
|
224
|
+
const vcekUrl = `https://${KDS_DOMAIN}/vcek/v1/${processor}/${chipIdHex}` +
|
|
225
|
+
`?blSPL=${pad2(tcb.bootloader)}&teeSPL=${pad2(tcb.tee)}&snpSPL=${pad2(tcb.snp)}&ucodeSPL=${pad2(tcb.microcode)}`;
|
|
226
|
+
if (!vcekCache[vcekUrl]) {
|
|
227
|
+
const vcekCacheName = `vcek_${chipIdHex}_${tcb.bootloader}_${tcb.tee}_${tcb.snp}_${tcb.microcode}.der`;
|
|
228
|
+
const cachedDer = readCertCache(vcekCacheName);
|
|
229
|
+
let vcekCert = null;
|
|
230
|
+
if (cachedDer) {
|
|
231
|
+
try {
|
|
232
|
+
vcekCert = new crypto_1.X509Certificate(cachedDer);
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
vcekCert = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (!vcekCert) {
|
|
239
|
+
const fetched = (await ctx.helpers.httpRequest({
|
|
240
|
+
method: 'GET',
|
|
241
|
+
url: vcekUrl,
|
|
242
|
+
encoding: 'arraybuffer',
|
|
243
|
+
timeout: 15000,
|
|
244
|
+
}));
|
|
245
|
+
const der = Buffer.from(fetched);
|
|
246
|
+
vcekCert = new crypto_1.X509Certificate(der);
|
|
247
|
+
writeCertCache(vcekCacheName, der);
|
|
248
|
+
}
|
|
249
|
+
vcekCache[vcekUrl] = vcekCert;
|
|
250
|
+
}
|
|
251
|
+
const vcek = vcekCache[vcekUrl];
|
|
252
|
+
const arkSelfSigned = ark.verify(ark.publicKey);
|
|
253
|
+
const askByArk = ask.verify(ark.publicKey);
|
|
254
|
+
const vcekByAsk = vcek.verify(ask.publicKey);
|
|
255
|
+
const sig = Buffer.concat([
|
|
256
|
+
leToBe48(raw.subarray(0x2a0, 0x2e8)),
|
|
257
|
+
leToBe48(raw.subarray(0x2e8, 0x330)),
|
|
258
|
+
]);
|
|
259
|
+
const signedBytes = raw.subarray(0x0, 0x2a0);
|
|
260
|
+
let sigValid = false;
|
|
261
|
+
try {
|
|
262
|
+
sigValid = (0, crypto_1.verify)('sha384', signedBytes, { key: vcek.publicKey, dsaEncoding: 'ieee-p1363' }, sig);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
sigValid = false;
|
|
266
|
+
}
|
|
267
|
+
const measurement = raw.subarray(0x90, 0x90 + 48).toString('hex');
|
|
268
|
+
const known = nilccVersion ? KNOWN_MEASUREMENTS[nilccVersion] : undefined;
|
|
269
|
+
const measurementMatches = known ? measurement === known : null;
|
|
270
|
+
const reportData = raw.subarray(0x50, 0x50 + 64).toString('hex');
|
|
271
|
+
const tcbOk = verifyTcbExtensions(vcek.raw, tcb, chipIdHex);
|
|
272
|
+
const host = (() => {
|
|
273
|
+
try {
|
|
274
|
+
return new URL(baseUrl).hostname;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return '';
|
|
278
|
+
}
|
|
279
|
+
})();
|
|
280
|
+
const serverCertDer = host ? await getServerCertDer(host) : null;
|
|
281
|
+
const tlsBound = serverCertDer ? checkReportDataBinding(reportData, serverCertDer) : false;
|
|
282
|
+
const checks = {
|
|
283
|
+
ark_self_signed: arkSelfSigned,
|
|
284
|
+
ask_signed_by_ark: askByArk,
|
|
285
|
+
vcek_signed_by_ask: vcekByAsk,
|
|
286
|
+
report_signature_valid: sigValid,
|
|
287
|
+
vcek_tcb_matches_report: tcbOk,
|
|
288
|
+
tls_session_bound: tlsBound,
|
|
289
|
+
debug_disabled: !debugAllowed,
|
|
290
|
+
};
|
|
291
|
+
const attestation_verified = Object.values(checks).every(Boolean) && measurementMatches !== false;
|
|
292
|
+
return {
|
|
293
|
+
attestation_verified,
|
|
294
|
+
processor,
|
|
295
|
+
nilcc_version: nilccVersion,
|
|
296
|
+
measurement,
|
|
297
|
+
measurement_matches_known_build: measurementMatches,
|
|
298
|
+
report_data: reportData,
|
|
299
|
+
checks,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (e) {
|
|
303
|
+
return { attestation_verified: false, error: e.message };
|
|
304
|
+
}
|
|
305
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = {};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "n8n-nodes-nilai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Use Nillion nilAI — private LLM inference in a TEE, OpenAI-compatible — directly in n8n, with built-in cryptographic verification of every response.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"n8n-community-node-package",
|
|
7
|
+
"nillion",
|
|
8
|
+
"nilai",
|
|
9
|
+
"tee",
|
|
10
|
+
"privacy",
|
|
11
|
+
"llm",
|
|
12
|
+
"ai",
|
|
13
|
+
"confidential-computing"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/iamrobertmoore/n8n-nodes-nilai",
|
|
17
|
+
"author": {
|
|
18
|
+
"name": "Robert Moore",
|
|
19
|
+
"email": "iamrobertmoore@gmail.com"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/iamrobertmoore/n8n-nodes-nilai.git"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.10"
|
|
27
|
+
},
|
|
28
|
+
"main": "index.js",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "rm -rf dist && tsc && mkdir -p dist/nodes/NilAi dist/credentials && cp nodes/NilAi/nilai.svg dist/nodes/NilAi/nilai.svg && cp nodes/NilAi/nilai.svg dist/credentials/nilai.svg",
|
|
31
|
+
"dev": "tsc --watch",
|
|
32
|
+
"prepublishOnly": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist",
|
|
36
|
+
"index.js"
|
|
37
|
+
],
|
|
38
|
+
"n8n": {
|
|
39
|
+
"n8nNodesApiVersion": 1,
|
|
40
|
+
"credentials": [
|
|
41
|
+
"dist/credentials/NilAiApi.credentials.js"
|
|
42
|
+
],
|
|
43
|
+
"nodes": [
|
|
44
|
+
"dist/nodes/NilAi/NilAi.node.js"
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"n8n-workflow": "*"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^20.11.0",
|
|
55
|
+
"n8n-workflow": "^2.16.0",
|
|
56
|
+
"typescript": "^5.6.0"
|
|
57
|
+
}
|
|
58
|
+
}
|