gitoria
All repositories: gitoria
6.1 KB
// 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`, sshgate.hl), 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, env } from 'hl:proc'import { now } from 'hl:time'import { writeFile, remove, exists } from 'hl:fs'import { randomBytes } from 'hl:crypto'import { storageDir, countOfList, firstOf, plainError, userRecord, envOr } from './users.hl'import { localStamp } from './localtime.hl'import { tmpDir } from './transport.hl'static sshKeysTable = new MPackDB(file = storageDir + '/sshkeys.db', primaryKey = '@id', indexes = ['!key', 'user'])static NL = ""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) => {let 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 first (no sort(): hybriel #1)static keyRows = (userId) => {let out = []let all = sshKeysTable.find('user', userId)if (countOfList(all) == 0) { return out }for (k of all) { out.push(rowOfKey(k)) }let i = 1while (i < out.length) {let cur = out[i]let j = i - 1while (j >= 0 && out[j].createdMs < cur.createdMs) {out[j + 1] = out[j]j = j - 1}out[j + 1] = curi = i + 1}return out}// `ssh-keygen -l` on the key: → { bits, fingerprint } or null when it is not a keystatic inspectKey = (type, key) => {let file = tmpDir() + '/' + randomBytes(12, 'hex') + '.pub'writeFile(file, type + ' ' + key + NL, 384)let 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 }let 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) => {let u = userRecord(userId)if (u == null) { return { error = 'log in with ident (top right) first' } }let n = name == null ? '' : ('' + name).trim()let 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)' } }let 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' } }let 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' } }let 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' } }let 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' } }let 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 }let k = firstOf(sshKeysTable.find('key', key))if (k == null || k.type != type) { return null }return k.user}
Branches
- mainmain branch
Latest commits
- 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