Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,4 @@ dist
*.code-workspace

# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
compose.yaml
2,538 changes: 1,508 additions & 1,030 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion services/payload/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@faceless-ui/modal": "2.0.1",
"@faceless-ui/window-info": "^2.1.1",
"@learncard/didkit-plugin": "^1.4.4",
"@learncard/init": "^1.2.19",
"@learncard/init": "^1.2.20",
"@payloadcms/bundler-webpack": "^1.0.6",
"@payloadcms/db-mongodb": "^1.5.1",
"@payloadcms/plugin-cloud": "^3.0.0",
Expand Down
3 changes: 3 additions & 0 deletions services/payload/src/access/noaccess.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import type { Access } from 'payload/config'

export const noaccess: Access = () => false
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Access } from 'payload/types'

import { isSuperAdmin } from '../../../utils/isSuperAdmin'

export const tenants: Access = ({ req: { user }, data }) =>
// individual documents
(data?.tenant?.id && user?.lastLoggedInTenant?.id === data.tenant.id) ||
(!user?.lastLoggedInTenant?.id && isSuperAdmin(user)) || {
// list of documents
tenant: {
equals: user?.lastLoggedInTenant?.id,
},
}
56 changes: 56 additions & 0 deletions services/payload/src/collections/SigningIdentities/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { CollectionConfig } from 'payload/types';

import { tenants } from './access/tenants'
import { noaccess } from '../../access/noaccess'

import { tenant } from '../../fields/tenant'

const SigningIdentitiesCollection: CollectionConfig = {
slug: 'signing-identities',
labels: { plural: 'Signing Identities' },
admin: {
useAsTitle: 'name',
defaultColumns: ['name', 'location'],
},
access: {
read: tenants,
create: noaccess,
update: noaccess,
delete: noaccess,
},
fields: [
{
name: 'name',
label: 'Signing Identity Name',
type: 'text',
required: true,
minLength: 3,
maxLength: 100,
},
{
name: 'did',
label: 'DID',
type: 'text',
required: true,
admin: { description: 'Example: did:example:1234' },
},
{
name: 'encryptionKeyVersion',
label: 'Encryption Key Version',
type: 'text',
required: true,
hidden: true,
admin: { description: 'v1' },
},
{
name: 'encryptedSecret',
label: 'Encrypted Secret',
type: 'json',
required: true,
hidden: true
},
tenant,
],
};

export default SigningIdentitiesCollection;
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { CollectionAfterChangeHook } from 'payload/types';

import { getLearnCard } from '../../../helpers/learncard.helpers';
import { generateEncryptedSigningIdentity } from '../../../helpers/signingIdentity.helpers';

const logs = true;

const upsertSigningIdentity: CollectionAfterChangeHook = async ({
doc, // full document data
req, // full express request
previousDoc, // document data before updating the collection
operation, // name of the operation ie. 'create', 'update'
}) => {
try {
const tenantId = doc?.id;

const signingIdentities = await req?.payload.find({
collection: 'signing-identities',
where: {
tenant: {
equals: tenantId,
},
},
depth: 0,
limit: 1,
req,
});

// if this tenant does not exist, deny access
if (signingIdentities.totalDocs === 0) {
if (logs) {
const msg = `No signing identity found for tenant. Creating new one...`;
req?.payload.logger.info({ msg });
}

const signingIdentity = await generateEncryptedSigningIdentity();
if (logs) {
const msg = `✅ New signing identity created: ${signingIdentity.did}`;
req?.payload.logger.info({ msg });
}

await req?.payload?.create({
collection: 'signing-identities',
data: {
name: `${doc?.name} Signing Identity`,
did: signingIdentity.did,
encryptionKeyVersion: signingIdentity.encryptionKeyVersion,
encryptedSecret: signingIdentity.encryptedSecret,
tenant: tenantId,
},
req,
});
}
} catch (e) {
console.error(`❌ There was an error creating a signing identity for tenant: ${doc?.name}`);
console.error(e);
}

return doc;
};

export default upsertSigningIdentity;
4 changes: 4 additions & 0 deletions services/payload/src/collections/Tenants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { superAdmins } from '../../access/superAdmins'
import { tenantAdmins } from './access/tenantAdmins'
import { tenantManagers } from './access/tenantManagers'
import enablePublicVisibility from './hooks/enablePublicVisibility';
import upsertSigningIdentity from './hooks/upsertSigningIdentity';
import { superAdminFieldAccess } from '../../access/superAdmins';

const Tenants: CollectionConfig = {
Expand Down Expand Up @@ -96,6 +97,9 @@ const Tenants: CollectionConfig = {
}
},
],
hooks: {
afterChange: [upsertSigningIdentity]
}
}

export default Tenants;
16 changes: 12 additions & 4 deletions services/payload/src/endpoints/selfIssueUserCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { PayloadHandler } from 'payload/config';
import { insertValuesIntoHandlebarsJsonTemplate } from '../helpers/handlebarhelpers';
import type { UnsignedVC } from '@learncard/types';
import { areDidsEqual, getLearnCard } from '../helpers/learncard.helpers';
import { issueCredentialViaTenantIdentity } from '../helpers/signingIdentity.helpers';
import getRedis from '../helpers/redis.helpers';
import { inflateObject } from '../helpers/objects.helpers';

Expand Down Expand Up @@ -50,7 +51,7 @@ export const selfIssueUserCredentials: PayloadHandler = async (req, res) => {

const credentials = credential.docs;

const builtCredentials = credentials
const batch = credentials
.map(credential => {
if (
typeof credential?.batch === 'string' ||
Expand Down Expand Up @@ -93,13 +94,20 @@ export const selfIssueUserCredentials: PayloadHandler = async (req, res) => {
builtCredential.issuanceDate = new Date().toISOString();
}

return builtCredential;
return {
builtCredential,
tenant: credential.tenant,
};
})
.filter(Boolean);

try {
const issuedCreds = await Promise.all(
builtCredentials.map(uvc => learnCard.invoke.issueCredential(uvc))
batch.map(entry =>
entry?.tenant
? issueCredentialViaTenantIdentity(entry.tenant, entry.builtCredential, req)
: learnCard.invoke.issueCredential(entry.builtCredential)
)
);

res.status(200).json(issuedCreds);
Expand All @@ -108,4 +116,4 @@ export const selfIssueUserCredentials: PayloadHandler = async (req, res) => {

return res.sendStatus(500);
}
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { areDidsEqual, getLearnCard } from '../../../helpers/learncard.helpers';
import { insertValuesIntoHandlebarsJsonTemplate } from '../../../helpers/handlebarhelpers';
import { inflateObject } from '../../../helpers/objects.helpers';
import { getTemplateAssociatedWithMembership } from '../../../helpers/membership.helpers';
import { issueCredentialViaTenantIdentity } from '../../../helpers/signingIdentity.helpers';

import { CREDENTIAL_STATUS } from '../../../constants/credentials';

Expand Down Expand Up @@ -92,7 +93,9 @@ const exchange = async (
// TODO: Add Status List revocation into credential

// Sign VC
const issuedCredential = await learnCard.invoke.issueCredential(builtCredential);
const issuedCredential = credential?.tenant
? await issueCredentialViaTenantIdentity(credential.tenant, builtCredential, req)
: await learnCard.invoke.issueCredential(builtCredential)

// Update VC Status to claimed
await payload.update({
Expand Down
113 changes: 113 additions & 0 deletions services/payload/src/helpers/signingIdentity.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import crypto from 'crypto';
import type { PayloadRequest } from 'payload/dist/types'
import type { Tenant } from '../payload-types';
import { initLearnCard, NetworkLearnCardFromSeed } from '@learncard/init';
import { LearnCard, JWE, UnsignedVC, VC } from '@learncard/types';

import { getLearnCard as getRootLearnCard } from './learncard.helpers';

export type SigningIdentity = {
did: string;
encryptedSecret: JWE;
encryptionKeyVersion: string;
};

export const generateJWE = async (
learnCard: LearnCard,
recipientDid: string,
item: any
): Promise<JWE> => {
return learnCard.invoke.getDIDObject().createDagJWE(item, [learnCard.id.did(), recipientDid]);
};

export const decryptJWE = async <T>(learnCard: LearnCard, jwe: JWE): Promise<T> => {
return learnCard.invoke.getDIDObject().decryptDagJWE(jwe) as any;
};

export const generateSecureSeedPhrase = async () => {
return crypto.randomBytes(32).toString('hex');
};

export const createSecretPhraseVC = (seed: string, did: string): any => {
return {
'@context': [
'https://www.w3.org/2018/credentials/v1',
{
'type': '@type',
'xsd': 'https://www.w3.org/2001/XMLSchema#',
'lcn': 'https://docs.learncard.com/definitions#',
'SecretPhraseCredential': {
'@id': 'lcn:secretPhraseCredential',
'@context': {
'secret': {
'@id': 'lcn:secret',
'@type': 'xsd:string',
},
},
},
},
],
'type': ['VerifiableCredential', 'SecretPhraseCredential'],
'issuanceDate': new Date().toISOString(),
'credentialSubject': {
'id': did,
},
'secret': seed,
};
};

export const generateEncryptedSigningIdentity = async (): Promise<SigningIdentity> => {
const rootLearnCard = await getRootLearnCard();

const seed = await generateSecureSeedPhrase();
const tenantLc = await initLearnCard({ network: true, seed });
const tenantLcDID = tenantLc.id.did();
const unsignedSecretVC = createSecretPhraseVC(seed, rootLearnCard.id.did(), tenantLcDID);
const signedSecretVc = await rootLearnCard.invoke.issueCredential(unsignedSecretVC);
const encryptedSecret = await generateJWE(rootLearnCard, tenantLcDID, signedSecretVc);
return {
did: tenantLcDid,
encryptedSecret,
encryptionKeyVersion: rootLearnCard.id.did(),
};
};

export const getSecretFromEncryptedSecretPhraseVC = async (
encryptedSecretPhraseVC: string
): Promise<string> => {
const rootLearnCard = await getRootLearnCard();
const decrypted = await decryptJWE(rootLearnCard, encryptedSecretPhraseVC);
return decrypted?.secret;
};


export const issueCredentialViaTenantIdentity = async (tenant: string | Tenant, credential: UnsignedVC, req: PayloadRequest): Promise<VC> => {
const tenantId = typeof tenant === 'string' ? tenant : tenant.id;
const tenantLC = await getSigningLearnCardForTenantId(tenantId, req);
if (typeof credential?.issuer === 'string') credential.issuer = {};
credential.issuer.id = tenantLC.id.did();
return tenantLC.issueCredential(credential);
}

export const getSigningLearnCardForTenantId = async (tenantId: string, req: PayloadRequest): Promise<LearnCard | undefined> => {
const signingIdentities = await req?.payload.find({
collection: 'signing-identities',
where: {
tenant: {
equals: tenantId,
},
},
depth: 0,
limit: 1,
req,
});

if (signingIdentities.totalDocs > 0) {
const signingIdentity = signingIdentities.docs[0];
const seed = await getSecretFromEncryptedSecretPhraseVC(signingIdentity.encryptedSecret);
return initLearnCard({ network: true, seed });
}
}



14 changes: 14 additions & 0 deletions services/payload/src/helpers/tenants.helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import payload from 'payload';
import type { PayloadRequest } from 'payload/dist/types'
import type { Tenant } from '../payload-types';

export const getTenantById = async (tenantId: string, req: PayloadRequest): Promise<Tenant | undefined> => {
return (
await payload.find({
collection: 'tenants',
where: { id: { equals: tenantId } },
depth: 0,
req
})
).docs?.[0];
};
5 changes: 5 additions & 0 deletions services/payload/src/jobs/queue.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ export const sendEmails = async (
queueName: 'email',
data: { email, collection },
})),
}, {
queuesOptions: {
connection,
prefix
}
});
// TODO: Fix Queue so it marks batch sent after emails have been sent.
return markBatchAsSent(req, collection, batchId);
Expand Down
Loading