Skip to content

Commit 443823a

Browse files
committed
⚗️ server: add panda signature
1 parent 95cebd1 commit 443823a

8 files changed

Lines changed: 581 additions & 21 deletions

File tree

common/pandaCertificate.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,5 @@ export default process.env.EXPO_PUBLIC_PANDA_PUBLIC_KEY ||
2121
"base.exactly.app": production,
2222
"base-sepolia.exactly.app": sandbox,
2323
"sandbox.exactly.app": sandbox,
24-
}[domain] ||
25-
`-----BEGIN PUBLIC KEY-----
26-
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCu2YOeObkaYiQmc49t2Cnk8syA
27-
1UBqFBMVhkJXyuSA9f+hGC22fXgQtpfAjQmFRpt5q4f6i0rG2bUi8Km0jZELdD6X
28-
Kz63/hp522fbxNuOOxs37dlH9B3k6W8NQjjDjaFhAwCsevq7uASXwEEK3NpV7DEP
29-
lJe6c8CQ0+QqTTy2ZwIDAQAB
30-
-----END PUBLIC KEY-----`;
24+
}[domain] || sandbox;
3125
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */

server/api/card.ts

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Hono } from "hono";
55
import { describeRoute } from "hono-openapi";
66
import { resolver, validator as vValidator } from "hono-openapi/valibot";
77
import {
8+
any,
89
integer,
910
literal,
1011
maxValue,
@@ -13,6 +14,7 @@ import {
1314
nullable,
1415
number,
1516
object,
17+
optional,
1618
parse,
1719
picklist,
1820
pipe,
@@ -21,12 +23,16 @@ import {
2123
transform,
2224
union,
2325
uuid,
26+
variant,
2427
type InferOutput,
2528
} from "valibot";
29+
import { createSiweMessage } from "viem/siwe";
2630

31+
import domain from "@exactly/common/domain";
32+
import chain from "@exactly/common/generated/chain";
2733
import MAX_INSTALLMENTS from "@exactly/common/MAX_INSTALLMENTS";
2834
import { PLATINUM_PRODUCT_ID, SIGNATURE_PRODUCT_ID } from "@exactly/common/panda";
29-
import { Address } from "@exactly/common/validation";
35+
import { Address, Base64URL } from "@exactly/common/validation";
3036

3137
import database, { cards, credentials } from "../database";
3238
import t from "../i18n";
@@ -41,9 +47,11 @@ import {
4147
getProcessorDetails,
4248
getSecrets,
4349
getUser,
50+
nonce,
4451
setPIN,
4552
updateCard,
4653
USD_TO_CENTS,
54+
verify,
4755
} from "../utils/panda";
4856
import { addCapita, deriveAssociateId } from "../utils/pax";
4957
import { getAccount, getCardLimitAccount } from "../utils/persona";
@@ -111,6 +119,30 @@ const UpdateCard = union([
111119
strictObject({ data: string(), iv: string(), sessionId: string() }),
112120
transform((patch) => ({ ...patch, type: "pin" as const })),
113121
),
122+
pipe(
123+
variant("action", [
124+
object({ method: literal("siwe"), action: literal("message") }),
125+
object({ method: literal("siwe"), action: literal("verify"), message: string(), signature: string() }),
126+
object({ method: literal("webauthn"), action: literal("challenge") }),
127+
object({
128+
method: literal("webauthn"),
129+
action: literal("verify"),
130+
assertion: object({
131+
id: Base64URL,
132+
rawId: Base64URL,
133+
response: object({
134+
clientDataJSON: Base64URL,
135+
authenticatorData: Base64URL,
136+
signature: Base64URL,
137+
userHandle: optional(Base64URL),
138+
}),
139+
clientExtensionResults: any(),
140+
type: literal("public-key"),
141+
}),
142+
}),
143+
]),
144+
transform((signature) => ({ ...signature, type: "signature" as const })),
145+
),
114146
]);
115147

116148
const WalletCredentialsResponse = object({
@@ -124,6 +156,9 @@ const UpdatedCardResponse = union([
124156
object({
125157
status: pipe(picklist(["ACTIVE", "DELETED", "FROZEN"]), metadata({ examples: ["ACTIVE", "DELETED", "FROZEN"] })),
126158
}),
159+
object({ message: string() }),
160+
object({ challenge: pipe(string(), metadata({ examples: ["1a2b3c"] })) }),
161+
object({}),
127162
]);
128163

129164
export default new Hono()
@@ -384,11 +419,7 @@ function decrypt(base64Secret: string, base64Iv: string, secretKey: string): str
384419
captureException(error, { level: "error", contexts: { details: { credentialId, scope: "cardLimit" } } });
385420
});
386421
const limit = cardLimitAccount?.attributes.fields.card_limit_usd?.value;
387-
const card = await createCard(
388-
credential.pandaId,
389-
SIGNATURE_PRODUCT_ID,
390-
limit == null ? undefined : limit * USD_TO_CENTS,
391-
);
422+
const card = await createCard(credential.pandaId, SIGNATURE_PRODUCT_ID);
392423
let mode = 0;
393424
try {
394425
if (await autoCredit(account)) mode = 1;
@@ -545,6 +576,12 @@ async function encryptPIN(pin: string) {
545576
},
546577
},
547578
},
579+
403: {
580+
description: "Forbidden",
581+
content: {
582+
"application/json": { schema: resolver(object({ code: literal("no panda") }), { errorMode: "ignore" }) },
583+
},
584+
},
548585
404: {
549586
description: "Not found",
550587
content: {
@@ -569,10 +606,20 @@ async function encryptPIN(pin: string) {
569606
return mutex
570607
.runExclusive(async () => {
571608
const credential = await database.query.credentials.findFirst({
572-
columns: { account: true },
609+
columns: {
610+
account: true,
611+
pandaId: true,
612+
factory: true,
613+
publicKey: true,
614+
transports: true,
615+
counter: true,
616+
},
573617
where: eq(credentials.id, credentialId),
574618
with: {
575-
cards: { columns: { id: true, mode: true, status: true }, where: ne(cards.status, "DELETED") },
619+
cards: {
620+
columns: { id: true, mode: true, status: true, lastFour: true },
621+
where: ne(cards.status, "DELETED"),
622+
},
576623
},
577624
});
578625
if (!credential) return c.json({ code: "no credential" }, 500);
@@ -619,6 +666,66 @@ async function encryptPIN(pin: string) {
619666
}
620667
return c.json({ data, iv } satisfies InferOutput<typeof UpdatedCardResponse>, 200);
621668
}
669+
case "signature":
670+
switch (patch.method) {
671+
case "siwe":
672+
switch (patch.action) {
673+
case "message": {
674+
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
675+
const { nonce: value } = await nonce(credential.pandaId);
676+
const message = createSiweMessage({
677+
domain,
678+
address: parse(Address, credentialId),
679+
statement: `I authorize the account ${account} to be linked with the card ending in ${card.lastFour} for my user (${credential.pandaId})`,
680+
uri: `https://${domain}`,
681+
version: "1",
682+
chainId: chain.id,
683+
nonce: value,
684+
issuedAt: new Date(),
685+
});
686+
return c.json({ message } satisfies InferOutput<typeof UpdatedCardResponse>, 200);
687+
}
688+
case "verify":
689+
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
690+
await verify(credential.pandaId, {
691+
message: patch.message,
692+
signature: patch.signature,
693+
authType: "siwe",
694+
});
695+
return c.json({}, 200);
696+
697+
default:
698+
return c.json({ code: "bad request" }, 400);
699+
}
700+
701+
case "webauthn": {
702+
const statement = `I authorize the account ${account} to be linked with the card ending in ${card.lastFour} for my user (${credential.pandaId})`;
703+
switch (patch.action) {
704+
case "challenge":
705+
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
706+
return c.json({ challenge: statement } satisfies InferOutput<typeof UpdatedCardResponse>, 200);
707+
case "verify":
708+
if (!credential.pandaId) return c.json({ code: "no panda" }, 403);
709+
await verify(credential.pandaId, {
710+
authType: "webauthn",
711+
credential: {
712+
publicKey: { type: "Buffer", data: [...credential.publicKey] },
713+
transports: credential.transports,
714+
counter: credential.counter,
715+
},
716+
assertion: patch.assertion,
717+
factory: credential.factory,
718+
statement,
719+
challenge: statement,
720+
});
721+
return c.json({}, 200);
722+
723+
default:
724+
return c.json({ code: "bad request" }, 400);
725+
}
726+
}
727+
}
728+
break;
622729
}
623730
})
624731
.finally(() => {

0 commit comments

Comments
 (0)