@rodit/verify-hola 0.1.3

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/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @rodit/verify-hola
2
+
3
+ Part of the [Discernible IO SDK monorepo](../README.md).
4
+
5
+ Verify-before-execute tooling for IdentyClaw HOLA lines.
6
+
7
+ For NEAR account enrollment, see [gennearaccount](https://github.com/discernible-io/gennearaccount). For in-gateway HOLA tools, see [openclaw-identyclaw-plugin](https://github.com/discernible-io/openclaw-identyclaw-plugin).
8
+
9
+ ## Recommended: direct NEAR RPC (`--rpc`)
10
+
11
+ No IdentyClaw API in the verify path — your machine reads chain state via FastNear (or any NEAR RPC).
12
+
13
+ ```bash
14
+ export NEAR_RPC_URL="https://rpc.mainnet.fastnear.com"
15
+ export NEAR_CONTRACT_ID="genaaaa-identyclaw-com.near"
16
+
17
+ npx @rodit/verify-hola report "HOLA/MUNDO/..." --rpc \
18
+ --canonical-peer <lemuel-gulliver-lobby-tokenId>
19
+ ```
20
+
21
+ From this monorepo (no npm publish required):
22
+
23
+ ```bash
24
+ export NEAR_RPC_URL="https://rpc.mainnet.fastnear.com"
25
+ export NEAR_CONTRACT_ID="genaaaa-identyclaw-com.near"
26
+ node bin/verify-hola.js report "HOLA/..." --rpc \
27
+ --canonical-peer <lemuel-gulliver-lobby-tokenId>
28
+ ```
29
+
30
+ ## Alternative: IdentyClaw API path
31
+
32
+ JWT is optional — `POST /api/identity/verify` is public.
33
+
34
+ ```bash
35
+ npx @rodit/verify-hola report "HOLA/MUNDO/..."
36
+ ```
37
+
38
+ ## Commands
39
+
40
+ | Command | Description |
41
+ | --- | --- |
42
+ | `verify-hola verify "HOLA/..." --rpc` | Cryptographic outcome via NEAR RPC |
43
+ | `verify-hola report "HOLA/..." --rpc` | Verify + on-chain passport profile (DN) |
44
+ | `verify-hola report "HOLA/..."` | Verify + profile via API (`includeProfile`) |
45
+
46
+ ## Environment
47
+
48
+ | Variable | `--rpc` | API (default) |
49
+ | --- | --- | --- |
50
+ | `NEAR_RPC_URL` | **Required** | — |
51
+ | `NEAR_CONTRACT_ID` | **Required** | — |
52
+ | `IDENTYCLAW_BASE_URL` | — | default `https://api.identyclaw.com` |
53
+ | `IDENTYCLAW_JWT` | — | optional |
54
+ | `IDENTYCLAW_CANONICAL_PEER_ID` | optional impersonation guard | optional |
55
+ | `IDENTYCLAW_EXPECTED_RECIPIENT` | — | optional |
56
+
57
+ ## Concierge demo (Lemuel Gulliver)
58
+
59
+ 1. Message **Lemuel Gulliver** via A2A at [identyclaw-concierge.identyclaw.com:7443](https://identyclaw-concierge.identyclaw.com:7443) (or email / Discord / Telegram) and ask for a HOLA.
60
+ 2. Verify with RPC (recommended):
61
+
62
+ ```bash
63
+ export NEAR_RPC_URL="https://rpc.mainnet.fastnear.com"
64
+ export NEAR_CONTRACT_ID="genaaaa-identyclaw-com.near"
65
+ export IDENTYCLAW_CANONICAL_PEER_ID="<lobby-tokenId-from-agent-card>"
66
+ npx @rodit/verify-hola report "HOLA/..." --rpc
67
+ ```
68
+
69
+ Registry: [com.identyclaw.lemuel_gulliver](https://www.a2a-registry.org/agent/com.identyclaw.lemuel_gulliver)
70
+
71
+ Web UI (API path): https://verify.identyclaw.com
72
+
73
+ ## API
74
+
75
+ ```javascript
76
+ const { buildHolaReportViaRpc } = require("@rodit/verify-hola/lib/verify-rpc");
77
+
78
+ const report = await buildHolaReportViaRpc(hola, {
79
+ nearRpcUrl: process.env.NEAR_RPC_URL,
80
+ nearContractId: process.env.NEAR_CONTRACT_ID,
81
+ canonicalPeerId: "abcdefghijkl"
82
+ });
83
+ ```
84
+
85
+ ## Related
86
+
87
+ - [`references/verify-hola-recipes.md`](../references/verify-hola-recipes.md)
88
+ - [`references/identity-verification-policy.md`](../references/identity-verification-policy.md) §1
89
+ - MCP `doc:reference:verify-hola-recipes`
90
+
91
+ ---
92
+
93
+ [discernible.io](https://www.discernible.io) · [identyclaw-agents](https://github.com/discernible-io/identyclaw-agents) · [discernible-io](https://github.com/discernible-io)
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { buildHolaReport, verifyHola } = require("../lib/report");
5
+ const { buildHolaReportViaRpc, verifyHolaViaRpc } = require("../lib/verify-rpc");
6
+
7
+ function usage() {
8
+ process.stderr.write(`Usage:
9
+ verify-hola verify <hola-string> [--rpc] [--expected-recipient <tokenId>]
10
+ verify-hola report <hola-string> [--rpc] [--expected-recipient <tokenId>] [--canonical-peer <tokenId>]
11
+ echo "HOLA/..." | verify-hola report - --rpc
12
+
13
+ Verify paths:
14
+ --rpc Direct NEAR RPC via @rodit/rodit-auth-be (recommended — no IdentyClaw API)
15
+ default POST /api/identity/verify on IDENTYCLAW_BASE_URL
16
+
17
+ RPC env (required with --rpc):
18
+ NEAR_RPC_URL e.g. https://rpc.mainnet.fastnear.com
19
+ NEAR_CONTRACT_ID e.g. genaaaa-identyclaw-com.near
20
+ NEAR_RPC_TIMEOUT optional milliseconds (default from rodit-auth-be)
21
+
22
+ API env (default path):
23
+ IDENTYCLAW_BASE_URL default https://api.identyclaw.com
24
+ IDENTYCLAW_JWT optional
25
+ IDENTYCLAW_CANONICAL_PEER_ID
26
+ IDENTYCLAW_EXPECTED_RECIPIENT
27
+ `);
28
+ }
29
+
30
+ function parseArgs(argv) {
31
+ const args = [...argv];
32
+ const command = args.shift();
33
+ let hola = args[0];
34
+ const options = {
35
+ useRpc: false,
36
+ expectedRecipient: process.env.IDENTYCLAW_EXPECTED_RECIPIENT,
37
+ canonicalPeerId: process.env.IDENTYCLAW_CANONICAL_PEER_ID,
38
+ baseUrl: process.env.IDENTYCLAW_BASE_URL,
39
+ jwt: process.env.IDENTYCLAW_JWT,
40
+ nearRpcUrl: process.env.NEAR_RPC_URL,
41
+ nearContractId: process.env.NEAR_CONTRACT_ID
42
+ };
43
+
44
+ if (hola === "-") hola = null;
45
+
46
+ for (let i = 1; i < args.length; i += 1) {
47
+ const flag = args[i];
48
+ if (flag === "--rpc") {
49
+ options.useRpc = true;
50
+ } else if (flag === "--expected-recipient") {
51
+ options.expectedRecipient = args[++i];
52
+ } else if (flag === "--canonical-peer") {
53
+ options.canonicalPeerId = args[++i];
54
+ } else if (flag === "--base-url") {
55
+ options.baseUrl = args[++i];
56
+ } else if (flag === "--near-rpc-url") {
57
+ options.nearRpcUrl = args[++i];
58
+ } else if (flag === "--near-contract-id") {
59
+ options.nearContractId = args[++i];
60
+ }
61
+ }
62
+
63
+ return { command, hola, options };
64
+ }
65
+
66
+ async function readHolaFromStdin() {
67
+ const chunks = [];
68
+ for await (const chunk of process.stdin) chunks.push(chunk);
69
+ const hola = Buffer.concat(chunks).toString("utf8").trim();
70
+ if (!hola) throw new Error("empty HOLA on stdin");
71
+ return hola;
72
+ }
73
+
74
+ async function main() {
75
+ const { command, hola: holaArg, options } = parseArgs(process.argv.slice(2));
76
+ if (!command || !["verify", "report"].includes(command)) {
77
+ usage();
78
+ process.exit(1);
79
+ }
80
+
81
+ const hola = holaArg ? holaArg : await readHolaFromStdin();
82
+
83
+ if (command === "verify") {
84
+ const result = options.useRpc
85
+ ? await verifyHolaViaRpc(hola, options)
86
+ : await verifyHola(hola, options);
87
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
88
+ process.exit(result.verified ? 0 : 1);
89
+ return;
90
+ }
91
+
92
+ const report = options.useRpc
93
+ ? await buildHolaReportViaRpc(hola, options)
94
+ : await buildHolaReport(hola, { ...options, includeProfile: true });
95
+
96
+ process.stdout.write(JSON.stringify(report, null, 2) + "\n");
97
+ process.exit(report.verified ? 0 : 1);
98
+ }
99
+
100
+ main().catch((err) => {
101
+ process.stderr.write(JSON.stringify({ verified: false, error: err.message }) + "\n");
102
+ process.exit(1);
103
+ });
@@ -0,0 +1,178 @@
1
+ const { validateHexNonce, computeHolaChecksum } = require("./nonce-encoding");
2
+
3
+ function validateTokenIdFormat(value) {
4
+ return /^[A-Z]{12}$/.test(String(value || ""));
5
+ }
6
+
7
+ function validateHolaMessage(hola) {
8
+ const result = {
9
+ valid: false,
10
+ isSubagentFormat: false,
11
+ recipient: "MUNDO",
12
+ tokenId: null,
13
+ delegateId: null,
14
+ issuerTokenId: null,
15
+ subagentPublicKey: null,
16
+ isoTimestamp: null,
17
+ noncetsHex: null,
18
+ signatureB32: null,
19
+ checksumChar: null,
20
+ reason: null,
21
+ checks: {
22
+ formatValid: false,
23
+ checksumValid: false,
24
+ timestampValid: false,
25
+ noncetsValid: false
26
+ }
27
+ };
28
+
29
+ if (!hola || typeof hola !== "string") {
30
+ result.reason = "Missing or invalid hola parameter (must be a string)";
31
+ return result;
32
+ }
33
+
34
+ const MAX_HOLA_LENGTH = 512;
35
+ if (hola.length > MAX_HOLA_LENGTH) {
36
+ result.reason = `Hola string exceeds maximum length of ${MAX_HOLA_LENGTH} characters`;
37
+ return result;
38
+ }
39
+
40
+ const prefixLiteral = "HOLA/";
41
+ const normalizedHola = hola.toUpperCase();
42
+ if (!normalizedHola.startsWith(prefixLiteral)) {
43
+ result.reason = "Unsupported protocol; expected HOLA prefix";
44
+ return result;
45
+ }
46
+
47
+ result.checks.formatValid = true;
48
+ const withoutPrefix = normalizedHola.slice(prefixLiteral.length);
49
+ const recipientSeparatorIndex = withoutPrefix.indexOf("/");
50
+ if (recipientSeparatorIndex === -1) {
51
+ result.reason = "Invalid HOLA format: missing destinatary separator";
52
+ return result;
53
+ }
54
+
55
+ const recipientRaw = withoutPrefix.slice(0, recipientSeparatorIndex);
56
+ result.recipient = recipientRaw.length > 0 ? recipientRaw : "MUNDO";
57
+ const afterRecipient = withoutPrefix.slice(recipientSeparatorIndex + 1);
58
+
59
+ const lastSeparatorIndex = afterRecipient.lastIndexOf("/");
60
+ if (lastSeparatorIndex === -1) {
61
+ result.reason = "Invalid HOLA format: missing checksum separator";
62
+ return result;
63
+ }
64
+
65
+ const checksumCharRaw = afterRecipient.slice(lastSeparatorIndex + 1);
66
+ const beforeChecksum = afterRecipient.slice(0, lastSeparatorIndex);
67
+ if (!checksumCharRaw) {
68
+ result.reason = "Missing checksum";
69
+ return result;
70
+ }
71
+ result.checksumChar = checksumCharRaw.toUpperCase();
72
+
73
+ const signatureSeparatorIndex = beforeChecksum.lastIndexOf("/");
74
+ if (signatureSeparatorIndex === -1) {
75
+ result.reason = "Invalid HOLA format: missing signature separator";
76
+ return result;
77
+ }
78
+
79
+ result.signatureB32 = beforeChecksum.slice(signatureSeparatorIndex + 1);
80
+ const beforeSignature = beforeChecksum.slice(0, signatureSeparatorIndex);
81
+ if (!result.signatureB32) {
82
+ result.reason = "Missing signature";
83
+ return result;
84
+ }
85
+
86
+ const protocolSeparatorIndex = beforeSignature.lastIndexOf("/");
87
+ if (protocolSeparatorIndex === -1) {
88
+ result.reason = "Invalid HOLA format: missing protocol marker separator";
89
+ return result;
90
+ }
91
+
92
+ const protocolMarker = beforeSignature.slice(protocolSeparatorIndex + 1);
93
+ if (protocolMarker !== "API.IDENTYCLAW.COM") {
94
+ result.reason = `Invalid protocol marker: expected API.IDENTYCLAW.COM, got ${protocolMarker}`;
95
+ return result;
96
+ }
97
+
98
+ const beforeProtocol = beforeSignature.slice(0, protocolSeparatorIndex);
99
+ const noncetsHexSeparatorIndex = beforeProtocol.lastIndexOf("/");
100
+ if (noncetsHexSeparatorIndex === -1) {
101
+ result.reason = "Invalid HOLA format: missing noncets separator";
102
+ return result;
103
+ }
104
+
105
+ const noncetsHexRaw = beforeProtocol.slice(noncetsHexSeparatorIndex + 1);
106
+ const tokenAndTimestamp = beforeProtocol.slice(0, noncetsHexSeparatorIndex);
107
+ if (!noncetsHexRaw) {
108
+ result.reason = "Missing noncets";
109
+ return result;
110
+ }
111
+
112
+ const noncetsCheck = validateHexNonce(noncetsHexRaw);
113
+ if (!noncetsCheck.valid) {
114
+ result.reason = noncetsCheck.reason;
115
+ return result;
116
+ }
117
+
118
+ result.noncetsHex = noncetsCheck.original ?? noncetsCheck.value;
119
+ result.checks.noncetsValid = true;
120
+
121
+ const senderFields = tokenAndTimestamp.split("/");
122
+ if (senderFields.length === 2) {
123
+ result.tokenId = senderFields[0];
124
+ result.isoTimestamp = senderFields[1];
125
+ } else if (senderFields.length === 4) {
126
+ result.isSubagentFormat = true;
127
+ result.delegateId = senderFields[0];
128
+ result.issuerTokenId = senderFields[1];
129
+ result.subagentPublicKey = senderFields[2];
130
+ result.isoTimestamp = senderFields[3];
131
+ result.tokenId = result.issuerTokenId;
132
+ } else {
133
+ result.reason = "Invalid HOLA format: expected standard or subagent sender fields";
134
+ return result;
135
+ }
136
+
137
+ if (!result.tokenId) {
138
+ result.reason = result.isSubagentFormat ? "Missing issuerTokenId" : "Missing tokenId";
139
+ return result;
140
+ }
141
+
142
+ if (!validateTokenIdFormat(result.tokenId)) {
143
+ result.reason = result.isSubagentFormat
144
+ ? "Invalid issuerTokenId: must be exactly 12 letters"
145
+ : "Invalid tokenId: must be exactly 12 letters";
146
+ return result;
147
+ }
148
+
149
+ if (!result.isoTimestamp) {
150
+ result.reason = "Missing timestamp";
151
+ return result;
152
+ }
153
+
154
+ const tsMs = Date.parse(result.isoTimestamp);
155
+ if (Number.isNaN(tsMs)) {
156
+ result.reason = "Invalid timestamp: must be a valid ISO-8601 string";
157
+ return result;
158
+ }
159
+ result.checks.timestampValid = true;
160
+
161
+ const signedMessage = result.isSubagentFormat
162
+ ? `HOLA/${result.recipient}/${result.delegateId}/${result.issuerTokenId}/${result.subagentPublicKey}/${result.isoTimestamp}/${result.noncetsHex}/API.IDENTYCLAW.COM/`
163
+ : `HOLA/${result.recipient}/${result.tokenId}/${result.isoTimestamp}/${result.noncetsHex}/API.IDENTYCLAW.COM/`;
164
+ const canonicalSignedMessage = signedMessage.toUpperCase();
165
+ const checksumPrefix = `${canonicalSignedMessage}${result.signatureB32}/`;
166
+ const expectedChecksum = computeHolaChecksum(checksumPrefix);
167
+
168
+ if (result.checksumChar !== expectedChecksum) {
169
+ result.reason = `Invalid checksum: expected ${expectedChecksum}, got ${result.checksumChar}`;
170
+ return result;
171
+ }
172
+
173
+ result.checks.checksumValid = true;
174
+ result.valid = true;
175
+ return result;
176
+ }
177
+
178
+ module.exports = { validateHolaMessage };
@@ -0,0 +1,26 @@
1
+ const HOLA_CHECKSUM_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ";
2
+ const HOLA_CHECKSUM_MOD = HOLA_CHECKSUM_ALPHABET.length;
3
+
4
+ function validateHexNonce(nonce) {
5
+ if (typeof nonce !== "string") {
6
+ return { valid: false, value: null, reason: "nonce must be a string" };
7
+ }
8
+ const upper = nonce.toUpperCase();
9
+ if (upper.length === 0) {
10
+ return { valid: false, value: null, reason: "nonce must not be empty" };
11
+ }
12
+ if (!/^[0-9A-F]+$/.test(upper)) {
13
+ return { valid: false, value: null, reason: "nonce must contain only [0-9A-F] characters" };
14
+ }
15
+ return { valid: true, value: upper, original: nonce, reason: null };
16
+ }
17
+
18
+ function computeHolaChecksum(prefix) {
19
+ let sum = 0;
20
+ for (let i = 0; i < prefix.length; i += 1) {
21
+ sum += prefix.charCodeAt(i);
22
+ }
23
+ return HOLA_CHECKSUM_ALPHABET[sum % HOLA_CHECKSUM_MOD];
24
+ }
25
+
26
+ module.exports = { validateHexNonce, computeHolaChecksum };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Parse userselected_dn comma-separated key=value pairs (public subset for verify report).
3
+ */
4
+ function splitDistinguishedName(rawDn) {
5
+ const parts = [];
6
+ let current = "";
7
+ let inQuotes = false;
8
+
9
+ for (let i = 0; i < rawDn.length; i += 1) {
10
+ const ch = rawDn[i];
11
+ if (ch === '"') {
12
+ inQuotes = !inQuotes;
13
+ current += ch;
14
+ continue;
15
+ }
16
+ if (ch === "," && !inQuotes) {
17
+ if (current.trim()) parts.push(current.trim());
18
+ current = "";
19
+ continue;
20
+ }
21
+ current += ch;
22
+ }
23
+ if (current.trim()) parts.push(current.trim());
24
+ return parts;
25
+ }
26
+
27
+ function parseDnComponent(part) {
28
+ const eq = part.indexOf("=");
29
+ if (eq <= 0) return null;
30
+ const attribute = part.slice(0, eq).trim();
31
+ let value = part.slice(eq + 1).trim();
32
+ if (value.startsWith('"') && value.endsWith('"')) {
33
+ value = value.slice(1, -1);
34
+ }
35
+ return { attribute, value };
36
+ }
37
+
38
+ function parseUserSelectedDn(rawDn) {
39
+ if (typeof rawDn !== "string" || rawDn.trim().length === 0) {
40
+ return {
41
+ displayName: null,
42
+ creature: null,
43
+ contactUris: [],
44
+ webhookUrl: null,
45
+ raw: rawDn || null
46
+ };
47
+ }
48
+
49
+ const components = splitDistinguishedName(rawDn.trim())
50
+ .map(parseDnComponent)
51
+ .filter(Boolean);
52
+
53
+ const attributes = {};
54
+ for (const component of components) {
55
+ const key = component.attribute;
56
+ if (Object.prototype.hasOwnProperty.call(attributes, key)) {
57
+ const existing = attributes[key];
58
+ attributes[key] = Array.isArray(existing)
59
+ ? [...existing, component.value]
60
+ : [existing, component.value];
61
+ } else {
62
+ attributes[key] = component.value;
63
+ }
64
+ }
65
+
66
+ const find = (name) =>
67
+ components.find((c) => c.attribute.toUpperCase() === name.toUpperCase()) || null;
68
+
69
+ const nnswf = find("NNSWF");
70
+ const nswf = find("NSWF");
71
+ const displayNameParts = [];
72
+ if (nnswf?.value) displayNameParts.push(nnswf.value);
73
+ if (nswf?.value) displayNameParts.push(nswf.value);
74
+
75
+ const contactUris = components
76
+ .filter((c) => c.attribute.toUpperCase() === "CONTACTURI")
77
+ .map((c) => c.value);
78
+
79
+ return {
80
+ displayName: displayNameParts.length > 0 ? displayNameParts.join(" ").trim() : null,
81
+ creature: attributes.Creature || attributes.creature || null,
82
+ contactUris,
83
+ webhookUrl: attributes.webhook_url || null,
84
+ raw: rawDn.trim(),
85
+ attributes
86
+ };
87
+ }
88
+
89
+ module.exports = { parseUserSelectedDn };
package/lib/report.js ADDED
@@ -0,0 +1,133 @@
1
+ const { parseUserSelectedDn } = require("./parse-dn");
2
+
3
+ const DEFAULT_BASE = "https://api.identyclaw.com";
4
+
5
+ async function verifyHola(hola, { baseUrl = DEFAULT_BASE, jwt, expectedRecipient, includeProfile = false } = {}) {
6
+ const body = { hola };
7
+ if (expectedRecipient) body.expectedRecipient = expectedRecipient;
8
+ if (includeProfile) body.includeProfile = true;
9
+
10
+ const headers = { "Content-Type": "application/json", Accept: "application/json" };
11
+ if (jwt) headers.Authorization = `Bearer ${jwt}`;
12
+
13
+ const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/identity/verify`, {
14
+ method: "POST",
15
+ headers,
16
+ body: JSON.stringify(body)
17
+ });
18
+
19
+ const result = await res.json().catch(() => ({}));
20
+ if (!res.ok) {
21
+ const message =
22
+ result?.error?.message || result?.message || `HTTP ${res.status}`;
23
+ throw new Error(message);
24
+ }
25
+ return result;
26
+ }
27
+
28
+ async function resolveDid(tokenId, { baseUrl = DEFAULT_BASE } = {}) {
29
+ const normalized = String(tokenId || "").trim().toLowerCase();
30
+ if (!/^[a-z]{12}$/.test(normalized)) {
31
+ throw new Error("tokenId must be 12 lowercase letters");
32
+ }
33
+
34
+ const res = await fetch(
35
+ `${baseUrl.replace(/\/$/, "")}/api/mcp/resource/did:resolve:${normalized}`,
36
+ { headers: { Accept: "application/json" } }
37
+ );
38
+
39
+ const payload = await res.json().catch(() => ({}));
40
+ if (!res.ok) {
41
+ throw new Error(payload?.error?.message || `DID resolve HTTP ${res.status}`);
42
+ }
43
+
44
+ const content = payload?.content ?? payload;
45
+ return typeof content === "string" ? JSON.parse(content) : content;
46
+ }
47
+
48
+ function extractProfileFromDid(didDocument) {
49
+ const metadataService = (didDocument?.service || []).find(
50
+ (s) => s.type === "RoditTokenMetadata"
51
+ );
52
+ const metadata = metadataService?.serviceEndpoint?.metadata || {};
53
+ const rawDn = metadata.userselected_dn || null;
54
+ const parsed = parseUserSelectedDn(rawDn);
55
+
56
+ return {
57
+ tokenId: metadataService?.serviceEndpoint?.tokenId || didDocument?.id?.split(":").pop() || null,
58
+ ownerAccountId: metadataService?.serviceEndpoint?.ownerAccountId || didDocument?.controller || null,
59
+ did: didDocument?.id || null,
60
+ displayName: parsed.displayName,
61
+ creature: parsed.creature,
62
+ contactUris: parsed.contactUris,
63
+ webhookUrl: metadata.webhook_url || parsed.webhookUrl || null,
64
+ dn: parsed
65
+ };
66
+ }
67
+
68
+ async function buildHolaReport(hola, options = {}) {
69
+ const {
70
+ baseUrl = DEFAULT_BASE,
71
+ jwt,
72
+ expectedRecipient,
73
+ canonicalPeerId,
74
+ includeProfile = true
75
+ } = options;
76
+
77
+ const verifyResult = await verifyHola(hola, {
78
+ baseUrl,
79
+ jwt,
80
+ expectedRecipient,
81
+ includeProfile
82
+ });
83
+
84
+ const report = {
85
+ verified: Boolean(verifyResult.verified),
86
+ peerTokenId: verifyResult.peerTokenId || null,
87
+ destinatary: verifyResult.destinatary || null,
88
+ checks: verifyResult.checks || {},
89
+ failureReasons: verifyResult.failureReasons || [],
90
+ failureDetails: verifyResult.failureDetails || [],
91
+ warnings: verifyResult.warnings || [],
92
+ requestId: verifyResult.requestId || null,
93
+ impersonationGuard: null,
94
+ profile: null
95
+ };
96
+
97
+ if (canonicalPeerId && report.verified) {
98
+ const expected = String(canonicalPeerId).trim().toLowerCase();
99
+ const matched = report.peerTokenId === expected;
100
+ report.impersonationGuard = {
101
+ expectedTokenId: expected,
102
+ matched,
103
+ passed: matched
104
+ };
105
+ if (!matched) {
106
+ report.verified = false;
107
+ report.failureReasons = [...(report.failureReasons || []), "impersonation_guard_failed"];
108
+ }
109
+ }
110
+
111
+ if (includeProfile && report.peerTokenId && !verifyResult.profile) {
112
+ try {
113
+ const didDocument = await resolveDid(report.peerTokenId, { baseUrl });
114
+ report.profile = extractProfileFromDid(didDocument);
115
+ } catch (error) {
116
+ report.profile = { error: error.message };
117
+ }
118
+ }
119
+
120
+ if (verifyResult.profile) {
121
+ report.profile = { ...report.profile, ...verifyResult.profile };
122
+ }
123
+
124
+ return report;
125
+ }
126
+
127
+ module.exports = {
128
+ DEFAULT_BASE,
129
+ verifyHola,
130
+ resolveDid,
131
+ extractProfileFromDid,
132
+ buildHolaReport
133
+ };
@@ -0,0 +1,250 @@
1
+ const base32 = require("hi-base32");
2
+ const nacl = require("tweetnacl");
3
+ const { validateHolaMessage } = require("./hola-validate");
4
+ const { parseUserSelectedDn } = require("./parse-dn");
5
+
6
+ const DEFAULT_MAX_HOLA_AGE_MS = 5 * 60 * 1000;
7
+ const nonceReplayCache = new Map();
8
+
9
+ function decodeBase32ToBytes(value) {
10
+ const raw = String(value).toUpperCase().replace(/=+$/g, "");
11
+ if (!/^[A-Z2-7]+$/.test(raw)) {
12
+ throw new Error("Invalid base32 input");
13
+ }
14
+ const padded = raw.padEnd(Math.ceil(raw.length / 8) * 8, "=");
15
+ return new Uint8Array(base32.decode.asBytes(padded));
16
+ }
17
+
18
+ function isRoditTokenExpired(token) {
19
+ const notAfter = token?.metadata?.not_after;
20
+ if (!notAfter || String(notAfter).startsWith("1970-01-01")) {
21
+ return false;
22
+ }
23
+ const notAfterMs = Date.parse(String(notAfter));
24
+ if (Number.isNaN(notAfterMs)) {
25
+ return false;
26
+ }
27
+ return Date.now() > notAfterMs;
28
+ }
29
+
30
+ function cleanupExpiredVerifyNonceEntries() {
31
+ const now = Date.now();
32
+ for (const [key, entry] of nonceReplayCache.entries()) {
33
+ if (!entry || entry.expiresAtMs <= now) {
34
+ nonceReplayCache.delete(key);
35
+ }
36
+ }
37
+ }
38
+
39
+ function buildProfileFromToken(token, tokenId) {
40
+ const metadata = token?.metadata || {};
41
+ const parsed = parseUserSelectedDn(metadata.userselected_dn || "");
42
+ return {
43
+ tokenId: String(tokenId).toLowerCase(),
44
+ did: `did:rodit:${String(tokenId).toLowerCase()}`,
45
+ ownerAccountId: token?.owner_id || null,
46
+ displayName: parsed.displayName,
47
+ creature: parsed.creature,
48
+ contactUris: parsed.contactUris,
49
+ webhookUrl: metadata.webhook_url || parsed.webhookUrl || null
50
+ };
51
+ }
52
+
53
+ function ensureNearEnv({ nearRpcUrl, nearContractId } = {}) {
54
+ if (nearRpcUrl) process.env.NEAR_RPC_URL = nearRpcUrl;
55
+ if (nearContractId) process.env.NEAR_CONTRACT_ID = nearContractId;
56
+
57
+ const rpc = process.env.NEAR_RPC_URL;
58
+ const contract = process.env.NEAR_CONTRACT_ID;
59
+ if (!rpc) {
60
+ throw new Error("NEAR_RPC_URL is required for --rpc verification (e.g. https://rpc.mainnet.fastnear.com)");
61
+ }
62
+ if (!contract) {
63
+ throw new Error("NEAR_CONTRACT_ID is required for --rpc verification (e.g. genaaaa-identyclaw-com.near)");
64
+ }
65
+ return { rpc, contract };
66
+ }
67
+
68
+ async function verifyHolaViaRpc(hola, options = {}) {
69
+ const {
70
+ maxAgeMs = DEFAULT_MAX_HOLA_AGE_MS,
71
+ nearRpcUrl,
72
+ nearContractId
73
+ } = options;
74
+
75
+ ensureNearEnv({ nearRpcUrl, nearContractId });
76
+
77
+ const validation = validateHolaMessage(hola);
78
+ if (!validation.valid) {
79
+ return {
80
+ verified: false,
81
+ verifyPath: "near-rpc",
82
+ peerTokenId: validation.tokenId ? String(validation.tokenId).toLowerCase() : null,
83
+ destinatary: validation.recipient,
84
+ checks: {
85
+ tokenExists: false,
86
+ tokenActive: false,
87
+ signatureValid: false,
88
+ timestampFresh: false,
89
+ checksumValid: Boolean(validation.checks?.checksumValid),
90
+ nonceReplaySafe: true
91
+ },
92
+ failureReasons: ["hola_format_invalid"],
93
+ failureDetails: [{ reasonCode: "hola_format_invalid", message: validation.reason }],
94
+ profile: null
95
+ };
96
+ }
97
+
98
+ const lookupTokenId = String(validation.tokenId).toLowerCase();
99
+ const checks = {
100
+ tokenExists: false,
101
+ tokenActive: false,
102
+ signatureValid: false,
103
+ timestampFresh: false,
104
+ checksumValid: true,
105
+ nonceReplaySafe: true
106
+ };
107
+ const failureReasons = [];
108
+
109
+ const tsMs = Date.parse(validation.isoTimestamp);
110
+ const nowSeconds = Math.floor(Date.now() / 1000);
111
+ const tsSeconds = Math.floor(tsMs / 1000);
112
+ const ageSeconds = nowSeconds - tsSeconds;
113
+ const maxAgeSeconds = Math.floor(maxAgeMs / 1000);
114
+ if (ageSeconds >= 0 && ageSeconds <= maxAgeSeconds) {
115
+ checks.timestampFresh = true;
116
+ } else {
117
+ failureReasons.push("timestamp_stale_or_future");
118
+ }
119
+
120
+ cleanupExpiredVerifyNonceEntries();
121
+ const nonceReplayKey = `${lookupTokenId}|${String(validation.noncetsHex).toUpperCase()}`;
122
+ if (nonceReplayCache.has(nonceReplayKey)) {
123
+ checks.nonceReplaySafe = false;
124
+ failureReasons.push("nonce_replay");
125
+ }
126
+
127
+ const { blockchainService } = require("@rodit/rodit-auth-be");
128
+ let token = null;
129
+ let profile = null;
130
+
131
+ try {
132
+ token = await blockchainService.nearorg_rpc_tokenfromroditid(lookupTokenId);
133
+ if (token && token.token_id) {
134
+ checks.tokenExists = true;
135
+ checks.tokenActive = !isRoditTokenExpired(token);
136
+ profile = buildProfileFromToken(token, lookupTokenId);
137
+ if (!checks.tokenActive) {
138
+ failureReasons.push("token_expired");
139
+ }
140
+ } else {
141
+ failureReasons.push("token_missing");
142
+ }
143
+
144
+ if (token && token.owner_id) {
145
+ let publicKeyBytes;
146
+ if (validation.isSubagentFormat) {
147
+ try {
148
+ publicKeyBytes = decodeBase32ToBytes(validation.subagentPublicKey);
149
+ if (publicKeyBytes.length !== 32) {
150
+ failureReasons.push("subagent_public_key_invalid_length");
151
+ publicKeyBytes = null;
152
+ }
153
+ } catch {
154
+ failureReasons.push("subagent_public_key_decode_failed");
155
+ publicKeyBytes = null;
156
+ }
157
+ } else {
158
+ publicKeyBytes = await blockchainService.nearorg_rpc_fetchpublickeybytes(token.owner_id);
159
+ }
160
+
161
+ if (publicKeyBytes && publicKeyBytes.length === 32) {
162
+ const message = validation.isSubagentFormat
163
+ ? `HOLA/${validation.recipient}/${validation.delegateId}/${validation.issuerTokenId}/${validation.subagentPublicKey}/${validation.isoTimestamp}/${validation.noncetsHex}/API.IDENTYCLAW.COM/`
164
+ : `HOLA/${validation.recipient}/${validation.tokenId}/${validation.isoTimestamp}/${validation.noncetsHex}/API.IDENTYCLAW.COM/`;
165
+ const canonicalMessage = message.toUpperCase();
166
+ const messageBytes = new TextEncoder().encode(canonicalMessage);
167
+ const signatureBytes = decodeBase32ToBytes(validation.signatureB32);
168
+ if (nacl.sign.detached.verify(messageBytes, signatureBytes, publicKeyBytes)) {
169
+ checks.signatureValid = true;
170
+ } else {
171
+ failureReasons.push("signature_invalid");
172
+ }
173
+ } else if (!validation.isSubagentFormat) {
174
+ failureReasons.push("public_key_unavailable");
175
+ }
176
+ }
177
+ } catch (error) {
178
+ failureReasons.push("public_key_error");
179
+ return {
180
+ verified: false,
181
+ verifyPath: "near-rpc",
182
+ peerTokenId: lookupTokenId,
183
+ destinatary: validation.recipient,
184
+ checks,
185
+ failureReasons,
186
+ failureDetails: [{ reasonCode: "public_key_error", message: error.message }],
187
+ profile
188
+ };
189
+ }
190
+
191
+ const verified =
192
+ checks.tokenExists &&
193
+ checks.tokenActive &&
194
+ checks.timestampFresh &&
195
+ checks.nonceReplaySafe &&
196
+ checks.signatureValid &&
197
+ checks.checksumValid;
198
+
199
+ if (verified) {
200
+ nonceReplayCache.set(nonceReplayKey, {
201
+ expiresAtMs: tsMs + maxAgeMs,
202
+ firstSeenAtMs: Date.now()
203
+ });
204
+ }
205
+
206
+ const response = {
207
+ verified,
208
+ verifyPath: "near-rpc",
209
+ nearRpcUrl: process.env.NEAR_RPC_URL,
210
+ nearContractId: process.env.NEAR_CONTRACT_ID,
211
+ peerTokenId: lookupTokenId,
212
+ destinatary: validation.recipient,
213
+ checks,
214
+ failureReasons,
215
+ profile
216
+ };
217
+
218
+ if (validation.isSubagentFormat) {
219
+ response.isSubagentFormat = true;
220
+ response.delegateId = validation.delegateId;
221
+ response.issuerTokenId = validation.issuerTokenId;
222
+ response.subagentNote =
223
+ "Subagent delegation requires POST /api/isauthorizedsigner — not checked on --rpc path";
224
+ }
225
+
226
+ return response;
227
+ }
228
+
229
+ async function buildHolaReportViaRpc(hola, options = {}) {
230
+ const { canonicalPeerId, ...rpcOptions } = options;
231
+ const report = await verifyHolaViaRpc(hola, rpcOptions);
232
+
233
+ if (canonicalPeerId && report.verified) {
234
+ const expected = String(canonicalPeerId).trim().toLowerCase();
235
+ const matched = report.peerTokenId === expected;
236
+ report.impersonationGuard = { expectedTokenId: expected, matched, passed: matched };
237
+ if (!matched) {
238
+ report.verified = false;
239
+ report.failureReasons = [...(report.failureReasons || []), "impersonation_guard_failed"];
240
+ }
241
+ }
242
+
243
+ return report;
244
+ }
245
+
246
+ module.exports = {
247
+ verifyHolaViaRpc,
248
+ buildHolaReportViaRpc,
249
+ ensureNearEnv
250
+ };
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@rodit/verify-hola",
3
+ "version": "0.1.3",
4
+ "description": "Verify HOLA lines and build passport reports via api.identyclaw.com (no JWT required for verify)",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "lib/report.js",
8
+ "bin": {
9
+ "verify-hola": "bin/verify-hola.js"
10
+ },
11
+ "dependencies": {
12
+ "@rodit/rodit-auth-be": "9.17.4",
13
+ "hi-base32": "^0.5.1",
14
+ "tweetnacl": "^1.0.3"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "files": [
20
+ "bin",
21
+ "lib",
22
+ "README.md"
23
+ ],
24
+ "keywords": [
25
+ "identyclaw",
26
+ "hola",
27
+ "agent-identity",
28
+ "verification",
29
+ "near",
30
+ "rodit"
31
+ ],
32
+ "author": "Discernible IO",
33
+ "private": false,
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/discernible-io/rodit-sdk.git",
37
+ "directory": "verify-hola"
38
+ },
39
+ "bugs": {
40
+ "url": "https://github.com/discernible-io/rodit-sdk/issues"
41
+ },
42
+ "homepage": "https://github.com/discernible-io/rodit-sdk/tree/main/verify-hola#readme",
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "scripts": {
47
+ "test": "node --test test/report.test.js"
48
+ }
49
+ }