aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorKonstantin Ryabitsev <konstantin@linuxfoundation.org>2021-11-15 11:23:00 -0500
committerKonstantin Ryabitsev <konstantin@linuxfoundation.org>2021-11-15 11:23:00 -0500
commit90b28bed4caf4484dd24866f6a001abb38db3524 (patch)
treef70bdf23df2cbaeb9945f64bc93802e9b4692375
parentac3ee85ec79360524f4f78f19b52c33dea7ffff6 (diff)
downloadpatatt-90b28bed4caf4484dd24866f6a001abb38db3524.tar.gz
Add support for openssh signatures
Git is about to gain ability to support openssh signatures, so introduce this as a supported signature format for patatt. To enable: [patatt] signingKey = openssh:~/.ssh/your-key-id.pub Since openssh supports a number of crypto/hashing algorithms, this is not algorithm-specific just as openpgp sigs are. Signed-off-by: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
-rw-r--r--.keys/openssh/linuxfoundation.org/konstantin/202111151
-rw-r--r--README.rst54
-rw-r--r--patatt/__init__.py115
-rw-r--r--samples/openssh-signed.txt32
-rw-r--r--samples/unsigned.txt23
5 files changed, 201 insertions, 24 deletions
diff --git a/.keys/openssh/linuxfoundation.org/konstantin/20211115 b/.keys/openssh/linuxfoundation.org/konstantin/20211115
new file mode 100644
index 0000000..13fd9bb
--- /dev/null
+++ b/.keys/openssh/linuxfoundation.org/konstantin/20211115
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKggcAE07YvL/ULFiQeMdclQO9qM1apV8I3GfyJkYz2b user@meerkat.local
diff --git a/README.rst b/README.rst
index 7135b9f..70ca70b 100644
--- a/README.rst
+++ b/README.rst
@@ -67,10 +67,11 @@ Supported Signature Algorithms
DKIM standard mostly relies on RSA signatures, though RFC 8463 extends
it to support ED25519 keys as well. While it is possible to use any of
the DKIM-defined algorithms, patatt only supports the following
-two signing/hashing schemes:
+signing/hashing schemes:
- ed25519-sha256: exactly as defined in RFC8463
- openpgp-sha256: uses OpenPGP to create the signature
+- openssh-sha256: uses OpenSSH signing capabilities
Note: Since GnuPG supports multiple signing key algorithms,
openpgp-sha256 signatures can be done using EDDSA keys as well. However,
@@ -78,9 +79,12 @@ since OpenPGP output includes additional headers, the "ed25519-sha256"
and "openpgp-sha256" schemes are not interchangeable even when ed25519
keys are used in both cases.
+Note: OpenSSH signature support was added in OpenSSH 8.0 and requires
+ssh-keygen that supports the -Y flag.
+
In the future, patatt may add support for more algorithms, especially if
-that allows incorporating TPM and U2F devices (e.g. for offloading
-credential storage and crypto operations into a sandboxed environment).
+that allows incorporating more hardware crypto offload devices (such as
+TPM).
X-Developer-Key header
~~~~~~~~~~~~~~~~~~~~~~
@@ -130,6 +134,27 @@ example::
[patatt]
signingkey = openpgp:E63EDCA9329DD07E
+Using OpenSSH
+~~~~~~~~~~~~~
+If you have OpenSSH version 8.0+, then you can use your ssh keys for
+generating and verifying signatures. There are several upsides to using
+openssh as opposed to generic ed25519:
+
+- you can passphrase-protect your ssh keys
+- passphrase-protected keys will benefit from ssh-agent caching
+- you can use hardware tokens and ed25519-sk keys for higher protection
+- you are much more likely to remember to back up your ssh keys
+
+To start using openssh signatures with patatt, add the following to your
+~/.gitconfig::
+
+ [patatt]
+ signingkey = openssh:~/.ssh/my_key_id.pub
+ selector = my_key_id
+
+Note, that the person verifying openssh signatures must also run the
+version of openssh that supports this functionality.
+
Using ed25519
~~~~~~~~~~~~~
If you don't already have a PGP key, you can opt to generate and use a
@@ -192,10 +217,11 @@ Or you can do it manually::
$ echo 'patatt sign --hook "${1}"' > "$(git rev-parse --git-dir)/hooks/sendemail-validate"
$ chmod a+x "$(git rev-parse --git-dir)/hooks/sendemail-validate"
-PGP vs ed25519 keys considerations
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-If you don't already have a PGP key, you may wonder whether it makes
-sense to create a new PGP key or start using standalone ed25519 keys.
+PGP vs OpenSSH vs ed25519 keys considerations
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+If you don't already have a PGP key that is used in your project, you
+may wonder whether it makes sense to create a new PGP key, reuse your
+OpenSSH key, or start using standalone ed25519 keys.
Reasons to choose PGP:
@@ -208,6 +234,15 @@ Reasons to choose PGP:
If you choose to create a new PGP key, you can use the following guide:
https://github.com/lfit/itpol/blob/master/protecting-code-integrity.md
+Reasons to choose OpenSSH keys:
+
+- you can protect openssh keys with a passphrase and rely on ssh-agent
+ passphrase caching
+- you can use ssh keys with u2f hardware tokens for additional
+ protection of your private key data
+- very recent versions of git can also use ssh keys to sign tags and
+ commits
+
Reasons to choose a standalone ed25519 key:
- much smaller signatures, especially compared to PGP RSA keys
@@ -305,6 +340,11 @@ following command::
gpg -a --export --export-options export-minimal keyid
+For openssh keys, the key contents are a single line in the usual
+openssh pubkey format, e.g.::
+
+ ssh-ed25519 AAAAC3N... comment@or-hostname
+
Whose keys to add to the keyring
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
It does not really make sense to require cryptographic attestation for
diff --git a/patatt/__init__.py b/patatt/__init__.py
index de69d91..c3a6375 100644
--- a/patatt/__init__.py
+++ b/patatt/__init__.py
@@ -29,6 +29,7 @@ logger = logging.getLogger(__name__)
# Overridable via [patatt] parameters
GPGBIN = 'gpg'
+SSHKBIN = 'ssh-keygen'
# Hardcoded defaults
DEVSIG_HDR = b'X-Developer-Signature'
@@ -205,6 +206,13 @@ class DevsigHeader:
pts = self.hval.rsplit(b'b=', 1)
dshdr = pts[0] + b'b='
bdata = re.sub(rb'\s*', b'', pts[1])
+ # Calculate our own digest
+ hashed = hashlib.sha256()
+ # Add in our _headervals first (they aready have CRLF endings)
+ hashed.update(b''.join(self._headervals))
+ # and the devsig header now, without the trailing CRLF
+ hashed.update(DEVSIG_HDR.lower() + b':' + dshdr)
+ vdigest = hashed.digest()
algo = self.get_field('a', decode=True)
if algo.startswith('ed25519'):
sdigest = DevsigHeader._validate_ed25519(bdata, keyinfo)
@@ -212,24 +220,24 @@ class DevsigHeader:
signkey = keyinfo
if not signtime:
raise ValidationError('t= field is required for ed25519 sigs')
+ if sdigest != vdigest:
+ raise ValidationError('Header validation failed')
+ elif algo.startswith('openssh'):
+ DevsigHeader._validate_openssh(bdata, vdigest, keyinfo)
+ signtime = self.get_field('t', decode=True)
+ signkey = keyinfo
+ if not signtime:
+ raise ValidationError('t= field is required for openssh sigs')
elif algo.startswith('openpgp'):
sdigest, (good, valid, trusted, signkey, signtime) = DevsigHeader._validate_openpgp(bdata, keyinfo)
+ if sdigest != vdigest:
+ raise ValidationError('Header validation failed')
else:
raise ValidationError('Unknown algorithm: %s', algo)
- # Now we calculate our own digest
- hashed = hashlib.sha256()
- # Add in our _headervals first (they aready have CRLF endings)
- hashed.update(b''.join(self._headervals))
- # and the devsig header now, without the trailing CRLF
- hashed.update(DEVSIG_HDR.lower() + b':' + dshdr)
- vdigest = hashed.digest()
- if sdigest != vdigest:
- raise ValidationError('Header validation failed')
-
return signkey, signtime
- def sign(self, keyinfo: bytes, split: bool = True) -> Tuple[bytes, bytes]:
+ def sign(self, keyinfo: Union[str, bytes], split: bool = True) -> Tuple[bytes, bytes]:
self.sanity_check()
self.set_field('bh', self._body_hash)
algo = self.get_field('a', decode=True)
@@ -252,6 +260,8 @@ class DevsigHeader:
bval, pkinfo = DevsigHeader._sign_ed25519(digest, keyinfo)
elif algo.startswith('openpgp'):
bval, pkinfo = DevsigHeader._sign_openpgp(digest, keyinfo)
+ elif algo.startswith('openssh'):
+ bval, pkinfo = DevsigHeader._sign_openssh(digest, keyinfo)
else:
raise RuntimeError('Unknown a=%s' % algo)
@@ -296,7 +306,57 @@ class DevsigHeader:
raise ValidationError('Failed to validate signature')
@staticmethod
- def _sign_openpgp(payload: bytes, keyid: bytes) -> Tuple[bytes, bytes]:
+ def _sign_openssh(payload: bytes, keyfile: str) -> Tuple[bytes, bytes]:
+ global KEYCACHE
+ keypath = os.path.expanduser(os.path.expandvars(keyfile))
+ if not os.access(keypath, os.R_OK):
+ raise SigningError('Unable to read openssh public key %s' % keypath)
+ sshkargs = ['-Y', 'sign', '-n', 'patatt', '-f', keypath]
+ ecode, out, err = sshk_run_command(sshkargs, payload)
+ if ecode > 0:
+ raise SigningError('Running ssh-keygen failed', errors=err.decode().split('\n'))
+ # Remove the header/footer
+ sigdata = b''
+ for bline in out.split(b'\n'):
+ if bline.startswith(b'----'):
+ continue
+ sigdata += bline
+ if keypath not in KEYCACHE:
+ # Now get the fingerprint of this keyid
+ sshkargs = ['-l', '-f', keypath]
+ ecode, out, err = sshk_run_command(sshkargs, payload)
+ if ecode > 0:
+ raise SigningError('Running ssh-keygen failed', errors=err.decode().split('\n'))
+ chunks = out.split()
+ keyfp = chunks[1]
+ KEYCACHE[keypath] = keyfp
+ else:
+ keyfp = KEYCACHE[keypath]
+
+ return sigdata, keyfp
+
+ @staticmethod
+ def _validate_openssh(sigdata: bytes, payload: bytes, keydata: bytes) -> None:
+ with tempfile.TemporaryDirectory(suffix='.patch-attest-poc') as td:
+ # Start by making a signers file
+ fpath = os.path.join(td, 'signers')
+ spath = os.path.join(td, 'sigdata')
+ with open(fpath, 'wb') as fh:
+ chunks = keydata.split()
+ bcont = b'patatter@local namespaces="patatt" ' + chunks[0] + b' ' + chunks[1] + b'\n'
+ logger.debug('allowed-signers: %s', bcont)
+ fh.write(bcont)
+ with open(spath, 'wb') as fh:
+ bcont = b'-----BEGIN SSH SIGNATURE-----\n' + sigdata + b'\n-----END SSH SIGNATURE-----\n'
+ logger.debug('sigdata: %s', bcont)
+ fh.write(bcont)
+ sshkargs = ['-Y', 'verify', '-n', 'patatt', '-I', 'patatter@local', '-f', fpath, '-s', spath]
+ ecode, out, err = sshk_run_command(sshkargs, payload)
+ if ecode > 0:
+ raise ValidationError('Failed to validate openssh signature', errors=err.decode().split('\n'))
+
+ @staticmethod
+ def _sign_openpgp(payload: bytes, keyid: str) -> Tuple[bytes, bytes]:
global KEYCACHE
gpgargs = ['-s', '-u', keyid]
ecode, out, err = gpg_run_command(gpgargs, payload)
@@ -495,12 +555,12 @@ class PatattMessage:
if selector:
ds.set_field('s', selector)
- if algo not in ('ed25519', 'openpgp'):
+ if algo not in ('ed25519', 'openpgp', 'openssh'):
raise SigningError('Unsupported algorithm: %s' % algo)
ds.set_field('a', '%s-sha256' % algo)
- if algo == 'ed25519':
- # Set signing time for ed25519 sigs
+ if algo in ('ed25519', 'openssh'):
+ # Set signing time for non-pgp sigs
ds.set_field('t', str(int(time.time())))
hv, pkinfo = ds.sign(keyinfo)
@@ -514,6 +574,8 @@ class PatattMessage:
]
if algo == 'openpgp':
idata.append(b'fpr=%s' % pkinfo)
+ elif algo == 'openssh':
+ idata.append(b'fpr=%s' % pkinfo)
else:
idata.append(b'pk=%s' % pkinfo)
@@ -708,6 +770,11 @@ def gpg_run_command(cmdargs: list, stdin: bytes = None) -> Tuple[int, bytes, byt
return _run_command(cmdargs, stdin)
+def sshk_run_command(cmdargs: list, stdin: bytes = None) -> Tuple[int, bytes, bytes]:
+ cmdargs = [SSHKBIN] + cmdargs
+ return _run_command(cmdargs, stdin)
+
+
def get_git_toplevel(gitdir: str = None) -> str:
cmdargs = ['git']
if gitdir:
@@ -836,7 +903,16 @@ def sign_message(msgdata: bytes, algo: str, keyinfo: Union[str, bytes],
return pm.as_bytes()
+def set_paths(config: dict) -> None:
+ global GPGBIN, SSHKBIN
+ if config.get('gpg-bin'):
+ GPGBIN = config.get('gpg-bin')
+ if config.get('ssh-keygen-bin'):
+ SSHKBIN = config.get('ssh-keygen-bin')
+
+
def cmd_sign(cmdargs, config: dict) -> None:
+ set_paths(config)
# Do we have the signingkey defined?
usercfg = get_config_from_git(r'user\..*')
if not config.get('identity') and usercfg.get('email'):
@@ -890,6 +966,9 @@ def cmd_sign(cmdargs, config: dict) -> None:
elif sk.startswith('openpgp:'):
algo = 'openpgp'
keydata = sk[8:]
+ elif sk.startswith('openssh:'):
+ algo = 'openssh'
+ keydata = sk[8:]
else:
logger.critical('E: Unknown key type: %s', sk)
sys.exit(1)
@@ -938,6 +1017,8 @@ def validate_message(msgdata: bytes, sources: list, trim_body: bool = False) ->
algo = 'ed25519'
elif a.startswith('openpgp'):
algo = 'openpgp'
+ elif a.startswith('openssh'):
+ algo = 'openssh'
else:
errors.append('%s/%s Unknown algorigthm: %s' % (i, s, a))
attestations.append((RES_ERROR, i, t, None, a, errors))
@@ -951,8 +1032,8 @@ def validate_message(msgdata: bytes, sources: list, trim_body: bool = False) ->
except KeyError:
pass
- if not pkey and algo == 'ed25519':
- errors.append('%s/%s no matching ed25519 key found' % (i, s))
+ if not pkey and algo in ('ed25519', 'openssh'):
+ errors.append('%s/%s no matching %s key found' % (i, s, algo))
attestations.append((RES_NOKEY, i, t, None, algo, errors))
continue
diff --git a/samples/openssh-signed.txt b/samples/openssh-signed.txt
new file mode 100644
index 0000000..45d9197
--- /dev/null
+++ b/samples/openssh-signed.txt
@@ -0,0 +1,32 @@
+From 82d3e4a03a72b787849fd406e985f3027fa04907 Mon Sep 17 00:00:00 2001
+From: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
+Date: Wed, 5 May 2021 17:11:46 -0400
+Subject: [PATCH] Specify subset of the world
+X-Developer-Signature: v=1; a=openssh-sha256; t=1636987789; l=403;
+ i=konstantin@linuxfoundation.org; s=20211115; h=from:subject;
+ bh=aWNA6NFmS5xpRH5Gpy45nWiKCOnDOKHOYOV7Y6lyLcU=;
+ b=U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgqCBwATTti8v9QsWJB4x1yVA72ozVqlXw
+ jcZ/ImRjPZsAAAAGcGF0YXR0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5AAAAQGyoMN
+ fuL86rhp2CLqjzAoVC9l1sFREfyvnkT/6QpnYht/gQCkAp+KyvWLOaywWPekG5OGMbmwnMu4WOSKmI
+ 0Qo=
+X-Developer-Key: i=konstantin@linuxfoundation.org; a=openssh;
+ fpr=SHA256:movubj27MLZcp0EAsOhlbu3/RJkj1VF9FfHGUsiB4Gw
+
+We don't want to say hello to the *whole* world, do we? Just the
+attested world, please.
+
+Signed-off-by: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
+---
+ hello.txt | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/hello.txt b/hello.txt
+index 18249f3..977f79b 100644
+--- a/hello.txt
++++ b/hello.txt
+@@ -1 +1 @@
+-Hello world.
++Hello attested world.
+--
+2.30.2
+
diff --git a/samples/unsigned.txt b/samples/unsigned.txt
new file mode 100644
index 0000000..1f2961d
--- /dev/null
+++ b/samples/unsigned.txt
@@ -0,0 +1,23 @@
+From 82d3e4a03a72b787849fd406e985f3027fa04907 Mon Sep 17 00:00:00 2001
+From: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
+Date: Wed, 5 May 2021 17:11:46 -0400
+Subject: [PATCH] Specify subset of the world
+
+We don't want to say hello to the *whole* world, do we? Just the
+attested world, please.
+
+Signed-off-by: Konstantin Ryabitsev <konstantin@linuxfoundation.org>
+---
+ hello.txt | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/hello.txt b/hello.txt
+index 18249f3..977f79b 100644
+--- a/hello.txt
++++ b/hello.txt
+@@ -1 +1 @@
+-Hello world.
++Hello attested world.
+--
+2.30.2
+