Skip to content

Commit

Permalink
initial implementation (#1)
Browse files Browse the repository at this point in the history
* initial implementation

* add statement generation

* move misc stuff to ./utils

* fix some errors

* setup jest and ts config

* default restriction to {}, meaning "anything"

* use canonicalize for encoding, improve decoding checks

* allow * namespace

* improve constructor

* fix restriction type and insertion

* add fns for extracting from and inserting into a SiweMessage

* basic tests

* test files

* impl merging of recaps
  • Loading branch information
chunningham authored Apr 19, 2023
1 parent 7a35ad9 commit cf5116b
Show file tree
Hide file tree
Showing 13 changed files with 3,847 additions and 0 deletions.
2 changes: 2 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
node_modules
11 changes: 11 additions & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
]
}
20 changes: 20 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const { pathsToModuleNameMapper } = require('ts-jest')
// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file
// which contains the path mapping (ie the `compilerOptions.paths` option):
const { compilerOptions } = require('./tsconfig')

/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
modulePaths: [compilerOptions.baseUrl],
moduleNameMapper: Object.assign(
pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/' }),
{
'^multiformats(.*)$': '<rootDir>/node_modules/multiformats/dist/index.min.js',
'^ethers(.*)$': '<rootDir>/node_modules/ethers/lib/index.js',
}
)
};
35 changes: 35 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "recap-ts",
"version": "0.0.1",
"description": "A Typescript implementation of EIP-5573 utilities",
"main": "dist/index.js",
"types": "dist/siwe.d.ts",
"license": "MIT",
"author": "Spruce Systems Inc.",
"scripts": {
"build": "tsc",
"test": "jest",
"lint": "eslint --ext .js,.ts .",
"format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json)\""
},
"devDependencies": {
"@types/jest": "^29.5.0",
"@typescript-eslint/eslint-plugin": "^5.23.0",
"@typescript-eslint/parser": "^5.23.0",
"eslint": "^8.38.0",
"eslint-config-prettier": "^8.5.0",
"jest": "^29.5.0",
"prettier": "^2.8.7",
"ts-jest": "^29.1.0",
"typescript": "^5.0.2",
"ethers": "^5.5.1"
},
"dependencies": {
"multiformats": "^11.0.2",
"canonicalize": "^2.0.0",
"siwe": "^2.0.5"
},
"peerDependencies": {
"ethers": "^5.5.1"
}
}
10 changes: 10 additions & 0 deletions prettierrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": true,
"arrowParens": "avoid",
"bracketSameLine": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf"
}
107 changes: 107 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { CID } from 'multiformats/cid';
import { SiweMessage } from 'siwe';

import { Recap } from './index';
import { validAbString, isSorted, validString } from './utils';

const jsonCap = require('../test/serialized_cap.json');
const valid = require('../test/valid.json');
const invalid = require('../test/invalid.json');

describe('Recap Handling', () => {
test('should build a recap', () => {
const recap = new Recap();

expect(recap.proofs).toEqual([]);

recap.addAttenuation('https://example.com', 'crud', 'read');
expect(recap.attenuations).toEqual({
'https://example.com': { 'crud/read': [{}] },
});
expect(recap.proofs).toEqual([]);

recap.addAttenuation('kepler:example://default/kv', 'kv', 'read');
expect(recap.attenuations).toEqual({
'https://example.com': { 'crud/read': [{}] },
'kepler:example://default/kv': { 'kv/read': [{}] }
});
expect(recap.proofs).toEqual([]);

recap.addAttenuation('kepler:example://default/kv', 'kv', 'write', { max: 10 });
expect(recap.attenuations).toEqual({
'https://example.com': { 'crud/read': [{}] },
'kepler:example://default/kv': { 'kv/read': [{}], 'kv/write': [{ max: 10 }] }
});
expect(recap.proofs).toEqual([]);

const cidStr = 'bagaaierasords4njcts6vs7qvdjfcvgnume4hqohf65zsfguprqphs3icwea';
const cid = CID.parse(cidStr);

recap.addProof(cidStr);
expect(recap.proofs).toEqual([cid]);
});
test('should decode properly', () => {
// @ts-ignore
for (const { message, recap } of Object.values(valid).map(
// @ts-ignore
({ message, recap: { att, prf } }) => ({
// @ts-ignore
message: new SiweMessage(message),
// @ts-ignore
recap: { att, prf: prf.map(CID.decode) }
}))
) {
let decoded;
// @ts-ignore
expect(() => decoded = Recap.extract_and_verify(message)).not.toThrow();
// @ts-ignore
expect(decoded.attenuations).toEqual(recap.att);
// @ts-ignore
let proofs = recap.prf.map(CID.decode);
// @ts-ignore
expect(decoded.proofs).toEqual(proofs);
}
// @ts-ignore
for (const { message } of Object.values(invalid)) {
expect(() => Recap.extract_and_verify(message)).toThrow();
}
})
})

describe('Utils', () => {
const unordered = {
c: 1,
b: 2,
ca: 3,
bnested: {
c: [3, 2, 1],
b: 2
}
};
const ordered = {
b: 2,
bnested: {
b: 2,
c: [3, 2, 1]
},
c: 1,
ca: 3
};
const validStrings = ['crud', 'kepler', 'https-proto'];
const validAbilityStrings = ['crud/read', 'kepler/*', 'https/put'];
const invalidAbilityStrings = ['crud', 'crud/read/write', 'with a/space', 'with/a space'];

test('should test for ordering', () => {
expect(isSorted(ordered)).toBeTruthy();
expect(isSorted(unordered)).toBeFalsy();
})
test('should test for valid strings', () => {
validStrings.forEach(str => expect(validString(str)).toBeTruthy());
validAbilityStrings.forEach((str) => {
expect(validAbString(str)).toBeTruthy();
})
invalidAbilityStrings.forEach((str) => {
expect(validAbString(str)).toBeFalsy();
})
})
})
155 changes: 155 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { CID } from 'multiformats/cid';
import {
AttObj,
PlainJSON,
encodeRecap,
decodeRecap,
validString,
checkAtt
} from './utils';
import { SiweMessage } from 'siwe';

export { AttObj, PlainJSON, CID };
const urnRecapPrefix = 'urn:recap:';

export class Recap {
#prf: Array<CID>;
#att: AttObj;

constructor(att: AttObj = {}, prf: Array<CID> | Array<string> = []) {
checkAtt(att);
this.#att = att;
this.#prf = prf.map(cid => typeof cid === 'string' ? CID.parse(cid) : cid);
}

get proofs(): Array<CID> {
return this.#prf;
}

get attenuations(): AttObj {
return this.#att;
}

get statement(): string {
let statement = 'I further authorize the stated URI to perform the following actions on my behalf: ';

let section = 1;
for (const resource of Object.keys(this.attenuations).sort()) {
const resourceAbilities = Object.keys(this.attenuations[resource]).sort().reduce((acc, cur) => {
const [namespace, name] = cur.split('/');
if (acc[namespace] === undefined) {
acc[namespace] = [name];
} else {
acc[namespace].push(name);
}
return acc;
}, {} as { [key: string]: Array<string> });

for (const [namespace, names] of Object.entries(resourceAbilities)) {
statement += `(${section}) "${namespace}": ${names.map(n => '"' + n + '"').join(', ')} for "${resource}". `;
section += 1;
}
}

return statement
}

addProof(cid: string | CID) {
if (typeof cid === 'string') {
this.#prf.push(CID.parse(cid))
} else {
this.#prf.push(cid);
}
}

addAttenuation(resource: string, namespace: string = '*', name: string = '*', restriction: { [key: string]: PlainJSON } = {}) {
if (!validString(namespace)) {
throw new Error('Invalid ability namespace');
}
if (!validString(name)) {
throw new Error('Invalid ability name');
}

const abString = `${namespace}/${name}`;
const ex = this.#att[resource];

if (ex !== undefined) {
if (ex[abString] !== undefined) {
ex[abString].push(restriction);
} else {
ex[abString] = [restriction];
}
} else {
this.#att[resource] = { [abString]: [restriction] };
}
}

merge(other: Recap) {
this.#prf.push(...other.proofs.filter(cid => !this.#prf.includes(cid)));

for (const [resource, abilities] of Object.entries(other.attenuations)) {
if (this.#att[resource] !== undefined) {
const ex = this.#att[resource];
for (const [ability, restrictions] of Object.entries(abilities)) {
if (ex[ability] === undefined || ex[ability].length === 0
|| ex[ability].every(r => Object.keys(r).length === 0)) {
ex[ability] = restrictions;
} else {
ex[ability].push(...restrictions);
}
}
} else {
this.#att[resource] = abilities;
}
}
}

static decode_urn(recap: string): Recap {
if (!recap.startsWith(urnRecapPrefix)) {
throw new Error('Invalid recap urn');
}

const { att, prf } = decodeRecap(recap.slice(urnRecapPrefix.length));
return new Recap(att, prf)
}

static extract(siwe: SiweMessage): Recap {
if (siwe.resources === undefined) {
throw new Error('No resources in SiweMessage');
}
let last_index = siwe.resources.length - 1;
return Recap.decode_urn(siwe.resources[last_index])
}

static extract_and_verify(siwe: SiweMessage): Recap {
const recap = Recap.extract(siwe);
if (siwe.statement === undefined || !siwe.statement.endsWith(recap.statement)) {
throw new Error('Invalid statement');
}
return recap
}

add_to_siwe_message(siwe: SiweMessage): SiweMessage {
try {
// try merge with existing recap
if (siwe.statement === undefined || siwe.resources === undefined || siwe.resources.length === 0) {
throw new Error('no recap')
}
let other = Recap.extract_and_verify(siwe);
let previousStatement = other.statement;
other.merge(this);
siwe.statement = siwe.statement.slice(0, -previousStatement.length) + other.statement;
siwe.resources[siwe.resources.length - 1] = other.encode();
return siwe
} catch (e) {
// no existing recap, just apply it
siwe.statement = siwe.statement === undefined ? this.statement : siwe.statement + ' ' + this.statement;
siwe.resources = siwe.resources === undefined ? [this.encode()] : [...siwe.resources, this.encode()];
return siwe
}
}

encode(): string {
return `${urnRecapPrefix}${encodeRecap(this.#att, this.#prf)}`
}
}
Loading

0 comments on commit cf5116b

Please sign in to comment.