| 1 | import {
|
| 2 | createCipheriv,
|
| 3 | createDecipheriv,
|
| 4 | createHash,
|
| 5 | createHmac,
|
| 6 | createPrivateKey,
|
| 7 | createPublicKey,
|
| 8 | randomBytes,
|
| 9 | sign,
|
| 10 | timingSafeEqual,
|
| 11 | verify,
|
| 12 | } from 'node:crypto';
|
| 13 | import { canonicalHash } from '../../protocol/src/index.js';
|
| 14 |
|
| 15 | export type GatewayGrant = {
|
| 16 | interactionId: string;
|
| 17 | senderAgentId: string;
|
| 18 | recipientAgentId: string;
|
| 19 | expiresAt: number;
|
| 20 | };
|
| 21 |
|
| 22 | const keyFromSecret = (secret: string) => createHash('sha256').update(secret).digest();
|
| 23 |
|
| 24 | export function encryptGatewayPayload(secret: string, payload: unknown) {
|
| 25 | const iv = randomBytes(12);
|
| 26 | const cipher = createCipheriv('aes-256-gcm', keyFromSecret(secret), iv);
|
| 27 | const ciphertext = Buffer.concat([
|
| 28 | cipher.update(JSON.stringify(payload), 'utf8'),
|
| 29 | cipher.final(),
|
| 30 | ]);
|
| 31 | return {
|
| 32 | ciphertext: ciphertext.toString('base64url'),
|
| 33 | iv: iv.toString('base64url'),
|
| 34 | authTag: cipher.getAuthTag().toString('base64url'),
|
| 35 | };
|
| 36 | }
|
| 37 |
|
| 38 | export function decryptGatewayPayload(
|
| 39 | secret: string,
|
| 40 | encrypted: { ciphertext: string; iv: string; authTag: string },
|
| 41 | ): unknown {
|
| 42 | const decipher = createDecipheriv(
|
| 43 | 'aes-256-gcm',
|
| 44 | keyFromSecret(secret),
|
| 45 | Buffer.from(encrypted.iv, 'base64url'),
|
| 46 | );
|
| 47 | decipher.setAuthTag(Buffer.from(encrypted.authTag, 'base64url'));
|
| 48 | return JSON.parse(
|
| 49 | Buffer.concat([
|
| 50 | decipher.update(Buffer.from(encrypted.ciphertext, 'base64url')),
|
| 51 | decipher.final(),
|
| 52 | ]).toString('utf8'),
|
| 53 | );
|
| 54 | }
|
| 55 |
|
| 56 | export function issueGatewayGrant(secret: string, grant: GatewayGrant): string {
|
| 57 | const payload = Buffer.from(JSON.stringify(grant)).toString('base64url');
|
| 58 | const signature = createHmac('sha256', secret).update(payload).digest('base64url');
|
| 59 | return `${payload}.${signature}`;
|
| 60 | }
|
| 61 |
|
| 62 | export function verifyGatewayGrant(secret: string, token: string): GatewayGrant {
|
| 63 | const [payload, provided] = token.split('.');
|
| 64 | if (!payload || !provided) throw new Error('Invalid gateway token');
|
| 65 | const expected = createHmac('sha256', secret).update(payload).digest();
|
| 66 | const actual = Buffer.from(provided, 'base64url');
|
| 67 | if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
|
| 68 | throw new Error('Invalid gateway token');
|
| 69 | const grant = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as GatewayGrant;
|
| 70 | if (grant.expiresAt <= Date.now()) throw new Error('Gateway token expired');
|
| 71 | if (!grant.interactionId || !grant.senderAgentId || !grant.recipientAgentId)
|
| 72 | throw new Error('Invalid gateway token');
|
| 73 | return grant;
|
| 74 | }
|
| 75 |
|
| 76 | function sessionPrivateKey(secret: string) {
|
| 77 | const seed = createHash('sha256').update(`openclasp:live-session:${secret}`).digest();
|
| 78 | return createPrivateKey({
|
| 79 | key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), seed]),
|
| 80 | type: 'pkcs8',
|
| 81 | format: 'der',
|
| 82 | });
|
| 83 | }
|
| 84 |
|
| 85 | export function getSessionVerificationKey(secret: string) {
|
| 86 | return createPublicKey(sessionPrivateKey(secret))
|
| 87 | .export({ type: 'spki', format: 'der' })
|
| 88 | .toString('base64url');
|
| 89 | }
|
| 90 |
|
| 91 | export function getSessionKeyId(secret: string) {
|
| 92 | return `openclasp:${createHash('sha256')
|
| 93 | .update(getSessionVerificationKey(secret))
|
| 94 | .digest('base64url')
|
| 95 | .slice(0, 16)}`;
|
| 96 | }
|
| 97 |
|
| 98 | export function attestSessionRecord(secret: string, value: unknown) {
|
| 99 | const digest = canonicalHash(value);
|
| 100 | return {
|
| 101 | algorithm: 'Ed25519' as const,
|
| 102 | keyId: getSessionKeyId(secret),
|
| 103 | value: sign(null, Buffer.from(digest), sessionPrivateKey(secret)).toString('base64url'),
|
| 104 | digest,
|
| 105 | };
|
| 106 | }
|
| 107 |
|
| 108 | export function signSessionControl(
|
| 109 | secret: string,
|
| 110 | requestId: string,
|
| 111 | timestamp: string,
|
| 112 | body: string,
|
| 113 | ) {
|
| 114 | return sign(
|
| 115 | null,
|
| 116 | Buffer.from(`${timestamp}.${requestId}.${body}`),
|
| 117 | sessionPrivateKey(secret),
|
| 118 | ).toString('base64url');
|
| 119 | }
|
| 120 |
|
| 121 | export function issueSessionGrant(secret: string, grant: GatewayGrant): string {
|
| 122 | const payload = Buffer.from(JSON.stringify(grant)).toString('base64url');
|
| 123 | const signature = sign(null, Buffer.from(payload), sessionPrivateKey(secret)).toString(
|
| 124 | 'base64url',
|
| 125 | );
|
| 126 | return `${payload}.${signature}`;
|
| 127 | }
|
| 128 |
|
| 129 | export function verifySessionGrant(secret: string, token: string): GatewayGrant {
|
| 130 | const [payload, signature] = token.split('.');
|
| 131 | if (!payload || !signature) throw new Error('Invalid live-session credential');
|
| 132 | const valid = verify(
|
| 133 | null,
|
| 134 | Buffer.from(payload),
|
| 135 | createPublicKey(sessionPrivateKey(secret)),
|
| 136 | Buffer.from(signature, 'base64url'),
|
| 137 | );
|
| 138 | if (!valid) throw new Error('Invalid live-session credential');
|
| 139 | const grant = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as GatewayGrant;
|
| 140 | if (grant.expiresAt <= Date.now()) throw new Error('Live-session credential expired');
|
| 141 | if (!grant.interactionId || !grant.senderAgentId || !grant.recipientAgentId)
|
| 142 | throw new Error('Invalid live-session credential');
|
| 143 | return grant;
|
| 144 | }
|