@@ -73,6 +73,20 @@ function rememberEncryptedSessionSigningKey(value: unknown): void {
7373 }
7474}
7575
76+ // OTP_LOGIN / STAMP_LOGIN model: there is no encryptedSessionSigningKey bundle
77+ // — the TEK private key *is* the session's API key once login registers it.
78+ // Cache it directly so turnkeyStamp() can authorize later signed retries
79+ // (e.g. adding a passkey) without the Verify-style clientKeyPair + bundle.
80+ function setSessionKeysFromTek ( tek : {
81+ publicKey : string ;
82+ privateKey : string ;
83+ } ) : void {
84+ cachedSessionKeys = {
85+ apiPublicKey : tek . publicKey ,
86+ apiPrivateKey : tek . privateKey ,
87+ } ;
88+ }
89+
7690function decryptSessionKeysOrThrow ( ) : SessionKeys {
7791 if ( cachedSessionKeys ) return cachedSessionKeys ;
7892 if ( ! clientKeyPair )
@@ -653,6 +667,10 @@ bindClick(
653667 ...session ,
654668 } ) ;
655669 if ( session . id ) setCtxSession ( session . id as string ) ;
670+ // The TEK is now the session's API key (OTP_LOGIN registered it). Cache it
671+ // as the active session signing key so later signed retries (add passkey,
672+ // quote execute, etc.) can stamp with this session via turnkeyStamp().
673+ if ( leg2 . status === 200 ) setSessionKeysFromTek ( tek ) ;
656674 // One bundle per challenge — force a fresh Challenge for the next run.
657675 v3TargetBundle = null ;
658676 v3TargetBundleCredId = null ;
@@ -811,6 +829,119 @@ bindClick(
811829 } ,
812830) ;
813831
832+ // ----- WebAuthn ceremony helpers (real passkeys) -----
833+ //
834+ // The sandbox flows accept magic placeholder strings, but a real Turnkey
835+ // sub-org needs a genuine WebAuthn credential. These helpers drive the
836+ // browser's authenticator (Touch ID, etc.) and base64url-encode the results
837+ // into the same fields the sandbox flow uses, so Create / Add / Verify work
838+ // unchanged against production Turnkey.
839+ //
840+ // NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the
841+ // page origin — on localhost that means rpId="localhost". The Turnkey sub-org
842+ // must have been created with the SAME RP ID or verification will fail.
843+
844+ function bytesToB64Url ( bytes : Uint8Array ) : string {
845+ let bin = "" ;
846+ for ( let i = 0 ; i < bytes . length ; i ++ ) bin += String . fromCharCode ( bytes [ i ] ) ;
847+ return btoa ( bin ) . replace ( / \+ / g, "-" ) . replace ( / \/ / g, "_" ) . replace ( / = + $ / , "" ) ;
848+ }
849+
850+ function b64UrlToBytes ( value : string ) : Uint8Array {
851+ const b64 = value . replace ( / - / g, "+" ) . replace ( / _ / g, "/" ) ;
852+ const padded = b64 + "=" . repeat ( ( 4 - ( b64 . length % 4 ) ) % 4 ) ;
853+ const bin = atob ( padded ) ;
854+ const bytes = new Uint8Array ( bin . length ) ;
855+ for ( let i = 0 ; i < bin . length ; i ++ ) bytes [ i ] = bin . charCodeAt ( i ) ;
856+ return bytes ;
857+ }
858+
859+ function passkeyRpId ( ) : string {
860+ return el < HTMLInputElement > ( "passkey-rp-id" ) . value . trim ( ) || location . hostname ;
861+ }
862+
863+ interface RealAttestation {
864+ challenge : string ;
865+ credentialId : string ;
866+ clientDataJson : string ;
867+ attestationObject : string ;
868+ }
869+
870+ // Real registration ceremony — produces the attestation that Create/Add send.
871+ async function createRealPasskey ( nickname : string ) : Promise < RealAttestation > {
872+ const challenge = crypto . getRandomValues ( new Uint8Array ( 32 ) ) ;
873+ const userId = crypto . getRandomValues ( new Uint8Array ( 16 ) ) ;
874+ const credential = ( await navigator . credentials . create ( {
875+ publicKey : {
876+ rp : { id : passkeyRpId ( ) , name : "Grid Example App" } ,
877+ user : {
878+ id : userId ,
879+ name : nickname || "grid-example-user" ,
880+ displayName : nickname || "Grid Example User" ,
881+ } ,
882+ challenge,
883+ pubKeyCredParams : [
884+ { type : "public-key" , alg : - 7 } ,
885+ { type : "public-key" , alg : - 257 } ,
886+ ] ,
887+ authenticatorSelection : {
888+ residentKey : "preferred" ,
889+ userVerification : "preferred" ,
890+ } ,
891+ attestation : "none" ,
892+ timeout : 60000 ,
893+ } ,
894+ } ) ) as PublicKeyCredential | null ;
895+ if ( ! credential ) throw new Error ( "Passkey creation returned no credential" ) ;
896+ const response = credential . response as AuthenticatorAttestationResponse ;
897+ return {
898+ challenge : bytesToB64Url ( challenge ) ,
899+ credentialId : bytesToB64Url ( new Uint8Array ( credential . rawId ) ) ,
900+ clientDataJson : bytesToB64Url ( new Uint8Array ( response . clientDataJSON ) ) ,
901+ attestationObject : bytesToB64Url ( new Uint8Array ( response . attestationObject ) ) ,
902+ } ;
903+ }
904+
905+ interface RealAssertion {
906+ credentialId : string ;
907+ authenticatorData : string ;
908+ clientDataJson : string ;
909+ signature : string ;
910+ }
911+
912+ // Real assertion ceremony — signs the issued session challenge.
913+ async function signWithPasskey (
914+ challengeValue : string ,
915+ credentialId : string ,
916+ ) : Promise < RealAssertion > {
917+ if ( ! challengeValue ) {
918+ throw new Error ( "No challenge — issue a session challenge (step above) first." ) ;
919+ }
920+ // PR #28427: Turnkey's WebAuthn challenge is the UTF-8 bytes of the
921+ // sha256-hex challenge string returned by /challenge — NOT base64url-decoded.
922+ const challenge = new TextEncoder ( ) . encode ( challengeValue ) ;
923+ const allowCredentials : PublicKeyCredentialDescriptor [ ] = credentialId
924+ ? [ { type : "public-key" , id : b64UrlToBytes ( credentialId ) as BufferSource } ]
925+ : [ ] ;
926+ const credential = ( await navigator . credentials . get ( {
927+ publicKey : {
928+ rpId : passkeyRpId ( ) ,
929+ challenge,
930+ allowCredentials,
931+ userVerification : "preferred" ,
932+ timeout : 60000 ,
933+ } ,
934+ } ) ) as PublicKeyCredential | null ;
935+ if ( ! credential ) throw new Error ( "Passkey assertion returned no credential" ) ;
936+ const response = credential . response as AuthenticatorAssertionResponse ;
937+ return {
938+ credentialId : bytesToB64Url ( new Uint8Array ( credential . rawId ) ) ,
939+ authenticatorData : bytesToB64Url ( new Uint8Array ( response . authenticatorData ) ) ,
940+ clientDataJson : bytesToB64Url ( new Uint8Array ( response . clientDataJSON ) ) ,
941+ signature : bytesToB64Url ( new Uint8Array ( response . signature ) ) ,
942+ } ;
943+ }
944+
814945// ----- PASSKEY -----
815946
816947bindClick (
@@ -838,8 +969,31 @@ bindClick(
838969 } ,
839970) ;
840971
972+ // Drive a real WebAuthn registration (Touch ID) and fill the attestation
973+ // fields above — used by both the "Create" and "Add additional" flows.
974+ bindClick (
975+ "btn-passkey-webauthn-create" ,
976+ "passkey-webauthn-create-status" ,
977+ "Passkey Register" ,
978+ "Waiting for authenticator (Touch ID)..." ,
979+ async ( ) => {
980+ const nickname = el < HTMLInputElement > ( "passkey-create-nickname" ) . value . trim ( ) ;
981+ const att = await createRealPasskey ( nickname ) ;
982+ el < HTMLInputElement > ( "passkey-create-challenge" ) . value = att . challenge ;
983+ el < HTMLInputElement > ( "passkey-create-cred-id-raw" ) . value = att . credentialId ;
984+ el < HTMLInputElement > ( "passkey-create-client-data-json" ) . value = att . clientDataJson ;
985+ el < HTMLInputElement > ( "passkey-create-attestation-object" ) . value =
986+ att . attestationObject ;
987+ addLog ( "Passkey Registered (real)" , att ) ;
988+ return "Real passkey created — attestation fields filled. Now run Create or Add." ;
989+ } ,
990+ ) ;
991+
841992wireGenKeyButton ( "btn-passkey-challenge-genkey" , "passkey-challenge-pubkey" ) ;
842993const passkeyVerifyRequestId = el < HTMLInputElement > ( "passkey-verify-request-id" ) ;
994+ // Captured from the session-challenge response so the real assertion ceremony
995+ // can sign the exact sha256-hex challenge Turnkey expects.
996+ let passkeySessionChallenge = "" ;
843997bindClick (
844998 "btn-passkey-challenge" ,
845999 "passkey-challenge-status" ,
@@ -856,6 +1010,7 @@ bindClick(
8561010 addLog ( "PASSKEY Challenge" , data ) ;
8571011 const d = data as Record < string , unknown > ;
8581012 if ( d . requestId ) passkeyVerifyRequestId . value = d . requestId as string ;
1013+ if ( typeof d . challenge === "string" ) passkeySessionChallenge = d . challenge ;
8591014 return JSON . stringify ( data , null , 2 ) ;
8601015 } ,
8611016) ;
@@ -893,7 +1048,30 @@ bindClick(
8931048 } ,
8941049) ;
8951050
1051+ // Drive a real WebAuthn assertion (Touch ID) against the issued challenge and
1052+ // fill the assertion fields above for Verify.
1053+ bindClick (
1054+ "btn-passkey-webauthn-get" ,
1055+ "passkey-webauthn-get-status" ,
1056+ "Passkey Sign" ,
1057+ "Waiting for authenticator (Touch ID)..." ,
1058+ async ( ) => {
1059+ const credId = el < HTMLInputElement > ( "passkey-create-cred-id-raw" ) . value . trim ( ) ;
1060+ const assertion = await signWithPasskey ( passkeySessionChallenge , credId ) ;
1061+ el < HTMLInputElement > ( "passkey-create-cred-id-raw" ) . value = assertion . credentialId ;
1062+ el < HTMLInputElement > ( "passkey-verify-client-data-json" ) . value =
1063+ assertion . clientDataJson ;
1064+ el < HTMLInputElement > ( "passkey-verify-auth-data" ) . value =
1065+ assertion . authenticatorData ;
1066+ el < HTMLInputElement > ( "passkey-verify-signature" ) . value = assertion . signature ;
1067+ addLog ( "Passkey Signed (real)" , assertion ) ;
1068+ return "Real assertion produced — verify fields filled. Now click Verify." ;
1069+ } ,
1070+ ) ;
1071+
8961072const passkeyAddRequestId = el < HTMLInputElement > ( "passkey-add-request-id" ) ;
1073+ // Captured from the add-issue 202 so the retry can stamp the exact payload.
1074+ let passkeyAddPayloadToSign = "" ;
8971075function buildPasskeyAddBody ( ) : Record < string , unknown > {
8981076 return {
8991077 type : "PASSKEY" ,
@@ -917,6 +1095,7 @@ bindClick(
9171095 addLog ( "PASSKEY Add (issue)" , data ) ;
9181096 const d = data as Record < string , unknown > ;
9191097 if ( d . requestId ) passkeyAddRequestId . value = d . requestId as string ;
1098+ if ( typeof d . payloadToSign === "string" ) passkeyAddPayloadToSign = d . payloadToSign ;
9201099 return JSON . stringify ( data , null , 2 ) ;
9211100 } ,
9221101) ;
@@ -928,10 +1107,21 @@ bindClick(
9281107 async ( ) => {
9291108 const requestId = passkeyAddRequestId . value . trim ( ) ;
9301109 if ( ! requestId ) throw new Error ( "Request-Id is required — run step 1 first." ) ;
1110+ // Sandbox accepts the magic value, but real Turnkey requires the
1111+ // CREATE_AUTHENTICATORS payload to be stamped by an authorized credential —
1112+ // the active session's signing key. Establish a session (e.g. OTP login or
1113+ // passkey verify) first so the session signing key is available.
1114+ let signature = SANDBOX_SIG ;
1115+ if ( getMode ( ) === "production" ) {
1116+ if ( ! passkeyAddPayloadToSign ) {
1117+ throw new Error ( "Missing payloadToSign — run step 1 first." ) ;
1118+ }
1119+ signature = await turnkeyStamp ( passkeyAddPayloadToSign ) ;
1120+ }
9311121 const { data } = await apiPost (
9321122 "/auth/credentials" ,
9331123 buildPasskeyAddBody ( ) ,
934- { "Grid-Wallet-Signature" : SANDBOX_SIG , "Request-Id" : requestId } ,
1124+ { "Grid-Wallet-Signature" : signature , "Request-Id" : requestId } ,
9351125 ) ;
9361126 addLog ( "PASSKEY Add (retry)" , data ) ;
9371127 return JSON . stringify ( data , null , 2 ) ;
0 commit comments