gitoria
All repositories: gitoria
6.3 KB
// lib/sshkeys.hl — SSH KEYS for git over SSH (ticket gitoria#7; docs/git-backend.md "Credentials"). A logged-in user pastes a// PUBLIC key; the sshd container (docker/sshd) asks this app which user a key belongs to (`/__git/keys`, lib/api.hl gitKeys → keyLine), so a new// key works at once and no authorized_keys file is edited. Only the public key is stored — nothing secret.// sshKeysTable pk @id index !key, user { user (users @id), name, type, key (base64), fingerprint, created } storage/mpackdb/sshkeys.db// The key is checked by `ssh-keygen -l -f` (the real parser): one line, an allowed type, a real key, RSA at least 2048 bits.import { MPackDB } from 'hl:mpackdb'import { run } from 'hl:proc'import { now } from 'hl:time'import { writeFile, remove, exists } from 'hl:fs'import { randomBytes } from 'hl:crypto'import { NL, storageDir, countOfList, firstOf, plainError, envOr, newestFirst, localStamp } from './util.hl'import { userRecord } from './users.hl'import { tmpDir } from './git.hl'static sshKeysTable = new MPackDB(file = storageDir + '/sshkeys.db', primaryKey = '@id', indexes = ['!key', 'user'])static maxKeys = 20// SSH is offered only when the sshd container is set up: the shared secret it uses to ask this app is in the environmentstatic sshSecret = envOr('GITORIA_SSH_SECRET', '')static sshPort = envOr('GITORIA_SSH_PORT', '2222')// the host name in SSH clone addresses: repo addresses go through Cloudflare's proxy, which does not pass SSH, so// production sets a DNS-only name (GITORIA_SSH_HOST=ssh.gitoria.worldapi.org); empty = the page's own host (dev, gates)static sshHost = envOr('GITORIA_SSH_HOST', '')static sshEnabled = sshSecret != ''static allowedTypes = ['ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-rsa', '[email protected]', '[email protected]']// the address to clone from over SSH: `git@host:slug.git` on port 22, else the ssh:// form with the portstatic sshUrl = (host, slug) => {h = sshHost != '' ? sshHost : hostif (sshPort == '22') { return 'git@' + h + ':' + slug + '.git' }return 'ssh://git@' + h + ':' + sshPort + '/' + slug + '.git'}static isKeyBase64 = (s) => {if (s.length < 20 || s.length > 1200) { return false }let i = 0while (i < s.length) {let c = s.charCodeAt(i)if (!((c >= 48 && c <= 57) || (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c == 43 || c == 47 || c == 61)) { return false }i = i + 1}return true}static rowOfKey = (k) => { return { id = k.id name = k.name fingerprint = k.fingerprint created = localStamp(k.created) createdMs = k.created } }// a user's keys, newest firststatic keyRows = (userId) => {out = []all = sshKeysTable.find('user', userId)if (countOfList(all) == 0) { return out }for (k of all) { out.push(rowOfKey(k)) }return newestFirst(out)}// `ssh-keygen -l` on the key: → { bits, fingerprint } or null when it is not a keystatic inspectKey = (type, key) => {file = tmpDir() + '/' + randomBytes(12, 'hex') + '.pub'writeFile(file, type + ' ' + key + NL, 384)r = run(['ssh-keygen', '-l', '-f', file], { timeout = 10 })if (exists(file)) { remove(file) }if (r == null || r.exit != 0 || r.lines.length == 0) { return null }parts = r.lines[0].split(' ')if (parts.length < 2 || !parts[1].startsWith('SHA256:')) { return null }return { bits = toNumber(parts[0]) fingerprint = parts[1] }}// a new key: { row } or { error }. `text` is the line from the .pub file: `<type> <base64> [comment]`static addKey = (userId, name, text) => {u = userRecord(userId)if (u == null) { return { error = 'log in with ident (top right) first' } }n = name == null ? '' : ('' + name).trim()bad = plainError(n, 60, 'the key name')if (bad != null) { return { error = bad } }if (n == '') { return { error = 'give the key a name (for example the computer it is for)' } }if (text == null || hlTypeName(text) != 'String' || text.length > 2000) { return { error = 'paste the public key (the .pub file)' } }t = text.trim()if (t.startsWith('-----')) { return { error = 'that is a PRIVATE key — never paste it. Paste the .pub file: ssh-keygen -y -f ~/.ssh/id_ed25519' } }if (t.includes(NL)) { return { error = 'paste one public key, on one line' } }parts = t.split(' ')let type = parts[0]if (parts.length < 2 || !allowedTypes.includes(type)) { return { error = 'not a public key of a supported type (ssh-ed25519, ssh-rsa, ecdsa, sk-…)' } }let key = parts[1]if (!isKeyBase64(key)) { return { error = 'the key text is not valid' } }info = inspectKey(type, key)if (info == null) { return { error = 'the key text is not valid' } }if (type == 'ssh-rsa' && info.bits < 2048) { return { error = 'RSA keys need at least 2048 bits' } }if (countOfList(sshKeysTable.find('key', key)) > 0) { return { error = 'that key is already added' } }if (countOfList(sshKeysTable.find('user', u.id)) >= maxKeys) { return { error = 'you have ' + maxKeys + ' keys already — remove one first' } }id = sshKeysTable.put({ user = u.id name = n type = type key = key fingerprint = info.fingerprint created = now() })if (id == null) { return { error = 'could not store the key: ' + sshKeysTable.lastError() } }return { row = rowOfKey(sshKeysTable.fetch(id)) }}// remove one of the user's own keys (works at once: the sshd container asks again on every login)static revokeKey = (userId, keyId) => {if (userId == null || keyId == null || hlTypeName(keyId) != 'String') { return { error = 'no such key' } }k = sshKeysTable.fetch(keyId)if (k == null || k.user != userId) { return { error = 'no such key' } }sshKeysTable.delete(keyId)return { ok = true }}// the user a key belongs to (users @id) or nullstatic userOfKey = (type, key) => {if (type == null || key == null || hlTypeName(type) != 'String' || hlTypeName(key) != 'String' || !isKeyBase64(key)) { return null }k = firstOf(sshKeysTable.find('key', key))if (k == null || k.type != type) { return null }return k.user}// what the sshd container's AuthorizedKeysCommand gets for a key (lib/api.hl gitKeys): the authorized_keys line of a known// user's key — forced command `gitoria-shell <user id>`, nothing else allowed — or '' for an unknown onestatic keyLine = (type, key) => {userId = userOfKey(type, key)if (userId == null || userRecord(userId) == null) { return '' }return 'restrict,command="/usr/local/bin/gitoria-shell ' + userId + '" ' + type + ' ' + key + NL}
Branches
- mainmain branch
Latest commits
- cc7bf7bamission 002 (code order) 3/4: let only where a variable is reassigned or re-bound in a loop body (289 lets → plain declarations; 213 left: 125 reassigned, 88 loop-bound; no member/import/param clash). gates 200/0, 46/0, 44/0; real-data reads + writes identical (browser modules: var → const only)mre
- 090a20c6mission 002 (code order) 2/4: one lib/ file per topic — git.hl split into git (calls, branches, init, temp folder) / homepage / code / pulls / releases (+ git-helpers: paths, ids, |||PR/|||RL markers); repos-helpers, tickets-helpers, transport-helpers; util.hl = localtime + env, storage dir, addresses, lists, text checks, one newest-first sort (was 3 copies); the function routes out of project.hl into lib/api.hl (thin; plumbing in api-helpers.hl), sshgate.hl folded into api.hl + sshkeys.hl keyLine + repos.hl mayPush; 'Make main' and the merge answer out of the faces (code.hl makeMain, pulls.hl pullsView), one login helper (users.hl userOfLoginCode); project.hl is the map. Session-writing routes get &req + &server.sessions. gates 200/0, 46/0, 44/0; real-data identical except /login/failed now shows the parked reason for a browser that already had a session (the old copy-of-req lost it)mre
- 110c2799mission 002 (code order) 1/4: .hl files out of the root — lib/ (api, git, localtime, markdown, repos, sshgate, sshkeys, tickets, tokens, transport, users), components/styles.hl; jsoncheck.hl removed (imported nowhere); import paths only. gates 200/0, 46/0, 44/0; real-data reads + writes identicalmre
- fdfb4b1bgitoria: Hybriel master 06617221 (plugin allocators 3a781359 + 413f60e4, mpackdb 2cb7ae5e, http1 773de63e); gates 200/0, 46/0, 44/0mre
- 5b46ac84antcolony#40: LOG.md — missions 069/072 are antcolony missions (report paths on Byrodin)mre
- 5602ff41gitoria: Hybriel master 190aa11d (fc838894 GC correctness, #127 mountKids by reference, #126, #48) — tracker README flat; gates 200/0, 46/0, 44/0mre
- e85eaf01gitoria: 069 round 2 — hybriel 1a096ad3 not adopted (Markdown SSR still grows); browser gate waits for the server-side logout before restartmre
- 09ce4f3fgitoria: mission 069 re-vendor hybriel 8efba065 stopped (big SSR pages grow + slow down); lambda audit clean; old vendor keptmre
- 3dc43108antcolony#40: mission references point to the moved missionsmre
- 8d9450fdantcolony#40: history (LOG.md), worker briefs (missions/) and reports moved here from antcolony, numbered per project; old numbers in antcolony docs/mission-map.mdmre
- 205d5fe4gitoria: Hybriel master ff51cf46; ssh keys/tokens no double rows (session sync); gates follow #20mre
- 9b27cb26gitoria#21: installable app (manifest, service worker, offline start page), own iconmre
- 68dcb603deploy.sh: back up live storage/.sessions/.env before every deploy (newest 5 kept)mre
- e2deed6dgitoria#20: "Add code" only on the Code page of an empty repository, no collapsiblemre
- 8bb97ffddeploy.sh: never send .git or .gitignore to Byrodinmre
- fd981932State of 2026-09-27; bin/ no longer tracked (Hybriel commit is in README)mre
- 4a2d7125initial commitmre