Importing SSH key as SecKey?

I'm trying to write an application to interact with an API which authenticates requests using a signature from an SSH key. Currently targeting macOS 10.15. Running on iOS eventually might be nice, but that's way, way down my list of priorities right now.


I generated a test key using this command:


ssh-keygen -f ./test_id_ecdsa -t ecdsa -b 521 -m pem


Here is the actual key data (it will never be used for anything but testing key import):


-----BEGIN EC PRIVATE KEY-----
MIHbAgEBBEFaaU0VdcgDi+me65TnhRo9+AodcV5DOfbi8UteeDpojXW5PfkKXNQ+
qJlAyA0nVmkJlrwSOlqSH7XGzHuOTu+nd6AHBgUrgQQAI6GBiQOBhgAEAZMhoDRn
GAeReuc4sKEq3fznP1rPZ4QDdwpNfxQbPLe0rzg4fk+J6BPlyQs74RfHtXxiHOiL
3GZJLzo/pPbi96z7AG1AEABHWCcmi/uclGsjg0wNuKuWHwY8bJGvHZIBtd+px5+L
6L0wg93uMy3o2nMEJd01n18LGvjdl3GUvgq2kXQN
-----END EC PRIVATE KEY-----


The key has no passphrase. OpenSSL is able to work with it. Here is what OpenSSL says about it:


➜  ~ openssl ec -in ./test_id_ecdsa -text
read EC key
Private-Key: (521 bit)
priv:
    5a:69:4d:15:75:c8:03:8b:e9:9e:eb:94:e7:85:1a:
    3d:f8:0a:1d:71:5e:43:39:f6:e2:f1:4b:5e:78:3a:
    68:8d:75:b9:3d:f9:0a:5c:d4:3e:a8:99:40:c8:0d:
    27:56:69:09:96:bc:12:3a:5a:92:1f:b5:c6:cc:7b:
    8e:4e:ef:a7:77
pub: 
    04:01:93:21:a0:34:67:18:07:91:7a:e7:38:b0:a1:
    2a:dd:fc:e7:3f:5a:cf:67:84:03:77:0a:4d:7f:14:
    1b:3c:b7:b4:af:38:38:7e:4f:89:e8:13:e5:c9:0b:
    3b:e1:17:c7:b5:7c:62:1c:e8:8b:dc:66:49:2f:3a:
    3f:a4:f6:e2:f7:ac:fb:00:6d:40:10:00:47:58:27:
    26:8b:fb:9c:94:6b:23:83:4c:0d:b8:ab:96:1f:06:
    3c:6c:91:af:1d:92:01:b5:df:a9:c7:9f:8b:e8:bd:
    30:83:dd:ee:33:2d:e8:da:73:04:25:dd:35:9f:5f:
    0b:1a:f8:dd:97:71:94:be:0a:b6:91:74:0d
ASN1 OID: secp521r1
NIST CURVE: P-521
writing EC key
-----BEGIN EC PRIVATE KEY-----
MIHbAgEBBEFaaU0VdcgDi+me65TnhRo9+AodcV5DOfbi8UteeDpojXW5PfkKXNQ+
qJlAyA0nVmkJlrwSOlqSH7XGzHuOTu+nd6AHBgUrgQQAI6GBiQOBhgAEAZMhoDRn
GAeReuc4sKEq3fznP1rPZ4QDdwpNfxQbPLe0rzg4fk+J6BPlyQs74RfHtXxiHOiL
3GZJLzo/pPbi96z7AG1AEABHWCcmi/uclGsjg0wNuKuWHwY8bJGvHZIBtd+px5+L
6L0wg93uMy3o2nMEJd01n18LGvjdl3GUvgq2kXQN
-----END EC PRIVATE KEY-----


I am trying to import it as a SecKey with the eventual goal of getting it into a CryptoKit P521 key. This is my current Swift code:


let sshPrivateText:String = """
MIHbAgEBBEFaaU0VdcgDi+me65TnhRo9+AodcV5DOfbi8UteeDpojXW5PfkKXNQ+
qJlAyA0nVmkJlrwSOlqSH7XGzHuOTu+nd6AHBgUrgQQAI6GBiQOBhgAEAZMhoDRn
GAeReuc4sKEq3fznP1rPZ4QDdwpNfxQbPLe0rzg4fk+J6BPlyQs74RfHtXxiHOiL
3GZJLzo/pPbi96z7AG1AEABHWCcmi/uclGsjg0wNuKuWHwY8bJGvHZIBtd+px5+L
6L0wg93uMy3o2nMEJd01n18LGvjdl3GUvgq2kXQN
"""
let ecdsaPrivateKeyData:Data = Data(base64Encoded: sshPrivateText, options: .ignoreUnknownCharacters)!
var secKeyCreateWithDataError: Unmanaged<CFError>?
let secKeyAttributes:Dictionary = [kSecAttrKeyType: kSecAttrKeyTypeECDSA,
  kSecAttrKeyClass: kSecAttrKeyClassPrivate,
  kSecAttrKeySizeInBits: 521] as [CFString : Any]
let ecdsaSecKey = SecKeyCreateWithData(ecdsaPrivateKeyData as CFData, secKeyAttributes as CFDictionary, &secKeyCreateWithDataError)!

The force unwrap at the end is intended, as I want the call to crash while I'm learning. The SecKeyCreateWithData call is failing with the error "EC private key creation from data failed", but no further details. I suspect I'm doing something dumb which will be obvious to somebody more experienced than me.




Can anybody tell me what I'm doing wrong and what I need to research to fix it?

Replies

Consider this:

$ base64 -D > tmp.asn1
MIHbAgEBBEFaaU0VdcgDi+me65TnhRo9+AodcV5DOfbi8UteeDpojXW5PfkKXNQ+  
qJlAyA0nVmkJlrwSOlqSH7XGzHuOTu+nd6AHBgUrgQQAI6GBiQOBhgAEAZMhoDRn  
GAeReuc4sKEq3fznP1rPZ4QDdwpNfxQbPLe0rzg4fk+J6BPlyQs74RfHtXxiHOiL  
3GZJLzo/pPbi96z7AG1AEABHWCcmi/uclGsjg0wNuKuWHwY8bJGvHZIBtd+px5+L  
6L0wg93uMy3o2nMEJd01n18LGvjdl3GUvgq2kXQN
^D
$ dumpasn1 -p tmp.asn1 
SEQUENCE {
  INTEGER 1
  OCTET STRING
    5A 69 4D 15 75 C8 03 8B E9 9E EB 94 E7 85 1A 3D
    F8 0A 1D 71 5E 43 39 F6 E2 F1 4B 5E 78 3A 68 8D
    75 B9 3D F9 0A 5C D4 3E A8 99 40 C8 0D 27 56 69
    09 96 BC 12 3A 5A 92 1F B5 C6 CC 7B 8E 4E EF A7
    77
  [0] {
    OBJECT IDENTIFIER secp521r1 (1 3 132 0 35)
    }
  [1] {
    BIT STRING
      04 01 93 21 A0 34 67 18 07 91 7A E7 38 B0 A1 2A
      DD FC E7 3F 5A CF 67 84 03 77 0A 4D 7F 14 1B 3C
      B7 B4 AF 38 38 7E 4F 89 E8 13 E5 C9 0B 3B E1 17
      C7 B5 7C 62 1C E8 8B DC 66 49 2F 3A 3F A4 F6 E2
      F7 AC FB 00 6D 40 10 00 47 58 27 26 8B FB 9C 94
      6B 23 83 4C 0D B8 AB 96 1F 06 3C 6C 91 AF 1D 92
      01 B5 DF A9 C7 9F 8B E8 BD 30 83 DD EE 33 2D E8
      DA 73 04 25 DD 35 9F 5F 0B 1A F8 DD 97 71 94 BE
      0A B6 91 74 0D
    }
  }

Note You can get

dumpasn1
here.

This is an

ECPrivateKey
structure as defined in SEC 1.

I’m able to the public key from this (starting from line 22) using this code:

let publicKeyBytes: [UInt8] = [
    0x04, 0x01, 0x93, 0x21, 0xA0, 0x34, 0x67, 0x18, 0x07, 0x91, 0x7A, 0xE7, 0x38, 0xB0, 0xA1, 0x2A,
    0xDD, 0xFC, 0xE7, 0x3F, 0x5A, 0xCF, 0x67, 0x84, 0x03, 0x77, 0x0A, 0x4D, 0x7F, 0x14, 0x1B, 0x3C,
    0xB7, 0xB4, 0xAF, 0x38, 0x38, 0x7E, 0x4F, 0x89, 0xE8, 0x13, 0xE5, 0xC9, 0x0B, 0x3B, 0xE1, 0x17,
    0xC7, 0xB5, 0x7C, 0x62, 0x1C, 0xE8, 0x8B, 0xDC, 0x66, 0x49, 0x2F, 0x3A, 0x3F, 0xA4, 0xF6, 0xE2,
    0xF7, 0xAC, 0xFB, 0x00, 0x6D, 0x40, 0x10, 0x00, 0x47, 0x58, 0x27, 0x26, 0x8B, 0xFB, 0x9C, 0x94,
    0x6B, 0x23, 0x83, 0x4C, 0x0D, 0xB8, 0xAB, 0x96, 0x1F, 0x06, 0x3C, 0x6C, 0x91, 0xAF, 0x1D, 0x92,
    0x01, 0xB5, 0xDF, 0xA9, 0xC7, 0x9F, 0x8B, 0xE8, 0xBD, 0x30, 0x83, 0xDD, 0xEE, 0x33, 0x2D, 0xE8,
    0xDA, 0x73, 0x04, 0x25, 0xDD, 0x35, 0x9F, 0x5F, 0x0B, 0x1A, 0xF8, 0xDD, 0x97, 0x71, 0x94, 0xBE,
    0x0A, 0xB6, 0x91, 0x74, 0x0D
]
let publicKeyData = Data(publicKeyBytes)
guard let publicKey = SecKeyCreateWithData(publicKeyData as NSData, [
    kSecAttrKeyType: kSecAttrKeyTypeECSECPrimeRandom,
    kSecAttrKeyClass: kSecAttrKeyClassPublic
] as NSDictionary, nil) else {
    print("error")
    return
}
print("success, publicKey: \(publicKey)")

This is based on the external representation format for EC keys in the

SecKeyCopyExternalRepresentation(_:_:)
docs.

The tricky part is the private key. Based on the

SecKeyCopyExternalRepresentation
docs and some test I ran here, you should be able to simply concatenate the private key bytes on to the end of the public key to get a private key that you can import using
SecKeyCopyExternalRepresentation
. However, that does not work. I’m not sure how SSH is encoding the
privateKey
field of the
ECPrivateKey
structure.

Alas, I’ve run out of time to research this in the context of DevForums. I suspect that the above will give you some hints as to how to proceed. If you get stuck, and no one else chimes in, you should open a DTS tech support incident, which will allow me more time to investigate.

Share and Enjoy

Quinn “The Eskimo!”
Apple Developer Relations, Developer Technical Support, Core OS/Hardware

let myEmail = "eskimo" + "1" + "@apple.com"

Thank you! I totally get that you can't allocate further time to help. Fortunately, what you shared got me enough further along that I think I can manage the rest. In case this helps someone down the road (like me in a month), this is where my research took me:


It's incredibly frustrating how many key representation formats there are, and how few tools talk more than one or two. If I'm going to have to figure out how to bridge from one format to another by processing the asn.1 myself, I may as well go whole-hog and try to get it into CryptoKit directly.


I started by generating a key using CryptoKit's P521.Signing.PrivateKey() and dumped all the forms:


pubCompact:
0000000 00 a1 29 25 02 ac 42 03 d7 cb 0d 72 be ab 0f fc
0000010 99 19 ac 9d 5d 31 4c 11 37 fc b9 d5 12 3d 5b c6
0000020 11 5c bd f2 43 dc 0d 9b 31 15 5f d2 0e 0c bc 25
0000030 39 1d d3 77 84 d5 63 40 9c 9a 3b 39 3d 0e d2 e7
0000040 17 e8

pubRaw:
0000000 00 a1 29 25 02 ac 42 03 d7 cb 0d 72 be ab 0f fc
0000010 99 19 ac 9d 5d 31 4c 11 37 fc b9 d5 12 3d 5b c6
0000020 11 5c bd f2 43 dc 0d 9b 31 15 5f d2 0e 0c bc 25
0000030 39 1d d3 77 84 d5 63 40 9c 9a 3b 39 3d 0e d2 e7
0000040 17 e8 00 fe de 50 9b 47 9e 8c 83 98 4e 80 3e 55
0000050 d5 7b 25 b6 e1 1e f0 14 d1 3d dc 23 c4 94 d6 aa
0000060 ed 1f a0 cb b7 12 4f 96 fe 7e 89 27 ac 89 84 5b
0000070 02 d9 19 e8 47 de 27 37 a9 27 e7 24 b7 36 23 3d
0000080 12 09 56 6a

pubx963:
0000000 04 00 a1 29 25 02 ac 42 03 d7 cb 0d 72 be ab 0f
0000010 fc 99 19 ac 9d 5d 31 4c 11 37 fc b9 d5 12 3d 5b
0000020 c6 11 5c bd f2 43 dc 0d 9b 31 15 5f d2 0e 0c bc
0000030 25 39 1d d3 77 84 d5 63 40 9c 9a 3b 39 3d 0e d2
0000040 e7 17 e8 00 fe de 50 9b 47 9e 8c 83 98 4e 80 3e
0000050 55 d5 7b 25 b6 e1 1e f0 14 d1 3d dc 23 c4 94 d6
0000060 aa ed 1f a0 cb b7 12 4f 96 fe 7e 89 27 ac 89 84
0000070 5b 02 d9 19 e8 47 de 27 37 a9 27 e7 24 b7 36 23
0000080 3d 12 09 56 6a

privRaw:
0000000 01 5c a6 d5 f0 ad 64 46 33 78 35 da 56 13 e1 0d
0000010 64 3c cf 4d 93 02 fc db 50 2d b0 4a 80 95 d8 40
0000020 e2 01 58 14 a4 b3 8e b9 16 f4 79 4f e7 de a6 67
0000030 87 fb 64 0b df f3 18 75 db 91 58 d3 44 60 ba 63
0000040 5e ba

privx963:
0000000 04 00 a1 29 25 02 ac 42 03 d7 cb 0d 72 be ab 0f
0000010 fc 99 19 ac 9d 5d 31 4c 11 37 fc b9 d5 12 3d 5b
0000020 c6 11 5c bd f2 43 dc 0d 9b 31 15 5f d2 0e 0c bc
0000030 25 39 1d d3 77 84 d5 63 40 9c 9a 3b 39 3d 0e d2
0000040 e7 17 e8 00 fe de 50 9b 47 9e 8c 83 98 4e 80 3e
0000050 55 d5 7b 25 b6 e1 1e f0 14 d1 3d dc 23 c4 94 d6
0000060 aa ed 1f a0 cb b7 12 4f 96 fe 7e 89 27 ac 89 84
0000070 5b 02 d9 19 e8 47 de 27 37 a9 27 e7 24 b7 36 23
0000080 3d 12 09 56 6a 01 5c a6 d5 f0 ad 64 46 33 78 35
0000090 da 56 13 e1 0d 64 3c cf 4d 93 02 fc db 50 2d b0
00000a0 4a 80 95 d8 40 e2 01 58 14 a4 b3 8e b9 16 f4 79
00000b0 4f e7 de a6 67 87 fb 64 0b df f3 18 75 db 91 58
00000c0 d3 44 60 ba 63 5e ba


Of note:

  • Public raw is the public compact plus extra data at the end.
  • Public x9.63 is the public raw plus 0x04 at the beginning.
  • Private x9.63 is the public x9.63 plus the private raw at the end.


The documentation for SecKeyCopyExternalRepresentation(_:_:) cited above says the x9.63 format is 0x04 || X || Y || K, so the public compact is the X, the delta between public compact and public raw is the Y, and the private raw is the K. Each of those values is 66 bytes for a P521 key.


My OpenSSL command earlier only lists 65 bytes for the private key. The X in my test key generated in CryptoKit starts with 0x00, and I bet leading zeros are dropped. Tried this code:


let privateKeyBytes: [UInt8] = [
  0x00, 0x5A, 0x69, 0x4D, 0x15, 0x75, 0xC8, 0x03,
  0x8B, 0xE9, 0x9E, 0xEB, 0x94, 0xE7, 0x85, 0x1A,
  0x3D, 0xF8, 0x0A, 0x1D, 0x71, 0x5E, 0x43, 0x39,
  0xF6, 0xE2, 0xF1, 0x4B, 0x5E, 0x78, 0x3A, 0x68,
  0x8D, 0x75, 0xB9, 0x3D, 0xF9, 0x0A, 0x5C, 0xD4,
  0x3E, 0xA8, 0x99, 0x40, 0xC8, 0x0D, 0x27, 0x56,
  0x69, 0x09, 0x96, 0xBC, 0x12, 0x3A, 0x5A, 0x92,
  0x1F, 0xB5, 0xC6, 0xCC, 0x7B, 0x8E, 0x4E, 0xEF,
  0xA7, 0x77
]
let privateKeyData = Data(privateKeyBytes)
let ecdsaPrivateKey = try! P521.Signing.PrivateKey(rawRepresentation: privateKeyData)


print("Private key raw: \(ecdsaPrivateKey.rawRepresentation.base64EncodedString())")
print("Private key x9.63: \(ecdsaPrivateKey.x963Representation.base64EncodedString())")
print("Public key raw: \(ecdsaPrivateKey.publicKey.rawRepresentation.base64EncodedString())")
print("Public key x9.63: \(ecdsaPrivateKey.publicKey.x963Representation.base64EncodedString())")
print("Public key compact: \(ecdsaPrivateKey.publicKey.compactRepresentation?.base64EncodedString() ?? "Compact representation not allowed.")")


From just the leading-zero-padded private key as reported by OpenSSL, X and Y were recomputed, and the public key x9.63 equals the public key reported by OpenSSL! I still have a lot of work to do to get exactly the bytes I want from the key as represented on disk, but I am now in much better shape than I was when I posted my question.

Just learned something irritating about the on-disk format of SSH keys (possibly other keys, not sure). The leading zeros are actually ommitted not just from OpenSSL's output, but from the file on disk as well. The base64 above decodes to hex starting with this:


30 81 db 02 01 01 04 41 5a 69 4d 15 75 c8 03


The private key starts at 5a 69 4d 15. No leading zeros in the bytes from the base64. Easy enough to prepend zeros as needed:


var tempPrivateKeyBytes:[UInt8] = privateKeyBytes
while tempPrivateKeyBytes.count < 66 {
     tempPrivateKeyBytes.insert(0x00, at: 0)
}
self.ecdsaPrivateKey = try! P521.Signing.PrivateKey(rawRepresentation: Data(tempPrivateKeyBytes))

Just learned something irritating about the on-disk format of SSH keys (possibly other keys, not sure).

I’m not an expert in SSH but I’ve seen this sort of thing before in other contexts. The ASN.1 distinguished encoding rules (hence DER) require that every value be encoded in a unique way. For ASN.1

INTEGER
values, this means that leading zeroes must be dropped, otherwise you could encode the value in many different ways (for example, 42 could be 2a, or 00 2a, or 00 00 2a, and so on). It’s possible that OpenSSL is applies these rules in this context, even though it probably shouldn’t be.

ps The actual DER rules are based on the sign of the

INTEGER
value:
  • If the value is positive but the first byte has the top bit set, DER requires a single leading 00.

  • If the value is is negative and the first byte has the top bit clear, DER requires a single leading ff.

  • Otherwise, DER requires no leading bytes.

Share and Enjoy

Quinn “The Eskimo!”
Apple Developer Relations, Developer Technical Support, Core OS/Hardware

let myEmail = "eskimo" + "1" + "@apple.com"