From 56dec78f44a62a480e5696063608e6b6dc02315f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 26 Jun 2026 00:08:09 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/sw):=20universal=20commitSigned=20?= =?UTF-8?q?=E2=80=94=20isomorphic-git=20receive-pack=20push=20+=20SSHSIG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CommitFile/CommitSigner types + commitSigned to GitHost interface. Implement git-push.ts (clone→add→commit({onSign=SSHSIG})→push over http/web) and wire GiteaHost.commitSigned / GitHubHost.commitSigned. Tests: 7/7 incl. golden-vector tie against the sshsig.test.ts byte-exact vector. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01LKBbrAjmpVXMEK15pNi3Ha --- extension/package-lock.json | 673 +++++++++++++++++- extension/package.json | 1 + .../service-worker/__tests__/git-push.test.ts | 192 +++++ extension/src/service-worker/git-host.ts | 28 + extension/src/service-worker/git-push.ts | 128 ++++ extension/src/service-worker/gitea.ts | 25 +- extension/src/service-worker/github.ts | 26 +- 7 files changed, 1064 insertions(+), 9 deletions(-) create mode 100644 extension/src/service-worker/__tests__/git-push.test.ts create mode 100644 extension/src/service-worker/git-push.ts diff --git a/extension/package-lock.json b/extension/package-lock.json index 47bf517..d322248 100644 --- a/extension/package-lock.json +++ b/extension/package-lock.json @@ -1,13 +1,14 @@ { "name": "relicario-extension", - "version": "0.5.0", + "version": "0.8.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "relicario-extension", - "version": "0.5.0", + "version": "0.8.2", "dependencies": { + "isomorphic-git": "^1.38.5", "jsqr": "^1.4.0" }, "devDependencies": { @@ -1328,6 +1329,18 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1428,6 +1441,47 @@ "node": ">=12" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.25", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", @@ -1488,6 +1542,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1505,6 +1583,53 @@ "node": ">=8" } }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001791", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", @@ -1580,6 +1705,12 @@ "node": ">=6.0" } }, + "node_modules/clean-git-ref": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/clean-git-ref/-/clean-git-ref-2.0.1.tgz", + "integrity": "sha512-bLSptAy2P0s6hU4PzuIMKmMJJSE6gLXGH1cntDu7bWJUksvuM+7ReOK61mozULErYvP6a15rnYl0zFDef+pyPw==", + "license": "Apache-2.0" + }, "node_modules/clone-deep": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", @@ -1654,6 +1785,18 @@ "webpack": "^5.1.0" } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1687,6 +1830,21 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", @@ -1697,6 +1855,43 @@ "node": ">=6" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/diff3": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/diff3/-/diff3-0.0.3.tgz", + "integrity": "sha512-iSq8ngPOt0K53A6eVr4d5Kn6GNrM2nQZtC740pzIriHtn4pOQ2lyzEXQMBeVcWERN0ye7fhBsk9PbLLQOnUx/g==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.348", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.348.tgz", @@ -1744,11 +1939,19 @@ "node": ">=4" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -1761,6 +1964,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1867,11 +2082,19 @@ "@types/estree": "^1.0.0" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.8.x" @@ -1998,6 +2221,21 @@ "flat": "cli.js" } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2017,12 +2255,48 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2064,6 +2338,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2096,11 +2382,49 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2109,6 +2433,26 @@ "node": ">= 0.4" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2139,6 +2483,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/interpret": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", @@ -2149,6 +2499,18 @@ "node": ">=10.13.0" } }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -2211,6 +2573,27 @@ "node": ">=0.10.0" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -2228,6 +2611,40 @@ "node": ">=0.10.0" } }, + "node_modules/isomorphic-git": { + "version": "1.38.5", + "resolved": "https://registry.npmjs.org/isomorphic-git/-/isomorphic-git-1.38.5.tgz", + "integrity": "sha512-4HBmx1UB+SdRaQh7+zN7lvFQxc4zl7s0oIM/1+5xveu6+QrOeFfx5QDeRNpqCzFHICISEdbK7GilsTsrgE006Q==", + "license": "MIT", + "dependencies": { + "async-lock": "^1.4.1", + "clean-git-ref": "^2.0.1", + "crc-32": "^1.2.0", + "diff3": "0.0.3", + "ignore": "^5.1.4", + "minimisted": "^2.0.0", + "pako": "^1.0.10", + "pify": "^4.0.1", + "readable-stream": "^4.0.0", + "sha.js": "^2.4.12", + "simple-get": "^4.0.1" + }, + "bin": { + "isogit": "cli.cjs" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/isomorphic-git/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -2326,6 +2743,15 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -2367,6 +2793,36 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimisted": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minimisted/-/minimisted-2.0.1.tgz", + "integrity": "sha512-1oPjfuLQa2caorJUM8HV8lGgWCc0qqAO1MNv/k05G4qslmsndV/5WdNZrqCiyqiz3wohia2Ij2B7w2Dr7/IyrA==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2417,6 +2873,15 @@ "node": ">=0.10.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -2456,6 +2921,12 @@ "node": ">=6" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2533,6 +3004,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz", + "integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", @@ -2546,6 +3026,15 @@ "node": ">=8" } }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", @@ -2575,6 +3064,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -2606,6 +3104,22 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -2758,7 +3272,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -2818,6 +3331,43 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sha.js": { + "version": "2.4.12", + "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", + "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", + "license": "(MIT AND BSD-3-Clause)", + "dependencies": { + "inherits": "^2.0.4", + "safe-buffer": "^5.2.1", + "to-buffer": "^1.2.0" + }, + "bin": { + "sha.js": "bin.js" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -2861,6 +3411,51 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -2929,6 +3524,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3066,6 +3670,20 @@ "node": ">=14.0.0" } }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3100,6 +3718,20 @@ "webpack": "^5.0.0" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -3500,6 +4132,27 @@ "node": ">= 8" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3523,6 +4176,12 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true, "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" } } } diff --git a/extension/package.json b/extension/package.json index f912527..053af6d 100644 --- a/extension/package.json +++ b/extension/package.json @@ -13,6 +13,7 @@ "test:watch": "vitest" }, "dependencies": { + "isomorphic-git": "^1.38.5", "jsqr": "^1.4.0" }, "devDependencies": { diff --git a/extension/src/service-worker/__tests__/git-push.test.ts b/extension/src/service-worker/__tests__/git-push.test.ts new file mode 100644 index 0000000..d03ebe1 --- /dev/null +++ b/extension/src/service-worker/__tests__/git-push.test.ts @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { createPrivateKey, sign as nodeSign } from 'node:crypto'; +import * as git from 'isomorphic-git'; +import { commitSignedPush } from '../git-push'; +import { GiteaHost } from '../gitea'; +import { GitHubHost } from '../github'; +import type { CommitFile, CommitSigner } from '../git-host'; + +// --------------------------------------------------------------------------- +// Mock isomorphic-git so no network or filesystem I/O occurs. +// The vi.mock calls are hoisted by Vitest and run before any imports. +// --------------------------------------------------------------------------- + +vi.mock('isomorphic-git', () => ({ + clone: vi.fn(async () => {}), + add: vi.fn(async () => {}), + commit: vi.fn(async () => 'oid'), + push: vi.fn(async () => ({ ok: true, errors: [] })), +})); +vi.mock('isomorphic-git/http/web', () => ({ default: {} })); + +// --------------------------------------------------------------------------- +// Golden-vector constants — byte-exact, identical to sshsig.test.ts. +// Tied to the SSHSIG spec proven against `git verify-commit`. +// --------------------------------------------------------------------------- + +const SEED_HEX = + '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f'; + +const PUB_OPENSSH = + 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAOhB7/zzhC+HXDdGOdLwJln5NYwm6UNXx3chmQSVTG4'; + +const PAYLOAD = + 'tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor Owner 1750000000 +0000\ncommitter Owner 1750000000 +0000\n\norg add\n'; + +const GOLDEN_ARMOR = + '-----BEGIN SSH SIGNATURE-----\n' + + 'U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgA6EHv/POEL4dcN0Y50vAmWfk1j\n' + + 'CbpQ1fHdyGZBJVMbgAAAADZ2l0AAAAAAAAAAZzaGE1MTIAAABTAAAAC3NzaC1lZDI1NTE5\n' + + 'AAAAQFLDkEyykOw6Z+I9qLKtTDToj2pXIfgvn+PoNDa5PfE6DQ/BmetpymL2aVdQXSpQ4k\n' + + 'i4fjWKgx63Q43NsyR/cAs=\n' + + '-----END SSH SIGNATURE-----'; + +// --------------------------------------------------------------------------- +// Fixed-seed deterministic ed25519 signer (Node.js only; test-only). +// The injected rawSign approach mirrors the production SW wiring, where +// rawSign wraps wasm.sign_for_git; here we use node:crypto for the seed. +// --------------------------------------------------------------------------- + +function fixedSigner(): (data: Uint8Array) => Uint8Array { + const pkcs8 = Buffer.concat([ + Buffer.from('302e020100300506032b657004220420', 'hex'), + Buffer.from(SEED_HEX, 'hex'), + ]); + const key = createPrivateKey({ key: pkcs8, format: 'der', type: 'pkcs8' }); + return (data: Uint8Array) => + new Uint8Array(nodeSign(null, Buffer.from(data), key)); +} + +// --------------------------------------------------------------------------- +// Shared test fixtures +// --------------------------------------------------------------------------- + +const GIT_URL = 'https://git.example.com/org/vault.git'; +const BRANCH = 'main'; +const AUTH = { username: 'relicario', password: 'test-token' }; + +function makeSigner(): CommitSigner { + return { + author: { name: 'Test User', email: 'test@example.com' }, + signingPubOpenssh: PUB_OPENSSH, + rawSign: fixedSigner(), + }; +} + +// --------------------------------------------------------------------------- +// Engine tests +// --------------------------------------------------------------------------- + +describe('commitSignedPush engine', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('1 — clone called once with correct url / ref / singleBranch / depth / onAuth', async () => { + const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1, 2, 3]) }]; + await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = (vi.mocked(git.clone).mock.calls[0][0] as any); + expect(vi.mocked(git.clone)).toHaveBeenCalledTimes(1); + expect(args.url).toBe(GIT_URL); + expect(args.ref).toBe(BRANCH); + expect(args.singleBranch).toBe(true); + expect(args.depth).toBe(1); + expect(typeof args.onAuth).toBe('function'); + expect(args.onAuth()).toEqual(AUTH); + }); + + test('2 — add called once per file with the right filepath', async () => { + const files: CommitFile[] = [ + { path: 'a/b.enc', content: new Uint8Array([1]) }, + { path: 'c.enc', content: new Uint8Array([2]) }, + ]; + await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }); + + expect(vi.mocked(git.add)).toHaveBeenCalledTimes(2); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((vi.mocked(git.add).mock.calls[0][0] as any).filepath).toBe('a/b.enc'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((vi.mocked(git.add).mock.calls[1][0] as any).filepath).toBe('c.enc'); + }); + + test('3 — golden-vector tie: onSign wires buildSshSig correctly', async () => { + // Use the fixed vector signer (same seed/key as sshsig.test.ts). + const vectorSigner: CommitSigner = { + author: { name: 'Owner', email: 'o@acme' }, + signingPubOpenssh: PUB_OPENSSH, + rawSign: fixedSigner(), + }; + await commitSignedPush({ + gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, + files: [], + message: 'org add', + signer: vectorSigner, + }); + + // Capture the onSign that was passed to git.commit and invoke it with the + // fixed PAYLOAD. The returned signature MUST equal GOLDEN_ARMOR byte-for-byte. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const commitArgs = (vi.mocked(git.commit).mock.calls[0][0] as any); + const { signature } = await commitArgs.onSign({ payload: PAYLOAD, secretKey: 'device' }); + expect(signature).toBe(GOLDEN_ARMOR); + }); + + test('4 — push called with correct url / ref / remoteRef / onAuth', async () => { + const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; + await commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }); + + expect(vi.mocked(git.push)).toHaveBeenCalledTimes(1); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = (vi.mocked(git.push).mock.calls[0][0] as any); + expect(args.url).toBe(GIT_URL); + expect(args.ref).toBe(BRANCH); + expect(args.remoteRef).toBe(BRANCH); + expect(typeof args.onAuth).toBe('function'); + expect(args.onAuth()).toEqual(AUTH); + }); + + test('5 — push rejection throws with server detail', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(git.push).mockResolvedValueOnce({ ok: false, errors: ['pre-receive hook declined'] } as any); + const files: CommitFile[] = [{ path: 'vault/item.enc', content: new Uint8Array([1]) }]; + + await expect( + commitSignedPush({ gitUrl: GIT_URL, branch: BRANCH, auth: AUTH, files, message: 'test', signer: makeSigner() }), + ).rejects.toThrow('pre-receive hook declined'); + }); +}); + +// --------------------------------------------------------------------------- +// Host wrapper tests — verifies commitSigned delegates correctly to the engine. +// The mocked git.clone captures the gitUrl and auth shape for each host. +// --------------------------------------------------------------------------- + +describe('GitHost wrappers — commitSigned wires commitSignedPush correctly', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('GiteaHost: builds https:///.git gitUrl; auth.password === apiToken', async () => { + // LIVE RESIDUAL: exact Basic-auth acceptance confirmed only by a live push against Gitea. + const signer = makeSigner(); + await new GiteaHost('https://git.example', 'o/r', 'tok').commitSigned([], 'm', signer); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = (vi.mocked(git.clone).mock.calls[0][0] as any); + expect(args.url).toBe('https://git.example/o/r.git'); + expect(args.onAuth()).toEqual({ username: 'relicario', password: 'tok' }); + }); + + test('GitHubHost: builds https://github.com/.git; auth.username === apiToken', async () => { + // LIVE RESIDUAL: GitHub PAT-over-Basic (username=token, password=x-oauth-basic) unconfirmed live. + const signer = makeSigner(); + await new GitHubHost('o/r', 'tok').commitSigned([], 'm', signer); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const args = (vi.mocked(git.clone).mock.calls[0][0] as any); + expect(args.url).toBe('https://github.com/o/r.git'); + expect(args.onAuth()).toEqual({ username: 'tok', password: 'x-oauth-basic' }); + }); +}); diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts index 45c6fa9..be7fa14 100644 --- a/extension/src/service-worker/git-host.ts +++ b/extension/src/service-worker/git-host.ts @@ -4,6 +4,27 @@ /// read, write, and delete individual files without cloning the repo. /// This interface captures just the operations the vault needs. +/// A single file to include in a commitSigned batch. +export interface CommitFile { + path: string; + content: Uint8Array; +} + +/// Ed25519 signing identity used by commitSigned. +/// +/// In production the SW passes a thin wrapper around `wasm.sign_for_git`; +/// in tests, a fixed-seed Node.js signer is injected for determinism. +export interface CommitSigner { + author: { name: string; email: string }; + /// The signer's public key in OpenSSH authorized-keys format, + /// e.g. "ssh-ed25519 AAAA... [comment]". + signingPubOpenssh: string; + /// Raw ed25519 signer — equivalent to base64decode(sign_for_git(data).signature). + /// Receives the SSHSIG blob (pre-hashed per the framing spec) and returns + /// the 64-byte raw ed25519 signature. + rawSign: (data: Uint8Array) => Promise | Uint8Array; +} + export interface GitHost { /// Read a single file from the repo, returning its raw bytes. readFile(path: string): Promise; @@ -42,6 +63,13 @@ export interface GitHost { /// kept distinct for symmetry with putBlob. deleteBlob(path: string, message: string): Promise; + /// Sign a batch of file changes as a single git commit and push to the host. + /// + /// Uses isomorphic-git over the HTTP/web transport (clone→add→commit→push). + /// Commit is signed with SSHSIG using the provided CommitSigner. Throws if + /// the push is rejected by the server or a pre-receive hook. + commitSigned(files: CommitFile[], message: string, signer: CommitSigner): Promise; + /// Cached sync metadata, populated by the `sync` handler — get_vault_status /// reads these without any network call. lastSyncAt is unix SECONDS (or null /// until the first sync). ahead/behind exist for parity with `relicario diff --git a/extension/src/service-worker/git-push.ts b/extension/src/service-worker/git-push.ts new file mode 100644 index 0000000..cfbbe67 --- /dev/null +++ b/extension/src/service-worker/git-push.ts @@ -0,0 +1,128 @@ +/** + * git-push.ts — universal isomorphic-git clone→add→commit→push engine for + * Relicario org-vault writes. + * + * Signs commits with SSHSIG (Relicario's commit-signing format) and pushes + * over git-receive-pack using the HTTP/web transport (fetch-based, required in + * the extension service worker — isomorphic-git/http/node is NOT available and + * MUST NOT be substituted here). + * + * The extension's host_permissions grant the SW fetch() access to org git + * hosts, so no CORS proxy is needed. + * + * The clone→sign→push flow is proven end-to-end against the Relicario org + * pre-receive hook (docs/superpowers/spikes/2026-06-20-org-signed-commit-spike.md). + * Build this engine faithfully; do not alter the onSign wiring or the + * push-rejection check. + */ + +import * as git from 'isomorphic-git'; +import type { FsClient } from 'isomorphic-git'; +import http from 'isomorphic-git/http/web'; +import { createMemFs } from './mem-fs'; +import { buildSshSig } from './sshsig'; +import type { CommitFile, CommitSigner } from './git-host'; + +// Runtime push results from isomorphic-git include an `errors` array that is +// not reflected in the TypeScript PushResult type. Intersect it here so the +// rejection check compiles under strict mode. +type PushResultRuntime = { ok: boolean; errors?: string[] }; + +/** + * Clone `gitUrl` at depth 1, write `files`, sign the commit with SSHSIG, and + * push back to the same URL. Throws if the server or a pre-receive hook + * rejects the push. + */ +export async function commitSignedPush(opts: { + gitUrl: string; + branch: string; + auth: { username: string; password: string }; + files: CommitFile[]; + message: string; + signer: CommitSigner; +}): Promise { + // mem-fs.ts declares a local PromiseFsClient to avoid pulling in the + // isomorphic-git package. Keep both a typed handle (for our own writeFile / + // mkdir calls) and a FsClient cast (for git.* functions) from the same object. + const memFs = createMemFs(); + const fs = memFs as unknown as FsClient; // structural cast — proven compatible + const dir = '/repo'; + const onAuth = () => opts.auth; + + // Shallow clone — we only need HEAD to build the new commit on top of it. + await git.clone({ + fs, http, dir, + url: opts.gitUrl, + ref: opts.branch, + singleBranch: true, + depth: 1, + onAuth, + }); + + // Write each file into the in-memory tree and stage it. + for (const f of opts.files) { + await mkdirp(memFs, dir, f.path); + await memFs.promises.writeFile(`${dir}/${f.path}`, f.content); + await git.add({ fs, dir, filepath: f.path }); + } + + const ident = { + name: opts.signer.author.name, + email: opts.signer.author.email, + timestamp: Math.floor(Date.now() / 1000), + timezoneOffset: 0, + }; + + // Commit with SSHSIG signing. + // `signingKey: 'device'` is a truthy sentinel — isomorphic-git passes it as + // `secretKey` to `onSign`. We ignore secretKey and use our own signer. + // `payload` is the serialised commit object (exactly what `git verify-commit` + // checks); we TextEncoder-encode it before handing it to buildSshSig. + await git.commit({ + fs, dir, + message: opts.message, + author: ident, + committer: ident, + signingKey: 'device', + onSign: async ({ payload }) => ({ + signature: await buildSshSig( + new TextEncoder().encode(payload), + opts.signer.signingPubOpenssh, + opts.signer.rawSign, + ), + }), + }); + + // Push over git-receive-pack. The runtime result includes `errors[]` when a + // pre-receive hook declines — cast to PushResultRuntime to access it. + const res = await git.push({ + fs, http, dir, + url: opts.gitUrl, + ref: opts.branch, + remoteRef: opts.branch, + onAuth, + }) as unknown as PushResultRuntime; + + const ok = res?.ok && (!res.errors || res.errors.length === 0); + if (!ok) { + throw new Error( + `org push rejected by server/hook: ${JSON.stringify(res?.errors ?? res)}`, + ); + } +} + +/** + * Ensure all parent directories of `filePath` exist inside `dir` in the + * given mem-fs. mem-fs.mkdir never throws, so no EEXIST guard is needed. + */ +async function mkdirp( + memFs: ReturnType, + dir: string, + filePath: string, +): Promise { + const segments = filePath.split('/').slice(0, -1); // drop the filename + for (let i = 0; i < segments.length; i++) { + const prefix = segments.slice(0, i + 1).join('/'); + await memFs.promises.mkdir(`${dir}/${prefix}`); + } +} diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index d51a931..db3d3c7 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -1,5 +1,6 @@ -import type { GitHost } from './git-host'; +import type { GitHost, CommitFile, CommitSigner } from './git-host'; import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host'; +import { commitSignedPush } from './git-push'; /// Gitea Contents API implementation. /// @@ -20,6 +21,8 @@ export class GiteaHost implements GitHost { private keysUrl: string; private branch: string = 'main'; private headers: Record; + private gitUrl: string; + private apiToken: string; lastSyncAt: number | null = null; ahead = 0; behind = 0; @@ -37,6 +40,8 @@ export class GiteaHost implements GitHost { 'Content-Type': 'application/json', 'Accept': 'application/json', }; + this.gitUrl = `${host}/${repoPath}.git`; + this.apiToken = apiToken; } async readFile(path: string): Promise { @@ -250,6 +255,24 @@ export class GiteaHost implements GitHost { return this.deleteFile(path, message); } + /// Sign a batch of changes as a single SSHSIG-signed commit and push to + /// the Gitea repo over git-receive-pack. + /// + /// Auth: Gitea accepts a PAT as the Basic-auth password; the username field + /// is ignored by Gitea token auth. Pending live-push confirmation. + async commitSigned(files: CommitFile[], message: string, signer: CommitSigner): Promise { + return commitSignedPush({ + gitUrl: this.gitUrl, + branch: this.branch, + // LIVE RESIDUAL: Gitea PAT-over-Basic (username ignored, password=token) + // — accepted in spike but not yet confirmed from a production Gitea push. + auth: { username: 'relicario', password: this.apiToken }, + files, + message, + signer, + }); + } + /// Create a deploy key for this repo, returning its numeric ID. async createDeployKey(title: string, publicKey: string): Promise { const resp = await fetch(this.keysUrl, { diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index 85bf198..6f92b0c 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -1,5 +1,6 @@ -import type { GitHost } from './git-host'; +import type { GitHost, CommitFile, CommitSigner } from './git-host'; import { uint8ArrayToBase64, base64ToUint8Array, BLOB_THRESHOLD_BYTES } from './git-host'; +import { commitSignedPush } from './git-push'; /// GitHub Contents API implementation. /// @@ -17,6 +18,8 @@ export class GitHubHost implements GitHost { private commitsUrl: string; private branch: string = 'main'; private headers: Record; + private repoPath: string; + private apiToken: string; lastSyncAt: number | null = null; ahead = 0; behind = 0; @@ -31,6 +34,8 @@ export class GitHubHost implements GitHost { 'Accept': 'application/vnd.github.v3+json', 'X-GitHub-Api-Version': '2022-11-28', }; + this.repoPath = repoPath; + this.apiToken = apiToken; } async readFile(path: string): Promise { @@ -240,4 +245,23 @@ export class GitHubHost implements GitHost { async deleteBlob(path: string, message: string): Promise { return this.deleteFile(path, message); } + + /// Sign a batch of changes as a single SSHSIG-signed commit and push to + /// GitHub over git-receive-pack. + /// + /// Auth: classic PAT-over-Basic form that git uses for GitHub: username is + /// the token itself, password is the literal string "x-oauth-basic". + /// Pending live-push confirmation. + async commitSigned(files: CommitFile[], message: string, signer: CommitSigner): Promise { + return commitSignedPush({ + gitUrl: `https://github.com/${this.repoPath}.git`, + branch: this.branch, + // LIVE RESIDUAL: GitHub PAT-over-Basic (username=token, password=x-oauth-basic) + // — standard git protocol; not yet confirmed from a production GitHub push. + auth: { username: this.apiToken, password: 'x-oauth-basic' }, + files, + message, + signer, + }); + } }