Skip to content

Commit

Permalink
Integrity-verification of cards via parser-generated HMACs (#1853)
Browse files Browse the repository at this point in the history
* WIP save integrity hashes on cards

* minor

* minor

* almost done

* maybe fix tests

* minor
  • Loading branch information
AlexNisnevich authored May 19, 2023
1 parent e1d3927 commit 1b976d9
Show file tree
Hide file tree
Showing 33 changed files with 1,091 additions and 270 deletions.
42 changes: 29 additions & 13 deletions src/common/components/admin/CardMigrationPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { compact, countBy, noop, omit } from 'lodash';
import { compact, countBy, isEqual, noop, omit } from 'lodash';
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Paper } from '@material-ui/core';
import * as React from 'react';

import * as w from '../../types';
import * as g from '../../guards';
import { TYPE_EVENT } from '../../constants';
import { expandKeywords, getSentencesFromInput, parseBatch } from '../../util/cards';
import { md5 } from '../../util/common';
import { getCardAbilities, getCardSentences, parseBatch } from '../../util/cards';
import * as firebase from '../../util/firebase';
import { collection as coreSet } from '../../store/cards';
import CardGrid from '../cards/CardGrid';
import { Card } from '../card/Card';
import { DIALOG_PAPER_BASE_STYLE } from '../RouterDialog';
import CardGrid from '../cards/CardGrid';

interface PreviewReport {
statistics: {
numErrors: number
numChanged: number
numUnchanged: number
}
changedCards: Array<w.CardInStore & { parseErrors: string[], oldAbilities: string[], newAbilities: string[] }>
changedCards: Array<w.CardInStore & { parseErrors: string[], oldAbilities: string[], newAbilities: string[], oldIntegrity: w.Hashes[] }>
}

interface CardMigrationPanelProps {
Expand Down Expand Up @@ -113,11 +115,17 @@ class CardMigrationPanel extends React.PureComponent<CardMigrationPanelProps> {
</div>
<div style={{ padding: 15 }}>
<div>Old JS ({card.parserV}): <pre style={{ whiteSpace: 'pre-wrap', width: '100%' }}>{card.oldAbilities.join('\n')}</pre></div>
<div>New JS ({parserVersion}):{' '}
<div>New JS ({parserVersion?.version}):{' '}
<pre style={{ whiteSpace: 'pre-wrap', width: '100%' }}>
{card.parseErrors.length > 0 ? <span style={{ fontWeight: 'bold', color: 'red' }}>{card.parseErrors.join('\n')}</span> : card.newAbilities.join('\n')}
</pre>
</div>
{!isEqual(new Set(card.integrity.map(i => i.hmac)), new Set((card.oldIntegrity || []).map(i => i.hmac))) && (
<div>
<div>Old Integrity: <pre>{JSON.stringify(card.oldIntegrity, null, 2)}</pre></div>
<div>New Integrity: <pre>{JSON.stringify(card.integrity, null, 2)}</pre></div>
</div>
)}
</div>
</div>
))}
Expand Down Expand Up @@ -157,25 +165,33 @@ class CardMigrationPanel extends React.PureComponent<CardMigrationPanelProps> {
const objectCards = cards.filter((c) => c.type !== TYPE_EVENT);
const eventCards = cards.filter((c) => c.type === TYPE_EVENT);

const objectCardSentences = compact(objectCards.flatMap(c => c.text ? getSentencesFromInput(c.text) : []).map((s) => expandKeywords(s)));
const eventCardSentences = compact(eventCards.flatMap(c => c.text ? getSentencesFromInput(c.text) : []).map((s) => expandKeywords(s)));
const objectCardSentences = compact(objectCards.flatMap(getCardSentences));
const eventCardSentences = compact(eventCards.flatMap(getCardSentences));

const objectCardParseResults = await parseBatch(objectCardSentences, 'object');
const eventCardParseResults = await parseBatch(eventCardSentences, 'event');
const parseResults = [...objectCardParseResults, ...eventCardParseResults];

const errors = compact(parseResults.map((r) => r.result.error));
const errors = compact(parseResults.map((r) => (r.result as w.FailedParseResult).error));
const integrityHashes: w.Hashes[] = compact(parseResults.flatMap((r) => g.isSuccessfulParseResult(r.result) ? r.result.hashes : []));
const parseResultForSentence = Object.fromEntries(parseResults.map((({ sentence, result }) => [sentence, result])));

const changedCards = (
cards
.filter((c) => !c.id.startsWith('builtin/'))
.map((c) => ({
...c,
parseErrors: compact(getSentencesFromInput(c.text || '').map((s) => expandKeywords(s)).map((s) => parseResultForSentence[s]?.error)),
oldAbilities: (c.command ? [c.command].flat() : c.abilities || []),
newAbilities: compact(getSentencesFromInput(c.text || '').map((s) => expandKeywords(s)).map((s) => parseResultForSentence[s]?.js))
parseErrors: compact(getCardSentences(c).map(s => parseResultForSentence[s]).map(r => g.isFailedParseResult(r) ? r.error : undefined)),
oldAbilities: getCardAbilities(c),
newAbilities: compact(getCardSentences(c).map(s => parseResultForSentence[s]).map(r => g.isSuccessfulParseResult(r) ? r.js : undefined)),
integrity: integrityHashes.filter((hash) => getCardSentences(c).map(md5).includes(hash.input)),
oldIntegrity: c.integrity
}))
.filter((c) => c.oldAbilities.join('\n') !== c.newAbilities.join('\n') || c.parseErrors.length > 0)
.filter((c) =>
c.oldAbilities.join('\n') !== c.newAbilities.join('\n')
|| !isEqual(new Set(c.integrity.map(i => i.hmac)), new Set((c.oldIntegrity || []).map(i => i.hmac)))
|| c.parseErrors.length > 0
)
);

this.setState({
Expand Down Expand Up @@ -218,7 +234,7 @@ class CardMigrationPanel extends React.PureComponent<CardMigrationPanelProps> {
if (card.parseErrors.length === 0) {
const originalParserV = card.parserV || 'unknown';
const migratedCard: w.CardInStore = {
...omit(card, ['parseErrors', 'oldAbilities', 'newAbilities']),
...omit(card, ['parseErrors', 'oldAbilities', 'newAbilities', 'oldIntegrity']) as w.CardInStore,
...(card.type === TYPE_EVENT ? { command: card.newAbilities } : { abilities: card.newAbilities }),
parserV: parserVersion!.version,
originalParserV,
Expand Down
51 changes: 47 additions & 4 deletions src/common/components/admin/MiscUtilitiesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@ import { uniqBy } from 'lodash';
import * as React from 'react';

import * as w from '../../types';
import { checkValidityOfIntegrityHashes, getCardAbilities, validateIntegrityHashesAreComplete } from '../../util/cards';
import * as firebase from '../../util/firebase';
import { collection as coreSet } from '../../store/cards';

interface MiscUtilitiesPanelProps {
cards: w.CardInStore[]
sets: w.Set[]
games: Record<string, w.SavedGame>
reloadData: () => void
}

interface MiscUtilitiesPanelState {
Expand All @@ -20,18 +25,24 @@ class MiscUtilitiesPanel extends React.Component<MiscUtilitiesPanelProps, MiscUt
}

public render(): JSX.Element {
const { reloadData } = this.props;
const { log } = this.state;

return (
<div>
<Toolbar
disableGutters
style={{
padding: 0,
gap: 20
}}
style={{ padding: 0, gap: 20 }}
>
<Button variant="contained" onClick={this.clearLog}>Clear Log</Button>
<Button variant="contained" onClick={reloadData}>Reload Data</Button>
</Toolbar >
<Toolbar
disableGutters
style={{ padding: 0, gap: 20 }}
>
<Button variant="contained" onClick={this.cleanupGames}>Deduplicate Games</Button>
<Button variant="contained" onClick={this.detectAllCardIntegrityProblems}>Detect Card Integrity Problems</Button>
</Toolbar >
<pre>
{log.map((line, i) => <div key={i}>{line}</div>)}
Expand Down Expand Up @@ -71,6 +82,38 @@ class MiscUtilitiesPanel extends React.Component<MiscUtilitiesPanelProps, MiscUt

firebase.removeGames(duplicateGameFbIds);
}

private detectAllCardIntegrityProblems = async (): Promise<void> => {
const { cards, sets } = this.props;
this.log('ALL CARDS');
await this.detectIntegrityProblemsForCards(cards);

this.log('~~~');
this.log('BUILT-IN CARDS');
await this.detectIntegrityProblemsForCards(coreSet);

for (const set of sets) {
this.log('~~~');
this.log(`SET: ${set.name} (${set.id}) by ${set.metadata.authorName} (excluding ${set.cards.filter((c) => c.id.startsWith('builtin/')).length} builtins)`);
await this.detectIntegrityProblemsForCards(set.cards.filter((c) => !c.id.startsWith('builtin/')));
}
}

private detectIntegrityProblemsForCards = async (cards: w.CardInStore[]): Promise<void> => {
this.log(`Total # cards: ${cards.length}`);
const numCardsWithNoIntegrity = cards.filter((c) => !c.integrity?.length).length;
const numCardsWithNoAbilities = cards.filter((c) => !c.integrity?.length && getCardAbilities(c).length === 0).length;
this.log(`Cards without integrity hashes at all: ${numCardsWithNoIntegrity} (of which ${numCardsWithNoAbilities} have no abilities)`);

const cardsWithIncompleteIntegrityHashes: w.CardInStore[] = cards.filter((c) => !validateIntegrityHashesAreComplete(c, this.log));
this.log(`Cards with missing/incomplete integrity hashes: ${cardsWithIncompleteIntegrityHashes.length}: ${cardsWithIncompleteIntegrityHashes.map((c) => c.id).join(', ')}`);

const cardsWithHashesToVerify: w.CardInStore[] = cards.filter((card) => !cardsWithIncompleteIntegrityHashes.map((c) => c.id).includes(card.id));
const { validCards, invalidCards, statistics: { numValidHashes, numInvalidHashes } } = await checkValidityOfIntegrityHashes(cardsWithHashesToVerify);
this.log(`${numValidHashes}/${numValidHashes + numInvalidHashes} hashes are valid`);
this.log(`Cards with invalid hashes: ${invalidCards.length}`);
this.log(`${validCards.length !== cards.length ? '* ' : ''}Valid cards: ${validCards.length}`);
}
}

export default MiscUtilitiesPanel;
3 changes: 2 additions & 1 deletion src/common/components/card/Sentence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { flatMap, times } from 'lodash';
import * as React from 'react';

import * as w from '../../types';
import * as g from '../../guards';
import { keywordsInSentence, splitSentences } from '../../util/cards';
import { id } from '../../util/common';

Expand Down Expand Up @@ -37,7 +38,7 @@ export default class Sentence extends React.Component<SentenceProps> {
<span
key={id()}
style={{
color: color || (result?.js ? 'green' : (result?.error ? 'red' : 'black'))
color: color || ((result && g.isSuccessfulParseResult(result)) ? 'green' : (result && g.isFailedParseResult(result)) ? 'red' : 'black')
}}
>
{times(numInitialNewlines, (i) => <br key={i} />)}
Expand Down
6 changes: 3 additions & 3 deletions src/common/components/card/StatusIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,12 @@ import Icon from '@material-ui/core/Icon';
import * as React from 'react';

import { PARSER_URL } from '../../constants';
import * as w from '../../types';
import { expandKeywords } from '../../util/cards';
import Tooltip from '../Tooltip';

interface StatusIconProps {
text: string
result: w.ParseResult | null
result: { js?: string, parsed?: boolean, error?: string } | null
}

export default class StatusIcon extends React.Component<StatusIconProps> {
Expand All @@ -27,7 +26,8 @@ export default class StatusIcon extends React.Component<StatusIconProps> {
style={{
fontSize: '0.7em',
verticalAlign: 'top',
color: isParsed ? 'green' : (result.error ? 'red' : 'black')}
color: isParsed ? 'green' : (result.error ? 'red' : 'black')
}
}
>
{isParsed ? 'code' : (result.error ? 'error_outline' : 'more_horiz')}
Expand Down
2 changes: 1 addition & 1 deletion src/common/components/card/Word.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export default class Word extends React.PureComponent<WordProps> {
public render(): JSX.Element {
const { word, keywords, result } = this.props;

if (((result?.unrecognizedTokens) || []).includes(word.toLowerCase())) {
if ((((result as w.FailedParseResult | null)?.unrecognizedTokens) || []).includes(word.toLowerCase())) {
return (
<span key={id()}>
{' '}<u>{word}</u>
Expand Down
4 changes: 2 additions & 2 deletions src/common/components/cards/CardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,10 @@ export default class CardPreview extends React.Component<CardPreviewProps> {
stats={this.stats}
cardStats={this.stats}
flavorText={this.props.flavorText}
text={this.props.sentences.map((s, i) => <Sentence key={i} text={s.sentence} result={s.result} />)}
text={this.props.sentences.map((s, i) => <Sentence key={i} text={s.sentence} result={s.result as w.ParseResult | null} />)}
rawText={this.props.sentences.map((s) => s.sentence).join('. ')}
source={{ type: 'user' }}
showSpinner={sentences.some((s) => !s.result.js && !s.result.error)}
showSpinner={sentences.some((s) => s === null)}
scale={2.5}
onSpriteClick={onSpriteClick}
overrideContainerStyles={{ padding: 0 }}
Expand Down
24 changes: 12 additions & 12 deletions src/common/components/cards/CardTextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export default class CardTextField extends React.Component<CardTextFieldProps, C

return flow(
flatMap((s: w.Sentence) =>
(s.result.suggestions || []).map((suggestion) =>
((s.result as w.FailedParseResult).suggestions || []).map((suggestion) =>
({
original: s.sentence.trim(),
suggestion: contractKeywords(suggestion)
Expand All @@ -65,16 +65,16 @@ export default class CardTextField extends React.Component<CardTextFieldProps, C

return (
<div>
<div style={{display: 'flex', justifyContent: 'space-between'}}>
<div style={{width: '100%'}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div style={{ width: '100%' }}>
<TextField
multiline
variant="outlined"
className={`card-creator-text-field ${error && 'error'}`}
disabled={this.props.readonly}
value={this.state.currentText}
label="Card Text"
style={{width: '100%'}}
style={{ width: '100%' }}
rows={2}
onChange={this.handleUpdateText}
/>
Expand All @@ -101,15 +101,15 @@ export default class CardTextField extends React.Component<CardTextFieldProps, C
private renderDidYouMean = () => {
if (this.textSuggestions.length > 0) {
return (
<div style={{marginTop: 5}}>
<div style={{ marginTop: 5 }}>
Did you mean: {this.textSuggestions.map(({ original, suggestion }) => (
<CardTextSuggestion
key={original}
original={original}
suggestion={suggestion}
onChooseSuggestion={this.handleChooseSuggestion}
/>
))} ?
<CardTextSuggestion
key={original}
original={original}
suggestion={suggestion}
onChooseSuggestion={this.handleChooseSuggestion}
/>
))} ?
</div>
);
}
Expand Down
Loading

0 comments on commit 1b976d9

Please sign in to comment.