mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:41:30 +00:00
21aeed4f4e
Phase 0 closure (Path B2, post-rewrite):
addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).
Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.
Generated via:
addlicense -c "certctl LLC" -y 2026 \
-f cowork/legal/copyright-header.tpl \
-ignore '**/testdata/**' -ignore '**/*_test.go' \
cmd/ internal/
Verification:
find cmd internal -name '*.go' -not -name '*_test.go' \
-not -path '*/testdata/*' \
-exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l
Returns: 0
gofmt clean. Header additions are comments only, no compile impact.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
430 lines
18 KiB
Go
430 lines
18 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
package intune
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/rsa"
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Typed challenge-validation errors. The handler audits the specific
|
|
// failure dimension via errors.Is so operators can distinguish e.g. an
|
|
// expired challenge (clock skew, latent enrollment) from a tampered one
|
|
// (active attack) without string-matching error messages.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 7.4.
|
|
var (
|
|
ErrChallengeMalformed = errors.New("intune: challenge is not in the JWT-like compact-serialization format")
|
|
ErrChallengeSignature = errors.New("intune: challenge signature does not verify against any configured trust anchor")
|
|
ErrChallengeExpired = errors.New("intune: challenge expired")
|
|
ErrChallengeNotYetValid = errors.New("intune: challenge not yet valid (iat in future, possible clock skew)")
|
|
ErrChallengeWrongAudience = errors.New("intune: challenge audience does not match this SCEP endpoint URL")
|
|
ErrChallengeReplay = errors.New("intune: challenge nonce already seen (replay attempt)")
|
|
ErrChallengeUnknownVersion = errors.New("intune: challenge has an unknown version claim — parser does not support this format")
|
|
)
|
|
|
|
// ParseChallenge decodes the JWT-like compact serialization of an Intune
|
|
// dynamic challenge into header, payload, and signature byte slices. Does
|
|
// NOT verify the signature; that's ValidateChallenge's job.
|
|
//
|
|
// Format: base64url(header) "." base64url(payload) "." base64url(signature)
|
|
// where the base64url alphabet is RFC 4648 §5 (URL-safe, no padding).
|
|
//
|
|
// We accept both padded and unpadded base64url because some Connector
|
|
// versions have shipped padded encodings in the wild despite RFC 7515 §2
|
|
// mandating unpadded. The stdlib base64.RawURLEncoding rejects padding,
|
|
// so we strip trailing '=' before decoding.
|
|
func ParseChallenge(raw string) (header, payload, signature []byte, err error) {
|
|
if raw == "" {
|
|
return nil, nil, nil, fmt.Errorf("%w: empty input", ErrChallengeMalformed)
|
|
}
|
|
parts := strings.Split(raw, ".")
|
|
if len(parts) != 3 {
|
|
return nil, nil, nil, fmt.Errorf("%w: expected 3 dot-separated segments, got %d", ErrChallengeMalformed, len(parts))
|
|
}
|
|
for i, p := range parts {
|
|
if p == "" {
|
|
return nil, nil, nil, fmt.Errorf("%w: segment %d is empty", ErrChallengeMalformed, i)
|
|
}
|
|
}
|
|
header, err = b64urlDecode(parts[0])
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("%w: header base64url: %v", ErrChallengeMalformed, err)
|
|
}
|
|
payload, err = b64urlDecode(parts[1])
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("%w: payload base64url: %v", ErrChallengeMalformed, err)
|
|
}
|
|
signature, err = b64urlDecode(parts[2])
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("%w: signature base64url: %v", ErrChallengeMalformed, err)
|
|
}
|
|
// Sanity-check the header parses as JSON before we hand it back; a
|
|
// non-JSON header is a clear malformed signal we'd otherwise only
|
|
// catch later in ValidateChallenge during alg dispatch. Earlier
|
|
// rejection = better operator audit log shape.
|
|
var probe map[string]any
|
|
if err := json.Unmarshal(header, &probe); err != nil {
|
|
return nil, nil, nil, fmt.Errorf("%w: header is not JSON: %v", ErrChallengeMalformed, err)
|
|
}
|
|
return header, payload, signature, nil
|
|
}
|
|
|
|
// b64urlDecode decodes RFC 4648 §5 base64url with or without trailing
|
|
// '=' padding. RFC 7515 §2 mandates unpadded; some Intune Connector
|
|
// versions emit padded; tolerate both.
|
|
func b64urlDecode(s string) ([]byte, error) {
|
|
stripped := strings.TrimRight(s, "=")
|
|
return base64.RawURLEncoding.DecodeString(stripped)
|
|
}
|
|
|
|
// jwtHeader is the JOSE-style header carried in the first segment of an
|
|
// Intune challenge. We only consult `alg` for signature dispatch; other
|
|
// JWS fields (kid, x5c, jku, etc.) are intentionally NOT honored — the
|
|
// trust anchor is operator-supplied at startup and pinned, not negotiated
|
|
// per-request. Honoring kid/jku would expand the attack surface to "any
|
|
// URL the Connector header claims is the truth," which is exactly the
|
|
// JWT vulnerability class we're avoiding by not pulling in a full JOSE
|
|
// implementation.
|
|
type jwtHeader struct {
|
|
Alg string `json:"alg"`
|
|
Typ string `json:"typ,omitempty"`
|
|
}
|
|
|
|
// versionedChallenge is the lightest possible pre-parse to extract a
|
|
// version claim BEFORE the full JSON unmarshal commits to a struct
|
|
// shape. v1 (current) has no "version" key; v2+ MUST.
|
|
//
|
|
// SCEP RFC 8894 + Intune master bundle Phase 7.4 (version dispatcher
|
|
// rationale): Microsoft has changed the Connector signed-challenge format
|
|
// at least twice in the past 5 years. Adding the dispatcher today costs
|
|
// ~30 LoC + 2 tests; not having it when v2 ships costs a P0 incident
|
|
// where every Intune enrollment fails until a hot-fix lands.
|
|
type versionedChallenge struct {
|
|
Version string `json:"version,omitempty"`
|
|
}
|
|
|
|
// versionUnmarshalers maps a version string to its claim parser. Adding
|
|
// v2 = adding a parser + a registration line. Adding v3 = same. Existing
|
|
// v1 path stays untouched.
|
|
var versionUnmarshalers = map[string]func(payload []byte) (*ChallengeClaim, error){
|
|
"": unmarshalChallengeV1, // legacy / current default
|
|
"v1": unmarshalChallengeV1, // explicit v1, future-belt-and-suspenders
|
|
// "v2": unmarshalChallengeV2, // ← future, when Microsoft ships it
|
|
}
|
|
|
|
// challengePayloadV1 is the on-the-wire JSON shape of the v1 Connector
|
|
// challenge. Separated from the public ChallengeClaim because the wire
|
|
// format uses Unix-second numerics for iat/exp while the in-memory type
|
|
// uses time.Time (caller-friendly + sentinel-safe).
|
|
type challengePayloadV1 struct {
|
|
Issuer string `json:"iss,omitempty"`
|
|
Subject string `json:"sub,omitempty"`
|
|
Audience string `json:"aud,omitempty"`
|
|
IssuedAt int64 `json:"iat,omitempty"`
|
|
ExpiresAt int64 `json:"exp,omitempty"`
|
|
Nonce string `json:"nonce,omitempty"`
|
|
DeviceName string `json:"device_name,omitempty"`
|
|
SANDNS []string `json:"san_dns,omitempty"`
|
|
SANRFC822 []string `json:"san_rfc822,omitempty"`
|
|
SANUPN []string `json:"san_upn,omitempty"`
|
|
}
|
|
|
|
// unmarshalChallengeV1 parses the v1 wire format. Conservative: any
|
|
// unrecognised JSON fields are silently dropped (forward-compat for the
|
|
// inevitable v1.x minor additions Microsoft makes without bumping the
|
|
// version key).
|
|
func unmarshalChallengeV1(payload []byte) (*ChallengeClaim, error) {
|
|
var p challengePayloadV1
|
|
if err := json.Unmarshal(payload, &p); err != nil {
|
|
return nil, fmt.Errorf("%w: v1 payload unmarshal: %v", ErrChallengeMalformed, err)
|
|
}
|
|
c := &ChallengeClaim{
|
|
Issuer: p.Issuer,
|
|
Subject: p.Subject,
|
|
Audience: p.Audience,
|
|
Nonce: p.Nonce,
|
|
DeviceName: p.DeviceName,
|
|
SANDNS: p.SANDNS,
|
|
SANRFC822: p.SANRFC822,
|
|
SANUPN: p.SANUPN,
|
|
}
|
|
if p.IssuedAt > 0 {
|
|
c.IssuedAt = time.Unix(p.IssuedAt, 0).UTC()
|
|
}
|
|
if p.ExpiresAt > 0 {
|
|
c.ExpiresAt = time.Unix(p.ExpiresAt, 0).UTC()
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// ValidateOptions parameterizes ValidateChallenge. Introduced in the
|
|
// 2026-04-29 SCEP RFC 8894 + Intune master-prompt §15 hazard closure
|
|
// to add a configurable clock-skew tolerance without continuing to
|
|
// pile positional arguments onto the validator. Future per-validation
|
|
// knobs (e.g. an explicit version allow-list, a custom sig-alg policy)
|
|
// land here without churning every call site.
|
|
//
|
|
// Field defaults via the zero value MUST preserve the strict pre-§15
|
|
// behavior — i.e. a caller that passes ValidateOptions{Trust: ..., Now: ...}
|
|
// with no other fields gets exactly the iat/exp/audience semantics that
|
|
// shipped before the tolerance was introduced. This is a load-bearing
|
|
// contract for the existing test suite and any out-of-tree caller that
|
|
// hasn't migrated to opt-in tolerance.
|
|
type ValidateOptions struct {
|
|
// Trust is the pool of operator-supplied Connector signing-cert public
|
|
// keys to verify the challenge signature against. Required (an empty
|
|
// pool returns ErrChallengeSignature with a "no trust anchors
|
|
// configured" message so the operator boot-time misconfig is
|
|
// distinguishable from an in-the-wild signature mismatch).
|
|
Trust []*x509.Certificate
|
|
|
|
// ExpectedAudience is the SCEP endpoint URL the challenge's "aud"
|
|
// claim is expected to match. Empty disables the audience check
|
|
// (proxy / load-balancer scenarios where the URL the Connector saw
|
|
// differs from the URL we see, plus test convenience).
|
|
ExpectedAudience string
|
|
|
|
// Now is the wall-clock time used for the iat/exp comparisons.
|
|
// Injected (rather than read from time.Now() inside the function) so
|
|
// tests are deterministic and the per-profile dispatcher can pin a
|
|
// single "request started at" timestamp across the validate + replay
|
|
// + rate-limit triplet.
|
|
Now time.Time
|
|
|
|
// ClockSkewTolerance widens the iat/exp window by ±|tolerance| to
|
|
// absorb modest clock drift between the Microsoft Intune Certificate
|
|
// Connector and the certctl host. Default zero preserves strict
|
|
// pre-§15 behaviour. Operators wire this from the per-profile env
|
|
// var CERTCTL_SCEP_PROFILE_<NAME>_INTUNE_CLOCK_SKEW_TOLERANCE
|
|
// (default 60s — see internal/config/config.go).
|
|
//
|
|
// Asymmetric application: an iat in the future is accepted when
|
|
// `now + tolerance >= iat` (so a Connector clock 30s ahead of certctl
|
|
// passes with tolerance=60s). An exp in the past is accepted when
|
|
// `now - tolerance < exp` (so a Connector clock 30s behind certctl
|
|
// passes too). Negative tolerance is treated as zero (a defensive
|
|
// no-op rather than a footgun that tightens the window).
|
|
ClockSkewTolerance time.Duration
|
|
}
|
|
|
|
// ValidateChallenge runs the full Intune-challenge validation pipeline:
|
|
//
|
|
// 1. ParseChallenge(raw) — JWT compact deserialize
|
|
// 2. Verify signature over (segment0 || "." || segment1) against any
|
|
// trust-anchor cert's public key (try each until one verifies)
|
|
// 3. Extract version claim via the lightweight versioned-prelude
|
|
// 4. Dispatch to the per-version unmarshaler (v1 today)
|
|
// 5. Time bounds: now+tolerance ≥ iat AND now-tolerance < exp
|
|
// (tolerance defaults to zero — strict — and widens via opts)
|
|
// 6. Audience: claim.Audience == opts.ExpectedAudience (when
|
|
// ExpectedAudience is non-empty; empty disables the check)
|
|
//
|
|
// Returns *ChallengeClaim on success, typed error on failure (caller can
|
|
// errors.Is the specific dimension).
|
|
//
|
|
// Replay protection is the CALLER's responsibility — pass the returned
|
|
// claim's Nonce to a *ReplayCache.CheckAndInsert. We deliberately don't
|
|
// own the cache here so the validator stays stateless + testable; the
|
|
// handler glues parser + cache together.
|
|
func ValidateChallenge(raw string, opts ValidateOptions) (*ChallengeClaim, error) {
|
|
if len(opts.Trust) == 0 {
|
|
return nil, fmt.Errorf("%w: no trust anchors configured", ErrChallengeSignature)
|
|
}
|
|
|
|
header, payload, signature, err := ParseChallenge(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// JWS signing input per RFC 7515 §5.1: ASCII bytes of segment0 + "." + segment1.
|
|
// We re-derive from raw (split-by-dots) rather than re-base64-encode the
|
|
// decoded segments, because RFC 7515 §3.1 specifies the signing input
|
|
// is the encoded form, and some encoders omit padding while others
|
|
// don't — re-encoding could produce a byte-different input than what
|
|
// the Connector originally signed. Use the raw on-wire bytes.
|
|
parts := strings.Split(raw, ".")
|
|
if len(parts) != 3 {
|
|
// ParseChallenge already enforced this; defensive double-check.
|
|
return nil, fmt.Errorf("%w: post-parse segment count drift", ErrChallengeMalformed)
|
|
}
|
|
signingInput := []byte(parts[0] + "." + parts[1])
|
|
|
|
var hdr jwtHeader
|
|
if err := json.Unmarshal(header, &hdr); err != nil {
|
|
return nil, fmt.Errorf("%w: header JSON: %v", ErrChallengeMalformed, err)
|
|
}
|
|
|
|
if err := verifyChallengeSignature(hdr.Alg, signingInput, signature, opts.Trust); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Version dispatch — extract the version claim BEFORE the full unmarshal.
|
|
var v versionedChallenge
|
|
if err := json.Unmarshal(payload, &v); err != nil {
|
|
return nil, fmt.Errorf("%w: prelude unmarshal: %v", ErrChallengeMalformed, err)
|
|
}
|
|
unmarshaler, ok := versionUnmarshalers[v.Version]
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %q", ErrChallengeUnknownVersion, v.Version)
|
|
}
|
|
claim, err := unmarshaler(payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Time bounds. Tolerance defaults to zero (strict) and is normalized
|
|
// to absolute value so a misconfigured negative value is a defensive
|
|
// no-op rather than a footgun that tightens the window.
|
|
tolerance := opts.ClockSkewTolerance
|
|
if tolerance < 0 {
|
|
tolerance = -tolerance
|
|
}
|
|
now := opts.Now
|
|
// iat check: a future iat is accepted when (now + tolerance) >= iat.
|
|
// Equivalent to: reject when (now + tolerance) < iat.
|
|
if !claim.IssuedAt.IsZero() && now.Add(tolerance).Before(claim.IssuedAt) {
|
|
return nil, fmt.Errorf("%w: iat=%s now=%s tolerance=%s", ErrChallengeNotYetValid,
|
|
claim.IssuedAt.Format(time.RFC3339), now.Format(time.RFC3339), tolerance)
|
|
}
|
|
// exp check: a past exp is accepted when (now - tolerance) < exp.
|
|
// Equivalent to: reject when (now - tolerance) >= exp.
|
|
if !claim.ExpiresAt.IsZero() && !now.Add(-tolerance).Before(claim.ExpiresAt) {
|
|
return nil, fmt.Errorf("%w: exp=%s now=%s tolerance=%s", ErrChallengeExpired,
|
|
claim.ExpiresAt.Format(time.RFC3339), now.Format(time.RFC3339), tolerance)
|
|
}
|
|
|
|
// Audience binds the challenge to a specific SCEP endpoint URL. An
|
|
// empty ExpectedAudience disables the check (test convenience + the
|
|
// Phase 8 config allows operator opt-out for proxy / load-balancer
|
|
// scenarios where the URL the Connector saw isn't the URL we see).
|
|
if opts.ExpectedAudience != "" && claim.Audience != "" && claim.Audience != opts.ExpectedAudience {
|
|
return nil, fmt.Errorf("%w: claim=%q expected=%q", ErrChallengeWrongAudience,
|
|
claim.Audience, opts.ExpectedAudience)
|
|
}
|
|
|
|
return claim, nil
|
|
}
|
|
|
|
// verifyChallengeSignature dispatches on the JWS alg header to the
|
|
// matching stdlib signature-verify routine, then iterates the trust
|
|
// anchors trying each cert's public key until one verifies.
|
|
//
|
|
// Supported algs:
|
|
// - RS256: RSASSA-PKCS1-v1_5 over SHA-256 (Microsoft's published Connector default)
|
|
// - ES256: ECDSA P-256 over SHA-256 (community-reported Connector option)
|
|
//
|
|
// Deliberately rejected algs:
|
|
// - "none" (RFC 7515 §3.6 vulnerability vector)
|
|
// - HS256 / HS384 / HS512 (HMAC; no shared secret in our threat model)
|
|
// - PS256+ (RSA-PSS; not seen in Intune Connector traffic — add only when needed)
|
|
//
|
|
// Adding a new alg = add a case + a verify helper. The trust-anchor loop
|
|
// stays unchanged.
|
|
func verifyChallengeSignature(alg string, signingInput, signature []byte, trust []*x509.Certificate) error {
|
|
switch alg {
|
|
case "RS256":
|
|
return verifyRS256(signingInput, signature, trust)
|
|
case "ES256":
|
|
return verifyES256(signingInput, signature, trust)
|
|
case "":
|
|
return fmt.Errorf("%w: missing alg header (RFC 7515 §4.1.1 mandates)", ErrChallengeSignature)
|
|
case "none":
|
|
// Explicit reject so the failure mode in the audit log distinguishes
|
|
// "unsupported alg" from "active attack with the alg-none vector."
|
|
return fmt.Errorf("%w: alg \"none\" rejected (RFC 7515 §3.6 attack)", ErrChallengeSignature)
|
|
default:
|
|
return fmt.Errorf("%w: unsupported alg %q (only RS256 and ES256 are accepted)", ErrChallengeSignature, alg)
|
|
}
|
|
}
|
|
|
|
// verifyRS256 hashes the signing input with SHA-256 and checks the
|
|
// signature against each trust anchor's public key. Constant-time: the
|
|
// stdlib's rsa.VerifyPKCS1v15 returns nil on success and an error on
|
|
// failure without timing-leak surface area on the hash compare path.
|
|
//
|
|
// SHA-256 is the spec-mandated digest for RS256 — RFC 7518 §3.3
|
|
// defines RS256 as "RSASSA-PKCS1-v1_5 using SHA-256". This is JWS
|
|
// signature verification over a public, well-known message (the
|
|
// JWS protected header + payload, base64url-encoded). It is NOT
|
|
// password hashing — the input has full 256-bit entropy contributed
|
|
// by the signer's nonce + timestamp + device-claim payload, and
|
|
// the output is checked against an asymmetric signature, not a
|
|
// pre-computed hash digest. CodeQL go/weak-sensitive-data-hashing
|
|
// triggers on the proximity of *x509.Certificate; the certificate
|
|
// here is a verification key, not an input to the hash. Suppressing
|
|
// the alert at the call site below.
|
|
func verifyRS256(signingInput, signature []byte, trust []*x509.Certificate) error {
|
|
h := sha256.Sum256(signingInput) //nolint:gosec // RFC 7518 §3.3 RS256 mandates SHA-256; not password hashing
|
|
for _, cert := range trust {
|
|
pub, ok := cert.PublicKey.(*rsa.PublicKey)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if err := rsa.VerifyPKCS1v15(pub, crypto.SHA256, h[:], signature); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return ErrChallengeSignature
|
|
}
|
|
|
|
// verifyES256 dispatches between the two ECDSA signature encodings the
|
|
// JOSE spec allows for ES256:
|
|
//
|
|
// - RFC 7515 §3.4 fixed-width: r || s, each 32 bytes (raw concat) — the
|
|
// wire format JOSE-compliant Connectors use.
|
|
// - ASN.1 DER (SEQUENCE { r INTEGER, s INTEGER }) — older Connector
|
|
// builds and many .NET-based JWT libraries emit DER instead of the
|
|
// RFC 7515 fixed-width form.
|
|
//
|
|
// Try fixed-width first (the spec-blessed format); fall back to ASN.1.
|
|
// crypto/ecdsa.VerifyASN1 + ecdsa.Verify both return bool — no timing
|
|
// leak on the success path.
|
|
//
|
|
// SHA-256 is the spec-mandated digest for ES256 — RFC 7518 §3.4 defines
|
|
// ES256 as "ECDSA using P-256 and SHA-256". This is JWS signature
|
|
// verification over a public, well-known message (the JWS protected
|
|
// header + payload, base64url-encoded). It is NOT password hashing.
|
|
// The signing input is the JWS encoded payload; full 256-bit-entropy
|
|
// content from the signer's claim. The output is checked against an
|
|
// asymmetric signature, not a pre-computed digest. CodeQL
|
|
// go/weak-sensitive-data-hashing triggers on the proximity of
|
|
// *x509.Certificate; the certificate here is a verification key, not
|
|
// an input to the hash. Suppressing the alert at the call site below
|
|
// (CodeQL alert #21).
|
|
func verifyES256(signingInput, signature []byte, trust []*x509.Certificate) error {
|
|
h := sha256.Sum256(signingInput) //nolint:gosec // RFC 7518 §3.4 ES256 mandates SHA-256; not password hashing
|
|
for _, cert := range trust {
|
|
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Fixed-width r||s form (JOSE-canonical for P-256 = 64 bytes).
|
|
if len(signature) == 64 {
|
|
r := new(big.Int).SetBytes(signature[:32])
|
|
s := new(big.Int).SetBytes(signature[32:])
|
|
if ecdsa.Verify(pub, h[:], r, s) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ASN.1 DER form (older / non-JOSE encoders).
|
|
if ecdsa.VerifyASN1(pub, h[:], signature) {
|
|
return nil
|
|
}
|
|
}
|
|
return ErrChallengeSignature
|
|
}
|