1
0
Fork 0
mirror of https://github.com/pgpainless/pgpainless.git synced 2024-12-24 03:47:57 +01:00

Implement WoT algorithm

This commit is contained in:
Heiko Schaefer 2023-07-16 17:02:16 +02:00
parent 389c99cf1b
commit cee061d01c
No known key found for this signature in database
GPG key ID: 4A849A1904CCBD7D
2 changed files with 441 additions and 0 deletions

View file

@ -0,0 +1,292 @@
// SPDX-FileCopyrightText: 2023 Heiko Schaefer <heiko@schaefer.name>
//
// SPDX-License-Identifier: Apache-2.0
package org.pgpainless.wot.dijkstra
import org.pgpainless.wot.network.*
import org.pgpainless.wot.query.Path
import org.pgpainless.wot.query.Paths
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlin.math.max
// The trust amount that is considered "fully trusted"
private const val FULLY_TRUSTED = 120
class Query(private val rawNetwork: Network,
private val roots: Roots,
private val certificationNetwork: Boolean) {
private val logger: Logger = LoggerFactory.getLogger(Query::class.java)
/**
* Authenticate the binding "targetFpr <-> targetUserid".
*
* Performs an OpenPGP "Web of Trust" query, following the semantics and approach described in
* https://gitlab.com/sequoia-pgp/sequoia-wot/-/blob/main/spec/sequoia-wot.md
*
* Searches for enough paths to satisfy `targetTrustAmount`, if available.
*/
fun authenticate(targetFpr: Fingerprint, targetUserid: String, targetTrustAmount: Int): Paths {
logger.debug("Authenticating <{}, '{}'>\nRoots: {}", targetFpr, targetUserid, roots)
// Wrap the raw Network in a WotNetwork
val network = WotNetwork(rawNetwork, certificationNetwork)
// FIXME: add roots to the WotNetwork? -> handle their trust amounts from there (-> drop suppressIssuer?)
if (!certificationNetwork) {
// We're building a regular authentication network.
// Set trust amount cap for any root that is not FULLY_TRUSTED
roots.roots()
.filter { it.amount != FULLY_TRUSTED }
.forEach { network.capCertificate(it.fingerprint, it.amount) }
// FIXME: If A is a fully trusted root, and B is a root at trust amount 40, should B's amount in
// the path A -> B -> C be capped?
//
// Theory: The cap should only be in effect for paths in which B serves as a root.
// -> Make a test case for this, and fix it. (How?)
// (Maybe the order in which paths are usually found will shadow this problem most of the time?)
}
val paths = Paths()
// Perform a (partial, until the targetTrustAmount is reached) run of the Ford-Fulkerson algorithm
// (https://en.wikipedia.org/wiki/Ford%E2%80%93Fulkerson_algorithm):
//
// Find a path, subtract that path from the network, then loop and search for more paths, if any.
while (paths.amount < targetTrustAmount) {
val authPaths = backwardPropagate(network, targetFpr, targetUserid)
// Pick one of the paths returned by backwardPropagate(), first by trust amount, then by length.
// We subtract that path from the network and search again, if we haven't yet reached 'targetTrustAmount'.
val bestPath = roots.fingerprints()
.mapNotNull { authPaths[it] } // Only consider paths that start at a root.
.maxWithOrNull(compareBy(
{ it.second }, // We want paths with the *largest* trust amount,
{ -it.first.length }, // and of these, the *shortest* path.
{ it.first.root.fingerprint } // Break ties based on the fingerprint of the root.
))
if (bestPath != null) {
val (path, amount) = bestPath
assert(path.length > 1) // We don't support paths without an edge!
network.suppressPath(path, amount) // Subtract the path from the residual network
paths.add(path, amount) // Add the path to the set of results
} else {
// We made no progress in this iteration, there are no more paths to be found. We're done.
break
}
}
return paths
}
// FIXME: This should not be public, but is currently needed for the `BackPropagationTest` suite.
fun backwardPropagate(targetFpr: Fingerprint, targetUserid: String): HashMap<Fingerprint, Pair<Path, Int>> {
val network = WotNetwork(rawNetwork, false) // these tests want authentication networks
return backwardPropagate(network, targetFpr, targetUserid)
}
/**
* Finds a path in the network from one or multiple `roots` that
* authenticates the target binding.
*
* If `roots` is empty, authenticated paths starting from any node
* are returned.
*/
private fun backwardPropagate(network: WotNetwork, targetFpr: Fingerprint, targetUserid: String):
HashMap<Fingerprint, Pair<Path, Int>> {
logger.debug("Query.backwardPropagate <{}, '{}'>\nRoots: {}", targetFpr, targetUserid, roots)
// If the Network tells us we can't use this node as a target, return early
val target = network.isValidTarget(targetFpr, targetUserid) ?: return HashMap()
// Perform Dijkstra's shortest path algorithm using a priority queue.
// https://en.wikipedia.org/wiki/Dijkstra's_algorithm#Using_a_priority_queue
// We are processing the OpenPGP certification graph in the backwards direction
// (working from the target binding to the trust roots).
// Note: The first step in the (reverse) paths we consider is always a certification of a User ID binding.
// All other steps must be delegations with the minimum depth appropriate to the path's length.
val prev: HashMap<Fingerprint, ForwardPointer> = HashMap()
val dist: HashMap<Fingerprint, Cost> = HashMap()
val queue: PairPriorityQueue<Fingerprint, Cost> = PairPriorityQueue()
// Does a self-sig for the target exist?
val selfSig = network.getSelfSig(targetFpr, targetUserid)
val selfSigAmount = if (selfSig != null) network.getEffectiveTrustAmount(selfSig) else 0
if (selfSig != null && selfSigAmount > 0) {
// The first step of the (backwards-facing) path:
// Arriving indirectly to the targetNode's User ID, via a delegation plus a self-signed binding
val cost = Cost(1, selfSigAmount)
prev[targetFpr] = ForwardPointer(selfSig)
dist[targetFpr] = cost
queue.insertOrUpdate(targetFpr, cost)
} else {
// The first step of the (backwards-facing) path:
// We will arrive directly at the targetNode's relevant User ID, via a third party certification
val cost = Cost(0, FULLY_TRUSTED)
prev[targetFpr] = ForwardPointer(null)
dist[targetFpr] = cost
queue.insertOrUpdate(targetFpr, cost)
}
// Process the priority queue until it is empty.
while (true) {
// To be safe, we're not using the cost from the priority queue (which is available in `pop().second`).
// It could have been updated in the meantime (?)
val signeeFpr = queue.pop()?.first ?: break
logger.debug("Processing signee {}", signeeFpr)
val root = roots.get(signeeFpr)
if ((root != null) && (root.amount >= FULLY_TRUSTED)) {
logger.debug(" Skipping signee that is a fully trusted root")
continue
}
val signee = network.nodeByFpr(signeeFpr)!! // We expect that the signee exists in the Network
// Get the signee's current forward pointer (the edge that currently points at the signee)
val signeeFp: ForwardPointer = prev[signeeFpr]!!
// ... and the current cost/"distance" from the signee to the target
val signeeCost = dist[signeeFpr]!!
logger.debug(" Current forward pointer: {}", signeeFp.next?.target)
logger.debug(" Current cost to target: {}", signeeCost)
// Get certifications that point at signeeFpr
// (with the necessary minimum depth, and matching the targetUserid to the certification regex, if any)
// We are considering two different possibilities: extending the existing path, or effectively
// *replacing* the existing path. The latter happens if our path currently consists of a self-sig,
// but we're "replacing" that path with a third party sig.
// XX: ask for "depth=n-1" to be able to replace a terminating self-sig with a depth 0 third party sig.
// This requires additional checking below. Can this be simplified?
val curMinLen = max(0, signeeCost.length - 1)
val ecs = network.certificationsForSignee(signeeFpr, targetUserid, curMinLen)
logger.debug(" Checking {} certifications for {}:", ecs.size, signee.toString())
for (ec in ecs) {
logger.debug(" Certification by {}", ec.issuer.fingerprint)
val amount = network.getEffectiveTrustAmount(ec)
if (amount == 0) {
logger.debug(" Skipping (effective trust amount is 0)")
continue
}
if (signeeFpr == targetFpr && ec.userId != targetUserid
// Matching User ID only matters for the last hop
&& signeeCost.length == 0) {
logger.debug(" Certification is for the wrong user id ({})", ec.userId)
continue
}
val altCost = if (ec.userId == targetUserid) {
// This path replaces a direct signature
Cost(1, amount)
} else {
// XX: temp hack, see above
if (ec.trustDepth < signeeCost.length) {
logger.debug(" Certification does not allow enough depth ({}, needed: {}), skipping",
ec.trustDepth, signeeCost.length)
continue
}
signeeCost.extendBy(amount)
}
logger.debug(" Cost to target via {}: {}", ec.target.fingerprint, altCost)
val issuerFpr = ec.issuer.fingerprint
val currentCost: Cost? = dist[issuerFpr]
// If we haven't visited this node before, or the new cost is preferable, store or update pointer+cost
if (currentCost == null || altCost < currentCost) {
logger.debug(" Setting forward pointer for {}: {}", ec.issuer, ec.target)
if (currentCost != null)
logger.debug(" (Replaces previous path with cost {})", currentCost)
prev[issuerFpr] = ForwardPointer(ec)
dist[issuerFpr] = altCost
}
if (currentCost == null) {
// We haven't seen this node before -> queue it for processing
logger.debug(" Queuing node {}", ec.issuer)
queue.insertOrUpdate(issuerFpr, altCost)
}
}
}
return reconstructPaths(network, targetUserid, prev, target, dist)
}
private fun reconstructPaths(network: WotNetwork, targetUserid: String, bestNextNode: HashMap<Fingerprint, ForwardPointer>, target: Node, dist: HashMap<Fingerprint, Cost>): HashMap<Fingerprint, Pair<Path, Int>> {
// Follow the forward pointers and reconstruct the paths.
val paths: HashMap<Fingerprint, Pair<Path, Int>> = HashMap()
bestNextNode.entries
// If roots were specified, only reconstruct paths for roots
.filter { roots.roots().isEmpty() || roots.isRoot(it.key) }
// Don't consider nodes that specify no "next" edge
.filter { it.value.next != null }
.forEach { (issuerFpr, fp) ->
// Next is guaranteed to be non-null by the filter above
val issuer = fp.next!!.issuer
logger.trace("Recovering path starting at {}", network.nodeByFpr(issuerFpr))
val path = assemblePath(target, targetUserid, issuer, bestNextNode)
val amount = dist[issuer.fingerprint]!!.amount
logger.debug("Authenticated <{}, {}>:\n{}", target.fingerprint, targetUserid, path)
paths[issuerFpr] = Pair(path, amount)
}
return paths
}
private fun assemblePath(target: Node, targetUserid: String,
issuer: Node, bestNextNode: HashMap<Fingerprint, ForwardPointer>): Path {
var fp = bestNextNode[issuer.fingerprint]!!
// Path starts at the root (issuer); the last edge points to the target.
val p = Path(issuer)
while (true) {
val ec = fp.next ?: break
p.append(ec)
if (ec.userId == targetUserid) {
// We've arrived (the target node may have an extra self-sig forward pointer to itself, but we don't
// want to collect that edge).
break
}
fp = bestNextNode[ec.target.fingerprint]!!
}
assert(p.certifications.isNotEmpty())
logger.trace("\nAssembled path from {} to <{} <-> {}>:\n {}",
issuer.fingerprint, targetUserid, target.fingerprint,
p.certifications.withIndex().joinToString("\n ") { (i, c) -> "$i: $c" })
return p
}
}

View file

@ -0,0 +1,149 @@
// SPDX-FileCopyrightText: 2023 Heiko Schaefer <heiko@schaefer.name>
//
// SPDX-License-Identifier: Apache-2.0
package org.pgpainless.wot.dijkstra
import org.pgpainless.wot.network.*
import org.pgpainless.wot.query.Path
/**
* A wrapper for `Network` that performs the following functions:
*
* - Expose an (experimental) API that simplifies the implementation of the WoT algorithm (in `Query`)
* - TODO: polish, and possibly upstream into `Network`
*
* - Form residual networks for the Ford-Fulkerson algorithm via `suppressPath()`
* - `getEffectiveTrustAmount()` returns effective trust amounts of certifications
* (taking the residual network into account)
*/
internal class WotNetwork(private val network: Network, private val certificationNetwork: Boolean) {
fun nodeByFpr(fpr: Fingerprint): Node? = network.nodes[fpr]
fun getSelfSig(targetFpr: Fingerprint, targetUserid: String): EdgeComponent? {
val target = nodeByFpr(targetFpr)
if (target?.userIds?.get(targetUserid) != null) {
// Return a synthesized self-binding.
// XX: This EC should be generated during network generation.
return EdgeComponent(target, target, targetUserid, network.referenceTime.timestamp,
null, true, 120, Depth.limited(0), RegexSet.wildcard())
} else
return null
}
/**
* Return list of EdgeComponents that point to `fpr`.
*
* Doesn't currently return self-bindings.
*
* `targetUserid` is currently only evaluated to check matching with regex scoped delegations.
* Doesn't currently filter out EdgeComponents that point to `fpr`, but a different User ID
* (FIXME: filter by target User ID, if fpr == target, and target user id != null, then simplify Query?)
*
* In authentication network mode:
* - only return EdgeComponents whose depth allows authentication of a path that is `curLen` long.
* - only return EdgeComponents whose regexes match `targetUserid`.
*/
fun certificationsForSignee(fpr: Fingerprint, targetUserid: String, curLen: Int): List<EdgeComponent> {
val edges = network.reverseEdges[fpr] ?: return listOf()
val ec = edges.map { edge ->
edge.components.map { it.value }.flatten()
}
.flatten()
.filter {
if (!certificationNetwork) {
// Authentication network mode: honor depth limitation and regexes
it.trustDepth >= curLen && it.regexes.matches(targetUserid)
} else {
// Certification network mode: Keep certifications of any depth, and ignore regex scoping
true
}
}
return ec
}
fun isValidTarget(targetFpr: Fingerprint, targetUserid: String): Node? {
// Node must be in the network.
val target = nodeByFpr(targetFpr) ?: return null
// Target may not be expired at the reference time.
if ((target.expirationTime != null) &&
(target.expirationTime <= network.referenceTime.timestamp)) {
return null
}
// Target may not be revoked at the reference time.
if (target.revocationState.isEffective(network.referenceTime)) {
return null
}
// The target doesn't need to have self-signed the User ID to authenticate the User ID.
// But if the target has revoked it, then it can't be authenticated.
val targetUa: RevocationState? = target.userIds[targetUserid]
if (targetUa != null && targetUa.isEffective(network.referenceTime)) {
return null
}
return target
}
// Modifiers are processed in order by getEffectiveTrustAmount() [first cap, then suppress]
private val capCertificate: HashMap<Fingerprint, Int> = HashMap()
private val suppressPath: HashMap<Pair<Fingerprint, Fingerprint>, Int> = HashMap()
/**
* Get effective trust amount for a certification:
* Cap if the root has limited trust, and take into account additional constraints of the residual network.
*/
fun getEffectiveTrustAmount(ec: EdgeComponent): Int {
// Start from trust amount on the certification
var amount = ec.trustAmount
// Cap to issuer's `capCertificate`, if set.
// (Used in case of less than fully trusted roots)
capCertificate[ec.issuer.fingerprint]?.let {
if (it < amount) {
amount = it
}
}
// Suppress by certificate (for Ford-Fulkerson residual network)
suppressPath[Pair(ec.issuer.fingerprint, ec.target.fingerprint)]?.let {
if (amount > it) {
amount -= it
} else {
amount = 0
}
}
return amount
}
/** Limit a certificate's initial trust amount to `amount` */
fun capCertificate(fingerprint: Fingerprint, amount: Int) {
capCertificate[fingerprint] = amount
}
/**
* Add suppression rules for all certifications along the specified path:
* Each edge is suppressed by `amountToSuppress`.
*/
fun suppressPath(path: Path, amountToSuppress: Int) {
if (amountToSuppress == 0) return
assert(amountToSuppress <= 120)
for (c in path.certifications) {
val curAmount = suppressPath[Pair(c.issuer.fingerprint, c.target.fingerprint)] ?: 0
val newAmount = curAmount + amountToSuppress
assert(newAmount <= 120)
suppressPath[Pair(c.issuer.fingerprint, c.target.fingerprint)] = newAmount
}
}
}