ssh-agent: Implement destination constraints

This change implements loading ssh-agent destination constraints from
KeeAgent.settings into the ssh-agent. For now there is no UI so
configuration must be done in KeePass2/KeeAgent or manually.

The ssh-agent constrain extension is described at [1]. However, I found
it partly misleading:
- in the constaint array each constraint is enveloped where in the
  keyspec arrays the keyspec are just appended to the constraint.
- each constraint and host has an additional string field reserved for
  future use.
The actual structure has been obtained from openssh ssh-add source code [2].

[1]: https://www.openssh.com/agent-restrict.html
[2]: 3ad669f81a/authfd.c (L538)

Signed-off-by: Konrad Vité <kvite@paktolos.net>
This commit is contained in:
Konrad Vité 2025-02-06 22:09:24 +01:00
parent c642e6bbd3
commit 872e914520
7 changed files with 395 additions and 1 deletions

View file

@ -9839,6 +9839,10 @@ This option is deprecated, use --set-key-file instead.</source>
<source>All SSH identities removed from agent.</source>
<translation type="unfinished"></translation>
</message>
<message>
<source>Destination constraints are invalid or not supported by the agent (check options).</source>
<translation type="unfinished"></translation>
</message>
</context>
<context>
<name>SearchHelpWidget</name>

View file

@ -35,6 +35,29 @@ KeeAgentSettings::KeeAgentSettings()
reset();
}
bool KeeAgentSettings::KeySpec::operator==(const KeeAgentSettings::KeySpec& other) const
{
return (key == other.key && isCertificateAuthority == other.isCertificateAuthority);
}
QByteArray KeeAgentSettings::KeySpec::getKeyBlob() const
{
// In KeeAgent the key data is the second word in the string. First is the
// key type. Third is the key comment which is optional.
auto words = key.split(" ");
if (words.length() >= 2) {
return QByteArray::fromBase64(words[1].toLatin1(), QByteArray::Base64Encoding);
} else {
return QByteArray();
}
}
bool KeeAgentSettings::DestinationConstraint::operator==(const KeeAgentSettings::DestinationConstraint& other) const
{
return (fromHost == other.fromHost && fromHostKeys == other.fromHostKeys && toUser == other.toUser
&& toHost == other.toHost && toHostKeys == other.toHostKeys);
}
bool KeeAgentSettings::operator==(const KeeAgentSettings& other) const
{
// clang-format off
@ -43,6 +66,8 @@ bool KeeAgentSettings::operator==(const KeeAgentSettings& other) const
&& m_useConfirmConstraintWhenAdding == other.m_useConfirmConstraintWhenAdding
&& m_useLifetimeConstraintWhenAdding == other.m_useLifetimeConstraintWhenAdding
&& m_lifetimeConstraintDuration == other.m_lifetimeConstraintDuration
&& m_useDestinationConstraintsWhenAdding == other.m_useDestinationConstraintsWhenAdding
&& m_destinationConstraints == other.m_destinationConstraints
&& m_selectedType == other.m_selectedType
&& m_attachmentName == other.m_attachmentName
&& m_saveAttachmentToTempFile == other.m_saveAttachmentToTempFile
@ -77,6 +102,8 @@ void KeeAgentSettings::reset()
m_useConfirmConstraintWhenAdding = false;
m_useLifetimeConstraintWhenAdding = false;
m_lifetimeConstraintDuration = 600;
m_useDestinationConstraintsWhenAdding = false;
m_destinationConstraints.clear();
m_selectedType = QStringLiteral("file");
m_attachmentName.clear();
@ -125,6 +152,16 @@ int KeeAgentSettings::lifetimeConstraintDuration() const
return m_lifetimeConstraintDuration;
}
bool KeeAgentSettings::useDestinationConstraintsWhenAdding() const
{
return m_useDestinationConstraintsWhenAdding;
}
QList<KeeAgentSettings::DestinationConstraint> KeeAgentSettings::destinationConstraints() const
{
return m_destinationConstraints;
}
const QString KeeAgentSettings::selectedType() const
{
return m_selectedType;
@ -180,6 +217,16 @@ void KeeAgentSettings::setLifetimeConstraintDuration(int lifetimeConstraintDurat
m_lifetimeConstraintDuration = lifetimeConstraintDuration;
}
void KeeAgentSettings::setUseDestinationConstraintsWhenAdding(bool useDestinationConstraintsWhenAdding)
{
m_useDestinationConstraintsWhenAdding = useDestinationConstraintsWhenAdding;
}
void KeeAgentSettings::setDestinationConstraints(const QList<DestinationConstraint>& destinationConstraints)
{
m_destinationConstraints = destinationConstraints;
}
void KeeAgentSettings::setSelectedType(const QString& selectedType)
{
m_selectedType = selectedType;
@ -229,6 +276,8 @@ bool KeeAgentSettings::fromXml(const QByteArray& ba)
QXmlStreamReader reader;
reader.addData(ba);
reset();
if (reader.error() || !reader.readNextStartElement()) {
m_error = reader.errorString();
return false;
@ -273,6 +322,88 @@ bool KeeAgentSettings::fromXml(const QByteArray& ba)
reader.skipCurrentElement();
}
}
if (!reader.error())
reader.readNext();
} else if (reader.name() == "UseDestinationConstraintWhenAdding") {
m_useDestinationConstraintsWhenAdding = readBool(reader);
} else if (reader.name() == "DestinationConstraints") {
while (!reader.error() && reader.readNextStartElement()) {
if (reader.name() == "Constraint") {
KeeAgentSettings::DestinationConstraint constraint;
while (!reader.error() && reader.readNextStartElement()) {
if (reader.name() == "FromHostKeys" || reader.name() == "ToHostKeys") {
QString section = reader.name().toString();
while (!reader.error() && reader.readNextStartElement()) {
if (reader.name() == "KeySpec") {
KeeAgentSettings::KeySpec keyspec;
while (!reader.error() && reader.readNextStartElement()) {
if (reader.name() == "HostKey") {
reader.readNext();
keyspec.key = reader.text().toString();
reader.readNext();
} else if (reader.name() == "IsCA") {
keyspec.isCertificateAuthority = readBool(reader);
} else {
qWarning() << "Skipping KeySpec element" << reader.name();
reader.skipCurrentElement();
}
}
if (keyspec.getKeyBlob().isEmpty()) {
return false;
}
if (section == "FromHostKeys") {
constraint.fromHostKeys.append(std::move(keyspec));
} else {
constraint.toHostKeys.append(std::move(keyspec));
}
if (!reader.error())
reader.readNext();
} else {
qWarning() << "Skipping " << section << " element" << reader.name();
reader.skipCurrentElement();
}
}
if (!reader.error())
reader.readNext();
} else if (reader.name() == "FromHost") {
reader.readNext();
constraint.fromHost = reader.text().toString();
reader.readNext();
} else if (reader.name() == "ToUser") {
reader.readNext();
constraint.toUser = reader.text().toString();
reader.readNext();
} else if (reader.name() == "ToHost") {
reader.readNext();
constraint.toHost = reader.text().toString();
reader.readNext();
} else {
qWarning() << "Skipping Constraint element" << reader.name();
reader.skipCurrentElement();
}
}
if ((constraint.fromHost.isEmpty() && !constraint.fromHostKeys.isEmpty())
|| (!constraint.fromHost.isEmpty() && constraint.fromHostKeys.isEmpty())) {
return false;
}
if (constraint.toHost.isEmpty() || constraint.toHostKeys.isEmpty()) {
return false;
}
m_destinationConstraints.append(std::move(constraint));
if (!reader.error())
reader.readNext();
} else {
qWarning() << "Skipping DestinationConstraints element" << reader.name();
reader.skipCurrentElement();
}
}
if (!reader.error())
reader.readNext();
} else {
qWarning() << "Skipping element" << reader.name();
reader.skipCurrentElement();
@ -309,6 +440,53 @@ QByteArray KeeAgentSettings::toXml() const
writer.writeTextElement("UseConfirmConstraintWhenAdding", m_useConfirmConstraintWhenAdding ? "true" : "false");
writer.writeTextElement("UseLifetimeConstraintWhenAdding", m_useLifetimeConstraintWhenAdding ? "true" : "false");
writer.writeTextElement("LifetimeConstraintDuration", QString::number(m_lifetimeConstraintDuration));
writer.writeTextElement("UseDestinationConstraintWhenAdding",
m_useDestinationConstraintsWhenAdding ? "true" : "false");
writer.writeStartElement("DestinationConstraints");
foreach (const auto& constraint, m_destinationConstraints) {
writer.writeStartElement("Constraint");
if (constraint.fromHost.isEmpty()) {
writer.writeEmptyElement("FromHost");
} else {
writer.writeTextElement("FromHost", constraint.fromHost);
}
writer.writeStartElement("FromHostKeys");
foreach (const auto& keyspec, constraint.fromHostKeys) {
writer.writeStartElement("KeySpec");
writer.writeTextElement("HostKey", keyspec.key);
writer.writeTextElement("IsCA", keyspec.isCertificateAuthority ? "true" : "false");
writer.writeEndElement(); // KeySpec
}
writer.writeEndElement(); // FromHostKeys
if (constraint.toUser.isEmpty()) {
writer.writeEmptyElement("ToUser");
} else {
writer.writeTextElement("ToUser", constraint.toUser);
}
if (constraint.toHost.isEmpty()) {
writer.writeEmptyElement("ToHost");
} else {
writer.writeTextElement("ToHost", constraint.toHost);
}
writer.writeStartElement("ToHostKeys");
foreach (const auto& keyspec, constraint.toHostKeys) {
writer.writeStartElement("KeySpec");
writer.writeTextElement("HostKey", keyspec.key);
writer.writeTextElement("IsCA", keyspec.isCertificateAuthority ? "true" : "false");
writer.writeEndElement(); // KeySpec
}
writer.writeEndElement(); // ToHostKeys
writer.writeEndElement(); // Constraint
}
writer.writeEndElement(); // DestinationConstraints
writer.writeStartElement("Location");
writer.writeTextElement("SelectedType", m_selectedType);

View file

@ -29,6 +29,27 @@ class QXmlStreamReader;
class KeeAgentSettings
{
public:
struct KeySpec
{
QString key;
bool isCertificateAuthority;
bool operator==(const KeySpec& other) const;
QByteArray getKeyBlob() const;
};
struct DestinationConstraint
{
QString fromHost;
QList<KeySpec> fromHostKeys;
QString toUser;
QString toHost;
QList<KeySpec> toHostKeys;
bool operator==(const DestinationConstraint& other) const;
};
KeeAgentSettings();
bool operator==(const KeeAgentSettings& other) const;
bool operator!=(const KeeAgentSettings& other) const;
@ -58,6 +79,8 @@ public:
bool useConfirmConstraintWhenAdding() const;
bool useLifetimeConstraintWhenAdding() const;
int lifetimeConstraintDuration() const;
bool useDestinationConstraintsWhenAdding() const;
QList<DestinationConstraint> destinationConstraints() const;
const QString selectedType() const;
const QString attachmentName() const;
@ -71,6 +94,8 @@ public:
void setUseConfirmConstraintWhenAdding(bool useConfirmConstraintWhenAdding);
void setUseLifetimeConstraintWhenAdding(bool useLifetimeConstraintWhenAdding);
void setLifetimeConstraintDuration(int lifetimeConstraintDuration);
void setUseDestinationConstraintsWhenAdding(bool useDestinationConstraintsWhenAdding);
void setDestinationConstraints(const QList<DestinationConstraint>& destinationConstraints);
void setSelectedType(const QString& type);
void setAttachmentName(const QString& attachmentName);
@ -87,6 +112,8 @@ private:
bool m_useConfirmConstraintWhenAdding;
bool m_useLifetimeConstraintWhenAdding;
int m_lifetimeConstraintDuration;
bool m_useDestinationConstraintsWhenAdding;
QList<DestinationConstraint> m_destinationConstraints;
// location
QString m_selectedType;

View file

@ -305,6 +305,12 @@ bool SSHAgent::addIdentity(OpenSSHKey& key, const KeeAgentSettings& settings, co
request.writeString(securityKeyProvider());
}
if (settings.useDestinationConstraintsWhenAdding()) {
request.write(SSH_AGENT_CONSTRAIN_EXTENSION);
request.writeString(QString("restrict-destination-v00@openssh.com"));
encodeDestinationConstraints(settings.destinationConstraints(), request);
}
QByteArray responseData;
if (!sendMessage(requestData, responseData)) {
return false;
@ -322,6 +328,10 @@ bool SSHAgent::addIdentity(OpenSSHKey& key, const KeeAgentSettings& settings, co
m_error += "\n" + tr("A confirmation request is not supported by the agent (check options).");
}
if (settings.useDestinationConstraintsWhenAdding()) {
m_error += "\n" + tr("Destination constraints are invalid or not supported by the agent (check options).");
}
if (isSecurityKey) {
m_error +=
"\n" + tr("Security keys are not supported by the agent or the security key provider is unavailable.");
@ -336,6 +346,54 @@ bool SSHAgent::addIdentity(OpenSSHKey& key, const KeeAgentSettings& settings, co
return true;
}
bool SSHAgent::encodeDestinationConstraints(const QList<KeeAgentSettings::DestinationConstraint>& constraints,
BinaryStream& out)
{
QByteArray data;
BinaryStream stream(&data);
foreach (const auto& constraint, constraints) {
encodeDestinationConstraint(constraint, stream);
}
out.writeString(data);
return true;
}
bool SSHAgent::encodeDestinationConstraint(const KeeAgentSettings::DestinationConstraint& constraint, BinaryStream& out)
{
QByteArray data;
BinaryStream stream(&data);
encodeDestinationConstraintHost("", constraint.fromHost, constraint.fromHostKeys, stream);
encodeDestinationConstraintHost(constraint.toUser, constraint.toHost, constraint.toHostKeys, stream);
stream.writeString(QString("")); // reserved
out.writeString(data);
return true;
}
bool SSHAgent::encodeDestinationConstraintHost(const QString user,
const QString hostname,
const QList<KeeAgentSettings::KeySpec>& keys,
BinaryStream& out)
{
QByteArray data;
BinaryStream stream(&data);
stream.writeString(user);
stream.writeString(hostname);
stream.writeString(QString("")); // reserved
foreach (const auto& key, keys) {
stream.writeString(key.getKeyBlob());
stream.write(static_cast<quint8>(key.isCertificateAuthority));
}
out.writeString(data);
return true;
}
/**
* Remove an identity from the SSH agent.
*

View file

@ -21,9 +21,9 @@
#include <QHash>
#include "KeeAgentSettings.h"
#include "OpenSSHKey.h"
class KeeAgentSettings;
class Database;
class SSHAgent : public QObject
@ -91,6 +91,14 @@ private:
const quint32 AGENT_COPYDATA_ID = 0x804e50ba;
#endif
bool encodeDestinationConstraints(const QList<KeeAgentSettings::DestinationConstraint>& constraints,
BinaryStream& out);
bool encodeDestinationConstraint(const KeeAgentSettings::DestinationConstraint& constraint, BinaryStream& out);
bool encodeDestinationConstraintHost(const QString user,
const QString hostname,
const QList<KeeAgentSettings::KeySpec>& keys,
BinaryStream& out);
QHash<OpenSSHKey, QPair<QUuid, bool>> m_addedKeys;
QString m_error;
};

View file

@ -27,6 +27,52 @@
QTEST_GUILESS_MAIN(TestSSHAgent)
static const QList<KeeAgentSettings::KeySpec> githubKeys = {
{
.key = "ssh-rsa "
"AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+"
"VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/"
"BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/"
"hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+"
"5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+"
"wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+"
"bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/"
"YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=",
.isCertificateAuthority = false,
},
{
.key = "ecdsa-sha2-nistp256 "
"AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N"
"87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=",
.isCertificateAuthority = false,
},
{
.key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl",
.isCertificateAuthority = false,
}};
static const QList<KeeAgentSettings::KeySpec> gitlabKeys = {
{
.key = "ssh-rsa "
"AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++"
"J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/"
"ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/"
"StKDHMoX4/OKyIzuS0q/"
"T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+"
"siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9",
.isCertificateAuthority = false,
},
{
.key = "ecdsa-sha2-nistp256 "
"AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3H"
"w9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=",
.isCertificateAuthority = false,
},
{
.key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf",
.isCertificateAuthority = false,
}};
void TestSSHAgent::initTestCase()
{
QVERIFY(Crypto::init());
@ -177,6 +223,41 @@ void TestSSHAgent::testKeeAgentSettings()
QVERIFY(settings2.fromXml(settings.toXml()));
QVERIFY(settings2.saveAttachmentToTempFile());
QVERIFY(settings == settings2);
QVERIFY(!settings.useDestinationConstraintsWhenAdding());
settings.setUseDestinationConstraintsWhenAdding(true);
QVERIFY(settings2.fromXml(settings.toXml()));
QVERIFY(settings2.useDestinationConstraintsWhenAdding());
QVERIFY(settings == settings2);
QList<KeeAgentSettings::DestinationConstraint> destinationConstraints;
// ssh-add -h github.com <keyfile>
destinationConstraints.append({
.fromHost = "",
.fromHostKeys = {},
.toUser = "",
.toHost = "github.com",
.toHostKeys = githubKeys,
});
QVERIFY(settings.destinationConstraints().isEmpty());
settings.setDestinationConstraints(destinationConstraints);
QVERIFY(settings2.fromXml(settings.toXml()));
QVERIFY(settings2.destinationConstraints() == destinationConstraints);
QVERIFY(settings == settings2);
// ssh-add -h github.com -h "github.com>git@gitlab.com" <keyfile>
destinationConstraints.append({
.fromHost = "github.com",
.fromHostKeys = githubKeys,
.toUser = "git",
.toHost = "gitlab.com",
.toHostKeys = gitlabKeys,
});
settings.setDestinationConstraints(destinationConstraints);
QVERIFY(settings2.fromXml(settings.toXml()));
QVERIFY(settings2.destinationConstraints() == destinationConstraints);
QVERIFY(settings == settings2);
}
void TestSSHAgent::testIdentity()
@ -281,6 +362,43 @@ void TestSSHAgent::testConfirmConstraint()
QVERIFY(agent.checkIdentity(m_key, keyInAgent) && !keyInAgent);
}
void TestSSHAgent::testDestinationConstraints()
{
SSHAgent agent;
agent.setEnabled(true);
agent.setAuthSockOverride(m_agentSocketFileName);
QVERIFY(agent.isAgentRunning());
KeeAgentSettings settings;
bool keyInAgent;
// ssh-add -h github.com -h "github.com>git@gitlab.com" <keyfile>
settings.setUseDestinationConstraintsWhenAdding(true);
settings.setDestinationConstraints({{
.fromHost = "",
.fromHostKeys = {},
.toUser = "",
.toHost = "github.com",
.toHostKeys = githubKeys,
},
{
.fromHost = "github.com",
.fromHostKeys = githubKeys,
.toUser = "git",
.toHost = "gitlab.com",
.toHostKeys = gitlabKeys,
}});
QVERIFY(agent.addIdentity(m_key, settings, m_uuid));
// we can't test destination constraints itself is working but we can test the agent accepts the key
QVERIFY(agent.checkIdentity(m_key, keyInAgent) && keyInAgent);
QVERIFY(agent.removeIdentity(m_key));
QVERIFY(agent.checkIdentity(m_key, keyInAgent) && !keyInAgent);
}
void TestSSHAgent::testToOpenSSHKey()
{
KeeAgentSettings settings;

View file

@ -35,6 +35,7 @@ private slots:
void testRemoveOnClose();
void testLifetimeConstraint();
void testConfirmConstraint();
void testDestinationConstraints();
void testToOpenSSHKey();
void testKeyGenRSA();
void testKeyGenECDSA();