Skip to content

Instantly share code, notes, and snippets.

@Szer
Created January 19, 2022 15:20
Show Gist options
  • Save Szer/97057b713fc99672940255db2833ae34 to your computer and use it in GitHub Desktop.
Save Szer/97057b713fc99672940255db2833ae34 to your computer and use it in GitHub Desktop.
Example of verifying and signing of MagicLink token
// 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)
}
}
// 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()
}
}
}
// 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