Javascript credential issuing service

This commit is contained in:
Maria Shodunke
2025-04-02 16:04:44 +01:00
parent d89f9fb2f0
commit 3fafeb5292
8 changed files with 562 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
ISSUER_ACCOUNT_SEED="<your_issuer_account_seed>"
PORT=3005

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env node
import dotenv from "dotenv";
import inquirer from "inquirer";
import { Client, Wallet } from "xrpl";
import { lookUpCredentials } from "./look_up_credentials.js";
import { decodeHex } from "./utils.js";
dotenv.config();
async function initWallet() {
let seed = process.env.SUBJECT_ACCOUNT_SEED;
if (!seed) {
const { seedInput } = await inquirer.prompt([
{
type: "password",
name: "seedInput",
message: "Subject account seed:",
validate: (input) => (input ? true : "Please specify the subject's master seed"),
},
]);
seed = seedInput;
}
return Wallet.fromSeed(seed);
}
async function main() {
const XRPL_SERVER = "wss://s.devnet.rippletest.net:51233"
const client = new Client(XRPL_SERVER);
await client.connect();
const wallet = await initWallet();
const pendingCredentials = await lookUpCredentials(client, "", wallet.address, "no");
if (pendingCredentials.length === 0) {
console.log("No pending credentials to accept");
process.exit(0);
}
const choices = pendingCredentials.map((cred, i) => ({
name: `${i+1})'${decodeHex(cred.CredentialType)}' issued by ${cred.Issuer}`,
value: i,
}));
choices.unshift({ name: "0) No, quit.", value: -1 });
const { selectedIndex } = await inquirer.prompt([
{
type: "list",
name: "selectedIndex",
message: "Accept a credential?",
choices,
},
]);
if (selectedIndex === -1) {
process.exit(0);
}
const chosenCred = pendingCredentials[selectedIndex];
const tx = {
TransactionType: "CredentialAccept",
Account: wallet.address,
CredentialType: chosenCred.CredentialType,
Issuer: chosenCred.Issuer,
};
console.log("Submitting transaction:", tx);
const response = await client.submit(tx, { autofill: true, wallet });
console.log("Response:", response);
await client.disconnect();
}
main().catch((err) => {
console.error("❌ Error:", err.message);
process.exit(1);
})

View File

@@ -0,0 +1,172 @@
import {
strToHex,
decodeHex,
datetimeToRippleTime,
rippleTimeToDatetime,
} from "./utils.js";
import { isValidClassicAddress } from "xrpl";
import { ValueError } from "./errors.js";
// Regex constants
const CREDENTIAL_REGEX = /^[A-Za-z0-9_.-]{1,64}$/;
const URI_REGEX = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
/**
* Validate credential request.
*
* This function performs parameter validation. Validated fields:
* - subject (required): the subject of the credential, as a classic address
* - credential (required): the credential type, in human-readable (ASCII) chars
* - uri (optional): URI of the credential in human-readable (ASCII) chars
* - expiration (optional): time when the credential expires (displayed as an ISO 8601 format string in JSON)
*/
export function validateCredentialRequest({ subject, credential, uri, expiration }) {
// Validate subject
if (typeof subject !== "string") {
throw new ValueError("Must provide a string 'subject' field");
}
if (!isValidClassicAddress(subject)) {
throw new ValueError(`subject not valid address: '${subject}'`);
}
// Validate credential
if (typeof credential !== "string") {
throw new ValueError("Must provide a string 'credential' field");
}
/*
Checks if the specified credential type is one that this service issues.
XRPL credential types can be any binary data; this service issues
any credential that can be encoded from the following ASCII chars:
alphanumeric characters, underscore, period, and dash.
(min length 1, max 64)
You might want to further limit the credential types, depending on your
use case; for example, you might only issue one specific credential type.
*/
if (!CREDENTIAL_REGEX.test(credential)) {
throw new ValueError(`credential not allowed: '${credential}'.`);
}
/*
(Optional) Checks if the specified URI is acceptable for this service.
XRPL Credentials' URI values can be any binary data; this service
adds any user-requested URI to a Credential as long as the URI
can be encoded from the characters usually allowed in URIs, namely
the following ASCII chars:
alphanumeric characters (upper and lower case)
the following symbols: -._~:/?#[]@!$&'()*+,;=%
(minimum length 1 and max length 256 chars)
You might want to instead define your own URI and attach it to the
Credential regardless of user input, or you might want to verify that the
URI points to a valid Verifiable Credential document that matches the user.
*/
if (uri !== undefined) {
if (typeof uri !== "string" || !URI_REGEX.test(uri)) {
throw new ValueError(`URI isn't valid: ${uri}`);
}
}
// Validate and parse expiration
let parsedExpiration;
if (expiration !== undefined) {
if (typeof expiration === "string") {
parsedExpiration = new Date(expiration);
} else {
throw new ValueError(`Unsupported expiration format: ${typeof expiration}`);
}
if (isNaN(parsedExpiration.getTime())) {
throw new ValueError(`Invalid expiration date: ${expiration}`);
}
}
return {
subject,
credential,
uri,
expiration: parsedExpiration,
};
}
/**
* As a credential issuer, you typically need to verify some information
* about someone before you issue them a credential. For this example,
* the user passes relevant information in a documents field of the API request.
* The documents are kept confidential, off-chain.
*/
export function verifyDocuments({documents}) {
/*
This is where you would check the user's documents to see if you
should issue the requested Credential to them.
Depending on the type of credentials your service needs, you might
need to implement different types of checks here.
*/
if (typeof documents !== 'object' || Object.keys(documents).length === 0) {
throw new ValueError("you must provide a non-empty 'documents' field");
}
// As a placeholder, this example checks that the documents field
// contains a string field named "reason" containing the word "please".
const reason = documents.reason;
if (typeof reason !== "string") {
throw new ValueError("documents must contain a 'reason' string");
}
if (!reason.toLowerCase().includes("please")) {
throw new ValueError("reason must include 'please'");
}
}
/**
* Convert to a Credential object in a format closer to the XRP Ledger representation.
* Credential type and URI are hexadecimal;
* Expiration, if present, is in seconds since the Ripple Epoch.
*/
export function credentialToXrpl(cred) {
return {
subject: cred.subject,
credential: strToHex(cred.credential),
uri: cred.uri ? strToHex(cred.uri) : undefined,
expiration: cred.expiration ? datetimeToRippleTime(cred.expiration) : undefined,
};
}
// Convert an XRPL ledger entry into a usable credential object
export function parseCredentialFromXrpl(entry) {
const { Subject, CredentialType, URI, Expiration, Flags } = entry;
if (!Subject || !CredentialType) {
throw new Error("Missing required fields from XRPL credential entry");
}
return {
subject: Subject,
credential: decodeHex(CredentialType),
uri: URI ? decodeHex(URI) : undefined,
expiration: Expiration ? rippleTimeToDatetime(Expiration) : undefined,
accepted: Boolean(Flags & 0x00010000), // lsfAccepted
};
}
/**
* Convert a credential object into API-friendly JSON
*/
export function serializeCredential(cred) {
const result = {
subject: cred.subject,
credential: cred.credential,
};
if (cred.uri) result.uri = cred.uri;
if (cred.expiration) result.expiration = cred.expiration.toISOString();
if (cred.accepted !== undefined) result.accepted = cred.accepted;
return result;
}

View File

@@ -0,0 +1,17 @@
export class ValueError extends Error {
constructor(message) {
super(message);
this.name = "ValueError";
this.status = 400;
this.type = "badRequest";
}
}
export class XRPLTxError extends Error {
constructor(xrplResponse, status = 400) {
super("XRPL transaction failed");
this.name = "XRPLTxError";
this.status = status;
this.body = xrplResponse.result;
}
}

View File

@@ -0,0 +1,175 @@
import express from "express";
import morgan from 'morgan';
import inquirer from "inquirer";
import dotenv from "dotenv";
import { Wallet, Client, RippledError } from "xrpl";
import {
validateCredentialRequest,
verifyDocuments,
credentialToXrpl,
serializeCredential,
parseCredentialFromXrpl,
} from "./credential.js";
import { XRPLTxError } from "./errors.js";
import { lookUpCredentials } from "./look_up_credentials.js";
dotenv.config();
async function main() {
// Set up XRPL connection ------------------------------------------------------
const wallet = await initWallet();
console.log("✅ Starting credential issuer with XRPL address", wallet.address);
const client = new Client("wss://s.devnet.rippletest.net:51233");
await client.connect();
// Define Express app ------------------------------------------------------
const app = express();
app.use(morgan('common')) // Logger
app.use(express.json()); // Middleware to parse JSON requests
// POST /credential - Method for users to request a credential from the service -------------------
app.post("/credential", async (req, res) => {
try {
// validateCredentialRequest() throws if the request is not validly formatted
const credRequest = validateCredentialRequest(req.body);
// verifyDocuments() throws if the provided documents don't pass inspection
verifyDocuments(req.body);
const credXrpl = credentialToXrpl(credRequest);
const tx = {
TransactionType: "CredentialCreate",
Account: wallet.address,
Subject: credXrpl.subject,
CredentialType: credXrpl.credential,
URI: credXrpl.uri,
Expiration: credXrpl.expiration,
};
const ccResponse = await client.submit(tx, { autofill: true, wallet });
if (ccResponse.result.engine_result === "tecDUPLICATE") {
throw new XRPLTxError(ccResponse, 409);
} else if (ccResponse.result.engine_result !== "tesSUCCESS") {
throw new XRPLTxError(ccResponse);
}
return res.status(201).json(ccResponse.result);
} catch (err) {
return handleAppError(res, err);
}
});
// GET /admin/credential - Method for admins to look up all credentials issued -------------------
app.get("/admin/credential", async (req, res) => {
try {
// ?accepted=yes|no|both query parameter - the default is "both"
const query = Object.fromEntries(
Object.entries(req.query).map(([k, v]) => [k.toLowerCase(), v])
);
const filterAccepted = (query.accepted || "both").toLowerCase();
const credentials = await lookUpCredentials(client, wallet.address, "", filterAccepted);
const result = credentials.map((entry) =>
serializeCredential(parseCredentialFromXrpl(entry))
);
return res.status(200).json({ credentials: result });
} catch (err) {
return handleAppError(res, err);
}
});
// DELETE /admin/credential - Method for admins to revoke an issued credential ----------------------------
app.delete("/admin/credential", async (req, res) => {
let delRequest;
try {
delRequest = validateCredentialRequest(req.body);
const { credential } = credentialToXrpl(delRequest);
// To save on transaction fees, check if the credential exists on ledger before attempting to delete it.
// If the credential is not found, a RippledError (`entryNotFound`) is thrown.
await client.request({
command: "ledger_entry",
credential: {
subject: delRequest.subject,
issuer: wallet.address,
credential_type: credential,
},
});
const tx = {
TransactionType: "CredentialDelete",
Account: wallet.address,
Subject: delRequest.subject,
CredentialType: credential,
};
const cdResponse = await client.submit(tx, { autofill: true, wallet });
if (cdResponse.result.engine_result === "tecNO_ENTRY") {
// Usually this won't happen since we just checked for the credential,
// but it's possible it got deleted since then.
throw new XRPLTxError(cdResponse, 404);
} else if (cdResponse.result.engine_result !== "tesSUCCESS") {
throw new XRPLTxError(cdResponse);
}
return res.status(200).json(cdResponse.result);
} catch (err) {
if (err instanceof RippledError) {
return res.status(404).json({
error: err.data.error,
error_message: `Credential doesn't exist for subject '${delRequest.subject}' and credential type '${delRequest.credential}'`,
});
} else {
return handleAppError(res, err);
}
}
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🔐 Credential issuer service running on port: ${PORT}`);
});
}
async function initWallet() {
let seed = process.env.ISSUER_ACCOUNT_SEED;
if (!seed) {
const { seedInput } = await inquirer.prompt([
{
type: "password",
name: "seedInput",
message: "Issuer account seed:",
validate: (input) => (input ? true : "Please specify the issuer's master seed"),
},
]);
seed = seedInput;
}
return Wallet.fromSeed(seed);
}
// Error handling --------------------------------------------------------------
function handleAppError(res, err) {
if (err.name === "ValueError") {
return res.status(err.status).json({
error: err.type,
error_message: err.message,
});
}
if (err.name === "XRPLTxError") {
return res.status(err.status).json(err.body);
}
// Default fallback
return res.status(400).json({ error_message: err.message });
}
// Start the server --------------------------------------------------------------
main().catch((err) => {
console.error("❌ Fatal startup error:", err);
process.exit(1);
});

View File

@@ -0,0 +1,52 @@
import { ValueError } from "./errors.js";
const lsfAccepted = 0x00010000;
/**
* Looks up Credentials issued by/to a specified XRPL account, optionally
* filtering by accepted status. Handles pagination.
*/
export async function lookUpCredentials(client, issuer, subject, accepted = "both") {
const account = issuer || subject;
if (!account) {
throw new ValueError("Must specify issuer or subject");
}
accepted = accepted.toLowerCase();
if (!["yes", "no", "both"].includes(accepted)) {
throw new ValueError("accepted must be 'yes', 'no', or 'both'");
}
const credentials = [];
let request = {
command: "account_objects",
account,
type: "credential",
};
// Fetch first page
let response = await client.request(request);
while (true) {
for (const obj of response.result.account_objects) {
if (issuer && obj.Issuer !== issuer) continue;
if (subject && obj.Subject !== subject) continue;
const credAccepted = Boolean(obj.Flags & lsfAccepted);
if (accepted === "yes" && !credAccepted) continue;
if (accepted === "no" && credAccepted) continue;
credentials.push(obj);
}
if (!response.result.marker) break;
/**
* If there is marker, request the next page using the convenience function "requestNextPage()".
* See https://js.xrpl.org/classes/Client.html#requestnextpage to learn more.
**/
response = await client.requestNextPage(request, response.result);
}
return credentials;
}

View File

@@ -0,0 +1,20 @@
{
"name": "issuer_service",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"dotenv": "^16.4.7",
"express": "^5.1.0",
"inquirer": "^12.5.2",
"morgan": "^1.10.0",
"xrpl": "^4.2.0"
}
}

View File

@@ -0,0 +1,44 @@
// Validate credential type (164 ASCII alphanum, underscore, dash, dot)
export function isAllowedCredentialType(credential) {
const regex = /^[A-Za-z0-9_.-]{1,64}$/;
return regex.test(credential);
}
// Validate URI (1256 characters, URL-safe)
export function isAllowedUri(uri) {
const regex = /^[A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]{1,256}$/;
return regex.test(uri);
}
// String → hex (uppercase)
export function strToHex(str) {
return Buffer.from(str, "utf8").toString("hex").toUpperCase();
}
export function decodeHex(sHex) {
/**
* Try decoding a hex string as ASCII; return the decoded string on success,
* or the un-decoded string prefixed by '(BIN) ' on failure.
*/
try {
const buffer = Buffer.from(sHex, "hex");
return buffer.toString("ascii");
// Could use utf-8 instead, but it has more edge cases.
// Optionally, sanitize the string for display before returning
} catch (err) {
return "(BIN) " + sHex;
}
}
// JS Date → Ripple epoch seconds
export function datetimeToRippleTime(date) {
const rippleEpoch = 946684800; // 2000-01-01T00:00:00Z
const seconds = Math.floor(date.getTime() / 1000);
return seconds - rippleEpoch;
}
// Ripple epoch seconds → JS Date
export function rippleTimeToDatetime(rippleTime) {
const rippleEpoch = 946684800;
return new Date((rippleTime + rippleEpoch) * 1000);
}