path/to/{Relying Party ID}/{Credential ID (64 hexadecimal figures)}.gpg
e. g. path/to/webauthn.io/a1b2c3d4...9f.gpg
Binary passkey data is stored as a Base64URL-encoded string on the first line of the password file. Just like in traditional pass files, the passkey data can be followed by additional lines containing TOTP data, key: value pairs, or freeform text. Freeform text is separated from the previous structured content by an empty line.
Passkey credentials are first serialised as CBOR (the top-level structure is a map, CBOR major type 5) and then encoded into a Base64URL string. The CBOR map uses text-string keys and the following value types (CBOR major types shown in parentheses):
{
"id": array, # credential ID — array (major type 4) of unsigned integers (0..255), 32 items
"rp": { # relying party info — map (major type 5)
"id": tstr, # RP ID — text string (major type 3), e. g. "example.com"
"name": tstr / null # RP name (optional) — text string, e. g. "Example Ltd.", or null
},
"user": { # credential owner info — map (major type 5)
"id": array, # user handle — array (major type 4) of unsigned integers (0..255), variable length
"name": tstr, # login name — text string, e. g. "alice", "alice@example.com"
"display_name": tstr / null # display name (optional) — text string, e. g. "Alice Doe", or null
},
"sign_count": uint, # signature counter — unsigned integer (major type 0); APS keeps at 0 to allow for cloned passkeys
"alg": int, # COSE algorithm identifier — integer (negative values use major type 1), ES256 = -7, Ed25519 = -8, RS256 = -257
"private_key": array, # raw private key bytes — array (major type 4) of unsigned integers (0..255); see 'Key data in private_key'
"created": uint, # seconds since Epoch — unsigned integer (major type 0)
"zone": tstr # time zone ID — text string (major type 3), e. g. "Europe/Berlin", parsable by `java.time.ZoneId.of(...)`
}
The raw bytes stored in the private_key field are obtained from java.security.PrivateKey as follows:
import java.security.PrivateKey
import java.security.interfaces.ECPrivateKey
import java.security.interfaces.RSAPrivateKey
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.util.PrivateKeyFactory
/* ECC P-256 private key as BigInteger scalar, converted to zero-padded, size 32 ByteArray
* with leading signum byte stripped */
fun getES256PrivateKeyRawBytes(privateKey: PrivateKey): ByteArray =
(ByteArray(32) + (privateKey as ECPrivateKey).s.toByteArray()).let {
it.copyOfRange(it.size - 32, it.size)
}
// Ed25519 private key, converted to size 32 ByteArray using BouncyCastle
fun getEd25519PrivateKeyRawBytes(privateKey: PrivateKey): ByteArray =
(PrivateKeyFactory.createKey(privateKey.encoded) as Ed25519PrivateKeyParameters).encoded
// RSA-2048 private key, with modulus and exponent concatenated to size 512 ByteArray
fun getRS256PrivateKeyRawBytes(privateKey: PrivateKey): ByteArray {
val rsaKey = privateKey as RSAPrivateKey
val nBytes =
(ByteArray(256) + rsaKey.modulus.toByteArray()).let {
it.copyOfRange(it.size - 256, it.size)
}
val dBytes =
(ByteArray(256) + rsaKey.privateExponent.toByteArray()).let {
it.copyOfRange(it.size - 256, it.size)
}
return nBytes + dBytes
}Private key raw data bytes can be converted back to java.security.PrivateKey with
import java.security.AlgorithmParameters
import java.security.KeyFactory
import java.security.PrivateKey
import java.security.spec.ECPrivateKeySpec
import java.security.spec.RSAPrivateKeySpec
import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
import org.bouncycastle.crypto.util.PrivateKeyInfoFactory
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.jce.spec.ECParameterSpec
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter
fun rebuildES256FromPrivateKeyRawBytes(bytes: ByteArray): PrivateKey {
require(bytes.size == 32) { "ECDSA P-256 raw private key must be 32 bytes" }
val s = BigInteger(1, bytes)
val params =
AlgorithmParameters.getInstance("EC", BouncyCastleProvider()).apply {
init(java.security.spec.ECGenParameterSpec("secp256r1"))
}
val ecParameters = params.getParameterSpec(java.security.spec.ECParameterSpec::class.java)
val keySpec = ECPrivateKeySpec(s, ecParameters)
return KeyFactory.getInstance("EC", BouncyCastleProvider()).generatePrivate(keySpec)
}
fun rebuildEd25519FromPrivateKeyRawBytes(bytes: ByteArray): PrivateKey {
require(bytes.size == 32) { "Ed25519 raw private key must be 32 bytes" }
val privateKeyParams = Ed25519PrivateKeyParameters(bytes)
val privateKeyInfo = PrivateKeyInfoFactory.createPrivateKeyInfo(privateKeyParams)
return JcaPEMKeyConverter().setProvider(BouncyCastleProvider()).getPrivateKey(privateKeyInfo)
}
fun rebuildRS256FromPrivateKeyRawBytes(bytes: ByteArray): PrivateKey {
require(bytes.size == 512) { "Buffer must be exactly 512 bytes" }
val n = BigInteger(1, bytes.copyOfRange(0, 256))
val d = BigInteger(1, bytes.copyOfRange(256, 512))
val keySpec = RSAPrivateKeySpec(n, d)
return KeyFactory.getInstance("RSA", BouncyCastleProvider()).generatePrivate(keySpec)
}