Merge branch 'authorized-fetch'

This commit is contained in:
fenwick67 2024-10-13 13:32:16 -04:00
commit f002795da8
11 changed files with 2521 additions and 2135 deletions

View File

@ -0,0 +1,71 @@
# Docs for the Azure Web Apps Deploy action: https://github.com/Azure/webapps-deploy
# More GitHub Actions for Azure: https://github.com/Azure/actions
name: Build and deploy Node.js app to Azure Web App - mastofeed
on:
push:
branches:
- authorized-fetch
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js version
uses: actions/setup-node@v3
with:
node-version: '18.x'
- name: npm install, build, and test
run: |
npm install
npm run build --if-present
npm run test --if-present
- name: Zip artifact for deployment
run: zip release.zip ./* -r
- name: Upload artifact for deployment job
uses: actions/upload-artifact@v4
with:
name: node-app
path: release.zip
deploy:
runs-on: ubuntu-latest
needs: build
environment:
name: 'Production'
url: ${{ steps.deploy-to-webapp.outputs.webapp-url }}
permissions:
id-token: write #This is required for requesting the JWT
steps:
- name: Download artifact from build job
uses: actions/download-artifact@v4
with:
name: node-app
- name: Unzip artifact for deployment
run: unzip release.zip
- name: Login to Azure
uses: azure/login@v2
with:
client-id: ${{ secrets.AZUREAPPSERVICE_CLIENTID_018ACC08E15440AE93CB0FEFF9E7DA74 }}
tenant-id: ${{ secrets.AZUREAPPSERVICE_TENANTID_4ECDC4A12EFB4AA5B9ECFD9556076FB4 }}
subscription-id: ${{ secrets.AZUREAPPSERVICE_SUBSCRIPTIONID_535A65DB81F54297A612AF9BEF13A6C9 }}
- name: 'Deploy to Azure Web App'
id: deploy-to-webapp
uses: azure/webapps-deploy@v3
with:
app-name: 'mastofeed'
slot-name: 'Production'
package: .

42
LICENSE
View File

@ -1,21 +1,21 @@
The MIT License The MIT License
Copyright © 2017 fenwick67 Copyright © 2024 fenwick67
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions: furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.

View File

@ -48,7 +48,19 @@ Querystring options:
## Server Installation ## Server Installation
This is a straightforward node project with zero databases or anything, you should just be able to run `npm install` and then `npm start` to get up and running. Set your `PORT` environment variable to change the port it listens on. This is a straightforward node project with zero databases or anything, you should just be able to run `npm install` and then `npm start` to get up and running.
### ENV VARS
then set em
you need to set:
`AP_PRIVATE_KEY_BASE64=asdfsd` Run utils/make-keys.js to make a key
`AP_PUBLIC_KEY_BASE64=safasdf` Run utils/make-keys.js to make a key
`DOMAIN_NAME=mastofeed.com` or whatever
`PORT=80` or whatever
## Improve me ## Improve me

53
authorized_fetch_notes.md Normal file
View File

@ -0,0 +1,53 @@
ok so here's the fucking deal...
https://docs.joinmastodon.org/admin/config/#authorized_fetch
when turned on:
> (...) Mastodon will require HTTP signature authentication on ActivityPub representations of public posts and profiles, which are normally available without any authentication. Profiles will only return barebones technical information when no authentication is supplied.
HTTP signature authentication
> HTTP Signatures is a specification for signing HTTP messages by using a Signature: header with your HTTP request. Mastodon requires the use of HTTP Signatures in order to validate that any activity received was authored by the actor generating it. When secure mode is enabled, all GET requests require HTTP signatures as well.
---
from https://docs.joinmastodon.org/spec/security/#http
> For any HTTP request incoming to Mastodon, the Signature header should be attached:
> Signature: keyId="https://my.example.com/actor#main-key",headers="(request-target) host date",signature="Y2FiYW...IxNGRiZDk4ZA=="
> The three parts of the Signature: header can be broken down like so:
```
Signature:
keyId="https://my.example.com/actor#main-key",
headers="(request-target) host date",
signature="Y2FiYW...IxNGRiZDk4ZA=="
```
> The keyId should correspond to the actor and the key being used to generate the signature, whose value is equal to all parameters in headers concatenated together and signed by the key, then Base64-encoded. See ActivityPub > Public key for more information on actor keys. An example key looks like this:
```json
"publicKey": {
"id": "https://my.example.com/actor#main-key",
"owner": "https://my.example.com/actor",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvXc4vkECU2/CeuSo1wtn\nFoim94Ne1jBMYxTZ9wm2YTdJq1oiZKif06I2fOqDzY/4q/S9uccrE9Bkajv1dnkO\nVm31QjWlhVpSKynVxEWjVBO5Ienue8gND0xvHIuXf87o61poqjEoepvsQFElA5ym\novljWGSA/jpj7ozygUZhCXtaS2W5AD5tnBQUpcO0lhItYPYTjnmzcc4y2NbJV8hz\n2s2G8qKv8fyimE23gY1XrPJg+cRF+g4PqFXujjlJ7MihD9oqtLGxbu7o1cifTn3x\nBfIdPythWu5b4cujNsB3m3awJjVmx+MHQ9SugkSIYXV0Ina77cTNS0M2PYiH1PFR\nTwIDAQAB\n-----END PUBLIC KEY-----\n"
},
```
-------
so for me...
[x] make a key (what kind? RSA SHA256)
[x] reimplement `https://mastodon.social/actor`
[x] sign the headers when making a request (done?)
[ ] test
test by pushing it out and seeing if it works.
NOTE that you can run a local copy AND the remote copy and do local debugging as long as the keys are the same, the remote server won't care about the actual request comes from!

102
index.js
View File

@ -6,6 +6,7 @@ var cors = require('cors');
var errorPage = require('./lib/errorPage'); var errorPage = require('./lib/errorPage');
var morgan = require('morgan'); var morgan = require('morgan');
var compression = require('compression') var compression = require('compression')
const apCryptoShit = require('./lib/apCryptoShit')
var app = Express(); var app = Express();
@ -109,12 +110,105 @@ app.get('/apiv2/feed',cors(),logger,function(req,res){
res.send(data); res.send(data);
}).catch((er)=>{ }).catch((er)=>{
res.status(500); res.status(500);
res.send(errorPage(500,null,{theme:opts.theme,size:opts.size})); res.send(errorPage(500,er.toString(),{theme:opts.theme,size:opts.size}));
// TODO log the error // log the error
console.error(er,er.stack); console.error(er,er.stack);
}) })
}) })
app.listen(process.env.PORT || 8000,function(){ app.get('/actor', logger, function(req,res){
console.log('Server started, listening on '+(process.env.PORT || 8000)); // return something like what https://mastodon.social/actor does...
res.status(200);
let j = {
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"toot": "http://joinmastodon.org/ns#",
"featured": {
"@id": "toot:featured",
"@type": "@id"
},
"featuredTags": {
"@id": "toot:featuredTags",
"@type": "@id"
},
"alsoKnownAs": {
"@id": "as:alsoKnownAs",
"@type": "@id"
},
"movedTo": {
"@id": "as:movedTo",
"@type": "@id"
},
"schema": "http://schema.org#",
"PropertyValue": "schema:PropertyValue",
"value": "schema:value",
"discoverable": "toot:discoverable",
"suspended": "toot:suspended",
"memorial": "toot:memorial",
"indexable": "toot:indexable",
"attributionDomains": {
"@id": "toot:attributionDomains",
"@type": "@id"
}
}
],
"id": `https://${apCryptoShit.getDomainName()}/actor`,
"type": "Application",
"inbox": `https://${apCryptoShit.getDomainName()}/actor/inbox`,
"outbox": `https://${apCryptoShit.getDomainName()}/actor/outbox`,
"preferredUsername": `${apCryptoShit.getDomainName()}`,
"url": `https://${apCryptoShit.getDomainName()}`,
"manuallyApprovesFollowers": true,
"publicKey": {
"id": `https://${apCryptoShit.getDomainName()}/actor#main-key`,
"owner": `https://${apCryptoShit.getDomainName()}/actor`,
"publicKeyPem": apCryptoShit.getPublicKey()
},
"endpoints": {
"sharedInbox": `https://${apCryptoShit.getDomainName()}/inbox`
}
};
res.setHeader("content-type","application/activity+json; charset=utf-8")
res.send(JSON.stringify(j,null,2));
})
app.get('/.well-known/webfinger', function(req,res){
let domainName = apCryptoShit.getDomainName();
if (req.query.resource == `acct:${domainName}@${domainName}`){
res.setHeader("content-type","application/jrd+json; charset=utf-8");
var resJson = {
"subject": `acct:${domainName}@${domainName}`,
"aliases": [
`https://${domainName}/actor`
],
"links": [
{
"rel": "http://webfinger.net/rel/profile-page",
"type": "text/html",
"href": `https://${domainName}`
},
{
"rel": "self",
"type": "application/activity+json",
"href": `https://${domainName}/actor`
}
// ,
// {
// "rel": "http://ostatus.org/schema/1.0/subscribe",
// "template": "https://mastodon.social/authorize_interaction?uri={uri}"
// }
]
};
return res.send(JSON.stringify(resJson));
} else {
res.status(404);
res.send("unknown user");
}
})
app.listen(process.env.PORT || 8080,function(){
console.log('Mastofeed started, listening on '+(process.env.PORT || 8080));
}); });

70
lib/apCryptoShit.js Normal file
View File

@ -0,0 +1,70 @@
const crypto = require('crypto')
// public methods
function getPublicKey(){
_precheck()
return _pubKey
}
function getPrivateKey(){
_precheck()
return _privKey
}
function getDomainName(){
_precheck();
return _domainName;
}
function getKeyId(){
_precheck();
return _keyId;
}
function sign(str){
_precheck()
var signerObject = crypto.createSign("RSA-SHA256");// needs to be "RSASSA-PKCS1-v1_5 with SHA-256" I'm assuming this is RSA_PKCS1_PADDING...???
signerObject.update(str);
return signerObject.sign({key:_privKey,padding:crypto.constants.RSA_PKCS1_PADDING}, "base64");
}
function verify(str,signature){
_precheck();
var verifierObject = crypto.createVerify("RSA-SHA256");
verifierObject.update(str);
return verifierObject.verify({key:_pubKey, padding:crypto.constants.RSA_PKCS1_PADDING}, signature, "base64");
}
// private
let _precheckOk=false;
let _privKey=""
let _pubKey="";
let _keyId=""
let _domainName=""
function _precheck(){
if (_precheckOk){return;}
if (!process.env.AP_PRIVATE_KEY_BASE64 || !process.env.AP_PUBLIC_KEY_BASE64){
console.error("you dumb shit, set AP_PRIVATE_KEY_BASE64 / AP_PUBLIC_KEY_BASE64 ")
process.exit(1)
}
_pubKey=atob(process.env.AP_PUBLIC_KEY_BASE64);
_privKey=atob(process.env.AP_PRIVATE_KEY_BASE64);
// actually check it lol
var signerObject = crypto.createSign("RSA-SHA256");
signerObject.update("hello world");
let signature = signerObject.sign({key:_privKey, padding:crypto.constants.RSA_PKCS1_PSS_PADDING}, "base64");
var verifierObject = crypto.createVerify("RSA-SHA256");
verifierObject.update("hello world");
var verified = verifierObject.verify({key:_pubKey, padding:crypto.constants.RSA_PKCS1_PSS_PADDING}, signature, "base64");
if (!verified){
console.error("idk what the fuck you did but the private and public keys dont fucking uhh work???")
console.error('probably fix your AP_PRIVATE_KEY_BASE64 and AP_PUBLIC_KEY_BASE64')
process.exit(1)
}
_domainName = process.env.DOMAIN_NAME || "mastofeed.com"
_keyId=`https://${_domainName}/actor#main-key`
_precheckOk=true;
}
module.exports={sign,verify,getPublicKey,getPrivateKey,getDomainName,getKeyId}

View File

@ -1,6 +1,10 @@
const axios = require('axios') const axios = require('axios')
const NanoCache = require('nano-cache') const NanoCache = require('nano-cache')
const hour = 3600000; const hour = 3600000;
const apCryptoShit = require('./apCryptoShit')
let createAuthzHeader=null;
let createSignatureString=null;
const cache = new NanoCache({ const cache = new NanoCache({
ttl: 24 * hour ttl: 24 * hour
@ -15,40 +19,68 @@ cache.on('del',key=>console.log(key,'deleted'))
// note: rejects on HTTP 4xx or 5xx // note: rejects on HTTP 4xx or 5xx
module.exports = async function apGet(url,ttl) { module.exports = async function apGet(url,ttl) {
return new Promise(function(resolve,reject){
// fail early // fail early
if (!url){ if (!url){
return reject(new Error('URL is invalid')); throw new Error('URL is invalid');
} }
var cachedResponse = cache.get(url); var cachedResponse = cache.get(url);
if (cachedResponse){ if (cachedResponse){
return resolve(cachedResponse); return cachedResponse;
} }
axios( { // import the signature module if we haven't already
if (!createAuthzHeader || !createSignatureString){
const module = await import('@digitalbazaar/http-signature-header');
createAuthzHeader=module.createAuthzHeader;
createSignatureString=module.createSignatureString;
}
let axiosOpts = {
method:'get', method:'get',
url:url, url:url,
headers: { headers: {
"accept": "application/activity+json", "accept": "application/activity+json",
"User-Agent": "mastofeed.com" "User-Agent": "mastofeed.com",
"date":new Date().toUTCString()
}, },
responseType: 'json', responseType: 'json',
};
const includeHeaders = ['(request-target)', 'host', 'date'];
const plaintext = createSignatureString({
includeHeaders,
requestOptions: axiosOpts
});
}) const signature = apCryptoShit.sign(plaintext);
.then((response)=>{
// axios would have rejected if we got a 4xx or 5xx or not json const Authorization = createAuthzHeader({
cache.set(url, response.data, { includeHeaders,
ttl: ttl || 24 * hour keyId: apCryptoShit.getKeyId(),
}); signature
return response.data });
})
.then(resolve) axiosOpts.headers.Signature=Authorization;
.catch(reject)
})
console.log("axios request info: \n"+JSON.stringify(axiosOpts,null,2));
console.log('string that was signed: \n---\n'+plaintext+'\n---')
let response
try {
response = await axios(axiosOpts)
} catch(e){
if (e.response){
throw new Error(`got ${e.response.status} response from server: `+JSON.stringify(e.response.data))
} else {
throw e
}
}
// axios would have rejected if we got a 4xx or 5xx or not json
cache.set(url, response.data, {
ttl: ttl || 24 * hour
});
return response.data
} }

View File

@ -1,5 +1,6 @@
{ {
"dependencies": { "dependencies": {
"@digitalbazaar/http-signature-header": "^5.0.1",
"axios": "^1.2.2", "axios": "^1.2.2",
"compression": "^1.7.4", "compression": "^1.7.4",
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@ -0,0 +1,8 @@
const crypto = require('crypto')
const cryptoShit = require('../lib/apCryptoShit.js')
var signature = cryptoShit.sign("hello world")
var verified = cryptoShit.verify("hello world", signature)
console.info("is signature ok?: %s", verified);

38
utils/make-keys.js Normal file
View File

@ -0,0 +1,38 @@
const crypto = require('crypto')
const cryptoShit = require('../lib/apCryptoShit.js')
crypto.generateKeyPair('rsa', {
modulusLength: 1024,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
}, (err, pub, priv)=>{
if (err){
console.error(err);
process.exit(1);
}
console.log('your public key (base64), set AP_PUBLIC_KEY_BASE64 in your environment :\n'+btoa(pub));
console.log('your private key (base64), set AP_PRIVATE_KEY_BASE64 in your environment:\n'+btoa(priv));
process.env.AP_PRIVATE_KEY_BASE64=btoa(priv);
process.env.AP_PUBLIC_KEY_BASE64=btoa(pub);
var signature = cryptoShit.sign("hello world")
var verifierObject = crypto.createVerify("RSA-SHA256");
verifierObject.update("hello world");
var verified = verifierObject.verify({key:pub, padding:crypto.constants.RSA_PKCS1_PSS_PADDING}, signature, "base64");
if (verified){
process.exit(0);
}
else {
console.error("FUCK IT DIDNT WORK OH GOD")
process.exit(1)
}
});

4189
yarn.lock

File diff suppressed because it is too large Load Diff