From 22d5f3dfea5acebb09f88dca931357d43d41e29a Mon Sep 17 00:00:00 2001 From: Finn <139806439+finn-tbd@users.noreply.github.com> Date: Thu, 16 Nov 2023 06:04:20 -0800 Subject: [PATCH] add sdk-development compliance validation to CI (#291) * Add sdk-development test harness --- .github/workflows/tests-ci.yml | 7 + .web5-spec/Dockerfile | 9 + .web5-spec/README.md | 3 + .web5-spec/credentials.ts | 53 ++++ .web5-spec/did-ion.ts | 15 + .web5-spec/encoders.ts | 40 +++ .web5-spec/main.ts | 53 ++++ .web5-spec/openapi.d.ts | 498 +++++++++++++++++++++++++++++++++ .web5-spec/tsconfig.json | 27 ++ 9 files changed, 705 insertions(+) create mode 100644 .web5-spec/Dockerfile create mode 100644 .web5-spec/README.md create mode 100644 .web5-spec/credentials.ts create mode 100644 .web5-spec/did-ion.ts create mode 100644 .web5-spec/encoders.ts create mode 100644 .web5-spec/main.ts create mode 100644 .web5-spec/openapi.d.ts create mode 100644 .web5-spec/tsconfig.json diff --git a/.github/workflows/tests-ci.yml b/.github/workflows/tests-ci.yml index 04c379a58..7129d5ae0 100644 --- a/.github/workflows/tests-ci.yml +++ b/.github/workflows/tests-ci.yml @@ -140,3 +140,10 @@ jobs: - file: packages/api/src/index.ts docsReporter: api-extractor docsGenerator: typedoc-markdown + + web5-spec: + runs-on: ubuntu-latest + steps: + - name: Checkout source + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: TBD54566975/sdk-development@v0.2.3 diff --git a/.web5-spec/Dockerfile b/.web5-spec/Dockerfile new file mode 100644 index 000000000..3403b8cca --- /dev/null +++ b/.web5-spec/Dockerfile @@ -0,0 +1,9 @@ +FROM node:18-alpine +RUN apk add --no-cache git python3 make g++ +ADD . /web5-js +WORKDIR /web5-js +RUN npm ci --ws +RUN npm install --no-save express express-openapi +RUN npm run build +RUN npx tsc -p .web5-spec/tsconfig.json +CMD ["node", ".web5-spec/dist/main.js"] diff --git a/.web5-spec/README.md b/.web5-spec/README.md new file mode 100644 index 000000000..728ee14f9 --- /dev/null +++ b/.web5-spec/README.md @@ -0,0 +1,3 @@ +# web5-spec + +this directory contains glue to allow [our standardized test suite](https://github.com/TBD54566975/sdk-development) to run against the web5-js SDK. diff --git a/.web5-spec/credentials.ts b/.web5-spec/credentials.ts new file mode 100644 index 000000000..39908fb4a --- /dev/null +++ b/.web5-spec/credentials.ts @@ -0,0 +1,53 @@ +import { Request, Response } from 'express'; +import { VerifiableCredential, SignOptions } from '@web5/credentials'; +import { DidKeyMethod, PortableDid } from '@web5/dids'; +import { Ed25519, Jose } from '@web5/crypto'; +import { paths } from './openapi.js'; + +type Signer = (data: Uint8Array) => Promise; + +let _ownDid: PortableDid; + +async function getOwnDid(): Promise { + if(_ownDid) { + return _ownDid; + } + _ownDid = await DidKeyMethod.create(); + return _ownDid; +} + +export async function credentialIssue(req: Request, res: Response) { + const body: paths["/credentials/issue"]["post"]["requestBody"]["content"]["application/json"] = + req.body; + + const ownDid = await getOwnDid() + + // build signing options + const [signingKeyPair] = ownDid.keySet.verificationMethodKeys!; + const privateKey = (await Jose.jwkToKey({ key: signingKeyPair.privateKeyJwk!})).keyMaterial; + const subjectIssuerDid = body.credential.credentialSubject["id"] as string; + const signer = EdDsaSigner(privateKey); + const signOptions: SignOptions = { + issuerDid : ownDid.did, + subjectDid : subjectIssuerDid, + kid : '#' + ownDid.did.split(':')[2], + signer : signer + }; + + const vc: VerifiableCredential = VerifiableCredential.create(body.credential.type[body.credential.type.length - 1], body.credential.issuer, subjectIssuerDid, body.credential.credentialSubject); + const vcJwt: string = await vc.sign(signOptions); + + const resp: paths["/credentials/issue"]["post"]["responses"]["200"]["content"]["application/json"] = + { + verifiableCredential: {data: vcJwt} + }; + + res.json(resp); +} + +function EdDsaSigner(privateKey: Uint8Array): Signer { + return async (data: Uint8Array): Promise => { + const signature = await Ed25519.sign({ data, key: privateKey}); + return signature; + }; +} \ No newline at end of file diff --git a/.web5-spec/did-ion.ts b/.web5-spec/did-ion.ts new file mode 100644 index 000000000..5bde69ec1 --- /dev/null +++ b/.web5-spec/did-ion.ts @@ -0,0 +1,15 @@ +import { DidIonMethod } from "@web5/dids"; +import { paths } from "./openapi.js"; +import { Request, Response } from "express"; + + +export async function didIonCreate(req: Request, res: Response) { + const did = await DidIonMethod.create({}); + + const resp: paths["/did-ion/create"]["post"]["responses"]["200"]["content"]["application/json"] = + { + did: did.did, + }; + + res.json(resp); +} diff --git a/.web5-spec/encoders.ts b/.web5-spec/encoders.ts new file mode 100644 index 000000000..abe740cf5 --- /dev/null +++ b/.web5-spec/encoders.ts @@ -0,0 +1,40 @@ +import { paths } from "./openapi.js"; +import { Request, Response } from "express"; +import { Convert } from "@web5/common"; +import { sha256 } from "@noble/hashes/sha256"; + +export function encoderBase64Encode(req: Request, res: Response) { + const requestBody: paths["/encoders/base64/encode"]["post"]["requestBody"]["content"]["application/json"] = + req.body; + + const resp: paths["/encoders/base64/encode"]["post"]["responses"]["200"]["content"]["application/json"] = + { + data: Convert.string(requestBody.data).toBase64Url(), + }; + + res.json(resp); +} + +export function encoderBase64Decode(req: Request, res: Response) { + const requestBody: paths["/encoders/base64/encode"]["post"]["requestBody"]["content"]["application/json"] = + req.body; + + const resp: paths["/encoders/base64/encode"]["post"]["responses"]["200"]["content"]["application/json"] = + { + data: Convert.base64Url(requestBody.data).toString(), + }; + + res.json(resp); +} + +export function encoderSha256Encode(req: Request, res: Response) { + const requestBody: paths["/encoders/sha256/encode"]["post"]["requestBody"]["content"]["application/json"] = + req.body; + + const resp: paths["/encoders/sha256/encode"]["post"]["responses"]["200"]["content"]["application/json"] = + { + data: Convert.arrayBuffer(sha256(requestBody.data)).toHex(), + }; + + res.json(resp); +} diff --git a/.web5-spec/main.ts b/.web5-spec/main.ts new file mode 100644 index 000000000..406fdca82 --- /dev/null +++ b/.web5-spec/main.ts @@ -0,0 +1,53 @@ +import express from "express"; +import { credentialIssue } from "./credentials.js"; +import { didIonCreate } from "./did-ion.js"; +import { + encoderBase64Decode, + encoderBase64Encode, + encoderSha256Encode, +} from "./encoders.js"; +import type * as http from "http"; +import type { Request, Response } from "express"; +import { paths } from "./openapi.js"; // generated with npx openapi-typescript .web5-component/openapi.yaml -o .web5-component/openapi.d.ts +import bodyparser from "body-parser"; + +const app: express.Application = express(); +app.use(express.json()); +app.use(bodyparser.json()); + +app.post("/did-ion/create", didIonCreate); + +app.post("/credentials/issue", credentialIssue); + +app.post("/encoders/base64/encode", encoderBase64Encode); +app.post("/encoders/base64/decode", encoderBase64Decode); +app.post("/encoders/sha256/encode", encoderSha256Encode); + +const serverID: paths["/"]["get"]["responses"]["200"]["content"]["application/json"] = + { + name: "web5-js", + language: "JavaScript", + url: "https://github.com/TBD54566975/web5-js", + }; +app.get("/", (req, res) => { + res.json(serverID); +}); + +let server: http.Server; +app.get("/shutdown", (req: Request, res: Response) => { + res.send("ok"); + console.log("shutting down server"); + server.close((e) => { + if (e) { + console.error("error shutting down server:", e.stack || e); + } + }); +}); + +server = app.listen(8080, () => console.log("test server started")); +process.on("SIGTERM", () => { + console.log("SIGTERM signal received: closing HTTP server"); + server.close(() => { + console.log("HTTP server closed"); + }); +}); diff --git a/.web5-spec/openapi.d.ts b/.web5-spec/openapi.d.ts new file mode 100644 index 000000000..953e4eefd --- /dev/null +++ b/.web5-spec/openapi.d.ts @@ -0,0 +1,498 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + + +export interface paths { + "/did-ion/create": { + post: operations["did_ion_create"]; + }; + "/did-ion/update": { + post: operations["did_ion_update"]; + }; + "/did-ion/recover": { + post: operations["did_ion_recover"]; + }; + "/did-ion/deactivate": { + post: operations["did_ion_deactivate"]; + }; + "/did-ion/resolve": { + post: operations["did_ion_resolve"]; + }; + "/did-ion/anchor": { + post: operations["did_ion_anchor"]; + }; + "/did-key/create": { + post: operations["did_key_create"]; + }; + "/did-key/resolve": { + post: operations["did_key_resolve"]; + }; + "/credentials/presentation-exchange": { + post: operations["credential_presentation_exchange"]; + }; + "/credentials/issue": { + post: operations["credential_issue"]; + }; + "/crypto/generate-key/secp256k1": { + post: operations["crypto_generate_key_secp256k1"]; + }; + "/crypto/generate-key/ed25519": { + post: operations["crypto_generate_key_ed25519"]; + }; + "/crypto/generate-key/secp256r1": { + post: operations["crypto_generate_key_secp256r1"]; + }; + "/crypto/verify/secp256k1": { + post: operations["crypto_verify_secp256k1"]; + }; + "/crypto/verify/ed25519": { + post: operations["crypto_verify_ed25519"]; + }; + "/crypto/verify/secp256r1": { + post: operations["crypto_verify_secp256r1"]; + }; + "/crypto/jose/jws-create": { + post: operations["crypto_jose_jws_create"]; + }; + "/crypto/jose/jws-verify": { + post: operations["crypto_jose_jws_verify"]; + }; + "/crypto/jose/jwk-encode": { + post: operations["crypto_jose_jwk_encode"]; + }; + "/crypto/jose/jwk-decode": { + post: operations["crypto_jose_jwk_decode"]; + }; + "/crypto/jose/jwt-create": { + post: operations["crypto_jose_jwt_create"]; + }; + "/crypto/jose/jwt-verify": { + post: operations["crypto_jose_jwt_verify"]; + }; + "/crypto/key-manager/generate-key": { + post: operations["crypto_key_manager_generate_key"]; + }; + "/crypto/key-manager/import-key": { + post: operations["crypto_key_manager_import_key"]; + }; + "/crypto/key-manager/sign": { + post: operations["crypto_key_manager_sign"]; + }; + "/crypto/key-manager/verify": { + post: operations["crypto_key_manager_verify"]; + }; + "/encoders/base64/encode": { + post: operations["encoders_base64_encode"]; + }; + "/encoders/base64/decode": { + post: operations["encoders_base64_decode"]; + }; + "/encoders/base58/encode": { + post: operations["encoders_base58_encode"]; + }; + "/encoders/base58/decode": { + post: operations["encoders_base58_decode"]; + }; + "/encoders/sha256/encode": { + post: operations["encoders_sha256_encode"]; + }; + "/encoders/cbor/encode": { + post: operations["encoders_cbor_encode"]; + }; + "/encoders/cbor/decode": { + post: operations["encoders_cbor_decode"]; + }; + "/ready": { + get: operations["server_ready"]; + }; + "/shutdown": { + get: operations["server_shutdown"]; + }; + "/": { + get: operations["identify_self"]; + }; +} + +export type webhooks = Record; + +export interface components { + schemas: { + CredentialIssuanceRequest: { + credential: components["schemas"]["CredentialIssuanceRequestCredential"]; + options: components["schemas"]["CredentialIssuanceRequestOptions"]; + }; + CredentialIssuanceRequestCredential: { + "@context": string[]; + id: string; + type: string[]; + issuer: string; + issuanceDate: string; + expirationDate: string; + credentialSubject: components["schemas"]["CredentialSubject"]; + }; + CredentialIssuanceRequestOptions: { + created: string; + challenge: string; + domain: string; + credentialStatus: components["schemas"]["CredentialStatus"]; + }; + CredentialIssuer: { + id: string; + }; + CredentialSubject: { + [key: string]: unknown; + }; + CredentialStatus: { + type: string; + }; + CredentialIssuanceResponse: { + verifiableCredential: components["schemas"]["StringEncodedData"]; + }; + VerifiableCredential: { + "@context": string[]; + id: string; + type: string[]; + issuer: components["schemas"]["CredentialIssuer"]; + issuanceDate: string; + expirationDate: string; + credentialSubject: components["schemas"]["CredentialSubject"]; + proof: components["schemas"]["CredentialProof"]; + }; + CredentialProof: { + type: string; + created: string; + challenge: string; + domain: string; + nonce: string; + verificationMethod: string; + proofPurpose: string; + jws: string; + proofValue: string; + }; + TestServerID: { + name: string; + language: string; + url: string; + }; + DIDIonCreateResponse: { + did: string; + }; + StringEncodedData: { + data: string; + }; + PresentationExchangeRequest: { + presentationDefinition?: components["schemas"]["PresentationDefinition"]; + vcJwts?: string[]; + }; + PresentationDefinition: { + id?: string; + name: string; + purpose?: string; + submissionRequirements?: components["schemas"]["PresentationDefinitionSubmissionRequirement"][]; + inputDescriptors: components["schemas"]["PresentationDefinitionInputDescriptor"][]; + }; + PresentationDefinitionSubmissionRequirement: { + name?: string; + purpose?: string; + /** @enum {string} */ + rule: "all" | "pick"; + count?: number; + min?: number; + max?: number; + from?: string; + fromNested?: components["schemas"]["PresentationDefinitionSubmissionRequirement"][]; + }; + PresentationDefinitionInputDescriptor: { + id: string; + name?: string; + purpose?: string; + group?: string[]; + issuance?: components["schemas"]["PresentationDefinitionIssuance"][]; + constraints?: components["schemas"]["PresentationDefinitionConstraints"]; + }; + PresentationDefinitionIssuance: { + manifest?: string; + }; + PresentationDefinitionConstraints: { + /** @enum {string} */ + limitDisclosure?: "required" | "preferred"; + statuses?: components["schemas"]["PresentationDefinitionStatuses"]; + fields?: components["schemas"]["PresentationDefinitionField"][]; + /** @enum {string} */ + subjectIsIssuer?: "required" | "preferred"; + isHolder?: components["schemas"]["PresentationDefinitionHolderSubject"][]; + sameSubject?: components["schemas"]["PresentationDefinitionHolderSubject"][]; + }; + PresentationDefinitionHolderSubject: { + fieldId?: string[]; + /** @enum {string} */ + directive?: "required" | "preferred"; + }; + PresentationDefinitionField: { + id?: string; + path?: string[]; + purpose?: string; + filter?: components["schemas"]["PresentationDefinitionFilter"]; + /** @enum {string} */ + predicate?: "required" | "preferred"; + name?: string; + }; + PresentationDefinitionFilter: { + const?: string; + enum?: string[]; + exclusiveMinimum?: string; + exclusiveMaximum?: string; + format?: string; + formatMaximum?: string; + formatMinimum?: string; + formatExclusiveMaximum?: string; + formatExclusiveMinimum?: string; + minLength?: number; + maxLength?: number; + minimum?: string; + maximum?: string; + pattern?: string; + type?: string; + }; + PresentationDefinitionStatuses: { + active?: components["schemas"]["PresentationDefinitionStatus"]; + suspended?: components["schemas"]["PresentationDefinitionStatus"]; + revoked?: components["schemas"]["PresentationDefinitionStatus"]; + }; + PresentationDefinitionStatus: { + /** @enum {string} */ + directive?: "required" | "allowed" | "disallowed"; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} + +export type $defs = Record; + +export type external = Record; + +export interface operations { + + did_ion_create: { + responses: { + 200: { + content: { + "application/json": components["schemas"]["DIDIonCreateResponse"]; + }; + }; + }; + }; + did_ion_update: { + }; + did_ion_recover: { + }; + did_ion_deactivate: { + }; + did_ion_resolve: { + }; + did_ion_anchor: { + }; + did_key_create: { + }; + did_key_resolve: { + }; + credential_presentation_exchange: { + requestBody: { + content: { + "application/json": components["schemas"]["PresentationExchangeRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + credential_issue: { + requestBody: { + content: { + "application/json": components["schemas"]["CredentialIssuanceRequest"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["CredentialIssuanceResponse"]; + }; + }; + }; + }; + crypto_generate_key_secp256k1: { + }; + crypto_generate_key_ed25519: { + }; + crypto_generate_key_secp256r1: { + }; + crypto_verify_secp256k1: { + }; + crypto_verify_ed25519: { + }; + crypto_verify_secp256r1: { + }; + crypto_jose_jws_create: { + }; + crypto_jose_jws_verify: { + }; + crypto_jose_jwk_encode: { + }; + crypto_jose_jwk_decode: { + }; + crypto_jose_jwt_create: { + }; + crypto_jose_jwt_verify: { + }; + crypto_key_manager_generate_key: { + }; + crypto_key_manager_import_key: { + }; + crypto_key_manager_sign: { + }; + crypto_key_manager_verify: { + }; + encoders_base64_encode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + encoders_base64_decode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + encoders_base58_encode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + encoders_base58_decode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + encoders_sha256_encode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + encoders_cbor_encode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + encoders_cbor_decode: { + requestBody: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + responses: { + /** @description Successful operation */ + 200: { + content: { + "application/json": components["schemas"]["StringEncodedData"]; + }; + }; + }; + }; + server_ready: { + responses: { + /** @description server is ready */ + 200: { + content: never; + }; + }; + }; + server_shutdown: { + responses: { + /** @description server will shut down */ + 204: { + content: never; + }; + }; + }; + identify_self: { + responses: { + /** @description information about the test server */ + 200: { + content: { + "application/json": components["schemas"]["TestServerID"]; + }; + }; + }; + }; +} diff --git a/.web5-spec/tsconfig.json b/.web5-spec/tsconfig.json new file mode 100644 index 000000000..46b6bcd42 --- /dev/null +++ b/.web5-spec/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + // "strict": true, + "lib": [ + "DOM", + "ES6" + ], + "allowJs": true, + "target": "es6", + "module": "NodeNext", + "declaration": true, + "declarationMap": true, + "declarationDir": "dist/types", + "outDir": "dist", + // `NodeNext` will throw compilation errors if relative import paths are missing file extension + // reference: https://devblogs.microsoft.com/typescript/announcing-typescript-4-7/#ecmascript-module-support-in-node-js + "moduleResolution": "NodeNext", + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": [ + "main.ts", + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file