Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,25 +1,54 @@
# blue-app-ssh-agent
# app-ssh-agent
Comment thread
cedelavergne-ledger marked this conversation as resolved.

## ⚠️ DEPRECATION WARNING

> **This application is no longer maintained and will be deprecated.**
>
> Please migrate to one of the following alternatives:
>
> - **[app-openpgp](https://github.com/LedgerHQ/app-openpgp)** - OpenPGP application for Ledger devices
> - Check [ssh support](https://github.com/LedgerHQ/app-openpgp/blob/develop/doc/user/app-openpgp.rst#ssh)
> - **[app-security-key](https://github.com/LedgerHQ/app-security-key)** - FIDO2/U2F security key application
> - Check [Web authn support](https://www.ledger.com/blog/strengthen-the-security-of-your-accounts-with-webauthn)
>
> No further updates or support will be provided for app-ssh-agent.

### Ledger SSH Methods Comparison

| Feature | **app-openpgp** (The "Smart Card" Way) | **app-security-key** (The "Modern" Way) |
| :--------------------------------- | :--------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------- |
| **Protocol** | **OpenPGP Card** (ISO 7816) | **FIDO2 / WebAuthn** (CTAP2) |
| **Integration** | **Bridge:** Uses `gpg-agent` as a translator between SSH and the device. | **Native:** Built directly into OpenSSH (v8.2+) via the `-sk` extension. |
| **Complexity** | **Configuration Required:** Involves setting up the GPG agent and environment variables. | **Minimal:** Standard `ssh-keygen` command. No extra software needed. |
| **Algorithms** | **RSA** (up to 4096), Ed25519, SECP256K1, SECP256R1 (NIST P-256). | **Ed25519**, NIST P-256. (**NO RSA**). |
| **Compatibility** | **Universal:** Works with almost any SSH server (legacy & modern). | **Modern:** Client requires OpenSSH 8.2+. Server requires recent OpenSSH. |
| **Key Storage** | **Stateful:** Private keys are stored permanently in the Ledger's NVRAM. | **Stateless / Hybrid:** "Key Handle" file stored on PC; Secret derived on Ledger during login. |
| **Agent Used** | `gpg-agent` (impersonating `ssh-agent`). | Standard `ssh-agent`. |
| **Touch Policy** | **Flexible:** Can be cached for a session (if configured). | **Mandatory:** Requires a physical touch for every login attempt. |
| **Migration from `app-ssh-agent`** | **Not Possible:** Protocol mismatch. You must generate new keys. | **Not Possible:** Protocol mismatch. You must generate new keys. |

## App overview

A simple PGP and SSH agent for Ledger Blue, supporting prime256v1 and ed25519 keys.

This agent is compatible with the third party SSH/PGP host client from Roman Zeyde available at https://github.com/romanz/trezor-agent - it is recommended to use it for extra functionalities
This agent is compatible with the third party SSH/PGP host client from Roman Zeyde available at [trezor](https://github.com/romanz/trezor-agent).
It is recommended to use it for extra functionalities.

You can also use the SSH functionalities with the following instructions using Python 2 :
You can also use the SSH functionalities with the following instructions using Python.

Run getPublicKey.py to get the public key in SSH format, to be added to your authorized keys on the target
Run `getPublicKey.py` to get the public key in SSH format, to be added to your authorized keys on the target

```
```bash
python getPublicKey.py
ecdsa-sha2-nistp256 AAAA....
```

Run agent.py, providing the base64 encoded key retrieved earlier
Run `agent.py`, providing the base64 encoded key retrieved earlier

```
```bash
python agent.py --key AAAA....
```

Export the environment variables in your shell to use it

You can also set the derivation path from the master seed by providing it with the --path parameter.

You can also set the derivation path from the master seed by providing it with the `--path` parameter.
92 changes: 41 additions & 51 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,13 @@
********************************************************************************
"""
from ledgerblue.comm import getDongle
from ledgerblue.commException import CommException
import argparse
import struct
import base64
import os
import socket
import tempfile
import thread
import threading
import logging

SIG_HEADER = "ecdsa-sha2-nistp256"
Expand All @@ -44,64 +43,56 @@

def handleRequestIdentities(message, key, eddsa, path):
logging.debug("Request identities")
response = chr(SSH2_AGENT_IDENTITIES_ANSWER)
response = bytes([SSH2_AGENT_IDENTITIES_ANSWER])
response += struct.pack(">I", 1)
response += struct.pack(">I", len(key)) + key
response += struct.pack(">I", len(path)) + path
response += struct.pack(">I", len(path)) + path.encode()
return response

def handleSignRequest(message, key, eddsa, path):
logging.debug("Sign request")
blobSize = struct.unpack(">I", message[0:4])[0]
blob = message[4 : 4 + blobSize]
if blob <> key:
logging.debug("Client sent a different blob " + blob.encode('hex'))
return chr(SSH_AGENT_FAILURE)
if blob != key:
logging.debug("Client sent a different blob " + blob.hex())
return bytes([SSH_AGENT_FAILURE])
challengeSize = struct.unpack(">I", message[4 + blobSize : 4 + blobSize + 4])[0]
challenge = message[4 + blobSize + 4: 4 + blobSize + 4 + challengeSize]
# Send the challenge in chunks
dongle = getDongle(logging.getLogger().isEnabledFor(logging.DEBUG))
donglePath = parse_bip32_path(args.path)
offset = 0
while offset <> len(challenge):
data = ""
while offset != len(challenge):
data = b""
if offset == 0:
donglePath = parse_bip32_path(path)
data = chr(len(donglePath) / 4) + donglePath
if (len(challenge) - offset) > (255 - len(data)):
chunkSize = (255 - len(data))
else:
chunkSize = len(challenge) - offset
data = bytes([len(donglePath) // 4]) + donglePath
chunkSize = min(255 - len(data), len(challenge) - offset)
data += challenge[offset : offset + chunkSize]
if offset == 0:
p1 = 0x00
else:
p1 = 0x01
if eddsa:
p2 = 0x02
else:
p2 = 0x01
p1 = 0x00 if offset == 0 else 0x01
p2 = 0x02 if eddsa else 0x01
offset += chunkSize
if offset == len(challenge):
p1 |= 0x80
apdu = "8004".decode('hex') + chr(p1) + chr(p2) + chr(len(data)) + data
signature = dongle.exchange(bytes(apdu))
apdu = bytes.fromhex("8004") + bytes([p1, p2, len(data)]) + data
signature = dongle.exchange(apdu)
dongle.close()
# Parse r and s
rLength = signature[3]
r = signature[4 : 4 + rLength]
sLength = signature[4 + rLength + 1]
s = signature[4 + rLength + 2:]
r = str(r)
s = str(s)
r = bytes(r)
s = bytes(s)

encodedSignatureValue = struct.pack(">I", len(r)) + r
encodedSignatureValue += struct.pack(">I", len(s)) + s

encodedSignature = struct.pack(">I", len(SIG_HEADER)) + SIG_HEADER
sig_header = SIG_HEADER_EDDSA if eddsa else SIG_HEADER
encodedSignature = struct.pack(">I", len(sig_header)) + sig_header.encode()
encodedSignature += struct.pack(">I", len(encodedSignatureValue)) + encodedSignatureValue

response = chr(SSH2_AGENT_SIGN_RESPONSE)
response = bytes([SSH2_AGENT_SIGN_RESPONSE])

response += struct.pack(">I", len(encodedSignature)) + encodedSignature
return response
Expand All @@ -122,44 +113,44 @@ def clientHandlerInternal(connection, key, eddsa, comment):
message = connection.recv(size)
except socket.timeout:
logging.debug("Timeout")
message = ""
message = ""
if len(message) == 0:
logging.debug("Client dropped connection")
break
logging.debug("<= " + message.encode('hex'))
messageType = ord(message[0])
logging.debug("<= " + message.hex())
messageType = message[0]
if messageType == SSH2_AGENTC_REQUEST_IDENTITIES:
response = handleRequestIdentities(message[1:], key, eddsa, comment)
elif messageType == SSH2_AGENTC_SIGN_REQUEST:
response = handleSignRequest(message[1:], key, eddsa, comment)
else:
logging.debug("Unhandled message")
response = chr(SSH_AGENT_FAILURE)
response = bytes([SSH_AGENT_FAILURE])
agentResponse = struct.pack(">I", len(response)) + response
logging.debug("=> " + agentResponse.encode('hex'))
connection.send(agentResponse)
logging.debug("=> " + agentResponse.hex())
connection.send(agentResponse)

def clientHandler(connection, key, eddsa, comment):
def clientHandler(connection, key, eddsa, comment):
try:
clientHandlerInternal(connection, key, eddsa, comment)
except Exception:
logging.debug("Internal error handling client", exc_info=True)
response = chr(SSH_AGENT_FAILURE)
response = bytes([SSH_AGENT_FAILURE])
agentResponse = struct.pack(">I", len(response)) + response
logging.debug("=> " + agentResponse.encode('hex'))
connection.send(agentResponse)
logging.debug("=> " + agentResponse.hex())
connection.send(agentResponse)

def parse_bip32_path(path):
if len(path) == 0:
return ""
result = ""
return b""
result = b""
elements = path.split('/')
for pathElement in elements:
element = pathElement.split('\'')
if len(element) == 1:
result = result + struct.pack(">I", int(element[0]))
result += struct.pack(">I", int(element[0]))
else:
result = result + struct.pack(">I", 0x80000000 | int(element[0]))
result += struct.pack(">I", 0x80000000 | int(element[0]))
return result

parser = argparse.ArgumentParser()
Expand All @@ -169,23 +160,23 @@ def parse_bip32_path(path):
parser.add_argument('--debug', help="Display debugging information", action='store_true')
args = parser.parse_args()

if args.path == None:
if args.path is None:
args.path = "44'/535348'/0'/0/0"

if args.key == None:
if args.key is None:
raise Exception("No key specified")

if args.debug:
logging.getLogger().setLevel(logging.DEBUG)
logging.basicConfig(level=logging.DEBUG)

keyBlob = base64.b64decode(args.key)

socketPath = tempfile.NamedTemporaryFile(prefix=SOCK_PREFIX, delete=False)
os.unlink(socketPath.name)
print "Export those variables in your shell to use this agent"
print "export SSH_AUTH_SOCK=" + socketPath.name
print "export SSH_AGENT_PID=" + str(os.getpid())
print "Agent running ..."
print("Export those variables in your shell to use this agent")
print("export SSH_AUTH_SOCK=" + socketPath.name)
print("export SSH_AGENT_PID=" + str(os.getpid()))
print("Agent running ...")

server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(socketPath.name)
Expand All @@ -196,9 +187,8 @@ def parse_bip32_path(path):
connection, addr = server.accept()
logging.debug("New client connected")
connection.settimeout(TIMEOUT)
thread.start_new_thread(clientHandler, (connection, keyBlob, args.ed25519, args.path))
threading.Thread(target=clientHandler, args=(connection, keyBlob, args.ed25519, args.path)).start()
except KeyboardInterrupt:
pass
finally:
os.unlink(socketPath.name)

25 changes: 12 additions & 13 deletions getPublicKey.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
********************************************************************************
"""
from ledgerblue.comm import getDongle
from ledgerblue.commException import CommException
import argparse
import struct
import base64
Expand All @@ -29,20 +28,21 @@

def parse_bip32_path(path):
if len(path) == 0:
return ""
result = ""
return b""
result = b""
elements = path.split('/')
for pathElement in elements:
element = pathElement.split('\'')
if len(element) == 1:
result = result + struct.pack(">I", int(element[0]))
result = result + struct.pack(">I", int(element[0]))
else:
result = result + struct.pack(">I", 0x80000000 | int(element[0]))
return result

parser = argparse.ArgumentParser()
parser.add_argument('--path', help="BIP 32 path to retrieve")
parser.add_argument("--ed25519", help="Use Ed25519 curve", action='store_true')
parser.add_argument("--verbose", help="Enable verbose output", action='store_true')
args = parser.parse_args()

if args.path == None:
Expand All @@ -57,21 +57,20 @@ def parse_bip32_path(path):

donglePath = parse_bip32_path(args.path)
apdu = "800200" + p2
apdu = apdu.decode('hex') + chr(len(donglePath) + 1) + chr(len(donglePath) / 4) + donglePath
apdu = bytes.fromhex(apdu) + bytes([len(donglePath) + 1]) + bytes([len(donglePath) // 4]) + donglePath

dongle = getDongle(True)
dongle = getDongle(args.verbose)
result = dongle.exchange(bytes(apdu))
key = str(result[1:])
blob = struct.pack(">I", len(KEY_HEADER)) + keyHeader
key = result[1:]
blob = struct.pack(">I", len(keyHeader)) + keyHeader.encode()
if args.ed25519:
keyX = bytearray(key[0:32])
keyY = bytearray(key[32:][::-1])
if ((keyX[31] & 1)<>0):
if ((keyX[31] & 1) != 0):
keyY[31] |= 0x80
key = str(keyY)
key = bytes(keyY)
else:
blob += struct.pack(">I", len(CURVE_NAME)) + CURVE_NAME
blob += struct.pack(">I", len(CURVE_NAME)) + CURVE_NAME.encode()

blob += struct.pack(">I", len(key)) + key
print keyHeader + " " + base64.b64encode(blob)

print(f"{keyHeader} {base64.b64encode(blob).decode()}")
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ledgerblue
Loading