ecluse:ecluse-core
Safe HaskellNone
LanguageGHC2021

Ecluse.Core.Package.Integrity

Description

Integrity-algorithm strength and the admission integrity floors.

Écluse trusts a digest only as far as its algorithm is collision-resistant. Both contexts -- the untrusted public upstream and the trusted private upstream -- default to requiring a SHA-256-or-stronger digest, but they are floored asymmetrically: the public floor is a hard SHA-256 boundary (raisable, never lowerable), while the trusted floor is operator-loosenable below SHA-256 for a legacy private mirror, where trust in the operator's own vetted source substitutes for cryptographic strength. This module is the one place that ranks algorithms by strength and decides what clears a floor, so the worker's tamper gate and the serve layer's two admission gates share a single notion of "strong enough" rather than each re-encoding the ranking.

The strength ranking

integrityStrength orders algorithms by collision resistance: the broken ones (MD5, SHA-1) rank below the SHA-256 floor; SHA-256 and the modern long digests rank at or above it. assertedAlg resolves what a Hash claims -- its tag directly, or for a Subresource-Integrity string the algorithm named in its <alg>-<base64> prefix -- so an SRI is ranked and floored by the algorithm it embeds. The IntegrityFloor class abstracts "the minimum algorithm a floor requires", so meetsFloor and classifyArtifacts rank candidates against either floor through this one ranking.

The public-integrity floor

A MinIntegrity is the configured minimum algorithm a public (untrusted) version's digest must meet to be admitted. It is opaque and hard-floored at SHA-256: it can be raised (to SHA-512 or Blake2b, as cryptanalysis ages an algorithm) but never set below SHA-256, because admitting a public version on a SHA-1 digest would let a collision substitute its bytes. There is no escape-hatch: mkMinIntegrity / parseMinIntegrity reject a sub-SHA-256 value at construction, so no config or constructor path can lower this floor.

The trusted-integrity floor

A MinTrustedIntegrity is the configured minimum algorithm a trusted (private) version's digest must meet to be served. It also defaults to SHA-256, but is __not hard-floored__: an operator may loosen it to SHA-1 or MD5 for a legacy private mirror (see docs/architecture/security.md → "Asymmetric integrity trust"). It still rejects an unknown algorithm name. This loosening is the only way Écluse will serve a sub-SHA-256 digest, and only on the operator's own trusted source -- never on untrusted public bytes.

Synopsis

Algorithm strength

data Strength Source #

The collision-resistance tier of a hash algorithm, with constructors ordered weakest to strongest so the derived Ord is the strength ranking: two tiers compare by collision resistance, and equal-strength algorithms share a tier (so they compare EQ). This is the one named ranking the worker's tamper gate and the serve layer's admission floor both consult.

Instances

Instances details
Show Strength Source # 
Instance details

Defined in Ecluse.Core.Package.Integrity

Eq Strength Source # 
Instance details

Defined in Ecluse.Core.Package.Integrity

Ord Strength Source # 
Instance details

Defined in Ecluse.Core.Package.Integrity

integrityStrength :: HashAlg -> Strength Source #

The collision-resistance Strength tier of an algorithm; __a stronger algorithm ranks higher__ under Strength's Ord.

The broken algorithms rank below the SHA-256 floor (integrityStrength SHA256): MD5 and SHA-1 have practical collisions, so a match on one cannot prove the bytes were not substituted. SHA-256 and the longer digests rank at or above the floor: SHA-384 above it in a tier of its own, then SHA-512 and Blake2b sharing the top tier (equal strength). A bare SRI ranks lowest of all -- it is a wrapper, not an algorithm, so resolve it with assertedAlg before ranking; ranking below every real algorithm, an unresolved SRI never wins a strongest-digest comparison.

>>> integrityStrength SHA512 > integrityStrength SHA256
True
>>> integrityStrength SHA1 >= integrityStrength SHA256
False

assertedAlg :: Hash -> Maybe HashAlg Source #

The algorithm a Hash asserts: its tag directly, or -- for an SRI string -- the algorithm named in its <alg>-<base64> prefix. The SRI prefixes resolved are sha256, sha384 and sha512 (every long digest the model represents and a registry serves); an unrecognised or malformed prefix yields Nothing, so it asserts no algorithm and clears no floor (the fail-closed reading).

>>> import Ecluse.Core.Package (mkHash, HashAlg (SHA1, SRI))
>>> assertedAlg <$> mkHash SRI "sha512-z4PhNX7vuL3xVChQ1m2AB9Yg5AULVxXcg/SpIdNs6c5H0NE8XYXysP+DGNKHfuwvY7kxvUdBeoGlODJ6+SfaPg=="
Right (Just SHA512)
>>> assertedAlg <$> mkHash SHA1 "da39a3ee5e6b4b0d3255bfef95601890afd80709"
Right (Just SHA1)
>>> assertedAlg <$> mkHash SRI "sha384-OLBgp1GsljhM2TJ+sbHjaiH9txEUvgdDTAzHv2P24donTt6/529l+9Ua0vFImLlb"
Right (Just SHA384)

Algorithm names and SRI strings

renderHashAlg :: HashAlg -> Text Source #

The lower-case wire name of an algorithm -- the canonical spelling parseHashAlg reads back. Total and injective, so it doubles as config rendering and error text.

>>> renderHashAlg SHA256
"sha256"

parseHashAlg :: Text -> Either Text HashAlg Source #

Parse an algorithm name, tolerating case and an optional internal '-' (so "SHA-256" and "sha256" both parse). An unrecognised name is reported as such, distinct from a recognised-but-too-weak floor. This admits only the named hash algorithms; the sri wrapper is not a config-selectable algorithm and is rejected.

>>> parseHashAlg "SHA-256"
Right SHA256
>>> parseHashAlg "frobnicate"
Left "unknown integrity algorithm: frobnicate"

sriAlgorithm :: Text -> Maybe HashAlg Source #

The HashAlg a Subresource-Integrity string names, read from its <alg> prefix. The prefixes resolved are the Subresource-Integrity set sha256, sha384 and sha512 (every long digest the model represents and a registry serves); an unrecognised or malformed prefix yields Nothing, so the string asserts no algorithm and clears no floor (the fail-closed reading).

>>> sriAlgorithm "sha512-Zm9vYmFy"
Just SHA512
>>> sriAlgorithm "sha384-Zm9vYmFy"
Just SHA384

sriPrefix :: Text -> Text Source #

The algorithm-name token of a Subresource-Integrity string -- the <alg> before the first '-' in <alg>-<base64>. A string with no '-' is all prefix.

>>> sriPrefix "sha512-Zm9vYmFy"
"sha512"

sriBody :: Text -> Text Source #

The base64 digest body of a Subresource-Integrity string -- the <base64> after the first '-' in <alg>-<base64>. A string with no '-' has an empty body.

>>> sriBody "sha512-Zm9vYmFy"
"Zm9vYmFy"

Integrity floors

class IntegrityFloor floor where Source #

The shared interface of an integrity floor: the minimum algorithm it requires. Both the hard-floored public MinIntegrity and the loosenable trusted MinTrustedIntegrity are floors, so meetsFloor and classifyArtifacts rank candidates against either through this one class -- backed by the single integrityStrength ranking the worker's tamper gate also consults. The class only reads a floor's algorithm; a newtype's construction invariant (the public hard-floor, the trusted loosenability) lives in its smart constructors, never here.

Methods

floorAlgorithm :: floor -> HashAlg Source #

The minimum algorithm this floor requires.

meetsFloor :: IntegrityFloor floor => floor -> HashAlg -> Bool Source #

Whether an algorithm meets a floor: at least as strong as the floor's configured minimum, by the shared integrityStrength ranking. The candidate algorithm is a resolved one (from assertedAlg), never a bare SRI.

The public-integrity floor (hard-floored at SHA-256)

data MinIntegrity Source #

The configured minimum integrity algorithm a public (untrusted) version's digest must meet to be admitted. Opaque and hard-floored at SHA-256: build it only through mkMinIntegrity / parseMinIntegrity, which reject anything weaker, so a value of this type carries the proof that the floor is itself collision-resistant. There is deliberately no loosenable variant of this floor: untrusted public bytes are never admitted on a sub-SHA-256 digest.

defaultMinIntegrity :: MinIntegrity Source #

The default public-integrity floor: SHA-256, which is also the hard minimum the floor may never be set below.

mkMinIntegrity :: HashAlg -> Either Text MinIntegrity Source #

Build a MinIntegrity, rejecting any algorithm weaker than SHA-256 (the hard floor). A weak floor is a configuration error, never a silent clamp: a public version admitted on a SHA-1 digest could be substituted by a collision, defeating the gate.

parseMinIntegrity :: Text -> Either Text MinIntegrity Source #

Parse a MinIntegrity from an algorithm name (e.g. "sha256", "sha512", "blake2b"), case- and separator-insensitive. An unrecognised name and an algorithm below the SHA-256 floor are distinct errors, so a misconfiguration is reported precisely.

unMinIntegrity :: MinIntegrity -> HashAlg Source #

The floor algorithm.

renderMinIntegrity :: MinIntegrity -> Text Source #

Render a MinIntegrity as its lower-case algorithm name (round-trips parseMinIntegrity).

The trusted-integrity floor (loosenable below SHA-256)

data MinTrustedIntegrity Source #

The configured minimum integrity algorithm a trusted (private) version's digest must meet to be served. Like MinIntegrity it defaults to SHA-256, but unlike it carries no hard floor: an operator may loosen it to SHA-1 or MD5 for a legacy private mirror, where trust in their own vetted source substitutes for cryptographic strength. Build it only through mkMinTrustedIntegrity / parseMinTrustedIntegrity, which still reject an unknown algorithm name (and the bare SRI wrapper, which names no algorithm). Loosening this floor is the only path by which Écluse serves a sub-SHA-256 digest, and only on the operator's own trusted source.

defaultMinTrustedIntegrity :: MinTrustedIntegrity Source #

The default trusted-integrity floor: SHA-256, the same secure default as the public floor.

mkMinTrustedIntegrity :: HashAlg -> Either Text MinTrustedIntegrity Source #

Build a MinTrustedIntegrity. Any known algorithm is accepted -- including the broken SHA-1 and MD5, which an operator may deliberately loosen the trusted floor to -- but the bare SRI wrapper, which asserts no algorithm of its own, is rejected (it could never be a meaningful floor). There is intentionally no SHA-256 hard minimum here: that is the one behavioural difference from mkMinIntegrity.

parseMinTrustedIntegrity :: Text -> Either Text MinTrustedIntegrity Source #

Parse a MinTrustedIntegrity from an algorithm name (e.g. "sha256", "sha1", "md5"), case- and separator-insensitive. An unrecognised name is rejected; unlike parseMinIntegrity, a sub-SHA-256 name is accepted -- the trusted floor is loosenable.

unMinTrustedIntegrity :: MinTrustedIntegrity -> HashAlg Source #

The trusted floor algorithm.

renderMinTrustedIntegrity :: MinTrustedIntegrity -> Text Source #

Render a MinTrustedIntegrity as its lower-case algorithm name (round-trips parseMinTrustedIntegrity).

Version admissibility

data VersionIntegrity Source #

How a version's artifacts stand against an integrity floor -- the three-way verdict an admission gate (public or trusted) acts on.

Constructors

MeetsFloor

At least one digest asserts an algorithm at or above the floor: admissible.

BelowFloor

The version carries an integrity digest, but none meets the floor (e.g. a legacy SHA-1 shasum only under a SHA-256 floor). Inadmissible -- distinct from carrying no digest at all, so the refusal can say which.

NoIntegrity

The version carries no integrity digest of any kind: inadmissible (no floor can be met without a digest).

classifyArtifacts :: IntegrityFloor floor => floor -> NonEmpty Artifact -> VersionIntegrity Source #

Classify a version's artifacts against a floor (public or trusted). A version MeetsFloor iff any of its digests (across all of its artifacts) asserts a floor-clearing algorithm; failing that, it is NoIntegrity when no artifact carries any digest at all, else BelowFloor. npm publishes one artifact per version, but the check spans the whole NonEmpty so it holds for a multi-artifact ecosystem too.