From d5bd9ad3c616bf736642511ac59e647ba3b6f92d Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Sun, 26 Jul 2026 00:10:05 +0100 Subject: [PATCH 1/4] feat(security): enforce version 2 cryptographic signatures for access QR codes This implements strict security (Option A), mandating that all scanned access QR codes contain a valid kid and secp256k1 signature over their canonical payload format. Legacy version 1 payloads are now rejected structurally. Closes #249 --- README.md | 16 +++-- app/access-scanner.tsx | 68 ++++++++++++++---- src/features/access/constants.ts | 2 +- src/features/access/qrPayload.test.ts | 22 +++++- src/features/access/qrPayload.ts | 22 +++--- src/features/access/verifyQrPayload.ts | 72 ++++++++----------- tests/fixtures/guild.fixtures.ts | 2 + tests/fixtures/qrSignature.fixtures.ts | 2 +- tests/qrKeyRotation.test.ts | 9 +-- tests/qrSignature.test.ts | 4 +- tests/verifyAndParseQrPayload.test.ts | 98 +++++++++++--------------- 11 files changed, 176 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index ba28481..8e52ab7 100644 --- a/README.md +++ b/README.md @@ -196,17 +196,25 @@ QR access checks use a JSON payload encoded directly in the QR code: ```json { "type": "guildpass.access-check", - "version": 1, + "version": 2, "guildId": "guild_abc", "resourceId": "vip-door", "walletAddress": "0x1234567890123456789012345678901234567890", - "expiresAt": "2026-06-23T12:05:00.000Z" + "expiresAt": "2026-06-23T12:05:00.000Z", + "kid": "key_2026", + "signature": "3045022100e...20b" } ``` -`type`, `version`, `guildId`, and `resourceId` are required. `walletAddress` and `expiresAt` +`type`, `version`, `guildId`, `resourceId`, `kid`, and `signature` are required. `walletAddress` and `expiresAt` are optional. Unsupported types or versions, malformed JSON, missing required fields, invalid -wallet addresses, and expired payloads are rejected before the access check is submitted. +wallet addresses, expired payloads, and cryptographically invalid signatures are rejected before the access check is submitted. + +### Version 1 Compatibility Policy +As of the rollout of cryptographic signatures in `version: 2`, legacy `version: 1` unsigned payloads are **strictly rejected**. This completely closes downgrade attacks where a malicious actor strips the signature and changes the version to 1 to bypass security controls. All issuers must use `version: 2`. + +### Cryptographic Signatures +The `signature` field is a DER-encoded, hex-secp256k1 signature over a deterministic, newline-delimited canonicalization of the payload fields (in order: `type`, `version`, `guildId`, `resourceId`, `walletAddress`, `expiresAt`, `kid`). This signature is verified locally using the issuer's public key (fetched from the GuildPass backend and cached per `kid`). ## � Deep Linking diff --git a/app/access-scanner.tsx b/app/access-scanner.tsx index 0692373..40204b0 100644 --- a/app/access-scanner.tsx +++ b/app/access-scanner.tsx @@ -16,8 +16,9 @@ import { useAccessHistoryStore } from "../src/features/access/accessHistory.stor export default function AccessScanner() { const router = useRouter(); const [permission, requestPermission] = useCameraPermissions(); - const [scanError, setScanError] = useState(null); + const [scanError, setScanError] = useState<{ message: string; isUntrusted: boolean } | null>(null); const [isProcessingScan, setIsProcessingScan] = useState(false); + const [verificationSuccess, setVerificationSuccess] = useState(false); const scanInProgressRef = useRef(false); const entries = useAccessHistoryStore((state) => state.entries); const clearHistory = useAccessHistoryStore((state) => state.clearHistory); @@ -34,22 +35,39 @@ export default function AccessScanner() { try { AccessibilityInfo.announceForAccessibility("Processing access QR code."); await verifyAndParseAccessQrPayload(data); - AccessibilityInfo.announceForAccessibility("QR code accepted. Opening access check."); - router.replace({ pathname: "/access-check", params: { qrPayload: data } }); + setVerificationSuccess(true); + AccessibilityInfo.announceForAccessibility("Signature verified. Opening access check."); + + setTimeout(() => { + setVerificationSuccess(false); + router.replace({ pathname: "/access-check", params: { qrPayload: data } }); + }, 1500); return; - } catch (scanError) { + } catch (error) { let errorMessage = "Unable to read QR payload."; + let isUntrusted = false; - if (scanError instanceof QrSignatureError) { - errorMessage = describeQrSignatureError(scanError.code); - } else if ( - scanError instanceof QrPayloadError && - scanError.code === QR_PAYLOAD_ERROR_CODES.ALREADY_USED - ) { - errorMessage = "This QR code has already been used."; + if (error instanceof QrSignatureError) { + errorMessage = describeQrSignatureError(error.code); + isUntrusted = true; + } else if (error instanceof QrPayloadError) { + if ( + error.code === QR_PAYLOAD_ERROR_CODES.INVALID_SIGNATURE || + error.code === QR_PAYLOAD_ERROR_CODES.UNSUPPORTED_VERSION || + error.code === QR_PAYLOAD_ERROR_CODES.INVALID_KID + ) { + errorMessage = error.message; + isUntrusted = true; + } else if (error.code === QR_PAYLOAD_ERROR_CODES.ALREADY_USED) { + errorMessage = "This QR code has already been used."; + } else { + errorMessage = error.message; + } + } else if (error instanceof Error) { + errorMessage = error.message; } - setScanError(errorMessage); + setScanError({ message: errorMessage, isUntrusted }); AccessibilityInfo.announceForAccessibility(`QR code rejected. ${errorMessage}`); } @@ -120,14 +138,34 @@ export default function AccessScanner() { ); } + if (verificationSuccess) { + return ( + + + + + + Signature verified + Redirecting to access check... + + + + ); + } + if (scanError) { + const isUntrusted = scanError.isUntrusted; return ( - - QR code rejected - {scanError} + + + {isUntrusted ? "Untrusted QR code" : "QR code rejected"} + + + {scanError.message} +