mirror of
https://github.com/pgpainless/pgpainless.git
synced 2024-12-24 11:57:59 +01:00
Implement WoT algorithm
This commit is contained in:
parent
389c99cf1b
commit
cee061d01c
2 changed files with 441 additions and 0 deletions
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue