git.guelker.eu siggraph / master siggraph
master

Tree @master (Download .tar.gz)

siggraph @masterraw · history · blame

#!/usr/bin/env ruby
# -*- coding: utf-8 -*-
#
# siggraph -- Format your web of trust as a Graphviz file.
# Copyright © 2015 Marvin Gülker
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

########################################
# Structures

Record = Struct.new(:type, :validity, :keylength, :algorithm, :key_id,
   :create_date, :expiration_date, :serial, :ownertrust,
   :userid, :signclass, :capabilities, :issuer, :reserved1,
   :reserved2, :hash_algorithm)
Entry = Struct.new(:record, :subentries){def initialize(*); super; self.subentries = []; end}

########################################
# Option handling

@options = {
  :color => true,
  :show_unknowns => false,
  :show_multisign => false,
  :gpgerr => "/dev/null"
}

nonoptions = []
while arg = ARGV.shift
  if arg.start_with?("-")
    case arg
    when "-h" then
      puts <<-HELP
#$0 [-c] [-u] [-m] KEYID > outfile

Formats the web of trust for the given GnuPG KEYID as a dot(1) file
which can then be passed to a useful renderer like Graphviz' twopi(1).

The colours indicate the "validity" level of a public key as expressed
by GnuPG, i.e. how certain you can be that the key belongs to the person
it pretends to belong to. This value is determined by GnuPG by looking
at your web of trust. The more trusty a key is, the greener it is coloured
in the graph. Revoked keys are red, expired keys are orange, keys without
enough information are uncoloured.

Options:
  -c       Do not colourise the graph.
  -g FILE  Redirect GnuPG's error stream into this FILE. Defaults to
           /dev/null.
  -h       Show this help.
  -m       Draw one edge per signature (by default, multiple signatures
           are condensed into one single edge).
  -u       Add nodes for signatures whose public keys we do not have
           (WARNING: Large graphs!).
      HELP
    when "-c" then @options[:color] = false
    when "-g" then @options[:gpgerr] = ARGV.shift || fail("-g requires a value, see -h for help.")
    when "-u" then @options[:show_unknowns] = true
    when "-m" then @options[:show_multisign] = true
    else fail "Unknown option #{arg}, see -h for help."
    end
  else
    nonoptions << arg
  end
end

# Determine key ID in the format we use everywhere else in this program
keyid = nonoptions.pop || fail("Missing the key id, see -h for help.")
@options[:keyid] = Record.new(*`gpg --batch --with-colon --list-keys`.match(/^pub:.*?$/)[0].split(":")).key_id

########################################
# Start of program

@keys = []

def parse_key(keyid)
  IO.popen([{"LC_ALL" => "C"}, "gpg", "--batch", "--with-colon", "--list-sigs", keyid, {:err => @options[:gpgerr]}]) do |io|
    current_key = nil
    current_uid = nil

    io.each_line do |line|
      record = Record.new(*line.strip.split(":"))

      if record.type == "pub"
        if record.key_id != keyid
          raise "Requested key #{keyid}, got key #{record.key_id} from GnuPG instead!"
        elsif @keys.any?{|e| e.record == record}
          return true # Do not examine key more than once if more than 1 person have signed it
        else
          current_key = Entry.new(record)
          @keys << current_key
        end
      elsif record.type == "uid"
        raise "Uid record without public key" unless current_key

        current_uid = Entry.new(record)
        current_key.subentries << current_uid
      elsif record.type == "sig"
        raise "Signature record without UID" unless current_uid

        signature = Entry.new(record)

        # Exclude multiple signatures between the same keys unless
        # explicitely requested. Note the following statement excludes
        # both multiple signatures between the same UIDs as well as multiple
        # signatures between the same public main keys (the former is included
        # in the latter).
        if current_key.subentries.any?{|uid| uid.subentries.any?{|sign| sign.record.key_id == record.key_id}} \
           && !@options[:show_multisign]
          next
        end

        if parse_key(record.key_id)
          current_uid.subentries << signature
        elsif @options[:show_unknowns]
          current_uid.subentries << signature
        else
          # Do not list signatures of unknown keys at all.
        end
      end
    end
  end

  $?.exitstatus == 0
end

def fmt_uid(key_id, uid)
  uid.match(/<.*>/).pre_match.strip + "\\n" + key_id[-7..-1]
end

def graph(keyid, handled_keyids = [])
  key = @keys.find{|e| e.record.key_id == keyid}
  handled_keyids << keyid

  return unless key # For signatures from unknown keys.

  # For now, just map all signatures directly to the public key
  # itself, rather than to the specific UIDs. Much simpler to graph.
  key.subentries.each do |uid|
    uid.subentries.each do |signature|
      puts %Q!"#{signature.record.key_id}" -> "#{key.record.key_id}"!

      # Ensure we don’t endlessly recurse when people have signed one
      # another (even via some nodes in-between).
      unless handled_keyids.include?(signature.record.key_id)
        graph(signature.record.key_id, handled_keyids)
      end
    end
  end
end

def generate_graph(keyid)
  puts "digraph G {"
  puts "overlap=false"
  #puts "mykey [color=\"#ff0000\",label=#{fmt(keyid)}];"
  @keys.each do |key|
    # Print main key-name mapping; assumes that the first UID entry of
    # a public key is the primary UID to use for representation of the
    # entire key (only keys do sign, not UIDs).
    attrs = ""
    # 1. Key name
    attrs << %Q!label="#{fmt_uid(key.record.key_id, key.subentries.first.record.userid)}"!
    if @options[:color]
      # 2. Key validity
      attrs << ",style=filled"
      case key.record.validity[0]
      when "i", "d" then attrs << ',fillcolor="#000000",fontcolor="#FFFFFF"'
      when "r"      then attrs << ',fillcolor="#ff0000"'
      when "e"      then attrs << ',fillcolor="#ff8b00"'
      when "m"      then attrs << ',fillcolor="#bdffc2"'
      when "n"      then attrs << ',fillcolor="#4adf56"'
      when "f"      then attrs << ',fillcolor="#00a30e"'
      when "u"      then attrs << ',fillcolor="#006809",fontcolor="#00ff00"'
      end
    end

    # Print it.
    puts %Q!"#{key.record.key_id}" [#{attrs}];!
  end

  graph(keyid)

  puts "}"
end

if __FILE__ == $0
  parse_key(@options[:keyid])
  generate_graph(@options[:keyid])
end