Created
January 19, 2022 15:20
-
-
Save Szer/97057b713fc99672940255db2833ae34 to your computer and use it in GitHub Desktop.
Example of verifying and signing of MagicLink token
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// org.apache.tuweni:tuweni-crypto:2.0.0 | |
import org.apache.tuweni.bytes.Bytes | |
import org.apache.tuweni.crypto.Hash | |
import org.apache.tuweni.crypto.SECP256K1 | |
import org.bouncycastle.jce.provider.BouncyCastleProvider | |
import org.slf4j.LoggerFactory | |
import java.security.Security | |
import java.time.Clock | |
import java.time.Duration | |
import java.util.* | |
object MagicLinkTokenVerifier { | |
init { | |
Security.addProvider(BouncyCastleProvider()) | |
} | |
private val MSG_PREFIX = "${Char(25)}Ethereum Signed Message:\n" | |
val NONE_ATTACHMENT = Bytes.wrap("none".toByteArray()) | |
private val logger = LoggerFactory.getLogger(MagicLinkTokenVerifier::class.java) | |
fun SECP256K1.PublicKey.pubToAddress(): Bytes { | |
val hash = Hash.keccak256(bytes()) | |
// Only take the lower 160bits of the hash | |
return hash.slice(hash.size() - 20) | |
} | |
private fun Bytes.prefixMsgWithEth(): Bytes { | |
val msgPrefix = Bytes.wrap((MSG_PREFIX + size().toString()).toByteArray()) | |
return Bytes.concatenate(msgPrefix, this) | |
} | |
private fun parsePublicAddressFromIssuer(issuer: String): Bytes = | |
Bytes.fromHexString(issuer.split(":")[2]) | |
private fun recoverIssuerFromMsgAndProof(msg: Bytes, proof: String): Bytes { | |
val prefixedMsg = msg.prefixMsgWithEth() | |
val msgHash = Hash.keccak256(prefixedMsg) | |
var sigBytes: Bytes = Bytes.fromHexString(proof).mutableCopy() | |
if (sigBytes[64] >= 27) { | |
// we are doing it because MagicLink sends this byte with +27 in mind | |
sigBytes = Bytes.concatenate(sigBytes.slice(0, 64), Bytes.of(sigBytes[64] - 27)) | |
} | |
val signature = SECP256K1.Signature.fromBytes(sigBytes) | |
val pubKey = SECP256K1.PublicKey.recoverFromHashAndSignature(msgHash, signature) ?: error("Invalid signature") | |
SECP256K1.verifyHashed(msgHash, signature, pubKey) | |
return pubKey.pubToAddress() | |
} | |
fun verify(token: MagicLinkToken, clock: Clock = Clock.systemUTC()) { | |
try { | |
val messageBytes = Bytes.wrap(token.claims.asJsonString().toByteArray()) | |
val tokenSigner = recoverIssuerFromMsgAndProof(messageBytes, token.proof) | |
val attachmentSigner = if (token.claims.additional != null) { | |
recoverIssuerFromMsgAndProof(NONE_ATTACHMENT, token.claims.additional) | |
} else Bytes.EMPTY | |
val claimedIssuer = parsePublicAddressFromIssuer(token.claims.issuer) | |
if (claimedIssuer != tokenSigner || claimedIssuer != attachmentSigner) error("Invalid issuer") | |
val now = clock.instant() | |
val leeway = Env.jwtLeeway | |
// Assert the token is not expired | |
if (token.claims.expiredAt < now) error("Token expired") | |
// Assert the token is not used before allowed. | |
if (token.claims.notBefore.minus(leeway) > now) error("Token not valid yet") | |
} catch (e: Exception) { | |
logger.error("Unexpected error while verifying MagicLink: $token", e) | |
error("Invalid token") | |
} | |
} | |
private fun Bytes.signToEthHex(keyPair: SECP256K1.KeyPair): String = | |
SECP256K1.sign(prefixMsgWithEth(), keyPair).bytes().toHexString() | |
fun signToken( | |
subject: String, | |
audience: String, | |
keyPair: SECP256K1.KeyPair, | |
tokenId: String = UUID.randomUUID().toString(), | |
clock: Clock = Clock.systemUTC(), | |
expiration: Duration = Env.jwtExpiration, | |
): MagicLinkToken { | |
val issuer = keyPair.publicKey().pubToAddress() | |
val now = clock.instant() | |
val claims = MagicLinkClaims( | |
issuedAt = now, | |
expiredAt = now.plus(expiration), | |
issuer = "did:ethr:" + issuer.toHexString(), | |
subject = subject, | |
audience = audience, | |
notBefore = now, | |
didTokenId = tokenId, | |
additional = NONE_ATTACHMENT.signToEthHex(keyPair), | |
) | |
val jsonMsg = Json.toJson(claims) | |
val msgBytes = Bytes.wrap(jsonMsg.toByteArray()) | |
val proof = msgBytes.signToEthHex(keyPair) | |
return MagicLinkToken(proof, claims) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// com.fasterxml.jackson.core:jackson-annotations:2.13.0 | |
// com.fasterxml.jackson.core:jackson-core:2.13.0 | |
// com.fasterxml.jackson.core:jackson-databind:2.13.0 | |
// com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.0 | |
// com.fasterxml.jackson.module:jackson-module-kotlin:2.13.0 | |
import com.fasterxml.jackson.annotation.JsonCreator | |
import com.fasterxml.jackson.annotation.JsonFormat | |
import com.fasterxml.jackson.annotation.JsonIgnore | |
import com.fasterxml.jackson.annotation.JsonProperty | |
import com.fasterxml.jackson.core.JsonGenerator | |
import com.fasterxml.jackson.databind.JsonSerializer | |
import com.fasterxml.jackson.databind.SerializerProvider | |
import com.fasterxml.jackson.databind.annotation.JsonSerialize | |
import io.ktor.util.* | |
import java.time.Instant | |
data class MagicLinkClaims( | |
@JsonProperty("iat") | |
val issuedAt: Instant, | |
@JsonProperty("ext") | |
val expiredAt: Instant, | |
@JsonProperty("iss") | |
val issuer: String, | |
@JsonProperty("sub") | |
val subject: String, | |
@JsonProperty("aud") | |
val audience: String, | |
@JsonProperty("nbf") | |
val notBefore: Instant, | |
@JsonProperty("tid") | |
val didTokenId: String, | |
@JsonProperty("add") | |
val additional: String?, | |
@JsonIgnore | |
private var jsonString: String? = null, | |
) { | |
fun asJsonString(): String = | |
when (val str = jsonString) { | |
null -> Json.toJson(this) | |
else -> str | |
} | |
companion object { | |
@JvmStatic | |
@JsonCreator | |
fun fromEscapedJson(str: String): MagicLinkClaims { | |
val token = Json.mapper.readValue(str, MagicLinkClaims::class.java) | |
token.jsonString = str | |
return token | |
} | |
} | |
} | |
@JsonFormat(shape = JsonFormat.Shape.ARRAY) | |
@JsonSerialize(using = MagicLinkToken.Serializer::class) | |
data class MagicLinkToken( | |
val proof: String, | |
val claims: MagicLinkClaims | |
) { | |
@OptIn(InternalAPI::class) | |
fun asWebToken(): String = Json.toJson(this).encodeBase64() | |
companion object Serializer : JsonSerializer<MagicLinkToken>() { | |
override fun serialize(value: MagicLinkToken, gen: JsonGenerator, serializers: SerializerProvider) { | |
gen.writeStartArray() | |
gen.writeString(value.proof) | |
gen.writeString(value.claims.asJsonString()) | |
gen.writeEndArray() | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// org.junit.jupiter:junit-jupiter:5.8.1 | |
import org.apache.tuweni.bytes.Bytes32 | |
import org.apache.tuweni.crypto.SECP256K1 | |
import org.bouncycastle.jce.provider.BouncyCastleProvider | |
import org.junit.jupiter.api.Assertions.assertEquals | |
import org.junit.jupiter.api.Test | |
import java.security.Security | |
import java.time.Clock | |
import java.time.Instant | |
import java.time.ZoneId | |
class MagicLinkTokenTest { | |
init { | |
Security.addProvider(BouncyCastleProvider()) | |
} | |
// for consistent tests | |
// 32 bytes | |
private val testSecretBytes = Bytes32.fromHexString("6f594ac70d7144fa87e8353c7943ff33d91919f4c2af459985c6a845f3a1a372") | |
private val secretKey = SECP256K1.SecretKey.fromBytes(testSecretBytes) | |
private val keyPair = SECP256K1.KeyPair.fromSecretKey(secretKey) | |
private val now = Instant.parse("2022-01-13T16:56:37Z") | |
// verification asserts | |
private val fakeClock = object : Clock() { | |
override fun getZone(): ZoneId { | |
TODO("Not yet implemented") | |
} | |
override fun withZone(zone: ZoneId?): Clock { | |
TODO("Not yet implemented") | |
} | |
override fun instant() = now | |
} | |
@Test | |
fun `MagicLink token should be verified and parsed correctly`() { | |
val json = """[ | |
"0x0016ff542ab7143cf598ebd3f4960243aee23a628ffd75aa87848cfcc70aa93f7ed294da3cb98c2555b387d358e76318c2e4babd73cd5a7a26f6f7bb3a2949fa1c", | |
"{\"iat\":1642092397,\"ext\":1642093297,\"iss\":\"did:ethr:0x750332BDf3D7BCC0644efC18D1fF6487f78C9402\",\"sub\":\"Q0caeWHvKDsQvJrGbDElDmtejgROsVF4uABQhWthR6Q=\",\"aud\":\"9OhuSJuPb4Zxh3HIFsqbhSN8Quiz8FCu-Cl55BdmaWY=\",\"nbf\":1642092397,\"tid\":\"9e5acd85-d604-4fd9-bb0b-d1fab5801885\",\"add\":\"0x59ea19cbe3d495c96de3372d53a9043874e361ecf091c05726c8297c74ef85ea112c0a1527bf6269010a6a02d4f27c2f0f17da96e2d6f74f2f00d2a1f1aed1191b\"}" | |
] | |
""".trimIndent() | |
val token = Json.fromJson<MagicLinkToken>(json) | |
// parsing asserts | |
assertEquals( | |
"0x0016ff542ab7143cf598ebd3f4960243aee23a628ffd75aa87848cfcc70aa93f7ed294da3cb98c2555b387d358e76318c2e4babd73cd5a7a26f6f7bb3a2949fa1c", | |
token.proof | |
) | |
assertEquals(Instant.parse("2022-01-13T16:46:37Z"), token.claims.issuedAt) | |
assertEquals(Instant.parse("2022-01-13T17:01:37Z"), token.claims.expiredAt) | |
assertEquals(Instant.parse("2022-01-13T16:46:37Z"), token.claims.notBefore) | |
assertEquals("did:ethr:0x750332BDf3D7BCC0644efC18D1fF6487f78C9402", token.claims.issuer) | |
assertEquals("Q0caeWHvKDsQvJrGbDElDmtejgROsVF4uABQhWthR6Q=", token.claims.subject) | |
assertEquals("9OhuSJuPb4Zxh3HIFsqbhSN8Quiz8FCu-Cl55BdmaWY=", token.claims.audience) | |
assertEquals("9e5acd85-d604-4fd9-bb0b-d1fab5801885", token.claims.didTokenId) | |
assertEquals( | |
"0x59ea19cbe3d495c96de3372d53a9043874e361ecf091c05726c8297c74ef85ea112c0a1527bf6269010a6a02d4f27c2f0f17da96e2d6f74f2f00d2a1f1aed1191b", | |
token.claims.additional | |
) | |
assertEquals( | |
"""{"iat":1642092397,"ext":1642093297,"iss":"did:ethr:0x750332BDf3D7BCC0644efC18D1fF6487f78C9402","sub":"Q0caeWHvKDsQvJrGbDElDmtejgROsVF4uABQhWthR6Q=","aud":"9OhuSJuPb4Zxh3HIFsqbhSN8Quiz8FCu-Cl55BdmaWY=","nbf":1642092397,"tid":"9e5acd85-d604-4fd9-bb0b-d1fab5801885","add":"0x59ea19cbe3d495c96de3372d53a9043874e361ecf091c05726c8297c74ef85ea112c0a1527bf6269010a6a02d4f27c2f0f17da96e2d6f74f2f00d2a1f1aed1191b"}""", | |
token.claims.asJsonString() | |
) | |
MagicLinkTokenVerifier.verify(token, clock = fakeClock) | |
} | |
@Test | |
fun `manually signed token should be (lol why) verifiable`() { | |
val token = MagicLinkTokenVerifier.signToken( | |
subject = "", | |
audience = "", | |
keyPair = keyPair, | |
tokenId = "d3224105-2402-49f3-b150-ad87438ae142", | |
clock = fakeClock, | |
) | |
val tokenJson = Json.toJson(token) | |
assertEquals( | |
"""["0xcc50b3d1048f7b294933310c25a664f2d0d6fd53ac9d0240c5ec7bb554d6ae02791f21f8793ee535c8eb011e050259e3d1c8652693c94658591241e3d3d6629c01","{\"iat\":\"2022-01-13T16:56:37Z\",\"ext\":\"2022-01-27T16:56:37Z\",\"iss\":\"did:ethr:0x25fea9c4d3712f8aaa80ab19be6c125bfd232b47\",\"sub\":\"\",\"aud\":\"\",\"nbf\":\"2022-01-13T16:56:37Z\",\"tid\":\"d3224105-2402-49f3-b150-ad87438ae142\",\"add\":\"0x506c14ca96d0a0f16de3979a78fad3fc888adff5b0434794348cdc1a656072e765194e90cf2fb3646123d260704b478ffad8daebb9486c1a90666778e8eb8dbc01\"}"]""", | |
tokenJson | |
) | |
MagicLinkTokenVerifier.verify(token, clock = fakeClock) | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment