###############################################################################
#
# sign.eagle --
#
# Extensible Adaptable Generalized Logic Engine (Eagle)
# Enterprise Edition Signing Tool
#
# Copyright (c) 2007-2012 by Joe Mistachkin.  All rights reserved.
#
# See the file "license.terms" for information on usage and redistribution of
# this file, and for a DISCLAIMER OF ALL WARRANTIES.
#
# RCS: @(#) $Id: $
#
###############################################################################

proc usage { error } {
  if {[string length $error] > 0} then {puts stdout $error}

  puts stdout "usage:\
[file tail [getShellExecutableName]]\
[file tail [info script]] <fileName> \[vendor\] \[embed\]\
\[duration\] \[entityType\] \[encoding\] \[publicKeyFile\]\
\[privateKeyFile\] \[importFirst\] \[quantity\]"

  #
  # NOTE: Indicate to the caller, if any, that we have failed.
  #
  exit 1
}

proc maybeForceHashAlgorithm { certificate keyPair } {
  #
  # HACK: Some key pair types, e.g. DSA v1, require hard-coded
  #       hash algorithms, e.g. SHA1.  Do that now, if needed.
  #
  if {[isNonNullObjectHandle $keyPair] && \
      [lsearch -exact -- [keypair metadata] KeyPairType] != -1 && \
      [$keyPair KeyPairType] eq "DSA"} then {
    $certificate HashAlgorithm SHA1
  }
}

proc isScriptFile { fileName } {
  switch -exact -- [file extension $fileName] {
    .eeagle -
    .etcl -
    .tcl -
    .tk -
    .test -
    .eagle {
      return true
    }
    .xml {
      set uri [object invoke Eagle._Constants.Xml ScriptNamespaceUri]
      set data [readFile $fileName]

      if {[string first $uri $data] != -1} then {
        return true
      }
    }
  }

  return false
}

proc isKeyRingFile { fileName } {
  if {[file extension $fileName] ne ".eagle"} then {
    return false
  }

  set rootName [file rootname [file tail $fileName]]

  if {$rootName eq "keyRing" || \
      [string match "keyRing*" $rootName]} then {
    return true
  }

  return false
}

proc checkAndMatchKeyFile { varName {keyFile ""} } {
  global env

  if {[info exists env($varName)]} then {
    set fileName $env($varName)

    if {[string length $fileName] > 0} then {
      if {[file exists $fileName]} then {
        if {[catch {keypair token $fileName} token] == 0 && \
            [string length $token] > 0} then {
          if {[string length $keyFile] == 0 || \
              [matchKeyFileTokens $fileName $keyFile]} then {
            return true
          } else {
            puts stdout [appendArgs \
                "file \"" $fileName \
                "\" mismatches public key token from file \"" \
                $keyFile \"]
          }
        } else {
          puts stdout [appendArgs \
              "file \"" $fileName "\" is probably not a key file"]
        }
      } else {
        puts stdout [appendArgs \
            "file \"" $fileName "\" does not exist"]
      }
    } else {
      puts stdout [appendArgs \
          "environment variable \"" $varName "\" has no value"]
    }
  } else {
    puts stdout [appendArgs \
        "environment variable \"" $varName "\" was not found"]
  }

  return false
}

proc matchKeyFileTokens { keyFile1 keyFile2 } {
  if {[string length $keyFile1] == 0 || \
      [string length $keyFile2] == 0} then {
    return false
  }

  if {![file exists $keyFile1] || ![file exists $keyFile2]} then {
    return false
  }

  set token1 [keypair token $keyFile1]

  if {[string length $token1] == 0} then {
    return false
  }

  set token2 [keypair token $keyFile2]

  if {[string length $token2] == 0} then {
    return false
  }

  return [expr {$token1 eq $token2}]
}

proc readEntityValue { fileName } {
  return [readFile $fileName]
}

proc readCertificateFile { fileName } {
  return [readFile $fileName]
}

proc writeCertificateFile { fileName data } {
  return [writeFile $fileName $data]
}

proc removeEmbeddedCertificate { fileName } {
  set prefixWithSpacing [appendArgs $::embedSpacing $::embedPrefix]

  set data [readFile $fileName]
  set beginIndex [string first $prefixWithSpacing $data]

  if {$beginIndex == -1} then {
    set prefixWithSpacing $::embedPrefix
    set beginIndex [string last $prefixWithSpacing $data]
  }

  if {$beginIndex != -1} then {
    set endIndex [string first $::embedSuffix $data \
        [expr {$beginIndex + [string length $prefixWithSpacing]}]]

    if {$endIndex != -1} then {
      writeFile $fileName [string replace $data $beginIndex \
          [expr {$endIndex + [string length $::embedSuffix]}]]

      puts stdout [appendArgs \
          "removed embedded certificate from file \"" $fileName \"]
    }
  }
}

if {[llength $argv] >= 1 && [llength $argv] <= 10} then {
  #
  # NOTE: This tool requires Eagle.
  #
  package require Eagle

  #
  # NOTE: Needed for the [getTemporaryPath] script procedure.
  #
  package require Eagle.Test

  #
  # NOTE: This tool requires the enterprise license features.
  #
  package require Licensing.Enterprise

  #
  # NOTE: If the tool base path does not already exist, set it
  #       to the directory where this script is running from.
  #
  if {![info exists path]} then {
    set path [file normalize [file dirname [info script]]]
    set path_set true
  }

  #
  # NOTE: Grab the first argument to this tool (the name of the
  #       data file to sign).
  #
  set fileName [file normalize [lindex $argv 0]]

  #
  # NOTE: Grab the second argument to this tool (the name of the
  #       certificate vendor).
  #
  if {[llength $argv] >= 2} then {
    set vendor [lindex $argv 1]
  } else {
    set vendor ""
  }

  #
  # NOTE: Grab the third argument to this tool (the embed flag).
  #
  if {[llength $argv] >= 3} then {
    set embed [lindex $argv 2]
  } else {
    set embed false; # TODO: Good default?
  }

  #
  # NOTE: Grab the fourth argument to this tool (the valid duration
  #       of the certificate).
  #
  if {[llength $argv] >= 4} then {
    set duration [lindex $argv 3]
  } else {
    set duration -1; # NOTE: Forever.
  }

  #
  # NOTE: Grab the fifth argument to this tool (the entity type
  #       of the certificate).
  #
  if {[llength $argv] >= 5} then {
    #
    # NOTE: Use the specified entity type verbatim.
    #
    set entityType [lindex $argv 4]
  } elseif {[isKeyRingFile $fileName]} then {
    #
    # NOTE: This should be a signed key ring script.
    #
    set entityType KeyRing; # NOTE: Could also be "Any".
  } elseif {[isScriptFile $fileName] || \
            [file extension $fileName] eq ".harpy"} then {
    #
    # NOTE: *LEGACY* This should be a signed script.
    #
    set entityType Script; # NOTE: Could also be "Any".
  } else {
    #
    # NOTE: This is an arbitrary data file of some kind.
    #
    set entityType File; # NOTE: Could also be "Any".
  }

  #
  # NOTE: Figure out which encoding to use.
  #
  if {[llength $argv] >= 6} then {
    #
    # NOTE: Use the specified encoding verbatim.
    #
    set encoding [lindex $argv 5]
  } else {
    #
    # NOTE: Use the default encoding.
    #
    set encoding null
  }

  #
  # NOTE: Figure out the file name for the private strong name key
  #       file that will be used to sign the data file.  Now, this
  #       is checked first so the selected public key can be based
  #       on it.
  #
  if {[llength $argv] >= 8} then {
    #
    # NOTE: Use the private key file name supplied on the command
    #       line.
    #
    set privateKeyFile [string trim [lindex $argv 7]]
  } elseif {[isKeyRingFile $fileName] && \
      [checkAndMatchKeyFile EagleEnterpriseTrustRootPrivateKey]} then {
    set privateKeyFile $env(EagleEnterpriseTrustRootPrivateKey)
  } elseif {[checkAndMatchKeyFile EagleEnterpriseScriptPrivateKey]} then {
    set privateKeyFile $env(EagleEnterpriseScriptPrivateKey)
  } elseif {[checkAndMatchKeyFile EagleEnterprisePersonalPrivateKey]} then {
    set privateKeyFile $env(EagleEnterprisePersonalPrivateKey)
  } elseif {[checkAndMatchKeyFile EagleEnterprisePrivateKey]} then {
    set privateKeyFile $env(EagleEnterprisePrivateKey)
  } elseif {[info exists env(Eagle)]} then {
    set privateKeyFile [file join \
        $env(Eagle) Keys EagleEnterprisePluginRootPrivate.snk]
  } else {
    #
    # NOTE: Default to "EagleEnterprisePluginRootPrivate.snk" in the current
    #       directory (which may not actually exist).
    #
    set privateKeyFile EagleEnterprisePluginRootPrivate.snk
  }

  #
  # NOTE: Figure out the file name for the public strong name key
  #       file that will be used to verify the data file.
  #
  if {[llength $argv] >= 7} then {
    #
    # NOTE: Use the public key file name supplied on the command
    #       line.
    #
    set publicKeyFile [string trim [lindex $argv 6]]
  } elseif {[checkAndMatchKeyFile EagleEnterpriseTrustRootPublicKey \
      $privateKeyFile]} then {
    set publicKeyFile $env(EagleEnterpriseTrustRootPublicKey)
  } elseif {[checkAndMatchKeyFile EagleEnterpriseScriptPublicKey \
      $privateKeyFile]} then {
    set publicKeyFile $env(EagleEnterpriseScriptPublicKey)
  } elseif {[checkAndMatchKeyFile EagleEnterprisePersonalPublicKey \
      $privateKeyFile]} then {
    set publicKeyFile $env(EagleEnterprisePersonalPublicKey)
  } elseif {[checkAndMatchKeyFile EagleEnterprisePublicKey \
      $privateKeyFile]} then {
    set publicKeyFile $env(EagleEnterprisePublicKey)
  } elseif {[info exists env(Eagle)]} then {
    set publicKeyFile [file join \
        $env(Eagle) Keys EagleEnterprisePluginRootPublic.snk]
  } else {
    #
    # NOTE: Default to "EagleEnterprisePluginRootPublic.snk" in the current
    #       directory (which may not actually exist).
    #
    set publicKeyFile EagleEnterprisePluginRootPublic.snk
  }

  #
  # NOTE: Do we want to import the existing certificate first?
  #
  if {[llength $argv] >= 9} then {
    set importFirst [lindex $argv 8]
  } else {
    set importFirst false; # TODO: Good default?
  }

  #
  # NOTE: Do we want to limit the number of uses (quantity)?
  #
  if {[llength $argv] >= 10} then {
    set quantity [lindex $argv 9]
  } else {
    set quantity -1; # NOTE: Unlimited.
  }

  #
  # NOTE: If the configuration file exists, load it now.
  #
  set configFileName [file join $path sign.settings.eagle]
  if {[file exists $configFileName]} then {source $configFileName}

  #
  # NOTE: The spacing to use before the embedded certificate.  This
  #       must match up with the number of blank lines used with the
  #       [linsert] command used to help produce the final embedded
  #       certificate string (below).
  #
  set embedSpacing [info newline]

  #
  # NOTE: Setup the prefix and suffix strings used for embedded
  #       certificates.
  #
  set embedPrefix "# <<CERTIFICATE-1.0>>"
  set embedSuffix "# <</CERTIFICATE-1.0>>"

  #
  # NOTE: Grab the public key we need to verify that our signing
  #       process worked correctly.
  #
  set publicKey [keypair open \
      -alias -public $publicKeyFile]

  #
  # NOTE: Grab the private key we need to actually create the
  #       detached certificate for the data file.
  #
  set privateKey [keypair open \
      -alias -public -private $privateKeyFile]

  #
  # NOTE: Has embedded certificate handling been requested?
  #       If so, make sure we can actually do it.
  #
  if {$embed && [isScriptFile $fileName]} then {
    set shouldEmbed true
  } else {
    set shouldEmbed false
  }

  #
  # HOOK: After all arguments have been parsed and processed.
  #
  catch {certificate_hook phase0}

  #
  # NOTE: The existing embedded certificate, if any, must be
  #       removed prior to signing the (script?) file.
  #
  if {$shouldEmbed && \
      ![info exists no(removeEmbeddedCertificate)]} then {
    removeEmbeddedCertificate $fileName
  }

  #
  # NOTE: If the file is an XML file, assume it is a license
  #       certificate that we need to manually re-sign.
  #
  if {[file extension $fileName] eq ".harpy"} then {
    #
    # NOTE: Set the file type for error messages.
    #
    set fileType "certificate file"

    #
    # NOTE: In this case, the certificate file to export is the same
    #       as the input file name.
    #
    set certificateFile $fileName

    #
    # NOTE: Import the license certificate.
    #
    set certificate [certificate import $fileName]
    maybeForceHashAlgorithm $certificate $privateKey

    #
    # HOOK: Post-certificate object creation (import).
    #
    catch {certificate_hook phase1}

    #
    # NOTE: Attempt to re-sign the license certificate file.  Skip
    #       setting the Id as it should already be set correctly.
    #
    if {[certificate sign -encoding $encoding -settimestamp \
            -setkey $certificate $privateKey] ne "SignedOk"} then {
      error [appendArgs \
          "failed to create updated signature for " $fileType \
          " \"" $fileName \"]
    }

    #
    # NOTE: Attempt to re-verify the license certificate file.
    #
    if {[certificate verify -encoding $encoding $certificate \
            $privateKey] ne "VerifiedOk"} then {
      error [appendArgs \
          "failed to verify signature for " $fileType " \"" $fileName \"]
    }
  } else {
    #
    # NOTE: Set the file type for error messages.
    #
    if {[isKeyRingFile $fileName]} then {
      set fileType "key ring file"
    } elseif {[isScriptFile $fileName]} then {
      set fileType "script file"
    } else {
      set fileType "data file"
    }

    #
    # NOTE: Build the name of the certificate file name (i.e. the
    #       file where the detached certificate information will be
    #       placed) based on the data file name.
    #
    set certificateFile [appendArgs $fileName .harpy]

    #
    # NOTE: Create an empty certificate object.
    #
    if {$importFirst} then {
      set certificate [certificate import -alias $certificateFile]
    } else {
      set certificate [object create -alias \
          Licensing.Components.Public.Certificate]
    }

    maybeForceHashAlgorithm $certificate $privateKey

    #
    # HOOK: Post-certificate object creation (create).
    #
    catch {certificate_hook phase1}

    #
    # NOTE: If the certificate vendor is available, set it.
    #
    if {[string length $vendor] > 0} then {
      $certificate Vendor $vendor
    }

    #
    # NOTE: Always set the duration for new certificates since the
    #       policy code now checks for this.  By default, non-license
    #       certificates are valid forever.
    #
    $certificate Duration $duration

    #
    # NOTE: Always set the entity type for new certificates since
    #       the policy code now checks for this.  By default,
    #       non-license certificates are set to the "Script" entity
    #       type.
    #
    $certificate EntityType $entityType

    #
    # NOTE: Maybe set the quantity for new certificates since the
    #       policy code now checks for this.  By default, non-license
    #       certificates have unlimited uses.
    #
    if {$quantity > 0} then {
      $certificate Quantity $quantity
      $certificate Restrictions L; # LimitedQuantity
    }

    #
    # NOTE: When embedding, special handling is required when signing
    #       the certificate.
    #
    if {$shouldEmbed} then {
      #
      # NOTE: Set the entity value to the file contents that will, at
      #       some point, be seen by the script policy callback.
      #
      $certificate EntityValue [readEntityValue $fileName]

      #
      # HOOK: Post-certificate property setup (embedded).
      #
      catch {certificate_hook phase2}

      #
      # NOTE: Attempt to sign the embedded file certificate and place
      #       the Id, timestamp, public key token, and signature bytes
      #       into the certificate we created above.
      #
      if {$importFirst} then {
        if {[certificate sign -encoding $encoding \
                -setkey -hashflags {+Basic Embedded} $certificate \
                $privateKey] ne "SignedOk"} then {
          error [appendArgs \
              "failed to create updated embedded signature for " \
              $fileType " \"" $fileName \"]
        }
      } else {
        if {[certificate sign -encoding $encoding -setid -settimestamp \
                -setkey -hashflags {+Basic Embedded} $certificate \
                $privateKey] ne "SignedOk"} then {
          error [appendArgs \
              "failed to create embedded signature for " $fileType \
              " \"" $fileName \"]
        }
      }

      #
      # NOTE: Sanity check that the embedded file certificate we just
      #       created validates properly.
      #
      if {[certificate verify -encoding $encoding \
              -hashflags {+Basic Embedded} $certificate \
              $publicKey] ne "VerifiedOk"} then {
        error [appendArgs \
            "failed to verify embedded signature for " $fileType \
            " \"" $fileName \"]
      }
    } else {
      #
      # HOOK: Post-certificate property setup (non-embedded).
      #
      catch {certificate_hook phase2}

      #
      # NOTE: Attempt to sign the data file and place the Id, timestamp,
      #       public key token, and signature bytes into the blank
      #       certificate we created above.
      #
      if {$importFirst} then {
        if {[certificate signfile -encoding $encoding \
                -setkey $certificate $privateKey \
                $fileName] ne "SignedOk"} then {
          error [appendArgs \
              "failed to create updated signature for " $fileType \
              " \"" $fileName \"]
        }
      } else {
        if {[certificate signfile -encoding $encoding -setid \
                -settimestamp -setkey $certificate $privateKey \
                $fileName] ne "SignedOk"} then {
          error [appendArgs \
              "failed to create signature for " $fileType \
              " \"" $fileName \"]
        }
      }

      #
      # NOTE: Sanity check that the data file certificate we just
      #       created validates properly.
      #
      if {[certificate verifyfile -encoding $encoding \
              $certificate $publicKey $fileName] ne "VerifiedOk"} then {
        error [appendArgs \
            "failed to verify signature for " $fileType \
            " \"" $fileName \"]
      }
    }
  }

  #
  # NOTE: When embedding, do not modify the external certificate
  #       file.  Also, do not save out the entity value we set.
  #
  if {$shouldEmbed} then {
    set certificateFile [file join [getTemporaryPath] \
        [appendArgs [file tail $fileName] . [pid] .embed]]

    $certificate EntityValue null
  }

  #
  # NOTE: Delete any previous [and possibly stale] data file
  #       certificate.
  #
  catch {file delete -force $certificateFile}

  #
  # NOTE: Export the data file certificate to disk.
  #
  if {[certificate export $certificate $certificateFile] \
          ne "ExportedOk"} then {
    error [appendArgs \
        "failed to export signature for " $fileType " \"" $fileName \"]
  }

  #
  # NOTE: Add the standard XML comment to the file.
  #
  if {[certificate warning -type Script $certificateFile] \
          ne "WarningOk"} then {
    error [appendArgs \
        "failed to add warning for " $fileType " \"" $fileName \"]
  }

  #
  # HACK: Read the certificate file data into memory, fix the
  #       formatting how we want it, and then re-write the modified
  #       data back out to the same certificate file.
  #
  set data [readCertificateFile $certificateFile]

  set xmlNs xmlns=\"https://eagle.to/2011/harpy\"
  set xmlNsXsi xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\"
  set xmlNsXsd xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\"

  set data [string map [list \
      [appendArgs $xmlNsXsd " " $xmlNsXsi " " $xmlNs] \
      [appendArgs $xmlNs " " $xmlNsXsi " " $xmlNsXsd]]  $data]

  set data [string map [list \
      [appendArgs $xmlNsXsi " " $xmlNsXsd " " $xmlNs] \
      [appendArgs $xmlNs " " $xmlNsXsi " " $xmlNsXsd]]  $data]

  set spaces "             "

  set data [string map [list \
      "\" xmlns" [appendArgs \" [info newline] $spaces xmlns]] $data]

  writeCertificateFile $certificateFile $data

  #
  # NOTE: Show that we signed it.
  #
  if {$importFirst} then {
    puts stdout [appendArgs \
        "re-signed " $fileType " \"" $fileName "\" (as \"" $entityType \
        "\") using encoding \"" $encoding "\" with key " [keypair token \
        $privateKeyFile]]
  } else {
    puts stdout [appendArgs \
        "signed " $fileType " \"" $fileName "\" (as \"" $entityType \
        "\") using encoding \"" $encoding "\" with key " [keypair token \
        $privateKeyFile]]
  }

  #
  # NOTE: Do we need to embed the certificate at the end of the script
  #       file?
  #
  if {$shouldEmbed} then {
    #
    # NOTE: Make sure any carriage-returns are removed as they impact
    #       the line splitting algorithm being used.
    #
    set data [string map [list [info newline] \n] $data]

    #
    # NOTE: Start out with the embedding prefix on a line by itself.
    #
    set lines [list $embedPrefix]

    #
    # NOTE: Add a comment character and a space to the start of each
    #       line, including any blank lines.
    #
    foreach line [split $data \n] {
      lappend lines [string trim [appendArgs "# " $line]]
    }

    #
    # NOTE: Finish off with the embedding suffix.  Also, add one extra
    #       blank line just before the start of the embedding prefix.
    #
    lappend lines $embedSuffix; set lines [linsert $lines 0 ""]

    #
    # NOTE: Append the entire embedded certificate block to the script
    #       file itself, forcing the line-endings to be native for the
    #       platform.
    #
    appendFile $fileName [join $lines [info newline]]

    #
    # NOTE: Show that we embedded it.
    #
    puts stdout [appendArgs \
        "added embedded certificate to " $fileType " \"" $fileName \"]
  }

  #
  # HOOK: Script completion.
  #
  catch {certificate_hook phase3}

  #
  # NOTE: Cleanup the external embedded certificate file, if any.
  #
  if {$shouldEmbed} then {
    catch {file delete -force $certificateFile}
  }

  #
  # NOTE: Play nice and cleanup all the variables we created during the
  #       whole the signing process.
  #
  unset -nocomplain fileName vendor embed duration entityType encoding \
      privateKeyFile publicKeyFile importFirst configFileName embedSpacing \
      embedPrefix embedSuffix publicKey privateKey shouldEmbed fileType \
      certificate certificateFile data xmlNs xmlNsXsi xmlNsXsd spaces \
      lines line

  if {[info exists path_set]} then {
    unset -nocomplain path path_set
  }
} else {
  usage ""
}
