Documentation
¶
Overview ¶
der_gate.go — strict-DER + low-S parser for secp256k1 ECDSA signatures.
Mirrors reef-core's `ReefCore.PublishedPods.DerGate` byte for byte against the shared P1.4 conformance vectors at `testdata/der_gate/vectors.json`. Both sides MUST report the same outcome on the same bytes — wire-format identity is the load-bearing property of P1.4.
Enforces the four PRD rules:
- Outer SEQUENCE tag (0x30) with minimal definite-length encoding (short form for ≤127 body bytes; one-byte long-form 0x81 only when body ≥128 bytes). All 0x82+ long-forms reject — a secp256k1 ECDSA SEQUENCE never exceeds 255 body bytes.
- Two INTEGERs (r, s) minimally encoded: no superfluous leading 0x00 (a leading 0x00 is permitted only when the next byte has its high bit set, to keep the signed INTEGER positive).
- No trailing bytes after the outer SEQUENCE.
- s ≤ n/2 where n is the secp256k1 curve order. konareef rejects high-S — never normalises — so the wire form a third party observed is the same form the verifier checked.
Surface:
- ErrDerGateReject — sentinel returned by every rule violation.
- ParseStrict(der) — (r, s, err); r, s are positive *big.Int on accept, nil on reject.
Package identity — env initializer for feature flags.
LoadEnv reads process environment variables and applies them to package-level identity flags. It MUST be called from main() early, before any signing or verification operation, so that integration tests can cover the env-var path by calling LoadEnv() directly without needing to invoke the application binary.
Design note: the initializer lives in the identity package (not in main) so that go test ./internal/identity/... exercises it. A bare os.Getenv block in main() is untestable from package-level tests, which was the Hermes round-3 B2 blocker.
Package identity implements the konareef publisher signing identity: a secp256k1 keypair plus a publisher handle, persisted as `~/.konareef/identity.json` (mode 0600). It is the local credential `konareef pod publish` signs manifests with and the only thing that proves a re-publish of `<handle>/<pod>` came from the same publisher.
The on-disk format is the publisher-signing-design-v0 §D3 layout:
{ "version": "konareef-identity/v1",
"scheme": "secp256k1-ecdsa-der",
"handle": "alice",
"public_key_hex": "02…", // 33-byte compressed point, hex
"private_key_hex": "8c…", // 32-byte scalar, hex
"created_at": "…Z" }
Index ¶
- Constants
- Variables
- func Canonical(att RotationAttestation) []byte
- func ClassifyStrictError(der []byte) string
- func LoadEnv()
- func ParseStrict(der []byte) (*big.Int, *big.Int, error)
- func Path(home string) string
- func Verify(pubKeyHex string, msg, sig []byte) (bool, error)
- func VerifyDigest(pubKeyHex string, digest, sig []byte) (bool, error)
- func VerifyRotationSignature(pubKeyHex string, att RotationAttestation, sig []byte) (bool, error)
- type Identity
- type RotationAttestation
- type RotationResponse
Constants ¶
const BackupVersion = "konareef-identity-backup/v1"
BackupVersion is the on-disk schema tag for v1 backup envelopes.
const Scheme = "secp256k1-ecdsa-der"
Scheme names the signature algorithm bound to this identity. Today every v1 identity is secp256k1 ECDSA with DER-encoded signatures — the future BRC-100 wallet-backed identity (design §D3) reuses the same scheme string and only swaps where the private key lives.
const Version = "konareef-identity/v1"
Version is the on-disk schema tag for the v1 identity file. Bumping this string is a breaking format change; new keys are introduced as optional JSON fields under the same tag until a v2 is required.
Variables ¶
var ErrDerGateReject = errors.New("der_gate_reject")
ErrDerGateReject is returned for any signature that fails strict-DER or low-S. Callers MUST surface this verbatim — no normalisation, no fallback, no lenient retry. The reef-core Elixir side reports the structurally-identical `{:error, :der_gate_reject}` on the same bytes.
var StrictDerGateEnabled bool
StrictDerGateEnabled gates the strict-DER + low-S enforcement in Verify and VerifyRotationSignature (P1.4). When false (the default), both functions accept any signature that dcrec/secp256k1 can parse and verify — matching pre-P1.4 behaviour. When true, an additional byte-level DER check plus low-S assertion is applied before the ECDSA verification, and any non-conforming signature returns ERR_DER_GATE_REJECT.
The flag is set by LoadEnv() from KONAREEF_STRICT_DER_GATE=true or directly in tests. It MUST NOT be set in init() — test binaries that import this package should not auto-enable the gate without an explicit call or env-var.
Functions ¶
func Canonical ¶
func Canonical(att RotationAttestation) []byte
Canonical returns the byte sequence that is signed and verified.
Encoding rules (a minimal JCS slice for this fixed six-field shape — RFC 8785 compatible):
- Keys sorted ascending bytewise.
- No whitespace anywhere outside string values.
- `reason` omitted entirely when empty (not emitted as `"reason":""` or `"reason":null`).
- String values escaped via encoding/json — the same rules Elixir's Jason library uses, so the byte form is identical across the two signers.
func ClassifyStrictError ¶
ClassifyStrictError returns a granular reason string for a DER byte slice that is known to have failed ParseStrict. It attempts a permissive integer extraction to distinguish the three normative failure reasons:
- "high_s" — strict-DER structure OK but s > n/2.
- "rs_out_of_range" — r or s is zero, negative, or ≥ n (structural parse succeeded but range violated).
- "strict_der_invalid" — DER structure itself is malformed.
This function is used by Check 4 (PRD 1 §4 / PRD 3 §11.2) to emit granular divergence reasons without requiring ParseStrict to grow a richer error type. Called only on the error path.
func LoadEnv ¶
func LoadEnv()
LoadEnv reads KONAREEF_STRICT_DER_GATE and enables StrictDerGateEnabled when the value is exactly "true". Any other value (unset, "1", "yes", etc.) leaves the flag at its current value, which defaults to false.
Idempotent: calling LoadEnv multiple times has the same effect as calling it once — the flag is only ever set, never cleared.
func ParseStrict ¶
ParseStrict validates the DER-encoded signature against the P1.4 rules and returns r, s as positive *big.Int on success. Any rule violation yields (nil, nil, ErrDerGateReject).
The two-return-value-plus-error shape matches the rest of the `identity` package; callers compose it with the existing ECDSA verify primitives without translation.
func Path ¶
Path returns the canonical identity-file path under home: `<home>/.konareef/identity.json`. The CLI passes os.UserHomeDir(); tests pass t.TempDir().
func Verify ¶
Verify reports whether sig is a valid secp256k1 ECDSA signature of SHA-256(msg) under the 33-byte compressed public key encoded as pubKeyHex.
The (bool, error) split is deliberate:
- (true, nil) — signature checks out.
- (false, nil) — inputs parsed fine; the signature is wrong.
- (false, non-nil) — inputs themselves are malformed (bad hex, unparseable DER, non-secp256k1 pubkey). Callers that only care "is this signature good?" can treat both false cases as failure.
This is the verification step `pod install` runs locally before accepting a manifest, and that reef-core re-runs server-side as authentication for `POST /api/pods` (no separate auth header is needed — the signature *is* the credential).
func VerifyDigest ¶
VerifyDigest verifies a DER ECDSA signature sig directly over the raw 32-byte digest (NOT over a preimage) under the compressed secp256k1 public key pubKeyHex. Used by PRD 3 §11.2 Check 4, where the signed message IS h_manifest (already a SHA-256 digest), so re-hashing (as Verify does at line 84) would produce the wrong result.
The digest argument MUST be exactly 32 bytes; any other length is rejected immediately to prevent accidental preimage misuse.
When StrictDerGateEnabled, the same strict-DER + low-S gate as Verify is applied before the ECDSA check (via ParseStrict in der_gate.go). The (bool, error) semantics are identical to Verify:
- (true, nil) — signature checks out.
- (false, nil) — inputs parsed fine; the signature is wrong.
- (false, non-nil) — inputs are malformed or the gate rejected.
func VerifyRotationSignature ¶
func VerifyRotationSignature(pubKeyHex string, att RotationAttestation, sig []byte) (bool, error)
VerifyRotationSignature reports whether sig is a valid signature of Canonical(att) under the compressed-secp256k1 public key encoded as pubKeyHex (typically the *old* key in a rotation hop, i.e. att.OldPubkeyHex itself).
The (bool, error) split mirrors identity.Verify:
- (true, nil) — valid signature.
- (false, nil) — well-formed inputs; signature is wrong.
- (false, non-nil) — inputs malformed (bad hex, unparseable DER, non-secp256k1 pubkey).
Types ¶
type Identity ¶
type Identity struct {
Version string `json:"version"`
Scheme string `json:"scheme"`
Handle string `json:"handle"`
PublicKeyHex string `json:"public_key_hex"`
PrivateKeyHex string `json:"private_key_hex"`
CreatedAt time.Time `json:"created_at"`
}
Identity is a publisher's signing identity. The JSON tags are the stable on-disk schema; renaming a field is a breaking format change.
PrivateKeyHex is the raw 32-byte scalar, hex-encoded. It MUST be stored only in mode-0600 files and MUST NEVER be logged or printed — the CLI's `identity show` command prints Handle and PublicKeyHex only, never PrivateKeyHex.
func Generate ¶
Generate creates a fresh secp256k1 keypair and binds it to handle. The underlying secp256k1.GeneratePrivateKey draws from crypto/rand.
The returned Identity is in-memory only; persist it with Save before any signing operation, otherwise the key is lost when the process exits.
func Import ¶
Import reads an encrypted backup from path, verifies the version + KDF + cipher tags, and decrypts the Identity using passphrase. A wrong passphrase, a malformed envelope, or any post-write tamper surfaces as an Import error — the AES-GCM auth tag does the integrity check; we never return partial / unverified plaintext.
func Load ¶
Load reads the identity file at Path(home). It returns an error rather than the parsed Identity in three cases:
- the file does not exist (run `konareef pod identity create` first);
- the file's permissions are anything other than exactly 0600;
- the file's contents are not valid Identity JSON.
The permission check is strict: a 0644 file is refused, not honored, because the private key inside it is a credential and a careless chmod is the most likely silent compromise vector.
func (*Identity) Export ¶
Export writes a passphrase-encrypted backup of id to path. The passphrase MUST be non-empty (an empty passphrase is rejected rather than silently downgraded to "no encryption").
The output file is mode 0600 and written atomically (temp file + rename), so a crash mid-write never leaves a partial backup and a successful Export never widens the file's permissions.
func (*Identity) Save ¶
Save writes id to <home>/.konareef/identity.json with mode 0600. The parent directory is created (mode 0700) on first save.
The write is atomic: id is serialized to a sibling temp file (with the target permission bits already set), then renamed onto the final path. A crash mid-write therefore never leaves a half-written identity, and a successful Save never widens the file's permissions.
func (*Identity) Sign ¶
Sign returns the DER-encoded secp256k1 ECDSA signature of SHA-256(msg) under id's private key. The output is the byte form that flows through `pod publish`, the `signature` column of the reef-core `pods` table, and the spawn-time verifier — bit-exact across every consumer.
Errors here mean the identity itself is malformed (private key is not 32 hex-encoded bytes); a fresh Generate() never produces one.
func (*Identity) SignRotation ¶
func (id *Identity) SignRotation(att RotationAttestation) ([]byte, error)
SignRotation produces the DER-encoded secp256k1 ECDSA signature of SHA-256(Canonical(att)) under id's private key. The returned bytes are what the producer POSTs to `/api/publishers/:handle/rotate` as `signature_by_old_key` (base64-encoded on the wire).
The wrapper exists so the rotation flow doesn't need to assemble canonical bytes itself; callers pass the typed attestation and receive a signature that is by construction over the right input.
type RotationAttestation ¶
type RotationAttestation struct {
// Kind is the format discriminator. Always
// "konareef-key-rotation/v1" for v1 attestations; the verifier
// rejects anything else.
Kind string
// Handle is the publisher handle this rotation applies to.
Handle string
// OldPubkeyHex / NewPubkeyHex are the compressed-secp256k1 keys
// as 66-char lowercase hex (33 bytes, leading 02 or 03 prefix).
// OldPubkeyHex MUST be the key currently bound to Handle in
// reef-core; the verifier compares against the registered identity.
OldPubkeyHex string
NewPubkeyHex string
// RotatedAt is the publisher-stated time of rotation as a
// microsecond-precision UTC ISO-8601 string
// (e.g. "2026-05-19T11:22:00.000000Z"). Inside the signed bytes
// — server-side overrides would invalidate the signature.
RotatedAt string
// Reason is optional free-text. Empty string is treated as
// absent: omitted from the canonical bytes entirely (not
// serialized as "" or null) so the byte form matches reef-core's
// `Enum.reject(is_nil/1)`-style filter.
Reason string
}
RotationAttestation is the typed view of a `konareef-key-rotation/v1` document.
All fields except Reason are required; Reason is a free-text human note (e.g. "scheduled key rotation", "hardware wallet migration") and is omitted from the canonical bytes when empty.
type RotationResponse ¶
type RotationResponse struct {
RotationID string `json:"rotation_id"`
RegisteredAt string `json:"registered_at"`
}
RotationResponse is the parsed 201 body from `POST /api/publishers/:handle/rotate`.
func SubmitRotation ¶
func SubmitRotation(serverURL, handle string, att RotationAttestation, sig []byte) (*RotationResponse, error)
SubmitRotation POSTs a signed key-rotation attestation to reef-core.
The `attestation` object in the request body uses the same field names as the canonical bytes Canonical/1 emits — so the server can reconstruct those bytes deterministically and re-verify `signature_by_old_key` under the registered publisher pubkey. `reason` is dropped from the request when empty; this matches Canonical's omit-when-nil rule and prevents a `"reason":""` field from sneaking into the JSON the server hashes.
On 201 returns the decoded RotationResponse. On any non-2xx the server's `{"error": "<atom>"}` body is surfaced verbatim in the returned error so the CLI can show it without re-parsing.