Skip to content

Commit 3fafeb5

Browse files
committed
Javascript credential issuing service
1 parent d89f9fb commit 3fafeb5

File tree

8 files changed

+562
-0
lines changed

8 files changed

+562
-0
lines changed
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ISSUER_ACCOUNT_SEED="<your_issuer_account_seed>"
2+
PORT=3005
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
#!/usr/bin/env node
2+
3+
import dotenv from "dotenv";
4+
import inquirer from "inquirer";
5+
import { Client, Wallet } from "xrpl";
6+
import { lookUpCredentials } from "./look_up_credentials.js";
7+
import { decodeHex } from "./utils.js";
8+
9+
dotenv.config();
10+
11+
async function initWallet() {
12+
let seed = process.env.SUBJECT_ACCOUNT_SEED;
13+
if (!seed) {
14+
const { seedInput } = await inquirer.prompt([
15+
{
16+
type: "password",
17+
name: "seedInput",
18+
message: "Subject account seed:",
19+
validate: (input) => (input ? true : "Please specify the subject's master seed"),
20+
},
21+
]);
22+
23+
seed = seedInput;
24+
}
25+
26+
return Wallet.fromSeed(seed);
27+
}
28+
29+
async function main() {
30+
const XRPL_SERVER = "wss://s.devnet.rippletest.net:51233"
31+
const client = new Client(XRPL_SERVER);
32+
await client.connect();
33+
34+
const wallet = await initWallet();
35+
36+
const pendingCredentials = await lookUpCredentials(client, "", wallet.address, "no");
37+
if (pendingCredentials.length === 0) {
38+
console.log("No pending credentials to accept");
39+
process.exit(0);
40+
}
41+
42+
const choices = pendingCredentials.map((cred, i) => ({
43+
name: `${i+1})'${decodeHex(cred.CredentialType)}' issued by ${cred.Issuer}`,
44+
value: i,
45+
}));
46+
choices.unshift({ name: "0) No, quit.", value: -1 });
47+
48+
const { selectedIndex } = await inquirer.prompt([
49+
{
50+
type: "list",
51+
name: "selectedIndex",
52+
message: "Accept a credential?",
53+
choices,
54+
},
55+
]);
56+
57+
if (selectedIndex === -1) {
58+
process.exit(0);
59+
}
60+
61+
const chosenCred = pendingCredentials[selectedIndex];
62+
const tx = {
63+
TransactionType: "CredentialAccept",
64+
Account: wallet.address,
65+
CredentialType: chosenCred.CredentialType,
66+
Issuer: chosenCred.Issuer,
67+
};
68+
69+
console.log("Submitting transaction:", tx);
70+
const response = await client.submit(tx, { autofill: true, wallet });
71+
console.log("Response:", response);
72+
73+
await client.disconnect();
74+
}
75+
76+
main().catch((err) => {
77+
console.error("❌ Error:", err.message);
78+
process.exit(1);
79+
})
80+
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
strToHex,
3+
decodeHex,
4+
datetimeToRippleTime,
5+
rippleTimeToDatetime,
6+
} from "./utils.js";
7+
8+
import { isValidClassicAddress } from "xrpl";
9+
10+
import { ValueError } from "./errors.js";
11+
12+
// Regex constants
13+
const CREDENTIAL_REGEX = /^[A-Za-z0-9_.-]{1,64}$/;
14+
const URI_REGEX = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
15+
16+
/**
17+
* Validate credential request.
18+
*
19+
* This function performs parameter validation. Validated fields:
20+
* - subject (required): the subject of the credential, as a classic address
21+
* - credential (required): the credential type, in human-readable (ASCII) chars
22+
* - uri (optional): URI of the credential in human-readable (ASCII) chars
23+
* - expiration (optional): time when the credential expires (displayed as an ISO 8601 format string in JSON)
24+
*/
25+
export function validateCredentialRequest({ subject, credential, uri, expiration }) {
26+
// Validate subject
27+
if (typeof subject !== "string") {
28+
throw new ValueError("Must provide a string 'subject' field");
29+
}
30+
if (!isValidClassicAddress(subject)) {
31+
throw new ValueError(`subject not valid address: '${subject}'`);
32+
}
33+
34+
// Validate credential
35+
if (typeof credential !== "string") {
36+
throw new ValueError("Must provide a string 'credential' field");
37+
}
38+
39+
/*
40+
Checks if the specified credential type is one that this service issues.
41+
42+
XRPL credential types can be any binary data; this service issues
43+
any credential that can be encoded from the following ASCII chars:
44+
alphanumeric characters, underscore, period, and dash.
45+
(min length 1, max 64)
46+
47+
You might want to further limit the credential types, depending on your
48+
use case; for example, you might only issue one specific credential type.
49+
*/
50+
if (!CREDENTIAL_REGEX.test(credential)) {
51+
throw new ValueError(`credential not allowed: '${credential}'.`);
52+
}
53+
54+
/*
55+
(Optional) Checks if the specified URI is acceptable for this service.
56+
57+
XRPL Credentials' URI values can be any binary data; this service
58+
adds any user-requested URI to a Credential as long as the URI
59+
can be encoded from the characters usually allowed in URIs, namely
60+
the following ASCII chars:
61+
62+
alphanumeric characters (upper and lower case)
63+
the following symbols: -._~:/?#[]@!$&'()*+,;=%
64+
(minimum length 1 and max length 256 chars)
65+
66+
You might want to instead define your own URI and attach it to the
67+
Credential regardless of user input, or you might want to verify that the
68+
URI points to a valid Verifiable Credential document that matches the user.
69+
*/
70+
if (uri !== undefined) {
71+
if (typeof uri !== "string" || !URI_REGEX.test(uri)) {
72+
throw new ValueError(`URI isn't valid: ${uri}`);
73+
}
74+
}
75+
76+
// Validate and parse expiration
77+
let parsedExpiration;
78+
if (expiration !== undefined) {
79+
if (typeof expiration === "string") {
80+
parsedExpiration = new Date(expiration);
81+
} else {
82+
throw new ValueError(`Unsupported expiration format: ${typeof expiration}`);
83+
}
84+
85+
if (isNaN(parsedExpiration.getTime())) {
86+
throw new ValueError(`Invalid expiration date: ${expiration}`);
87+
}
88+
}
89+
90+
return {
91+
subject,
92+
credential,
93+
uri,
94+
expiration: parsedExpiration,
95+
};
96+
}
97+
98+
/**
99+
* As a credential issuer, you typically need to verify some information
100+
* about someone before you issue them a credential. For this example,
101+
* the user passes relevant information in a documents field of the API request.
102+
* The documents are kept confidential, off-chain.
103+
*/
104+
export function verifyDocuments({documents}) {
105+
/*
106+
This is where you would check the user's documents to see if you
107+
should issue the requested Credential to them.
108+
Depending on the type of credentials your service needs, you might
109+
need to implement different types of checks here.
110+
*/
111+
if (typeof documents !== 'object' || Object.keys(documents).length === 0) {
112+
throw new ValueError("you must provide a non-empty 'documents' field");
113+
}
114+
115+
// As a placeholder, this example checks that the documents field
116+
// contains a string field named "reason" containing the word "please".
117+
const reason = documents.reason;
118+
if (typeof reason !== "string") {
119+
throw new ValueError("documents must contain a 'reason' string");
120+
}
121+
122+
if (!reason.toLowerCase().includes("please")) {
123+
throw new ValueError("reason must include 'please'");
124+
}
125+
}
126+
127+
/**
128+
* Convert to a Credential object in a format closer to the XRP Ledger representation.
129+
* Credential type and URI are hexadecimal;
130+
* Expiration, if present, is in seconds since the Ripple Epoch.
131+
*/
132+
export function credentialToXrpl(cred) {
133+
return {
134+
subject: cred.subject,
135+
credential: strToHex(cred.credential),
136+
uri: cred.uri ? strToHex(cred.uri) : undefined,
137+
expiration: cred.expiration ? datetimeToRippleTime(cred.expiration) : undefined,
138+
};
139+
}
140+
141+
// Convert an XRPL ledger entry into a usable credential object
142+
export function parseCredentialFromXrpl(entry) {
143+
const { Subject, CredentialType, URI, Expiration, Flags } = entry;
144+
145+
if (!Subject || !CredentialType) {
146+
throw new Error("Missing required fields from XRPL credential entry");
147+
}
148+
149+
return {
150+
subject: Subject,
151+
credential: decodeHex(CredentialType),
152+
uri: URI ? decodeHex(URI) : undefined,
153+
expiration: Expiration ? rippleTimeToDatetime(Expiration) : undefined,
154+
accepted: Boolean(Flags & 0x00010000), // lsfAccepted
155+
};
156+
}
157+
158+
/**
159+
* Convert a credential object into API-friendly JSON
160+
*/
161+
export function serializeCredential(cred) {
162+
const result = {
163+
subject: cred.subject,
164+
credential: cred.credential,
165+
};
166+
167+
if (cred.uri) result.uri = cred.uri;
168+
if (cred.expiration) result.expiration = cred.expiration.toISOString();
169+
if (cred.accepted !== undefined) result.accepted = cred.accepted;
170+
171+
return result;
172+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export class ValueError extends Error {
2+
constructor(message) {
3+
super(message);
4+
this.name = "ValueError";
5+
this.status = 400;
6+
this.type = "badRequest";
7+
}
8+
}
9+
10+
export class XRPLTxError extends Error {
11+
constructor(xrplResponse, status = 400) {
12+
super("XRPL transaction failed");
13+
this.name = "XRPLTxError";
14+
this.status = status;
15+
this.body = xrplResponse.result;
16+
}
17+
}

0 commit comments

Comments
 (0)