Skip to content

Latest commit

 

History

History
114 lines (97 loc) · 5.44 KB

File metadata and controls

114 lines (97 loc) · 5.44 KB

Passkey storage

File name convention

path/to/{Relying Party ID}/{Credential ID (64 hexadecimal figures)}.gpg

e. g. path/to/webauthn.io/a1b2c3d4...9f.gpg

File format

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 data format

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(...)`
}

Key data in private_key

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)
}