README
¶
ky-primitives
Shared security primitives for the Busness.app suite.
The offsite/ directory is a separately versioned nested module. Its transport
dependencies are not requirements of this root module.
- Contributing — how changes land, the test policy and the coding standard.
- Verifying releases — check a module version against this repository and the Go checksum database.
- Assurance case — threat model, trust boundaries, and why the security requirements are met.
The root module's dependency budget is golang.org/x/crypto and the golang.org/x/sys it drags in,
and nothing else. In that module, everything but password is standard-library-only. The rule was "no
dependencies, ever" so that products with minimal trees could import this for free —
kypassword-server's go.mod named no third-party module at all, and now names exactly
one: this library — and Argon2 is the one thing that broke it: the suite standardised on it for password hashing, and it is neither in the
standard library nor on a proposal track.
Two tests hold that line. TestModuleDependenciesAreAllowlisted fails on any require
outside the budget; TestOnlyPasswordImportsADependency discovers every other package and
fails if any of them imports one. A consumer that only wants capsule compiles none of
x/crypto, but it does inherit the requirement in its own go.mod — that is the cost.
go list -m all names five golang.org/x modules because they sit in x/crypto's own
requirement graph. Only two reach go.sum, and the build compiles three packages:
x/sys/cpu, x/crypto/blake2b and x/crypto/argon2.
A primitive belongs here when the suite's copies of it disagree in a way that silently corrupts or loses recovery data. Duplicated HTTP, session and header code across the suite is real security debt, but it is per-product configuration rather than a durable format, so it is fixed in place rather than moved here.
capsule
Reads and writes the suite's encrypted backup container.
kycap/3: a JSON object holding the manifest, a base64 ciphertext, and nothing else. The
payload is sealed to the suite recovery public key through HPKE — X-Wing (ML-KEM-768 with
X25519), HKDF-SHA256, AES-256-GCM — so Seal returns no key and a product that seals a
backup holds nothing afterwards that opens it. The public key is not secret, so a
successful Open proves the container is unmodified and was sealed to this key — not who
sealed it, and not that it is the newest backup; origin and freshness come from
kyrecovery's authenticated deposit record, and a restore must check service_name,
capsule_id and created_at against what it expects. The manifest is bound into the
AEAD, so every field describing the capsule is authenticated rather than merely present,
and it is carried and authenticated as the exact bytes that were read, not a re-encoding.
The manifest carries what identifies a capsule — capsule_id, service_name,
created_at, payload_hash, the recovery topology, and two fields that name the key:
recovery_key_id (the hex SHA-256 of the recovery public key) and encapsulated_key (the
HPKE encapsulation, 1120 bytes base64). Neither is secret; both are inside the AAD.
kyrecovery reads the key ID without any key to display it and to refuse a deposit sealed to
a key it did not hand out. The per-member list of paths, sizes and SHA-256 digests travels
inside the encrypted payload, as a reserved member, because the manifest is stored in the
clear and a per-member digest read without the key is an offline confirmation oracle.
Open fills Manifest.Files after the payload hash verifies; ReadUnverifiedManifest
returns none.
Open compares the manifest's recovery_key_id with the key it was given before
decapsulating anything, and fails with ErrWrongRecoveryKey: the custodians brought the
wrong kit. That compare is a courtesy on unauthenticated data; the AEAD is the check, and a
forged ID that matches the wrong key still fails there.
Three containers came before it and all are retired unread:
| Container | Was written by | Why it is gone |
|---|---|---|
kycap/1 |
kysignon-server |
Authenticated its ciphertext and nothing else |
| tar | kyrecovery-server |
Authenticated its ciphertext and its own aad string, not the rest of the manifest |
kycap/2 |
this package, before v0.4.0 |
Authenticated the manifest, then handed the caller a raw key to protect and split per capsule |
In the first two, capsule_id, service_name, threshold, total_shares and the
verification recipe were rewritable by anyone who could reach the file — a 2-of-3 kit
could be restated as 1-of-1 and still open. In the third, every backup was a fresh key and
so a fresh custodian ceremony, which no one runs nightly, so the key ended up stored next
to the data it protected. Nothing is in the wild, so none of the three is read.
Seal refuses a kit that cannot exist — threshold below 2, above totalShares, or a
total past 255 — because a manifest that records recovery topology without checking it
sends a custodian looking for shares that were never issued.
raw, m, err := capsule.Seal(name, version, files, nil, nil, 2, 3, pub) // pub: recoverykey.PublicKey
m, files, err := capsule.Open(raw, priv, "/var/restore") // priv: recoverykey.PrivateKey
m, files, err := capsule.Open(raw, priv, "") // decode only, writes nothing
u, err := capsule.ReadUnverifiedManifest(raw) // no key; show, do not decide
Open returns the manifest because a successful Open is the proof that the container was
not modified after sealing and was sealed to this recovery key — nothing more; see
capsule.Open's doc comment for what that does and does not prove. Seal returns one too:
capsule_id, created_at, payload_hash and
encapsulated_key are minted inside Seal and have no other source.
ReadUnverifiedManifest returns a different type, UnverifiedManifest, so the compiler
stops it reaching anything that decides on it.
errors.Is a failed Open against ErrWrongRecoveryKey for the wrong kit, against
ErrUnknownContainer for a retired or foreign format, and against ErrCorruptCapsule for
a malformed container; an AEAD failure wraps none of them.
The ciphertext field is standard base64 in and out.
Extraction hardening
The payload decrypts to a gzipped tar, extracted through this hardened path. It is ported
from kysignon-server, which carried the strongest version in the suite — stronger than
the checks kyrecovery-server's own Unpack ever applied to its own format, now retired.
Extraction runs through an os.Root handle, so every component resolves against a
directory descriptor and no member can name a location outside the target. Containment
used to be a string comparison plus Lstat on each parent, which a process able to swap a
checked parent for a symlink could step through — O_NOFOLLOW only guards the last
component. Go picks openat2 where the kernel has it, so this carries no minimum kernel of
its own. A failed extraction rolls back whatever it wrote, because the target must be empty
to start and a half-restored keyset otherwise poisons every retry.
Refused: path traversal, absolute paths, backslash separators, NUL bytes, symlinks,
hardlinks, directories, device nodes, FIFOs, two members that normalise to one
destination, archives over 256 MiB expanded (the tar stream, so per-member headers and
padding count, and Seal measures the same bytes), members over 64 MiB, more than 4096
files, and any restore into a non-empty directory. File modes are clamped to owner-only — a
restored capsule carries signing keys, and an archive header is attacker-controlled.
The two size numbers are memory budgets rather than archive sizes, because Open holds
the raw container, the decrypted payload and every expanded member at once. They are
capsule.MaxFiles, capsule.MaxFileBytes and capsule.MaxExpandedBytes (and
capsule.MaxContainerBytes, the cap on a container before it is parsed), the
values capsule/extract.go enforces, exported so a consumer can name the limit it hit;
raising them is what a streaming Open is for.
Every refusal in that list is covered by a test in capsule/hardening_test.go or
capsule/review_test.go, with one exception: the 256 MiB expansion budget and the 64 MiB
member ceiling are exercised on the Seal side only, and capsule/limits_test.go asserts
the constants rather than the refusal. An extraction-side test for both is outstanding.
Fixtures
testdata/capsules/ holds one kycap/3 capsule with the recovery seed that opens it, and
under retired/ the last kycap/2 capsule, kept so Open's refusal is measured against a
real container. See the README there for why the seed is committed.
recoverykey
The suite's recovery keypair: one public key every product seals its backups to, and the private key that exists only while it is being split into custodian shares and while a restore combines them.
The KEM is X-Wing (ML-KEM-768 with X25519) through Go's crypto/hpke. A backup is the
artefact most likely to still matter when a recorded ciphertext is attacked, so this is
the one place the library pays for post-quantum security. Every crypto/hpke KEM rebuilds
its private key from a 32-byte seed, and the seed is the only thing this package hands to
shamir: a custodian card carries 32 bytes whatever the KEM.
priv, err := recoverykey.Generate() // once, in kyrecovery's ceremony
shares, err := recoverykey.Split(priv, 3, 5) // shamir shares of the seed; print the cards, then destroy the host
pub := priv.Public() // hand pub.Bytes() to every product; pin pub.ID()
pub, err := recoverykey.ParsePublicKey(b) // in a product, from keyfile.Load
priv, err := recoverykey.Combine(shares) // at restore, from k custodian cards
Generate is called on exactly one host, once, and that host holds the seed in memory
until Split returns. That is the one place in the suite the recovery private key exists
outside custodian cards. Go cannot reliably erase it — value copies, the HPKE key's own
state and the garbage collector keep it recoverable from a core dump or a swap page for
the process lifetime — so the ceremony code must never log, persist or return it, and the
ceremony runs on a dedicated ephemeral host with swap off and core dumps disabled,
destroyed after Split returns.
ID() is the hex SHA-256 of the public key. It is what a capsule names, what kyrecovery
pins per product, and what a custodian writes on a card. FromSeed refuses any length but
32; ParsePublicKey refuses any length but 1216. Pinned to the draft-ietf-hpke-pq X-Wing
vector: that seed produces that public key, or the package is not X-Wing.
shamir
Splits a secret into custodian shares and reconstructs it.
The suite carried two implementations over two different GF(2^8) fields — 0x11d in
ky_server_base, gridlock-server and kysignon-server, 0x11b in kyrecovery-server.
63232 of the 65536 products in their multiplication tables disagree, so shares written by
one reconstruct a different secret through the other, with a nil error.
It stayed hidden because shares 1, 2 and 3 make every Lagrange coefficient 1. The combine
degenerates to XOR and agrees in any field — and those are the shares a round-trip test
reaches for first. TestRoundTripEveryIndexSubset covers all ten 3-of-5 subsets for that
reason.
This package uses 0x11b, the AES field, pinned by golden vectors derived by hand rather than read off the implementation. Nothing was in the wild, so the choice was free.
Arithmetic is branch-free and table-free. Both predecessors indexed exp/log tables with share bytes, which are the secret; this one multiplies with shifts and masks and inverts by exponentiation, so no memory access depends on a secret value. A split is not a hot path.
The 0x11d copies also had three ways to fail on a malformed share, all reachable from
kyrestore's repeated -shard flag — a custodian retrying mid-disaster:
Input to Combine |
0x11d copies | here |
|---|---|---|
| Same share twice | panic, divide by zero in GF(256) |
ErrDuplicateIndex |
| Truncated share | panic, index out of range | ErrShareLength |
| Share at index 0 | all-zero secret, nil error | ErrShareIndex |
FuzzCombineNeverPanics holds that line. Distinct non-zero indices make every Lagrange
denominator non-zero, so no input can divide by zero however malformed.
Split resamples a zero leading coefficient. Without it the polynomial's degree drops and
fewer shares than promised recover that byte, once every 256 draws.
shares, err := shamir.Split(key, 3, 5) // any 3 of 5
secret, err := shamir.Combine(shares) // every share given is used
card := shares[0].String() // ky2-3-9f2a71c4...-1-a1b2...-7d1e
A share is self-describing: ky2-<threshold>-<set id>-<index>-<value>-<check>. The share
used to be index-hex and nothing else, which meant it could not defend against any of the
four ways a custodian gets this wrong. Each field is there for one of them.
| Field | Refuses |
|---|---|
ky2 |
A share from the suite's 0x11d implementations, which was byte-indistinguishable and reconstructed a different secret with no error |
| threshold | Combining fewer shares than the split needs — previously a silent wrong answer |
| set id | Mixing shares from two different splits — also previously silent |
| check | A character mistyped off the card, before it becomes a wrong secret |
Combine still uses every share it is given; passing more than the threshold is correct.
Bind a hash of the plaintext alongside anyway — capsule's payload hash is that check.
The set id is 128 bits. The first version of this format carried 32, which reached even odds of a collision after about 65,000 splits — a number a deployment issuing recovery kits reaches — and a collision means the check the field exists for silently does not happen. The narrow format is not parsed: nothing outside this package ever wrote a share, so there are no cards to strand, and a narrow id accepted "for compatibility" is the same collision still reachable.
Every share must declare a threshold of 2..255. It used to be enforced only above zero,
which is what an absent field decodes to, so a Share filled in by a deserialiser landed
in the unchecked mode by default.
auditchain
Builds and verifies a tamper-evident hash chain over audit records.
Three implementations existed, with three tuple layouts and three key policies:
kypassword-server |
kybookmarks-server |
kyrecovery-server |
|
|---|---|---|---|
| Key | generated, ≥32 bytes enforced | a literal survives on the verify/convert path; the write path has no fallback | HKDF from the keyring |
| Truncation | anchor beside the key | undetectable | sequence numbers in the DB |
| Fields | joined on bare | |
joined on bare | |
details pre-hashed |
kybookmarks-server/internal/audit/audit.go:50 keeps a literal,
"kybookmarks-default-hmac-secret", and its write path no longer reaches it —
loadOrCreateKey has no constant fallback. What survives is narrower: the literal is what
legacyHash verifies v0 entries against, so it can still recognise a log an attacker
authored under it. It can no longer launder one. converge re-mints under the real key
only when the high-water mark attests to that exact log — same record count and same tail
hash — so a forged log is either refused by the mark or left in the old format for
Resume's digest check to reject. Only a genuine first boot, with no mark at all and every
entry v: 0, converts. The key floor is 32 bytes, and on the write path — the one that
mints and checks new records — there is no fallback at all.
Every field is length-prefixed. Joining on a delimiter lets a field containing it shift
into its neighbour and produce another record's digest, forging a record without the key;
TestFieldsAreUnambiguousAcrossDelimiters pins that.
Persistence is a parameter of Append, not the caller's next statement. The chain's
in-memory head is a claim about what is on disk: advancing it first meant a failed insert
left the next record chained onto one that never existed, and a record stored without its
anchor left the opposite inconsistency, with no way to roll back either. persist runs
under the lock that reserves the sequence number, and the chain advances only if it
returns nil.
Verify requires the Anchor — a count and head hash kept outside the log. Hashes alone
cannot catch a log with records removed from the end, because what remains still chains
correctly, so the anchor is a parameter rather than an option.
Resume requires it too. A valid digest does not make a record the tail — every record in
a healthy chain carries one — so resuming from the middle was accepted and the next append
minted a sequence number that already existed. The anchor is the only thing that knows
where the end is. Sequence 0 and MaxUint64 are refused: Append starts at one, and
minting count+1 from the top wraps to zero.
Append takes a context.Context, which bounds the wait for the chain lock, not
persist. persist still runs under the lock — the record and its anchor have to be written together — so give it its own
timeout, and do not call back into the Chain from it: the lock is not reentrant, and the
anchor it would ask for is already one of its arguments.
chain, err := auditchain.New(key) // or Resume(key, lastRecord, anchor)
rec, err := chain.Append(ctx, func(r auditchain.Record, a auditchain.Anchor) error {
return store.WriteRecordAndAnchor(r, a) // one transaction
}, "login", user)
err = auditchain.Verify(key, records, anchor)
err = auditchain.VerifyStream(key, rows, anchor) // same, for a log too large to hold
VerifyRecord asks whether a record carries its own digest, and nothing about where it
sits. It also refuses a record numbered zero or carrying a malformed hash, because Append
mints neither — a legacy log numbered from zero is exactly what a migration probe hands it.
Resume answers a different question — is this the tail — and needs the anchor to do
it. A conversion probe wants the first.
Replay builds a chain in memory from field tuples and hands back the records with their
final anchor, for a bulk rewrite that writes the log once. Append's persist parameter
means the chain advances only when the store agrees; passing one that does nothing per
record would satisfy the signature and mean the opposite.
Errors worth an errors.Is in a caller: ErrWeakKey, ErrBrokenChain.
Chains in this suite reach six figures, and the bug that follows from paging them is
specific: kyrecovery-server's VerifyChain read a fixed 100000 events and then reported a
sequence gap on a perfectly healthy chain. VerifyStream removes the reason to page. A
record yielded with a non-nil error fails the verification rather than ending the walk, so a
store that dies mid-read cannot look like a short chain.
Anchor is {Count, Hash}, which is exactly kypassword-server's audit.state and
kyrecovery-server's ledger.head. KyPassword has migrated onto this package. It needed no
version field: its legacy format was a single keyed HMAC digest, so converge verifies
every entry under that digest and re-mints the whole log under auditchain's in one atomic
rewrite, leaving nothing to distinguish afterwards. The unkeyed variant that preceded it was
deleted rather than carried, because a chain under no secret is one anyone who can write the
log can recompute end to end.
kybookmarks-server is the one with two legacy versions, v: 0 and v: 1, and it converts
the same way: a log is re-minted only when it verifies whole under one of them and the
mark outside the log attests to it.
Fields are opaque and storage is not this package's business. The three implementations
logged different things and wrote to JSON lines, a file and a database; forcing one schema
on them is what made them diverge, and where the bytes went was never the part that broke.
logging
Emits JSON lines on stderr. A collector agent ships them; this package opens no socket, holds no buffer and rotates no file, because an agent survives the process dying and a library buffer does not.
The suite ran five logging architectures across nine servers. Seven of them shipped a
LOGGING.md promising structured, privacy-safe output and one delivered it. Redaction
existed in two products with opposite strategies: kynotes dropped anything outside a
21-key allowlist, kypost redacted values whose key name looked sensitive — a denylist,
which fails silently on a key nobody thought of, and which seventeen of its own call sites
bypassed anyway.
Three things here are why it is in this library rather than fixed per product.
A field value cannot forge a line. Control characters become U+FFFD on the value, before
any renderer sees it, and values cap at 256 bytes. Six products passed values into
log.Printf unescaped and one passed a free-form map[string]any into json.Marshal; a
newline in any of them writes a log line of the attacker's choosing, and a forged line is
indistinguishable from a real one.
A key must be declared. There is no Field(name, value), and values are string,
int64, bool or time.Time rather than any, so a struct holding a token cannot be
logged whole.
An event has one name. Four products spelled the same audit event four ways — user_id
against actor against actorId, and one with positional arguments and no names at all.
lg, err := logging.New(logging.Config{App: "kypassword", Out: os.Stderr})
lg.Security(ctx, logging.AuthFailed, logging.UserID(id), logging.RemoteIP(ip))
if err := lg.Audit(ctx, logging.ShareRedeemed, rec, logging.UserID(id)); err != nil {
// The shipped copy did not land; the local chained file still has it.
}
lg.Handler() is a slog.Handler for products mid-migration. Install it with
slog.SetDefault and existing call sites keep working; attributes outside the vocabulary
are dropped and counted into dropped_fields, so what is lost shows on the line. A
declared key does not save an arbitrary value: slog.Any("user_id", someStruct) drops
even though user_id is declared, because a struct or a map is KindAny or KindGroup,
and a declared name vouches for the key, not for whatever Go value showed up under it —
that is the first thing a migrating call site hits.
Audit lines are flat and carry seq, prev, hash and fields at the top level, so
cmd/kyauditverify needs no code change to verify them — provided the export handed to it
is filtered to the lines that carry hash first. An ordinary Log or Security line has
none of those four keys; it decodes into a zero auditchain.Record with no JSON error, and
VerifyStream reports that as a broken chain, not as a line outside its scope. Audit
recomputes the flat keys from the fields it is given and checks them against the record's
own Fields; a disagreement is caught at emission, not assumed away by construction, and
costs the flat keys — replaced with an audit_fields_mismatch marker — while the record
itself, the part that is authenticated, ships regardless. The product keeps its own local
chained file: a tamper-evident chain that exists only on a machine the attacker may also
own is not evidence.
There is no syslog renderer. log/syslog is frozen, absent on Windows and Plan 9, and
broken on macOS 12 and later. Writing RFC 5424 to stderr instead would double-frame the
line, because the collector already supplies a frame — so severity and facility are
ordinary JSON fields and the frame is the agent's job.
health
Serves /healthz in the suite's one shape, ky.health/1, which kyPulse reads. It is here
rather than fixed per product because the shape is a wire contract: before it, the suite's
health routes disagreed on path, field names, status codes and even content type, so a
monitor needed an adapter per product.
var appendDisabled = health.DeclareReason("append_disabled")
mux.Handle("GET /healthz", health.Handler("kyvault", lg,
health.Check{Name: "database", Run: db.PingContext},
health.Check{Name: "audit", Run: func(ctx context.Context) error {
if auditor.Disabled() {
return health.Degrade(appendDisabled)
}
return nil
}},
))
{"schema":"ky.health/1","service":"kyvault","status":"degraded","time":"2026-09-26T10:41:00Z",
"checks":[{"name":"database","status":"ok"},{"name":"audit","status":"degraded","reason":"append_disabled"}]}
The route is public, so the response is built to be safe to show anyone.
- No error text. A check's error text never reaches the response: a DSN, a path or a
hostname in
err.Error()is the leak this prevents. An ordinary error isdownwith no reason. A check showsdegradedor a reason only by returninghealth.Degrade(r)orhealth.Fail(r), wherercame fromDeclareReason, which panics at startup on a code outside[a-z0-9_]{1,64}. The operator gets the rest on stderr: each non-ok check writes ahealth_check_failedline with the check name, reason andlogging.Errerror kind. - No version, build or uptime. On a public route they fingerprint releases and reveal restart timing. A monitor gets the version from the container image.
- Cached and shared. One evaluation answers every request for
CacheFor(5 s), and concurrent requests share it, so hammering the route cannot hammer the database. The evaluation uses its own context, so one client hanging up cannot cancel the result every other client sees. - Deadlines. A check has
Timeout(default 2 s) and must honour its context. A check past its deadline isdownwith reasontimeout, and a check still running from the previous evaluation is not started again: it reportstimeoutuntil it returns. A panicking check isdown.
The service status is the worst check status; no checks is ok. ok and degraded
return 200, down returns 503, so a readiness probe or a monitor that reads only the code
keeps working. Liveness should stay on a route that checks nothing: restarting the app does
not fix a dependency, so a liveness probe wired to /healthz would restart in a loop while
the dependency is down. HEAD returns the code with no body; other methods return 405.
Response and CheckResult are exported for monitors that decode the body.
password
Hashes and verifies passwords with Argon2id.
The suite ran three algorithms and five parameter sets:
| Repos | Algorithm | Parameters |
|---|---|---|
ky_server_base, gridlock-server |
Argon2id | m=64 MiB, t=1, p=4 |
kysignon-server, kyrecovery-server |
Argon2id | m=64 MiB, t=3, p=4 |
kydns-server |
Argon2id | m=64 MiB, t=3, p=2 |
kynotes-server, kypost-server |
scrypt | N=2^17 |
kybookmarks-server |
scrypt | N=2^15 |
The Argon2 encodings are mutually parseable, so a hash minted at t=1 verified in a t=3 product at a third of the intended attacker cost and nothing flagged it. This package is the one answer: RFC 9106's second recommended profile, m=64 MiB, t=3, p=4, self-describing in PHC form. The first recommended profile asks for 2 GiB, which no login endpoint can afford per request.
A malformed stored hash is an error, never a fallback. ky_server_base and
gridlock-server re-split the string when the parameter segment fails to parse and, if it
merely starts with $argon2id$, verify against their compiled defaults instead. parse
here requires all six PHC segments, the exact algorithm, version 19, and three readable
parameters.
Parameters are bounded on the way in. A stored hash is attacker-controlled wherever the
store is, and m=4294967295 asks for 4 TiB and OOM-kills the process on the next login
rather than failing it. Accepted: 8–256 MiB, t 1–10, p 1–16, salt ≥8 bytes, hash ≥16.
kypost-server bounds its scrypt equivalent for exactly this reason; nothing on the Argon2
side did.
Derivations are bounded and shed rather than queue. Four concurrent slots at 64 MiB caps
derivation memory at 256 MiB. Past a 2-second wait Hash and Verify return ErrBusy, which
callers should answer 503 to and must not spend a lockout strike on — the server is saying
"not now", not the user getting the password wrong. kynotes-server bounds the same way but
blocks forever, so a burst parks unbounded goroutines and overload reports itself as a wrong
password.
Concurrent derivations are bounded in two dimensions: 256 MiB of memory and 16 Argon2 lanes, each reservation the size that hash actually asks for, both taken together under one queue. A request needing more than either whole budget fails immediately rather than waiting for what it can never get.
A slot count alone bounded how many derivations ran, not how large they were, and Verify
accepts a stored hash asking for anything up to 256 MiB — so four slots admitted 1 GiB while
the comment above them claimed 256 MiB. Memory alone was no better: a stored hash at the
8 MiB floor reserves a thirty-second of the byte budget, so 32 run at once, each free to ask
for 16 lanes — 512 lanes, comfortably under the memory ceiling and quite enough to take
the login endpoint with it. Verify reads those parameters out of the stored hash, so
whoever can write the store chooses them. CPU was written off as degrading rather than
killing, which is true of one derivation and false of the fleet a byte budget admits.
Taking both under the same single acquirer is what keeps the second dimension free: two waiters can never each hold part of what the other needs.
The PHC parser compares against the canonical spelling rather than scanning. fmt.Sscanf
reads the fields it is asked for and ignores the rest, so p=4TRAILINGGARBAGE, p=04 and
v=19GARBAGE all verified clean.
HashWith mints at chosen Params — {Memory, Time, Threads}, bounded to the same band
Verify accepts — for a test suite that cannot afford 64 MiB per derivation. Hash is the
suite's answer, calling HashWith at DefaultParams(), and what production code calls.
DummyVerify spends a verification's cost on a reject path that never reached one, so a
missing account does not answer faster than a wrong password — though ErrBusy returns
without deriving, so under load that leak reopens.
encoded, err := password.Hash(plaintext) // "$argon2id$v=19$m=65536,t=3,p=4$..."
ok, err := password.Verify(plaintext, encoded) // ErrMalformed, never a silent default
stale, err := password.NeedsRehash(encoded) // upgrade on next successful login
fast, err := password.HashWith(plaintext, password.Params{Memory: 8 * 1024, Time: 1, Threads: 1}) // tests only
Errors worth an errors.Is in a caller: ErrBusy, ErrMalformed.
Verify accepts a weaker-but-valid hash so a deployment rehashes on login instead of
locking everyone out. Pair it with NeedsRehash; no Argon2 repo in the suite had either.
totp
RFC 6238 one-time passwords. Pinned to the published RFC 4226 Appendix D vectors, so the construction is checked against a document rather than against itself.
All four implementations in the suite agreed on the arithmetic — HMAC-SHA1, 20-byte secret, 6 digits, 30-second period — so enrolled secrets stay valid across a migration onto this one. They disagreed on everything around it, and two of those differences outlive the request.
ky_server_base and gridlock-server build the enrolment URI by interpolation:
fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&...", issuer, accountName, secret, issuer)
An account name carrying a ? starts the query string early. Parsed:
account = "x?secret=ATTACKERAAAAAAAA&digits=8"
enrolled secret -> ATTACKERAAAAAAAA digits -> 8
The user's phone holds a secret the server never issued, until they re-enrol. Nothing
errors. ProvisioningURI escapes the label and builds the query with url.Values.
The same two return a bare bool from validation, so a caller cannot know which step
matched and cannot spend it — a phished code stays live for the full 90-second window.
Validate returns the counter:
counter, ok := totp.Validate(secret, code, time.Now()) // record counter, refuse a repeat
uri := totp.ProvisioningURI(issuer, account, secret)
recoverycode
One-time codes that bypass every other factor, so their strength is the account's strength.
ky_server_base, gridlock-server and kysignon-server issue 8 symbols over a 32-symbol
alphabet — 40 bits — and store them as a bare SHA-256. That is searchable offline by
anyone who reads the store. This package issues 12 symbols: 60 bits.
Hashing and storage stay with the caller; the products disagree about the hash for reasons
this package cannot settle, and it is not what diverged dangerously. What is here is
generation, the normalisation both sides of a comparison must agree on, and MatchCode,
which normalises and hashes before scanning every entry instead of breaking on the first
hit — ky_server_base's redeem loop
reports the matching code's position in the list through its timing. An empty stored entry
is a redeemed slot and never matches, so a caller blanks in place rather than removing and
renumbering, which is how two concurrent redemptions lose each other's write.
Match was a digest comparison named for codes. Both parameters were strings, so passing
what the user typed compiled, ran, and never matched: enrolment hashed Normalize(code)
and a redemption path that forgot the same call rejected a valid code during the emergency
the code exists for. It is now MatchDigest, and MatchCode(code, digests, hash)
normalises internally so the two sides cannot disagree.
keyfile
Loads a long-lived secret from disk, creating it on first use. Seven products did this seven ways; the five whose behaviour differs materially are:
| Product | On a corrupt key file | First-boot race | fsync |
|---|---|---|---|
kynotes-server |
continues with an empty secret | unguarded | no |
kypassword-server |
regenerates, orphaning every old record | unguarded | no |
kysignon-server |
refuses to start | unguarded | no |
kyrecovery-server |
refuses | O_EXCL |
no |
kypost-server |
refuses | mutex + recheck | yes |
This package refuses rather than guesses: an existing file that does not decode to exactly
the expected size is an error and is left untouched. The key is written to a temporary file
and then os.Linked to its final name, so the cross-process race is settled by link
failing with EEXIST — not by O_EXCL, which would leave a partial file at the real path
that this package then refuses to replace forever. The loser re-reads the winner's key
rather than returning one that was never persisted. The file and its directory are both fsynced, because a crash after first boot
otherwise leaves a zero-length file that the refusal above then reports forever.
RequireOwnerOnly is exported separately. Nothing in the suite checked that a key file is
still 0600, so one relaxed by a stray chmod or a restore kept being used as though it
were secret.
key, err := keyfile.LoadOrCreate(filepath.Join(dir, "audit.key"), 32)
LoadOrCreate and Load are hex; LoadOrCreateEncoded and LoadEncoded take an
Encoding — Hex, Raw or Base64 — because four products already wrote their key
files those ways: kydns-server writes a raw 32-byte ed25519 seed, kysignon-server
writes raw, kynotes-server and kypost-server write base64. A package that can only read
hex is a package those four cannot adopt.
Load never creates. kypost-server runs a daemon and an API process against the same
key file, and the daemon must never mint a key the API process did not — when both are
allowed to create, a restart in the wrong order leaves half the data under a key that no
longer exists, and nothing reports it: every write succeeds, and every old read fails as
though the data were corrupt.
FromEnv reads a key from an environment variable, hex or base64, and applies the same
content checks a file-supplied key gets — the encoding must decode and the result must be
exactly size bytes — with set-but-unparseable an error rather than a fall-through to the
file. It cannot apply the rest: a file key also goes through openKey's symlink and
os.SameFile checks and checkKeyInfo's ownership and 0600 permission checks, and an
environment variable has no inode to check. Four products check an environment variable before the file; doing that outside this
package means the env-supplied key skips every check the file-supplied one gets.
key, err := keyfile.LoadOrCreateEncoded(path, 32, keyfile.Base64)
key, err := keyfile.Load(path, 32) // never creates
key, ok, err := keyfile.FromEnv("KY_AUDIT_KEY", 32) // ok is false when unset
err := keyfile.Store(path, pub.Bytes(), keyfile.Raw) // a key the caller was handed; never overwrites
Store persists a key the caller already holds — the recovery public key received at
pairing — and refuses to replace an existing file, with errors.Is(err, fs.ErrExist).
Replacing a product's recovery public key is how every later backup gets sealed to whoever
wrote the replacement.
A hex key file is lowercase hex with exactly one trailing newline — 65 bytes for a 32-byte
key. That format is pinned by TestHexFileFormat.
errors.Is a failed load against ErrUnreadable to tell a corrupt key file from a missing
one — the second creates under LoadOrCreate, the first never does.
derive
The login secret a client sends in place of a password: PBKDF2-SHA256 stretched, then HKDF-SHA256 expanded under a per-product label.
kynotes-server and kypost-server both do this, each mirroring a browser-side
TypeScript implementation. That makes it a contract across four programs rather than a
helper — change any byte and every user is locked out. The golden vectors in the test were
produced by running kynotes-server's implementation, not this one, and they pass, so that
product can adopt this package without a single login breaking.
Both existing copies import golang.org/x/crypto/pbkdf2 and .../hkdf. Both moved into
the standard library in Go 1.24, so adopting this package deletes a dependency from each
rather than adding one — which is the only reason it can live here at all.
secret, err := derive.AuthSecret(password, saltB64, iterations, "kynotes/auth/v1")
salt, err := derive.SyntheticSalt(pairingKey, "login-salt/v1", username)
Iterations are bounded to 100,000–12,000,000: the value arrives from a client or a stored record, and unbounded it buys minutes of CPU per login.
The two products do not agree on the ceiling, and this package takes the looser of them.
kypost-server enforces 12,000,000 with nothing tighter in front of it. kynotes-server's
derivation carries the same 12,000,000, and four of its five routes that accept a client
iteration count cap it below that — one clamps an out-of-range value to 600,000 rather
than refusing it, the other three refuse anything outside 100,000-1,000,000. Its fifth
route, account recovery, checks the client's count only through the derivation's own
100,000-12,000,000 bound, so kynotes does admit the full ceiling, just not on every route.
Adopting this package as the only bound would raise those four routes' ceiling
twelvefold. Which number is right is an open product decision; keep their tighter
checks until it is made.
A ceiling is not admission control, though — it bounds one call and says nothing about how
many run at once, so a modest burst at the ceiling takes every core and starves the handlers
that would have shed it. Four concurrent slots and a 2-second wait, past which
AuthSecret returns ErrBusy — 503, and no lockout strike. PBKDF2 is single-threaded, so a
slot really is a core. The budget is derive's own rather than password's because this
package is standard-library-only by design and importing password would pull x/crypto
into it. SyntheticSalt lower-cases the username, because keying anything off the raw string
lets one account present as many and quietly multiplies any per-account budget above it.
AuthSecretContext bounds the wait for a slot. The budget stays separate from
password's — the two bound different resources, and this package importing password
would pull x/crypto into it — but derive.MaxConcurrent, password.MaxMemoryKiB and
password.MaxLanes are exported so a product running both can add them up.
recoveryclient
The product side of the suite's backup contract with KyRecovery. Four products carried
divergent copies of this code, two of them hand-rolled with their own key-pin and token
semantics; a prune that deletes every .kycap in a directory, or a pin that accepts a second
key, loses data silently, which is the admission bar. kysignon-server was the reference and
is the first consumer.
The product supplies four things: a Settings row store (mapping its not-found error to
recoveryclient.ErrNotFound), a Sealer under its deployment key (or NewAESGCMSealer),
a collect func that says what to seal, and a checks func for the drill. Config, HTTP
handlers, step-up, UI, audit API, compose and docs stay in the product.
sealer, _ := recoveryclient.NewAESGCMSealer(cfg.EncryptionKey, "myapp:setting:kyrecovery_token")
client := recoveryclient.NewClient(recoveryclient.Options{AllowPrivate: cfg.AllowPrivateRecovery})
res, err := recoveryclient.Run(ctx, recoveryclient.RunConfig{
DataDir: cfg.DataDir, AppName: cfg.AppName, AppVersion: version,
BackupDir: cfg.BackupDir, Keep: cfg.BackupKeep, Sealer: sealer,
}, settings, collectPayload, client)
action, outcome, details := recoveryclient.Outcome(res, err)
What it pins, each by a named test:
-
One run seals once and delivers to every configured destination; a key with nowhere to go is
ErrNoDestination, andcollectnever runs before that is known (TestRunNeedsAKeyThenADestination,TestRunPairedDeliversToBothAndRecordsReceipt). -
A failed local copy never cancels the deposit; it rides in
Result.LocalError(TestRunLocalFailureDoesNotCancelTheDeposit). -
Local copies are
<AppName>.<capsule-id>.kycapat 0600 (the app name escaped reversibly into[A-Za-z0-9-_], so distinct names never share a prefix and no name can be another's prefix plus the delimiter), written by temp file and rename, and only that prefix is listed or pruned (TestWriteLocalCopyPrunesOwnPrefixOnly,TestLocalCopiesOfAPrefixedAppNameAreInvisibleToTheShorterName,TestLocalCopiesOfCollidingNamesStaySeparate). A retention count below one is refused before anything is written, so a zero value can never mean "delete everything" (TestWriteLocalCopyRetention). -
The schedule is bounded in whole seconds before any
Durationmath, on the way in and on the way out, so a stored 2^55 seconds cannot wrap to zero and read as off and a stored negative cannot make every tick due (TestSetIntervalBoundsSeconds,TestIntervalRefusesAStoredValueOutsideTheBound); the next run counts from the last attempt (TestNextRunCountsFromLastAttempt). -
The key pin is write-once and the settings row decides, not the file (
TestStoreRecoveryKeyIsWriteOnce,TestLoadRecoveryKeyDetectsSwappedFile). -
URLs are HTTPS without credentials, query or fragment; private and carrier-grade NAT destinations need
Options.AllowPrivate; loopback, link-local, multicast, unspecified and reserved never pass (TestValidateURL*). Redirects are refused (TestClientRefusesRedirect).errors.Is(err, recoveryclient.ErrPrivateDestination)identifies literal-IP and DNS-time refusals where enablingAllowPrivatewould admit an address, including errors returned byClaimPairingandDeposit. Products use this to name their own environment setting and container recreation instructions. It does not match addresses that remain forbidden, lookup failures, or connection/TLS/server errors. -
A receipt must describe the bytes sent, and one naming another capsule is
ErrRemoteand unrecorded (TestDepositRefusesReceiptThatDoesNotDescribeBytesSent,TestRunReceiptForOtherCapsuleIsRemoteAndUnrecorded). -
ClearPairingremoves the URL and token rows and nothing else; the key pin and receipts stay, and a half-cleared pairing still clears (TestClearPairingKeepsKeyPinAndReceipt,TestClearPairingHalfClearedStillClears). A pairing whose key will not resolve isErrKeyPinMissing, reported, not skipped (TestLoadPairingReportsKeyPinMissing). -
Drillseals to a throwaway key generated and dropped inside the call, hands the checks the manifest as read back from the opened capsule so they see the JSON-shaped recipe a real restore sees. Recipe values are authenticated but caller-authored, so checks validate types and clean relative paths before acting (TestDrillHandsChecksTheOpenedManifest), works in a 0700 scratch directory under a root the product names (its data directory, never the system temp dir), wipes it, and sweeps a killed drill's residue first (TestDrillOpensWhatItSealedAndWipesScratch,TestDrillUsesTheCallerScratchRoot).Restorechecks the manifest's service name before combining shares (TestRestoreRefusesOtherServiceBeforeCombine). -
SQLiteSnapshotcopies through the live handle withVACUUM INTO; copying the file misses every commit still in the write-ahead log. The lib has no SQLite driver, so the row-in-the-WAL case is proven in each product's tests. -
guardtest.NoDecryptOutsidewalks a product's repository from an absolute root, fails on fewer than ten files, and names any call ofcapsule.Open,recoverykey.Combine,recoverykey.FromSeedorrecoveryclient.Restoreoutside the functions the product lists (TestGuardCatchesPlantedOpen). The lib runs it on itself.
syncauth
One definition of how KySignOn signs a directory-sync event and how a product verifies it. Before this, the sender signed the timestamp and body and at least one receiver verified the body alone, so the two sides could not agree; and the signing secret also travelled as the bearer token on every request. A receiver that accepts a forged deprovision, or that never sees a real one, cannot tell either from a good event, which is the admission bar.
Scheme v1: HMAC-SHA256 over a canonical string binding the timestamp, the event type, the
event ID and the SHA-256 of the body, carried as X-KySignOn-Signature: v1=<hex> with the
timestamp, type and ID in their own headers. The key travels nowhere.
// sender
h, _ := syncauth.Sign(secret, time.Now(), "user.deprovisioned", eventID, body)
h.Apply(req)
// receiver
mux.Handle("POST /api/sync", syncauth.Middleware(keyFn, syncauth.Options{Replay: syncauth.NewMemoryReplay(0, 0)}, 0, logReject)(handler))
Pinned: every bound field changes the verdict (TestVerifyBindsEveryField); a missing
signature, a timestamp outside the window in either direction, and a short key each refuse
(TestVerifyRefusesMissingStaleAndShort); the replay guard forgets only what has left the window and refuses with
ErrReplayGuardFull rather than forget an ID it must remember (TestReplayGuard); the middleware reads a bounded body, fails closed when the key is not
configured, and hands the handler exactly the bytes that were signed
(TestMiddlewareVerifiesAndReplaysTheBodyToTheHandler); FuzzVerify accepts only the
original tuple.
oidcverify
Verifies the RS256 tokens KySignOn issues against its JWKS, on the standard library. Five
products parsed these with their own code, and at least one read the claims without checking
the signature. One wrong check of alg, aud, iss, exp or kid accepts a token it
should not.
v := &oidcverify.Verifier{Issuer: cfg.IssuerURL, Audience: cfg.ClientID}
claims, err := v.VerifyWithNonce(ctx, idToken, session.Nonce)
mux.Handle("GET /api/me", v.Middleware(logReject)(handler))
Pinned: the issuer and JWKS URL must be HTTPS and the fetch follows no redirect, checked
before any request is made (TestPlaintextIssuerIsRefusedWithoutARequest,
TestJWKSRedirectIsRefused); the algorithm is RS256 or nothing, so none, HS256 and an empty signature segment
are refused before any key work (TestVerifyRefusesAlgorithmConfusionAndBadSignatures);
each standard claim is checked with one minute of leeway and an audience array is refused
unless the product opts in (TestVerifyRefusesEachBadClaim); base64url must be unpadded so
one token has one encoding; JWKS entries that are not RSA signing keys are ignored even under
the right kid (TestJWKSIgnoresNonRSAKeysUnderTheSameKid); an unknown kid triggers at
most one rate-limited refresh whether the fetch succeeds or fails, the fetch runs outside
the lock so concurrent verifications share it, and a stale key set outlives an issuer outage
(TestJWKSRefreshIsRateLimitedAndStaleSetSurvivesOutage, TestUnknownKidDoesNotStallVerification,
TestConcurrentUnknownKidsShareOneFetch).
Back-channel logout
VerifyLogout is the dedicated verifier for the KySignOn profile of
OpenID Connect Back-Channel Logout:
logout, err := v.VerifyLogout(ctx, logoutToken)
// On success, atomically record (logout.Issuer, logout.JWTID) and invalidate
// matching sessions. Keep the replay record through logout.ReplayUntil.
It shares the verifier's RS256 signature checks, HTTPS JWKS cache, issuer/audience checks,
clock and leeway. It requires exact typ: logout+jwt, numeric iat and exp, a nonempty
jti, a nonempty sub or sid (or both), and an events object whose
http://schemas.openid.net/event/backchannel-logout member is an object. nonce and
token_use must be absent, including null or empty values. Expiration must follow
issuance; tokens older than MaxLogoutTokenAge (five minutes) plus leeway are refused,
even with a later expiration. KySignOn currently issues two-minute tokens per attempt.
ReplayUntil is the earlier expiration/freshness cutoff, including leeway (one minute
by default). ErrTokenType reports a wrong token purpose; ErrLogoutClaims reports a
logout-specific claim failure; common signature, issuer, audience and time errors are
shared with Verify.
The returned LogoutClaims is separate from authentication Claims. With SessionID,
match the local sessions for this issuer/client and that session ID, checking Subject
when present. Without SessionID, end all this client's sessions for Issuer and
Subject. Never broaden an unknown session ID into a subject-wide logout. A missing
local session is an idempotent success. The receiving product owns the HTTP body limit,
durable replay check, session invalidation and audit transaction; this primitive verifies
only and deliberately holds no replay database. Store replay IDs across restart and check
them atomically with invalidation so concurrent deliveries cannot replay a logout.
Authentication compatibility: Verify, VerifyWithNonce and Middleware now accept
only an absent/empty typ, JWT, or at+jwt, and reject any events claim. An ID token
with a logout type cannot authenticate, and ordinary ID/access tokens cannot authorize
logout. Numeric dates must be JSON numbers rather than quoted numbers, nulls or values
outside the supported integer range; payloads must be a single JSON object. Existing
KySignOn ID/access tokens use JWT and remain compatible. Neither authentication API
becomes an access-token-only verifier: products must still enforce their token-use policy.
The oidcverify/logout_test.go suite checks both logout scopes, malformed claims,
signatures, purpose separation through all three authentication APIs, JWKS reuse, and
exact freshness/replay boundaries with an injected clock.
scim
The SCIM 2.0 client KySignOn provisions accounts with, and the RFC 7643 User type it shares with a server. Before this the types, paths and status handling sat in KySignOn's sync engine, where a 409 on create counted as a delivered account, later calls used local IDs the server never issued, and generic targets got the suite signature instead of their bearer token. An account that does not exist recorded as provisioned is the bar.
c := &scim.Client{BaseURL: sys.URL, Token: sys.Token, HTTPClient: netguard.Client(5 * time.Second)}
created, err := c.CreateUser(ctx, user) // keep created.ID; it is the handle from here on
if errors.Is(err, scim.ErrConflict) {
created, err = c.FindUser(ctx, "externalId", user.ExternalID)
}
_, err = c.PatchUser(ctx, created.ID, scim.PatchOperation{Op: "replace", Path: "active", Value: false})
err = c.DeleteUser(ctx, created.ID) // errors.Is(err, scim.ErrNotFound): already gone
Users only; groups, listing and bulk wait for a caller. FindUser takes externalId or
userName, the two attributes whose match it can check on the way back. HTTPClient is the
product's, so its outbound policy applies; the client adds what every product must not get
wrong.
Pinned: the base URL is HTTPS with a host and nothing else, and an empty token is refused,
before any request (TestPlaintextOrMalformedBaseURLIsRefusedWithoutARequest); a redirect is
refused and the bearer does not follow it (TestRedirectIsRefusedAndTokenNotReplayed);
resource IDs are path-escaped and an empty or dot ID never reaches the wire
(TestResourceIDsAreEscapedAndRequired); the filter value is quoted with \ and " escaped
and any other attribute is refused (TestFilterIsEscapedAndAttributeRestricted); zero matches
is ErrNotFound, two is ErrAmbiguous, and one that does not carry the value is
ErrMalformedResponse, so a server that ignores the filter cannot hand back someone else
(TestFindUserZeroAndManyResults, TestFindUserRefusesAUserThatDoesNotMatch); 409 is
ErrConflict and a 2xx create without an id is ErrMalformedResponse
(TestCreateConflictIsAnErrorNotSuccess, TestCreateWithoutAnIDIsAnError); only 200, 201 and
204 are success, so a 3xx without Location or a 202 is not a completed delete, and Ping
needs a JSON object (TestUnexpectedStatusesAreNotSuccess); bodies are read to 1 MiB, error
detail is printable and short with the bearer token redacted out of it, and Retry-After is
surfaced capped at an hour and never negative (TestOversizedSuccessBodyIsRefused,
TestErrorBodyIsBoundedAndPrintable, TestTokenIsRedactedFromErrorDetail,
TestRetryAfterIsBoundedAndNonNegative); a transport error names the path, not the query
that carries a user identifier (TestTransportErrorsDoNotCarryTheQuery); the round trip
against a server that mints unrelated IDs uses those IDs throughout
(TestLifecycleAgainstServerMintedIDs).
offsite nested module
One client for copying opaque data to S3-compatible storage, pinned SFTP, signed SMB 2/3, or a local directory. It is a nested module so products that only use the root primitives do not inherit the SFTP and SMB dependency trees.
target, err := offsite.Parse(offsite.Config{
URL: "sftp://backup@vault.example/copies",
Secret: privateKey,
HostKey: "SHA256:...",
})
err = target.Put(ctx, "attachments/object-id", body, size)
See offsite/README.md for URL forms and the security
contract. Release tags are namespaced for the nested module: offsite/v0.1.0.
Consumers install that release with
go get github.com/Busnes-app/ky-primitives/offsite@v0.1.0.
Contributing
Nothing in this repository builds its consumers. The check that does lives in each of them:
.github/workflows/ky-primitives-compat.yml in gridlock-server, kybookmarks-server and
kypassword-server builds and tests that consumer against this module's default branch
instead of the version it pins. It runs on a daily schedule and on workflow_dispatch, not
on pull requests -- in either repository.
It lives there rather than here because the dependency only points one way without a credential. A consumer, public or private, can read this repository anonymously; reaching a private consumer from here needed an organisation token, which is why the job that used to do it never ran.
The cost is immediacy. A change that breaks a consumer will not fail your pull request.
It will fail in that consumer's scheduled run, up to a day later, in a repository you may not
watch, and the person who sees it red will be someone whose own work is unrelated. If you are
making a change you expect to break a consumer, do not wait for the schedule: open the
consumer's PR alongside yours and run its compatibility workflow by hand from the Actions tab
once this change is on master.
The other six suite repositories do not import this module and have no such workflow; adding one is a copy of the file with the cron minute changed.
Container-local installer setup
applysetup provides strict private-file bundle decoding, file-referenced credentials,
common SSO/identity/backup inputs, capability discovery and bounded report shapes.
keyfile.ReadPrivate reads opaque private files through the same verified descriptor as
key loading. Products retain their own transactions, audited grants, conflict handling,
configuration activation and read-only checks; this package never changes a product.
A callback can return &applysetup.Refusal{Reason: "fixed safe explanation"} to
show an actionable reason in CallLocal CLI errors. Reasons must be fixed safe text,
at most 512 bytes without controls; ordinary errors remain redacted. Never expose
credentials, input values or database errors this way.
Products may opt into ParsePreparation to accept --stage with bounded secret-free
stdin metadata; legacy Parse refuses that flag. Strictly decode and validate the
product envelope (including extensions), use Common.ValidatePreparation for common
metadata and distinct clean absolute credential references, then marshal canonical JSON
before Stage. The Linux helper publishes only
<data>/installer/<setup UUID>/setup.json through protected ancestry and private owned
directories, atomically without replacing retained files. Check creates nothing;
changed or unsafe input is refused. Other platforms refuse staging. WritePreparation
emits a fixed ky.setup-preparation/1 created/present receipt, describing metadata only.
Credentials, accounts and product configuration remain separate native operations.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package applysetup defines the container-local installer boundary.
|
Package applysetup defines the container-local installer boundary. |
|
Package auditchain builds and verifies a tamper-evident hash chain over audit records.
|
Package auditchain builds and verifies a tamper-evident hash chain over audit records. |
|
Package capsule reads and writes the suite's encrypted backup container.
|
Package capsule reads and writes the suite's encrypted backup container. |
|
cmd
|
|
|
kyauditverify
command
Command kyauditverify checks any audit chain in the suite.
|
Command kyauditverify checks any audit chain in the suite. |
|
Package derive computes the login secret a client sends in place of a password.
|
Package derive computes the login secret a client sends in place of a password. |
|
Package health serves the suite's ky.health/1 endpoint: one public JSON shape that says whether a service works, without saying anything an attacker could use.
|
Package health serves the suite's ky.health/1 endpoint: one public JSON shape that says whether a service works, without saying anything an attacker could use. |
|
Package keyfile loads a long-lived key from disk, creating it on first use or storing one it was handed.
|
Package keyfile loads a long-lived key from disk, creating it on first use or storing one it was handed. |
|
Package logging is the suite's log line builder.
|
Package logging is the suite's log line builder. |
|
offsite
module
|
|
|
Package oidcverify verifies tokens KySignOn issues: RS256 JWTs whose signing key is published at the issuer's JWKS endpoint.
|
Package oidcverify verifies tokens KySignOn issues: RS256 JWTs whose signing key is published at the issuer's JWKS endpoint. |
|
Package password hashes and verifies passwords with Argon2id.
|
Package password hashes and verifies passwords with Argon2id. |
|
Package recoveryclient is the product side of the suite's backup contract with KyRecovery: pin the suite recovery public key, pair with a KyRecovery server, seal the product's payload into a capsule, deliver it to a local directory and to KyRecovery on a schedule, drill a restore against a throwaway key, and restore from custodian shares.
|
Package recoveryclient is the product side of the suite's backup contract with KyRecovery: pin the suite recovery public key, pair with a KyRecovery server, seal the product's payload into a capsule, deliver it to a local directory and to KyRecovery on a schedule, drill a restore against a throwaway key, and restore from custodian shares. |
|
guardtest
Package guardtest pins the decrypt boundary of a product: nothing in the server may open a capsule sealed to the suite key, combine shares or rebuild the key from a seed, except the functions the product names.
|
Package guardtest pins the decrypt boundary of a product: nothing in the server may open a capsule sealed to the suite key, combine shares or rebuild the key from a seed, except the functions the product names. |
|
Package recoverycode generates and matches the one-time codes that let a user back into an account when every other factor is gone.
|
Package recoverycode generates and matches the one-time codes that let a user back into an account when every other factor is gone. |
|
Package recoverykey is the suite's recovery keypair: the one public key every product seals its backups to, and the private key that exists only while it is being split into custodian shares and while a restore combines them.
|
Package recoverykey is the suite's recovery keypair: the one public key every product seals its backups to, and the private key that exists only while it is being split into custodian shares and while a restore combines them. |
|
Package scim is the suite's SCIM 2.0 client (RFC 7643, RFC 7644) and the wire types it shares with a server.
|
Package scim is the suite's SCIM 2.0 client (RFC 7643, RFC 7644) and the wire types it shares with a server. |
|
Package shamir splits a secret into custodian shares and reconstructs it.
|
Package shamir splits a secret into custodian shares and reconstructs it. |
|
Package syncauth is the one definition of how KySignOn signs a directory-sync event and how a product verifies it.
|
Package syncauth is the one definition of how KySignOn signs a directory-sync event and how a product verifies it. |
|
Package totp implements RFC 6238 time-based one-time passwords using only the standard library.
|
Package totp implements RFC 6238 time-based one-time passwords using only the standard library. |