diff --git a/.github/workflows/generate-structured-token.yml b/.github/workflows/generate-structured-token.yml new file mode 100644 index 0000000..a56c4f9 --- /dev/null +++ b/.github/workflows/generate-structured-token.yml @@ -0,0 +1,123 @@ +name: Generate Structured Token + + +on: + workflow_dispatch: + inputs: + system_id: + description: 'System abbreviation for the token.' + required: true + type: string + environment: + description: 'Target deployment environment for the token.' + required: true + type: choice + options: + - Preview + - Production + domain_purpose: + description: 'Domain purpose abbreviation for the token.' + required: true + type: string + + +jobs: + generate_token: + name: Generate Token + runs-on: ubuntu-latest + steps: + - name: 🔍 Validate Inputs + id: validate_inputs + run: | + SYSTEM_ID='${{ inputs.system_id }}' + DOMAIN_PURPOSE='${{ inputs.domain_purpose }}' + + if [[ -z "$SYSTEM_ID" ]]; then + echo "::error::System identifier must not be empty." + + exit 1 + fi + + if [[ -z "$DOMAIN_PURPOSE" ]]; then + echo "::error::Domain purpose identifier must not be empty." + + exit 1 + fi + + if [[ "$SYSTEM_ID" =~ [_\ ] ]]; then + echo "::error::System identifier must not contain underscores or spaces." + + exit 1 + fi + + if [[ "$DOMAIN_PURPOSE" =~ [_\ ] ]]; then + echo "::error::Domain purpose identifier must not contain underscores or spaces." + + exit 1 + fi + + if [ '${{ inputs.environment }}' = 'Preview' ]; then + echo "env_id=prev" >> "$GITHUB_OUTPUT" + else + echo "env_id=prod" >> "$GITHUB_OUTPUT" + fi + + - name: 🎲 Generate Token + id: generate_token + run: | + SYSTEM_ID='${{ inputs.system_id }}' + ENV_ID='${{ steps.validate_inputs.outputs.env_id }}' + DOMAIN_PURPOSE='${{ inputs.domain_purpose }}' + + # Generate 256-bit high-strength random entropy. + ENTROPY=$(openssl rand -hex 32) + + # Assemble token body (without checksum). + TOKEN_BODY="${SYSTEM_ID}_${ENV_ID}_${DOMAIN_PURPOSE}_${ENTROPY}" + + # Compute CRC32 checksum encoded as 6-char base62. + # Base62 alphabet: 0-9 A-Z a-z; 62^6 > 2^32 so the full CRC32 fits losslessly. + CHECKSUM=$(python3 - "$TOKEN_BODY" << 'PYEOF' + import binascii, sys + ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + data = sys.argv[1] + crc = binascii.crc32(data.encode("utf-8")) & 0xFFFFFFFF + result = [] + + for _ in range(6): + result.append(ALPHABET[crc % 62]) + crc //= 62 + print("".join(reversed(result))) + + PYEOF + ) + + FINAL_TOKEN="${TOKEN_BODY}_${CHECKSUM}" + + echo "system_id=${SYSTEM_ID}" >> "$GITHUB_OUTPUT" + echo "env_id=${ENV_ID}" >> "$GITHUB_OUTPUT" + echo "domain_purpose=${DOMAIN_PURPOSE}" >> "$GITHUB_OUTPUT" + echo "entropy=${ENTROPY}" >> "$GITHUB_OUTPUT" + echo "checksum=${CHECKSUM}" >> "$GITHUB_OUTPUT" + echo "final_token=${FINAL_TOKEN}" >> "$GITHUB_OUTPUT" + + - name: '📝 Write Summary' + env: + FINAL_TOKEN: ${{ steps.generate_token.outputs.final_token }} + CHECKSUM: ${{ steps.generate_token.outputs.checksum }} + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ## 🔑 Generated Structured Token + + | Field | Value | + |---|---| + | System ID | \`${{ steps.generate_token.outputs.system_id }}\` | + | Environment ID | \`${{ steps.generate_token.outputs.env_id }}\` | + | Domain Purpose ID | \`${{ steps.generate_token.outputs.domain_purpose }}\` | + | Entropy | \`(masked — 256-bit random)\` | + | CRC32 Checksum (base62) | \`${CHECKSUM}\` | + + \`\`\` + ${FINAL_TOKEN} + \`\`\` + EOF diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 64658b2..490dde9 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,16 +1,27 @@ name: Prepare Release + on: push: tags: - 'v*' + - '*/v*' + permissions: contents: write pull-requests: write + jobs: call-prepare: uses: leoweyr/github-release-workflow/.github/workflows/reusable-prepare-release.yml@develop + + with: + packages: | + { + "go": "go" + } + secrets: ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index c13e710..ad40b9b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,123 @@ # Tokenforge One spec, every language — context-aware credential architecture for generating and verifying structured tokens with byte-identical layout and CRC32 tail checksums. + +``` +[SystemIdentifier]_[EnvironmentIdentifier]_[DomainPurposeIdentifier]_[Entropy][Checksum] +``` + +| Segment | Content | Width | +|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------| +| Prefix | System, environment, domain purpose identifiers, delimited by `_`
Restricts every semantic component to lowercase ASCII letters and decimal digits only. Underscores, uppercase letters, and any multi-byte non-ASCII characters are strictly forbidden.
`ALPHABET = 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz` | Variable (≥ 6) | +| High-intensity Entropy | 24 Base62 characters
Strictly the 62-character GMP dictionary, ordered by ASCII code in ascending order. Third-party Base62 dialects that embed internal permutations are prohibited
`PREFIX_CHARSET = [a-z0-9]` | 24 | +| Tail Checksum | 6 Base62 characters encoding CRC32-IEEE of Prefix + Entropy | 6 | + +**Physical Seamless Fusion** — No separator is permitted between the high-intensity entropy and the tail checksum. The full-stack slice cursor operates as a hard asymmetric dead-lock: the last 6 characters are always the checksum, everything before them is always the base string. + +**Absolute Unambiguous Prefix** — Each semantic component of the prefix is subject to strict character-set constraints that fundamentally eliminate cross-platform parsing ambiguity and the hash inconsistency introduced by Unicode normalization (NFC/NFD). + +**Unbiased Uniform Sampling** — Direct modulo on a low-bit-width integer is prohibited across all platforms. Every random index must be drawn from an OS-level cryptographically secure random number generator (CSPRNG) via rejection sampling, completely eliminating modulo bias. + +**Structured Threshold Assertion** — Perimeter length guards discard hard-coded magic numbers. Minimum valid lengths are derived dynamically from the credential's own topology formula. + +## 🚀 Quick Start + +### Go + +```bash +go get go.leoweyr.com/tokenforge/go +``` + +## 🏗️ Generation Pipeline + +### 1. Prefix Construction + +Concatenate plaintext semantic segments for the given use case. Prefix length is variable and determined by system design, but must conform to a fixed topology. + +*Example*: `odc_prod_msk` + +### 2. Unbiased Entropy Generation + +Draw 24 characters from `ALPHABET` using an OS-level CSPRNG with a rejection-sampling loop to ensure each index is drawn uniformly from `[0, 61]`. Direct modulo on a raw random integer is forbidden — it introduces a statistical skew that taints the distribution. The output is a strictly fixed 24-character string. + +*Example*: `7xT2zP9qL4wK1mN8vV5cB3nA` + +### 3. Base String Concatenation + +Concatenate the prefix from step 1 and the high-intensity entropy string from step 2 directly: + +$$ +\text{BaseString} = \text{Prefix} + \text{HighIntensityEntropy} +$$ + +*Example*: `odc_prod_msk_7xT2zP9qL4wK1mN8vV5cB3nA` + +### 4. Mathematical CRC32 Mapping + +Compute the CRC32-IEEE (reflected form) checksum of `BaseString` encoded as a UTF-8 byte stream, yielding a 32-bit unsigned integer $\text{Value}$. Convert $\text{Value}$ to a 6-character Base62 string $\text{Checksum}$ using the following right-to-left modulo loop: + +$$ +\begin{array}{l} +\text{for } i = 5 \rightarrow 0: \\ +\quad \text{Remainder} = \text{Value} \bmod 62 \\ +\quad \text{Checksum}[i] = \text{ALPHABET}[\text{Remainder}] \\ +\quad \text{Value} = \left\lfloor \dfrac{\text{Value}}{62} \right\rfloor +\end{array} +$$ + +The loop fills from the last position backward. Any value that does not require all 6 digits is naturally zero-padded at the front — no explicit padding logic is needed. + +### 5. Final Assembly + +Append the 6-character checksum directly to the end of the base string with no separator: + +$$ +\text{Token} = \text{BaseString} + \text{TailChecksum} +$$ + +*Example*: `odc_prod_msk_7xT2zP9qL4wK1mN8vV5cB3nA4VHrHM` + +## 🛡️ Validation Pipeline + +### 1. Structural Guard & Asymmetric Slice + +At every network edge gateway or application entry point, perform hard physical boundary assertions before any business logic runs. + +Length check: + +$$ +\text{MinLength} = \text{len}(\text{Prefix}) + 24 + 6 +$$ + +If the validator holds a known expected `Prefix`, the token length must equal exactly $\text{len}(\text{Prefix}) + 30$. Without a known prefix, the absolute minimum token length is 36 characters (each of the three prefix components must be at least 1 character, so the shortest valid prefix is 6 characters). Any token shorter than the derived threshold is discarded immediately. + +Atomic slice, ignoring internal underscore structure, using the fixed-width checksum cursor: + +$$ +\begin{matrix} +\text{BaseString} = \text{Token}[0:\text{len}(\text{Token}) - 6] \\ +\text{ProvidedTailCheckSum} = \text{Token}[\text{len}(\text{Token}) - 6:\text{len}(\text{Token})] +\end{matrix} +$$ + +### 2. Idempotent Verification + +Re-execute the generation pipeline step 4 mapping locally on the extracted `BaseString` to obtain `ExpectedTailChecksum`. If `ExpectedChecksum ≠ ProvidedChecksum`, the token is rejected immediately as corrupted or truncated — fail-fast, no further processing. + +### 3. Context Reification + +Only after passing step 2 may the system split the prefix portion of `BaseString` on `_`. Because `PREFIX_CHARSET` forbids underscores within any component, the split result is uniquely deterministic across every platform and encoding. Extract the system, environment, and domain purpose identifiers and inject them as a typed security context object into downstream operations. + +## ⚖️ Why 24 Characters + +On the cryptographic side, 24 Base62 characters carry: + +$$ +62^{24} \approx 2^{143} \text{ bits} +$$ + +The practical security floor for API tokens in cloud-native architecture is 128 bits. 24 characters delivers 143 bits — clearing the threshold with margin to spare. + +Some vendors push further: GitHub's personal access tokens use 30 entropy characters, reaching ~178 bits. From a pure cryptographic standpoint, that number is unimpeachable. From an engineering leverage standpoint, it is unnecessary — beyond ~140 bits, the marginal security return per additional character converges to zero. The cost, however, is real. Every extra character widens network payloads, inflates database index pages, and — on mobile — turns a token into a string too long to select cleanly with a long-press. + +Tokenforge locks high-intensity entropy at 24 characters: the precise point where cryptographic surplus meets transmission efficiency and human ergonomics, with nothing wasted on either side. diff --git a/go/CHANGELOG.md b/go/CHANGELOG.md new file mode 100644 index 0000000..b6ee6d2 --- /dev/null +++ b/go/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +# [unreleased] +### Features + +* **go:** implement token generation and validation module ([42a2e00](https://github.com/leoweyr/tokenforge/commit/42a2e00d9419792fbc719d8e5b53cb0392b4009d)) [@leoweyr](https://github.com/leoweyr) + + +### Refactor + +* **go:** align module path with `go/` subdirectory location in repository ([f507e51](https://github.com/leoweyr/tokenforge/commit/f507e51324b143d75d962eeeb02cb7c29e580331)) [@leoweyr](https://github.com/leoweyr) +* batch CSPRNG reads and name components in validation errors ([bd322ad](https://github.com/leoweyr/tokenforge/commit/bd322adce4beb2ace1d1ef5fa9ad9121f43286ac)) [@leoweyr](https://github.com/leoweyr) + + + + diff --git a/go/forge.go b/go/forge.go new file mode 100644 index 0000000..4192570 --- /dev/null +++ b/go/forge.go @@ -0,0 +1,63 @@ +package tokenforge + +import ( + "go.leoweyr.com/tokenforge/go/internal/checksum" + "go.leoweyr.com/tokenforge/go/internal/encoding" + "go.leoweyr.com/tokenforge/go/internal/entropy" + "go.leoweyr.com/tokenforge/go/internal/token" +) + +// Forge is the public entry point of the Tokenforge module. It composes the +// generation and validation pipelines and exposes them behind a small surface. +type Forge struct { + generator *token.TokenGenerator + validator *token.TokenValidator +} + +// NewForge builds a Forge by wiring the Base62 alphabet, the prefix alphabet, the +// Base62 encoder, the CRC32 checksum calculator, and the secure entropy generator +// into a generation pipeline and a validation pipeline. +func NewForge() *Forge { + var base62Alphabet *encoding.Alphabet = encoding.NewAlphabet(encoding.Base62Characters) + var prefixAlphabet *encoding.Alphabet = encoding.NewAlphabet(encoding.PrefixCharacters) + + var encoder *encoding.Base62Encoder = encoding.NewBase62Encoder(base62Alphabet) + var checksumCalculator *checksum.Crc32Calculator = checksum.NewCrc32Calculator(encoder) + var entropyGenerator *entropy.SecureGenerator = entropy.NewSecureGenerator(base62Alphabet) + + var generator *token.TokenGenerator = token.NewTokenGenerator(prefixAlphabet, entropyGenerator, checksumCalculator) + var validator *token.TokenValidator = token.NewTokenValidator(prefixAlphabet, checksumCalculator) + + return &Forge{ + generator: generator, + validator: validator, + } +} + +// Generate runs the full generation pipeline for the given semantic identifiers and +// returns the rendered token string. +func (forge *Forge) Generate(systemIdentifier string, environmentIdentifier string, domainPurposeIdentifier string) (string, error) { + var generated *token.Token + var generationError error + generated, generationError = forge.generator.Generate(systemIdentifier, environmentIdentifier, domainPurposeIdentifier) + + if generationError != nil { + return "", generationError + } + + return generated.String(), nil +} + +// Validate runs the full validation pipeline against the raw token and returns the +// reified security context when the token is structurally and cryptographically sound. +func (forge *Forge) Validate(rawToken string) (*SecurityContext, error) { + var validated *token.Token + var validationError error + validated, validationError = forge.validator.Validate(rawToken) + + if validationError != nil { + return nil, validationError + } + + return newSecurityContext(validated.SystemIdentifier(), validated.EnvironmentIdentifier(), validated.DomainPurposeIdentifier()), nil +} diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..15835bf --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module go.leoweyr.com/tokenforge/go + +go 1.26.2 diff --git a/go/internal/checksum/calculator.go b/go/internal/checksum/calculator.go new file mode 100644 index 0000000..fe74c82 --- /dev/null +++ b/go/internal/checksum/calculator.go @@ -0,0 +1,32 @@ +package checksum + +import ( + "hash/crc32" + + "go.leoweyr.com/tokenforge/go/internal/encoding" +) + +// Calculator maps a token base string to its fixed-width Base62 tail checksum. +type Calculator interface { + Calculate(baseString string) *Checksum +} + +// Crc32Calculator derives the tail checksum from the CRC32-IEEE value of a base +// string encoded as Base62. +type Crc32Calculator struct { + encoder *encoding.Base62Encoder +} + +// NewCrc32Calculator builds a Crc32Calculator backed by the given Base62 encoder. +func NewCrc32Calculator(encoder *encoding.Base62Encoder) *Crc32Calculator { + return &Crc32Calculator{encoder: encoder} +} + +// Calculate computes the CRC32-IEEE value of the base string interpreted as a UTF-8 +// byte stream and encodes it as a fixed-width Base62 checksum. +func (crc32Calculator *Crc32Calculator) Calculate(baseString string) *Checksum { + var value uint32 = crc32.ChecksumIEEE([]byte(baseString)) + var encoded string = crc32Calculator.encoder.Encode(value, Length) + + return NewChecksum(encoded) +} diff --git a/go/internal/checksum/checksum.go b/go/internal/checksum/checksum.go new file mode 100644 index 0000000..3267d10 --- /dev/null +++ b/go/internal/checksum/checksum.go @@ -0,0 +1,31 @@ +package checksum + +// Length is the fixed number of Base62 characters in the tail checksum segment, +// wide enough to encode any 32-bit CRC value without truncation. +const Length int = 6 + +// Checksum is the fixed-width Base62 tail segment encoding the CRC32 integrity +// value of a token base string. +type Checksum struct { + value string +} + +// NewChecksum wraps the given fixed-width Base62 string as a Checksum value. +func NewChecksum(value string) *Checksum { + return &Checksum{value: value} +} + +// Value returns the raw checksum characters. +func (checksum *Checksum) Value() string { + return checksum.value +} + +// Equals reports whether this checksum carries the same characters as the other checksum. +func (checksum *Checksum) Equals(other *Checksum) bool { + return checksum.value == other.value +} + +// String returns the raw checksum characters. +func (checksum *Checksum) String() string { + return checksum.value +} diff --git a/go/internal/encoding/alphabet.go b/go/internal/encoding/alphabet.go new file mode 100644 index 0000000..94caf4d --- /dev/null +++ b/go/internal/encoding/alphabet.go @@ -0,0 +1,64 @@ +package encoding + +// Base62Characters is the canonical 62-symbol GMP dictionary ordered by ascending +// ASCII code, used for high-intensity entropy and tail checksum encoding. +const Base62Characters string = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// PrefixCharacters is the restricted symbol set permitted inside prefix semantic +// components, limited to lowercase ASCII letters and decimal digits only. +const PrefixCharacters string = "0123456789abcdefghijklmnopqrstuvwxyz" + +// Alphabet is an immutable ordered symbol set that maps between character values +// and their positional indices. +type Alphabet struct { + characters string + indexByCharacter map[byte]int +} + +// NewAlphabet builds an Alphabet from the given ordered character set and indexes +// every symbol for constant-time membership and lookup queries. +func NewAlphabet(characters string) *Alphabet { + var indexByCharacter map[byte]int = make(map[byte]int, len(characters)) + + var position int + + for position = 0; position < len(characters); position++ { + indexByCharacter[characters[position]] = position + } + + return &Alphabet{ + characters: characters, + indexByCharacter: indexByCharacter, + } +} + +// Size returns the number of symbols contained in the alphabet. +func (alphabet *Alphabet) Size() int { + return len(alphabet.characters) +} + +// CharacterAt returns the symbol located at the given positional index. +func (alphabet *Alphabet) CharacterAt(index int) byte { + return alphabet.characters[index] +} + +// Contains reports whether the given character belongs to the alphabet. +func (alphabet *Alphabet) Contains(character byte) bool { + var present bool + _, present = alphabet.indexByCharacter[character] + + return present +} + +// Permits reports whether every character in the text belongs to the alphabet. +func (alphabet *Alphabet) Permits(text string) bool { + var position int + + for position = 0; position < len(text); position++ { + if !alphabet.Contains(text[position]) { + return false + } + } + + return true +} diff --git a/go/internal/encoding/base62_encoder.go b/go/internal/encoding/base62_encoder.go new file mode 100644 index 0000000..c44f54c --- /dev/null +++ b/go/internal/encoding/base62_encoder.go @@ -0,0 +1,30 @@ +package encoding + +// Base62Encoder converts unsigned integers into fixed-width Base62 strings using a +// right-to-left modulo loop that yields natural front zero-padding. +type Base62Encoder struct { + base62Alphabet *Alphabet +} + +// NewBase62Encoder builds a Base62Encoder bound to the given Base62 alphabet. +func NewBase62Encoder(base62Alphabet *Alphabet) *Base62Encoder { + return &Base62Encoder{base62Alphabet: base62Alphabet} +} + +// Encode renders the given value as a Base62 string of exactly the requested width, +// filling positions from the least significant digit backward. +func (base62Encoder *Base62Encoder) Encode(value uint32, width int) string { + var radix uint32 = uint32(base62Encoder.base62Alphabet.Size()) + var characters []byte = make([]byte, width) + var remaining uint32 = value + + var position int + + for position = width - 1; position >= 0; position-- { + var remainder uint32 = remaining % radix + characters[position] = base62Encoder.base62Alphabet.CharacterAt(int(remainder)) + remaining = remaining / radix + } + + return string(characters) +} diff --git a/go/internal/entropy/entropy.go b/go/internal/entropy/entropy.go new file mode 100644 index 0000000..62586e9 --- /dev/null +++ b/go/internal/entropy/entropy.go @@ -0,0 +1,30 @@ +package entropy + +// Length is the fixed number of Base62 characters in the high-intensity entropy +// segment, sized to deliver roughly 143 bits of randomness. +const Length int = 24 + +// Entropy is the fixed-width high-intensity random segment of a token. +type Entropy struct { + value string +} + +// NewEntropy wraps the given fixed-width random string as an Entropy value. +func NewEntropy(value string) *Entropy { + return &Entropy{value: value} +} + +// Value returns the raw entropy characters. +func (entropy *Entropy) Value() string { + return entropy.value +} + +// Length returns the number of characters in the entropy segment. +func (entropy *Entropy) Length() int { + return len(entropy.value) +} + +// String returns the raw entropy characters. +func (entropy *Entropy) String() string { + return entropy.value +} diff --git a/go/internal/entropy/generator.go b/go/internal/entropy/generator.go new file mode 100644 index 0000000..6cb5b08 --- /dev/null +++ b/go/internal/entropy/generator.go @@ -0,0 +1,58 @@ +package entropy + +import ( + "crypto/rand" + + "go.leoweyr.com/tokenforge/go/internal/encoding" +) + +// Generator produces a fresh high-intensity entropy segment on demand. +type Generator interface { + Generate() (*Entropy, error) +} + +// SecureGenerator draws entropy from an operating-system CSPRNG and applies +// rejection sampling to guarantee an unbiased uniform symbol distribution. +type SecureGenerator struct { + base62Alphabet *encoding.Alphabet +} + +// NewSecureGenerator builds a SecureGenerator bound to the given Base62 alphabet. +func NewSecureGenerator(base62Alphabet *encoding.Alphabet) *SecureGenerator { + return &SecureGenerator{base62Alphabet: base62Alphabet} +} + +// Generate produces a fixed-width entropy segment by drawing unbiased indices from the bound alphabet. +// A 32-byte bulk read reduces CSPRNG syscall overhead to O(1). +func (secureGenerator *SecureGenerator) Generate() (*Entropy, error) { + var characters []byte = make([]byte, Length) + var size int = secureGenerator.base62Alphabet.Size() + var ceiling int = 256 - (256 % size) + var buffer [32]byte + var bufferPosition int = len(buffer) + + var position int = 0 + + for position < Length { + if bufferPosition >= len(buffer) { + var readError error + _, readError = rand.Read(buffer[:]) + + if readError != nil { + return nil, readError + } + + bufferPosition = 0 + } + + var candidate int = int(buffer[bufferPosition]) + bufferPosition++ + + if candidate < ceiling { + characters[position] = secureGenerator.base62Alphabet.CharacterAt(candidate % size) + position++ + } + } + + return NewEntropy(string(characters)), nil +} diff --git a/go/internal/fault/validation_error.go b/go/internal/fault/validation_error.go new file mode 100644 index 0000000..64d7821 --- /dev/null +++ b/go/internal/fault/validation_error.go @@ -0,0 +1,17 @@ +package fault + +// ValidationError represents a structural or integrity failure detected while a +// token is checked against the Tokenforge specification. +type ValidationError struct { + reason string +} + +// NewValidationError creates a ValidationError carrying the given human-readable reason. +func NewValidationError(reason string) *ValidationError { + return &ValidationError{reason: reason} +} + +// Error returns the underlying failure reason and satisfies the error interface. +func (validationError *ValidationError) Error() string { + return validationError.reason +} diff --git a/go/internal/token/generator.go b/go/internal/token/generator.go new file mode 100644 index 0000000..ad2475a --- /dev/null +++ b/go/internal/token/generator.go @@ -0,0 +1,75 @@ +package token + +import ( + "go.leoweyr.com/tokenforge/go/internal/checksum" + "go.leoweyr.com/tokenforge/go/internal/encoding" + "go.leoweyr.com/tokenforge/go/internal/entropy" + "go.leoweyr.com/tokenforge/go/internal/fault" +) + +// TokenGenerator orchestrates the full generation pipeline, turning a set of +// semantic identifiers into a verified, fully assembled token. +type TokenGenerator struct { + prefixAlphabet *encoding.Alphabet + entropyGenerator entropy.Generator + checksumCalculator checksum.Calculator +} + +// NewTokenGenerator wires a TokenGenerator to its prefix alphabet, entropy source, +// and checksum calculator. +func NewTokenGenerator(prefixAlphabet *encoding.Alphabet, entropyGenerator entropy.Generator, checksumCalculator checksum.Calculator) *TokenGenerator { + return &TokenGenerator{ + prefixAlphabet: prefixAlphabet, + entropyGenerator: entropyGenerator, + checksumCalculator: checksumCalculator, + } +} + +// validateComponent rejects an empty semantic identifier or one carrying any +// character outside the permitted prefix alphabet. +func (tokenGenerator *TokenGenerator) validateComponent(label string, value string) error { + if len(value) == 0 { + return fault.NewValidationError(label + " identifier must not be empty") + } + + if !tokenGenerator.prefixAlphabet.Permits(value) { + return fault.NewValidationError(label + " identifier contains a character outside the permitted set") + } + + return nil +} + +// Generate validates the supplied identifiers, draws unbiased entropy, fuses the +// base string, maps the checksum, and returns the assembled token. +func (tokenGenerator *TokenGenerator) Generate(systemIdentifier string, environmentIdentifier string, domainPurposeIdentifier string) (*Token, error) { + var systemError error = tokenGenerator.validateComponent("System", systemIdentifier) + + if systemError != nil { + return nil, systemError + } + + var environmentError error = tokenGenerator.validateComponent("Environment", environmentIdentifier) + + if environmentError != nil { + return nil, environmentError + } + + var domainError error = tokenGenerator.validateComponent("Domain purpose", domainPurposeIdentifier) + + if domainError != nil { + return nil, domainError + } + + var entropySegment *entropy.Entropy + var generationError error + entropySegment, generationError = tokenGenerator.entropyGenerator.Generate() + + if generationError != nil { + return nil, generationError + } + + var baseString string = assembleBaseString(systemIdentifier, environmentIdentifier, domainPurposeIdentifier, entropySegment) + var checksumSegment *checksum.Checksum = tokenGenerator.checksumCalculator.Calculate(baseString) + + return NewToken(systemIdentifier, environmentIdentifier, domainPurposeIdentifier, entropySegment, checksumSegment), nil +} diff --git a/go/internal/token/token.go b/go/internal/token/token.go new file mode 100644 index 0000000..ef03e1c --- /dev/null +++ b/go/internal/token/token.go @@ -0,0 +1,84 @@ +package token + +import ( + "go.leoweyr.com/tokenforge/go/internal/checksum" + "go.leoweyr.com/tokenforge/go/internal/entropy" +) + +// Separator is the single-byte delimiter placed between prefix semantic components +// and between the prefix and the entropy segment. +const Separator string = "_" + +// PrefixComponentCount is the exact number of semantic identifiers required inside +// a well-formed prefix. +const PrefixComponentCount int = 3 + +// MinimumPrefixLength is the shortest legal prefix portion, reached when each of the +// three semantic components holds a single character (Plus the three delimiters). +const MinimumPrefixLength int = PrefixComponentCount * 2 + +// MinimumLength is the shortest legal token, derived from the credential topology +// rather than any hard-coded magic number. +const MinimumLength int = MinimumPrefixLength + entropy.Length + checksum.Length + +// assembleBaseString concatenates the three prefix identifiers, separators, and the +// entropy segment into the canonical base string used for checksum mapping. +func assembleBaseString(systemIdentifier string, environmentIdentifier string, domainPurposeIdentifier string, entropy *entropy.Entropy) string { + return systemIdentifier + Separator + environmentIdentifier + Separator + domainPurposeIdentifier + Separator + entropy.Value() +} + +// Token is a fully assembled credential storing the three prefix semantic identifiers, +// a high-intensity entropy segment, and a tail checksum. +type Token struct { + systemIdentifier string + environmentIdentifier string + domainPurposeIdentifier string + entropy *entropy.Entropy + checksum *checksum.Checksum +} + +// NewToken composes a Token from its three prefix identifiers, entropy, and checksum. +func NewToken(systemIdentifier string, environmentIdentifier string, domainPurposeIdentifier string, entropy *entropy.Entropy, checksum *checksum.Checksum) *Token { + return &Token{ + systemIdentifier: systemIdentifier, + environmentIdentifier: environmentIdentifier, + domainPurposeIdentifier: domainPurposeIdentifier, + entropy: entropy, + checksum: checksum, + } +} + +// SystemIdentifier returns the system identifier. +func (token *Token) SystemIdentifier() string { + return token.systemIdentifier +} + +// EnvironmentIdentifier returns the environment identifier. +func (token *Token) EnvironmentIdentifier() string { + return token.environmentIdentifier +} + +// DomainPurposeIdentifier returns the domain purpose identifier. +func (token *Token) DomainPurposeIdentifier() string { + return token.domainPurposeIdentifier +} + +// Entropy returns the high-intensity entropy segment. +func (token *Token) Entropy() *entropy.Entropy { + return token.entropy +} + +// Checksum returns the tail checksum segment. +func (token *Token) Checksum() *checksum.Checksum { + return token.checksum +} + +// BaseString returns the checksum-free base string (prefix + entropy, joined by separators). +func (token *Token) BaseString() string { + return assembleBaseString(token.systemIdentifier, token.environmentIdentifier, token.domainPurposeIdentifier, token.entropy) +} + +// String renders the complete token. +func (token *Token) String() string { + return token.BaseString() + token.checksum.Value() +} diff --git a/go/internal/token/validator.go b/go/internal/token/validator.go new file mode 100644 index 0000000..aa27302 --- /dev/null +++ b/go/internal/token/validator.go @@ -0,0 +1,119 @@ +package token + +import ( + "strings" + + "go.leoweyr.com/tokenforge/go/internal/checksum" + "go.leoweyr.com/tokenforge/go/internal/encoding" + "go.leoweyr.com/tokenforge/go/internal/entropy" + "go.leoweyr.com/tokenforge/go/internal/fault" +) + +// TokenValidator orchestrates the validation pipeline: a structural guard, an +// asymmetric checksum slice, idempotent integrity verification, and context +// reification into the token's three prefix identifiers. +type TokenValidator struct { + prefixAlphabet *encoding.Alphabet + checksumCalculator checksum.Calculator +} + +// NewTokenValidator wires a TokenValidator to its prefix alphabet and checksum calculator. +func NewTokenValidator(prefixAlphabet *encoding.Alphabet, checksumCalculator checksum.Calculator) *TokenValidator { + return &TokenValidator{ + prefixAlphabet: prefixAlphabet, + checksumCalculator: checksumCalculator, + } +} + +// verifyChecksum re-derives the expected checksum from the base string and rejects +// the token when it diverges from the provided checksum. +func (tokenValidator *TokenValidator) verifyChecksum(baseString string, provided *checksum.Checksum) error { + var expected *checksum.Checksum = tokenValidator.checksumCalculator.Calculate(baseString) + + if !expected.Equals(provided) { + return fault.NewValidationError("Token checksum does not match its base string") + } + + return nil +} + +// validateComponent rejects an empty identifier or one bearing a character outside +// the permitted prefix alphabet. +func (tokenValidator *TokenValidator) validateComponent(componentName string, value string) error { + if len(value) == 0 { + return fault.NewValidationError("Token prefix " + componentName + " component is empty") + } + + if !tokenValidator.prefixAlphabet.Permits(value) { + return fault.NewValidationError("Token prefix " + componentName + " component contains a character outside the permitted set") + } + + return nil +} + +// reifyContext splits the prefix portion into its three semantic identifiers, +// enforcing the exact component count and the permitted prefix alphabet. +func (tokenValidator *TokenValidator) reifyContext(prefixPortion string) (string, string, string, error) { + if !strings.HasSuffix(prefixPortion, Separator) { + return "", "", "", fault.NewValidationError("Token prefix is not terminated by a separator") + } + + var core string = prefixPortion[:len(prefixPortion)-len(Separator)] + var components []string = strings.Split(core, Separator) + + if len(components) != PrefixComponentCount { + return "", "", "", fault.NewValidationError("Token prefix does not contain exactly three semantic components") + } + + var systemError error = tokenValidator.validateComponent("system", components[0]) + + if systemError != nil { + return "", "", "", systemError + } + + var environmentError error = tokenValidator.validateComponent("environment", components[1]) + + if environmentError != nil { + return "", "", "", environmentError + } + + var domainPurposeError error = tokenValidator.validateComponent("domain purpose", components[2]) + + if domainPurposeError != nil { + return "", "", "", domainPurposeError + } + + return components[0], components[1], components[2], nil +} + +// Validate enforces the structural guard, performs the asymmetric checksum slice, +// verifies integrity idempotently, and reifies the semantic context into a token. +func (tokenValidator *TokenValidator) Validate(rawToken string) (*Token, error) { + if len(rawToken) < MinimumLength { + return nil, fault.NewValidationError("Token is shorter than the minimum derived length") + } + + var baseString string = rawToken[:len(rawToken)-checksum.Length] + var providedChecksum *checksum.Checksum = checksum.NewChecksum(rawToken[len(rawToken)-checksum.Length:]) + + var verificationError error = tokenValidator.verifyChecksum(baseString, providedChecksum) + + if verificationError != nil { + return nil, verificationError + } + + var prefixPortion string = baseString[:len(baseString)-entropy.Length] + var entropyValue string = baseString[len(baseString)-entropy.Length:] + + var system string + var environment string + var domain string + var reificationError error + system, environment, domain, reificationError = tokenValidator.reifyContext(prefixPortion) + + if reificationError != nil { + return nil, reificationError + } + + return NewToken(system, environment, domain, entropy.NewEntropy(entropyValue), providedChecksum), nil +} diff --git a/go/security_context.go b/go/security_context.go new file mode 100644 index 0000000..1b7dafe --- /dev/null +++ b/go/security_context.go @@ -0,0 +1,40 @@ +package tokenforge + +import "strings" + +// SecurityContext exposes the three plaintext semantic identifiers reified from a +// validated token: the system, the environment, and the domain purpose. +type SecurityContext struct { + systemIdentifier string + environmentIdentifier string + domainPurposeIdentifier string +} + +// newSecurityContext assembles a SecurityContext from its three semantic identifiers. +func newSecurityContext(systemIdentifier string, environmentIdentifier string, domainIdentifier string) *SecurityContext { + return &SecurityContext{ + systemIdentifier: systemIdentifier, + environmentIdentifier: environmentIdentifier, + domainPurposeIdentifier: domainIdentifier, + } +} + +// SystemIdentifier returns the system identifier. +func (securityContext *SecurityContext) SystemIdentifier() string { + return securityContext.systemIdentifier +} + +// EnvironmentIdentifier returns the environment identifier. +func (securityContext *SecurityContext) EnvironmentIdentifier() string { + return securityContext.environmentIdentifier +} + +// DomainPurposeIdentifier returns the domain purpose identifier. +func (securityContext *SecurityContext) DomainPurposeIdentifier() string { + return securityContext.domainPurposeIdentifier +} + +// String renders the three identifiers joined by underscores. +func (securityContext *SecurityContext) String() string { + return strings.Join([]string{securityContext.systemIdentifier, securityContext.environmentIdentifier, securityContext.domainPurposeIdentifier}, "_") +}